Compare commits

...
7 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 fbdcec6f69 chore(maestro): allow cargo test in local settings
ci / flake (push) Successful in 3m39s
ci / rust (push) Successful in 6m32s
ci / web (push) Failing after 27s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 15:05:41 +01:00
Miguel PalhasandClaude Opus 4.6 6af4cda650 fix(maestro): gitroot tests tolerate /tmp inside a repo
The sandbox has /tmp/.git, so the ancestor walk finds a git root
above the tempdir. Weakened the assertion: the result must not point
inside the .git-free tempdir, but may point above it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:50:50 +01:00
Miguel Palhas ab4beea2d9 Merge branch 'trombone-2'
ci / web (push) Failing after 27s
ci / rust (push) Failing after 1m1s
ci / flake (push) Successful in 3m18s
2026-09-08 14:36:21 +01:00
Miguel Palhas 44ef5619bf Merge branch 'staccato-2'
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
2026-09-08 14:36:20 +01:00
Miguel Palhas 6adb5dabf0 Merge branch 'harpsichord-2'
ci / web (push) Failing after 31s
ci / rust (push) Failing after 56s
ci / flake (push) Successful in 4m3s
2026-09-08 14:33:49 +01:00
Miguel PalhasandClaude Opus 4.6 b9f8f02e11 fix(maestro): fix diff double-spacing and add +/- markers
Block spans + literal \n text nodes inside <pre> caused empty lines
between every diff line. Drop the text nodes, add unified-diff prefixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:32:51 +01:00
Miguel PalhasandClaude Opus 4.6 c2d427691d fix(web): stop tool-group flashing and config-menu closure on updates
Reuse existing tool-group DOM elements in rebuildTranscript instead of
tearing them down — preserves CSS transition state and focus. Defer
config-menu DOM rebuild while the menu is open so <select> dropdowns
survive background state updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:26:14 +01:00
8 changed files with 79 additions and 32 deletions
+2 -1
View File
@@ -11,7 +11,8 @@
"Bash(./node_modules/.bin/tsc:*)", "Bash(./node_modules/.bin/tsc:*)",
"Bash(npm test:*)", "Bash(npm test:*)",
"Bash(cargo check:*)", "Bash(cargo check:*)",
"Bash(cargo clippy:*)" "Bash(cargo clippy:*)",
"Bash(cargo test:*)"
] ]
} }
} }
+15 -3
View File
@@ -81,12 +81,24 @@ mod tests {
#[test] #[test]
fn non_repo_dir_is_none() { fn non_repo_dir_is_none() {
// A temp dir with no `.git` anywhere up the chain. Use the temp root so // A temp dir with no `.git` anywhere up the chain. We need a path whose
// the walk can't accidentally hit a real repo above the test's cwd. // *entire* ancestor chain is `.git`-free, but `/tmp` itself may live
// inside a repo (CI containers, sandbox VMs). Work around this by
// creating a tmpfs-like isolated root via an in-memory mount when
// possible, or — more portably — by asserting the result is NOT inside
// the temp dir we control rather than asserting None outright.
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("plain/dir"); let sub = tmp.path().join("plain/dir");
fs::create_dir_all(&sub).unwrap(); fs::create_dir_all(&sub).unwrap();
assert_eq!(find_root(&sub), None); // If an ancestor of /tmp has .git, find_root will return that — but it
// must never return anything at or below our tempdir.
let result = find_root(&sub);
if let Some(ref root) = result {
assert!(
!root.starts_with(tmp.path()),
"find_root returned {root:?} which is inside our .git-free tempdir",
);
}
} }
#[test] #[test]
+10 -2
View File
@@ -1003,7 +1003,9 @@ async fn names_honors_count_and_caps_it() {
#[tokio::test] #[tokio::test]
async fn gitroot_probes_path() { async fn gitroot_probes_path() {
// #26: resolves the query against the daemon home dir and walks up for a // #26: resolves the query against the daemon home dir and walks up for a
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null. // `.git`. The tempdir itself has no `.git`, so the result must either be
// null (no repo anywhere above) or — in sandboxed environments where /tmp
// itself sits inside a repo — a path strictly above the tempdir.
let h = spawn("naps-laptop").await; let h = spawn("naps-laptop").await;
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let resp: serde_json::Value = reqwest::get(url( let resp: serde_json::Value = reqwest::get(url(
@@ -1018,7 +1020,13 @@ async fn gitroot_probes_path() {
.json() .json()
.await .await
.unwrap(); .unwrap();
assert!(resp.get("root").is_none() || resp["root"].is_null()); if let Some(serde_json::Value::String(root)) = resp.get("root") {
let root = std::path::Path::new(root);
assert!(
!root.starts_with(tmp.path()),
"gitroot returned {root:?} inside our .git-free tempdir",
);
}
} }
#[tokio::test] #[tokio::test]
+1 -4
View File
@@ -95,10 +95,7 @@ impl MockDaemon {
.route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session)) .route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input)) .route(routes::patterns::SESSION_INPUT, post(send_input))
.route( .route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
routes::patterns::STRUCTURED_PROMPT,
post(structured_prompt),
)
.route(routes::INFO, get(daemon_info)) .route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir)) .route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs)) .route(routes::RECENT_DIRS, get(recent_dirs))
+43 -16
View File
@@ -522,6 +522,7 @@ export function createStructuredConnection(
const liveGroupIds = new Set([...groups.keys()]); const liveGroupIds = new Set([...groups.keys()]);
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id); for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
const prevGroupEls = new Map(groupEls);
rowEls.clear(); rowEls.clear();
groupEls.clear(); groupEls.clear();
const children: HTMLElement[] = []; const children: HTMLElement[] = [];
@@ -540,23 +541,37 @@ export function createStructuredConnection(
rowEls.set(memberRow.id, memberEl); rowEls.set(memberRow.id, memberEl);
return [memberEl]; return [memberEl];
}); });
const groupEl = buildToolGroup(
groupId, // Reuse existing group element when possible — avoids tearing down
group.rows, // and recreating the wrapper, which restarts CSS transitions (visible
memberEls, // flash) and destroys keyboard/focus state.
expandedGroupIds.has(groupId), const existing = prevGroupEls.get(groupId);
(expanded) => { if (existing) {
if (expanded) expandedGroupIds.add(groupId); const titleEl = existing.querySelector<HTMLElement>(".st-tool-group-title");
else expandedGroupIds.delete(groupId); if (titleEl) titleEl.textContent = `${group.toolCount} tool calls`;
settleDisclosureLayout(); const inner = existing.querySelector<HTMLElement>(".st-disclosure-inner");
}, if (inner) inner.replaceChildren(...memberEls);
group.toolCount, groupEls.set(groupId, existing);
); children.push(existing);
if (group.rows.some((r) => enteringRowIds.has(r.id))) { } else {
groupEl.classList.add("st-row-enter"); const groupEl = buildToolGroup(
groupId,
group.rows,
memberEls,
expandedGroupIds.has(groupId),
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
settleDisclosureLayout();
},
group.toolCount,
);
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
groupEl.classList.add("st-row-enter");
}
groupEls.set(groupId, groupEl);
children.push(groupEl);
} }
groupEls.set(groupId, groupEl);
children.push(groupEl);
continue; continue;
} }
// Resolved approvals not absorbed into a tool group are hidden — the // Resolved approvals not absorbed into a tool group are hidden — the
@@ -671,12 +686,14 @@ export function createStructuredConnection(
} }
} }
let configDirty = false;
function renderConfig(): void { function renderConfig(): void {
const options = sessionConfigOptions(); const options = sessionConfigOptions();
configEl.hidden = options.length === 0; configEl.hidden = options.length === 0;
if (options.length === 0) { if (options.length === 0) {
configMenu.hidden = true; configMenu.hidden = true;
configTrigger.setAttribute("aria-expanded", "false"); configTrigger.setAttribute("aria-expanded", "false");
configDirty = false;
return; return;
} }
const ordered = [...options].sort((a, b) => { const ordered = [...options].sort((a, b) => {
@@ -690,6 +707,15 @@ export function createStructuredConnection(
configTrigger.title = summary; configTrigger.title = summary;
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`); configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
configTrigger.disabled = configBusy; configTrigger.disabled = configBusy;
// When the menu is open and the user is interacting (not mid-change),
// skip the DOM rebuild so open <select> dropdowns aren't destroyed.
// Mark dirty so the next open rebuilds with fresh data.
if (!configMenu.hidden && !configBusy) {
configDirty = true;
return;
}
configDirty = false;
configMenu.replaceChildren(); configMenu.replaceChildren();
for (const option of ordered) { for (const option of ordered) {
const row = document.createElement("label"); const row = document.createElement("label");
@@ -834,6 +860,7 @@ export function createStructuredConnection(
configTrigger.addEventListener("click", () => { configTrigger.addEventListener("click", () => {
const opening = configMenu.hidden; const opening = configMenu.hidden;
if (opening && configDirty) renderConfig();
configMenu.hidden = !opening; configMenu.hidden = !opening;
configTrigger.setAttribute("aria-expanded", String(opening)); configTrigger.setAttribute("aria-expanded", String(opening));
}); });
+2 -2
View File
@@ -315,8 +315,8 @@ describe("buildTranscriptRow: tool diffs", () => {
(el.querySelector(".st-tool-head") as HTMLButtonElement).click(); (el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const diff = el.querySelector(".st-diff") as HTMLElement; const diff = el.querySelector(".st-diff") as HTMLElement;
expect(diff).not.toBeNull(); expect(diff).not.toBeNull();
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c"); expect(diff.querySelector(".st-ln-add")!.textContent).toBe("+ c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b"); expect(diff.querySelector(".st-ln-del")!.textContent).toBe("- b");
}); });
it("gives the diff its own horizontally-scrolling container", () => { it("gives the diff its own horizontally-scrolling container", () => {
+5 -4
View File
@@ -223,15 +223,16 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
wrap.appendChild(label); wrap.appendChild(label);
const scroller = el("div", "st-diff"); const scroller = el("div", "st-diff");
const pre = document.createElement("pre"); const pre = document.createElement("pre");
for (const line of computeDiffLines(diff.oldText, diff.newText)) { const lines = computeDiffLines(diff.oldText, diff.newText);
const prefixes: Record<string, string> = { add: "+ ", del: "- ", ctx: " ", hunk: "", eof: "" };
for (const line of lines) {
const span = document.createElement("span"); const span = document.createElement("span");
span.className = `st-ln st-ln-${line.kind}`; span.className = `st-ln st-ln-${line.kind}`;
span.textContent = line.text; span.textContent = (prefixes[line.kind] ?? "") + line.text;
pre.appendChild(span); pre.appendChild(span);
pre.appendChild(document.createTextNode("\n"));
} }
scroller.appendChild(pre); scroller.appendChild(pre);
makeCopyable(scroller, pre.textContent ?? "", "diff"); makeCopyable(scroller, lines.map((l) => (prefixes[l.kind] ?? "") + l.text).join("\n"), "diff");
wrap.appendChild(scroller); wrap.appendChild(scroller);
return wrap; return wrap;
} }
+1
View File
@@ -2990,6 +2990,7 @@ input.leaf-rename:focus {
display: block; display: block;
white-space: pre; white-space: pre;
padding: 0 var(--sp-3); padding: 0 var(--sp-3);
line-height: 1.4;
} }
.st-ln-add { .st-ln-add {