Skip to content
Draft
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
2 changes: 1 addition & 1 deletion site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"@novnc/novnc": "^1.5.0",
"@pierre/diffs": "1.3.3",
"@pierre/trees": "1.0.0-beta.4",
"@shadcn/react": "0.3.0",
"@tanstack/react-query-devtools": "5.82.0",
"@xterm/addon-canvas": "0.7.0",
"@xterm/addon-fit": "0.11.0",
Expand Down Expand Up @@ -94,7 +95,6 @@
"react-confetti": "6.4.0",
"react-day-picker": "9.14.0",
"react-dom": "19.2.8",
"react-infinite-scroll-component": "7.2.1",
"react-markdown": "9.1.0",
"react-query": "npm:@tanstack/react-query@5.82.0",
"react-resizable-panels": "3.0.6",
Expand Down
34 changes: 19 additions & 15 deletions site/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 66 additions & 3 deletions site/src/pages/AgentsPage/AgentChatPage.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { FC } from "react";
import { useRef } from "react";
import { hashKey } from "react-query";
import { Outlet, useNavigate } from "react-router";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
Expand Down Expand Up @@ -50,7 +49,6 @@ import type { AgentsPageOutletContext } from "./AgentsPageLayout";
// Layout wrapper: provides outlet context for the child route.
// ---------------------------------------------------------------------------
const AgentChatPageLayout: FC = () => {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
return (
<div className="flex h-full">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
Expand All @@ -74,7 +72,6 @@ const AgentChatPageLayout: FC = () => {
onToggleSidebarCollapsed: () => {},
onExpandSidebar: () => {},
onChatReady: () => {},
scrollContainerRef,
} satisfies AgentsPageOutletContext
}
/>
Expand Down Expand Up @@ -3149,6 +3146,72 @@ export const SendResponseAfterChatSwitch: Story = {
},
};

/**
* The send flow renders the durable user row once the server accepts the
* prompt, before the assistant turn produces any output.
*/
export const SendRendersDurableUserRowBeforeAssistantOutput: Story = {
parameters: {
queries: buildQueries(
{
id: CHAT_ID,
...baseChatFields,
title: "Durable send",
status: "waiting",
},
{ messages: [], queued_messages: [], has_more: false },
{ diffUrl: undefined },
),
},
beforeEach: () => {
spyOn(API.experimental, "getUserSkills").mockResolvedValue([]);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
let releaseSend: (() => void) | undefined;
const sendGate = new Promise<void>((resolve) => {
releaseSend = resolve;
});
const sendSpy = spyOn(
API.experimental,
"createChatMessage",
).mockImplementation(async () => {
await sendGate;
return {
queued: false,
message: {
...MockChatMessage,
id: 60,
chat_id: CHAT_ID,
role: "user",
content: [{ type: "text", text: "Durable prompt" }],
},
};
});

const editor = await canvas.findByTestId("chat-message-input");
await userEvent.click(editor);
await userEvent.type(editor, "Durable prompt");
await userEvent.keyboard("{Enter}");
await waitFor(() => {
expect(sendSpy).toHaveBeenCalledTimes(1);
});

const timeline = within(await canvas.findByTestId("conversation-timeline"));
expect(
timeline.queryByTestId("chat-message-message:60"),
).not.toBeInTheDocument();

releaseSend?.();
expect(
await timeline.findByTestId("chat-message-message:60"),
).toHaveTextContent("Durable prompt");
// The turn is still waiting on its first chunk, so the durable row is in
// place before any assistant output exists.
expect(canvas.getByTestId("live-activity-slot")).toBeVisible();
},
};

const mockErrorChat: TypesGen.Chat = {
...MockChat,
id: CHAT_ID,
Expand Down
39 changes: 8 additions & 31 deletions site/src/pages/AgentsPage/AgentChatPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
restoreOptimisticRequestSnapshot,
runPromoteQueuedMessage,
settlePromotedQueueHead,
submitEditAndScroll,
submitEdit,
useConversationEditingState,
waitForPendingChatSettingsSyncs,
} from "./AgentChatPage";
Expand Down Expand Up @@ -1246,63 +1246,40 @@ describe("useConversationEditingState", () => {
});
});

describe("submitEditAndScroll", () => {
describe("submitEdit", () => {
const dummyArgs = {
messageId: 42,
req: { content: [{ type: "text" as const, text: "edited" }] },
};

it("calls scrollToBottom after editMessage resolves", async () => {
const callOrder: string[] = [];
const editMessage = vi.fn(async () => {
callOrder.push("editMessage");
});
const scrollToBottom = vi.fn(() => {
callOrder.push("scrollToBottom");
});
it("awaits editMessage", async () => {
const editMessage = vi.fn().mockResolvedValue(undefined);

await submitEditAndScroll({
await submitEdit({
editMessage,
editArgs: dummyArgs,
scrollToBottom,
onError: vi.fn(),
});

expect(callOrder).toEqual(["editMessage", "scrollToBottom"]);
expect(editMessage).toHaveBeenCalledWith(dummyArgs);
});

it("does not call scrollToBottom when editMessage throws", async () => {
const scrollToBottom = vi.fn();
it("reports and rethrows an editMessage failure", async () => {
const onError = vi.fn();
const editMessage = vi.fn().mockRejectedValue(new Error("boom"));

await expect(
submitEditAndScroll({
submitEdit({
editMessage,
editArgs: dummyArgs,
scrollToBottom,
onError,
}),
).rejects.toThrow("boom");

expect(scrollToBottom).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: "boom" }),
);
});

it("tolerates null scrollToBottom", async () => {
const editMessage = vi.fn().mockResolvedValue(undefined);

await submitEditAndScroll({
editMessage,
editArgs: dummyArgs,
scrollToBottom: null,
onError: vi.fn(),
});

expect(editMessage).toHaveBeenCalled();
});
});

describe("sidebar tab persistence", () => {
Expand Down
27 changes: 8 additions & 19 deletions site/src/pages/AgentsPage/AgentChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,10 +338,9 @@ export const settlePromotedQueueHead = async (
);
};

export async function submitEditAndScroll({
export async function submitEdit({
editMessage,
editArgs,
scrollToBottom,
onError,
}: {
editMessage: (args: {
Expand All @@ -354,7 +353,6 @@ export async function submitEditAndScroll({
optimisticMessage?: TypesGen.ChatMessage;
req: TypesGen.EditChatMessageRequest;
};
scrollToBottom: (() => void) | null | undefined;
onError: (error: unknown) => void;
}): Promise<void> {
try {
Expand All @@ -363,13 +361,6 @@ export async function submitEditAndScroll({
onError(error);
throw error;
}
// Scroll after the mutation resolves so the optimistic
// truncation and server reconciliation have already been
// applied to the DOM. Scrolling before this point causes
// the sticky user message to cycle through prior messages
// as the IntersectionObserver reacts to rapid layout
// shifts between the old and truncated content.
scrollToBottom?.();
}

/** @internal Exported for testing. */
Expand Down Expand Up @@ -882,7 +873,6 @@ const AgentChatPage: FC = () => {
isSidebarCollapsed,
onToggleSidebarCollapsed,
onChatReady,
scrollContainerRef,
} = useOutletContext<AgentsPageOutletContext>();
const queryClient = useQueryClient();
const { permissions, user: currentUser } = useAuthenticated();
Expand All @@ -891,7 +881,6 @@ const AgentChatPage: FC = () => {
const [selectedModel, setSelectedModel] = useState("");
const [selectedReasoningEffort, setSelectedReasoningEffort] = useState("");
const isEditReasoningEffortDirtyRef = useRef(false);
const scrollToBottomRef = useRef<(() => void) | null>(null);
const chatInputRef = useRef<ChatMessageInputRef | null>(null);
const inputValueRef = useRef(
agentId
Expand Down Expand Up @@ -1240,6 +1229,7 @@ const AgentChatPage: FC = () => {
};

const aiGatewayDisabled = !useAIGatewayEnabled();
const [liveEdgeSignal, setLiveEdgeSignal] = useState(0);
const {
store,
acceptServerChatStatus,
Expand Down Expand Up @@ -1627,6 +1617,9 @@ const AgentChatPage: FC = () => {
if (!hasContent || isSubmissionPending || !agentId || !hasModelOptions) {
return;
}
// Every accepted submission (send, edit, /compact) is an explicit ask
// to be at the live edge, even one that appends no visible prompt.
setLiveEdgeSignal((signal) => signal + 1);
Comment on lines +1620 to +1622

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Signal the live edge after pending settings settle

When a user submits while a plan-mode or workspace update is pending, this signal scrolls them away from historical messages before waitForPendingChatSettingsSyncs completes. If either update rejects, the function exits without sending or compacting anything, but the user's reading position has already been lost; emit the signal only after the prerequisite sync succeeds and the requested operation actually begins.

Useful? React with 👍 / 👎.

// Wait for chat-setting mutations to settle before sending so the
// message observes the workspace and plan-mode choices the user just made.
await waitForPendingChatSettingsSyncs([
Expand Down Expand Up @@ -1662,7 +1655,6 @@ const AgentChatPage: FC = () => {
clearStreamError();
store.clearStreamState();
store.setChatStatus("running");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore live-edge navigation for manual compaction

When a user submits /compact while reading older history, this branch no longer returns the viewport to the live edge. Manual compaction appends no user prompt, and when the latest durable message is an assistant, selectIsAwaitingFirstStreamChunk remains false, so the new scroller creates neither a live row nor a scroll anchor; compaction activity and eventual summary rows therefore remain out of view. Preserve an explicit navigation path for non-message submissions and cover compaction from a historical scroll position in Storybook.

AGENTS.md reference: site/AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0d030d3 without a ref bridge. submitChatTurn now increments a liveEdgeSignal counter for every accepted submission (send, edit, /compact), threaded down as a prop. A small LiveEdgeFollower component inside the Provider calls the library's own scrollToEnd when the signal changes, using React's render-time state adjustment rather than an effect. This covers /compact (no prompt, no anchor) and also restores main's instant pre-network snap for sends and edits, which previously only navigated once the POST resolved and the anchor fired. scrollToEnd also re-engages following-bottom, so the stream that follows is tracked too.

Generated by Coder Agents on behalf of @DanielleMaywood.

scrollToBottomRef.current?.();
try {
await compact();
} catch (error) {
Expand Down Expand Up @@ -1715,14 +1707,13 @@ const AgentChatPage: FC = () => {
store.setChatStatus("running");
store.clearStreamState();
});
await submitEditAndScroll({
await submitEdit({
editMessage,
editArgs: {
messageId: editedMessageID,
optimisticMessage,
req: request,
},
scrollToBottom: scrollToBottomRef.current,
onError: (error) => {
restoreOptimisticRequestSnapshot(store, previousSnapshot);
handleRequestError(error);
Expand Down Expand Up @@ -1758,7 +1749,6 @@ const AgentChatPage: FC = () => {
};
clearChatErrorReason(agentId);
clearStreamError();
scrollToBottomRef.current?.();

// An errored-chat send may promote the queue head that existed when the request began.
const queuedMessagesBeforeSend = store.getSnapshot().queuedMessages;
Expand Down Expand Up @@ -1973,6 +1963,7 @@ const AgentChatPage: FC = () => {
workspaceAgent={workspaceAgent}
chatBuildId={chatQuery.data?.build_id}
store={store}
liveEdgeSignal={liveEdgeSignal}
editing={{ ...editing, handleEditUserMessage }}
effectiveSelectedModel={effectiveSelectedModel}
setSelectedModel={setSelectedModel}
Expand Down Expand Up @@ -2035,12 +2026,10 @@ const AgentChatPage: FC = () => {
isPinned={(chatRecord?.pin_order ?? 0) > 0}
isChildChat={parentChatID !== undefined}
urlTransform={urlTransform}
scrollContainerRef={scrollContainerRef}
scrollToBottomRef={scrollToBottomRef}
hasMoreMessages={chatMessagesQuery.hasNextPage ?? false}
isFetchingMoreMessages={chatMessagesQuery.isFetchingNextPage}
hasFetchMoreError={chatMessagesQuery.isFetchNextPageError}
onFetchMoreMessages={chatMessagesQuery.fetchNextPage}
messageCount={storeMessageCount}
desktopChatId={desktopEnabled ? agentId : undefined}
mcpServers={mcpServers}
selectedMCPServerIds={effectiveMCPServerIds}
Expand Down
Loading
Loading