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
93 changes: 93 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
import "../index.css";

import {
EventId,
ORCHESTRATION_WS_METHODS,
type MessageId,
type OrchestrationReadModel,
type ProjectId,
type ServerConfig,
type ThreadId,
TurnId,
type WsWelcomePayload,
WS_CHANNELS,
WS_METHODS,
Expand Down Expand Up @@ -356,6 +358,43 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
};
}

function createSnapshotWithActivePlan(): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-active-plan-target" as MessageId,
targetText: "active plan thread",
});

return {
...snapshot,
threads: snapshot.threads.map((thread) =>
thread.id === THREAD_ID
? Object.assign({}, thread, {
activities: [
{
id: EventId.makeUnsafe("active-plan-browser-test"),
createdAt: isoAt(1_050),
kind: "turn.plan.updated",
summary: "Plan updated",
tone: "info",
turnId: TurnId.makeUnsafe("turn-active-plan"),
sequence: 1,
payload: {
explanation: "Keep the plan row visible while trimming its footprint.",
plan: [
{ step: "Keep timeline rows aligned", status: "inProgress" },
{ step: "Preserve header context when collapsed", status: "pending" },
{ step: "Retain stable collapse state across remounts", status: "pending" },
],
},
},
],
updatedAt: isoAt(1_051),
})
: thread,
),
};
}

function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
const tag = body._tag;
if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
Expand Down Expand Up @@ -1247,4 +1286,58 @@ describe("ChatView timeline estimator parity (full app)", () => {
await mounted.cleanup();
}
});

it("collapses the active plan container to its header row", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotWithActivePlan(),
});

try {
const collapseButton = await waitForElement(
() =>
document.querySelector('button[aria-label="Collapse plan"]') as HTMLButtonElement | null,
"Unable to find Collapse plan button.",
);

await waitForElement(
() => document.querySelector('[data-active-plan-card="true"]') as HTMLDivElement | null,
"Unable to find active plan card.",
);

expect(document.body.textContent).toContain("Keep timeline rows aligned");
collapseButton.click();

await vi.waitFor(
() => {
const activePlanRow = document.querySelector(
'[data-active-plan-card="true"]',
) as HTMLDivElement | null;
expect(activePlanRow?.textContent).toContain("Plan");
expect(activePlanRow?.textContent).toContain("Updated");
expect(activePlanRow?.textContent).not.toContain("Keep timeline rows aligned");
},
{ timeout: 8_000, interval: 16 },
);

const expandButton = await waitForElement(
() =>
document.querySelector('button[aria-label="Expand plan"]') as HTMLButtonElement | null,
"Unable to find Expand plan button.",
);
expandButton.click();

await vi.waitFor(
() => {
const activePlanRow = document.querySelector(
'[data-active-plan-card="true"]',
) as HTMLDivElement | null;
expect(activePlanRow?.textContent).toContain("Keep timeline rows aligned");
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});
});
30 changes: 29 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta
import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
import { MessagesTimeline } from "./chat/MessagesTimeline";
import { ActivePlanCard } from "./chat/ActivePlanCard";
import { ChatHeader } from "./chat/ChatHeader";
import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";
import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
Expand Down Expand Up @@ -264,6 +265,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
useState<Record<string, number>>({});
const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
const [collapsedActivePlanByTurnId, setCollapsedActivePlanByTurnId] = useState<
Record<string, boolean>
>({});
const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
// Tracks whether the user explicitly dismissed the sidebar for the active turn.
Expand Down Expand Up @@ -638,6 +642,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
[activeLatestTurn?.turnId, threadActivities],
);
const activePlanCollapseKey = activePlan ? (activePlan.turnId ?? activePlan.createdAt) : null;
const activePlanCollapsed = activePlanCollapseKey
? (collapsedActivePlanByTurnId[activePlanCollapseKey] ?? false)
: false;
const showPlanFollowUpPrompt =
pendingUserInputs.length === 0 &&
interactionMode === "plan" &&
Expand Down Expand Up @@ -3162,6 +3170,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
[groupId]: !existing[groupId],
}));
}, []);
const onToggleActivePlanCollapsed = useCallback((planKey: string) => {
setCollapsedActivePlanByTurnId((existing) => ({
...existing,
[planKey]: !(existing[planKey] ?? false),
}));
}, []);
const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
setExpandedImage(preview);
}, []);
Expand Down Expand Up @@ -3274,9 +3288,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
onTouchEnd={onMessagesTouchEnd}
onTouchCancel={onMessagesTouchEnd}
>
{activePlan ? (
<div className="sticky top-0 z-10 mx-auto w-full max-w-3xl px-0 pb-3 pt-1">
<ActivePlanCard
activePlan={activePlan}
collapsed={activePlanCollapsed}
timestampFormat={timestampFormat}
onToggleCollapsed={() => {
if (activePlanCollapseKey) {
onToggleActivePlanCollapsed(activePlanCollapseKey);
}
}}
/>
Comment on lines +3297 to +3302
</div>
) : null}
<MessagesTimeline
key={activeThread.id}
hasMessages={timelineEntries.length > 0}
hasMessages={timelineEntries.length > 0 || activePlan !== null}
isWorking={isWorking}
activeTurnInProgress={isWorking || !latestTurnSettled}
activeTurnStartedAt={activeWorkStartedAt}
Expand Down
122 changes: 122 additions & 0 deletions apps/web/src/components/chat/ActivePlanCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { memo } from "react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";

import type { TimestampFormat } from "../../appSettings";
import type { ActivePlanState } from "../../session-logic";
import { formatTimestamp } from "../../timestampFormat";
import { cn } from "~/lib/utils";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";

interface ActivePlanCardProps {
activePlan: ActivePlanState;
collapsed: boolean;
timestampFormat: TimestampFormat;
onToggleCollapsed: () => void;
}

export const ActivePlanCard = memo(function ActivePlanCard({
activePlan,
collapsed,
timestampFormat,
onToggleCollapsed,
}: ActivePlanCardProps) {
const keyedSteps = buildKeyedSteps(activePlan.steps);

return (
<div
data-active-plan-card="true"
className="isolate overflow-hidden rounded-[24px] border border-border/80 bg-card/70 p-4 backdrop-blur-xs sm:p-5"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="secondary">Plan</Badge>
<p className="truncate text-sm font-medium text-muted-foreground/75">
Updated {formatTimestamp(activePlan.createdAt, timestampFormat)}
</p>
</div>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground/50 hover:text-foreground/75"
aria-label={collapsed ? "Expand plan" : "Collapse plan"}
title={collapsed ? "Expand plan" : "Collapse plan"}
data-scroll-anchor-ignore
onClick={onToggleCollapsed}
>
{collapsed ? (
<ChevronDownIcon aria-hidden="true" className="size-3.5" />
) : (
<ChevronUpIcon aria-hidden="true" className="size-3.5" />
)}
</Button>
</div>
{collapsed ? null : (
<div className="mt-4 space-y-3">
{activePlan.explanation?.trim() ? (
<p className="text-[13px] leading-relaxed text-muted-foreground/80">
{activePlan.explanation}
</p>
) : null}
<div className="space-y-3">
{keyedSteps.map(({ key, step }) => (
<div
key={key}
className="rounded-[18px] border border-border/55 bg-background/35 px-4 py-3"
>
<div className="flex items-start gap-3">
<span
className={cn(
"inline-flex shrink-0 items-center rounded-full border px-2.5 py-0.5 text-[10px] font-semibold",
step.status === "inProgress" &&
"border-blue-400/25 bg-blue-400/10 text-blue-300",
step.status === "pending" &&
"border-border/60 bg-background/50 text-muted-foreground/85",
step.status === "completed" &&
"border-emerald-400/25 bg-emerald-400/10 text-emerald-300",
)}
>
{statusLabel(step.status)}
</span>
<p
className={cn(
"min-w-0 flex-1 text-[13px] leading-snug text-foreground/92",
step.status === "completed" &&
"text-muted-foreground/65 line-through decoration-muted-foreground/25",
)}
>
{step.step}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
});

function statusLabel(status: ActivePlanState["steps"][number]["status"]): string {
if (status === "inProgress") return "In progress";
if (status === "completed") return "Completed";
return "Pending";
}

function buildKeyedSteps(steps: ActivePlanState["steps"]): Array<{
key: string;
step: ActivePlanState["steps"][number];
}> {
const occurrenceCountByBaseKey = new Map<string, number>();

return steps.map((step) => {
const baseKey = `${step.status}:${step.step}`;
const nextOccurrence = (occurrenceCountByBaseKey.get(baseKey) ?? 0) + 1;
occurrenceCountByBaseKey.set(baseKey, nextOccurrence);

return {
key: `${baseKey}:${nextOccurrence}`,
step,
};
});
}