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(npm test:*)",
"Bash(cargo check:*)",
"Bash(cargo clippy:*)"
"Bash(cargo clippy:*)",
"Bash(cargo test:*)"
]
}
}
+15 -3
View File
@@ -81,12 +81,24 @@ mod tests {
#[test]
fn non_repo_dir_is_none() {
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
// the walk can't accidentally hit a real repo above the test's cwd.
// A temp dir with no `.git` anywhere up the chain. We need a path whose
// *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 sub = tmp.path().join("plain/dir");
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]
+10 -2
View File
@@ -1003,7 +1003,9 @@ async fn names_honors_count_and_caps_it() {
#[tokio::test]
async fn gitroot_probes_path() {
// #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 tmp = tempfile::tempdir().unwrap();
let resp: serde_json::Value = reqwest::get(url(
@@ -1018,7 +1020,13 @@ async fn gitroot_probes_path() {
.json()
.await
.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]
+1 -4
View File
@@ -95,10 +95,7 @@ impl MockDaemon {
.route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.route(
routes::patterns::STRUCTURED_PROMPT,
post(structured_prompt),
)
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
+43 -16
View File
@@ -522,6 +522,7 @@ export function createStructuredConnection(
const liveGroupIds = new Set([...groups.keys()]);
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
const prevGroupEls = new Map(groupEls);
rowEls.clear();
groupEls.clear();
const children: HTMLElement[] = [];
@@ -540,23 +541,37 @@ export function createStructuredConnection(
rowEls.set(memberRow.id, memberEl);
return [memberEl];
});
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");
// Reuse existing group element when possible — avoids tearing down
// and recreating the wrapper, which restarts CSS transitions (visible
// flash) and destroys keyboard/focus state.
const existing = prevGroupEls.get(groupId);
if (existing) {
const titleEl = existing.querySelector<HTMLElement>(".st-tool-group-title");
if (titleEl) titleEl.textContent = `${group.toolCount} tool calls`;
const inner = existing.querySelector<HTMLElement>(".st-disclosure-inner");
if (inner) inner.replaceChildren(...memberEls);
groupEls.set(groupId, existing);
children.push(existing);
} else {
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;
}
// Resolved approvals not absorbed into a tool group are hidden — the
@@ -671,12 +686,14 @@ export function createStructuredConnection(
}
}
let configDirty = false;
function renderConfig(): void {
const options = sessionConfigOptions();
configEl.hidden = options.length === 0;
if (options.length === 0) {
configMenu.hidden = true;
configTrigger.setAttribute("aria-expanded", "false");
configDirty = false;
return;
}
const ordered = [...options].sort((a, b) => {
@@ -690,6 +707,15 @@ export function createStructuredConnection(
configTrigger.title = summary;
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
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();
for (const option of ordered) {
const row = document.createElement("label");
@@ -834,6 +860,7 @@ export function createStructuredConnection(
configTrigger.addEventListener("click", () => {
const opening = configMenu.hidden;
if (opening && configDirty) renderConfig();
configMenu.hidden = !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();
const diff = el.querySelector(".st-diff") as HTMLElement;
expect(diff).not.toBeNull();
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("+ c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("- b");
});
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);
const scroller = el("div", "st-diff");
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");
span.className = `st-ln st-ln-${line.kind}`;
span.textContent = line.text;
span.textContent = (prefixes[line.kind] ?? "") + line.text;
pre.appendChild(span);
pre.appendChild(document.createTextNode("\n"));
}
scroller.appendChild(pre);
makeCopyable(scroller, pre.textContent ?? "", "diff");
makeCopyable(scroller, lines.map((l) => (prefixes[l.kind] ?? "") + l.text).join("\n"), "diff");
wrap.appendChild(scroller);
return wrap;
}
+1
View File
@@ -2990,6 +2990,7 @@ input.leaf-rename:focus {
display: block;
white-space: pre;
padding: 0 var(--sp-3);
line-height: 1.4;
}
.st-ln-add {