Просмотр исходного кода

Merge pull request #1169 from deepseek-harness/worktree/directory-picker-path-editor

fix(directory-picker-browse): advertise the path editor and walk the panes with the draft
imccyu 1 месяц назад
Родитель
Сommit
8527ce23ae

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
-2026-07-28-directory-picker-capability-seam.md: 9884385cf9e0d51604bab9e4fd3c4bee77448331
-2026-07-28-directory-picker-capability-seam.zh.md: 8c229b9fb08d5052ba8a512f2153a89a9e5fd455
+2026-07-28-directory-picker-capability-seam.md: 01968990db81852dbf965a90fc151bab357ecb55
+2026-07-28-directory-picker-capability-seam.zh.md: ffbb939eabcca3e16711a4cadcadad50660a9e04

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md


+ 2 - 1
apps/web/tests/snapshots/workspace-management/directory-browser.expected.md

@@ -4,7 +4,8 @@
     - button "Home"
     - img
     - button "browse-golden"
-  - button "Edit path"
+  - button "Edit path":
+    - img
   - list:
     - listitem:
       - button "adopted":

+ 40 - 2
apps/web/tests/workspace-management.e2e.ts

@@ -1,6 +1,7 @@
 // Web e2e scenarios: workspace management — adding a workspace through the
 // composed directory dialog (its own New folder affordance is the product's
-// one creation route), same-basename directory adoption, the rename round
+// one creation route), the dialog's path editor walking the panes with the
+// typed draft, same-basename directory adoption, the rename round
 // trip over the real wire (workspace.rename RPC + durable registry), the
 // duplicate-name pre-check, the
 // flat "In one list" view with its persisted group-by preference, the session
@@ -12,7 +13,7 @@
 // seeded-history seed reused verbatim — no new recording).
 import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
-import { join } from 'node:path'
+import { join, sep } from 'node:path'
 import type { Browser, Locator, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
@@ -403,6 +404,43 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
+  it('walks the panes with the typed path: deeper past a separator, back up on erase, whole on a miss', async () => {
+    // The panes must track the draft without leaving the editor, so the
+    // typed text and what is listed under it never disagree.
+    // Staged by this scenario itself (mkdir is recursive and idempotent), so
+    // running it alone through -t sees the same tree the assertions describe.
+    const staged = join(scaffold.workspaceCwd, 'browse-golden')
+    await mkdir(join(staged, 'alpha', 'only-under-alpha'), { recursive: true })
+    await mkdir(join(staged, 'beta'), { recursive: true })
+    const dialog = await browseTo(staged)
+    await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+    await dialog.getByRole('button', { name: 'Edit path' }).click()
+    const path = dialog.getByLabel('Edit path')
+    // A directory part no pane lists: the panes walk to it, landing the
+    // ordinary two-pane Miller view (level | its children) with the editor
+    // still up and the draft intact.
+    await path.fill(`${join(staged, 'alpha')}${sep}`)
+    await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+    await expect.poll(() => dialog.getByRole('list').count(), { timeout: 10_000 }).toBe(2)
+    expect(await path.inputValue()).toBe(`${join(staged, 'alpha')}${sep}`)
+    // Erasing back past the separator walks the panes up, so the level being
+    // typed is the last pane again (its children no longer stand to its
+    // right) and the tail filters it.
+    await path.fill(`${staged}${sep}al`)
+    await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
+    expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1)
+    expect(await dialog.getByText('beta', { exact: true }).count()).toBe(0)
+    await expect.poll(() => dialog.getByRole('list').count(), { timeout: 10_000 }).toBe(2)
+    // A tail nobody matches is a name still being spelled: the level shows
+    // whole instead of emptying under it.
+    await path.fill(`${staged}${sep}zzz`)
+    await expect.poll(() => dialog.getByText('beta', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+    expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1)
+    await dialog.getByRole('button', { name: 'Cancel' }).click()
+    await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   /**
    * Expand Ungrouped and return its seeded session row. The only visible child
    * is the non-blank persisted Session; the blank Session created while

+ 2 - 2
packages/host/directory-picker-browse/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
-README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c
-README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f
+README.md: 62384cc0b0e5756e56c1d608252721c506a0915f
+README.zh.md: 8f495e1e4d87486d0565eadcbf7df694494c7096

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
packages/host/directory-picker-browse/README.md


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
packages/host/directory-picker-browse/README.zh.md


+ 51 - 8
packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css

@@ -49,11 +49,29 @@
   color: var(--dsw-alias-label-primary);
 }
 
+/* The bar IS the editor's box in both modes: it carries the rounded outline
+ * and the inner padding, the crumbs and the input sit inside it, and hovering
+ * the edit zone lights the whole row rather than the remainder right of the
+ * crumbs. The negative left margin pays back the border and padding, so the
+ * crumb (and input) text keeps the column the title sits in. */
 .crumbBar {
   display: flex;
   align-items: center;
   gap: 4px;
-  min-height: 20px;
+  box-sizing: border-box;
+  min-height: 24px;
+  margin-left: -9px;
+  padding: 0 8px;
+  border: 1px solid transparent;
+  border-radius: 8px;
+}
+
+/* Lit by the affordance the row belongs to, never by a crumb: a crumb's hover
+ * offers navigation, not path entry. Editing keeps the outline standing. */
+.crumbBar:has(.crumbEditZone:enabled:hover),
+.crumbBar:has(.crumbEditZone:focus-visible),
+.crumbBar:has(.pathInput) {
+  border-color: var(--dsw-alias-border-l2);
 }
 
 /* Deep chains scroll inside the trail (the effect pins the tail into view)
@@ -118,25 +136,50 @@
   color: var(--dsw-alias-label-tertiary);
 }
 
-/* The empty remainder of the bar: invisible, but a real click target that
- * flips the bar into path-edit mode. */
+/* The empty remainder of the bar: a real click target that flips the bar into
+ * path-edit mode. The pencil glyph seated at its right edge is the standing
+ * affordance; the outline the gesture lights belongs to the bar, so the whole
+ * row reads as the box the input will occupy. */
 .crumbEditZone {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
   flex: 1 0 34px;
   min-width: 34px;
-  align-self: stretch;
+  height: 22px;
+  padding: 0;
   border: none;
   background: transparent;
   cursor: text;
+  outline: none;
+}
+
+.crumbEditGlyph {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
 }
 
+.crumbEditZone:enabled:hover .crumbEditGlyph,
+.crumbEditZone:focus-visible .crumbEditGlyph {
+  color: var(--dsw-alias-label-primary);
+}
+
+.crumbEditZone:disabled {
+  cursor: default;
+}
+
+.crumbEditZone:disabled .crumbEditGlyph {
+  color: var(--dsw-alias-label-caption);
+}
+
+/* Chrome-free: the bar around it draws the box (border, radius, padding). */
 .pathInput {
   box-sizing: border-box;
   flex: 1 1 0;
   min-width: 0;
-  height: 24px;
-  padding: 0 8px;
-  border: 1px solid var(--dsw-alias-border-l2);
-  border-radius: 8px;
+  height: 22px;
+  padding: 0;
+  border: none;
   outline: none;
   background: transparent;
   font-size: 13px;

+ 257 - 50
packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx

@@ -18,15 +18,27 @@
  * owning flow decides what "Open" means and owns the workspace-creation
  * error surface. Hidden entries are host-flagged and hidden by default; the
  * footer's fixed-label "Show hidden files" toggle (aria-pressed, check when
- * on) reveals them (client-side only). The path editor opens seeded with a
- * trailing separator, and while the draft's directory part names a listed
- * level, its final segment prefix-filters that level's rows (a dot-led
- * prefix also reveals the hidden entries it names).
+ * on) reveals them (client-side only). The path editor announces itself with
+ * a pencil glyph and a bar-wide hover-lit outline, opens seeded with a
+ * trailing separator, and keeps the panes under the draft: the final segment
+ * prefix-filters the LAST pane while that pane's level is the one the draft's
+ * directory part names (a dot-led prefix also reveals the hidden entries it
+ * names, and a prefix nobody matches releases the filter), while any other
+ * directory part is scanned after a short debounce and lands like any other
+ * navigation — selection-anchored and two-pane away from the display root,
+ * both legs waited out so one keystroke moves the view once. The pane arity
+ * holds throughout: the last pane is the level the path names and the one
+ * beside it is its parent, so typing deeper descends and erasing segments
+ * walks back up, moving the Miller view without leaving the editor. Panes the
+ * draft walked to stay put when the editor closes (cancellation included):
+ * the crumbs name where the walk ended, and Open's fallback target follows
+ * them.
  */
 import { useCallback, useEffect, useRef, useState } from 'react'
 import clsx from 'clsx'
 import {
-  Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
+  Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16,
+  IconPlusOutline16, Modal,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
 import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
@@ -75,6 +87,15 @@ const SLOW_SCAN_DELAY_MS = 300
  */
 const PARENT_LEG_WAIT_MS = 200
 
+/**
+ * How long a typed draft rests before the panes follow it to a directory no
+ * pane lists. The window absorbs the keystrokes that walk through
+ * intermediate directory parts (every character of `/usr/lo` past the
+ * separator would otherwise be its own scan) while staying short enough that
+ * a pause reads as "the list moved with me".
+ */
+const DRAFT_PREVIEW_DEBOUNCE_MS = 250
+
 /**
  * Breadcrumb rows for display: inside the home subtree the chain starts at a
  * localized Home crumb; outside it the full ancestry shows, the root labeled
@@ -100,21 +121,89 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' {
   return listing.home.includes('\\') ? '\\' : '/'
 }
 
+/** The listed level as a directory part: its own path, separator-terminated (the root already is). */
+function levelDirectory(listing: DirectoryListing): string {
+  const sep = separatorOf(listing)
+  return listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
+}
+
+/** The directory text a draft-following scan last sent, with the level path the host answered it with. */
+interface ScannedDirectory {
+  /** The draft's directory part, verbatim as it went to the host. */
+  readonly directory: string
+  /** `path` of the listing that came back. */
+  readonly landed: string
+}
+
 /**
- * The path draft's final segment, when its directory part is exactly the
- * level `listing` lists — the segment the level prefix-filters on while the
- * user types. Any other draft (no separator yet, or naming some other
- * directory) leaves the level unfiltered. The directory part compares
- * exactly (it is the host's own path text, reached by seeding or erasing);
- * only the name filter downstream is case-insensitive.
+ * The draft's directory part — everything through its last separator — or
+ * null while no separator has been typed at all (nothing addresses a
+ * directory yet). The platform comes from `listing`: on Windows a forward
+ * slash separates too (the host's `resolve` accepts either), while on POSIX a
+ * backslash is a legal name character and never separates.
  */
-function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null {
-  if (draft === null) return null
-  const sep = separatorOf(listing)
-  const cut = draft.lastIndexOf(sep)
-  if (cut === -1) return null
-  const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
-  return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null
+function draftDirectory(listing: DirectoryListing, draft: string): string | null {
+  const cut = separatorOf(listing) === '\\'
+    ? Math.max(draft.lastIndexOf('\\'), draft.lastIndexOf('/'))
+    : draft.lastIndexOf('/')
+  return cut === -1 ? null : draft.slice(0, cut + 1)
+}
+
+/**
+ * How the draft reads against one level: the directory part it names, and —
+ * when `listing` is the level that directory part addresses — the final
+ * segment that prefix-filters it while the user types (case-insensitively,
+ * downstream). A level answers a directory part when its own path is that
+ * part, or when it is the level that very text just produced (`scanned`): the
+ * host resolves what it is given, so `..` segments and Windows forward
+ * slashes reach a level whose path spells the request differently.
+ * @param listing - the level to read the draft against.
+ * @param draft - the current path draft.
+ * @param scanned - the last draft-following scan's directory and landing.
+ * @returns the draft's directory part (null with no separator typed) and its
+ * filtering tail (null when this level does not answer that directory).
+ */
+function readDraft(
+  listing: DirectoryListing,
+  draft: string,
+  scanned: ScannedDirectory | null,
+): { directory: string | null; tail: string | null } {
+  const directory = draftDirectory(listing, draft)
+  if (directory === null) return { directory: null, tail: null }
+  const answers = directory === levelDirectory(listing)
+    || (scanned !== null && scanned.directory === directory && scanned.landed === listing.path)
+  return { directory, tail: answers ? draft.slice(directory.length) : null }
+}
+
+/**
+ * The rows one column renders. The selection is exempt from every filter: it
+ * anchors the two-pane view (crumbs and the child pane point at it), so
+ * neither the hidden filter after a dot-reveal pick nor a prefix miss may
+ * orphan it. A prefix narrows the level only while some row it would actually
+ * show matches — a tail nobody matches is a name being spelled, not a demand
+ * for an empty pane, so the level shows whole and its hidden rows return to
+ * obeying the toggle. Counting only displayable rows is what keeps that true:
+ * were a hidden row ever to match a prefix that does not reveal it (today
+ * `hidden` means dot-prefixed, so it cannot), the level would narrow to
+ * nothing.
+ */
+function visibleEntries(
+  entries: readonly DirectoryEntry[],
+  selectedPath: string | null,
+  showHidden: boolean,
+  filterPrefix: string | null,
+): readonly DirectoryEntry[] {
+  const needle = filterPrefix === null ? '' : filterPrefix.toLowerCase()
+  // A dot-led prefix names hidden entries explicitly, so matching ones
+  // surface even while the toggle keeps the rest hidden.
+  const displayable = (entry: DirectoryEntry): boolean => showHidden || !entry.hidden || needle.startsWith('.')
+  const matches = (entry: DirectoryEntry): boolean => displayable(entry) && entry.name.toLowerCase().startsWith(needle)
+  const narrowing = needle !== '' && entries.some(matches)
+  return entries.filter((entry) => {
+    if (entry.path === selectedPath) return true
+    if (narrowing) return matches(entry)
+    return showHidden || !entry.hidden
+  })
 }
 
 /** One column of folder rows (the Miller view renders one or two of these). */
@@ -127,16 +216,7 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr
   filterPrefix: string | null
   pathEditing: boolean
 }) {
-  const visible = entries.filter((entry) => {
-    // The selection is exempt from both filters: it anchors the two-pane
-    // view (crumbs and the child pane point at it), so neither the hidden
-    // filter after a dot-reveal pick nor a prefix miss may orphan it.
-    if (entry.path === selectedPath) return true
-    if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false
-    // A dot-led prefix names hidden entries explicitly, so matching ones
-    // surface even while the toggle keeps the rest hidden.
-    return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true
-  })
+  const visible = visibleEntries(entries, selectedPath, showHidden, filterPrefix)
   return (
     <div className={css.column} role="list">
       {visible.map((entry) => {
@@ -264,27 +344,83 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
   }, [restartSlowScanWindow, listDirectory])
 
   /**
-   * Replace the whole view with a freshly navigated level. Away from the
+   * Enter owns the view from submission until its navigation lands, so the
+   * debounce timer the same keystrokes armed must not supersede it. Cleared
+   * by the next edit (and by opening the editor); a failed submission leaves
+   * it set until the operator edits again, so the rejected path is not
+   * immediately re-scanned as a preview.
+   */
+  const previewSuspended = useRef(false)
+
+  // The panes as the draft-following scan must read them when its wait
+  // fires: current, but NOT a dependency of the wait (see the effect below).
+  const viewRef = useRef<{ parent: DirectoryListing | null; child: DirectoryListing | null }>({ parent: null, child: null })
+  useEffect(() => { viewRef.current = { parent, child } }, [parent, child])
+
+  // What the last draft-following scan asked for and what came back, so a
+  // level still answers the text that produced it after the host respelled
+  // it. Stale entries are harmless: a match needs both the directory text and
+  // that level's own path, which together already mean the same directory.
+  const scanned = useRef<ScannedDirectory | null>(null)
+
+  /**
+   * A landed preview replaced the pane a keyboard operator may have Tabbed
+   * onto, so the focus it drops is re-parked on the still-open editor (the
+   * Modal has no focus trap). Consumed by the refocus effect below.
+   */
+  const refocusPathInput = useRef(false)
+
+  /**
+   * Replace the whole view with a freshly scanned level. Away from the
    * display root — the same collapse the crumb header renders, so crumbs and
    * pane shape never disagree — the landing is two-pane: the target's ACTUAL
    * parent-level entry re-selected (left pane = parent, right pane = the
    * target), so a crumb jump reads as stepping back one pane. Both legs land
    * as one frame when the parent leg settles within
    * {@link PARENT_LEG_WAIT_MS}; past that bound (or at the display root) the
-   * target commits alone — single wide level, the editor closes, loading
-   * ends — and a late parent leg still upgrades the landing in place. A
-   * failed parent leg, or a truncated parent window that lacks the target,
-   * leaves the single-pane landing — the upgrade must never orphan the
-   * selection it exists to anchor. Until whichever commit comes first, the
-   * previous view keeps rendering: navigation swaps the panes, it never
-   * blanks them.
+   * target commits alone — single wide level, loading ends — and a late
+   * parent leg still upgrades the landing in place. A failed parent leg, or a
+   * truncated parent window that lacks the target, leaves the single-pane
+   * landing — the upgrade must never orphan the selection it exists to
+   * anchor. Until whichever commit comes first, the previous view keeps
+   * rendering: a landing swaps the panes, it never blanks them.
+   *
+   * Two callers, one landing shape. A submitted path (Enter, a crumb) closes
+   * the editor on arrival, announces its failure, and takes the wait bound —
+   * it is answering a gesture, so it may not hang on a stalled parent. The
+   * editor's own draft-following scan keeps all three to itself: it is
+   * speculative, nothing waits on it, and the stale view keeps rendering, so
+   * it waits for BOTH legs rather than flashing a single pane it would then
+   * upgrade — one keystroke must move the view once. A failure leaves the
+   * last readable panes standing and says nothing, while an arrival clears
+   * the stale message and re-parks focus the swap dropped.
+   * @param path - the level to list; absent lists the Host home directory.
+   * @param options - `closeEditor` retires the path draft on arrival and
+   * bounds the wait for the parent leg; `announce` surfaces a failure as the
+   * dialog's alert.
    */
-  const navigate = useCallback((path?: string) => {
+  const land = useCallback((path: string | undefined, options: { closeEditor: boolean; announce: boolean }) => {
     const { seq, scan } = launchListing(path)
     setLoading(true)
-    setError(null)
+    if (options.announce) setError(null)
+    // What every landing does once its panes are committed, whichever shape
+    // committed them.
+    const settle = (): void => {
+      setLoading(false)
+      if (options.closeEditor) {
+        setPathDraft(null)
+        return
+      }
+      setError(null)
+      refocusPathInput.current = true
+    }
     scan.then((target) => {
       if (seq !== requestSeq.current) return
+      // The level the panes will present as current answers this exact
+      // directory text, however the host respelled it (`..`, a Windows
+      // forward slash): the tail filters, and the same text asks for no
+      // second scan.
+      if (!options.closeEditor && path !== undefined) scanned.current = { directory: path, landed: target.path }
       // The single-pane landing; `landed` makes it first-commit-only, while
       // the two-pane commit below may still upgrade an already-landed view.
       let landed = false
@@ -294,8 +430,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
         setParent(target)
         setSelected(null)
         setChild(null)
-        setLoading(false)
-        setPathDraft(null)
+        settle()
       }
       // Arity is label-independent: only the collapsed chain's depth decides.
       if (displayCrumbs(target, '').length < 2) { landSingle(); return }
@@ -316,23 +451,29 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
         setChild(target)
         // Idempotent on a late upgrade of a timed-out landing: reopening the
         // editor or starting a newer scan supersedes this seq, so reaching
-        // here means the draft is closed and the loading flag is this
-        // navigation's own.
-        setLoading(false)
-        setPathDraft(null)
+        // here means the settlement is still this landing's own.
+        settle()
       }, () => {
         // The parent-leg failure (its abort included) never surfaces: the
         // target listed fine, and nobody asked to see the parent level.
         landSingle()
       })
-      window.setTimeout(landSingle, PARENT_LEG_WAIT_MS)
+      // Only a submitted navigation is bounded: the walk waits both legs out
+      // (see the contract above), and a keystroke aborts it if the operator
+      // moves on first.
+      if (options.closeEditor) window.setTimeout(landSingle, PARENT_LEG_WAIT_MS)
     }, (reason: unknown) => {
       if (seq !== requestSeq.current) return
       setLoading(false)
-      setError(failureText(reason))
+      if (options.announce) setError(failureText(reason))
     })
   }, [launchListing, continueScan])
 
+  /** Commit a submitted path (Enter, a crumb, the initial home listing): the editor closes, failures surface. */
+  const navigate = useCallback((path?: string) => {
+    land(path, { closeEditor: true, announce: true })
+  }, [land])
+
   // Editor-close focus parking (consumed by the refocus effect below the
   // miller-row ref): a pick parks on the selection's row, Enter and an
   // input-focused Escape park on the crumb edit zone that replaces the
@@ -381,6 +522,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
     })
   }, [launchListing, pathDraft])
 
+  /**
+   * Walk the panes to the directory the draft addresses, WITHOUT closing the
+   * editor. The landing is an ordinary one — selection-anchored and two-pane
+   * away from the display root — so typing a path moves the Miller view
+   * exactly as a crumb jump does, and the draft's final segment
+   * prefix-filters the arrival from the next render on.
+   */
+  const previewDraftLevel = useCallback((directory: string) => {
+    land(directory, { closeEditor: false, announce: false })
+  }, [land])
+
   /** Abandon path editing (Escape or clicking away) and restore the crumb view. */
   const cancelPathEdit = useCallback(() => {
     // Cancel also withdraws a navigation the editor already launched: its
@@ -499,8 +651,40 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
     return () => { window.clearTimeout(timer) }
   }, [loading, scanWindow])
 
+  // The panes follow the draft: EVERY keystroke replaces the pending timer,
+  // and the target is decided when it fires, off the panes as they stand
+  // then. Keying the wait on the draft (not on the directory part it names)
+  // is what makes a keystroke that superseded an in-flight scan re-arm one,
+  // and what lets an edit after a rejected submission release the hold the
+  // submission took. The panes are read through a ref for the converse
+  // reason: were they dependencies, the landing this commits would re-arm the
+  // wait, and a host answering with a differently spelled path would scan
+  // forever.
+  useEffect(() => {
+    if (pathDraft === null) return
+    const timer = window.setTimeout(() => {
+      if (previewSuspended.current) return
+      // The level the panes present as current: it alone may answer the
+      // draft, so anything else it names is a level to walk to.
+      const current = viewRef.current.child ?? viewRef.current.parent
+      if (current === null) return
+      const { directory, tail } = readDraft(current, pathDraft, scanned.current)
+      if (directory === null || tail !== null) return
+      previewDraftLevel(directory)
+    }, DRAFT_PREVIEW_DEBOUNCE_MS)
+    return () => { window.clearTimeout(timer) }
+  }, [pathDraft, previewDraftLevel])
+
   // After the hooks: a closed dialog renders nothing and evaluates no copy.
   const crumbSource = child ?? parent
+  // The draft's tail filters the level it names, which by the pane invariant
+  // is the LAST pane — never a pane the draft has already walked away from.
+  // Narrowing that stale pane would move the view twice for one keystroke:
+  // once as it narrows, again as its landing replaces it. It holds still
+  // instead, and the filter arrives with the level it belongs to.
+  const typedPrefix = crumbSource === null || pathDraft === null
+    ? null
+    : readDraft(crumbSource, pathDraft, scanned.current).tail
   const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
   const crumbTail = crumbs.at(-1)?.path
   useEffect(() => {
@@ -523,6 +707,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
   // replacing the picked button's column — while Enter and an input-focused
   // Escape land on the crumb edit zone that replaces the input.
   useEffect(() => {
+    if (refocusPathInput.current) {
+      refocusPathInput.current = false
+      // Only when the swap actually dropped focus to body: focus the operator
+      // still holds (the input itself, a surviving row) stays theirs.
+      if (document.activeElement === document.body) pathInputRef.current?.focus()
+    }
     if (pathDraft !== null) return
     if (refocusPick.current) {
       refocusPick.current = false
@@ -637,11 +827,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
                       </span>
                     ))}
                   </span>
-                  {/* The empty zone right of the crumbs is the path-edit affordance. */}
+                  {/* The empty zone right of the crumbs is the path-edit
+                    * affordance: the whole remainder of the bar clicks into
+                    * the editor, and the pencil glyph parked at its right
+                    * edge (with the same tooltip) is what says so — an
+                    * invisible target the operator must guess at is the one
+                    * way into typing a path. */}
                   <button
                     type="button"
                     className={css.crumbEditZone}
                     aria-label={t('browser.editPath')}
+                    title={t('browser.editPath')}
                     // Stays available with no listed level: when the home
                     // listing itself fails, typing an absolute path is the one
                     // remaining way forward.
@@ -653,6 +849,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
                     // otherwise close the editor via navigate's draft reset.
                       supersede()
                       setLoading(false)
+                      previewSuspended.current = false
                       // Seed with a trailing separator so typing immediately
                       // continues into child names (and prefix-filters below).
                       // No listed level means nothing to seed from (the editor
@@ -665,7 +862,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
                       const sep = separatorOf(parent)
                       setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`)
                     }}
-                  />
+                  >
+                    <IconEditOutline16 size={14} className={css.crumbEditGlyph} />
+                  </button>
                 </>
               )
               : (
@@ -682,6 +881,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
                   // repopulate the view with the older path.
                     supersede()
                     setLoading(false)
+                    // A fresh edit releases the submission hold: the panes
+                    // may follow the new text wherever it points.
+                    previewSuspended.current = false
                     setPathDraft(event.target.value)
                   }}
                   {...compositionGuard}
@@ -699,6 +901,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
                         // focus on the returning crumb edit zone (a failure
                         // keeps the editor, so the flag waits until close).
                         refocusEditZone.current = true
+                        // The submitted path owns the view now: a debounce
+                        // timer still pending from these keystrokes would
+                        // otherwise supersede this navigation and land the
+                        // draft's parent directory instead.
+                        previewSuspended.current = true
                         navigate(pathDraft)
                       }
                     }
@@ -716,7 +923,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
                 busy={parentInert}
                 onPick={select}
                 showHidden={showHidden}
-                filterPrefix={draftPrefixFor(parent, pathDraft)}
+                filterPrefix={child === null ? typedPrefix : null}
                 pathEditing={draftPending}
               />
             )}
@@ -728,7 +935,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
                 busy={parentInert}
                 onPick={advance}
                 showHidden={showHidden}
-                filterPrefix={draftPrefixFor(child, pathDraft)}
+                filterPrefix={typedPrefix}
                 pathEditing={draftPending}
               />
             )}

+ 334 - 13
packages/host/directory-picker-browse/tests/directory-browser.spec.tsx

@@ -11,9 +11,14 @@ const HOME = '/home/u'
 const DOCS = `${HOME}/Documents`
 const HARNESS = `${DOCS}/harness`
 
-/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */
+/**
+ * Listing fake over a tiny fixed tree; unknown paths reject like the Host.
+ * A trailing separator is dropped the way the Host's own `resolve` drops it,
+ * so a directory part typed into the path editor addresses its level.
+ */
 function listingFor(path?: string): DirectoryListing {
-  const target = path ?? HOME
+  const asked = path ?? HOME
+  const target = asked.length > 1 && asked.endsWith('/') ? asked.slice(0, -1) : asked
   const tree: Record<string, DirectoryListing> = {
     [HOME]: {
       path: HOME,
@@ -647,7 +652,7 @@ describe('DirectoryBrowser', () => {
   })
 
   it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => {
-    mount()
+    const b = mount()
     await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
     fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
     const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
@@ -659,16 +664,29 @@ describe('DirectoryBrowser', () => {
     // A dot-led prefix names hidden entries, so it reveals the match.
     fireEvent.change(input, { target: { value: `${HOME}/.co` } })
     expect(screen.getByRole('listitem').textContent).toBe('.config')
-    // A prefix matching nothing empties the level (no stale rows linger).
+    // A prefix nobody matches releases the filter: the level shows whole
+    // (hidden rows back under the toggle) instead of emptying under a name
+    // the operator is still spelling.
     fireEvent.change(input, { target: { value: `${HOME}/zzz` } })
-    expect(screen.queryByRole('listitem')).toBeNull()
-    // A draft naming some other directory (or none) leaves the level whole.
+    expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
+    // Its dot-led reveal lapses with it.
+    fireEvent.change(input, { target: { value: `${HOME}/.zzz` } })
+    expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
+    // A tail inside the listed level names no level to walk to: the wait
+    // fires and finds nothing to scan.
+    const settled = b.listDirectory.mock.calls.length
+    await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
+    expect(b.listDirectory.mock.calls).toHaveLength(settled)
+    // A draft naming some other directory (or none) leaves the level whole —
+    // and a draft with no separator at all addresses no directory either.
     fireEvent.change(input, { target: { value: 'no-separator' } })
     expect(screen.getByRole('listitem').textContent).toBe('Documents')
+    await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
+    expect(b.listDirectory.mock.calls).toHaveLength(settled)
   })
 
   it('filters the child pane in two-pane mode and follows the draft back up a level', async () => {
-    mount()
+    const b = mount()
     await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
     fireEvent.click(rowButton(screen.getByRole('listitem')))
     await waitFor(() => { expect(columns()).toHaveLength(2) })
@@ -679,17 +697,303 @@ describe('DirectoryBrowser', () => {
     expect(input.value).toBe(`${DOCS}/`)
     fireEvent.change(input, { target: { value: `${DOCS}/h` } })
     expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
+    // The child pane already lists that directory: no scan follows, and both
+    // panes stay.
+    const settled = b.listDirectory.mock.calls.length
+    await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
+    expect(b.listDirectory.mock.calls).toHaveLength(settled)
+    expect(columns()).toHaveLength(2)
+    // A miss releases the right pane's filter rather than emptying it.
     fireEvent.change(input, { target: { value: `${DOCS}/zzz` } })
-    expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
+    expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
     expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
-    // Erasing back into the parent's own path moves the filter to the LEFT
-    // pane and releases the right one. The selected row is exempt (it
-    // anchors the two-pane view), so it alone survives the miss.
+    // Erasing back into the parent's own path re-lands on it rather than
+    // filtering the LEFT pane: the level being typed is always the last pane,
+    // never a pane with a deeper level standing to its right. Home is the
+    // display root, so it lands alone.
     fireEvent.change(input, { target: { value: `${HOME}/zz` } })
-    expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
+    await waitFor(() => { expect(columns()).toHaveLength(1) })
+    expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
+  })
+
+  it('follows the draft into a directory no pane lists, landing the two-pane Miller view', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    expect(columns()).toHaveLength(1)
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    // Typing past a separator addresses a level nobody shows: the panes walk
+    // to it once the typing rests, landing the ordinary selection-anchored
+    // two-pane view (level | its children) with the tail filtering the right
+    // pane — a typed path moves the Miller view exactly as a crumb jump does.
+    fireEvent.change(input, { target: { value: `${DOCS}/h` } })
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything())
+    expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
+    expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
+    // Still editing: the panes moved under the draft, the editor stayed.
+    expect(screen.getByLabelText<HTMLInputElement>('browser.editPath').value).toBe(`${DOCS}/h`)
+    // Typing on inside a level the panes already list costs no scan at all:
+    // the prefix filter alone answers the draft, both panes stay.
+    const settled = b.listDirectory.mock.calls.length
+    fireEvent.change(input, { target: { value: `${DOCS}/ha` } })
+    await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
+    expect(b.listDirectory.mock.calls).toHaveLength(settled)
+    expect(columns()).toHaveLength(2)
+  })
+
+  it('keeps the typed level in the last pane, its parent beside it, as the draft walks', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    // Two levels down: the typed level on the right, its parent on the left.
+    fireEvent.change(input, { target: { value: `${HARNESS}/` } })
+    await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() })
+    expect(columns()).toHaveLength(2)
+    expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
+    // Erasing back to the parent's own path re-lands on it: the level being
+    // typed moves BACK into the last pane instead of staying on the left with
+    // its own child pane still to the right.
+    fireEvent.change(input, { target: { value: `${DOCS}/ha` } })
+    await waitFor(() => { expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() })
+    expect(columns()).toHaveLength(2)
+    expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['harness'])
+    expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything())
+  })
+
+  it('holds a stale pane still until its landing, instead of narrowing it first', async () => {
+    // Own three-level tree: the level that goes stale needs two rows for the
+    // narrowing this pins against to be visible at all.
+    const ROOT = '/u'
+    const MID = `${ROOT}/mid`
+    const LEAF = `${MID}/leaf`
+    const chain = [{ name: '/', path: '/', hidden: false }, { name: 'u', path: ROOT, hidden: false }]
+    const tree: Record<string, DirectoryListing> = {
+      [ROOT]: {
+        path: ROOT,
+        home: ROOT,
+        crumbs: chain,
+        entries: [{ name: 'mid', path: MID, hidden: false }, { name: 'other', path: `${ROOT}/other`, hidden: false }],
+        truncated: false,
+      },
+      [MID]: {
+        path: MID,
+        home: ROOT,
+        crumbs: [...chain, { name: 'mid', path: MID, hidden: false }],
+        entries: [{ name: 'leaf', path: LEAF, hidden: false }, { name: 'sibling', path: `${MID}/sibling`, hidden: false }],
+        truncated: false,
+      },
+      [LEAF]: {
+        path: LEAF,
+        home: ROOT,
+        crumbs: [...chain, { name: 'mid', path: MID, hidden: false }, { name: 'leaf', path: LEAF, hidden: false }],
+        entries: [],
+        truncated: false,
+      },
+    }
+    mount({
+      listDirectory: vi.fn(async (path?: string) => {
+        const asked = path ?? ROOT
+        const found = tree[asked.length > 1 && asked.endsWith('/') ? asked.slice(0, -1) : asked]
+        if (found === undefined) throw new Error(`cannot list ${asked}`)
+        return found
+      }),
+    })
+    await waitFor(() => { expect(screen.getByText('mid')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    fireEvent.change(input, { target: { value: `${LEAF}/` } })
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf', 'sibling'])
+    // Deleting the separator names the level the LEFT pane lists. That pane
+    // is stale — its landing will move it right — so it must not narrow to
+    // the tail first: one deletion, one movement.
+    fireEvent.change(input, { target: { value: LEAF } })
+    expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf', 'sibling'])
+    await waitFor(() => { expect(within(columns()[0]!).getByText('other')).toBeTruthy() })
+    expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf'])
+  })
+
+  it('keeps the walked-to panes when the editor is cancelled, Open adopting where the walk ended', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    fireEvent.change(input, { target: { value: `${DOCS}/h` } })
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    fireEvent.keyDown(input, { key: 'Escape' })
+    // Cancel closes the editor; it does not rewind the walk. The operator
+    // watched the panes move, so the crumbs, the panes, and Open's target all
+    // stay where the walk ended.
+    expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
+    expect(columns()).toHaveLength(2)
+    expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
+    expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
+    expect(screen.getByRole('navigation').textContent).toContain('Documents')
+    const open = screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' })
+    expect(open.disabled).toBe(false)
+    fireEvent.click(open)
+    expect(b.onOpen).toHaveBeenCalledWith(DOCS)
+  })
+
+  it('waits both legs out for a walk: one keystroke never flashes a single pane', async () => {
+    let landParent = (): void => {}
+    const listDirectory = vi.fn(async (path?: string) => {
+      // The parent leg outlives the submitted-navigation wait bound; a walk
+      // has nothing waiting on it, so it holds the stale view instead of
+      // landing single-pane and upgrading.
+      if (path === HOME) return await new Promise<DirectoryListing>((resolve) => { landParent = () => { resolve(listingFor(HOME)) } })
+      return listingFor(path)
+    })
+    mount({ listDirectory })
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    fireEvent.change(input, { target: { value: `${DOCS}/h` } })
+    await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(HOME, expect.anything()) })
+    await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
+    // Well past the submitted-navigation bound: still the pre-walk view.
+    expect(columns()).toHaveLength(1)
+    expect(screen.getByText('Documents')).toBeTruthy()
+    await act(async () => { landParent() })
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
     expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
   })
 
+  it('walks the panes back up when erased segments leave the listed levels', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    fireEvent.change(input, { target: { value: `${DOCS}/h` } })
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    // Erasing back to a directory neither pane lists walks up to it; the
+    // filesystem root is the display root, so it lands the single wide level
+    // with the tail filtering it.
+    fireEvent.change(input, { target: { value: '/ho' } })
+    await waitFor(() => { expect(columns()).toHaveLength(1) })
+    expect(b.listDirectory).toHaveBeenCalledWith('/', expect.anything())
+    expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['home'])
+  })
+
+  it('re-arms the draft-following scan after a keystroke superseded one in flight', async () => {
+    let started = 0
+    const listDirectory = vi.fn(async (path?: string) => {
+      if (path !== `${DOCS}/`) return listingFor(path)
+      started += 1
+      // The first scan never settles: the next keystroke aborts it, and only
+      // a re-armed wait can still land the level the draft names.
+      if (started === 1) return await new Promise<DirectoryListing>(() => {})
+      return listingFor(path)
+    })
+    mount({ listDirectory })
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    fireEvent.change(input, { target: { value: `${DOCS}/h` } })
+    await waitFor(() => { expect(started).toBe(1) })
+    // A further tail keystroke supersedes the in-flight scan; the panes must
+    // still follow, not sit on the stale level until a separator is typed.
+    fireEvent.change(input, { target: { value: `${DOCS}/ha` } })
+    await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() })
+  })
+
+  it('follows the draft again after an edit releases a failed submission hold', async () => {
+    const listDirectory = vi.fn(async (path?: string) => {
+      if (path === HARNESS) throw new Error('target unreadable')
+      return listingFor(path)
+    })
+    mount({ listDirectory })
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    // Submitting inside the debounce window holds the pending scan back.
+    fireEvent.change(input, { target: { value: HARNESS } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('target unreadable') })
+    // Correcting only the final segment leaves the directory part unchanged;
+    // the edit must still release the hold and re-arm the wait.
+    fireEvent.change(input, { target: { value: `${HARNESS}x` } })
+    await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) })
+    await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() })
+  })
+
+  it('re-parks focus on the editor when a landed scan unmounts the focused row', async () => {
+    mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    // Two levels down, so the walk replaces the LEFT pane the focused row
+    // lives in (a landing that re-lists the same level reuses its rows).
+    fireEvent.change(input, { target: { value: `${HARNESS}/` } })
+    // The keyboard path: focus Tabbed onto a row of the level about to be
+    // replaced. Without a re-park it would fall to body, outside a Modal that
+    // has no focus trap.
+    rowButton(screen.getByRole('listitem')).focus()
+    await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() })
+    expect(document.activeElement).toBe(screen.getByLabelText('browser.editPath'))
+  })
+
+  it('keeps the panes and stays silent when a draft-following scan fails', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    fireEvent.change(input, { target: { value: `${HOME}/nope/x` } })
+    await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledWith(`${HOME}/nope/`, expect.anything()) })
+    // A half-typed directory is unreadable most of the time: the last
+    // readable level keeps rendering and no error interrupts the typing.
+    expect(screen.getByText('Documents')).toBeTruthy()
+    expect(screen.queryByRole('alert')).toBeNull()
+  })
+
+  it('holds the draft-following scan while a submitted path is in flight', async () => {
+    const listDirectory = vi.fn(async (path?: string) => {
+      // The submitted leg never settles, so the debounce window elapses with
+      // the navigation still owning the view.
+      if (path === HARNESS) return await new Promise<DirectoryListing>(() => {})
+      return listingFor(path)
+    })
+    mount({ listDirectory })
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    fireEvent.change(input, { target: { value: HARNESS } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
+    // Only the initial home listing and the submitted path — the draft's
+    // directory part was never scanned behind the navigation's back.
+    expect(listDirectory.mock.calls.map(call => call[0])).toEqual([undefined, HARNESS])
+  })
+
+  it('discards draft-following scans that a newer edit superseded', async () => {
+    let landDocs = (): void => {}
+    let failRoot = (): void => {}
+    const listDirectory = vi.fn(async (path?: string) => {
+      if (path === `${DOCS}/`) return await new Promise<DirectoryListing>((resolve) => { landDocs = () => { resolve(listingFor(DOCS)) } })
+      if (path === '/') {
+        return await new Promise<DirectoryListing>((_, reject) => {
+          failRoot = () => { reject(new Error('root unreadable')) }
+        })
+      }
+      return listingFor(path)
+    })
+    mount({ listDirectory })
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    fireEvent.change(input, { target: { value: `${DOCS}/h` } })
+    await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) })
+    fireEvent.change(input, { target: { value: '/x' } })
+    await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith('/', expect.anything()) })
+    // Back onto the listed level: neither pending scan may still land.
+    fireEvent.change(input, { target: { value: `${HOME}/D` } })
+    await act(async () => { landDocs(); failRoot() })
+    expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
+    expect(screen.queryByRole('alert')).toBeNull()
+  })
+
   it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => {
     mount()
     await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -813,7 +1117,8 @@ describe('DirectoryBrowser', () => {
       ],
       truncated: false,
     }
-    mount({ listDirectory: vi.fn(async () => windowsListing) })
+    const listDirectory = vi.fn(async () => windowsListing)
+    mount({ listDirectory })
     await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) })
     fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
     const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
@@ -821,6 +1126,18 @@ describe('DirectoryBrowser', () => {
     expect(input.value).toBe(ROOT)
     fireEvent.change(input, { target: { value: `${ROOT}u` } })
     expect(screen.getByRole('listitem').textContent).toBe('Users')
+    // Windows separates on a forward slash too (so does the Host's resolve),
+    // so a path typed that way names its directory; the level the Host
+    // answers with spells it back with a backslash, and once that scan lands
+    // the level answers the typed spelling — the tail filters it.
+    fireEvent.change(input, { target: { value: 'C:/p' } })
+    await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Program Files') })
+    // And the same spelling asks for no second scan.
+    const settled = listDirectory.mock.calls.length
+    fireEvent.change(input, { target: { value: 'C:/pr' } })
+    await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
+    expect(listDirectory.mock.calls).toHaveLength(settled)
+    expect(screen.getByRole('listitem').textContent).toBe('Program Files')
   })
 
   it('clicking away from the path editor cancels it back to the crumb view', async () => {
@@ -977,6 +1294,10 @@ describe('DirectoryBrowser', () => {
     fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
     const input = screen.getByLabelText('browser.editPath')
     fireEvent.change(input, { target: { value: DOCS } })
+    // With no level listed there is no platform separator to read, so the
+    // draft-following wait resolves to nothing and the editor types blind.
+    await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
+    expect(listDirectory).toHaveBeenCalledTimes(1)
     listDirectory.mockImplementation(async (path?: string) => listingFor(path))
     fireEvent.keyDown(input, { key: 'Enter' })
     await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() })

Некоторые файлы не были показаны из-за большого количества измененных файлов