Skip to content

Commit 0ad6952

Browse files
committed
🤖 fix: stabilize chat input status indicators
Reserve collapsed decoration rail space above the workspace chat input so automatic TODO and background bash indicators can appear without resizing the transcript viewport. Default newly-created pinned TODO indicators to collapsed while preserving persisted user expansion, and cover the stable rail behavior with focused UI tests. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `1466331{MUX_COSTS_USD:-unknown}`_ <!-- mux-attribution: model=openai:gpt-5.5 thinking=xhigh costs=5.51 -->
1 parent e2c9870 commit 0ad6952

6 files changed

Lines changed: 85 additions & 21 deletions

File tree

src/browser/components/ChatPane/ChatInputDecoration.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import type { ReactNode } from "react";
22
import { ChevronDown, ChevronRight } from "lucide-react";
33
import { cn } from "@/common/lib/utils";
44

5+
// Share the collapsed row height with layout reservations so automatic
6+
// indicators can mount without resizing the transcript viewport.
7+
export const CHAT_INPUT_DECORATION_COLLAPSED_ROW_HEIGHT_PX = 25;
8+
59
interface ChatInputDecorationProps {
610
expanded: boolean;
711
onToggle: () => void;

src/browser/components/ChatPane/ChatPane.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { RetryBarrier } from "@/browser/features/Messages/ChatBarrier/RetryBarri
2121
import { PinnedTodoList } from "../PinnedTodoList/PinnedTodoList";
2222
import { LayoutStackLane } from "./LayoutStackLane";
2323
import type { LayoutStackItem } from "./layoutStack";
24+
import { CHAT_INPUT_DECORATION_COLLAPSED_ROW_HEIGHT_PX } from "./ChatInputDecoration";
2425
import { VIM_ENABLED_KEY } from "@/common/constants/storage";
2526
import { ChatInput, type ChatInputAPI } from "@/browser/features/ChatInput/index";
2627
import type { QueueDispatchMode } from "@/browser/features/ChatInput/types";
@@ -1101,6 +1102,12 @@ interface ChatInputPaneProps {
11011102

11021103
const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
11031104
const { reviews } = props;
1105+
// Reserve only the TODO row we already know is visible. Background bash state
1106+
// stays colocated in BackgroundProcessesBanner so process updates do not rerender
1107+
// the whole input pane, and hidden background rows no longer leave an empty rail.
1108+
const automaticDecorationRailHeightPx = props.shouldShowPinnedTodoList
1109+
? CHAT_INPUT_DECORATION_COLLAPSED_ROW_HEIGHT_PX
1110+
: 0;
11041111

11051112
// Keep optional banners/warnings on one shared lane so the seam right above the textarea is
11061113
// owned by a single component boundary. That lets hydration reserve only the volatile
@@ -1178,6 +1185,7 @@ const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
11781185
isHydrating={props.isHydratingTranscript}
11791186
align="end"
11801187
dataComponent="ChatInputDecorationStack"
1188+
minReservedHeightPx={automaticDecorationRailHeightPx}
11811189
items={decorationEntries}
11821190
/>
11831191
<ChatInput

src/browser/components/ChatPane/LayoutStackLane.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,47 @@ describe("LayoutStackLane", () => {
238238
});
239239
});
240240

241+
it("lets callers drop unused automatic rail space", () => {
242+
const view = render(
243+
<LayoutStackLane
244+
workspaceId="workspace-a"
245+
isHydrating={false}
246+
align="end"
247+
dataComponent="decoration-lane"
248+
minReservedHeightPx={50}
249+
items={[createTextItem("todo", "TODO status row"), createTextItem("bash", "Bash row")]}
250+
/>
251+
);
252+
253+
expect(getRenderedStack(view.container, "decoration-lane").style.minHeight).toBe("50px");
254+
255+
view.rerender(
256+
<LayoutStackLane
257+
workspaceId="workspace-a"
258+
isHydrating={false}
259+
align="end"
260+
dataComponent="decoration-lane"
261+
minReservedHeightPx={25}
262+
items={[createTextItem("todo", "TODO status row")]}
263+
/>
264+
);
265+
266+
expect(getRenderedStack(view.container, "decoration-lane").style.minHeight).toBe("25px");
267+
268+
view.rerender(
269+
<LayoutStackLane
270+
workspaceId="workspace-a"
271+
isHydrating={false}
272+
align="end"
273+
dataComponent="decoration-lane"
274+
minReservedHeightPx={0}
275+
items={[createHiddenItem()]}
276+
/>
277+
);
278+
279+
expect(getRenderedStack(view.container, "decoration-lane").style.minHeight).toBe("");
280+
});
281+
241282
it("attaches ResizeObserver when items mount after an empty null lane", async () => {
242283
const view = render(
243284
<LayoutStackLane

src/browser/components/ChatPane/LayoutStackLane.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
* - `overflowAnchor="none"` opts the tail lane out of browser scroll anchoring
2121
* so a newly-inserted tail row (streaming barrier, etc.) can't win the anchor
2222
* heuristic and flash the layout underneath.
23+
* - `minReservedHeightPx` keeps known volatile lanes from resizing the transcript
24+
* when automatic chrome appears after the initial layout has settled.
2325
*
2426
* Height changes are observed by the transcript scroll owner; this lane only
2527
* handles reservation and alignment.
@@ -31,6 +33,7 @@ interface LayoutStackLaneProps {
3133
align: "start" | "end";
3234
overflowAnchor?: "none";
3335
dataComponent?: string;
36+
minReservedHeightPx?: number;
3437
}
3538

3639
export const LayoutStackLane: React.FC<LayoutStackLaneProps> = (props) => {
@@ -39,12 +42,17 @@ export const LayoutStackLane: React.FC<LayoutStackLaneProps> = (props) => {
3942
const lastMeasuredStackHeightRef = useRef(0);
4043

4144
const hasItems = props.items.length > 0;
42-
const reservedStackHeightPx = getReservedLayoutStackHeightPx({
45+
const hydratedReservedStackHeightPx = getReservedLayoutStackHeightPx({
4346
workspaceId: props.workspaceId,
4447
isHydrating: props.isHydrating,
4548
stackHeightByWorkspaceId: stackHeightByWorkspaceIdRef.current,
4649
fallbackStackHeightPx: lastMeasuredStackHeightRef.current,
4750
});
51+
const minReservedHeightPx = Math.max(0, Math.round(props.minReservedHeightPx ?? 0));
52+
const reservedStackHeightPx =
53+
hydratedReservedStackHeightPx === null
54+
? minReservedHeightPx
55+
: Math.max(hydratedReservedStackHeightPx, minReservedHeightPx);
4856

4957
useLayoutEffect(() => {
5058
const content = contentRef.current;
@@ -113,12 +121,12 @@ export const LayoutStackLane: React.FC<LayoutStackLaneProps> = (props) => {
113121
}
114122
}, [hasItems, props.isHydrating, props.workspaceId]);
115123

116-
if (!hasItems && reservedStackHeightPx === null) {
124+
if (!hasItems && reservedStackHeightPx === 0) {
117125
return null;
118126
}
119127

120128
const style: React.CSSProperties = {};
121-
if (reservedStackHeightPx !== null) {
129+
if (reservedStackHeightPx > 0) {
122130
style.minHeight = `${reservedStackHeightPx}px`;
123131
}
124132
if (props.overflowAnchor === "none") {

src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,13 @@ describe("PinnedTodoList", () => {
124124
workspaceSubscribers.clear();
125125
});
126126

127-
test("renders expanded by default when todos exist", () => {
128-
seedWorkspaceState("ws-expanded", { todos: defaultTodos });
127+
test("renders collapsed by default when todos exist", () => {
128+
seedWorkspaceState("ws-collapsed-default", { todos: defaultTodos });
129129

130-
const renderResult = renderPinnedTodoList("ws-expanded");
130+
const renderResult = renderPinnedTodoList("ws-collapsed-default");
131131

132-
expect(renderResult.getByText("Add tests")).toBeTruthy();
132+
expect(getHeader(renderResult)).toBeTruthy();
133+
expect(renderResult.queryByText("Add tests")).toBeNull();
133134
});
134135

135136
test("renders nothing when there are no todos", () => {
@@ -140,29 +141,29 @@ describe("PinnedTodoList", () => {
140141
expect(renderResult.container.firstChild).toBeNull();
141142
});
142143

143-
test("reads a persisted collapsed state on mount", () => {
144-
const workspaceId = "ws-collapsed";
144+
test("reads a persisted expanded state on mount", () => {
145+
const workspaceId = "ws-expanded";
145146
seedWorkspaceState(workspaceId, { todos: defaultTodos });
146-
globalThis.localStorage.setItem(getPinnedTodoExpandedKey(workspaceId), JSON.stringify(false));
147+
globalThis.localStorage.setItem(getPinnedTodoExpandedKey(workspaceId), JSON.stringify(true));
147148

148149
const renderResult = renderPinnedTodoList(workspaceId);
149150

150-
expect(renderResult.queryByText("Add tests")).toBeNull();
151+
expect(renderResult.getByText("Add tests")).toBeTruthy();
151152
});
152153

153-
test("manual header click collapses and re-expands while persisting state", () => {
154+
test("manual header click expands and re-collapses while persisting state", () => {
154155
const workspaceId = "ws-toggle";
155156
seedWorkspaceState(workspaceId, { todos: defaultTodos });
156157

157158
const renderResult = renderPinnedTodoList(workspaceId);
158159

159-
fireEvent.click(getHeader(renderResult));
160-
expect(renderResult.queryByText("Add tests")).toBeNull();
161-
expect(readPersistedState(getPinnedTodoExpandedKey(workspaceId), true)).toBe(false);
162-
163160
fireEvent.click(getHeader(renderResult));
164161
expect(renderResult.getByText("Add tests")).toBeTruthy();
165162
expect(readPersistedState(getPinnedTodoExpandedKey(workspaceId), false)).toBe(true);
163+
164+
fireEvent.click(getHeader(renderResult));
165+
expect(renderResult.queryByText("Add tests")).toBeNull();
166+
expect(readPersistedState(getPinnedTodoExpandedKey(workspaceId), true)).toBe(false);
166167
});
167168

168169
test("persists expansion state per workspace instead of globally", () => {
@@ -172,13 +173,13 @@ describe("PinnedTodoList", () => {
172173
const firstRender = renderPinnedTodoList("ws-a");
173174
fireEvent.click(getHeader(firstRender));
174175

175-
expect(firstRender.queryByText("Add tests")).toBeNull();
176-
expect(readPersistedState(getPinnedTodoExpandedKey("ws-a"), true)).toBe(false);
177-
expect(readPersistedState(getPinnedTodoExpandedKey("ws-b"), true)).toBe(true);
176+
expect(firstRender.getByText("Add tests")).toBeTruthy();
177+
expect(readPersistedState(getPinnedTodoExpandedKey("ws-a"), false)).toBe(true);
178+
expect(readPersistedState(getPinnedTodoExpandedKey("ws-b"), false)).toBe(false);
178179

179180
firstRender.unmount();
180181
const secondRender = renderPinnedTodoList("ws-b");
181182

182-
expect(secondRender.getByText("Add tests")).toBeTruthy();
183+
expect(secondRender.queryByText("Add tests")).toBeNull();
183184
});
184185
});

src/browser/components/PinnedTodoList/PinnedTodoList.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ interface PinnedTodoListProps {
2323
* - MapStore caches WorkspaceState per version, avoiding unnecessary recomputation
2424
*/
2525
export const PinnedTodoList: React.FC<PinnedTodoListProps> = ({ workspaceId }) => {
26-
const [expanded, setExpanded] = usePersistedState(getPinnedTodoExpandedKey(workspaceId), true);
26+
// Default to the reserved collapsed rail so a newly-created TODO list does not
27+
// resize the transcript. A user's persisted expansion still wins.
28+
const [expanded, setExpanded] = usePersistedState(getPinnedTodoExpandedKey(workspaceId), false);
2729

2830
const workspaceStore = useWorkspaceStoreRaw();
2931
const subscribeToWorkspace = (callback: () => void) =>

0 commit comments

Comments
 (0)