Bläddra i källkod

fix(app): preserve composer caret after requests (#36503)

Luke Parker 1 månad sedan
förälder
incheckning
a625d35f7b

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

@@ -1,6 +1,7 @@
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { expect, test, type Page } from "@playwright/test"
 import { expect, test, type Page } from "@playwright/test"
 import { mockOpenCodeServer } from "../utils/mock-server"
 import { mockOpenCodeServer } from "../utils/mock-server"
+import { installSseTransport } from "../utils/sse-transport"
 import { expectSessionTitle } from "../utils/waits"
 import { expectSessionTitle } from "../utils/waits"
 
 
 const directory = "C:/OpenCode/RequestDocks"
 const directory = "C:/OpenCode/RequestDocks"
@@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => {
   expect(request.postDataJSON()).toEqual({ response: "once" })
   expect(request.postDataJSON()).toEqual({ response: "once" })
 })
 })
 
 
+test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
+  const transport = await installSseTransport(page, {
+    server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
+    retry: 20,
+  })
+  await mockServer(page, { questions: [] })
+  await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+  await transport.waitForConnection()
+  await expectSessionTitle(page, title)
+
+  const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
+  const draft = "keep the caret at the end"
+  await editor.fill(draft)
+  await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
+  for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
+  const cursor = draft.length - 4
+  await expect
+    .poll(() =>
+      editor.evaluate((element) => {
+        const selection = window.getSelection()
+        if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
+        const range = selection.getRangeAt(0).cloneRange()
+        range.selectNodeContents(element)
+        range.setEnd(selection.anchorNode!, selection.anchorOffset)
+        return range.toString().length
+      }),
+    )
+    .toBe(cursor)
+  await transport.send({
+    directory,
+    payload: {
+      type: "question.asked",
+      properties: {
+        id: "question-caret",
+        sessionID,
+        questions: [
+          {
+            header: "Continue",
+            question: "Continue?",
+            options: [{ label: "Yes", description: "Continue the session" }],
+          },
+        ],
+        tool: { messageID: "message-caret", callID: "call-caret" },
+      },
+    },
+  })
+  const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
+  await expect(question).toBeVisible()
+  await expect(editor).toHaveCount(0)
+
+  await transport.send({
+    directory,
+    payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
+  })
+  await expect(question).toHaveCount(0)
+  await expect(editor).toBeVisible()
+  await page.keyboard.press("x")
+
+  await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
+})
+
 async function mockServer(
 async function mockServer(
   page: Page,
   page: Page,
   requests: {
   requests: {

+ 3 - 1
packages/app/src/components/prompt-input.tsx

@@ -633,7 +633,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
   const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
 
 
   const handleBlur = () => {
   const handleBlur = () => {
-    savedCursor = currentCursor()
+    const cursor = currentCursor()
+    savedCursor = cursor
+    if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
     closePopover()
     closePopover()
     setComposing(false)
     setComposing(false)
   }
   }

+ 6 - 1
packages/app/src/pages/session.tsx

@@ -58,6 +58,8 @@ import { useSync } from "@/context/sync"
 import { useTabs } from "@/context/tabs"
 import { useTabs } from "@/context/tabs"
 import { TerminalProvider, useTerminal } from "@/context/terminal"
 import { TerminalProvider, useTerminal } from "@/context/terminal"
 import { PromptInput } from "@/components/prompt-input"
 import { PromptInput } from "@/components/prompt-input"
+import { setCursorPosition } from "@/components/prompt-input/editor-dom"
+import { promptLength } from "@/components/prompt-input/history"
 import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
 import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
 import {
 import {
   createPromptInputController,
   createPromptInputController,
@@ -1054,7 +1056,10 @@ export default function Page() {
 
 
     if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
     if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
       if (composer.blocked() || isChildSession()) return
       if (composer.blocked() || isChildSession()) return
-      inputRef?.focus()
+      const input = inputRef
+      if (!input) return
+      input.focus()
+      setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current()))
     }
     }
   }
   }