Skip to content

Commit a625d35

Browse files
authored
fix(app): preserve composer caret after requests (anomalyco#36503)
1 parent 449c649 commit a625d35

3 files changed

Lines changed: 71 additions & 2 deletions

File tree

packages/app/e2e/regression/session-request-docks.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { base64Encode } from "@opencode-ai/core/util/encode"
22
import { expect, test, type Page } from "@playwright/test"
33
import { mockOpenCodeServer } from "../utils/mock-server"
4+
import { installSseTransport } from "../utils/sse-transport"
45
import { expectSessionTitle } from "../utils/waits"
56

67
const directory = "C:/OpenCode/RequestDocks"
@@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => {
100101
expect(request.postDataJSON()).toEqual({ response: "once" })
101102
})
102103

104+
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
105+
const transport = await installSseTransport(page, {
106+
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
107+
retry: 20,
108+
})
109+
await mockServer(page, { questions: [] })
110+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
111+
await transport.waitForConnection()
112+
await expectSessionTitle(page, title)
113+
114+
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
115+
const draft = "keep the caret at the end"
116+
await editor.fill(draft)
117+
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
118+
for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
119+
const cursor = draft.length - 4
120+
await expect
121+
.poll(() =>
122+
editor.evaluate((element) => {
123+
const selection = window.getSelection()
124+
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
125+
const range = selection.getRangeAt(0).cloneRange()
126+
range.selectNodeContents(element)
127+
range.setEnd(selection.anchorNode!, selection.anchorOffset)
128+
return range.toString().length
129+
}),
130+
)
131+
.toBe(cursor)
132+
await transport.send({
133+
directory,
134+
payload: {
135+
type: "question.asked",
136+
properties: {
137+
id: "question-caret",
138+
sessionID,
139+
questions: [
140+
{
141+
header: "Continue",
142+
question: "Continue?",
143+
options: [{ label: "Yes", description: "Continue the session" }],
144+
},
145+
],
146+
tool: { messageID: "message-caret", callID: "call-caret" },
147+
},
148+
},
149+
})
150+
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
151+
await expect(question).toBeVisible()
152+
await expect(editor).toHaveCount(0)
153+
154+
await transport.send({
155+
directory,
156+
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
157+
})
158+
await expect(question).toHaveCount(0)
159+
await expect(editor).toBeVisible()
160+
await page.keyboard.press("x")
161+
162+
await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
163+
})
164+
103165
async function mockServer(
104166
page: Page,
105167
requests: {

packages/app/src/components/prompt-input.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
633633
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
634634

635635
const handleBlur = () => {
636-
savedCursor = currentCursor()
636+
const cursor = currentCursor()
637+
savedCursor = cursor
638+
if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
637639
closePopover()
638640
setComposing(false)
639641
}

packages/app/src/pages/session.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ import { useSync } from "@/context/sync"
5858
import { useTabs } from "@/context/tabs"
5959
import { TerminalProvider, useTerminal } from "@/context/terminal"
6060
import { PromptInput } from "@/components/prompt-input"
61+
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
62+
import { promptLength } from "@/components/prompt-input/history"
6163
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
6264
import {
6365
createPromptInputController,
@@ -1054,7 +1056,10 @@ export default function Page() {
10541056

10551057
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
10561058
if (composer.blocked() || isChildSession()) return
1057-
inputRef?.focus()
1059+
const input = inputRef
1060+
if (!input) return
1061+
input.focus()
1062+
setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current()))
10581063
}
10591064
}
10601065

0 commit comments

Comments
 (0)