Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5a262f9
feat(web): add surround selection in composer
mbuvarp Mar 28, 2026
3e18743
fix(web): avoid duplicate composer input listeners
mbuvarp Mar 28, 2026
77c605f
fix(web): support dead-key backtick surround
mbuvarp Mar 28, 2026
0bcabac
fix(web): clear stale dead-key surround state
mbuvarp Mar 28, 2026
f18468b
fix(web): align surround selection with mention offsets
mbuvarp Mar 28, 2026
c6f30df
refactor(web): remove unused composer selection snapshot fields
mbuvarp Mar 28, 2026
4dc6517
fix(web): preserve dead-key state during composition
mbuvarp Mar 28, 2026
99b6ea1
refactor(web): drop no-op composer beforeinput command
mbuvarp Mar 28, 2026
dcb7d6f
fix(web): support dead-key backtick across layouts
mbuvarp Mar 28, 2026
d401a9a
Merge branch 'main' into feature/surround-selection
mbuvarp Mar 30, 2026
832d8ee
fix(web): guard mention boundary surround selections
mbuvarp Mar 30, 2026
0ddcd97
Merge branch 'main' into feature/surround-selection
mbuvarp Mar 30, 2026
a9c6462
Merge upstream/main into feature/surround-selection
mbuvarp Apr 1, 2026
d6a207e
Merge remote-tracking branch 'origin/feature/surround-selection' into…
mbuvarp Apr 1, 2026
cfff926
Merge branch 'main' into feature/surround-selection
mbuvarp Apr 3, 2026
525a0c5
Merge branch 'main' into feature/surround-selection
mbuvarp Apr 7, 2026
754162b
Merge upstream/main into feature/surround-selection
mbuvarp Apr 9, 2026
7dd2475
fix(web): align surround browser tests with scoped drafts
mbuvarp Apr 9, 2026
521cc25
fix(web): detect mention boundaries after terminal placeholders
mbuvarp Apr 9, 2026
f0c0b6f
fix(web): narrow dead-key backtick tracking
mbuvarp Apr 9, 2026
25be906
Merge branch 'main' into feature/surround-selection
mbuvarp Apr 9, 2026
e45e44f
refactor(web): share prompt slice traversal
mbuvarp Apr 9, 2026
f3f5ad6
Merge branch 'main' into feature/surround-selection
mbuvarp Apr 9, 2026
248a7cc
Merge branch 'main' into feature/surround-selection
juliusmarminge Apr 9, 2026
4e68b88
Merge surround-selection history into one undo step
juliusmarminge Apr 10, 2026
8daa878
Merge branch 'main' into feature/surround-selection
juliusmarminge Apr 10, 2026
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
374 changes: 373 additions & 1 deletion apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -915,13 +915,189 @@ 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 pressComposerUndo(): Promise<void> {
const composerEditor = await waitForComposerEditor();
const useMetaForMod = isMacPlatform(navigator.platform);
composerEditor.focus();
composerEditor.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
metaKey: useMetaForMod,
ctrlKey: !useMetaForMod,
bubbles: true,
cancelable: true,
}),
);
await waitForLayout();
}

async function waitForComposerText(expectedText: string): Promise<void> {
await vi.waitFor(
() => {
expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.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 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"]'),
Expand Down Expand Up @@ -2336,6 +2512,202 @@ 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_REF, "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_REF, "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_REF, "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_REF, "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("supports dead-key composition that resolves to another surround symbol without an extra undo step", async () => {
useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted");

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

try {
await waitForComposerText("quoted");
await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length });
const composerEditor = await waitForComposerEditor();
composerEditor.focus();
composerEditor.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Dead",
bubbles: true,
cancelable: true,
}),
);
composerEditor.dispatchEvent(
new InputEvent("beforeinput", {
data: "'",
inputType: "insertCompositionText",
bubbles: true,
cancelable: true,
}),
);
const resolvedInputEvent = new InputEvent("beforeinput", {
data: "'",
inputType: "insertText",
bubbles: true,
cancelable: true,
});
composerEditor.dispatchEvent(resolvedInputEvent);
expect(resolvedInputEvent.defaultPrevented).toBe(true);
await waitForComposerText("'quoted'");
await pressComposerUndo();
await waitForComposerText("quoted");
} finally {
await mounted.cleanup();
}
});

it("surrounds text after a mention using the correct expanded offsets", async () => {
useComposerDraftStore.getState().setPrompt(THREAD_REF, "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_REF, "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("shows runtime mode descriptions in the desktop composer access select", async () => {
setDraftThreadWithoutWorktree();

Expand Down
Loading
Loading