diff --git a/acp_runtime.py b/acp_runtime.py index 0cf2d00..a62f7e2 100644 --- a/acp_runtime.py +++ b/acp_runtime.py @@ -7,12 +7,28 @@ from __future__ import annotations import os +import signal import subprocess +import threading -from agent_runtime import AgentError, clean +from agent_runtime import AgentError, clean, _killpg_safe ACP_AGENT_TIMEOUT = int(os.environ.get("OPENFORGE_ACP_AGENT_TIMEOUT", "240")) +# chip_post_id → pgid for in-flight ACP CLI invocations. Used by the +# cancel endpoint to SIGTERM the subprocess group on ✖ click. +_acp_chip_to_pgid: dict[str, int] = {} +_acp_chip_to_pgid_lock = threading.Lock() + + +def cancel_acp_chip_subprocess(chip_post_id: str) -> bool: + with _acp_chip_to_pgid_lock: + pgid = _acp_chip_to_pgid.get(chip_post_id) + if pgid is None: + return False + _killpg_safe(pgid, signal.SIGTERM) + return True + def _argv_for_agent(agent_id: str, prompt: str) -> list[str]: if agent_id == "codex": @@ -33,6 +49,7 @@ def call_acp_agent( prompt: str, extra_env: dict[str, str] | None = None, cwd: str | None = None, + chip_post_id: str | None = None, ) -> str: """Invoke an ACP-backed CLI employee in oneshot mode. @@ -47,26 +64,45 @@ def call_acp_agent( if cleaned: spawn_env = {**os.environ, **cleaned} argv = _argv_for_agent(agent_id, prompt) + proc: subprocess.Popen | None = None + pgid: int | None = None try: - proc = subprocess.run( - argv, - cwd=run_cwd, - env=spawn_env, - capture_output=True, - text=True, - timeout=ACP_AGENT_TIMEOUT, - ) - except FileNotFoundError: - raise AgentError(f"ACP CLI not found for {agent_id}: {argv[0]}") from None - except subprocess.TimeoutExpired: - raise AgentError(f"ACP {agent_id} timeout after {ACP_AGENT_TIMEOUT}s") from None - - if proc.returncode != 0: - tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-3:] - raise AgentError( - f"ACP {agent_id} exited {proc.returncode}: " + " | ".join(tail) - ) - out = clean(proc.stdout or "") - if not out: - raise AgentError(f"ACP {agent_id} produced no output") - return out + try: + proc = subprocess.Popen( + argv, + cwd=run_cwd, + env=spawn_env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + except FileNotFoundError: + raise AgentError(f"ACP CLI not found for {agent_id}: {argv[0]}") from None + pgid = proc.pid + if chip_post_id: + with _acp_chip_to_pgid_lock: + _acp_chip_to_pgid[chip_post_id] = pgid + try: + stdout, stderr = proc.communicate(timeout=ACP_AGENT_TIMEOUT) + except subprocess.TimeoutExpired: + _killpg_safe(pgid, signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + _killpg_safe(pgid, signal.SIGKILL) + raise AgentError(f"ACP {agent_id} timeout after {ACP_AGENT_TIMEOUT}s") from None + returncode = proc.returncode + if returncode != 0: + tail = (stderr or stdout or "").strip().splitlines()[-3:] + raise AgentError( + f"ACP {agent_id} exited {returncode}: " + " | ".join(tail) + ) + out = clean(stdout or "") + if not out: + raise AgentError(f"ACP {agent_id} produced no output") + return out + finally: + if chip_post_id: + with _acp_chip_to_pgid_lock: + _acp_chip_to_pgid.pop(chip_post_id, None) diff --git a/agent_runtime.py b/agent_runtime.py index b94c00a..76450f0 100644 --- a/agent_runtime.py +++ b/agent_runtime.py @@ -177,6 +177,43 @@ class AgentError(RuntimeError): _active_procs: dict[int, subprocess.Popen] = {} _active_procs_lock = _threading.Lock() +# Map chip_post_id → pgid for in-flight agent turns. Lets the cancel +# endpoint (POST /api/.../posts//cancel) SIGTERM the specific +# subprocess group when a user clicks ✖ on a thinking/running chip. +_chip_to_pgid: dict[str, int] = {} +_chip_to_pgid_lock = _threading.Lock() + + +def _bind_chip(chip_post_id: str, pgid: int) -> None: + if not chip_post_id: + return + with _chip_to_pgid_lock: + _chip_to_pgid[chip_post_id] = pgid + + +def _unbind_chip(chip_post_id: str) -> None: + if not chip_post_id: + return + with _chip_to_pgid_lock: + _chip_to_pgid.pop(chip_post_id, None) + + +def cancel_chip_subprocess(chip_post_id: str) -> bool: + """SIGTERM the subprocess group bound to ``chip_post_id`` if any. + + Returns True iff a live process group was signalled. The worker thread + itself owns lifecycle cleanup (unregister + chip patching) — we only + nudge the subprocess so the worker's ``communicate()`` returns fast and + the cancellation can be observed by the router (which then drops the + reply). Idempotent: missing/already-gone groups are silent. + """ + with _chip_to_pgid_lock: + pgid = _chip_to_pgid.get(chip_post_id) + if pgid is None: + return False + _killpg_safe(pgid, signal.SIGTERM) + return True + def _register_active(pgid: int, proc: subprocess.Popen) -> None: with _active_procs_lock: @@ -271,7 +308,7 @@ def _killpg_safe(pgid: int, sig: int) -> None: pass -def call_agent(agent_id: str, session_id: str, prompt: str, extra_env: dict[str, str] | None = None) -> str: +def call_agent(agent_id: str, session_id: str, prompt: str, extra_env: dict[str, str] | None = None, chip_post_id: str | None = None) -> str: """Invoke `openclaw agent --local --json`. Raises AgentError on failure. --local keeps the run fully sandboxed in a subprocess so @@ -298,7 +335,7 @@ def call_agent(agent_id: str, session_id: str, prompt: str, extra_env: dict[str, from forge_employees import acp_employee_ids if agent_id in acp_employee_ids(): from acp_runtime import call_acp_agent - return call_acp_agent(agent_id, session_id, prompt, extra_env) + return call_acp_agent(agent_id, session_id, prompt, extra_env, chip_post_id=chip_post_id) argv = [ OPENCLAW_BIN, "agent", @@ -327,10 +364,13 @@ def call_agent(agent_id: str, session_id: str, prompt: str, extra_env: dict[str, pgid = proc.pid # equals process group id thanks to start_new_session _register_active(pgid, proc) + if chip_post_id: + _bind_chip(chip_post_id, pgid) try: stdout, stderr = proc.communicate(timeout=AGENT_TIMEOUT + 30) except subprocess.TimeoutExpired: _unregister_active(pgid) + _unbind_chip(chip_post_id or "") _killpg_safe(pgid, signal.SIGTERM) try: proc.wait(timeout=_GROUP_KILL_GRACE_SECONDS) @@ -352,6 +392,7 @@ def call_agent(agent_id: str, session_id: str, prompt: str, extra_env: dict[str, ) from None _unregister_active(pgid) + _unbind_chip(chip_post_id or "") if proc.returncode != 0: tail = (stderr or stdout or "").strip().splitlines()[-3:] raise AgentError( diff --git a/post_router.py b/post_router.py index 37e96f7..548b2c2 100644 --- a/post_router.py +++ b/post_router.py @@ -58,7 +58,7 @@ # ─── config ────────────────────────────────────────────────────────── ROUTER_SPEAKER_FALLBACK = "__router__" -STATUS_PHASES = {"thinking", "running", "done", "failed", "skipped"} +STATUS_PHASES = {"thinking", "running", "done", "failed", "skipped", "cancelled"} ERROR_TAIL_LIMIT = 2048 # Concurrency cap across all in-flight agent subprocesses. --local agent @@ -72,6 +72,39 @@ _inflight: set[tuple[str, str]] = set() _inflight_lock = threading.Lock() +# Set of chip_post_ids that have been cancelled by the user. The worker +# checks this BEFORE writing the agent's reply post and, if present, +# discards the reply (so a hung subprocess that finally returns can't +# pollute the thread after cancel). +_cancelled_chips: set[str] = set() +_cancelled_chips_lock = threading.Lock() + + +def mark_cancelled(chip_post_id: str) -> None: + if not chip_post_id: + return + with _cancelled_chips_lock: + _cancelled_chips.add(chip_post_id) + + +def _consume_cancelled(chip_post_id: str) -> bool: + with _cancelled_chips_lock: + return _cancelled_chips.discard(chip_post_id) or False # noqa + + +def is_cancelled(chip_post_id: str) -> bool: + if not chip_post_id: + return False + with _cancelled_chips_lock: + return chip_post_id in _cancelled_chips + + +def _clear_cancelled(chip_post_id: str) -> None: + if not chip_post_id: + return + with _cancelled_chips_lock: + _cancelled_chips.discard(chip_post_id) + # Set to True by drain_and_terminate() when forge is shutting down. # Once set, enqueue_if_needed() refuses to spawn new workers — those # triggers will be picked up as orphan placeholders on the next boot @@ -529,13 +562,34 @@ def _route_to_agent(thread_id: str, agent_id: str, trigger: dict, _patch_chip(thread_id, placeholder_id, phase="running") if agent_id in forge_employees.acp_employee_ids(): prompt = _render_acp_preamble(thread_id, agent_id, trigger) + prompt - reply = call_agent(agent_id, session_id, prompt, extra_env=spawn_env) + reply = call_agent(agent_id, session_id, prompt, extra_env=spawn_env, chip_post_id=placeholder_id) except AgentError as e: + # If the user cancelled this chip mid-flight, the SIGTERM we + # sent surfaces here as a non-zero exit. Convert to phase + # 'cancelled' so the UI matches intent (no misleading 'failed'). + if is_cancelled(placeholder_id or ""): + if placeholder_id: + _patch_chip(thread_id, placeholder_id, + phase="cancelled", + duration_ms=_duration_ms(started)) + _clear_cancelled(placeholder_id or "") + return if placeholder_id: _patch_chip(thread_id, placeholder_id, phase="failed", error=_error_tail(e), duration_ms=_duration_ms(started)) return + # Late cancel: subprocess produced a reply but the user clicked + # the cancel button while we were rendering. Drop the reply + # silently — router is the single source of truth for what lands + # in the thread, so a hung agent can never pollute it post-cancel. + if is_cancelled(placeholder_id or ""): + if placeholder_id: + _patch_chip(thread_id, placeholder_id, + phase="cancelled", + duration_ms=_duration_ms(started)) + _clear_cancelled(placeholder_id or "") + return reply = clean(reply) if is_empty(reply): if placeholder_id: diff --git a/server.py b/server.py index 7a46a8e..a9f91db 100644 --- a/server.py +++ b/server.py @@ -98,7 +98,7 @@ SQUAD_ROUTE_RE = r"([\w-]{1,32})" THREAD_ROUTE_RE = r"(th_[0-9a-f]+_[0-9a-f]+)" POST_ID_ROUTE_RE = r"(p_[A-Za-z0-9_]+)" -STATUS_PHASES = {"thinking", "running", "done", "failed", "skipped"} +STATUS_PHASES = {"thinking", "running", "done", "failed", "skipped", "cancelled"} def _is_local(host: str) -> bool: @@ -1316,6 +1316,83 @@ def do_POST(self): self._json(_serializable_post(updated)) return + m = re.match(rf"^/api/threads/{THREAD_ROUTE_RE}/posts/{POST_ID_ROUTE_RE}/cancel$", url.path) + if m: + tid = m.group(1) + pid = m.group(2) + thread = store.project_thread(tid) + if thread is None: + self._json({"error": "unknown thread"}, 404) + return + post = (thread.get("posts_by_id") or {}).get(pid) + if post is None: + self._json({"error": "unknown post"}, 404) + return + if post.get("post_type") != "status_chip": + self._json({"error": "post must be status_chip"}, 400) + return + current_phase = post.get("phase") + # Idempotent: already cancelled is a no-op success; terminal + # phases (done/failed/skipped/cancelled) cannot be cancelled. + if current_phase == "cancelled": + self._json(_serializable_post(post)) + return + if current_phase in ("done", "failed", "skipped"): + self._json({ + "error": "already_completed", + "phase": current_phase, + }, 409) + return + # 1) Mark the chip as cancelled so the worker discards any + # reply the subprocess may still emit. + post_router.mark_cancelled(pid) + # 2) Best-effort SIGTERM the subprocess group so the worker + # returns fast. Either side independently lands the + # cancellation; router-side suppression is the guarantee. + try: + from agent_runtime import cancel_chip_subprocess + cancel_chip_subprocess(pid) + except Exception: + pass + try: + from acp_runtime import cancel_acp_chip_subprocess + cancel_acp_chip_subprocess(pid) + except Exception: + pass + # 3) Patch chip phase to 'cancelled' immediately. The worker, + # when it returns, will see is_cancelled() and noop on + # chip patching/reply writing. + try: + updated = store.patch_post(tid, pid, { + "phase": "cancelled", + "error": None, + }) + except ValueError as e: + self._json({"error": str(e)}, 400) + return + # 4) Audit post so anyone reading the thread later understands + # why no reply appeared. + try: + agent_id = post.get("agent_id") + if not agent_id: + import re as _re + mm = _re.match(r"^([a-z][a-z0-9_-]*)\s+thinking$", + (post.get("content") or "").strip(), + _re.IGNORECASE) + if mm: + agent_id = mm.group(1) + trigger_pid = post.get("trigger_post_id") or post.get("parent_post_id") + display = agent_id or "agent" + store.add_thread_post( + tid, post_router.ROUTER_SPEAKER_FALLBACK, + f"⛔ {display} 的本轮回复已被中断。", + parent_post_id=trigger_pid, + ) + except Exception as e: + print(f"⚠️ cancel audit-post failed: {e!r}", flush=True) + self._json(_serializable_post(updated)) + return + m = re.match(rf"^/api/threads/{THREAD_ROUTE_RE}/posts/{POST_ID_ROUTE_RE}/reactions$", url.path) if m: tid = m.group(1) diff --git a/tests/test_acp_runtime.py b/tests/test_acp_runtime.py index 6c316a2..528e2b1 100644 --- a/tests/test_acp_runtime.py +++ b/tests/test_acp_runtime.py @@ -5,16 +5,46 @@ import pytest +class _FakePopen: + """Drop-in replacement for subprocess.Popen used by acp_runtime tests. + + The real implementation runs in its own process group so the cancel + endpoint (PR feature/interrupt-dispatch) can SIGTERM it. We only need + to fake `communicate()` + `returncode` + `pid` here. + """ + + def __init__(self, argv, cwd=None, env=None, stdout=None, stderr=None, + text=False, start_new_session=False, _result=None, _raise=None): + self.argv = argv + self.cwd = cwd + self.env = env + self.text = text + self._result = _result or ("", "", 0) + self._raise = _raise + self.pid = 999_999 + self.returncode = 0 + + def communicate(self, timeout=None): + if self._raise: + raise self._raise + out, err, rc = self._result + self.returncode = rc + return out, err + + def wait(self, timeout=None): + return self.returncode + + def test_call_acp_agent_success(monkeypatch): import acp_runtime calls = [] - def fake_run(argv, cwd, env, capture_output, text, timeout): - calls.append((argv, cwd, env, capture_output, text, timeout)) - return subprocess.CompletedProcess(argv, 0, stdout=" reply \n", stderr="") + def fake_popen(argv, **kwargs): + calls.append((argv, kwargs)) + return _FakePopen(argv, _result=(" reply \n", "", 0), **{k: v for k, v in kwargs.items() if k in ("cwd", "env", "text", "start_new_session")}, stdout=kwargs.get("stdout"), stderr=kwargs.get("stderr")) - monkeypatch.setattr(acp_runtime.subprocess, "run", fake_run) + monkeypatch.setattr(acp_runtime.subprocess, "Popen", fake_popen) monkeypatch.setattr(acp_runtime, "ACP_AGENT_TIMEOUT", 9) out = acp_runtime.call_acp_agent( @@ -22,27 +52,29 @@ def fake_run(argv, cwd, env, capture_output, text, timeout): ) assert out == "reply" - assert calls[0][0] == [ + argv, kwargs = calls[0] + assert argv == [ "codex", "exec", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "do it", ] - assert calls[0][1] == "/tmp/project" - assert calls[0][2]["OPENFORGE_PROJECT_DIR"] == "/tmp/project" - assert calls[0][3] is True - assert calls[0][4] is True - assert calls[0][5] == 9 + assert kwargs["cwd"] == "/tmp/project" + assert kwargs["env"]["OPENFORGE_PROJECT_DIR"] == "/tmp/project" + assert kwargs["text"] is True + assert kwargs["start_new_session"] is True def test_call_acp_agent_timeout(monkeypatch): import acp_runtime from agent_runtime import AgentError - def fake_run(*args, **kwargs): - raise subprocess.TimeoutExpired(args[0], timeout=1) + def fake_popen(argv, **kwargs): + return _FakePopen(argv, _raise=subprocess.TimeoutExpired(argv[0], timeout=1)) - monkeypatch.setattr(acp_runtime.subprocess, "run", fake_run) + monkeypatch.setattr(acp_runtime.subprocess, "Popen", fake_popen) + # Avoid SIGTERM on bogus pid. + monkeypatch.setattr(acp_runtime, "_killpg_safe", lambda pgid, sig: None) monkeypatch.setattr(acp_runtime, "ACP_AGENT_TIMEOUT", 1) with pytest.raises(AgentError, match="timeout after 1s"): @@ -53,10 +85,10 @@ def test_call_acp_agent_failure(monkeypatch): import acp_runtime from agent_runtime import AgentError - def fake_run(argv, **kwargs): - return subprocess.CompletedProcess(argv, 7, stdout="", stderr="bad\nfailed") + def fake_popen(argv, **kwargs): + return _FakePopen(argv, _result=("", "bad\nfailed", 7)) - monkeypatch.setattr(acp_runtime.subprocess, "run", fake_run) + monkeypatch.setattr(acp_runtime.subprocess, "Popen", fake_popen) with pytest.raises(AgentError, match=r"exited 7: bad \| failed"): acp_runtime.call_acp_agent("claude", "sid", "hi") diff --git a/tests/test_status_chip.py b/tests/test_status_chip.py index c091c81..3f83f74 100644 --- a/tests/test_status_chip.py +++ b/tests/test_status_chip.py @@ -193,3 +193,97 @@ def post(url: str, body: dict): wire = json.loads(r.read().decode("utf-8")) reply = next(p for p in wire["posts"] if p["speaker"] == "milk") assert reply["from_chip_post_id"] == chip["post_id"] + + +def test_cancel_endpoint_marks_chip_and_audits(store, monkeypatch): + """POST /posts//cancel: chip flips to 'cancelled', router marks + the chip so a late reply is dropped, an audit __router__ post is added, + and a best-effort SIGTERM is sent to the bound subprocess group.""" + import sys + sys.modules.pop("server", None) + import server as srv + import post_router + + t = _make_thread(store) + chip = store.add_thread_post( + t["thread_id"], "__router__", "milk thinking", + post_type="status_chip", phase="thinking", + trigger_post_id=t["posts"][0]["id"], agent_id="milk", + ) + + sigterm_calls = [] + monkeypatch.setattr( + "agent_runtime.cancel_chip_subprocess", + lambda pid: sigterm_calls.append(("native", pid)) or True, + ) + monkeypatch.setattr( + "acp_runtime.cancel_acp_chip_subprocess", + lambda pid: sigterm_calls.append(("acp", pid)) or False, + ) + + handler = object.__new__(srv.OpenForgeHandler) + handler.path = f"/api/threads/{t['thread_id']}/posts/{chip['post_id']}/cancel" + handler.headers = {} + handler.rfile = io.BytesIO(b"{}") + out = {} + monkeypatch.setattr(handler, "_check_auth", lambda: True) + monkeypatch.setattr(handler, "_json", lambda obj, status=200, extra_headers=None: out.update(status=status, obj=obj)) + handler.do_POST() + + assert out["status"] == 200 + assert out["obj"]["phase"] == "cancelled" + assert post_router.is_cancelled(chip["post_id"]) + # Both cancel hooks invoked best-effort. + kinds = {k for k, _ in sigterm_calls} + assert kinds == {"native", "acp"} + # Audit post landed. + proj = store.project_thread(t["thread_id"]) + audits = [p for p in proj["posts"] + if p.get("speaker") == "__router__" + and p.get("post_type") != "status_chip" + and "已被中断" in (p.get("content") or "")] + assert len(audits) == 1 + + +def test_cancel_endpoint_idempotent_on_already_cancelled(store, monkeypatch): + import sys + sys.modules.pop("server", None) + import server as srv + t = _make_thread(store) + chip = store.add_thread_post( + t["thread_id"], "__router__", "milk thinking", + post_type="status_chip", phase="cancelled", + trigger_post_id=t["posts"][0]["id"], agent_id="milk", + ) + handler = object.__new__(srv.OpenForgeHandler) + handler.path = f"/api/threads/{t['thread_id']}/posts/{chip['post_id']}/cancel" + handler.headers = {} + handler.rfile = io.BytesIO(b"{}") + out = {} + monkeypatch.setattr(handler, "_check_auth", lambda: True) + monkeypatch.setattr(handler, "_json", lambda obj, status=200, extra_headers=None: out.update(status=status, obj=obj)) + handler.do_POST() + assert out["status"] == 200 + assert out["obj"]["phase"] == "cancelled" + + +def test_cancel_endpoint_rejects_terminal_phase(store, monkeypatch): + import sys + sys.modules.pop("server", None) + import server as srv + t = _make_thread(store) + chip = store.add_thread_post( + t["thread_id"], "__router__", "milk thinking", + post_type="status_chip", phase="done", + trigger_post_id=t["posts"][0]["id"], agent_id="milk", + ) + handler = object.__new__(srv.OpenForgeHandler) + handler.path = f"/api/threads/{t['thread_id']}/posts/{chip['post_id']}/cancel" + handler.headers = {} + handler.rfile = io.BytesIO(b"{}") + out = {} + monkeypatch.setattr(handler, "_check_auth", lambda: True) + monkeypatch.setattr(handler, "_json", lambda obj, status=200, extra_headers=None: out.update(status=status, obj=obj)) + handler.do_POST() + assert out["status"] == 409 + assert out["obj"]["error"] == "already_completed" diff --git a/web/app.js b/web/app.js index c453e03..2a75b1d 100644 --- a/web/app.js +++ b/web/app.js @@ -1854,10 +1854,16 @@ function renderAgentStatusChip(post) { const sep = `·`; if (phase === 'thinking') { - chip.innerHTML = `${avatar}${nameHtml}${sep}思考中…`; + chip.innerHTML = `${avatar}${nameHtml}${sep}思考中…` + + ``; + chip.querySelector('.asc-cancel').onclick = (e) => { e.stopPropagation(); _chipCancel(pid, agent); }; } else if (phase === 'running') { const tool = post.tool_name ? ` · ${escapeHtml(post.tool_name)}` : ''; - chip.innerHTML = `${avatar}${nameHtml}${sep}执行中${tool}`; + chip.innerHTML = `${avatar}${nameHtml}${sep}执行中${tool}` + + ``; + chip.querySelector('.asc-cancel').onclick = (e) => { e.stopPropagation(); _chipCancel(pid, agent); }; + } else if (phase === 'cancelled') { + chip.innerHTML = `${escapeHtml(avLetter)}${escapeHtml(name)}${sep}已中断`; } else if (phase === 'done') { const dur = post.duration_ms != null ? ` · ${(post.duration_ms / 1000).toFixed(1)}s` : ''; @@ -1926,6 +1932,38 @@ async function _chipSkip(pid) { } } +// Cancel a thinking/running chip. Optimistically flips it to 'cancelled' +// immediately so the click feels crisp; the SSE/refresh path will reconcile +// (the backend writes the same phase, so usually a no-op rerender). On +// 409 already_completed, we revert and rely on the upcoming refresh to +// show the real final phase. +async function _chipCancel(pid, agentId) { + if (!state.currentThreadId || !pid) return; + // Optimistic UI: find the chip in the DOM and morph it to 'cancelled' + // so the user sees an instant reaction. + const chipEl = document.querySelector(`.agent-status-chip[data-post-id="${cssQuoteEscape(pid)}"]`); + if (chipEl) { + chipEl.dataset.phase = 'cancelled'; + chipEl.classList.add('asc-cancelling'); + } + try { + await apiJson(`/api/threads/${encodeURIComponent(state.currentThreadId)}/posts/${encodeURIComponent(pid)}/cancel`, { + method: 'POST', body: '{}', + }); + refreshCurrentThread(); + } catch (err) { + // Server says it's already in a terminal phase — rollback the optimistic + // morph by triggering a fresh fetch which will repaint the real state. + if (chipEl) chipEl.classList.remove('asc-cancelling'); + setStatus(`中断失败: ${err.message}`, false); + refreshCurrentThread(); + } +} + +function cssQuoteEscape(s) { + return String(s).replace(/["\\]/g, '\\$&'); +} + // One-line preview of a post's content for use inside quote cards / banners. // Strips markdown noise just enough to look clean in 1 line; never returns // more than n chars. diff --git a/web/style.css b/web/style.css index db3d3fd..6722574 100644 --- a/web/style.css +++ b/web/style.css @@ -52,6 +52,7 @@ --warn-soft: #FBF1DC; --danger: #D04A4A; --danger-soft: #FAE3E3; + --danger-soft-strong: rgba(208, 74, 74, 0.16); --info: #3B82F6; --info-soft: #EFF6FF; --closed: #8D8D8D; @@ -116,6 +117,7 @@ --warn-soft: #3A2C0F; --danger: #F87171; --danger-soft: #3A1818; + --danger-soft-strong: rgba(248, 113, 113, 0.22); --info: #60A5FA; --info-soft: #1E2A3F; --closed: #6E7682; @@ -173,6 +175,7 @@ --warn-soft: #3A2C0F; --danger: #F87171; --danger-soft: #3A1818; + --danger-soft-strong: rgba(248, 113, 113, 0.22); --info: #60A5FA; --info-soft: #1E2A3F; --closed: #6E7682; @@ -1304,6 +1307,69 @@ body.squad-collapsed #squad-list::-webkit-scrollbar { display: none; } /* WebKi .agent-status-chip[data-phase="skipped"] .asc-avatar { width: 16px; height: 16px; font-size: 10px; opacity: .7; } .agent-status-chip[data-phase="skipped"] .asc-name { font-weight: 400; color: var(--text-soft); } +/* ✕ cancel button on thinking/running chips. Default semi-opaque so the + user can find it without hover; full opacity + danger color on hover. + Touch devices keep opacity:1 always — hover-only on iPad/phone = invisible. + 28×28 hit area via transparent padding around a 16px visual target; + no confirm dialog (cancel is cheap + reversible by re-@'ing the agent). */ +.agent-status-chip .asc-cancel { + margin-left: 4px; + width: 28px; height: 28px; + padding: 0; border: 0; background: transparent; + color: var(--text-soft); + font-size: 14px; line-height: 1; + cursor: pointer; + opacity: .45; + border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + transition: opacity .12s ease, color .12s ease, background-color .12s ease; + font-family: inherit; +} +.agent-status-chip:hover .asc-cancel { opacity: 1; } +.agent-status-chip .asc-cancel:hover { + opacity: 1; color: var(--danger); background: var(--danger-soft-strong); +} +.agent-status-chip .asc-cancel:focus-visible { + outline: 2px solid var(--danger); + outline-offset: 1px; + opacity: 1; +} +@media (hover: none) { + .agent-status-chip .asc-cancel { opacity: 1; } +} + +/* cancelled phase: minimal pill, soft danger color so it's visible as a + thread artifact but doesn't shout. No avatar block, no actions. */ +.agent-status-chip[data-phase="cancelled"] { + background: transparent; border-color: transparent; + color: var(--text-soft); font-size: 12px; + padding: 2px 8px; +} +.agent-status-chip[data-phase="cancelled"] .asc-avatar { width: 16px; height: 16px; font-size: 10px; opacity: .6; } +.agent-status-chip[data-phase="cancelled"] .asc-name { font-weight: 400; color: var(--text-soft); } +.agent-status-chip[data-phase="cancelled"] .asc-phase { color: var(--text-soft); } +.agent-status-chip[data-phase="cancelled"] .asc-icon { color: var(--text-soft); } +.agent-status-chip.asc-cancelling .asc-phase { + /* During the half-second optimistic transition we pull the phase + text up to full --text contrast (out of --text-soft) and thicken + the strike-through so the "click was registered" feedback is + unmistakable in dark mode (designer 2026-06-25 verdict: the soft + strike on soft text in dark was "barely visible" and killed the + 0ms perceived latency win). After the 0.5s fade it eases back to + soft so the cancelled pill rests at low-key gravity. */ + color: var(--text); + text-decoration: line-through; + text-decoration-thickness: 2px; + opacity: 1; + transition: color .5s ease .4s, opacity .5s ease .4s; + animation: ascCancelFade .9s ease forwards; +} +@keyframes ascCancelFade { + 0% { color: var(--text); opacity: 1; } + 60% { color: var(--text); opacity: 1; } + 100% { color: var(--text-soft); opacity: .8; } +} + @keyframes ascSpin { to { transform: rotate(360deg); } } @keyframes ascPulse { 0%,100%{opacity:1} 50%{opacity:.4} } @keyframes ascIn { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: translateY(0); } }