Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f5b71d563 | ||
|
|
de603ccd20 | ||
|
|
c6af78e9dd | ||
|
|
7ab503e4f5 | ||
|
|
eaf11a7696 | ||
|
|
fffd2a55f3 | ||
|
|
9dead5652e |
@@ -777,7 +777,7 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
||||
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
||||
}
|
||||
|
||||
// Force overrides the refusal and nothing else: session gone, tree kept.
|
||||
// Force overrides the refusal: session gone, tree removed.
|
||||
client.kill_session_forced(s.id).await.expect("forced kill");
|
||||
|
||||
assert!(
|
||||
@@ -789,12 +789,12 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
||||
.any(|x| x.id == s.id),
|
||||
"the forced kill ends the session"
|
||||
);
|
||||
assert!(wt.is_dir(), "a guarded worktree must stay on disk");
|
||||
assert!(!wt.is_dir(), "a forced kill removes the worktree");
|
||||
assert!(
|
||||
git_worktree_list(base.path())
|
||||
!git_worktree_list(base.path())
|
||||
.iter()
|
||||
.any(|l| l.contains("feature/local")),
|
||||
"the kept tree stays registered in git"
|
||||
"the removed tree is unregistered from git"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+11
-8
@@ -189,14 +189,17 @@ choices, not daemon ones.
|
||||
presentation for `Notice`. `Plan` is the exception: both clients omit it
|
||||
from the transcript; the TUI may show the current plan in its status line,
|
||||
while the web client deliberately has no stats or plan rail.
|
||||
- The web client groups two or more tool rows after the same `User` row into
|
||||
one collapsed turn activity block. `AgentText` and `AgentThought` rows stay
|
||||
outside the group as standalone transcript items — the operator sees all
|
||||
reasoning without expanding. Resolved approval rows between tools are
|
||||
absorbed into the group. The collapsed header reads "N tool calls · Label
|
||||
'arg'" where the label and argument come from the last tool row's
|
||||
`splitToolTitle` — the operator sees which command is current at a glance.
|
||||
Opening the group reveals tool rows in chronological order.
|
||||
- The web client groups each contiguous run of two or more tool rows into a
|
||||
collapsed activity block. An `AgentText` or `AgentThought` row between
|
||||
tools splits them into separate groups, so the timeline reads
|
||||
text → group → text → group rather than lumping every tool in a turn into
|
||||
one box. Text and thought rows stay outside groups as standalone transcript
|
||||
items — the operator sees all reasoning without expanding. Resolved
|
||||
approval rows between tools within a run are absorbed into the group. The
|
||||
collapsed header reads "N tool calls · Label 'arg'" where the label and
|
||||
argument come from the last tool row's `splitToolTitle` — the operator
|
||||
sees which command is current at a glance. Opening the group reveals tool
|
||||
rows in chronological order.
|
||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||
copy their complete source text when clicked or keyboard-activated.
|
||||
- The web client consumes `available_commands_update` notices as the live
|
||||
|
||||
@@ -323,6 +323,26 @@ describe("prompt and cancel", () => {
|
||||
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up"));
|
||||
});
|
||||
|
||||
it("concatenates multiple queued messages into a single prompt", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [], busy);
|
||||
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
const form = document.querySelector("form.structured-composer")!;
|
||||
|
||||
fireEvent.change(input, { target: { value: "first" } });
|
||||
fireEvent.submit(form);
|
||||
fireEvent.change(input, { target: { value: "second" } });
|
||||
fireEvent.submit(form);
|
||||
|
||||
expect(sendStructuredPrompt).not.toHaveBeenCalled();
|
||||
const queued = document.querySelectorAll(".st-row-queued");
|
||||
expect(queued).toHaveLength(2);
|
||||
|
||||
socket.emit({ type: "reduced_state", state: idle });
|
||||
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "first\n\nsecond"));
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hands a failed send's draft back to the operator", async () => {
|
||||
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
|
||||
const { socket } = mount();
|
||||
|
||||
@@ -251,14 +251,16 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
||||
[sessionId, socketGeneration, session],
|
||||
);
|
||||
|
||||
// A queued message goes out as soon as the projected turn is idle — the
|
||||
// daemon answers a second prompt `409 turn_in_flight`, so the queue is
|
||||
// client-local (docs/STRUCTURED-VIEW.md).
|
||||
// Queued messages are concatenated into a single prompt so the agent sees
|
||||
// one user turn instead of N back-to-back turns. The daemon rejects a
|
||||
// second prompt while a turn is in flight (`409 turn_in_flight`), so the
|
||||
// queue is client-local (docs/STRUCTURED-VIEW.md).
|
||||
useEffect(() => {
|
||||
if (active || promptPending.current || queue.length === 0) return;
|
||||
const [next, ...rest] = queue;
|
||||
setQueue(rest);
|
||||
dispatchPrompt(next.text, next.attachments);
|
||||
const text = queue.map((q) => q.text).join("\n\n");
|
||||
const files = queue.flatMap((q) => q.attachments);
|
||||
setQueue([]);
|
||||
dispatchPrompt(text, files);
|
||||
}, [active, queue, dispatchPrompt]);
|
||||
|
||||
const submit = (text: string, files: File[]) => {
|
||||
|
||||
@@ -115,8 +115,8 @@ describe("activity grouping", () => {
|
||||
it("groups a turn's tools once there are two of them", () => {
|
||||
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2")];
|
||||
const groups = activityGroupsByTurn(rows, null, false);
|
||||
expect([...groups.keys()]).toEqual(["turn:user:1"]);
|
||||
expect(groups.get("turn:user:1")).toMatchObject({ toolCount: 2 });
|
||||
expect([...groups.keys()]).toEqual(["turn:user:1:0"]);
|
||||
expect(groups.get("turn:user:1:0")).toMatchObject({ toolCount: 2 });
|
||||
});
|
||||
|
||||
it("leaves a single tool call standalone", () => {
|
||||
@@ -124,7 +124,7 @@ describe("activity grouping", () => {
|
||||
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps intermediate text outside the group as standalone items", () => {
|
||||
it("splits tools into separate runs when text appears between them", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
@@ -132,9 +132,23 @@ describe("activity grouping", () => {
|
||||
tool("tool:2"),
|
||||
text("agent:2", "agent_text", "done"),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
|
||||
expect(group.toolCount).toBe(2);
|
||||
// Each run has only one tool, so neither qualifies for grouping.
|
||||
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
|
||||
});
|
||||
|
||||
it("groups contiguous tools separated by text into distinct groups", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
tool("tool:2"),
|
||||
text("agent:1", "agent_text", "working"),
|
||||
tool("tool:3"),
|
||||
tool("tool:4"),
|
||||
];
|
||||
const groups = activityGroupsByTurn(rows, null, false);
|
||||
expect([...groups.keys()]).toEqual(["turn:user:1:0", "turn:user:1:1"]);
|
||||
expect(groups.get("turn:user:1:0")!.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
|
||||
expect(groups.get("turn:user:1:1")!.rows.map((r) => r.id)).toEqual(["tool:3", "tool:4"]);
|
||||
});
|
||||
|
||||
it("keeps thoughts outside the group as standalone items", () => {
|
||||
@@ -145,7 +159,7 @@ describe("activity grouping", () => {
|
||||
text("thought:1", "agent_thought", "hmm"),
|
||||
text("thought:2", "agent_thought", "aha"),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1:0")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
|
||||
});
|
||||
|
||||
@@ -155,7 +169,7 @@ describe("activity grouping", () => {
|
||||
row("tool:1", "tool_call", { title: "Read(src/main.ts)", status: "completed" }),
|
||||
row("tool:2", "tool_call", { title: "Execute(git status)", kind: "execute", status: "completed" }),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1:0")!;
|
||||
expect(group.lastToolLabel).toBe("Execute");
|
||||
expect(group.lastToolArg).toBe("git status");
|
||||
});
|
||||
@@ -169,7 +183,7 @@ describe("activity grouping", () => {
|
||||
row("approval:2", "approval"),
|
||||
tool("tool:3"),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1")!;
|
||||
const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1:0")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "approval:1", "tool:2", "tool:3"]);
|
||||
});
|
||||
});
|
||||
@@ -180,11 +194,31 @@ describe("transcript render list", () => {
|
||||
const items = transcriptItems(rows, null, false);
|
||||
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
|
||||
"user:1",
|
||||
"turn:user:1",
|
||||
"turn:user:1:0",
|
||||
"agent:1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("interleaves groups and text in timeline order", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
tool("tool:2"),
|
||||
text("agent:1", "agent_text", "working"),
|
||||
tool("tool:3"),
|
||||
tool("tool:4"),
|
||||
text("agent:2", "agent_text", "done"),
|
||||
];
|
||||
const items = transcriptItems(rows, null, false);
|
||||
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
|
||||
"user:1",
|
||||
"turn:user:1:0",
|
||||
"agent:1",
|
||||
"turn:user:1:1",
|
||||
"agent:2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits plan rows, state-only notices and standalone resolved approvals", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
|
||||
@@ -155,57 +155,68 @@ export interface ActivityGroup {
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tool rows (and resolved approvals between them) by turn. Text and
|
||||
* thought rows stay outside the group as standalone transcript items — the
|
||||
* operator sees all reasoning without expanding anything.
|
||||
* Group contiguous tool rows (and resolved approvals between them) into
|
||||
* collapsible blocks. An `AgentText` or `AgentThought` row between tools
|
||||
* splits the run — each contiguous stretch of tools becomes its own group,
|
||||
* so the timeline reads text → group → text → group rather than lumping
|
||||
* every tool in a turn into one box.
|
||||
*
|
||||
* Returns groups keyed by `turn:<userId>`.
|
||||
* A group must contain at least two tools to qualify; a single tool stays
|
||||
* standalone. Returns groups keyed by `turn:<userId>:<seq>`.
|
||||
*/
|
||||
export function activityGroupsByTurn(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
_turnActive: boolean,
|
||||
): Map<string, ActivityGroup> {
|
||||
// First: count tools per turn to know which turns qualify for grouping.
|
||||
const toolCounts = new Map<string, number>();
|
||||
let turnId = "before-first-message";
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") turnId = row.id;
|
||||
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
|
||||
const gid = `turn:${turnId}`;
|
||||
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
||||
}
|
||||
// Second: build groups for turns with 2+ tools.
|
||||
const groups = new Map<string, ActivityGroup>();
|
||||
turnId = "before-first-message";
|
||||
let pending: TranscriptRow[] = [];
|
||||
let currentGid = `turn:${turnId}`;
|
||||
let turnId = "before-first-message";
|
||||
let runSeq = 0;
|
||||
let runTools: TranscriptRow[] = [];
|
||||
let runPending: TranscriptRow[] = [];
|
||||
|
||||
const flushRun = () => {
|
||||
if (runTools.length >= 2) {
|
||||
const gid = `turn:${turnId}:${runSeq}`;
|
||||
const tool = (runTools[runTools.length - 1].data ?? {}) as { title?: string; name?: string | null; kind?: string };
|
||||
const { label, arg } = splitToolTitle(tool.title, tool.name ?? undefined, tool.kind);
|
||||
groups.set(gid, {
|
||||
toolCount: runTools.length,
|
||||
rows: [...runTools],
|
||||
lastToolLabel: label,
|
||||
lastToolArg: arg,
|
||||
});
|
||||
runSeq++;
|
||||
}
|
||||
runTools = [];
|
||||
runPending = [];
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") {
|
||||
flushRun();
|
||||
turnId = row.id;
|
||||
currentGid = `turn:${turnId}`;
|
||||
pending = [];
|
||||
runSeq = 0;
|
||||
continue;
|
||||
}
|
||||
// Text and thought rows break the current tool run.
|
||||
if (row.kind === "agent_text" || row.kind === "agent_thought") {
|
||||
flushRun();
|
||||
continue;
|
||||
}
|
||||
const count = toolCounts.get(currentGid) ?? 0;
|
||||
if (count < 2) continue;
|
||||
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
|
||||
if (isResolvedApproval) {
|
||||
pending.push(row);
|
||||
runPending.push(row);
|
||||
continue;
|
||||
}
|
||||
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
||||
const group = groups.get(currentGid) ?? { toolCount: count, rows: [], lastToolLabel: "", lastToolArg: "" };
|
||||
group.rows.push(...pending);
|
||||
pending = [];
|
||||
group.rows.push(row);
|
||||
const tool = (row.data ?? {}) as { title?: string; name?: string | null; kind?: string };
|
||||
const { label, arg } = splitToolTitle(tool.title, tool.name ?? undefined, tool.kind);
|
||||
group.lastToolLabel = label;
|
||||
group.lastToolArg = arg;
|
||||
groups.set(currentGid, group);
|
||||
// Flush any pending resolved approvals into the run before this tool.
|
||||
runTools.push(...runPending);
|
||||
runPending = [];
|
||||
runTools.push(row);
|
||||
}
|
||||
}
|
||||
flushRun();
|
||||
|
||||
return groups;
|
||||
}
|
||||
@@ -217,10 +228,10 @@ export type TranscriptItem =
|
||||
| { type: "group"; id: string; group: ActivityGroup };
|
||||
|
||||
/**
|
||||
* The transcript in render order: rows, with each turn's tool activity folded
|
||||
* into a single group item where one exists. Resolved approvals not absorbed
|
||||
* into a group are dropped — the tool row itself already identifies the
|
||||
* action. `plan` rows and state-only notices are dropped too (both clients
|
||||
* The transcript in render order: rows, with each contiguous tool stretch
|
||||
* folded into a single group item where one exists. Resolved approvals not
|
||||
* absorbed into a group are dropped — the tool row itself already identifies
|
||||
* the action. `plan` rows and state-only notices are dropped too (both clients
|
||||
* omit `Plan` from the transcript). A turn footer only survives as the final
|
||||
* item, so old footers stop reading like transcript content.
|
||||
*/
|
||||
@@ -230,21 +241,19 @@ export function transcriptItems(
|
||||
turnActive: boolean,
|
||||
): TranscriptItem[] {
|
||||
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
|
||||
const grouped = new Set<string>();
|
||||
for (const group of groups.values()) for (const row of group.rows) grouped.add(row.id);
|
||||
// Reverse index: row id → group id that contains it.
|
||||
const rowToGroup = new Map<string, string>();
|
||||
for (const [groupId, group] of groups) for (const r of group.rows) rowToGroup.set(r.id, groupId);
|
||||
|
||||
const items: TranscriptItem[] = [];
|
||||
let turnId = "before-first-message";
|
||||
const emitted = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") turnId = row.id;
|
||||
const groupId = `turn:${turnId}`;
|
||||
const group = groups.get(groupId);
|
||||
if (group && grouped.has(row.id)) {
|
||||
const groupId = rowToGroup.get(row.id);
|
||||
if (groupId !== undefined) {
|
||||
// The group renders once, at its first member's position.
|
||||
if (!emitted.has(groupId)) {
|
||||
emitted.add(groupId);
|
||||
items.push({ type: "group", id: groupId, group });
|
||||
items.push({ type: "group", id: groupId, group: groups.get(groupId)! });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -196,12 +196,24 @@ export function useStructuredSession(
|
||||
connect();
|
||||
};
|
||||
|
||||
// Mobile browsers suspend timers and silently kill WebSockets in the
|
||||
// background. Kick a fresh connect when the page becomes visible again.
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState !== "visible" || disposed) return;
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
|
||||
cancelTimer();
|
||||
setStatus("reconnecting");
|
||||
connect();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
setStatus("connecting");
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
reconnectRef.current = () => {};
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
cancelTimer();
|
||||
closeSocket();
|
||||
};
|
||||
|
||||
@@ -302,6 +302,24 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
|
||||
ta?.addEventListener("focus", onTaFocus);
|
||||
ta?.addEventListener("blur", onTaBlur);
|
||||
|
||||
// Mobile browsers (Android especially) suspend timers and silently kill
|
||||
// WebSockets when the app goes to background. The backoff timer set by
|
||||
// onclose is frozen too, so recovery stalls until visibility returns. Kick
|
||||
// a fresh connect immediately when the page becomes visible again.
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState !== "visible" || disposed) return;
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) return;
|
||||
// Cancel any pending backoff — we're reconnecting right now.
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
onStatusChange?.("reconnecting");
|
||||
connect();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
const visual = window.visualViewport ?? null;
|
||||
visual?.addEventListener("resize", onResize);
|
||||
@@ -326,6 +344,7 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
|
||||
reconnectTimer = null;
|
||||
}
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
window.removeEventListener("resize", onResize);
|
||||
visual?.removeEventListener("resize", onResize);
|
||||
ta?.removeEventListener("focus", onTaFocus);
|
||||
|
||||
Reference in New Issue
Block a user