fix: v2.7.4 — Changes matches JetBrains (#72), the chat says when Claude waits on a trust prompt (#73), Vietnamese Telex in Safari
- Changes (#72): a file git reports `MM` whose working copy equals HEAD again (a staged change since undone on disk) is no longer listed — JetBrains' GitChangesCollector drops it the same way. Mode-only changes stay listed. statusCounts (the dirty-tree safety checks) is unchanged. - Chat (#73): Claude Code runs no hook before a folder is trusted, so a new pane's session has no row; the chat now says the session has not started, offers Open terminal, and asks before typing a message or /model //effort into the pane (an Enter would pick the trust prompt's "No, exit"). - Terminal: Safari + macOS's own Vietnamese Telex replaces the whole word on each key (insertReplacementText), which xterm.js ignores — only each word's first letter reached the pane. The edit is now sent as DELs + the new tail. - Also: Ctrl+V pastes a screenshot into the terminal as a file path; the folder browser fallback lists files for a file field (`files=1`); the macOS native picker opens in front of the browser; a hidden tab is marked while a chat waits or once a turn ended; chat live line shows running calls, turn time and tokens, and "Done in …".
This commit is contained in:
@@ -143,6 +143,34 @@ describe("collectChanges — work range", () => {
|
||||
assert.equal((await gitFacts(LANE)).untracked, 2);
|
||||
});
|
||||
|
||||
// #72: a change staged and then undone in the working tree is `MM` in
|
||||
// `git status` while the file on disk equals HEAD — JetBrains' Changes view
|
||||
// (GitChangesCollector) drops it after a `git diff HEAD`, and so does this.
|
||||
it("leaves out a staged change the working tree has reverted (#72)", async () => {
|
||||
write(LANE, "a.txt", "one\nTWO\nthree\n");
|
||||
g(LANE, "add", "a.txt");
|
||||
write(LANE, "a.txt", "one\ntwo\nthree\n"); // back to HEAD's content
|
||||
write(LANE, "keep.txt", "staged\n");
|
||||
g(LANE, "add", "keep.txt");
|
||||
write(LANE, "keep.txt", "staged, then edited\n"); // MM and still a change
|
||||
assert.deepEqual(porcelain(LANE).sort(), ["MM a.txt", "MM keep.txt"]);
|
||||
|
||||
const out = await collectChanges(LANE, { range: "work" });
|
||||
assert.deepEqual(
|
||||
out.files.map((f) => f.path),
|
||||
["keep.txt"]
|
||||
);
|
||||
});
|
||||
|
||||
it("still lists a file whose only change is its mode, as JetBrains does", async () => {
|
||||
fs.chmodSync(path.join(LANE, "a.txt"), 0o755);
|
||||
const out = await collectChanges(LANE, { range: "work" });
|
||||
assert.deepEqual(
|
||||
out.files.map((f) => [f.path, f.added, f.deleted]),
|
||||
[["a.txt", 0, 0]]
|
||||
);
|
||||
});
|
||||
|
||||
it("returns hunks for every file in full mode", async () => {
|
||||
write(LANE, "a.txt", "one\nTWO\nthree\n");
|
||||
const out = await collectChanges(LANE, { range: "work", full: true });
|
||||
|
||||
@@ -579,6 +579,25 @@ describe("GET /api/lanes/browse", () => {
|
||||
assert.equal(notADir.status, 400);
|
||||
assert.equal(notADir.body.error.code, "ENOTADIR");
|
||||
});
|
||||
|
||||
it("with files=1 lists files too, opening a file path at its folder", async () => {
|
||||
const filePath = path.join(ROOT, "b-file.txt");
|
||||
fs.writeFileSync(filePath, "hi\n");
|
||||
const response = await request(
|
||||
"GET",
|
||||
`/api/lanes/browse?files=1&path=${encodeURIComponent(filePath)}`
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.path, ROOT);
|
||||
const file = response.body.entries.find((e) => e.name === "b-file.txt");
|
||||
assert.equal(file?.file, true);
|
||||
assert.equal(file.path, filePath);
|
||||
const dir = response.body.entries.find((e) => e.name === "src-repo");
|
||||
assert.ok(dir && !dir.file);
|
||||
|
||||
const dirsOnly = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
|
||||
assert.ok(!dirsOnly.body.entries.some((e) => e.name === "b-file.txt"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("destructive lane lifecycle actions", () => {
|
||||
|
||||
@@ -44,6 +44,13 @@ describe("native-dialog", () => {
|
||||
assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
|
||||
});
|
||||
|
||||
it("brings osascript forward before the macOS dialog, so it does not open behind the browser", () => {
|
||||
const [[, args]] = nd.dialogCommands("dir", "/x", "darwin");
|
||||
const script = args.filter((_, i) => args[i - 1] === "-e");
|
||||
assert.ok(script.indexOf("activate") > -1);
|
||||
assert.ok(script.indexOf("activate") < script.findIndex((s) => s.includes("choose folder")));
|
||||
});
|
||||
|
||||
it("gives the Windows dialog a foreground TopMost owner, so it does not open behind the browser", () => {
|
||||
for (const kind of ["dir", "file"]) {
|
||||
const [[cmd, args]] = nd.dialogCommands(kind, "C:\\x", "win32");
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* non-admin owns no host-folder workspace and a pane's cwd is its
|
||||
* workspace's account's; a pane token works only from its pane's uid; an
|
||||
* analyst's GET path stays in their own folder; a drop goes to the pane's
|
||||
* account. A fake passwd (every member = this test's uid) and server.conf
|
||||
* account; an image a session read is served only from a member session's
|
||||
* own home (a host session's from anywhere). A fake passwd (every member = this test's uid) and server.conf
|
||||
* make the members provisioned; fake sudo runs their commands as this
|
||||
* account — and a fake `sudo` on PATH that never returns stands in for a
|
||||
* stalled member, in a child process.
|
||||
@@ -674,7 +675,54 @@ describe("a dropped file", () => {
|
||||
const host = await drop("boss", hostLane);
|
||||
assert.equal(host.status, 200, JSON.stringify(host.body));
|
||||
assert.equal(path.dirname(host.body.path), path.join(DATA, "drops"));
|
||||
// …and the admin's chat shows it back, though the GET names no workspace.
|
||||
fs.mkdirSync(path.join(USERS, "boss", ".agenthub", "handoff"), { recursive: true });
|
||||
const shown = await fetch(`${BASE}/api/run/drop/${path.basename(host.body.path)}`, {
|
||||
headers: { cookie: cookies.boss },
|
||||
});
|
||||
assert.equal(shown.status, 200);
|
||||
assert.equal(await shown.text(), "hello");
|
||||
// Another member's workspace is not one to name.
|
||||
assert.equal((await drop("bob", miaLane)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an image a session read (the Chat pane's Files read preview)", () => {
|
||||
const readBy = (sid, cwd, file) => {
|
||||
db.prepare("INSERT OR IGNORE INTO sessions (id, cwd) VALUES (?, ?)").run(sid, cwd);
|
||||
db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES (?, 'PostToolUse', 'Read', ?)"
|
||||
).run(sid, JSON.stringify({ tool_input: { file_path: file } }));
|
||||
};
|
||||
const get = (sid, file) =>
|
||||
fetch(`${BASE}/api/sessions/${sid}/image?path=${encodeURIComponent(file)}`, {
|
||||
headers: { cookie: cookies.boss },
|
||||
});
|
||||
|
||||
it("is served for the host's own session and a member's file in that member's home", async () => {
|
||||
const hostPng = path.join(TMP, "hostdir", "shot.png");
|
||||
const miaPng = path.join(MIA, "proj", "shot.png");
|
||||
fs.mkdirSync(path.dirname(hostPng), { recursive: true });
|
||||
fs.writeFileSync(hostPng, "HOSTPNG");
|
||||
fs.writeFileSync(miaPng, "MIAPNG");
|
||||
readBy("img-host", path.join(TMP, "hostdir"), hostPng);
|
||||
readBy("img-mia", path.join(MIA, "proj"), miaPng);
|
||||
const host = await get("img-host", hostPng);
|
||||
assert.equal(host.status, 200);
|
||||
assert.equal(await host.text(), "HOSTPNG");
|
||||
const mia = await get("img-mia", miaPng);
|
||||
assert.equal(mia.status, 200);
|
||||
assert.equal(await mia.text(), "MIAPNG");
|
||||
});
|
||||
|
||||
it("is refused when a member's session names a file outside that member's home", async () => {
|
||||
readBy("img-mia-out", path.join(MIA, "proj"), path.join(TMP, "hostdir", "shot.png"));
|
||||
readBy("img-mia-bob", path.join(MIA, "proj"), path.join(BOB, "b.png"));
|
||||
fs.writeFileSync(path.join(BOB, "b.png"), "BOBPNG");
|
||||
for (const f of [path.join(TMP, "hostdir", "shot.png"), path.join(BOB, "b.png")]) {
|
||||
const r = await get(f.includes("bob") ? "img-mia-bob" : "img-mia-out", f);
|
||||
assert.equal(r.status, 404, f);
|
||||
assert.ok(!/PNG/.test(await r.text()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user