Skip to content
Open
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
29 changes: 25 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,8 @@ import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
import { MessagesTimeline } from "./chat/MessagesTimeline";
import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
import { MessagesTimeline, TurnPlanTimelineRow } from "./chat/MessagesTimeline";
import { excludePinnedTurnPlan, resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
import { ChatHeader } from "./chat/ChatHeader";
import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
Expand Down Expand Up @@ -2239,6 +2239,22 @@ function ChatViewContent(props: ChatViewProps) {
threadError,
});
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
const pinnedActiveTurnPlan = useMemo(() => {
const activeTurnId = activeLatestTurn?.turnId ?? null;
if (
phase !== "running" ||
activeLatestTurn?.state !== "running" ||
activeTurnId === null ||
activePlan?.turnId !== activeTurnId
) {
return null;
}
return turnPlans.find((turnPlan) => turnPlan.turnId === activeTurnId) ?? null;
}, [activeLatestTurn?.state, activeLatestTurn?.turnId, activePlan, phase, turnPlans]);
const turnPlansForTimeline = useMemo(
() => excludePinnedTurnPlan(turnPlans, pinnedActiveTurnPlan?.turnId ?? null),
[pinnedActiveTurnPlan?.turnId, turnPlans],
);
const activeWorkStartedAt = deriveActiveWorkStartedAt(
activeLatestTurn,
activeThread?.session ?? null,
Expand Down Expand Up @@ -2490,9 +2506,9 @@ function ChatViewContent(props: ChatViewProps) {
timelineMessages,
activeThread?.proposedPlans ?? [],
workLogEntries,
turnPlans,
turnPlansForTimeline,
),
[activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries],
[activeThread?.proposedPlans, timelineMessages, turnPlansForTimeline, workLogEntries],
);
const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
const draftHeroDockRequested =
Expand Down Expand Up @@ -6185,6 +6201,11 @@ function ChatViewContent(props: ChatViewProps) {
{threadSyncPhase && !activeEnvironmentUnavailable ? (
<ThreadSyncStatusPill phase={threadSyncPhase} />
) : null}
{pinnedActiveTurnPlan ? (
<div data-active-plan-bar="true" className="mx-auto mb-1.5 w-full max-w-3xl">
<TurnPlanTimelineRow turnPlan={pinnedActiveTurnPlan} />
</div>
) : null}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pinned plan reuses expand state

Low Severity

The pinned TurnPlanTimelineRow has no React key tied to the plan or turn identity. expanded is local component state, so when the pinned plan switches to a different turn or thread without unmounting, the prior expand/collapse state is reused on the new plan.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 53ce6b4. Configure here.

<div
className="relative"
style={
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,46 @@
import { TurnId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import {
computeStableMessagesTimelineRows,
computeMessageDurationStart,
deriveMessagesTimelineRows,
excludePinnedTurnPlan,
normalizeCompactToolLabel,
resolveAssistantMessageCopyState,
} from "./MessagesTimeline.logic";

describe("excludePinnedTurnPlan", () => {
it("removes only the currently pinned turn plan", () => {
const turnPlans = [
{
id: "turn-plan:turn-1",
createdAt: "2026-01-01T00:00:00Z",
turnId: TurnId.make("turn-1"),
plan: {
createdAt: "2026-01-01T00:00:00Z",
turnId: TurnId.make("turn-1"),
steps: [{ step: "Inspect", status: "inProgress" as const }],
},
},
{
id: "turn-plan:turn-2",
createdAt: "2026-01-01T00:01:00Z",
turnId: TurnId.make("turn-2"),
plan: {
createdAt: "2026-01-01T00:01:00Z",
turnId: TurnId.make("turn-2"),
steps: [{ step: "Ship", status: "pending" as const }],
},
},
];

expect(excludePinnedTurnPlan(turnPlans, TurnId.make("turn-1")).map((plan) => plan.id)).toEqual([
"turn-plan:turn-2",
]);
expect(excludePinnedTurnPlan(turnPlans, null)).toHaveLength(2);
});
});

describe("computeMessageDurationStart", () => {
it("returns message createdAt when there is no preceding user message", () => {
const result = computeMessageDurationStart([
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,20 @@ export type MessagesTimelineRow =
}
| { kind: "working"; id: string; createdAt: string | null };

/**
* The active turn's plan is rendered in the composer overlay while it runs.
* Keep completed and historical turn plans in the chronological timeline.
*/
export function excludePinnedTurnPlan(
turnPlans: ReadonlyArray<TurnPlanEntry>,
pinnedTurnId: TurnId | null,
): TurnPlanEntry[] {
if (pinnedTurnId === null) {
return [...turnPlans];
}
return turnPlans.filter((turnPlan) => turnPlan.turnId !== pinnedTurnId);
}

export interface StableMessagesTimelineRowsState {
byId: Map<string, MessagesTimelineRow>;
result: MessagesTimelineRow[];
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
workEntryIndicatesToolNeutralStatus,
workEntryIndicatesToolSuccess,
workLogEntryIsToolLike,
type TurnPlanEntry,
} from "../../session-logic";
import { type TurnDiffSummary } from "../../types";
import {
Expand Down Expand Up @@ -952,7 +953,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
<AssistantTimelineRow row={row} />
) : null}
{row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
{row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null}
{row.kind === "turn-plan" ? <TurnPlanTimelineRow turnPlan={row.turnPlan} /> : null}
{row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
</div>
);
Expand Down Expand Up @@ -1188,13 +1189,13 @@ function ProposedPlanTimelineRow({
* Collapsed by default — a segment bar plus the in-progress step label —
* and expands in place to the full step list. Replaces the old plan sidebar.
*/
const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
row,
export const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
turnPlan,
}: {
row: Extract<TimelineRow, { kind: "turn-plan" }>;
turnPlan: TurnPlanEntry;
}) {
const [expanded, setExpanded] = useState(false);
const { steps } = row.turnPlan.plan;
const { steps } = turnPlan.plan;
const completedCount = steps.filter((step) => step.status === "completed").length;
const allDone = completedCount === steps.length;
// Label priority: the in-progress step, else the next pending step (plan
Expand Down
Loading