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
316 changes: 315 additions & 1 deletion apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type ProjectId,
type ServerConfig,
type ThreadId,
type TurnId,
type WsWelcomePayload,
WS_CHANNELS,
WS_METHODS,
Expand Down Expand Up @@ -79,6 +80,20 @@ const DEFAULT_VIEWPORT: ViewportSpec = {
textTolerancePx: 44,
attachmentTolerancePx: 56,
};
const WIDE_FOOTER_VIEWPORT: ViewportSpec = {
name: "wide-footer",
width: 1_400,
height: 1_100,
textTolerancePx: 44,
attachmentTolerancePx: 56,
};
const COMPACT_FOOTER_VIEWPORT: ViewportSpec = {
name: "compact-footer",
width: 430,
height: 932,
textTolerancePx: 56,
attachmentTolerancePx: 56,
};
const TEXT_VIEWPORT_MATRIX = [
DEFAULT_VIEWPORT,
{ name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 },
Expand All @@ -102,6 +117,7 @@ interface MountedChatView {
cleanup: () => Promise<void>;
measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>;
setViewport: (viewport: ViewportSpec) => Promise<void>;
setContainerSize: (viewport: Pick<ViewportSpec, "width" | "height">) => Promise<void>;
router: ReturnType<typeof getRouter>;
}

Expand Down Expand Up @@ -513,6 +529,114 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
};
}

function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-pending-input-target" as MessageId,
targetText: "question thread",
});

return {
...snapshot,
threads: snapshot.threads.map((thread) =>
thread.id === THREAD_ID
? Object.assign({}, thread, {
interactionMode: "plan",
activities: [
{
id: EventId.makeUnsafe("activity-user-input-requested"),
tone: "info",
kind: "user-input.requested",
summary: "User input requested",
payload: {
requestId: "req-browser-user-input",
questions: [
{
id: "scope",
header: "Scope",
question: "What should this change cover?",
options: [
{
label: "Tight",
description: "Touch only the footer layout logic.",
},
{
label: "Broad",
description: "Also adjust the related composer controls.",
},
],
},
{
id: "risk",
header: "Risk",
question: "How aggressive should the imaginary plan be?",
options: [
{
label: "Conservative",
description: "Favor reliability and low-risk changes.",
},
{
label: "Balanced",
description: "Mix quick wins with one structural improvement.",
},
],
},
],
},
turnId: null,
sequence: 1,
createdAt: isoAt(1_000),
},
],
updatedAt: isoAt(1_000),
})
: thread,
),
};
}

function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-plan-follow-up-target" as MessageId,
targetText: "plan follow-up thread",
});

return {
...snapshot,
threads: snapshot.threads.map((thread) =>
thread.id === THREAD_ID
? Object.assign({}, thread, {
interactionMode: "plan",
latestTurn: {
turnId: "turn-plan-follow-up" as TurnId,
state: "completed",
requestedAt: isoAt(1_000),
startedAt: isoAt(1_001),
completedAt: isoAt(1_010),
assistantMessageId: null,
},
proposedPlans: [
{
id: "plan-follow-up-browser-test",
turnId: "turn-plan-follow-up" as TurnId,
planMarkdown: "# Follow-up plan\n\n- Keep the composer footer stable on resize.",
implementedAt: null,
implementationThreadId: null,
createdAt: isoAt(1_002),
updatedAt: isoAt(1_003),
},
],
session: {
...thread.session,
status: "ready",
updatedAt: isoAt(1_010),
},
updatedAt: isoAt(1_010),
})
: thread,
),
};
}

function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
const customResult = customWsRpcResolver?.(body);
if (customResult !== undefined) {
Expand Down Expand Up @@ -694,13 +818,76 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
);
}

async function waitForComposerMenuItem(itemId: string): Promise<HTMLElement> {
return waitForElement(
() => document.querySelector<HTMLElement>(`[data-composer-item-id="${itemId}"]`),
`Unable to find composer menu item "${itemId}".`,
);
}

async function waitForSendButton(): Promise<HTMLButtonElement> {
return waitForElement(
() => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'),
"Unable to find send button.",
);
}

function findComposerProviderModelPicker(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>('[data-chat-provider-model-picker="true"]');
}

function findButtonByText(text: string): HTMLButtonElement | null {
return (Array.from(document.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === text,
) ?? null) as HTMLButtonElement | null;
}

async function waitForButtonByText(text: string): Promise<HTMLButtonElement> {
return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`);
}

function findButtonContainingText(text: string): HTMLButtonElement | null {
return (Array.from(document.querySelectorAll("button")).find((button) =>
button.textContent?.includes(text),
) ?? null) as HTMLButtonElement | null;
}

async function waitForButtonContainingText(text: string): Promise<HTMLButtonElement> {
return waitForElement(
() => findButtonContainingText(text),
`Unable to find button containing "${text}".`,
);
}

async function expectComposerActionsContained(): Promise<void> {
const footer = await waitForElement(
() => document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'),
"Unable to find composer footer.",
);
const actions = await waitForElement(
() => document.querySelector<HTMLElement>('[data-chat-composer-actions="right"]'),
"Unable to find composer actions container.",
);

await vi.waitFor(
() => {
const footerRect = footer.getBoundingClientRect();
const actionButtons = Array.from(actions.querySelectorAll<HTMLButtonElement>("button"));
expect(actionButtons.length).toBeGreaterThanOrEqual(1);

const buttonRects = actionButtons.map((button) => button.getBoundingClientRect());
const firstTop = buttonRects[0]?.top ?? 0;

for (const rect of buttonRects) {
expect(rect.right).toBeLessThanOrEqual(footerRect.right + 0.5);
expect(rect.bottom).toBeLessThanOrEqual(footerRect.bottom + 0.5);
expect(Math.abs(rect.top - firstTop)).toBeLessThanOrEqual(1.5);
}
},
{ timeout: 8_000, interval: 16 },
);
}

async function waitForInteractionModeButton(
expectedLabel: "Chat" | "Plan",
): Promise<HTMLButtonElement> {
Expand Down Expand Up @@ -864,7 +1051,8 @@ async function mountChatView(options: {

const host = document.createElement("div");
host.style.position = "fixed";
host.style.inset = "0";
host.style.top = "0";
host.style.left = "0";
host.style.width = "100vw";
host.style.height = "100vh";
host.style.display = "grid";
Expand Down Expand Up @@ -897,6 +1085,11 @@ async function mountChatView(options: {
await setViewport(viewport);
await waitForProductionStyles();
},
setContainerSize: async (viewport) => {
host.style.width = `${viewport.width}px`;
host.style.height = `${viewport.height}px`;
await waitForLayout();
},
router,
};
}
Expand Down Expand Up @@ -2341,4 +2534,125 @@ describe("ChatView timeline estimator parity (full app)", () => {
await mounted.cleanup();
}
});

it("keeps pending-question footer actions inside the composer after a real resize", async () => {
const mounted = await mountChatView({
viewport: WIDE_FOOTER_VIEWPORT,
snapshot: createSnapshotWithPendingUserInput(),
});

try {
const firstOption = await waitForButtonContainingText("Tight");
firstOption.click();

await waitForButtonByText("Previous");
await waitForButtonByText("Submit answers");

await mounted.setContainerSize(COMPACT_FOOTER_VIEWPORT);
await expectComposerActionsContained();
} finally {
await mounted.cleanup();
}
});

it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => {
const mounted = await mountChatView({
viewport: WIDE_FOOTER_VIEWPORT,
snapshot: createSnapshotWithPlanFollowUpPrompt(),
});

try {
const footer = await waitForElement(
() => document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'),
"Unable to find composer footer.",
);
const initialModelPicker = await waitForElement(
findComposerProviderModelPicker,
"Unable to find provider model picker.",
);
const initialModelPickerOffset =
initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left;

await waitForButtonByText("Implement");
await waitForElement(
() =>
document.querySelector<HTMLButtonElement>('button[aria-label="Implementation actions"]'),
"Unable to find implementation actions trigger.",
);

await mounted.setContainerSize({
width: 440,
height: WIDE_FOOTER_VIEWPORT.height,
});
await expectComposerActionsContained();

const implementButton = await waitForButtonByText("Implement");
const implementActionsButton = await waitForElement(
() =>
document.querySelector<HTMLButtonElement>('button[aria-label="Implementation actions"]'),
"Unable to find implementation actions trigger.",
);

await vi.waitFor(
() => {
const implementRect = implementButton.getBoundingClientRect();
const implementActionsRect = implementActionsButton.getBoundingClientRect();
const compactModelPicker = findComposerProviderModelPicker();
expect(compactModelPicker).toBeTruthy();

const compactModelPickerOffset =
compactModelPicker!.getBoundingClientRect().left - footer.getBoundingClientRect().left;

expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1);
expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1);
expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual(
1,
);
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("keeps the slash-command menu visible above the composer", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-command-menu-target" as MessageId,
targetText: "command menu thread",
}),
});

try {
await waitForComposerEditor();
await page.getByTestId("composer-editor").fill("/");

const menuItem = await waitForComposerMenuItem("slash:model");
const composerForm = await waitForElement(
() => document.querySelector<HTMLElement>('[data-chat-composer-form="true"]'),
"Unable to find composer form.",
);

await vi.waitFor(
() => {
const menuRect = menuItem.getBoundingClientRect();
const composerRect = composerForm.getBoundingClientRect();
const hitTarget = document.elementFromPoint(
menuRect.left + menuRect.width / 2,
menuRect.top + menuRect.height / 2,
);

expect(menuRect.width).toBeGreaterThan(0);
expect(menuRect.height).toBeGreaterThan(0);
expect(menuRect.bottom).toBeLessThanOrEqual(composerRect.bottom);
expect(hitTarget instanceof Element && menuItem.contains(hitTarget)).toBe(true);
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});
});
Loading
Loading