瀏覽代碼

fix(tui): stabilize dialog action focus

Track focused footer actions by command instead of re-reading the reactive action list from footer rendering. This avoids the session selector mount cycle that could expose an undefined action-list memo and crash in indexOf.

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Aiden Cline 1 月之前
父節點
當前提交
b7a3671b30
共有 2 個文件被更改,包括 51 次插入13 次删除
  1. 14 12
      packages/tui/src/ui/dialog-select.tsx
  2. 37 1
      packages/tui/test/cli/tui/dialog-select.test.tsx

+ 14 - 12
packages/tui/src/ui/dialog-select.tsx

@@ -106,7 +106,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     filter: "",
     input: "keyboard" as "keyboard" | "mouse",
   })
-  const [focusedAction, setFocusedAction] = createSignal<number>()
+  const [focusedAction, setFocusedAction] = createSignal<string>()
   const actionFocused = createMemo(() => focusedAction() !== undefined)
   let selection: { value: T; category?: string } | undefined
   let resetSelection = false
@@ -162,8 +162,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   )
 
   createEffect(() => {
-    const index = focusedAction()
-    if (index !== undefined && index >= actionItems().length) setFocusedAction(undefined)
+    const command = focusedAction()
+    if (command && !actionItems().some((item) => item.command === command)) setFocusedAction(undefined)
   })
 
   const filtered = createMemo(() => {
@@ -363,9 +363,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   function submit() {
     if (props.locked) return
     setStore("input", "keyboard")
-    const index = focusedAction()
-    if (index !== undefined) {
-      trigger(actionItems()[index])
+    const command = focusedAction()
+    if (command) {
+      trigger(actionItems().find((item) => item.command === command))
       return
     }
     const option = selected()
@@ -376,12 +376,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
 
   function moveAction(direction: 1 | -1) {
     if (props.locked) return
-    const total = actionItems().length
-    if (total === 0) return
-    setFocusedAction((index) => {
-      if (index === undefined) return direction === 1 ? 0 : total - 1
+    const items = actionItems()
+    if (items.length === 0) return
+    setFocusedAction((command) => {
+      if (!command) return items[direction === 1 ? 0 : items.length - 1].command
+      const index = items.findIndex((item) => item.command === command)
+      if (index === -1) return items[direction === 1 ? 0 : items.length - 1].command
       const next = index + direction
-      return next < 0 || next >= total ? undefined : next
+      return next < 0 || next >= items.length ? undefined : items[next].command
     })
   }
 
@@ -537,7 +539,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   function isActionFocused(item: VisibleAction) {
     if (props.locked) return false
     if (!isActionItem(item)) return false
-    return actionItems().indexOf(item) === focusedAction()
+    return item.command === focusedAction()
   }
 
   function FooterAction(action: { item: VisibleAction }) {

+ 37 - 1
packages/tui/test/cli/tui/dialog-select.test.tsx

@@ -104,14 +104,19 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
 
   const selected: string[] = []
   const moved: string[] = []
+  const globals: number[] = []
+  const rows: string[] = []
   let replaceOptions!: (options: DialogSelectOption<string>[]) => void
+  let disableRow!: () => void
 
   function Harness() {
     const renderer = useRenderer()
     const keymap = createDefaultOpenTuiKeymap(renderer)
     const off = registerOpencodeKeymap(keymap, renderer, config)
     const [options, setOptions] = createSignal(initial)
+    const [rowEnabled, setRowEnabled] = createSignal(true)
     replaceOptions = setOptions
+    disableRow = () => setRowEnabled(false)
     onCleanup(off)
 
     function Fixture() {
@@ -123,6 +128,20 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
             options={options()}
             onMove={(option) => moved.push(option.value)}
             onSelect={(option) => selected.push(option.value)}
+            actions={[
+              {
+                command: "dialog.move_session.delete",
+                title: "delete",
+                disabled: !rowEnabled(),
+                onTrigger: (option) => rows.push(option.value),
+              },
+              {
+                command: "dialog.move_session.new",
+                title: "new",
+                selection: "none",
+                onTrigger: () => globals.push(1),
+              },
+            ]}
           />
         )),
       )
@@ -150,7 +169,7 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
   app.renderer.start()
   await app.waitForFrame((frame) => frame.includes("Mutable options"))
   await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
-  return { app, moved, replaceOptions, selected }
+  return { app, disableRow, globals, moved, replaceOptions, rows, selected }
 }
 
 test("dialog actions run without options while row actions still require a selection", async () => {
@@ -201,6 +220,23 @@ test("footer actions run when filtering leaves no selected row", async () => {
   }
 })
 
+test("does not move focus when the focused action becomes disabled", async () => {
+  await using tmp = await tmpdir()
+  const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }])
+
+  try {
+    select.app.mockInput.pressTab()
+    select.disableRow()
+    select.app.mockInput.pressEnter()
+
+    expect(select.globals).toEqual([])
+    expect(select.rows).toEqual([])
+    expect(select.selected).toEqual(["alpha"])
+  } finally {
+    select.app.renderer.destroy()
+  }
+})
+
 test("row actions receive the selected option", async () => {
   await using tmp = await tmpdir()
   const rows: string[] = []