Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:?}"),
|
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");
|
client.kill_session_forced(s.id).await.expect("forced kill");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -789,12 +789,12 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
|||||||
.any(|x| x.id == s.id),
|
.any(|x| x.id == s.id),
|
||||||
"the forced kill ends the session"
|
"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!(
|
assert!(
|
||||||
git_worktree_list(base.path())
|
!git_worktree_list(base.path())
|
||||||
.iter()
|
.iter()
|
||||||
.any(|l| l.contains("feature/local")),
|
.any(|l| l.contains("feature/local")),
|
||||||
"the kept tree stays registered in git"
|
"the removed tree is unregistered from git"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -323,6 +323,26 @@ describe("prompt and cancel", () => {
|
|||||||
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up"));
|
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 () => {
|
it("hands a failed send's draft back to the operator", async () => {
|
||||||
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
|
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
|
||||||
const { socket } = mount();
|
const { socket } = mount();
|
||||||
|
|||||||
@@ -251,14 +251,16 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
|||||||
[sessionId, socketGeneration, session],
|
[sessionId, socketGeneration, session],
|
||||||
);
|
);
|
||||||
|
|
||||||
// A queued message goes out as soon as the projected turn is idle — the
|
// Queued messages are concatenated into a single prompt so the agent sees
|
||||||
// daemon answers a second prompt `409 turn_in_flight`, so the queue is
|
// one user turn instead of N back-to-back turns. The daemon rejects a
|
||||||
// client-local (docs/STRUCTURED-VIEW.md).
|
// second prompt while a turn is in flight (`409 turn_in_flight`), so the
|
||||||
|
// queue is client-local (docs/STRUCTURED-VIEW.md).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active || promptPending.current || queue.length === 0) return;
|
if (active || promptPending.current || queue.length === 0) return;
|
||||||
const [next, ...rest] = queue;
|
const text = queue.map((q) => q.text).join("\n\n");
|
||||||
setQueue(rest);
|
const files = queue.flatMap((q) => q.attachments);
|
||||||
dispatchPrompt(next.text, next.attachments);
|
setQueue([]);
|
||||||
|
dispatchPrompt(text, files);
|
||||||
}, [active, queue, dispatchPrompt]);
|
}, [active, queue, dispatchPrompt]);
|
||||||
|
|
||||||
const submit = (text: string, files: File[]) => {
|
const submit = (text: string, files: File[]) => {
|
||||||
|
|||||||
@@ -196,12 +196,24 @@ export function useStructuredSession(
|
|||||||
connect();
|
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");
|
setStatus("connecting");
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
reconnectRef.current = () => {};
|
reconnectRef.current = () => {};
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
cancelTimer();
|
cancelTimer();
|
||||||
closeSocket();
|
closeSocket();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -302,6 +302,24 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
|
|||||||
ta?.addEventListener("focus", onTaFocus);
|
ta?.addEventListener("focus", onTaFocus);
|
||||||
ta?.addEventListener("blur", onTaBlur);
|
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);
|
window.addEventListener("resize", onResize);
|
||||||
const visual = window.visualViewport ?? null;
|
const visual = window.visualViewport ?? null;
|
||||||
visual?.addEventListener("resize", onResize);
|
visual?.addEventListener("resize", onResize);
|
||||||
@@ -326,6 +344,7 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
|
|||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
}
|
}
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
window.removeEventListener("resize", onResize);
|
window.removeEventListener("resize", onResize);
|
||||||
visual?.removeEventListener("resize", onResize);
|
visual?.removeEventListener("resize", onResize);
|
||||||
ta?.removeEventListener("focus", onTaFocus);
|
ta?.removeEventListener("focus", onTaFocus);
|
||||||
|
|||||||
Reference in New Issue
Block a user