Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 49a02d8eb9 test(app): name the split resize helper arguments 2026-10-13 17:26:14 +02:00
paseo-bot[bot] ee6b095d0e test(app): take the page from the divider locator in the split resize spec 2026-10-13 17:26:14 +02:00
paseo-bot[bot] fb065afa2f refactor(app): choose split group sizes at the call site 2026-10-13 17:26:14 +02:00
paseo-bot[bot] 6eee6a6b3a fix(app): let every split divider drag after panes are added to a resized row
Divider drags store sizes per group id, apart from the layout tree. A group
keeps its id when panes are added, so after resizing a row and splitting it
again the stored list had fewer entries than panes, and dividers past its end
could not move. Use the dragged sizes only while they have one entry per pane.

Closes #5893
2026-10-13 17:26:13 +02:00
5 changed files with 127 additions and 107 deletions
@@ -0,0 +1,101 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const DRAG_DISTANCE = -40;
function splitDividers(page: Page): Locator {
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
}
async function dividerX(divider: Locator): Promise<number> {
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
return box.x;
}
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + deltaX / 2, y, { steps: 4 });
await page.mouse.move(x + deltaX, y, { steps: 4 });
await page.mouse.up();
}
async function expectDividerFollowsDrag({
page,
index,
}: {
page: Page;
index: number;
}): Promise<void> {
const divider = splitDividers(page).nth(index);
const before = await dividerX(divider);
await dragDivider(divider, DRAG_DISTANCE);
await expect
.poll(async () => (await dividerX(divider)) - before, {
message: `divider ${index + 1} should follow the drag`,
})
.toBeLessThan(DRAG_DISTANCE * 0.75);
}
async function splitRight({
page,
expectedDividers,
}: {
page: Page;
expectedDividers: number;
}): Promise<void> {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(splitDividers(page)).toHaveCount(expectedDividers);
}
test.describe("Split pane resize", () => {
test("every divider drags in a four-pane row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-resize-fresh-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitRight({ page, expectedDividers: 1 });
await splitRight({ page, expectedDividers: 2 });
await splitRight({ page, expectedDividers: 3 });
for (const index of [0, 1, 2]) {
await expectDividerFollowsDrag({ page, index });
}
} finally {
await workspace.cleanup();
}
});
test("every divider drags after panes are added to a resized row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-resize-grown-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitRight({ page, expectedDividers: 1 });
await expectDividerFollowsDrag({ page, index: 0 });
await splitRight({ page, expectedDividers: 2 });
await splitRight({ page, expectedDividers: 3 });
for (const index of [0, 1, 2]) {
await expectDividerFollowsDrag({ page, index });
}
} finally {
await workspace.cleanup();
}
});
});
@@ -968,8 +968,12 @@ function SplitNodeView({
groupId ? state.splitSizesByWorkspace[workspaceKey]?.[groupId] : undefined,
);
const groupChildren = node.kind === "group" ? node.group.children : EMPTY_SPLIT_NODES;
// Dragged sizes live per group id, apart from the layout tree, and a group keeps its id when
// panes are added or removed. They only apply while they still have one entry per child;
// otherwise the dividers past the end of the list could not move.
const layoutGroupSizes = node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES;
const groupSizes =
storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES);
storedGroupSizes?.length === groupChildren.length ? storedGroupSizes : layoutGroupSizes;
const visibleFlex = useMemo(
() => resolveVisibleGroupFlex(groupChildren, groupSizes, maximizedPaneId),
[groupChildren, groupSizes, maximizedPaneId],
@@ -12,7 +12,6 @@ interface QueryMock {
close: ReturnType<typeof vi.fn>;
setPermissionMode: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
applyFlagSettings: ReturnType<typeof vi.fn>;
supportedModels: ReturnType<typeof vi.fn>;
supportedCommands: ReturnType<typeof vi.fn>;
rewindFiles: ReturnType<typeof vi.fn>;
@@ -149,7 +148,6 @@ function createScriptedQuery(params: {
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
applyFlagSettings: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
@@ -278,64 +276,6 @@ afterEach(() => {
queryFactory.mockReset();
});
async function createThinkingRestartSession() {
const queries: ScriptedQuery[] = [];
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = createScriptedQuery({
prompt,
sessionId: "thinking-restart-session",
handlePrompt: ({ query }) => query.emit(buildSuccessResult("thinking-restart-session")),
});
queries.push(scriptedQuery);
return scriptedQuery;
});
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
return { session, queries };
}
test("submits after a thinking change before the first turn", async () => {
const { session, queries } = await createThinkingRestartSession();
try {
await session.setThinkingOption!("medium");
const events = await collectUntilTerminal(
streamSession(session, "continue", { clientMessageId: "client-continue" }),
);
expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
expect(events.at(-1)?.type).toBe("turn_completed");
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queries[0].prompts.map((prompt) => prompt.text)).toEqual(["continue"]);
} finally {
await session.close();
}
});
test("submits after a thinking change once the previous query ended", async () => {
const { session, queries } = await createThinkingRestartSession();
try {
await collectUntilTerminal(streamSession(session, "first prompt"));
queries[0].end();
// Let the pump retire the old query before changing thinking.
await new Promise((resolve) => setTimeout(resolve, 0));
await session.setThinkingOption!("medium");
const events = await collectUntilTerminal(
streamSession(session, "continue", { clientMessageId: "client-continue" }),
);
expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
expect(events.at(-1)?.type).toBe("turn_completed");
expect(queryFactory).toHaveBeenCalledTimes(2);
expect(queries[1].prompts.map((prompt) => prompt.text)).toEqual(["continue"]);
} finally {
await session.close();
}
});
test("interrupt only calls query.interrupt and leaves the query open", async () => {
const logger = createTestLogger();
const queries: ScriptedQuery[] = [];
@@ -3275,9 +3275,6 @@ class ClaudeAgentSession implements AgentSession {
}
}
// A fresh query already uses the current settings, even when there was no old query to retire.
this.queryRestartNeeded = false;
// Preserve claudeSessionId across query recreation so buildOptions() passes
// resume: sessionId and the new query continues the existing conversation.
this.persistence = null;
@@ -3880,12 +3877,11 @@ class ClaudeAgentSession implements AgentSession {
}
private startQueryPump(): void {
if (this.closed || this.queryPumpPromise || !this.query) {
if (this.closed || this.queryPumpPromise) {
return;
}
// Drain the initialized query; a pending settings change belongs to the next turn.
const pump = this.runQueryPump(this.query).catch((error) => {
const pump = this.runQueryPump().catch((error) => {
this.logger.trace(
{
agentId: this.agentId,
@@ -3906,7 +3902,25 @@ class ClaudeAgentSession implements AgentSession {
});
}
private async runQueryPump(activeQuery: Query): Promise<void> {
private async runQueryPump(): Promise<void> {
let activeQuery: Query;
try {
activeQuery = await this.ensureQuery();
} catch (error) {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.init_failed",
);
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
return;
}
let consecutiveInterruptAbortRecoveries = 0;
const logRawMessage = (message: SDKMessage): void => {
this.logger.trace(
@@ -115,43 +115,4 @@ describe("daemon E2E (real claude) - thinking effort memory", () => {
rmSync(cwd, { recursive: true, force: true });
}
}, 420_000);
test("a thinking change before the first turn does not fail that turn", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: createRealProviderClients(["claude"], logger),
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const modelList = await client.listProviderModels("claude", { cwd });
const model = modelList.models.find(modelHasLowThinkingOption);
if (!model) {
throw new Error("No Claude Sonnet model with low thinking effort returned");
}
const agent = await client.createAgent({
cwd,
title: "claude-thinking-before-first-turn-real",
...getRealProviderConfig("claude"),
model: model.id,
});
await client.setAgentThinkingOption(agent.id, "low");
await client.sendMessage(agent.id, "Reply exactly: ACK_FIRST");
const finish = await client.waitForFinish(agent.id, 180_000);
expect(finish.final?.lastError).toBeUndefined();
expect(finish.status).toBe("idle");
expect(compactText(await getAssistantText(client, agent.id))).toContain("ack_first");
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
});