Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbdcec6f69 | ||
|
|
6af4cda650 | ||
|
|
ab4beea2d9 | ||
|
|
44ef5619bf | ||
|
|
6adb5dabf0 | ||
|
|
e9c9e1c647 | ||
|
|
4167b21fe9 | ||
|
|
b9f8f02e11 | ||
|
|
c2d427691d |
@@ -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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -824,7 +824,9 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
expect(send.disabled).toBe(false);
|
expect(send.disabled).toBe(false);
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
||||||
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued");
|
const queued = conn.el.querySelectorAll(".st-row-queued");
|
||||||
|
expect(queued).toHaveLength(1);
|
||||||
|
expect(queued[0].textContent).toBe("queued next");
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -844,7 +846,7 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
submit("second");
|
submit("second");
|
||||||
submit("third");
|
submit("third");
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||||
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("2 messages queued");
|
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(2);
|
||||||
|
|
||||||
await Promise.resolve().then(() => Promise.resolve());
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
ws.emit({ type: "reduced_state", state: idleState });
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
@@ -853,7 +855,7 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
await Promise.resolve().then(() => Promise.resolve());
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
ws.emit({ type: "reduced_state", state: idleState });
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
|
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
|
||||||
expect((conn.el.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true);
|
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(0);
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+60
-22
@@ -117,7 +117,6 @@ export function createStructuredConnection(
|
|||||||
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
|
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
|
||||||
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
|
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="structured-queue" role="status" aria-live="polite" hidden></span>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +138,6 @@ export function createStructuredConnection(
|
|||||||
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
||||||
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
|
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
|
||||||
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||||
const queueEl = el.querySelector(".structured-queue") as HTMLSpanElement;
|
|
||||||
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
|
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
|
||||||
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
||||||
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
||||||
@@ -524,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[] = [];
|
||||||
@@ -542,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
|
||||||
@@ -673,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) => {
|
||||||
@@ -692,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");
|
||||||
@@ -730,6 +754,22 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderQueuedPrompts(): void {
|
||||||
|
for (const old of rowsEl.querySelectorAll(".st-row-queued")) old.remove();
|
||||||
|
for (const queued of promptQueue) {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "st-row st-row-queued";
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "st-body";
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "st-user-text";
|
||||||
|
line.textContent = queued.text;
|
||||||
|
body.appendChild(line);
|
||||||
|
wrap.appendChild(body);
|
||||||
|
rowsEl.appendChild(wrap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderComposerState(): void {
|
function renderComposerState(): void {
|
||||||
const projectedActive = state.turn.responding || state.turn.thinking;
|
const projectedActive = state.turn.responding || state.turn.thinking;
|
||||||
input.disabled = false;
|
input.disabled = false;
|
||||||
@@ -740,10 +780,7 @@ export function createStructuredConnection(
|
|||||||
sendBtn.hidden = false;
|
sendBtn.hidden = false;
|
||||||
sendLabel.textContent = "Send";
|
sendLabel.textContent = "Send";
|
||||||
cancelBtn.hidden = !projectedActive;
|
cancelBtn.hidden = !projectedActive;
|
||||||
queueEl.hidden = promptQueue.length === 0;
|
renderQueuedPrompts();
|
||||||
queueEl.textContent = promptQueue.length === 1
|
|
||||||
? "1 message queued"
|
|
||||||
: `${promptQueue.length} messages queued`;
|
|
||||||
renderActivity();
|
renderActivity();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-12
@@ -2375,6 +2375,22 @@ input.leaf-rename:focus {
|
|||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Queued messages: optimistic user bubbles, not yet sent. */
|
||||||
|
.st-row-queued {
|
||||||
|
justify-content: flex-end;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-row-queued .st-body {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: min(86%, 72ch);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
background: var(--surface-3);
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: blur(0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
.st-user-text {
|
.st-user-text {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -2974,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 {
|
||||||
@@ -3121,18 +3138,6 @@ input.leaf-rename:focus {
|
|||||||
transition: border-color var(--dur-fast) var(--ease-out);
|
transition: border-color var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.structured-queue {
|
|
||||||
position: absolute;
|
|
||||||
right: var(--sp-2);
|
|
||||||
bottom: calc(100% + var(--sp-1));
|
|
||||||
padding: 3px var(--sp-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--r-pill);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: var(--fs-11);
|
|
||||||
box-shadow: var(--shadow-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.structured-composer:focus-within {
|
.structured-composer:focus-within {
|
||||||
border-top-color: var(--border);
|
border-top-color: var(--border);
|
||||||
|
|||||||
Reference in New Issue
Block a user