diff --git a/CHANGELOG.md b/CHANGELOG.md index 93430dfdbc..d9f734d3d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ([#724](https://github.com/devswha/herdr-web-ui/pull/724)) ### Fixed +- Codex conversations on Windows resolve the transcript even when the pane's working directory + uses different letter casing from Codex's stored path. The lookup verifies the directory on + disk before considering its canonical spelling, so another pane's chat is not substituted. + ([#740](https://github.com/devswha/herdr-web-ui/pull/740) by @antonydoyle) - The browser demo keeps each row's own split when resizing a grid and keeps deeply split panes visible and resizable. ([#726](https://github.com/devswha/herdr-web-ui/pull/726)) - Workspace and tab changes, pane moves and layout updates made in herdr's terminal UI diff --git a/server/codex.ts b/server/codex.ts index 7381695d68..adee438f70 100644 --- a/server/codex.ts +++ b/server/codex.ts @@ -466,13 +466,21 @@ export function directoryKey(cwd: string): string { /** Whether two cwds are one directory by `directoryKey`; POSIX ones only when they are equal. */ export function sameDirectory(left: string, right: string): boolean { - return directoryKey(left) === directoryKey(right); + if (directoryKey(left) === directoryKey(right)) return true; + if (process.platform !== "win32") return false; + try { + // bigint: a Windows file ID is 64-bit, and two of them can round to one number + const a = statSync(left, { bigint: true }); + const b = statSync(right, { bigint: true }); + return a.isDirectory() && b.isDirectory() && a.dev === b.dev && a.ino === b.ino; + } catch { return false; } } /** The other Codex panes herdr shows in this pane's directory, in any spelling `sameDirectory` takes. */ export function codexPeersIn(panes: readonly HerdrPane[], paneId: string, cwd: string): HerdrPane[] { - return panes.filter((pane) => pane.pane_id !== paneId && pane.cwd != null && sameDirectory(pane.cwd, cwd) - && (pane.agent ?? pane.agent_session?.agent) === "codex"); + // the agent first: sameDirectory can ask the file system, and only Codex panes need it + return panes.filter((pane) => pane.pane_id !== paneId && (pane.agent ?? pane.agent_session?.agent) === "codex" + && pane.cwd != null && sameDirectory(pane.cwd, cwd)); } /** @@ -481,7 +489,14 @@ export function codexPeersIn(panes: readonly HerdrPane[], paneId: string, cwd: s * stays on Codex's own binary cwd index. */ export function storedCwdCondition(cwd: string): { where: string; params: string[] } { - const params = [...new Set(storedCwds(cwd).flatMap((spelling) => { + // Windows resolves an existing directory to its on-disk casing. Herdr may report the + // shell's spelling while Codex stores the canonical spelling in its binary cwd index. + // Keep both: the original is needed when Codex recorded it before a rename or via a link. + let canonical = cwd; + if (process.platform === "win32") { + try { canonical = realpathSync.native(cwd); } catch { /* A removed cwd still has its reported spelling. */ } + } + const params = [...new Set([cwd, canonical].flatMap((path) => storedCwds(path)).flatMap((spelling) => { const drive = /^((?:\\\\\?\\)?)([A-Za-z])(:\\.*)$/s.exec(spelling); return drive ? [`${drive[1]}${drive[2]!.toUpperCase()}${drive[3]}`, `${drive[1]}${drive[2]!.toLowerCase()}${drive[3]}`] : [spelling]; }))]; diff --git a/server/windows-native.test.ts b/server/windows-native.test.ts index 99b273f5ab..fd5ea8d6fe 100644 --- a/server/windows-native.test.ts +++ b/server/windows-native.test.ts @@ -11,7 +11,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { claudeProcessSession, forgetClaudeSessions, processClaudeConfigDir } from "./claude-store.ts"; -import { codexRolloutPath } from "./codex.ts"; +import { Database } from "bun:sqlite"; + +import { codexRolloutPath, directoryKey, sameDirectory, storedCwdCondition } from "./codex.ts"; import { gjcSessionFile, storeRelative } from "./gjc-runtime.ts"; import { descendantArgv, windowsProcessTable } from "./windows-processes.ts"; import { windowsHost } from "./remote-host.ts"; @@ -122,6 +124,25 @@ it.skipIf(!onWindows)("reads a Codex rollout path stored with the \\\\?\\ prefix } finally { rmSync(root, { recursive: true, force: true }); } }); +it.skipIf(!onWindows)("finds Codex's thread under the on-disk casing when herdr reports another spelling of the directory (#740)", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "herdr-codex-cwd-"))); + mkdirSync(join(root, "Recorde")); + const reported = join(root, "recorde"); + const canonical = realpathSync.native(reported); + const db = new Database(":memory:"); + try { + // the two spellings are only one directory to the file system, not to directoryKey + expect(directoryKey(reported)).not.toBe(directoryKey(canonical)); + db.run("CREATE TABLE threads (id TEXT, cwd TEXT)"); + db.run("INSERT INTO threads VALUES (?, ?)", ["own", `\\\\?\\${canonical}`]); + const { where, params } = storedCwdCondition(reported); + expect(db.query<{ id: string }, string[]>(`SELECT id FROM threads WHERE ${where}`).all(...params)).toEqual([{ id: "own" }]); + expect(sameDirectory(reported, canonical)).toBe(true); + mkdirSync(join(root, "Other")); + expect(sameDirectory(reported, join(root, "Other"))).toBe(false); + } finally { db.close(); rmSync(root, { recursive: true, force: true }); } +}); + it.skipIf(!onWindows)("finds a Claude's own store from the start it records, which the process table repeats", async () => { const home = realpathSync(mkdtempSync(join(tmpdir(), "herdr-claude-native-"))); const serverConfigDir = process.env["CLAUDE_CONFIG_DIR"];