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, 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",
|
||||
|
||||
@ -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");
|
||||
|
||||
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 { 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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user