Compare commits

..
5 changed files with 208 additions and 11 deletions
+1 -1
View File
@@ -477,7 +477,7 @@ impl App {
.state
.lock()
.expect("structured state mutex")
.approval_failed(error.to_string());
.approval_failed(&nonce, error.to_string());
}
}
}
+79 -1
View File
@@ -289,7 +289,10 @@ impl StructuredState {
Some((nonce, decision))
}
pub fn approval_failed(&mut self, error: String) {
pub fn approval_failed(&mut self, nonce: &str, error: String) {
if self.pending_nonce() != Some(nonce) {
return;
}
self.approval_submitting = false;
self.error = Some(error);
}
@@ -554,6 +557,81 @@ mod tests {
);
}
#[test]
fn resolved_approval_ignores_its_late_failure() {
let mut state = StructuredState::default();
state.rows.push(row(
"approval:1",
TranscriptRowKind::Approval,
json!({"options":[{"kind":"allow_once"}]}),
));
state.selected = Some("approval:1".into());
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
assert!(state.approval_decision('a').is_some());
state.apply(StructuredEvent::ProjectionUpdate {
transcript: None,
state: ReducedState::default(),
});
state.approval_failed("old", "response lost".into());
assert_eq!(state.error, None);
assert!(!state.approval_submitting);
}
#[test]
fn superseded_approval_ignores_its_late_failure() {
let mut state = StructuredState::default();
state.rows.push(row(
"approval:1",
TranscriptRowKind::Approval,
json!({"options":[{"kind":"allow_once"}]}),
));
state.selected = Some("approval:1".into());
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
assert!(state.approval_decision('a').is_some());
state.rows.push(row(
"approval:2",
TranscriptRowKind::Approval,
json!({"options":[{"kind":"allow_once"}]}),
));
state.apply(StructuredEvent::ProjectionUpdate {
transcript: None,
state: ReducedState {
pending_approval: Some(json!({"nonce":"new"})),
..ReducedState::default()
},
});
assert_eq!(
state.approval_decision('a'),
Some(("new".into(), StructuredApprovalDecision::AllowOnce))
);
state.approval_failed("old", "response lost".into());
assert_eq!(state.error, None);
assert!(state.approval_submitting);
}
#[test]
fn matching_approval_surfaces_its_failure() {
let mut state = StructuredState::default();
state.rows.push(row(
"approval:1",
TranscriptRowKind::Approval,
json!({"options":[{"kind":"allow_once"}]}),
));
state.selected = Some("approval:1".into());
state.reduced.pending_approval = Some(json!({"nonce":"current"}));
assert!(state.approval_decision('a').is_some());
state.approval_failed("current", "request failed".into());
assert_eq!(state.error.as_deref(), Some("request failed"));
assert!(!state.approval_submitting);
}
#[test]
fn composer_is_gated_by_the_projected_turn_lifecycle() {
let mut state = StructuredState::default();
+107
View File
@@ -115,6 +115,113 @@ describe("structured connection: data flow", () => {
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
conn.dispose();
});
it("keeps an expanded running tool open without emitting a click when patched", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [
row("tool:1", "tool_call", {
title: "Bash(pnpm test)",
status: "in_progress",
content: [{ type: "content", content: { type: "text", text: "starting" } }],
}),
],
},
});
ws.emit({ type: "reduced_state", state: idleState });
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
originalHead.click();
expect(originalHead.getAttribute("aria-expanded")).toBe("true");
const clickListener = vi.fn();
window.addEventListener("click", clickListener, { capture: true, once: true });
ws.emit({
type: "projection_update",
transcript: {
type: "patch",
row: row("tool:1", "tool_call", {
title: "Bash(pnpm test)",
status: "in_progress",
content: [{ type: "content", content: { type: "text", text: "still running" } }],
}),
},
state: idleState,
});
expect(clickListener).not.toHaveBeenCalled();
window.removeEventListener("click", clickListener, true);
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
expect(patchedHead).not.toBe(originalHead);
expect(patchedHead.getAttribute("aria-expanded")).toBe("true");
expect(conn.el.querySelector<HTMLElement>(".st-tool-out")!.hidden).toBe(false);
expect(conn.el.textContent).toContain("still running");
conn.dispose();
});
it("restores focus to a patched tool header with the same stable row id", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })],
},
});
ws.emit({ type: "reduced_state", state: idleState });
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
originalHead.focus();
expect(document.activeElement).toBe(originalHead);
ws.emit({
type: "projection_update",
transcript: {
type: "patch",
row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }),
},
state: idleState,
});
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
expect(patchedHead).not.toBe(originalHead);
expect(document.activeElement).toBe(patchedHead);
conn.dispose();
});
it("does not move unrelated focus when a tool row is patched", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })],
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
input.focus();
ws.emit({
type: "projection_update",
transcript: {
type: "patch",
row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }),
},
state: idleState,
});
expect(document.activeElement).toBe(input);
conn.dispose();
});
});
describe("structured connection: turn/composer gating", () => {
+6 -1
View File
@@ -159,10 +159,15 @@ export function createStructuredConnection(
function remountRow(row: TranscriptRow): void {
const old = rowEls.get(row.id);
const el = buildTranscriptRow(row, approvalStateFor(row));
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
const wasExpanded = oldToolHead?.getAttribute("aria-expanded") === "true";
const restoreFocus = document.activeElement === oldToolHead;
const el = buildTranscriptRow(row, approvalStateFor(row), wasExpanded);
rowEls.set(row.id, el);
if (old) old.replaceWith(el);
else rowsEl.appendChild(el);
const newToolHead = el.querySelector<HTMLButtonElement>(".st-tool-head");
if (restoreFocus) newToolHead?.focus();
}
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
+15 -8
View File
@@ -187,7 +187,7 @@ function statusMeta(status: string | undefined, hasDiff: boolean): { text: strin
return null;
}
function buildToolRow(row: TranscriptRow): HTMLElement {
function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement {
const tool = (row.data ?? {}) as AcpToolCallLike;
const content = tool.content ?? [];
const hasDiff = content.some((c) => c.type === "diff");
@@ -251,13 +251,16 @@ function buildToolRow(row: TranscriptRow): HTMLElement {
};
card.appendChild(out);
const setExpanded = (nextExpanded: boolean) => {
if (nextExpanded) buildOut();
if (out.children.length === 0) nextExpanded = false;
head.setAttribute("aria-expanded", String(nextExpanded));
out.hidden = !nextExpanded;
};
head.addEventListener("click", () => {
const expanded = head.getAttribute("aria-expanded") === "true";
if (!expanded) buildOut();
if (out.children.length === 0) return; // nothing to show — stay collapsed
head.setAttribute("aria-expanded", String(!expanded));
out.hidden = expanded;
setExpanded(head.getAttribute("aria-expanded") !== "true");
});
setExpanded(expanded);
return wrap;
}
@@ -361,7 +364,11 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
/** Build the DOM for one transcript row. `approval` is consulted only for an
* `approval` row; pass it whenever the row might be that kind. */
export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowState): HTMLElement {
export function buildTranscriptRow(
row: TranscriptRow,
approval?: ApprovalRowState,
toolExpanded = false,
): HTMLElement {
let built: HTMLElement;
switch (row.kind) {
case "user":
@@ -375,7 +382,7 @@ export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowSta
break;
case "tool_call":
case "tool_diff":
built = buildToolRow(row);
built = buildToolRow(row, toolExpanded);
break;
case "approval":
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });