Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a5c3b9982 | ||
|
|
443800bd79 | ||
|
|
d55d92d58a | ||
|
|
41770a54a7 | ||
|
|
6cf0b200f8 |
@@ -477,7 +477,7 @@ impl App {
|
||||
.state
|
||||
.lock()
|
||||
.expect("structured state mutex")
|
||||
.approval_failed(error.to_string());
|
||||
.approval_failed(&nonce, error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() });
|
||||
|
||||
Reference in New Issue
Block a user