feat(web,mobile): dock expandable live task pill above composer and fold plan steps
This commit is contained in:
parent
78827e0a5b
commit
95061c648f
@ -358,6 +358,9 @@ function Thread() {
|
|||||||
() => reactionView.visibleMessages.filter((message) => !runNarration.hidden.has(message.id)),
|
() => reactionView.visibleMessages.filter((message) => !runNarration.hidden.has(message.id)),
|
||||||
[reactionView.visibleMessages, runNarration],
|
[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 latestMessageId = visibleMessages.at(-1)?.id ?? null;
|
||||||
const activePendingAttachments = attachmentsForThread(pendingAttachments, threadKey);
|
const activePendingAttachments = attachmentsForThread(pendingAttachments, threadKey);
|
||||||
const composerMentionTargets = useMemo(
|
const composerMentionTargets = useMemo(
|
||||||
@ -436,6 +439,92 @@ function Thread() {
|
|||||||
});
|
});
|
||||||
}, [inGroup, snap?.activeRuns, snap?.members, snap?.run]);
|
}, [inGroup, snap?.activeRuns, snap?.members, snap?.run]);
|
||||||
const working = inGroup ? workingGroupBots.length > 0 : isWorkingStatus(currentBotStatus);
|
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 activeRunCreatedAt = useMemo(() => {
|
||||||
const candidates = snap?.activeRuns ?? (snap?.run ? [snap.run] : []);
|
const candidates = snap?.activeRuns ?? (snap?.run ? [snap.run] : []);
|
||||||
return candidates.find((run) => isWorkingStatus(run.status))?.createdAt;
|
return candidates.find((run) => isWorkingStatus(run.status))?.createdAt;
|
||||||
@ -1654,7 +1743,9 @@ function Thread() {
|
|||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{foldedSteps ? <RunSteps steps={foldedSteps} /> : null}
|
{foldedSteps && !activeMobilePlan?.absorbedIds.has(message.id) ? (
|
||||||
|
<RunSteps steps={foldedSteps} />
|
||||||
|
) : null}
|
||||||
<Pressable accessible={false} onLongPress={actionProps.onLongPress}>
|
<Pressable accessible={false} onLongPress={actionProps.onLongPress}>
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
botId={botId ?? snap?.members?.[0]?.botId ?? ""}
|
botId={botId ?? snap?.members?.[0]?.botId ?? ""}
|
||||||
@ -2175,6 +2266,191 @@ function Thread() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@ -193,6 +193,7 @@ import {
|
|||||||
import { DualSurfaceHeaderControls } from "./shell/dual-surface-header-controls";
|
import { DualSurfaceHeaderControls } from "./shell/dual-surface-header-controls";
|
||||||
import { FullscreenComputerOverlay } from "./shell/fullscreen-computer-overlay";
|
import { FullscreenComputerOverlay } from "./shell/fullscreen-computer-overlay";
|
||||||
import { MainConversationRow } from "./shell/main-conversation-row";
|
import { MainConversationRow } from "./shell/main-conversation-row";
|
||||||
|
import { extractActivePlanState } from "./shell/message-cards";
|
||||||
import { PointerMenu } from "./shell/pointer-menu";
|
import { PointerMenu } from "./shell/pointer-menu";
|
||||||
import { cleanRosterPreview, formatRosterTime } from "./shell/roster-format";
|
import { cleanRosterPreview, formatRosterTime } from "./shell/roster-format";
|
||||||
import { rosterRowClass } from "./shell/roster-row-style";
|
import { rosterRowClass } from "./shell/roster-row-style";
|
||||||
@ -1924,9 +1925,9 @@ export function ShellPage() {
|
|||||||
rememberSeenRunErrorId(runId);
|
rememberSeenRunErrorId(runId);
|
||||||
}, []);
|
}, []);
|
||||||
const groupComputerBotId = inGroup
|
const groupComputerBotId = inGroup
|
||||||
? (activeSnapshot?.leadBotId && bots.some((bot) => bot.id === activeSnapshot.leadBotId)
|
? activeSnapshot?.leadBotId && bots.some((bot) => bot.id === activeSnapshot.leadBotId)
|
||||||
? activeSnapshot.leadBotId
|
? activeSnapshot.leadBotId
|
||||||
: coordinatorBot?.id)
|
: coordinatorBot?.id
|
||||||
: undefined;
|
: undefined;
|
||||||
// A bot message without a sender speaks for the conversation's own bot, never as an anonymous one.
|
// 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;
|
const defaultSpeakerId = inGroup ? activeSnapshot?.leadBotId : active?.id;
|
||||||
@ -4368,6 +4369,11 @@ export function ShellPage() {
|
|||||||
bootstrapMe?.needsModel ? () => openSettings("models") : undefined
|
bootstrapMe?.needsModel ? () => openSettings("models") : undefined
|
||||||
}
|
}
|
||||||
onAutopilot={startAutopilot}
|
onAutopilot={startAutopilot}
|
||||||
|
activePlan={extractActivePlanState(
|
||||||
|
transcriptMessages,
|
||||||
|
transcriptRunning,
|
||||||
|
workingRuns[0]?.createdAt,
|
||||||
|
)}
|
||||||
onSlashAction={(action) => {
|
onSlashAction={(action) => {
|
||||||
if (action === "chat-settings") {
|
if (action === "chat-settings") {
|
||||||
setPanel(inGroup ? "group-settings" : "settings");
|
setPanel(inGroup ? "group-settings" : "settings");
|
||||||
|
|||||||
224
apps/web/src/pages/shell/composer-task-pill.test.tsx
Normal file
224
apps/web/src/pages/shell/composer-task-pill.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -40,6 +40,8 @@ import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } fr
|
|||||||
import { VoiceAuroraRim } from "../../components/voice/VoiceAurora";
|
import { VoiceAuroraRim } from "../../components/voice/VoiceAurora";
|
||||||
import { isFileDrag, isFilePaste } from "../../lib/pending-attachments";
|
import { isFileDrag, isFilePaste } from "../../lib/pending-attachments";
|
||||||
import { AutopilotDialog } from "./autopilot-dialog";
|
import { AutopilotDialog } from "./autopilot-dialog";
|
||||||
|
import type { ComposerActivePlan } from "./message-cards";
|
||||||
|
import { ComposerTaskPill } from "./message-cards";
|
||||||
import { previewMessageText } from "./message-preview";
|
import { previewMessageText } from "./message-preview";
|
||||||
import type { PendingAttachment } from "./shell-types";
|
import type { PendingAttachment } from "./shell-types";
|
||||||
import { FALLBACK_BOT_COLOR } from "./shell-types";
|
import { FALLBACK_BOT_COLOR } from "./shell-types";
|
||||||
@ -80,6 +82,7 @@ export const Composer = memo(function Composer({
|
|||||||
onSlashAction,
|
onSlashAction,
|
||||||
onConnectModel,
|
onConnectModel,
|
||||||
onAutopilot,
|
onAutopilot,
|
||||||
|
activePlan,
|
||||||
}: {
|
}: {
|
||||||
draftKey?: string;
|
draftKey?: string;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
@ -112,6 +115,7 @@ export const Composer = memo(function Composer({
|
|||||||
onConnectModel?: () => void;
|
onConnectModel?: () => void;
|
||||||
/** Hand the order to the autopilot; the composer menu offers it only when set. */
|
/** Hand the order to the autopilot; the composer menu offers it only when set. */
|
||||||
onAutopilot?: (goal: string, until?: string) => Promise<void>;
|
onAutopilot?: (goal: string, until?: string) => Promise<void>;
|
||||||
|
activePlan?: ComposerActivePlan | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]);
|
const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]);
|
||||||
@ -643,6 +647,7 @@ export const Composer = memo(function Composer({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{activePlan ? <ComposerTaskPill activePlan={activePlan} /> : null}
|
||||||
<div
|
<div
|
||||||
data-testid="composer-bar"
|
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"
|
className="apple-island relative flex items-center gap-3.5 rounded-[30px] py-3 pe-3.5 ps-4 transition-all duration-200"
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { MessageBlock } from "@rakazo/contracts";
|
import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
||||||
|
import type { MessageBlock, ThreadMessage } from "@rakazo/contracts";
|
||||||
import { CHOICE_TEXT_ANSWER_ID } 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 { darkTokens, lightTokens } from "@rakazo/ui-tokens";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@ -21,8 +28,10 @@ import {
|
|||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Check,
|
Check,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
Circle,
|
Circle,
|
||||||
Download,
|
Download,
|
||||||
Globe,
|
Globe,
|
||||||
@ -36,7 +45,7 @@ import {
|
|||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
} from "lucide-react";
|
} 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 { BuiCard, SuccessPop } from "../../components/ai/primitives";
|
||||||
import { artifactUrl } from "../../lib/artifact-url";
|
import { artifactUrl } from "../../lib/artifact-url";
|
||||||
import { chartViewport } from "../../lib/chart-viewport";
|
import { chartViewport } from "../../lib/chart-viewport";
|
||||||
@ -44,6 +53,7 @@ import { connectMcpOauth } from "../../lib/mcp-connect";
|
|||||||
import { chosenOption, localizedChoice } from "../../lib/onboarding-copy";
|
import { chosenOption, localizedChoice } from "../../lib/onboarding-copy";
|
||||||
import { rpc } from "../../lib/rpc";
|
import { rpc } from "../../lib/rpc";
|
||||||
import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push";
|
import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push";
|
||||||
|
import { detectStepMeta } from "./run-steps";
|
||||||
|
|
||||||
type ChoiceOption = Extract<MessageBlock, { kind: "choice" }>["options"][number];
|
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 { t } = useLingui();
|
||||||
const total = block.steps.length;
|
const total = block.steps.length;
|
||||||
const doneCount = block.steps.filter(
|
const doneCount = block.steps.filter(
|
||||||
@ -1026,6 +1179,10 @@ export function PlanCard({ block }: { block: Extract<MessageBlock, { kind: "plan
|
|||||||
).length;
|
).length;
|
||||||
const isCompleted = block.status === "completed";
|
const isCompleted = block.status === "completed";
|
||||||
const isAwaiting = block.status === "awaiting_approval";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -1055,12 +1212,18 @@ export function PlanCard({ block }: { block: Extract<MessageBlock, { kind: "plan
|
|||||||
const done = step.status === "completed";
|
const done = step.status === "completed";
|
||||||
const active = step.status === "in_progress";
|
const active = step.status === "in_progress";
|
||||||
const skipped = step.status === "skipped";
|
const skipped = step.status === "skipped";
|
||||||
|
const attachSteps = steps.length > 0 && step.id === activeStepId;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={step.id}
|
key={step.id}
|
||||||
data-testid={`plan-step-${step.id}`}
|
data-testid={`plan-step-${step.id}`}
|
||||||
data-step-status={step.status}
|
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">
|
<span className="mt-0.5 shrink-0">
|
||||||
{done ? (
|
{done ? (
|
||||||
@ -1092,6 +1255,7 @@ export function PlanCard({ block }: { block: Extract<MessageBlock, { kind: "plan
|
|||||||
{step.note}
|
{step.note}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{attachSteps ? <PlanIntermediateSteps steps={steps} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@ -1100,3 +1264,369 @@ export function PlanCard({ block }: { block: Extract<MessageBlock, { kind: "plan
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -260,6 +260,42 @@ export const Transcript = memo(function Transcript({
|
|||||||
[reactionView.visibleMessages, reactionView.reactions],
|
[reactionView.visibleMessages, reactionView.reactions],
|
||||||
);
|
);
|
||||||
const runNarration = useMemo(() => foldRunNarration(coalescedMessages), [coalescedMessages]);
|
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 trailingAnsweringBots = useMemo(() => {
|
||||||
const liveProgressMessages = coalescedMessages.filter(
|
const liveProgressMessages = coalescedMessages.filter(
|
||||||
(message) => !runNarration.hidden.has(message.id) && message.id.startsWith("progress:"),
|
(message) => !runNarration.hidden.has(message.id) && message.id.startsWith("progress:"),
|
||||||
@ -636,9 +672,12 @@ export const Transcript = memo(function Transcript({
|
|||||||
{coalescedMessages.map((message) => {
|
{coalescedMessages.map((message) => {
|
||||||
if (runNarration.hidden.has(message.id)) return null;
|
if (runNarration.hidden.has(message.id)) return null;
|
||||||
const foldedSteps = runNarration.steps.get(message.id);
|
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 (
|
if (
|
||||||
!message.id.startsWith("progress:") &&
|
!message.id.startsWith("progress:") &&
|
||||||
!foldedSteps?.length &&
|
!showRunSteps &&
|
||||||
!message.blocks.some((block) => !isToolActivityBlock(block))
|
!message.blocks.some((block) => !isToolActivityBlock(block))
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
@ -666,7 +705,7 @@ export const Transcript = memo(function Transcript({
|
|||||||
data-quotable={message.id.includes(":") ? undefined : ""}
|
data-quotable={message.id.includes(":") ? undefined : ""}
|
||||||
className={peerReceipt ? "relative py-0.5" : "group/message relative hover:z-20"}
|
className={peerReceipt ? "relative py-0.5" : "group/message relative hover:z-20"}
|
||||||
>
|
>
|
||||||
{foldedSteps ? (
|
{showRunSteps && foldedSteps ? (
|
||||||
<RunSteps
|
<RunSteps
|
||||||
bot={
|
bot={
|
||||||
message.botId
|
message.botId
|
||||||
@ -744,6 +783,7 @@ export const Transcript = memo(function Transcript({
|
|||||||
onOpenArtifact={onOpenArtifact}
|
onOpenArtifact={onOpenArtifact}
|
||||||
message={message}
|
message={message}
|
||||||
foldedSteps={foldedSteps}
|
foldedSteps={foldedSteps}
|
||||||
|
planSteps={planSteps}
|
||||||
runStartedAt={activeTurnStartAt}
|
runStartedAt={activeTurnStartAt}
|
||||||
celebrating={message.id === celebratingMessageId}
|
celebrating={message.id === celebratingMessageId}
|
||||||
speakerJoined={joinedMessageIds.has(message.id)}
|
speakerJoined={joinedMessageIds.has(message.id)}
|
||||||
@ -1405,6 +1445,7 @@ const MessageView = memo(function MessageView({
|
|||||||
runStartedAt,
|
runStartedAt,
|
||||||
message,
|
message,
|
||||||
foldedSteps,
|
foldedSteps,
|
||||||
|
planSteps,
|
||||||
onAnswer,
|
onAnswer,
|
||||||
onCardAct,
|
onCardAct,
|
||||||
onOpenBot,
|
onOpenBot,
|
||||||
@ -1434,6 +1475,7 @@ const MessageView = memo(function MessageView({
|
|||||||
runStartedAt?: string;
|
runStartedAt?: string;
|
||||||
message: ThreadMessage;
|
message: ThreadMessage;
|
||||||
foldedSteps?: readonly ThreadMessage[];
|
foldedSteps?: readonly ThreadMessage[];
|
||||||
|
planSteps?: readonly ThreadMessage[];
|
||||||
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
|
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
|
||||||
onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise<void>;
|
onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise<void>;
|
||||||
onOpenBot: (botId: string) => void;
|
onOpenBot: (botId: string) => void;
|
||||||
@ -2129,7 +2171,7 @@ const MessageView = memo(function MessageView({
|
|||||||
if (block.kind === "plan") {
|
if (block.kind === "plan") {
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex justify-start">
|
<div key={i} className="flex justify-start">
|
||||||
<PlanCard block={block} />
|
<PlanCard block={block} steps={planSteps} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -565,6 +565,24 @@ body,
|
|||||||
animation: ooHoleHopOut 0.76s cubic-bezier(0.22, 1, 0.36, 1) both;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.voice-bloom-emerald,
|
.voice-bloom-emerald,
|
||||||
.oo-hole-pit,
|
.oo-hole-pit,
|
||||||
@ -572,4 +590,8 @@ body,
|
|||||||
.oo-hole-jumper {
|
.oo-hole-jumper {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-pill-drawer {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user