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
5 changes: 4 additions & 1 deletion src/browser/components/ChatPane/ChatInputDecoration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ interface ChatInputDecorationProps {
export function ChatInputDecoration(props: ChatInputDecorationProps) {
return (
<div
className={cn("border-border bg-surface-primary border-t px-4", props.className)}
className={cn(
"border-border bg-surface-primary pointer-events-auto border-t px-4",
props.className
)}
data-component={props.dataComponent}
>
<button
Expand Down
59 changes: 41 additions & 18 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1101,13 +1101,14 @@ interface ChatInputPaneProps {

const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
const { reviews } = props;

// Keep optional banners/warnings on one shared lane so the seam right above the textarea is
// owned by a single component boundary. That lets hydration reserve only the volatile
// workspace-specific decoration stack instead of the whole composer pane.
const decorationEntries: LayoutStackItem[] = [];
// Keep user-facing warnings in normal flow because they are intentional layout
// changes. Automatic status rows float only when they are the sole chrome above
// the composer, so they cannot shrink the transcript or cover action banners.
const topFlowDecorationEntries: LayoutStackItem[] = [];
const automaticDecorationEntries: LayoutStackItem[] = [];
const lowerFlowDecorationEntries: LayoutStackItem[] = [];
if (props.shouldShowCompactionWarning) {
decorationEntries.push({
topFlowDecorationEntries.push({
key: "compaction-warning",
node: (
<CompactionWarning
Expand All @@ -1119,7 +1120,7 @@ const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
});
}
if (props.contextSwitchWarning) {
decorationEntries.push({
topFlowDecorationEntries.push({
key: "context-switch-warning",
node: (
<ContextSwitchWarningBanner
Expand All @@ -1131,23 +1132,23 @@ const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
});
}
if (props.shouldShowPinnedTodoList) {
decorationEntries.push({
automaticDecorationEntries.push({
key: "pinned-todo-list",
node: <PinnedTodoList workspaceId={props.workspaceId} />,
});
}
decorationEntries.push({
automaticDecorationEntries.push({
key: "background-processes",
node: <BackgroundProcessesBanner workspaceId={props.workspaceId} />,
});
if (props.shouldShowReviewsBanner) {
decorationEntries.push({
lowerFlowDecorationEntries.push({
key: "reviews-banner",
node: <ReviewsBanner workspaceId={props.workspaceId} />,
});
}
if (props.queuedMessage) {
decorationEntries.push({
lowerFlowDecorationEntries.push({
key: "queued-message",
node: (
<QueuedMessage
Expand All @@ -1159,7 +1160,7 @@ const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
});
}
if (props.isQueuedAgentTask) {
decorationEntries.push({
lowerFlowDecorationEntries.push({
key: "queued-agent-task",
node: (
<div className="border-border-medium bg-background-secondary text-muted rounded-md border px-3 py-2 text-xs">
Expand All @@ -1168,17 +1169,39 @@ const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
),
});
}
// The decoration lane changes the transcript viewport height from below; the
// scrollport ResizeObserver inside useAutoScroll owns any required bottom pin.
const hasFlowDecorations =
topFlowDecorationEntries.length > 0 || lowerFlowDecorationEntries.length > 0;

return (
<>
<div className="relative">
<LayoutStackLane
workspaceId={props.workspaceId}
isHydrating={props.isHydratingTranscript}
align="end"
dataComponent="ChatInputTopFlowDecorationStack"
items={topFlowDecorationEntries}
/>
<div
className={cn(
!hasFlowDecorations && "pointer-events-none absolute inset-x-0 bottom-full z-10"
)}
>
<div>
<LayoutStackLane
workspaceId={props.workspaceId}
isHydrating={props.isHydratingTranscript}
align="end"
dataComponent="ChatInputAutomaticDecorationStack"
items={automaticDecorationEntries}
/>
</div>
</div>
<LayoutStackLane
workspaceId={props.workspaceId}
isHydrating={props.isHydratingTranscript}
align="end"
dataComponent="ChatInputDecorationStack"
items={decorationEntries}
dataComponent="ChatInputLowerFlowDecorationStack"
items={lowerFlowDecorationEntries}
/>
<ChatInput
key={props.workspaceId}
Expand Down Expand Up @@ -1210,6 +1233,6 @@ const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
onDeleteReview={reviews.removeReview}
onUpdateReviewNote={reviews.updateReviewNote}
/>
</>
</div>
);
};
35 changes: 18 additions & 17 deletions src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,13 @@ describe("PinnedTodoList", () => {
workspaceSubscribers.clear();
});

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

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

expect(renderResult.getByText("Add tests")).toBeTruthy();
expect(getHeader(renderResult)).toBeTruthy();
expect(renderResult.queryByText("Add tests")).toBeNull();
});

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

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

const renderResult = renderPinnedTodoList(workspaceId);

expect(renderResult.queryByText("Add tests")).toBeNull();
expect(renderResult.getByText("Add tests")).toBeTruthy();
});

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

const renderResult = renderPinnedTodoList(workspaceId);

fireEvent.click(getHeader(renderResult));
expect(renderResult.queryByText("Add tests")).toBeNull();
expect(readPersistedState(getPinnedTodoExpandedKey(workspaceId), true)).toBe(false);

fireEvent.click(getHeader(renderResult));
expect(renderResult.getByText("Add tests")).toBeTruthy();
expect(readPersistedState(getPinnedTodoExpandedKey(workspaceId), false)).toBe(true);

fireEvent.click(getHeader(renderResult));
expect(renderResult.queryByText("Add tests")).toBeNull();
expect(readPersistedState(getPinnedTodoExpandedKey(workspaceId), true)).toBe(false);
});

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

expect(firstRender.queryByText("Add tests")).toBeNull();
expect(readPersistedState(getPinnedTodoExpandedKey("ws-a"), true)).toBe(false);
expect(readPersistedState(getPinnedTodoExpandedKey("ws-b"), true)).toBe(true);
expect(firstRender.getByText("Add tests")).toBeTruthy();
expect(readPersistedState(getPinnedTodoExpandedKey("ws-a"), false)).toBe(true);
expect(readPersistedState(getPinnedTodoExpandedKey("ws-b"), false)).toBe(false);

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

expect(secondRender.getByText("Add tests")).toBeTruthy();
expect(secondRender.queryByText("Add tests")).toBeNull();
});
});
4 changes: 3 additions & 1 deletion src/browser/components/PinnedTodoList/PinnedTodoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ interface PinnedTodoListProps {
* - MapStore caches WorkspaceState per version, avoiding unnecessary recomputation
*/
export const PinnedTodoList: React.FC<PinnedTodoListProps> = ({ workspaceId }) => {
const [expanded, setExpanded] = usePersistedState(getPinnedTodoExpandedKey(workspaceId), true);
// Default to the reserved collapsed rail so a newly-created TODO list does not
// resize the transcript. A user's persisted expansion still wins.
const [expanded, setExpanded] = usePersistedState(getPinnedTodoExpandedKey(workspaceId), false);

const workspaceStore = useWorkspaceStoreRaw();
const subscribeToWorkspace = (callback: () => void) =>
Expand Down
Loading