feat(web,mobile): dock expandable live task pill above composer and fold plan steps

This commit is contained in:
cynarAI 2026-10-22 14:11:19 +02:00
parent 78827e0a5b
commit 95061c648f
7 changed files with 1118 additions and 13 deletions

View File

@ -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<string | null>(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<MessageBlock, { kind: "plan" }> | 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<string>();
const stepIds = new Set<string>();
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 ? <RunSteps steps={foldedSteps} /> : null}
{foldedSteps && !activeMobilePlan?.absorbedIds.has(message.id) ? (
<RunSteps steps={foldedSteps} />
) : null}
<Pressable accessible={false} onLongPress={actionProps.onLongPress}>
<MessageBubble
botId={botId ?? snap?.members?.[0]?.botId ?? ""}
@ -2175,6 +2266,191 @@ function Thread() {
))}
</View>
) : null}
{activeMobilePlan &&
!(activeMobilePlan.isCompleted && dismissedPlanKey === activeMobilePlan.completionKey) ? (
<View
testID="composer-task-pill"
style={{
marginTop: 12,
borderRadius: 20,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 14,
paddingTop: 8,
paddingBottom: 10,
gap: 8,
}}
>
<Pressable
testID="composer-task-pill-toggle"
accessibilityRole="button"
accessibilityState={{ expanded: taskPillExpanded }}
onPress={() => setTaskPillExpanded((current) => !current)}
style={{ gap: 6 }}
>
<View
style={{
width: 32,
height: 4,
borderRadius: 2,
backgroundColor: tokens.border,
alignSelf: "center",
}}
/>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
<Text
style={{
color: activeMobilePlan.isCompleted ? tokens.success : tokens.foreground,
fontSize: 14,
fontWeight: "700",
}}
>
{activeMobilePlan.isCompleted ? "✓" : "▸"}
</Text>
<Text
numberOfLines={1}
style={{
color: tokens.foreground,
fontSize: 13.5,
fontWeight: "600",
flex: 1,
}}
>
{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)}
</Text>
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
<Text
style={{
color: activeMobilePlan.isCompleted ? tokens.success : tokens.mutedForeground,
fontSize: 12.5,
fontWeight: "500",
}}
>
{activeMobilePlan.isCompleted
? t("Completed")
: `${activeMobilePlan.doneCount}/${activeMobilePlan.block.steps.length}`}
</Text>
<NativeSymbol
ios={taskPillExpanded ? "chevron.down" : "chevron.up"}
android={taskPillExpanded ? "chevron-down" : "chevron-up"}
size={14}
color={tokens.mutedForeground}
/>
</View>
</View>
</Pressable>
{taskPillExpanded ? (
<View
style={{
borderTopWidth: 1,
borderTopColor: tokens.border,
paddingTop: 10,
gap: 6,
}}
>
<Text
style={{
color: tokens.cardForeground,
fontSize: 14,
fontWeight: "600",
}}
>
{activeMobilePlan.block.title}
</Text>
{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 (
<View
key={step.id}
style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}
>
<Text
style={{
color: done
? tokens.success
: active
? tokens.foreground
: tokens.mutedForeground,
fontSize: 13.5,
fontWeight: active ? "700" : "400",
width: 16,
}}
>
{glyph}
</Text>
<View style={{ flex: 1 }}>
<Text
style={{
color: done || skipped ? tokens.mutedForeground : tokens.foreground,
fontSize: 13.5,
fontWeight: active ? "600" : "400",
textDecorationLine: done || skipped ? "line-through" : "none",
}}
>
{step.label}
</Text>
{step.note ? (
<Text style={{ color: tokens.mutedForeground, fontSize: 12 }}>
{step.note}
</Text>
) : null}
</View>
</View>
);
})}
{activeMobilePlan.steps.length > 0 ? (
<View
style={{
borderTopWidth: 1,
borderTopColor: tokens.border,
paddingTop: 6,
marginTop: 2,
gap: 4,
}}
>
<Pressable
accessibilityRole="button"
onPress={() => setTaskPillSubStepsOpen((current) => !current)}
>
<Text style={{ color: tokens.mutedForeground, fontSize: 12.5 }}>
{t("{count} steps", { count: activeMobilePlan.steps.length })}{" "}
{taskPillSubStepsOpen ? "▴" : "▾"}
</Text>
</Pressable>
{taskPillSubStepsOpen
? activeMobilePlan.steps.map((s) => (
<Text
key={s.id}
style={{ color: tokens.mutedForeground, fontSize: 12.5 }}
>
{blockText(s)}
</Text>
))
: null}
</View>
) : null}
</View>
) : null}
</View>
) : null}
<View
style={{
flexDirection: "row",

View File

@ -193,6 +193,7 @@ import {
import { DualSurfaceHeaderControls } from "./shell/dual-surface-header-controls";
import { FullscreenComputerOverlay } from "./shell/fullscreen-computer-overlay";
import { MainConversationRow } from "./shell/main-conversation-row";
import { extractActivePlanState } from "./shell/message-cards";
import { PointerMenu } from "./shell/pointer-menu";
import { cleanRosterPreview, formatRosterTime } from "./shell/roster-format";
import { rosterRowClass } from "./shell/roster-row-style";
@ -1924,9 +1925,9 @@ export function ShellPage() {
rememberSeenRunErrorId(runId);
}, []);
const groupComputerBotId = inGroup
? (activeSnapshot?.leadBotId && bots.some((bot) => 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");

View File

@ -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(
<I18nProvider i18n={i18n}>
<ComposerTaskPill activePlan={state} />
</I18nProvider>,
);
});
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(
<I18nProvider i18n={i18n}>
<ComposerTaskPill activePlan={completedPlan} />
</I18nProvider>,
);
});
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(
<I18nProvider i18n={i18n}>
<PlanCard block={state.block} steps={state.steps} />
</I18nProvider>,
);
});
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();
});
});

View File

@ -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<void>;
activePlan?: ComposerActivePlan | null;
}) {
const { t } = useLingui();
const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]);
@ -643,6 +647,7 @@ export const Composer = memo(function Composer({
})}
</div>
) : null}
{activePlan ? <ComposerTaskPill activePlan={activePlan} /> : null}
<div
data-testid="composer-bar"
className="apple-island relative flex items-center gap-3.5 rounded-[30px] py-3 pe-3.5 ps-4 transition-all duration-200"

View File

@ -1,7 +1,14 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { MessageBlock } from "@rakazo/contracts";
import { Plural, Trans, useLingui } from "@lingui/react/macro";
import { ChatMarkdown } from "@rakazo/chat-ui/web";
import type { MessageBlock, ThreadMessage } from "@rakazo/contracts";
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
import { abortableDelay, extractExternalUrls, formatAssetDisplayName } from "@rakazo/core";
import {
abortableDelay,
coalesceRunAttachmentMessages,
extractExternalUrls,
foldRunNarration,
formatAssetDisplayName,
} from "@rakazo/core";
import { darkTokens, lightTokens } from "@rakazo/ui-tokens";
import {
Button,
@ -21,8 +28,10 @@ import {
ArrowUpRight,
Check,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Circle,
Download,
Globe,
@ -36,7 +45,7 @@ import {
ZoomIn,
ZoomOut,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { BuiCard, SuccessPop } from "../../components/ai/primitives";
import { artifactUrl } from "../../lib/artifact-url";
import { chartViewport } from "../../lib/chart-viewport";
@ -44,6 +53,7 @@ import { connectMcpOauth } from "../../lib/mcp-connect";
import { chosenOption, localizedChoice } from "../../lib/onboarding-copy";
import { rpc } from "../../lib/rpc";
import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push";
import { detectStepMeta } from "./run-steps";
type ChoiceOption = Extract<MessageBlock, { kind: "choice" }>["options"][number];
@ -1018,7 +1028,150 @@ export function ExternalLinksBar({ text }: { text: string }) {
);
}
export function PlanCard({ block }: { block: Extract<MessageBlock, { kind: "plan" }> }) {
export type PlanBlock = Extract<MessageBlock, { kind: "plan" }>;
export interface ComposerActivePlan {
block: PlanBlock;
steps: readonly ThreadMessage[];
running: boolean;
startedAt?: number;
}
function summarizePlanSteps(steps: readonly ThreadMessage[]) {
const seen = new Set<string>();
const badges: { label: string; icon: React.ReactNode; count: number; type: string }[] = [];
const counts = new Map<string, number>();
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 (
<div data-testid={testId} className="mt-2 border-t border-border/60 pt-2">
<button
type="button"
aria-expanded={open}
onClick={(event) => {
event.stopPropagation();
setOpen((current) => !current);
}}
className="flex w-full items-center justify-between gap-2 text-start text-ui-label font-medium text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<span className="flex flex-wrap items-center gap-1.5">
{badges.map((badge) => (
<span
key={badge.type}
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-muted/60 px-1.5 py-0.5 font-mono text-xs text-muted-foreground"
>
{badge.icon}
<span>{badge.label}</span>
{badge.count > 1 ? <span className="opacity-75">({badge.count})</span> : null}
</span>
))}
</span>
<span className="inline-flex shrink-0 items-center gap-1">
<Plural value={stepCount} one="# step" other="# steps" />
<ChevronDown
aria-hidden="true"
className={cn("size-3.5 transition-transform duration-200", open && "rotate-180")}
/>
</span>
</button>
{open ? (
<ol className="mt-2 space-y-2 border-t border-border/50 pt-2">
{steps.map((step, index) => {
const meta = detectStepMeta(step);
return (
<li
key={step.id}
data-message-id={step.id}
dir="auto"
className="flex items-start gap-2 text-ui-body-sm"
>
<span className="inline-flex size-5 shrink-0 items-center justify-center rounded-md border border-border/50 bg-muted font-mono text-xs font-semibold text-muted-foreground">
{String(index + 1).padStart(2, "0")}
</span>
<span className="mt-0.5 shrink-0 text-muted-foreground" title={meta.label}>
{meta.icon}
</span>
<div className="min-w-0 flex-1 space-y-1 text-foreground/90">
{step.blocks.map((b, idx) =>
b.kind === "text" ? (
<div key={idx} className="leading-snug">
<ChatMarkdown>{b.text}</ChatMarkdown>
</div>
) : null,
)}
</div>
</li>
);
})}
</ol>
) : null}
</div>
);
}
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<MessageBlock, { kind: "plan
).length;
const isCompleted = block.status === "completed";
const isAwaiting = block.status === "awaiting_approval";
const activeStepId =
block.steps.find((s) => s.status === "in_progress")?.id ??
block.steps.find((s) => s.status === "pending")?.id ??
block.steps.at(-1)?.id;
return (
<div
@ -1055,12 +1212,18 @@ export function PlanCard({ block }: { block: Extract<MessageBlock, { kind: "plan
const done = step.status === "completed";
const active = step.status === "in_progress";
const skipped = step.status === "skipped";
const attachSteps = steps.length > 0 && step.id === activeStepId;
return (
<li
key={step.id}
data-testid={`plan-step-${step.id}`}
data-step-status={step.status}
className="flex items-start gap-2.5 text-ui-body-sm"
className={cn(
"flex items-start gap-2.5 text-ui-body-sm",
active &&
attachSteps &&
"rounded-xl border border-border/70 bg-background/60 p-2.5",
)}
>
<span className="mt-0.5 shrink-0">
{done ? (
@ -1092,6 +1255,7 @@ export function PlanCard({ block }: { block: Extract<MessageBlock, { kind: "plan
{step.note}
</div>
) : null}
{attachSteps ? <PlanIntermediateSteps steps={steps} /> : null}
</div>
</li>
);
@ -1100,3 +1264,369 @@ export function PlanCard({ block }: { block: Extract<MessageBlock, { kind: "plan
</div>
);
}
/**
* 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<string>();
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<string | null>(null);
const dragStartYRef = useRef<number | null>(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 (
<div
data-testid="composer-task-pill"
data-plan-status={isCompleted ? "completed" : block.status}
data-expanded={expanded ? "true" : "false"}
className="mb-2.5 rounded-[22px] border border-border bg-card/95 px-3.5 pt-2 pb-2.5 shadow-md backdrop-blur-md transition-all duration-200"
>
<button
type="button"
data-testid="composer-task-pill-toggle"
aria-expanded={expanded}
aria-controls={drawerId}
onPointerDown={(event) => {
dragStartYRef.current = event.clientY;
didSwipeRef.current = false;
}}
onPointerMove={(event) => {
if (dragStartYRef.current === null) return;
const deltaY = event.clientY - dragStartYRef.current;
if (deltaY < -18 && !expanded) {
didSwipeRef.current = true;
dragStartYRef.current = null;
setExpanded(true);
} else if (deltaY > 18 && expanded) {
didSwipeRef.current = true;
dragStartYRef.current = null;
setExpanded(false);
}
}}
onPointerUp={() => {
dragStartYRef.current = null;
}}
onClick={() => {
if (didSwipeRef.current) {
didSwipeRef.current = false;
return;
}
setExpanded((current) => !current);
}}
className="w-full cursor-pointer text-start outline-none"
>
<div
aria-hidden="true"
className="mx-auto mb-1.5 h-1 w-8 rounded-full bg-border transition-colors"
/>
<div className="flex items-center justify-between gap-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="shrink-0">
{isCompleted ? (
<CheckCircle2 className="size-4 text-success" aria-hidden="true" />
) : activeStep?.status === "in_progress" || running ? (
<Loader2 className="size-4 animate-spin text-warning" aria-hidden="true" />
) : (
<Circle className="size-4 text-muted-foreground" aria-hidden="true" />
)}
</span>
<div className="min-w-0 flex-1">
<div
data-testid="composer-task-pill-label"
className="truncate text-ui-body-sm font-semibold text-foreground"
dir="auto"
>
{headline}
</div>
{showSubline ? (
<div className="mt-0.5 flex items-center gap-1.5 truncate text-ui-label text-muted-foreground">
{liveTimer ? (
<span className="shrink-0 font-mono text-warning tabular-nums">
{liveTimer}
</span>
) : null}
{badges.slice(0, 2).map((badge) => (
<span
key={badge.type}
className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-muted-foreground"
>
<span>·</span>
{badge.icon}
<span>
{badge.label}
{badge.count > 1 ? ` (${badge.count})` : ""}
</span>
</span>
))}
{sublineText ? (
<>
{liveTimer || badges.length > 0 ? <span>·</span> : null}
<span className="truncate" dir="auto">
{sublineText}
</span>
</>
) : null}
</div>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span
data-testid="composer-task-pill-progress"
className={cn(
"shrink-0 rounded-full border px-2 py-0.5 font-mono text-ui-label font-medium tabular-nums",
isCompleted
? "border-success/30 bg-success/10 text-success"
: isAwaiting
? "border-warning/30 bg-warning/10 text-warning"
: "border-border bg-background text-foreground",
)}
>
{isCompleted ? t`Done` : `${doneCount}/${total}`}
</span>
<ChevronUp
aria-hidden="true"
className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
expanded && "rotate-180",
)}
/>
</div>
</div>
</button>
<div
id={drawerId}
hidden={drawerHidden && !expanded}
onTransitionEnd={(event) => {
if (event.propertyName === "block-size" && !expanded) {
setDrawerHidden(true);
}
}}
className={cn("task-pill-drawer", expanded && "is-expanded")}
>
<div className="mt-2.5 space-y-2 border-t border-border pt-3">
<div className="flex items-center justify-between gap-2">
<span className="text-ui-body-sm font-semibold text-foreground" dir="auto">
{block.title}
</span>
{liveTimer && !isCompleted ? (
<span className="font-mono text-ui-label text-warning tabular-nums">
{t`In Bearbeitung`} · {liveTimer}
</span>
) : null}
</div>
<ol className="max-h-60 space-y-2 overflow-y-auto pe-1">
{block.steps.map((step) => {
const done = step.status === "completed";
const active = step.status === "in_progress";
const skipped = step.status === "skipped";
const isCurrent = step.id === activeStep?.id;
return (
<li
key={step.id}
data-testid={`composer-plan-step-${step.id}`}
data-step-status={step.status}
className={cn(
"flex items-start gap-2.5 text-ui-body-sm",
isCurrent &&
!isCompleted &&
"rounded-xl border border-border bg-background/75 p-2.5",
)}
>
<span className="mt-0.5 shrink-0">
{done ? (
<CheckCircle2 className="size-4 text-success" aria-hidden="true" />
) : active ? (
<Loader2 className="size-4 animate-spin text-warning" aria-hidden="true" />
) : skipped ? (
<MinusCircle className="size-4 text-muted-foreground" aria-hidden="true" />
) : (
<Circle className="size-4 text-muted-foreground/60" aria-hidden="true" />
)}
</span>
<div className="min-w-0 flex-1">
<div
className={cn(
"leading-snug",
done || skipped
? "text-muted-foreground line-through"
: active || isCurrent
? "font-semibold text-foreground"
: "text-foreground/85",
)}
dir="auto"
>
{step.label}
</div>
{step.note ? (
<div className="mt-0.5 text-ui-label text-muted-foreground" dir="auto">
{step.note}
</div>
) : null}
{isCurrent && steps.length > 0 ? (
<PlanIntermediateSteps
steps={steps}
testId="composer-plan-intermediate-steps"
/>
) : null}
</div>
</li>
);
})}
</ol>
</div>
</div>
</div>
);
}

View File

@ -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<string>();
const stepsByPlanMsgId = new Map<string, ThreadMessage[]>();
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 ? (
<RunSteps
bot={
message.botId
@ -744,6 +783,7 @@ export const Transcript = memo(function Transcript({
onOpenArtifact={onOpenArtifact}
message={message}
foldedSteps={foldedSteps}
planSteps={planSteps}
runStartedAt={activeTurnStartAt}
celebrating={message.id === celebratingMessageId}
speakerJoined={joinedMessageIds.has(message.id)}
@ -1405,6 +1445,7 @@ const MessageView = memo(function MessageView({
runStartedAt,
message,
foldedSteps,
planSteps,
onAnswer,
onCardAct,
onOpenBot,
@ -1434,6 +1475,7 @@ const MessageView = memo(function MessageView({
runStartedAt?: string;
message: ThreadMessage;
foldedSteps?: readonly ThreadMessage[];
planSteps?: readonly ThreadMessage[];
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise<void>;
onOpenBot: (botId: string) => void;
@ -2129,7 +2171,7 @@ const MessageView = memo(function MessageView({
if (block.kind === "plan") {
return (
<div key={i} className="flex justify-start">
<PlanCard block={block} />
<PlanCard block={block} steps={planSteps} />
</div>
);
}

View File

@ -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;
}
}