Skip to content
310 changes: 310 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,167 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
);
}

async function pressComposerKey(key: string): Promise<void> {
const composerEditor = await waitForComposerEditor();
composerEditor.focus();
const keydownEvent = new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
});
composerEditor.dispatchEvent(keydownEvent);
if (keydownEvent.defaultPrevented) {
await waitForLayout();
return;
}

const beforeInputEvent = new InputEvent("beforeinput", {
data: key,
inputType: "insertText",
bubbles: true,
cancelable: true,
});
composerEditor.dispatchEvent(beforeInputEvent);
if (beforeInputEvent.defaultPrevented) {
await waitForLayout();
return;
}

if (
typeof document.execCommand === "function" &&
document.execCommand("insertText", false, key)
) {
await waitForLayout();
return;
}

const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
throw new Error("Unable to resolve composer selection for text input.");
}
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(key);
range.insertNode(textNode);
range.setStartAfter(textNode);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
composerEditor.dispatchEvent(
new InputEvent("input", {
data: key,
inputType: "insertText",
bubbles: true,
}),
);
await waitForLayout();
}

async function waitForComposerText(expectedText: string): Promise<void> {
await vi.waitFor(
() => {
expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt ?? "").toBe(
expectedText,
);
},
{ timeout: 8_000, interval: 16 },
);
}

async function setComposerSelectionByTextOffsets(options: {
start: number;
end: number;
direction?: "forward" | "backward";
}): Promise<void> {
const composerEditor = await waitForComposerEditor();
composerEditor.focus();
const resolvePoint = (targetOffset: number) => {
const traversedRef = { value: 0 };

const visitNode = (node: Node): { node: Node; offset: number } | null => {
if (node.nodeType === Node.TEXT_NODE) {
const textLength = node.textContent?.length ?? 0;
if (targetOffset <= traversedRef.value + textLength) {
return {
node,
offset: Math.max(0, Math.min(targetOffset - traversedRef.value, textLength)),
};
}
traversedRef.value += textLength;
return null;
}

if (node instanceof HTMLBRElement) {
const parent = node.parentNode;
if (!parent) {
return null;
}
const siblingIndex = Array.prototype.indexOf.call(parent.childNodes, node);
if (targetOffset <= traversedRef.value) {
return { node: parent, offset: siblingIndex };
}
if (targetOffset <= traversedRef.value + 1) {
return { node: parent, offset: siblingIndex + 1 };
}
traversedRef.value += 1;
return null;
}

if (node instanceof Element || node instanceof DocumentFragment) {
for (const child of node.childNodes) {
const point = visitNode(child);
if (point) {
return point;
}
}
}

return null;
};

return (
visitNode(composerEditor) ?? {
node: composerEditor,
offset: composerEditor.childNodes.length,
}
);
};

const startPoint = resolvePoint(options.start);
const endPoint = resolvePoint(options.end);
const selection = window.getSelection();
if (!selection) {
throw new Error("Unable to resolve window selection.");
}
selection.removeAllRanges();

if (options.direction === "backward" && "setBaseAndExtent" in selection) {
selection.setBaseAndExtent(endPoint.node, endPoint.offset, startPoint.node, startPoint.offset);
await waitForLayout();
return;
}

const range = document.createRange();
range.setStart(startPoint.node, startPoint.offset);
range.setEnd(endPoint.node, endPoint.offset);
selection.addRange(range);
await waitForLayout();
}

async function selectAllComposerContent(): Promise<void> {
const composerEditor = await waitForComposerEditor();
composerEditor.focus();
const selection = window.getSelection();
if (!selection) {
throw new Error("Unable to resolve window selection.");
}
selection.removeAllRanges();
const range = document.createRange();
range.selectNodeContents(composerEditor);
selection.addRange(range);
await waitForLayout();
}

async function waitForSendButton(): Promise<HTMLButtonElement> {
return waitForElement(
() => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'),
Expand Down Expand Up @@ -1582,6 +1743,155 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-surround-basic" as MessageId,
targetText: "surround basic",
}),
});

try {
useComposerDraftStore.getState().setPrompt(THREAD_ID, "selected");
await waitForComposerText("selected");
await setComposerSelectionByTextOffsets({ start: 0, end: "selected".length });
await pressComposerKey("(");
await waitForComposerText("(selected)");

await pressComposerKey("[");
await waitForComposerText("([selected])");
} finally {
await mounted.cleanup();
}
});

it("leaves collapsed-caret typing unchanged for surround symbols", async () => {
useComposerDraftStore.getState().setPrompt(THREAD_ID, "selected");

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-surround-collapsed" as MessageId,
targetText: "surround collapsed",
}),
});

try {
await waitForComposerText("selected");
await setComposerSelectionByTextOffsets({
start: "selected".length,
end: "selected".length,
});
await pressComposerKey("(");
await waitForComposerText("selected(");
} finally {
await mounted.cleanup();
}
});

it("supports symmetric and backward-selection surrounds", async () => {
useComposerDraftStore.getState().setPrompt(THREAD_ID, "backward");

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-surround-backward" as MessageId,
targetText: "surround backward",
}),
});

try {
await waitForComposerText("backward");
await setComposerSelectionByTextOffsets({
start: 0,
end: "backward".length,
direction: "backward",
});
await pressComposerKey("*");
await waitForComposerText("*backward*");
} finally {
await mounted.cleanup();
}
});

it("supports option-produced surround symbols like guillemets", async () => {
useComposerDraftStore.getState().setPrompt(THREAD_ID, "quoted");

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-surround-guillemet" as MessageId,
targetText: "surround guillemet",
}),
});

try {
await waitForComposerText("quoted");
await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length });
await pressComposerKey("«");
await waitForComposerText("«quoted»");
} finally {
await mounted.cleanup();
}
});

it("surrounds text after a mention using the correct expanded offsets", async () => {
useComposerDraftStore.getState().setPrompt(THREAD_ID, "hi @package.json there");

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-surround-after-mention" as MessageId,
targetText: "surround after mention",
}),
});

try {
await vi.waitFor(
() => {
expect(document.body.textContent).toContain("package.json");
},
{ timeout: 8_000, interval: 16 },
);
await waitForComposerText("hi @package.json there");
await setComposerSelectionByTextOffsets({
start: "hi package.json ".length,
end: "hi package.json there".length,
});
await pressComposerKey("(");
await waitForComposerText("hi @package.json (there)");
} finally {
await mounted.cleanup();
}
});

it("falls back to normal replacement when the selection includes a mention token", async () => {
useComposerDraftStore.getState().setPrompt(THREAD_ID, "hi @package.json there ");

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-surround-token" as MessageId,
targetText: "surround token",
}),
});

try {
await vi.waitFor(
() => {
expect(document.body.textContent).toContain("package.json");
},
{ timeout: 8_000, interval: 16 },
);
await selectAllComposerContent();
await pressComposerKey("(");
await waitForComposerText("(");
} finally {
await mounted.cleanup();
}
});

it("keeps removed terminal context pills removed when a new one is added", async () => {
const removedLabel = "Terminal 1 lines 1-2";
const addedLabel = "Terminal 2 lines 9-10";
Expand Down
Loading