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
48 changes: 45 additions & 3 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ import { recordSyntheticReactRenderSample } from "@/browser/utils/perf/reactProf
const TRANSCRIPT_ONLY_NOTICE =
"This workspace's worktree is no longer available. This is a read-only chat transcript kept for historical and usage-tracking reasons.";

function findTailProposePlanToolId(messages: readonly DisplayedMessage[]): string | null {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.type !== "tool") {
continue;
Comment thread
ThomasK33 marked this conversation as resolved.
}
return message.toolName === "propose_plan" ? message.id : null;
}

return null;
}

function PerfRenderMarker(props: { id: string; children: React.ReactNode }): React.ReactElement {
const renderStartTimeRef = useRef(performance.now());
renderStartTimeRef.current = performance.now();
Expand Down Expand Up @@ -463,6 +475,21 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
[canInterrupt, deferredMessages, isStreamStarting, transcriptDensity]
);

// A tail propose_plan usually means the agent paused for user review; reveal only the
// containing hyper-density bundles by default so historical plans stay collapsed.
const tailProposePlanToolId =
transcriptDensity === "hyper" ? findTailProposePlanToolId(deferredMessages) : null;
const tailProposePlanIndex =
tailProposePlanToolId === null
? -1
: deferredMessages.findIndex((message) => message.id === tailProposePlanToolId);
const tailProposePlanWorkBundleKey =
tailProposePlanIndex === -1 ? null : (workBundleInfos?.[tailProposePlanIndex]?.key ?? null);
const tailProposePlanOperationalBundleKey =
tailProposePlanIndex === -1
? null
: (operationalBundleInfos?.[tailProposePlanIndex]?.key ?? null);

const autoCompactionResult = useMemo(
() =>
checkAutoCompaction(
Expand Down Expand Up @@ -1235,8 +1262,12 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
const workBundleOverride = workBundle
? workBundleExpansionOverrides.get(workBundle.key)
: undefined;
const defaultRevealTailPlanWorkBundle =
tailProposePlanWorkBundleKey !== null &&
workBundle?.key === tailProposePlanWorkBundleKey;
const isWorkBundleExpanded = workBundle
? (workBundleOverride ?? workBundle.defaultExpanded)
? (workBundleOverride ??
(defaultRevealTailPlanWorkBundle || workBundle.defaultExpanded))
: false;

const keepCollapsedWorkBundleMemberVisible =
Expand All @@ -1259,8 +1290,13 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
const operationalBundleOverride = operationalBundle
? operationalBundleExpansionOverrides.get(operationalBundle.key)
: undefined;
const defaultRevealTailPlanOperationalBundle =
tailProposePlanOperationalBundleKey !== null &&
operationalBundle?.key === tailProposePlanOperationalBundleKey;
const isOperationalBundleExpanded = operationalBundle
? (operationalBundleOverride ?? operationalBundle.defaultExpanded)
? (operationalBundleOverride ??
(defaultRevealTailPlanOperationalBundle ||
operationalBundle.defaultExpanded))
: false;

if (
Expand Down Expand Up @@ -1301,8 +1337,14 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
nestedOperationalBundle.key
)
: undefined;
const defaultRevealTailPlanNestedBundle =
tailProposePlanOperationalBundleKey !== null &&
nestedOperationalBundle?.key ===
tailProposePlanOperationalBundleKey;
const isNestedExpanded = nestedOperationalBundle
? (nestedOverride ?? nestedOperationalBundle.defaultExpanded)
? (nestedOverride ??
(defaultRevealTailPlanNestedBundle ||
nestedOperationalBundle.defaultExpanded))
: false;

if (
Expand Down
42 changes: 42 additions & 0 deletions src/browser/features/Messages/TranscriptDensity.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createFileReadTool,
createGenericTool,
createPendingTool,
createProposePlanTool,
createWebSearchTool,
} from "@/browser/stories/mocks/tools";
import { STABLE_TIMESTAMP } from "@/browser/stories/mocks/workspaces";
Expand Down Expand Up @@ -121,6 +122,47 @@ export const HyperCollapsedBundles: AppStory = {
render: () => <AppWithMocks setup={() => setupTranscriptDensityStory("hyper")} />,
};

export const HyperTailProposePlanExpanded: AppStory = {
parameters: { chromatic: { modes: CHROMATIC_SMOKE_MODES } },
render: () => (
<AppWithMocks
setup={() => {
collapseLeftSidebar();
setDensity("hyper");
return setupSimpleChatStory({
messages: [
createUserMessage("tail-plan-user-1", "Plan the transcript density fix", {
historySequence: 1,
timestamp: STABLE_TIMESTAMP - 20_000,
}),
createAssistantMessage("tail-plan-assistant-1", "I'll draft the implementation plan.", {
historySequence: 2,
timestamp: STABLE_TIMESTAMP - 15_000,
toolCalls: [
createProposePlanTool(
"tail-plan-tool-1",
[
"# Tail Plan",
"",
"## Acceptance",
"",
"- The tail propose_plan is visible in hyper density without expanding bundles.",
].join("\n")
),
{
type: "text",
text: "Plan ready for review.",
timestamp: STABLE_TIMESTAMP - 5_000,
},
],
}),
],
});
}}
/>
),
};

export const HyperExpandedBundle: AppStory = {
// Chromatic executes this play function for the visual snapshot, while the
// app-level UI test covers expansion behavior without Storybook's manager-page timing.
Expand Down
154 changes: 154 additions & 0 deletions tests/ui/chat/transcriptDensity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
createBashTool,
createFileReadTool,
createGenericTool,
createProposePlanTool,
createWebSearchTool,
} from "@/browser/stories/mocks/tools";
import { installDom } from "../dom";
Expand Down Expand Up @@ -182,4 +183,157 @@ describe("Hyper transcript density", () => {
await cleanupView(view, cleanupDom);
}
}, 30_000);

test("reveals a tail propose_plan through collapsed hyper-density bundles", async () => {
const cleanupDom = installDom();
updatePersistedState<TranscriptDensity>(TRANSCRIPT_DENSITY_KEY, "hyper");

const metadata = createWorkspace({
id: "ws-tail-plan",
name: "tail-plan",
projectName: "my-app",
projectPath: "/home/user/projects/my-app",
});
const client = setupSimpleChatStory({
workspaceId: metadata.id,
workspaceName: metadata.name,
projectName: metadata.projectName,
projectPath: metadata.projectPath,
messages: [
createUserMessage("tail-plan-user-1", "Plan the transcript density fix", {
historySequence: 1,
timestamp: 0,
}),
createAssistantMessage("tail-plan-assistant-1", "I'll draft the implementation plan.", {
historySequence: 2,
timestamp: 1_000,
toolCalls: [
createProposePlanTool(
"tail-plan-tool-1",
"# Tail Plan\n\n- Reveal the tail propose_plan without a click."
),
{ type: "text", text: "Plan ready for review." },
],
}),
],
});
const view = renderApp({ apiClient: client, metadata });

try {
await setupWorkspaceView(view, metadata, metadata.id);

const workButton = await waitFor(() => {
const button = queryButton(view.container, "work-bundle");
if (!button) {
throw new Error("Tail plan work bundle button not found");
}
return button;
});
expect(workButton.getAttribute("aria-expanded")).toBe("true");

const operationalButton = await waitFor(() => {
const button = queryButton(view.container, "operational-bundle");
if (!button) {
throw new Error("Tail plan operational bundle button not found");
}
return button;
});
expect(operationalButton.getAttribute("aria-expanded")).toBe("true");
expect(view.container.textContent).toContain("Tail Plan");
expect(view.container.textContent).toContain("Reveal the tail propose_plan without a click.");

fireEvent.click(workButton);
await waitFor(() => {
expect(workButton.getAttribute("aria-expanded")).toBe("false");
});
expect(view.container.textContent).not.toContain("Tail Plan");
} finally {
await cleanupView(view, cleanupDom);
}
}, 30_000);

test("keeps historical propose_plan collapsed when a later image tool call exists", async () => {
const cleanupDom = installDom();
updatePersistedState<TranscriptDensity>(TRANSCRIPT_DENSITY_KEY, "hyper");

const metadata = createWorkspace({
id: "ws-historical-plan",
name: "historical-plan",
projectName: "my-app",
projectPath: "/home/user/projects/my-app",
});
const client = setupSimpleChatStory({
workspaceId: metadata.id,
workspaceName: metadata.name,
projectName: metadata.projectName,
projectPath: metadata.projectPath,
messages: [
createUserMessage("historical-plan-user-1", "Plan then validate", {
historySequence: 1,
timestamp: 0,
}),
createAssistantMessage("historical-plan-assistant-1", "I'll plan and then validate.", {
historySequence: 2,
timestamp: 1_000,
toolCalls: [
createProposePlanTool(
"historical-plan-tool-1",
"# Historical Plan\n\n- This older plan should stay hidden."
),
createGenericTool(
"historical-plan-image-1",
"image_generate",
{ prompt: "Create a validation image" },
{
success: true,
model: "gpt-image-1",
prompt: "Create a validation image",
requestedCount: 1,
images: [
{
path: "/tmp/generated.png",
filename: "generated.png",
mediaType: "image/png",
},
],
}
),
{ type: "text", text: "Validation finished." },
],
}),
],
});
const view = renderApp({ apiClient: client, metadata });

try {
await setupWorkspaceView(view, metadata, metadata.id);
await waitFor(() => {
expect(view.container.textContent).toContain("Validation finished.");
});
expect(view.container.textContent).not.toContain("Historical Plan");

const workButton = queryButton(view.container, "work-bundle");
if (workButton) {
expect(workButton.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(workButton);
}

const operationalButton = await waitFor(() => {
const button = queryButton(view.container, "operational-bundle");
if (!button) {
throw new Error("Historical plan operational bundle button not found");
}
return button;
});
expect(operationalButton.getAttribute("aria-expanded")).toBe("false");
expect(view.container.textContent).not.toContain("Historical Plan");

fireEvent.click(operationalButton);
await waitFor(() => {
expect(view.container.textContent).toContain("Historical Plan");
});
} finally {
await cleanupView(view, cleanupDom);
}
}, 30_000);
});
Loading