Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a5c3b9982 | ||
|
|
443800bd79 | ||
|
|
d55d92d58a | ||
|
|
41770a54a7 | ||
|
|
6cf0b200f8 |
@@ -477,7 +477,7 @@ impl App {
|
|||||||
.state
|
.state
|
||||||
.lock()
|
.lock()
|
||||||
.expect("structured state mutex")
|
.expect("structured state mutex")
|
||||||
.approval_failed(error.to_string());
|
.approval_failed(&nonce, error.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,7 +289,10 @@ impl StructuredState {
|
|||||||
Some((nonce, decision))
|
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.approval_submitting = false;
|
||||||
self.error = Some(error);
|
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]
|
#[test]
|
||||||
fn composer_is_gated_by_the_projected_turn_lifecycle() {
|
fn composer_is_gated_by_the_projected_turn_lifecycle() {
|
||||||
let mut state = StructuredState::default();
|
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
|
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
|
||||||
conn.dispose();
|
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", () => {
|
describe("structured connection: turn/composer gating", () => {
|
||||||
|
|||||||
@@ -159,10 +159,15 @@ export function createStructuredConnection(
|
|||||||
|
|
||||||
function remountRow(row: TranscriptRow): void {
|
function remountRow(row: TranscriptRow): void {
|
||||||
const old = rowEls.get(row.id);
|
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);
|
rowEls.set(row.id, el);
|
||||||
if (old) old.replaceWith(el);
|
if (old) old.replaceWith(el);
|
||||||
else rowsEl.appendChild(el);
|
else rowsEl.appendChild(el);
|
||||||
|
const newToolHead = el.querySelector<HTMLButtonElement>(".st-tool-head");
|
||||||
|
if (restoreFocus) newToolHead?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ function statusMeta(status: string | undefined, hasDiff: boolean): { text: strin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolRow(row: TranscriptRow): HTMLElement {
|
function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement {
|
||||||
const tool = (row.data ?? {}) as AcpToolCallLike;
|
const tool = (row.data ?? {}) as AcpToolCallLike;
|
||||||
const content = tool.content ?? [];
|
const content = tool.content ?? [];
|
||||||
const hasDiff = content.some((c) => c.type === "diff");
|
const hasDiff = content.some((c) => c.type === "diff");
|
||||||
@@ -251,13 +251,16 @@ function buildToolRow(row: TranscriptRow): HTMLElement {
|
|||||||
};
|
};
|
||||||
card.appendChild(out);
|
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", () => {
|
head.addEventListener("click", () => {
|
||||||
const expanded = head.getAttribute("aria-expanded") === "true";
|
setExpanded(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(expanded);
|
||||||
|
|
||||||
return wrap;
|
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
|
/** Build the DOM for one transcript row. `approval` is consulted only for an
|
||||||
* `approval` row; pass it whenever the row might be that kind. */
|
* `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;
|
let built: HTMLElement;
|
||||||
switch (row.kind) {
|
switch (row.kind) {
|
||||||
case "user":
|
case "user":
|
||||||
@@ -375,7 +382,7 @@ export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowSta
|
|||||||
break;
|
break;
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
case "tool_diff":
|
case "tool_diff":
|
||||||
built = buildToolRow(row);
|
built = buildToolRow(row, toolExpanded);
|
||||||
break;
|
break;
|
||||||
case "approval":
|
case "approval":
|
||||||
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
||||||
|
|||||||
Reference in New Issue
Block a user