Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions src/components/session/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ import {
import {
getReasoningTraceExpansionMode,
getMessageScrollFingerprint,
resolvePlanMessagePresentation,
shouldShowConversationLoadingState,
} from "@/components/session/chat-panel.utils";
import { ConversationPlanCard } from "@/components/session/ConversationPlanCard";
import { useScopedTaskId } from "@/components/session/task-scope-context";
import { getTurnModelInfoLabel } from "@/lib/providers/turn-model-info";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -165,6 +167,8 @@ interface MessageRowProps {
completedAt?: string;
parts: MessagePart[];
displayParts?: MessagePart[];
isPlanResponse?: boolean;
planText?: string;
isStreaming?: boolean;
steerDeliveryState?: ChatMessage["steerDeliveryState"];
providerBoundary?: ChatMessage["providerBoundary"];
Expand Down Expand Up @@ -217,6 +221,10 @@ const MessageRow = memo(function MessageRow(args: MessageRowProps) {
() => getMessageElapsedLabel({ message, nowMs: elapsedAnchorMs }),
[elapsedAnchorMs, message],
);
const planPresentation = useMemo(
() => resolvePlanMessagePresentation(message),
[message],
);
const userMessageSourceText = message.displayContent ?? message.content;
const turnModelInfoLabel = getTurnModelInfoLabel(message);
const steerDeliveryLabel =
Expand Down Expand Up @@ -319,14 +327,19 @@ const MessageRow = memo(function MessageRow(args: MessageRowProps) {
className={message.role === "assistant" ? "pb-1" : undefined}
onCopy={handleUserMessageCopy}
>
<MemoizedAssistantMessageBody
message={message}
taskId={taskId}
messageId={message.id}
streamingEnabled={chatStreamingEnabled}
traceExpansionMode={traceExpansionMode}
showInterimMessages={showInterimMessages}
/>
{planPresentation.showPlanCard ? (
<ConversationPlanCard planText={planPresentation.planText} />
) : null}
{planPresentation.showAssistantBody ? (
<MemoizedAssistantMessageBody
message={message}
taskId={taskId}
messageId={message.id}
streamingEnabled={chatStreamingEnabled}
traceExpansionMode={traceExpansionMode}
showInterimMessages={showInterimMessages}
/>
) : null}
</MessageContent>
{message.role === "user" && steerDeliveryLabel ? (
<span className="self-end px-1 text-[11px] text-muted-foreground">
Expand Down Expand Up @@ -653,10 +666,12 @@ function ChatPanelMessageList(props: {
const [turnCompletionScrollTick, setTurnCompletionScrollTick] = useState(0);
const previousActiveTurnIdRef = useRef<string | undefined>(activeTurnId);

const visibleMessages = useMemo(
() => messages.filter((message) => !message.isPlanResponse),
[messages],
);
// Plan responses stay in the transcript and render as a dedicated plan card
// (see `resolvePlanMessagePresentation`). They used to be filtered out here,
// which left the floating `PlanViewer` as their only renderer — so the plan
// vanished as soon as the task moved past plan review, and any follow-up
// content sharing the message was dropped with it.
const visibleMessages = messages;
const threadActionStateByMessageId = useMemo(
() =>
buildConversationTurnActionStateByMessageId({
Expand Down
26 changes: 26 additions & 0 deletions src/components/session/ConversationPlanCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ClipboardCheck } from "lucide-react";
import { MessageResponse } from "@/components/ai-elements";

/**
* Renders a plan response inline in the conversation.
*
* The floating `PlanViewer` only stays open while the task is under plan
* review, so the transcript needs its own copy — otherwise an approved or
* revised plan becomes unreadable the moment the conversation moves on.
*/
export function ConversationPlanCard(props: { planText: string }) {
return (
<div
data-plan-card="true"
className="overflow-hidden rounded-xl border border-border/80 bg-card"
>
<div className="flex items-center gap-2 border-b border-border/80 px-4 py-2.5">
<ClipboardCheck className="size-4 shrink-0 text-primary" />
<p className="text-sm font-medium">Plan</p>
</div>
<div className="px-4 py-3">
<MessageResponse>{props.planText}</MessageResponse>
</div>
</div>
);
}
47 changes: 47 additions & 0 deletions src/components/session/chat-panel.utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ChatMessage, CodeDiffPart, FileContextPart, ImageContextPart, MessagePart, ToolUsePart } from "@/types/chat";
import { detectTruncationNotice } from "@/lib/truncation-visibility";
import { hasMeaningfulPlanText, normalizePlanText } from "@/lib/plan-text";

export function isPendingDiffStatus(status: CodeDiffPart["status"]) {
return status === "pending";
Expand Down Expand Up @@ -560,3 +561,49 @@ export function getMessageBodyFallbackState(args: {

return "content";
}

export interface PlanMessagePresentation {
/** Normalized plan body to render in the transcript plan card. */
planText: string;
showPlanCard: boolean;
/** Whether the regular assistant trace should render alongside the card. */
showAssistantBody: boolean;
}

/**
* Plan responses render as a dedicated card in the transcript so the reviewed
* plan stays readable after the floating plan viewer closes.
*
* Older records folded the rest of the turn (follow-up text, tool calls,
* pending approvals) into the same message. Those parts are still rendered
* below the card so nothing stays hidden; freshly captured plan messages carry
* no parts, so they render as the card alone.
*/
export function resolvePlanMessagePresentation(
message: Pick<
ChatMessage,
"isPlanResponse" | "planText" | "content" | "parts" | "displayParts"
>,
): PlanMessagePresentation {
if (message.isPlanResponse !== true) {
return { planText: "", showPlanCard: false, showAssistantBody: true };
}

const planText = normalizePlanText(message.planText ?? message.content ?? "");
if (!hasMeaningfulPlanText(planText)) {
return { planText: "", showPlanCard: false, showAssistantBody: true };
}

// Deliberately scoped to `parts` — `getRenderableMessageParts` would fall
// back to `content`, which on a plan message is the plan text itself and
// would render underneath the card a second time.
const planParts = message.displayParts ?? message.parts;
const showAssistantBody =
planParts.length > 0
&& getMessageBodyFallbackState({
isActivelyStreaming: false,
renderableParts: planParts,
}) === "content";

return { planText, showPlanCard: true, showAssistantBody };
}
174 changes: 162 additions & 12 deletions src/lib/session/provider-event-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,18 +386,118 @@ function normalizeEventToPart(args: {
}
}

/**
* True when an event belongs to a message of its own because the current target
* is already a plan response.
*
* `plan_ready` is excluded on purpose: re-presenting an updated plan replaces
* the existing plan message rather than starting a new one. `provider_turn`
* only qualifies when it announces a *different* native turn — the plan's own
* turn still belongs to the plan row.
*/
function startsMessageAfterPlan(args: {
target: ChatMessage;
event: NormalizedProviderEvent;
}): boolean {
const { target, event } = args;
if (event.type === "plan_ready") {
return false;
}
if (event.type === "provider_turn") {
return (
target.nativeProviderTurnId != null &&
target.nativeProviderTurnId !== event.nativeTurnId
);
}
return normalizeEventToPart({ event }) !== null;
}

function providerBoundariesEqual(
left: ChatMessage["providerBoundary"],
right: ChatMessage["providerBoundary"],
): boolean {
return (
left?.providerId === right?.providerId &&
left?.kind === right?.kind &&
left?.nativeId === right?.nativeId
);
}

/**
* Copy the native turn identity of `from` onto `message`.
*
* Splitting one provider turn across several rows must not strand a row without
* that identity: `buildConversationTurnActionStateByMessageId` disables
* fork/rollback on any assistant row missing `nativeProviderTurnId` ("this
* response predates native turn tracking").
*/
function inheritNativeTurnIdentity(args: {
message: ChatMessage;
from: ChatMessage;
}): ChatMessage {
const { from } = args;
return {
...args.message,
...(from.nativeProviderSessionId
? { nativeProviderSessionId: from.nativeProviderSessionId }
: {}),
...(from.nativeProviderTurnId
? { nativeProviderTurnId: from.nativeProviderTurnId }
: {}),
...(from.providerBoundary
? { providerBoundary: from.providerBoundary }
: {}),
};
}

/**
* Seal the trailing plan row and open the assistant message that carries the
* rest of the turn. The new row inherits the plan's native turn identity; a
* later `provider_turn`/`history_boundary` for a genuinely new turn overwrites
* it in place.
*/
function openMessageAfterPlan(args: {
messages: ChatMessage[];
plan: ChatMessage;
taskId: string;
messageIndexOffset: number;
provider: ProviderId;
model: string;
}): { messages: ChatMessage[]; target: ChatMessage } {
const target = inheritNativeTurnIdentity({
message: createStreamingAssistantMessage({
taskId: args.taskId,
count: args.messages.length + args.messageIndexOffset,
provider: args.provider,
model: args.model,
...(args.plan.modelInfo ? { modelInfo: args.plan.modelInfo } : {}),
}),
from: args.plan,
});
return {
messages: [
...args.messages.slice(0, -1),
finalizeAssistantMessage({ message: args.plan }),
target,
],
target,
};
}

function createStreamingAssistantMessage(args: {
taskId: string;
count: number;
provider: ProviderId;
model: string;
modelInfo?: TurnModelInfo;
}): ChatMessage {
const startedAt = buildRecentTimestamp();
return {
id: buildMessageId({ taskId: args.taskId, count: args.count }),
role: "assistant",
model: args.model,
providerId: args.provider,
...(args.modelInfo ? { modelInfo: args.modelInfo } : {}),
content: "",
startedAt,
isStreaming: true,
Expand Down Expand Up @@ -962,18 +1062,43 @@ export function replayProviderEventsToTaskState(args: {
current = [...current, assistant];
targetIndex = current.length - 1;
}
const boundaryTarget = current[targetIndex];
let boundaryTarget = current[targetIndex];
if (boundaryTarget) {
const nextBoundary = {
providerId: event.providerId,
kind: event.boundaryKind,
nativeId: event.nativeId,
} as const;
// A boundary for a different native turn cannot belong to a sealed plan
// row — it belongs to the response that follows the plan. Claude emits
// this ahead of `provider_turn`, so the split has to start here too.
if (
boundaryTarget.providerBoundary?.providerId !==
nextBoundary.providerId ||
boundaryTarget.providerBoundary.kind !== nextBoundary.kind ||
boundaryTarget.providerBoundary.nativeId !== nextBoundary.nativeId
boundaryTarget.isPlanResponse === true &&
targetIndex === current.length - 1 &&
boundaryTarget.providerBoundary != null &&
!providerBoundariesEqual(
boundaryTarget.providerBoundary,
nextBoundary,
)
) {
const opened = openMessageAfterPlan({
messages: current,
plan: boundaryTarget,
taskId: args.taskId,
messageIndexOffset,
provider: args.provider,
model: args.model,
});
current = opened.messages;
targetIndex = current.length - 1;
boundaryTarget = opened.target;
changed = true;
}
if (
!providerBoundariesEqual(
boundaryTarget.providerBoundary,
nextBoundary,
)
) {
current = current.map((message, index) =>
index === targetIndex
Expand Down Expand Up @@ -1019,13 +1144,16 @@ export function replayProviderEventsToTaskState(args: {
const finalizedTarget = finalizeAssistantMessage({
message: cleanedTarget,
});
const planMessage = createPlanAssistantMessage({
taskId: args.taskId,
count: current.length + messageIndexOffset,
provider: args.provider,
model: args.model,
modelInfo: target.modelInfo,
planText: event.planText,
const planMessage = inheritNativeTurnIdentity({
message: createPlanAssistantMessage({
taskId: args.taskId,
count: current.length + messageIndexOffset,
provider: args.provider,
model: args.model,
modelInfo: target.modelInfo,
planText: event.planText,
}),
from: finalizedTarget,
});

current = [...current.slice(0, -1), finalizedTarget, planMessage];
Expand All @@ -1039,6 +1167,28 @@ export function replayProviderEventsToTaskState(args: {
current = [...current.slice(0, -1), cleanedTarget];
}

// A plan response renders as a dedicated plan card whose body is the plan
// text alone, so anything the agent produces afterwards has no place in it.
// Appending it here used to hide the rest of the turn — the "shall I
// proceed?" question, follow-up tool calls, even pending approvals — behind
// the card. Start a fresh assistant message instead.
if (
target.isPlanResponse === true &&
startsMessageAfterPlan({ target, event })
) {
const opened = openMessageAfterPlan({
messages: current,
plan: target,
taskId: args.taskId,
messageIndexOffset,
provider: args.provider,
model: args.model,
});
current = opened.messages;
target = opened.target;
changed = true;
}

const updated = appendProviderEventToAssistant({
message: target,
event,
Expand Down
Loading
Loading