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
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 @@ -694,6 +694,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 @@ -1675,6 +1836,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
Loading