Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions server/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} 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));
}

/**
Expand All @@ -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];
}))];
Expand Down
23 changes: 22 additions & 1 deletion server/windows-native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"];
Expand Down
Loading