Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbdcec6f69 | ||
|
|
6af4cda650 | ||
|
|
ab4beea2d9 | ||
|
|
44ef5619bf | ||
|
|
6adb5dabf0 | ||
|
|
b9f8f02e11 | ||
|
|
c2d427691d |
@@ -11,7 +11,8 @@
|
||||
"Bash(./node_modules/.bin/tsc:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(cargo clippy:*)"
|
||||
"Bash(cargo clippy:*)",
|
||||
"Bash(cargo test:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user