Plan-mode chat: AskUserQuestion double-card/hang fix + desktop-parity plan/question cards (v0.1.61) (#84)

* Fix harness prompt bugs: AskUserQuestion double-card + hang, resolved-card rendering

- plan_auto_policy auto-allows AskUserQuestion: the bridge no longer fronts
  the question card with a redundant permission card whose held request also
  deadlocked the end-turn resume (answer rejected as busy until interrupt).
- respond() resolves a card only after its answer is delivered, so a rejected
  send leaves the card actionable instead of resolved-with-the-answer-dropped.
- Resolved cards mirror Claude Code: permission cards vanish, plan/question
  cards collapse to compact inline expandable rows with an answer echo
  (answers/approved/note stamped on resolve, carried through turn flushes).

* Review round 1 fixes: doc split, echo-preserving adopt, neutral outcome, read-only class rename

* Review round 2 fixes: PendingGuard doc, best-effort resolve folded into helper, neutral question echo

* Post-review nits: doc wording, dedupe question text in collapsed body, word-break on collapsed rows

* Plan-mode AskUserQuestion truly blocks: bridge holds the call as the question card

Auto-allowing AskUserQuestion was wrong: headless the tool returns no
answer, so the model guessed and kept planning instead of waiting. The
bridge now surfaces the QUESTION itself as a held mid-turn card (like
ExitPlanMode's plan card); the answer settles the held call as a denial
whose message is the answer, so the model continues the same turn with
the user's actual choice. The turn-status line says 'Waiting for your
input…' instead of 'Working…' while a card holds the turn open.

* Post-review nits: deny-message hardening, question cards don't suppress plan-synth, empty-options fallback, held-card status via nativeId, comment accuracy

* Polish: no tool row for bridge-held calls (deny-with-answer painted them red), plan pane gets padding + plain background

The bridged AskUserQuestion/ExitPlanMode tool_use now renders nothing —
the held card is the representation, and the denial that carries the
user's answer back made the CLI report the tool as errored, painting a
successful Q&A as a red error row. The right-pane plan tab had no CSS
at all: flush text on the panel tint; it's a markdown document, so it
gets a padded, plain reading surface.

* PlanStrip: Claude-desktop shape — title line, real buttons in a row below

The strip's buttons used .btn-primary/.btn-ghost, which are only styled
inside .prompt-actions — in the strip they rendered as bare text crammed
next to the title. The actions row now reuses .prompt-actions (same
buttons as every card) and sits on its own line under the title.

* PlanStrip buttons: monochrome — black text/borders, no brand red

* PlanStrip split button: single internal divider (caret drops its own left border)

* PlanStrip split button: flat inner corners (radius reset must out-rank .prompt-actions shorthand)

* Composer: attention bell instead of Stop while a held card awaits the user

A blocked-on-input turn is busy, so the composer showed the Stop X — a
loading/cancel affordance while the agent is actually waiting on an
answer. When the session is busy AND a held card (nativeId) is
unresolved, show a pulsing bell that scrolls to the pending card; Esc
still interrupts. The transcript status line shares the same
awaitingInput signal.

* Composer: two states only — Send by default, Stop X only while actively streaming

Drop the bell experiment. A turn held on an unanswered card shows the
idle Send button; the card and the status line are the waiting
affordance. Esc still interrupts a held turn.

* Composer answers the pending card (Claude-desktop): typed text = plan revision feedback / custom question answer

- send() routes plain typed text to the newest unresolved plan/question
  card (respond with note) instead of racing the held turn with a new
  message the busy guard would drop; placeholder says so. Questions
  route only on Claude sessions (opencode rejects note-only replies).
- Rail busy-dot stops pulsing when the turn is held waiting on the user
  (steady dot; same awaitingInput signal as the status line).
- Note-only question answers echo the note in the collapsed row.

* PlanStrip: Claude-desktop parity actions + popovers above the strip

Reject / Revise… / Accept / Accept-and-auto-mode (+ bypass in the caret),
Open plan as a title-row link. Reject is a true rejection: the note-less
plan denial now tells the model to stop and wait (was 'keep refining',
which made it improvise revisions/questions); collapsed rows echo
'Rejected' vs 'Revision requested' by note presence. Accept resumes
under accept-edits (guarded), Accept-and-auto under Auto — the primary
is the solid dark button, not brand red. The strip moved inside
.composer so the mode/model picker popovers (z 50 in that stacking
context) layer above it — as a sibling, .composer's z-index:4 capped
them below the strip.

* PlanStrip: single Accept-and-auto-mode primary, drop the plan excerpt

Matches the desktop reference exactly: [Reject] [Revise…] on the left,
one primary button on the right whose caret menu holds the two guarded
tiers (Accept = accept-edits, Accept and bypass all). The separate
outline Accept button and the plan-title excerpt next to the header
are gone — plan.excerpt/plan prop was fully unused after removal.

* PlanStrip: self-contained Revise mode (Claude-desktop parity)

Swaps the strip into its own textarea + [Back] [Revise ↵] footer
instead of routing through the main composer — matches the desktop
reference exactly. Enter submits, Shift+Enter newlines, Esc cancels
back to the action row. The main-composer typed-text routing
(pendingPrompt) is now question-only; plans have their own dedicated
UI and no longer need it.

* PlanStrip: drop the plain accept-edits Accept option

We have no partial-approval story (edits-only, everything else still
gated) — the caret now holds only Accept and bypass all.

* Plan revision: visible feedback + never silently dead-end

- Submitting a revision now swaps the strip to a 'Revising the plan…'
  placeholder until the revised card arrives (or the turn ends) —
  previously the strip snapped back to an identical-looking action row
  and the submit read as a no-op.
- An empty revise note maps to a generic 'Please revise the plan.'
  instead of riding the note-less wire shape, which the backend reads
  as a hard rejection ('stop and wait') — the '(optional)' field was
  accidentally a Reject.
- respond() reconciles messages AND busy state from the server after
  every answer: a stale card (e.g. resolved by the bridge timeout while
  the tab's SSE was dropped) previously stayed actionable forever and
  swallowed every answer.

* PlanStrip: drop the 'Revising the plan…' placeholder

The transcript's Working… spinner already communicates it. The strip
still hides while a submitted revision is in flight (the outgoing card
must not look actionable) and the revised card swaps in on arrival.

* Review round 1 fixes: zombie-card recovery, honest composer button, reject-loop fix

Blockers:
- A held card orphaned by a process restart (PendingGuard never ran)
  permanently captured typed input: the composer routed every message to
  it and errors were swallowed. Now the backend collapses the zombie on
  the first answer attempt, and the composer only routes typed text to a
  question card that is actually answerable (live turn for native cards).
- The Send/Stop swap keyed on awaitingInput while send() could only
  service question cards — a held plan/permission card showed a lying
  Send (silent no-op) with no Stop control. Now keyed on pendingQuestion.

Also: note-less Reject on an end-turn/synthesized plan card resolves
with no resume (resuming just to say 'stop' re-synthesized a card — an
un-dismissable strip); the bridge plan-deny arm reuses synthesize_resume
verbatim; respond() reconciles busy for its own session only and reports
failure so the question route can restore a consumed draft; revising
clear gated to its session; un-docked plan card fallback matches the
strip's semantics; Rejected gets its own outcome class; note-only
answers echo once; split-button CSS consolidated; dead CSS/union
removed; test pins the reject wording.

* Post-clean-round nits: keep optimistic busy on failed reconcile fetch, sweep zombie held cards at claude turn entry

- respond()'s busy reconcile no longer clears busy when the sessions
  fetch fails — during a Handled resume nothing re-asserts busy
  mid-stream, so a transient fetch failure would hide Working…/Stop for
  the rest of the turn.
- claude run_turn sweeps unresolved native_id cards at turn entry
  (resolve_stale_prompts gains a native_only mode; codex keeps sweeping
  everything). A held card can never outlive its process, so one left
  unresolved is a crash artifact — previously it could capture the
  composer's typed-text routing once the new turn made the session busy.

* Bump version to 0.1.61
This commit is contained in:
Daniel Kim
2026-07-17 00:15:45 -07:00
committed by GitHub
parent 7450249cec
commit 012672958d
14 changed files with 1070 additions and 427 deletions
Generated
+1 -1
View File
@@ -928,7 +928,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openresearch-cli"
version = "0.1.60"
version = "0.1.61"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "openresearch-cli"
version = "0.1.60"
version = "0.1.61"
edition = "2021"
description = "OpenResearch CLI (orx) — Rust port"
repository = "https://github.com/alphaXiv/openresearch-cli"
+218 -45
View File
@@ -74,7 +74,8 @@ pub struct WireQuestionOption {
pub struct WirePrompt {
/// `plan` | `permission` | `question`.
pub kind: String,
/// Whether this prompt has been answered (answered cards render read-only).
/// Whether this prompt has been answered (resolved permission cards
/// vanish; resolved plan/question cards collapse to a one-line row).
#[serde(default)]
pub resolved: bool,
/// plan: the proposed plan markdown.
@@ -101,11 +102,24 @@ pub struct WirePrompt {
#[serde(default)]
pub multi_select: bool,
/// The harness-native id used to reply over a live protocol (opencode's
/// permission/question request id). Internal to the backend resume path —
/// the UI never reads it and only echoes the `WirePart` id. `None` for
/// end-turn harnesses (Claude), which resume by message, not by reply id.
/// permission/question request id, the Claude bridge's held request id).
/// The backend resume path routes on it; the UI reads only its *presence*
/// (a held mid-turn card — the turn is blocked on this answer) and echoes
/// the `WirePart` id when answering. `None` for end-turn cards, which
/// resume by message, not by reply id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native_id: Option<String>,
/// Answer echo, stamped when the user resolves the card so the collapsed
/// rendering can show the outcome (and it survives a reload):
/// questions record the chosen labels, plan/permission whether it was
/// approved, and any freeform note rides along. Absent on cards resolved
/// without an answer (stale-card cleanup, cancelled bridge requests).
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub answers: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approved: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -339,7 +353,8 @@ fn plan_auto_policy(tool_name: &str, tool_input: &Value) -> Option<PermissionDec
/// `request_permission` — answered, timed out, or the handler future dropped
/// mid-await (the HTTP connection died with the claude child). Removes the
/// pending entry and resolves the card so it can't be answered into the void.
/// Resolving an already-resolved card is an idempotent re-persist.
/// Re-resolving an already-answered card is a no-op (`mark_prompt_resolved`
/// skips it) so this late pass can't shadow an echo-stamped broadcast.
struct PendingGuard {
host: Arc<ChatHost>,
session_id: String,
@@ -353,9 +368,12 @@ impl Drop for PendingGuard {
.lock()
.unwrap()
.remove(&self.prompt_id);
if let Ok(Some(msg)) =
mark_prompt_resolved(&self.host.msg_write, &self.session_id, &self.prompt_id)
{
if let Ok(Some(msg)) = mark_prompt_resolved(
&self.host.msg_write,
&self.session_id,
&self.prompt_id,
None,
) {
self.host
.emit("chat.message", message_json(&msg, &self.session_id));
}
@@ -550,7 +568,13 @@ impl ChatHost {
// Tier 2 — the user decides. ExitPlanMode becomes the plan card (the
// hook routes it here with an "ask" so headless can't self-approve);
// everything else — gray-area Bash, MCP tools, … — a permission card.
// AskUserQuestion becomes the QUESTION card itself, held mid-turn —
// gating it behind a permission card would be a pointless double
// interaction, and *allowing* it is worse: headless the tool returns
// no answer, so the model guesses and keeps going instead of blocking.
// Holding the call is the only shape that actually blocks the turn on
// the user's answer. Everything else — gray-area Bash, MCP tools, … —
// a permission card.
let prompt_id = format!("perm_{}", uuid::Uuid::new_v4());
let prompt = if tool_name == "ExitPlanMode" {
WirePrompt {
@@ -565,6 +589,18 @@ impl ChatHost {
native_id: Some(prompt_id.clone()),
..Default::default()
}
} else if let Some(question) =
crate::local::harness::question_prompt(tool_name, Some(&tool_input))
.filter(|q| !q.options.is_empty())
{
// Malformed question input — unparseable, or no options at all —
// falls through to a permission card instead: options are the
// question card's primary interface, and allow/deny on the raw
// tool call is a saner fallback than an options-less card.
WirePrompt {
native_id: Some(prompt_id.clone()),
..question
}
} else {
WirePrompt {
kind: "permission".into(),
@@ -575,6 +611,7 @@ impl ChatHost {
}
};
let is_question = prompt.kind == "question";
// The card rides its own assistant message: the running turn owns its
// in-flight message's parts (a foreign part appended there would be
// clobbered by the turn's next flush).
@@ -592,10 +629,16 @@ impl ChatHost {
created_at: msg.created_at,
})?;
self.emit("chat.message", message_json(&msg, session_id));
self.bridge_prompted
.lock()
.unwrap()
.insert(session_id.to_string());
// A question card answered mid-turn is NOT an exit recourse from plan
// mode, so it must not count as "saw a prompt" — a turn that asks a
// question and then ends with its plan as plain text still needs the
// synthesized plan card. Plan/permission cards keep counting.
if !is_question {
self.bridge_prompted
.lock()
.unwrap()
.insert(session_id.to_string());
}
let (tx, rx) = tokio::sync::oneshot::channel();
self.pending_permissions.lock().unwrap().insert(
@@ -985,13 +1028,15 @@ impl ChatHost {
.resume_from_prompt(&resume_ctx, &prompt, &req)
.await?;
// Mark resolved and broadcast the updated card so it renders read-only
// on every client immediately (send_message only emits the new user
// message, never the mutated assistant one).
let resolved_msg = mark_prompt_resolved(&self.msg_write, &req.session_id, &req.prompt_id)?
.ok_or_else(|| anyhow!("prompt not found"))?;
self.emit("chat.message", message_json(&resolved_msg, &req.session_id));
// Each arm delivers the answer FIRST and only then marks the card
// resolved (`resolve_prompt_card`). The old order (resolve, then
// deliver) had a stranding failure mode: if `send_message` was
// rejected — e.g. the session was still busy because a held bridge
// request kept the turn alive — the card was already read-only but
// the answer was dropped, leaving no recourse but an interrupt.
// Resolving after a successful delivery keeps a failed answer
// retryable: nothing has been mutated, the card is still actionable.
// (The resolve itself is best-effort — see `resolve_prompt_card`.)
match action {
ResumeAction::SendMessage { text, mode } => {
// A native (mid-turn) card may resume while its turn is still
@@ -1010,16 +1055,20 @@ impl ChatHost {
reasoning_level: None,
};
self.send_message(&req.session_id, text, overrides, Vec::new())
.await
.await?;
self.resolve_prompt_card(&req);
Ok(())
}
ResumeAction::Handled => {
// The inline reply unblocked the still-running turn; it keeps
// streaming and will `finish_turn` itself. Leave `busy` alone.
self.resolve_prompt_card(&req);
Ok(())
}
ResumeAction::Nothing => {
// Card closed with no resume (e.g. a denied Claude permission);
// broadcast idle so `busy` clears in the UI.
self.resolve_prompt_card(&req);
if let Ok(Some(session)) = Store::open()?.get_chat_session(&req.session_id) {
self.emit(
"chat.session",
@@ -1031,6 +1080,33 @@ impl ChatHost {
}
}
/// Resolve one card answerless and broadcast — for zombie native cards
/// whose held turn died without cleanup (process crash/restart, so
/// [`PendingGuard`] never ran). Collapses the card so it stops rendering
/// actionable and swallowing every answer. Best-effort by design.
pub fn resolve_zombie_prompt(&self, session_id: &str, prompt_id: &str) {
if let Ok(Some(msg)) = mark_prompt_resolved(&self.msg_write, session_id, prompt_id, None) {
self.emit("chat.message", message_json(&msg, session_id));
}
}
/// Mark an answered card resolved (stamping the answer echo) and broadcast
/// the updated message so it re-renders collapsed on every client
/// immediately (send_message only emits the new user message, never the
/// mutated assistant one). Best-effort: by the time this runs the answer
/// has already been delivered, so a (store-only) failure is logged rather
/// than surfaced — an Err from `respond` would make the UI's catch clear
/// `busy` on a turn that is actually still streaming.
fn resolve_prompt_card(&self, req: &PromptAnswer) {
let resolved =
mark_prompt_resolved(&self.msg_write, &req.session_id, &req.prompt_id, Some(req))
.and_then(|m| m.ok_or_else(|| anyhow!("prompt not found")));
match resolved {
Ok(msg) => self.emit("chat.message", message_json(&msg, &req.session_id)),
Err(e) => eprintln!("orx up: answered prompt not marked resolved: {e}"),
}
}
/// Archive/unarchive a session and broadcast the updated row so every open
/// dashboard's Recents list re-filters. Returns None for an unknown id.
pub async fn set_archived(
@@ -1194,10 +1270,27 @@ fn unresolved_prompt(session_id: &str, prompt_id: &str) -> Result<Option<WirePro
Ok(None)
}
/// Flip the `resolved` flag on the prompt part with `prompt_id` in the session's
/// last assistant message that carries it, persist it, and return the mutated
/// message (so the caller can broadcast a `chat.message` and the card renders
/// read-only). `None` if no such prompt part exists.
/// Flip a prompt to resolved and stamp the answer echo (see
/// [`WirePrompt::answers`]) so the collapsed card can show the outcome.
/// `None` (stale-card cleanup, cancelled bridge requests) leaves any earlier
/// echo intact — a re-resolve must not erase it.
fn stamp_resolved(prompt: &mut WirePrompt, answer: Option<&PromptAnswer>) {
prompt.resolved = true;
if let Some(answer) = answer {
prompt.answers = answer.answers.clone();
prompt.approved = Some(answer.approve);
prompt.note = answer.note.clone().filter(|n| !n.trim().is_empty());
}
}
/// Resolve the prompt part with `prompt_id` in the session's last assistant
/// message that carries it ([`stamp_resolved`] with `answer`), persist it, and
/// return the mutated message (so the caller can broadcast a `chat.message`
/// and the card re-renders collapsed). `None` if no such prompt part exists,
/// or if it was already resolved and there's no answer to stamp — an
/// answerless re-resolve (stale-card cleanup) has nothing to change, and
/// skipping the write keeps its late broadcast from shadowing an echo-stamped
/// one a client already received.
///
/// The read→modify→write runs under `msg_write` so it's atomic against a
/// still-running turn's `flush` reconcile-and-persist of the same message (see
@@ -1206,6 +1299,7 @@ fn mark_prompt_resolved(
msg_write: &std::sync::Mutex<()>,
session_id: &str,
prompt_id: &str,
answer: Option<&PromptAnswer>,
) -> Result<Option<WireMessage>> {
let _guard = msg_write.lock().unwrap();
let store = Store::open()?;
@@ -1219,7 +1313,10 @@ fn mark_prompt_resolved(
.find(|p| p.id == prompt_id && p.prompt.is_some())
{
if let Some(prompt) = part.prompt.as_mut() {
prompt.resolved = true;
if prompt.resolved && answer.is_none() {
return Ok(None);
}
stamp_resolved(prompt, answer);
}
store.upsert_chat_message(&StoredChatMessage {
id: msg.id.clone(),
@@ -1239,21 +1336,27 @@ fn mark_prompt_resolved(
Ok(None)
}
/// Resolve every still-unresolved prompt card of a session, store-side.
/// Resolve still-unresolved prompt cards of a session, store-side.
///
/// For inline-approval harnesses whose prompts die with their turn (codex: a
/// JSON-RPC request the process has since abandoned), a leftover unresolved
/// card is a zombie — unanswerable, and worse, its reply id can collide with a
/// fresh child's restarting request ids, so a click on the dead card could be
/// delivered to a *different, live* request. Called at codex turn entry to
/// close both. NOT for end-turn harnesses (Claude): their unresolved cards
/// deliberately outlive turns and resume via a new message.
/// delivered to a *different, live* request. Called at codex turn entry
/// (`native_only: false`) to close both.
///
/// End-turn harnesses (Claude) sweep with `native_only: true`: their
/// UN-held cards deliberately outlive turns and resume via a new message, but
/// a *held* (`native_id`) card can never outlive its process — one left
/// unresolved is a crash/restart artifact that would otherwise capture the
/// composer once the fresh turn makes the session busy again.
///
/// Same `msg_write` contract as `mark_prompt_resolved`. Returns the updated
/// messages so the caller can broadcast them.
fn resolve_stale_prompts(
msg_write: &std::sync::Mutex<()>,
session_id: &str,
native_only: bool,
) -> Result<Vec<WireMessage>> {
let _guard = msg_write.lock().unwrap();
let store = Store::open()?;
@@ -1266,8 +1369,8 @@ fn resolve_stale_prompts(
let mut changed = false;
for part in parts.iter_mut() {
if let Some(prompt) = part.prompt.as_mut() {
if !prompt.resolved {
prompt.resolved = true;
if !prompt.resolved && (!native_only || prompt.native_id.is_some()) {
stamp_resolved(prompt, None);
changed = true;
}
}
@@ -1293,8 +1396,8 @@ fn resolve_stale_prompts(
impl ChatHost {
/// [`resolve_stale_prompts`] + broadcast, for harness turn-entry use.
pub async fn resolve_stale_prompts(&self, session_id: &str) -> Result<()> {
for msg in resolve_stale_prompts(&self.msg_write, session_id)? {
pub async fn resolve_stale_prompts(&self, session_id: &str, native_only: bool) -> Result<()> {
for msg in resolve_stale_prompts(&self.msg_write, session_id, native_only)? {
self.emit("chat.message", message_json(&msg, session_id));
}
Ok(())
@@ -1490,10 +1593,13 @@ impl TurnCtx {
Ok(())
}
/// Merge the persisted `resolved` state of prompt parts into the in-memory
/// Merge the persisted resolution state of prompt parts into the in-memory
/// assistant message, so a concurrent `respond` that resolved a card isn't
/// clobbered by this turn's next flush. Only ever flips `false`→`true`
/// (a card never un-resolves), so it's safe regardless of ordering.
/// clobbered by this turn's next flush. Only ever flips `false`→`true` and
/// fills an empty echo (`answers`/`approved`/`note`) — never the reverse —
/// so it's safe regardless of ordering: the in-memory copy normally never
/// carries an echo of its own, and dropping the stored one here would
/// erase the stamped outcome on the next flush.
fn adopt_resolved_prompts(&mut self, store: &Store) {
let Ok(Some(stored)) = store.get_chat_message(&self.assistant.id) else {
return;
@@ -1503,16 +1609,22 @@ impl TurnCtx {
let Some(prompt) = part.prompt.as_mut() else {
continue;
};
if prompt.resolved {
continue;
}
let resolved_in_store = persisted
let stored_prompt = persisted
.iter()
.find(|p| p.id == part.id)
.and_then(|p| p.prompt.as_ref())
.is_some_and(|p| p.resolved);
if resolved_in_store {
prompt.resolved = true;
.and_then(|p| p.prompt.as_ref());
let Some(stored_prompt) = stored_prompt.filter(|p| p.resolved) else {
continue;
};
prompt.resolved = true;
// Adopt the echo even when this copy is already resolved but
// echo-less (codex's turn loop resolves its in-memory card
// without one) — else this flush would persist the bare copy
// over the stamped outcome.
if prompt.answers.is_empty() && prompt.approved.is_none() && prompt.note.is_none() {
prompt.answers = stored_prompt.answers.clone();
prompt.approved = stored_prompt.approved;
prompt.note = stored_prompt.note.clone();
}
}
}
@@ -1680,6 +1792,15 @@ mod bridge_tests {
&json!({"url": "https://example.com"})
)));
assert!(allow(plan_auto_policy("WebSearch", &json!({"query": "x"}))));
// AskUserQuestion: tier 2, but its card is the QUESTION itself, held
// mid-turn (see `request_permission`) — auto-allowing would run the
// tool headless, which returns no answer, so the model would guess
// instead of blocking on the user.
assert!(plan_auto_policy(
"AskUserQuestion",
&json!({"questions": [{"question": "Which?", "options": []}]})
)
.is_none());
// File edits: denied — this branch IS plan mode's edit block once a
// permission tool is configured.
for tool in ["Write", "Edit", "MultiEdit", "NotebookEdit"] {
@@ -1692,4 +1813,56 @@ mod bridge_tests {
assert!(plan_auto_policy("ExitPlanMode", &json!({"plan": "x"})).is_none());
assert!(plan_auto_policy("mcp__foo__bar", &json!({})).is_none());
}
fn answer(answers: &[&str], approve: bool, note: Option<&str>) -> PromptAnswer {
PromptAnswer {
session_id: "s".into(),
prompt_id: "p".into(),
approve,
resume_mode: None,
answers: answers.iter().map(|s| s.to_string()).collect(),
note: note.map(str::to_string),
}
}
#[test]
fn stamp_resolved_records_the_answer_echo() {
let mut prompt = WirePrompt {
kind: "question".into(),
..Default::default()
};
stamp_resolved(
&mut prompt,
Some(&answer(&["Core patching science"], true, Some("go deep"))),
);
assert!(prompt.resolved);
assert_eq!(prompt.answers, vec!["Core patching science"]);
assert_eq!(prompt.approved, Some(true));
assert_eq!(prompt.note.as_deref(), Some("go deep"));
// A whitespace-only note is dropped, a denial echoes approved=false.
let mut prompt = WirePrompt {
kind: "permission".into(),
..Default::default()
};
stamp_resolved(&mut prompt, Some(&answer(&[], false, Some(" "))));
assert!(prompt.resolved);
assert_eq!(prompt.approved, Some(false));
assert_eq!(prompt.note, None);
}
#[test]
fn stamp_resolved_without_answer_preserves_an_earlier_echo() {
// A stale-card cleanup (PendingGuard drop, resolve_stale_prompts) runs
// with no answer; re-resolving must not erase what the user chose.
let mut prompt = WirePrompt {
kind: "question".into(),
..Default::default()
};
stamp_resolved(&mut prompt, Some(&answer(&["A"], true, None)));
stamp_resolved(&mut prompt, None);
assert!(prompt.resolved);
assert_eq!(prompt.answers, vec!["A"]);
assert_eq!(prompt.approved, Some(true));
}
}
+99 -28
View File
@@ -4,8 +4,10 @@
//! multi-turn via `--resume` against Claude Code's own session store. The
//! playbook rides `--append-system-prompt-file`; the permission mode is
//! `--permission-mode` from the session's setting (`auto`/`bypass` — see
//! `options`). AskUserQuestion / ExitPlanMode surface as interactive cards; the
//! turn ends on them and the user's answer resumes the session.
//! `options`). AskUserQuestion / ExitPlanMode surface as interactive cards: the
//! turn ends on them and the user's answer resumes the session — except in
//! plan mode, where the mcp-gate bridge holds both open mid-turn and the
//! answer continues the same turn.
//!
//! Detection: `~/.claude.json` carries the signed-in OAuth account (no secrets
//! read); `ANTHROPIC_API_KEY` is the api-key fallback.
@@ -156,8 +158,13 @@ impl Harness for ClaudeCode {
) -> Result<ResumeAction> {
if let Some(native_id) = &prompt.native_id {
// The bridge request lives inside a running turn; once that turn is
// gone (interrupted / errored) the card is stale.
// gone the card is stale. Normally `PendingGuard` resolves it at
// turn teardown, but a process crash/restart skips that — leaving
// a zombie card that renders actionable and swallows every answer
// forever. Collapse it store-side before reporting the miss.
if !ctx.is_busy().await {
ctx.host
.resolve_zombie_prompt(&ctx.session_id, &answer.prompt_id);
return Err(anyhow!("this approval is no longer pending"));
}
let note = answer.note.as_deref().filter(|s| !s.trim().is_empty());
@@ -188,13 +195,15 @@ impl Harness for ClaudeCode {
)?;
Ok(ResumeAction::Handled)
}
// Keep planning: deny the held ExitPlanMode with the refinement
// as the reason — the model revises the plan in the same turn.
// Deny the held ExitPlanMode. With a note it's a revision
// request — the model revises the plan in the same turn. With
// no note it's a plain REJECTION (the strip's Reject button):
// tell the model to stop, not to improvise a revision (or a
// "what should change?" question card). The wording is
// `synthesize_resume`'s plan-deny arm verbatim — one source
// for both delivery shapes.
("plan", false) => {
let message = match note {
Some(note) => format!("Keep refining the plan: {note}"),
None => "The user wants the plan revised before approving.".to_string(),
};
let (message, _) = synthesize_resume("plan", answer);
ctx.host.settle_permission(
native_id,
crate::local::chat::PermissionDecision::Deny { message },
@@ -210,6 +219,29 @@ impl Harness for ClaudeCode {
let (text, mode) = synthesize_resume("plan", answer);
Ok(ResumeAction::SendMessage { text, mode })
}
// Mid-turn question (a bridge-held AskUserQuestion): the held
// tool call is denied with the user's answer as the message —
// the model reads the answer from the denial and continues the
// same turn. (Allowing the tool instead would run it headless,
// which returns no answer — the model would guess and move on
// rather than block; that's the bug this arm exists to avoid.)
("question", _) => {
let (text, _) = synthesize_resume("question", answer);
if text.trim().is_empty() {
return Err(anyhow!("select an option (or add a note) to answer"));
}
ctx.host.settle_permission(
native_id,
crate::local::chat::PermissionDecision::Deny {
message: format!(
"The user answered: {text}. Treat this as their answer and \
continue — do not ask this question again. (Only the first \
question of the call was shown; ask any others separately.)"
),
},
)?;
Ok(ResumeAction::Handled)
}
_ => Err(anyhow!("unsupported prompt kind for a bridge card")),
};
}
@@ -219,6 +251,16 @@ impl Harness for ClaudeCode {
if prompt.kind == "permission" && !answer.approve {
return Ok(ResumeAction::Nothing);
}
// Likewise a note-less plan REJECTION on an end-turn card: the turn is
// already over — resuming just to say "stop" would end in fresh text
// that `should_synthesize_plan` turns into ANOTHER card, so Reject
// could never dismiss the strip. Close the card with no resume.
if prompt.kind == "plan"
&& !answer.approve
&& answer.note.as_deref().is_none_or(|s| s.trim().is_empty())
{
return Ok(ResumeAction::Nothing);
}
let (text, mode) = synthesize_resume(&prompt.kind, answer);
// Reject an empty resume (e.g. a question answered with no selection and
// no note) so `respond` leaves the card actionable.
@@ -358,7 +400,9 @@ fn claude_effort(level: Option<&str>) -> Option<&str> {
/// resume strategy: a prompt ends the turn and the answer becomes a *new user
/// message* that continues via `--resume`. `resume_mode` on the answer is a
/// harness-agnostic wire id; unknown/absent ids fall through to the per-kind
/// default (or the session's mode, applied downstream).
/// default (or the session's mode, applied downstream). The question arm is
/// also reused as a plain text builder by the bridge's mid-turn question
/// resume (the denial message that carries the answer).
fn synthesize_resume(kind: &str, req: &PromptAnswer) -> (String, Option<PermissionMode>) {
let note = req.note.as_deref().filter(|s| !s.trim().is_empty());
let chosen = req.resume_mode.as_deref().and_then(PermissionMode::from_id);
@@ -372,10 +416,15 @@ fn synthesize_resume(kind: &str, req: &PromptAnswer) -> (String, Option<Permissi
(text, chosen.or(Some(PermissionMode::Auto)))
}
"plan" => {
// "Keep planning" — stay in plan mode with the refinement.
// Stay in plan mode. With a note it's a revision request; without
// one it's a plain rejection — stop, don't guess at revisions.
let text = note
.map(|n| format!("Keep refining the plan: {n}"))
.unwrap_or_else(|| "Please revise the plan.".to_string());
.unwrap_or_else(|| {
"The user rejected this plan. Stop planning and wait for \
further instructions."
.to_string()
});
(text, Some(PermissionMode::Plan))
}
"permission" => {
@@ -440,8 +489,10 @@ fn plan_prompt(name: &str, input: Option<&Value>) -> Option<WirePrompt> {
/// AskUserQuestion → a `question` prompt. Claude's schema is
/// `{questions: [{question, header, options: [{label, description}], multiSelect}]}`;
/// we surface the first question (the composer answers one at a time).
fn question_prompt(name: &str, input: Option<&Value>) -> Option<WirePrompt> {
/// we surface the first question (the composer answers one at a time). Also
/// used by the plan-mode bridge (`ChatHost::request_permission`, via the
/// harness re-export) to build the held mid-turn question card.
pub(crate) fn question_prompt(name: &str, input: Option<&Value>) -> Option<WirePrompt> {
if name != "AskUserQuestion" {
return None;
}
@@ -528,7 +579,8 @@ async fn run_turn(ctx: &mut TurnCtx) -> Result<()> {
])
// AskUserQuestion and ExitPlanMode are now surfaced to the user as
// interactive cards (see plan_prompt / question_prompt) instead of being
// disallowed; the turn ends on them and the answer resumes the session.
// disallowed; the turn ends on them and the answer resumes the session —
// unless the plan-mode bridge is active, which holds them open mid-turn.
.arg("--append-system-prompt-file")
.arg(&playbook)
.current_dir(&repo)
@@ -610,6 +662,11 @@ async fn run_turn(ctx: &mut TurnCtx) -> Result<()> {
// can't suppress this turn's fallback.
let plan_mode = ctx.permission_mode == Some(PermissionMode::Plan);
let _ = ctx.host.take_bridge_prompted(&ctx.session_id);
// Sweep zombie HELD cards (native_id) a crashed/restarted process left
// unresolved: they can never be answered again, and once this turn makes
// the session busy one could capture the composer's typed-text routing.
// End-turn cards are deliberately left alone — they resume via --resume.
let _ = ctx.host.resolve_stale_prompts(&ctx.session_id, true).await;
let mut saw_prompt = false;
let mut turn_errored = false;
let mut last_text = String::new();
@@ -661,17 +718,17 @@ async fn run_turn(ctx: &mut TurnCtx) -> Result<()> {
// ExitPlanMode / AskUserQuestion surface as interactive
// prompt cards instead of plain tool rows, and the
// user's choice resumes the session. With the bridge
// active, ExitPlanMode's card comes from the bridge
// instead (a held, mid-turn-answerable card), so the
// tool_use renders as a plain tool row — two cards
// for one plan would race each other.
let bridged_plan = bridge_active && name == "ExitPlanMode";
if let Some(prompt) = (!bridged_plan)
.then(|| {
plan_prompt(name, input)
.or_else(|| question_prompt(name, input))
})
.flatten()
// active, BOTH cards come from the bridge instead
// (held, mid-turn-answerable) and the tool_use
// renders NOTHING: a tool row would duplicate the
// card — and the denial that carries the user's
// answer back would paint it as a spurious error
// row once the tool_result lands.
if bridge_active && matches!(name, "ExitPlanMode" | "AskUserQuestion") {
continue;
}
if let Some(prompt) =
plan_prompt(name, input).or_else(|| question_prompt(name, input))
{
saw_prompt = true;
ctx.upsert_part(WirePart::prompt(id, prompt));
@@ -772,8 +829,10 @@ async fn run_turn(ctx: &mut TurnCtx) -> Result<()> {
// and no ExitPlanMode call. Headless leaves no way out of plan mode then —
// only a plan-card answer switches the resume mode, so a chat "yes" would
// resume still read-only. Synthesize a card from the final text so
// approval always has a handle. A card the bridge surfaced mid-turn counts
// as "saw a prompt" (e.g. keep-planning continued this same turn).
// approval always has a handle. A plan/permission card the bridge surfaced
// mid-turn counts as "saw a prompt" (e.g. keep-planning continued this
// same turn); a mid-turn *question* deliberately does not — its answer is
// no exit recourse, and the turn may still end with a texty plan.
let saw_prompt = saw_prompt || ctx.host.take_bridge_prompted(&ctx.session_id);
if should_synthesize_plan(plan_mode, saw_prompt, turn_errored, &last_text) {
ctx.upsert_part(WirePart::prompt(
@@ -874,6 +933,18 @@ mod tests {
assert_eq!(mode, Some(PermissionMode::Plan));
}
#[test]
fn plan_noteless_deny_is_a_rejection() {
// The strip's Reject: no note → "stop and wait" wording, still plan
// mode. The bridge deny arm reuses this string verbatim, and the
// end-turn path short-circuits to ResumeAction::Nothing before ever
// sending it — this pins the wording the bridge relays.
let (text, mode) = synthesize_resume("plan", &answer(false, None, &[], None));
assert!(text.contains("rejected"), "{text}");
assert!(text.contains("Stop planning"), "{text}");
assert_eq!(mode, Some(PermissionMode::Plan));
}
#[test]
fn permission_approve_defaults_to_bypass() {
// Approving a blocked tool must resume under `bypass` — the only mode
+3 -1
View File
@@ -581,7 +581,9 @@ async fn run_turn_app_server(ctx: &mut TurnCtx) -> Result<()> {
// request ids restart per child, so a click on a stale card could be
// delivered to a live request minted later. Resolve them all before this
// turn can surface anything.
ctx.host.resolve_stale_prompts(&ctx.session_id).await?;
ctx.host
.resolve_stale_prompts(&ctx.session_id, false)
.await?;
let project = ctx.project.clone();
let session_id = ctx.session_id.clone();
let (repo, playbook) =
+1
View File
@@ -32,6 +32,7 @@ use async_trait::async_trait;
use crate::error::{anyhow, Result};
use crate::local::chat::{PromptAnswer, ResumeCtx, TurnCtx, WirePrompt};
pub(crate) use claude::question_prompt;
pub use detect::{HarnessInfo, ModelInfo};
pub use options::{HarnessOptions, PermissionMode};
pub use plan_gate::command_is_readonly;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
html { background: #ffffff; }
@media (prefers-color-scheme: dark) { html { background: #0e0c0c; } }
</style>
<script type="module" crossorigin src="/assets/index-DZiQBFi6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BZvR_mUi.css">
<script type="module" crossorigin src="/assets/index-DgScguTc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BsmS7b0n.css">
</head>
<body>
<div id="root"></div>
+9
View File
@@ -713,6 +713,15 @@ export interface ChatPrompt {
header?: string;
options?: ChatQuestionOption[];
multiSelect?: boolean;
/** Answer echo, stamped on resolve: chosen labels (questions), whether the
* card was approved (plan/permission), and any freeform note. Absent on
* cards resolved without an answer (stale-card cleanup). */
answers?: string[];
approved?: boolean;
note?: string;
/** Backend resume routing id. Presence marks a HELD mid-turn card (the
* turn is blocked open waiting on this answer); absent on end-turn cards. */
nativeId?: string;
}
export interface ChatPart {
+261 -66
View File
@@ -309,7 +309,10 @@ function ToolGroup({ parts, onOpenFile }: { parts: ChatPart[]; onOpenFile?: (pat
}
/** Interactive card for a plan / permission / question prompt. Approving (or
* answering) resumes the session; the card renders read-only once resolved. */
* answering) resumes the session. Once resolved, cards mirror Claude Code:
* a permission leaves no trace, a plan collapses to an expandable
* "Proposed plan" row, a question collapses to a compact record of the
* chosen answer — all inline at the card's chronological position. */
function PromptCard({
part,
onRespond,
@@ -323,11 +326,80 @@ function PromptCard({
}) {
const p = part.prompt as ChatPrompt;
const [picked, setPicked] = useState<string[]>([]);
const done = p.resolved || !onRespond;
// Read-only host (no onRespond): actions disabled or hidden, card visible.
const done = !onRespond;
const respond = (answer: Omit<PromptAnswer, "promptId">) =>
onRespond?.({ promptId: part.id, ...answer });
// Resolved rendering, keyed off `resolved` alone (`done` also covers
// read-only hosts, where an *unresolved* card must stay visible).
if (p.resolved) {
if (p.kind === "permission") return null;
if (p.kind === "plan") {
// No echo (`approved` absent — stale-card cleanup, pre-echo history):
// neutral "Resolved", not a checkmark implying approval. A denial with
// a note asked for changes; without one it was a plain rejection.
const outcome =
p.approved === true
? "Plan approved"
: p.approved === false
? p.note
? "Revision requested"
: "Rejected"
: "Resolved";
const outcomeClass =
p.approved === true
? "approved"
: p.approved === false
? p.note
? "revised"
: "rejected"
: "";
return (
<details className="prompt-collapsed">
<summary>
<span className="prompt-collapsed-title">
{p.synthesized ? "Plan" : "Proposed plan"}
</span>
<span className={`prompt-outcome ${outcomeClass}`}>{outcome}</span>
</summary>
<div className="prompt-collapsed-body">
<Md text={p.plan ?? ""} onOpenFile={onOpenFile} />
{p.note && <div className="prompt-collapsed-note">{p.note}</div>}
</div>
</details>
);
}
// question — one line: header/question + what was chosen (or the typed
// custom answer). No echo at all (stale-resolved): neutral "Resolved",
// matching the plan row.
const chosen = (p.answers ?? []).join(", ") || p.note || "";
return (
<details className="prompt-collapsed">
<summary>
<span className="prompt-collapsed-title">{p.header || p.question || "Question"}</span>
<span className={`prompt-outcome ${chosen ? "chosen" : ""}`}>{chosen || "Resolved"}</span>
</summary>
<div className="prompt-collapsed-body">
{/* The summary title already shows the question when there's no header. */}
{p.header && p.question && <div className="prompt-q">{p.question}</div>}
{(p.options ?? []).length > 0 && (
<ul className="prompt-collapsed-options">
{(p.options ?? []).map((o) => (
<li key={o.label} className={p.answers?.includes(o.label) ? "sel" : ""}>
{o.label}
</li>
))}
</ul>
)}
{/* A note-only answer is already the summary outcome — don't echo it twice. */}
{p.note && p.note !== chosen && <div className="prompt-collapsed-note">{p.note}</div>}
</div>
</details>
);
}
if (p.kind === "plan") {
// With a plan-strip host (onOpenPlan), the docked strip owns the approval
// actions and the full plan lives in the right pane — the inline card is a
@@ -336,7 +408,7 @@ function PromptCard({
// harness-agnostic permission-mode wire ids).
const docked = !!onOpenPlan;
return (
<div className={`prompt-card plan ${done ? "resolved" : ""}`}>
<div className={`prompt-card plan ${done ? "readonly" : ""}`}>
<div className="prompt-head">
{p.synthesized ? "Plan mode — ready to proceed?" : "Proposed plan"}
</div>
@@ -348,25 +420,20 @@ function PromptCard({
View full plan
</button>
)}
{done ? (
<div className="prompt-resolved">Resolved</div>
) : (
!docked && (
<div className="prompt-actions">
<button className="btn-primary" onClick={() => respond({ approve: true, resumeMode: "auto" })}>
Approve &amp; run
</button>
<button className="btn-ghost" onClick={() => respond({ approve: true, resumeMode: "accept-edits" })}>
Approve &amp; accept edits
</button>
<button className="btn-ghost" onClick={() => respond({ approve: true, resumeMode: "bypass" })}>
Approve &amp; bypass all
</button>
<button className="btn-ghost" onClick={() => respond({ approve: false })}>
Keep planning
</button>
</div>
)
{/* Strip-less fallback (unreachable in the main app — App always
provides onOpenPlan): same action semantics as the strip. */}
{!done && !docked && (
<div className="prompt-actions">
<button className="btn-primary" onClick={() => respond({ approve: true, resumeMode: "auto" })}>
Accept and auto mode
</button>
<button className="btn-ghost" onClick={() => respond({ approve: true, resumeMode: "bypass" })}>
Accept and bypass all
</button>
<button className="btn-ghost" onClick={() => respond({ approve: false })}>
Reject
</button>
</div>
)}
</div>
);
@@ -382,15 +449,13 @@ function PromptCard({
const reason =
(typeof p.toolInput?.reason === "string" && p.toolInput.reason) || "";
return (
<div className={`prompt-card permission ${done ? "resolved" : ""}`}>
<div className={`prompt-card permission ${done ? "readonly" : ""}`}>
<div className="prompt-head">
Permission needed: <code>{p.tool}</code>
</div>
{summary && <div className="prompt-sub">{summary}</div>}
{reason && <div className="prompt-sub">{reason}</div>}
{done ? (
<div className="prompt-resolved">Resolved</div>
) : (
{!done && (
// No resumeMode: the harness picks the right one for an approval.
// Claude resumes under `bypass` (the only mode that actually grants a
// blocked tool — acceptEdits would re-deny Bash); inline harnesses
@@ -418,7 +483,7 @@ function PromptCard({
: [label],
);
return (
<div className={`prompt-card question ${done ? "resolved" : ""}`}>
<div className={`prompt-card question ${done ? "readonly" : ""}`}>
{p.header && <div className="prompt-head">{p.header}</div>}
{p.question && <div className="prompt-q">{p.question}</div>}
<div className="prompt-options">
@@ -448,11 +513,23 @@ function PromptCard({
</button>
</div>
)}
{done && <div className="prompt-resolved">Resolved</div>}
</div>
);
}
/** Whether a message renders anything once resolved-permission cards vanish —
* a bridge permission card rides its own message, so resolving it leaves the
* message empty and it must drop out of the transcript entirely. */
function messageHasVisibleContent(m: ChatMessage): boolean {
if (m.role === "user") return true;
return m.parts.some((part) => {
if (part.type === "prompt")
return !!part.prompt && !(part.prompt.resolved && part.prompt.kind === "permission");
if (part.type === "text" || part.type === "reasoning") return !!part.text;
return true; // tool, image, …
});
}
function Message({
message,
onOpenFile,
@@ -589,6 +666,7 @@ function SessionRow({
session,
active,
busy,
waiting,
onOpen,
onRename,
onSetArchived,
@@ -597,6 +675,8 @@ function SessionRow({
session: ChatSession;
active: boolean;
busy: boolean;
/** Turn held on an unanswered card: steady dot, not the working pulse. */
waiting: boolean;
onOpen: () => void;
onRename: (title: string) => void;
onSetArchived: (archived: boolean) => void;
@@ -660,7 +740,9 @@ function SessionRow({
}
}}
>
<span className="session-dot">{busy && <span className="busy-dot" />}</span>
<span className="session-dot">
{busy && <span className={`busy-dot ${waiting ? "waiting" : ""}`} />}
</span>
{editing ? (
<input
ref={inputRef}
@@ -950,6 +1032,21 @@ export function ChatPanel({
const messages = activeId ? (state.messagesBySession[activeId] ?? []) : [];
const busy = activeId ? state.busySessions.has(activeId) : false;
// A busy turn blocked on an unanswered HELD card (nativeId — a bridge or
// inline mid-turn request) is waiting on the user, not the model. Drives
// the status line and the rail dot (the composer button is keyed on
// `pendingQuestion` instead — what send() can actually service). End-turn
// cards (no nativeId) never coexist with a busy turn of their own, so
// keying on nativeId avoids false positives from stale cards. (Sessions
// whose transcripts aren't loaded fall back to plain busy.)
const sessionWaiting = (id: string) =>
state.busySessions.has(id) &&
(state.messagesBySession[id] ?? []).some((m) =>
m.parts.some(
(p) => p.type === "prompt" && p.prompt && !p.prompt.resolved && p.prompt.nativeId,
),
);
const awaitingInput = activeId ? sessionWaiting(activeId) : false;
const activeSession = openSession;
// The newest unresolved plan prompt, if any — it drives the docked strip
@@ -970,6 +1067,44 @@ export function ChatPanel({
return null;
}, [messages]);
// The newest ANSWERABLE unresolved question card's part id: typed composer
// text answers IT as a custom answer, instead of racing the held turn with
// a new message (which the busy guard would reject/drop). Plan cards have
// their own inline revise textarea (PlanStrip) and don't route through
// here. Claude sessions only: opencode rejects note-only replies (see
// reply_inline), so its options stay the interface. A held (nativeId) card
// is answerable only while its turn is alive — a zombie left by a process
// restart must not capture the composer (its own buttons error and the
// backend collapses it on the first attempt).
const pendingQuestion = useMemo(() => {
if (!activeId || activeSession?.harness !== "claude-code") return null;
for (let i = messages.length - 1; i >= 0; i--) {
for (const part of messages[i].parts) {
if (part.type !== "prompt" || !part.prompt || part.prompt.resolved) continue;
if (part.prompt.kind !== "question") continue;
if (part.prompt.nativeId && !state.busySessions.has(activeId)) return null;
return part.id;
}
}
return null;
}, [messages, activeSession?.harness, activeId, state.busySessions]);
// A submitted plan revision, until its replacement card arrives: hides the
// outgoing card's strip so it never sits there looking actionable while
// the model rewrites the plan (the transcript's Working… spinner is the
// feedback). Cleared when the session's turn ends or a DIFFERENT plan card
// shows up in the same session — pendingPlan derives from the ACTIVE
// session, so the replaced check must not fire on a session switch.
const [revising, setRevising] = useState<{ sessionId: string; promptId: string } | null>(null);
const revisingPlan = revising && revising.sessionId === activeId ? revising : null;
useEffect(() => {
if (!revising) return;
const stillBusy = state.busySessions.has(revising.sessionId);
const replaced =
revising.sessionId === activeId && pendingPlan && pendingPlan.promptId !== revising.promptId;
if (!stillBusy || replaced) setRevising(null);
}, [revising, pendingPlan, state.busySessions, activeId]);
// Plan opens are stamped with the session like file opens are.
const openPlan =
onOpenPlan && activeId
@@ -1013,7 +1148,19 @@ export function ChatPanel({
async function send() {
const text = draft.trim();
const pending = attachments;
if ((!text && pending.length === 0) || busy) return;
if (!text && pending.length === 0) return;
// A pending question card owns plain typed text as a custom answer
// (Claude-desktop behavior). This also works while the turn is HELD on
// the card — where a new message would be rejected as busy and silently
// dropped. A failed answer restores the draft so the text isn't lost.
if (text && pendingQuestion && pending.length === 0) {
setDraft("");
void respond({ promptId: pendingQuestion, answers: [], note: text }).then((ok) => {
if (!ok) setDraft((cur) => cur || text);
});
return;
}
if (busy) return;
// `composerSelection` already resolves to the open session's settings (+ any
// unsent tweak) or, for a new session, the global preference.
const effective = composerSelection;
@@ -1145,13 +1292,41 @@ export function ChatPanel({
forgetSession(session.id);
}
function respond(answer: PromptAnswer) {
if (!activeId) return;
/** Deliver a card answer; resolves `false` when delivery failed (so a
* caller can e.g. restore a consumed draft). */
function respond(answer: PromptAnswer): Promise<boolean> {
if (!activeId) return Promise.resolve(false);
const sid = activeId;
// The resumed turn streams over SSE; optimistically mark busy.
dispatch({ type: "busy", sessionId: activeId, busy: true });
void respondChat(activeId, answer).catch(() => {
if (activeId) dispatch({ type: "busy", sessionId: activeId, busy: false });
});
dispatch({ type: "busy", sessionId: sid, busy: true });
return respondChat(sid, answer)
.then(() => true)
.catch(() => false)
.finally(() => {
// Reconcile with the store: if this tab's copy of the card was stale
// (e.g. the held turn timed out and resolved it while our SSE was
// dropped), the answer no-ops server-side and nothing re-broadcasts —
// without this the card stays actionable forever and every answer
// silently dead-ends. Busy is reconciled from the server for THIS
// session only (a whole-set replace could stomp another session's
// just-started optimistic flag), so the optimistic dispatch above
// can't wedge true after a no-op or failure.
getChatMessages(sid)
.then((messages) => dispatch({ type: "seed", sessionId: sid, messages }))
.catch(() => {});
listChatSessions(projectId)
.then((list) =>
dispatch({
type: "busy",
sessionId: sid,
busy: !!list.find((s) => s.id === sid)?.busy,
}),
)
// On a failed fetch keep the optimistic flag: clearing busy while a
// Handled resume is still streaming would hide Working…/Stop for
// the rest of the turn (nothing re-asserts busy mid-stream).
.catch(() => {});
});
}
const visibleSessions = sessions.filter((s) =>
@@ -1204,6 +1379,7 @@ export function ChatPanel({
session={s}
active={s.id === activeId && mainView === "chat"}
busy={state.busySessions.has(s.id)}
waiting={sessionWaiting(s.id)}
onOpen={() => {
setActiveId(s.id);
onSelectMainView("chat");
@@ -1321,7 +1497,7 @@ export function ChatPanel({
<div className="chat-thread-inner" ref={threadInnerRef}>
{/* Stamp the session onto file opens: the agent runs in this
session's worktree, so that's where its paths point. */}
{messages.map((m) => (
{messages.filter(messageHasVisibleContent).map((m) => (
<Message
key={m.id}
message={m}
@@ -1330,36 +1506,47 @@ export function ChatPanel({
onOpenPlan={openPlan}
/>
))}
{busy && (
<div className="working">
<span className="spinner" /> Working…
</div>
)}
{busy &&
(awaitingInput ? (
<div className="working awaiting">Waiting for your input…</div>
) : (
<div className="working">
<span className="spinner" /> Working…
</div>
))}
</div>
</div>
)}
{/* Docked while a plan awaits a decision, so the approval controls never
scroll away. Actions mirror the (now compact) inline card's wire. */}
{pendingPlan && (
<PlanStrip
plan={pendingPlan.plan}
synthesized={pendingPlan.synthesized}
onView={() => openPlan?.(pendingPlan.plan, pendingPlan.promptId)}
onApprove={(resumeMode) =>
respond({ promptId: pendingPlan.promptId, approve: true, resumeMode })
}
onKeepPlanning={() => {
// The composer draft rides along as the refinement note — typing
// feedback then clicking "Keep planning" is the natural gesture.
const note = draft.trim();
respond({ promptId: pendingPlan.promptId, approve: false, note: note || undefined });
if (note) setDraft("");
}}
/>
)}
<div className="composer">
{/* Inside the composer so the composer's popovers (mode/model pickers,
z 50 within this stacking context) layer above the strip — as a
sibling, the composer's own z-index: 4 capped them below it. */}
{/* Hidden while a submitted revision is in flight so the outgoing
card never sits there looking actionable; the revised card swaps
in when it arrives (effect above). The transcript status covers
the interim ("Waiting for your input…" for a beat until the old
card's resolve broadcast lands, then Working…). */}
{pendingPlan && !(revisingPlan && pendingPlan.promptId === revisingPlan.promptId) && (
<PlanStrip
synthesized={pendingPlan.synthesized}
onView={() => openPlan?.(pendingPlan.plan, pendingPlan.promptId)}
onApprove={(resumeMode) =>
respond({ promptId: pendingPlan.promptId, approve: true, resumeMode })
}
// Plain rejection — no note; the model stops and waits.
onReject={() => respond({ promptId: pendingPlan.promptId, approve: false })}
// The strip owns its own revise textarea (Claude-desktop style);
// the note comes back on submit, always non-empty (note presence
// is what distinguishes revise from reject on the wire).
onRevise={(note) => {
if (activeId) setRevising({ sessionId: activeId, promptId: pendingPlan.promptId });
respond({ promptId: pendingPlan.promptId, approve: false, note });
}}
/>
)}
<div className="composer-box">
{skillMenuOpen && (
<SkillMenu
@@ -1389,11 +1576,14 @@ export function ChatPanel({
ref={composerRef}
value={draft}
placeholder={
// Follow `composerSelection` so the name tracks the picker for a
// new session and the open session once one exists.
composerSelection
? `Message ${HARNESS_LABELS[composerSelection.harness]}… ( / for skills)`
: "Ask the research agent… ( / for skills)"
// A pending question card owns typed text (see send()); say so.
// Otherwise follow `composerSelection` so the name tracks the
// picker for a new session and the open session once one exists.
pendingQuestion
? "Type a custom answer…"
: composerSelection
? `Message ${HARNESS_LABELS[composerSelection.harness]}… ( / for skills)`
: "Ask the research agent… ( / for skills)"
}
rows={2}
onPaste={onComposerPaste}
@@ -1469,7 +1659,12 @@ export function ChatPanel({
title="Reasoning level for this chat"
onSelect={setReasoningLevel}
/>
{busy ? (
{busy && !pendingQuestion ? (
// Stop whenever the turn is busy and typed text has nowhere to
// go — actively streaming, or held on a plan/permission card
// (their cards are the affordance; send() can't service them).
// Send stays only when it actually works: idle, or a held
// QUESTION card that owns typed text.
<button className="send-btn stop" title="Stop" aria-label="Stop" onClick={stop}>
<X size={16} />
</button>
+115 -58
View File
@@ -1,26 +1,43 @@
import { ChevronDown, ScrollText } from "lucide-react";
import { ChevronDown, CornerDownLeft, ScrollText } from "lucide-react";
import { useEffect, useRef, useState } from "react";
/** Docked strip above the composer while a plan awaits the user's decision.
* It owns the plan actions (the inline card renders compact, buttonless) so
* the approval controls never scroll away with the transcript. Disappears when
* the prompt resolves (the server re-emits the message with `resolved`). */
* the prompt resolves (the server re-emits the message with `resolved`).
*
* Claude-desktop parity — the actions mean:
* - Reject: plain rejection, no feedback; the model stops and waits.
* - Revise…: swaps the strip into its own inline textarea ("What should
* change? (optional)") with Back/Revise buttons — self-contained, not a
* detour through the main composer.
* - Accept and auto mode (primary): approve + resume under Auto — the
* default accept action. The caret menu holds Accept and bypass all
* (skip every gate, not just Auto's). No plain "accept-edits" tier here —
* the app has no story for partial (edits-only) approval.
* - Open plan: link in the title row → the right-pane plan tab. */
export function PlanStrip({
plan,
synthesized,
onView,
onApprove,
onKeepPlanning,
onReject,
onRevise,
}: {
plan: string;
/** Card synthesized from the turn's final text (no ExitPlanMode call). */
synthesized: boolean;
onView: () => void;
onApprove: (resumeMode: "auto" | "accept-edits" | "bypass") => void;
onKeepPlanning: () => void;
onApprove: (resumeMode: "auto" | "bypass") => void;
onReject: () => void;
/** Revision feedback; always non-empty (a blank submit sends a generic
* "please revise" — note presence is what distinguishes revise from
* reject on the wire). */
onRevise: (note: string) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const [revising, setRevising] = useState(false);
const [note, setNote] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (!menuOpen) return;
@@ -31,64 +48,104 @@ export function PlanStrip({
return () => window.removeEventListener("pointerdown", close);
}, [menuOpen]);
// One-line excerpt: the first heading or non-empty line.
const excerpt =
plan
.split("\n")
.map((l) => l.replace(/^#+\s*/, "").trim())
.find((l) => l.length > 0) ?? "";
useEffect(() => {
if (revising) textareaRef.current?.focus();
}, [revising]);
const submitRevision = () => {
// A blank submit still means "revise": the wire distinguishes reject
// (no note) from revise (note), so an empty field gets a generic nudge
// rather than accidentally reading as a hard rejection. (The backend
// wraps it as "Keep refining the plan: <note>", so word it to read well
// there.)
onRevise(note.trim() || "no specific feedback — use your judgment");
setNote("");
setRevising(false);
};
return (
<div className="plan-strip">
<div className="plan-strip-info">
<ScrollText size={14} className="plan-strip-icon" />
<div className="plan-strip-text">
<span className="plan-strip-title">
{synthesized ? "Plan mode — ready to proceed?" : "Plan ready"}
</span>
{excerpt && <span className="plan-strip-excerpt">{excerpt}</span>}
</div>
</div>
<div className="plan-strip-actions">
<button className="btn-ghost" onClick={onView}>
View plan
<span className="plan-strip-title">
{synthesized ? "Claude is ready to proceed" : "Claude proposed a plan"}
</span>
<button className="plan-strip-open" onClick={onView}>
Open plan
</button>
<button className="btn-ghost" onClick={onKeepPlanning}>
Keep planning
</button>
<div className="plan-strip-approve" ref={menuRef}>
<button className="btn-primary" onClick={() => onApprove("auto")}>
Approve &amp; run
</button>
<button
className="btn-primary plan-strip-caret"
aria-label="More approval options"
onClick={() => setMenuOpen((o) => !o)}
>
<ChevronDown size={13} />
</button>
{menuOpen && (
<div className="plan-strip-menu">
<button
onClick={() => {
setMenuOpen(false);
onApprove("accept-edits");
}}
>
Approve &amp; accept edits
</button>
<button
onClick={() => {
setMenuOpen(false);
onApprove("bypass");
}}
>
Approve &amp; bypass all
</button>
</div>
)}
</div>
</div>
{revising ? (
<>
<textarea
ref={textareaRef}
className="plan-strip-revise-input"
placeholder="What should change? (optional)"
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
setNote("");
setRevising(false);
} else if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitRevision();
}
}}
/>
<div className="prompt-actions plan-strip-actions">
<button
className="btn-ghost"
onClick={() => {
setNote("");
setRevising(false);
}}
>
Back
</button>
<span className="plan-strip-spacer" />
<button className="btn-primary plan-strip-primary" onClick={submitRevision}>
Revise
<CornerDownLeft size={13} />
</button>
</div>
</>
) : (
<div className="prompt-actions plan-strip-actions">
<button className="btn-ghost" onClick={onReject}>
Reject
</button>
<button className="btn-ghost" onClick={() => setRevising(true)}>
Revise…
</button>
<span className="plan-strip-spacer" />
<div className="plan-strip-approve" ref={menuRef}>
<button className="btn-primary plan-strip-primary" onClick={() => onApprove("auto")}>
Accept and auto mode
</button>
<button
className="btn-primary plan-strip-primary plan-strip-caret"
aria-label="More approval options"
onClick={() => setMenuOpen((o) => !o)}
>
<ChevronDown size={13} />
</button>
{menuOpen && (
<div className="plan-strip-menu">
<button
onClick={() => {
setMenuOpen(false);
onApprove("bypass");
}}
>
Accept and bypass all
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}
+172 -36
View File
@@ -699,6 +699,17 @@ textarea::placeholder {
position: relative;
}
/* Right-pane plan tab: a plain, padded document surface — the plan is a
markdown file, not a card, so no tinted background. */
.plan-tab-content {
overflow-y: auto;
background: var(--base);
padding: 18px 24px;
}
.plan-tab-content .md {
max-width: var(--readable-col);
}
/* Segmented control: a clearly-darker track (the theme's creams all blend
into each other) with a raised white pill on the active segment. */
.seg {
@@ -858,6 +869,10 @@ textarea::placeholder {
animation: pulse 1.2s infinite;
flex-shrink: 0;
}
/* Turn held on an unanswered card: ready for the user — steady, no pulse. */
.session-row .busy-dot.waiting {
animation: none;
}
/* Inline rename: the title span is replaced by an input sharing its box so the
text doesn't jump. Take the row's full width and hide the kebab/time. */
.session-row .session-title-input {
@@ -1120,6 +1135,10 @@ textarea::placeholder {
font-size: 12.5px;
padding: 2px 0 8px;
}
/* Turn held open on an unanswered card: waiting on the user, not the model. */
.working.awaiting {
font-style: italic;
}
.spinner {
width: 13px;
@@ -3626,8 +3645,10 @@ button.files-pill-link:hover {
.prompt-card.question {
border-left-color: var(--accent-purple);
}
/* A resolved card is read-only history: mute it further. */
.prompt-card.resolved {
/* A card in a read-only host (no respond handler): muted, buttons disabled.
Resolved cards never render as .prompt-card at all — permission cards
vanish, plan/question cards collapse to .prompt-collapsed rows. */
.prompt-card.readonly {
opacity: 0.6;
}
@@ -3757,27 +3778,87 @@ button.files-pill-link:hover {
cursor: default;
}
.prompt-resolved {
font-size: 11px;
font-weight: 600;
/* Resolved plan/question cards collapse to a one-line expandable row at their
chronological position (Claude-desktop style); resolved permission cards
render nothing at all. Same visual family as `.reasoning`. */
.prompt-collapsed {
color: var(--muted);
font-size: 13px;
margin: 4px 0;
}
.prompt-collapsed summary {
display: flex;
align-items: baseline;
gap: 8px;
cursor: pointer;
list-style: none;
user-select: none;
}
.prompt-collapsed summary::-webkit-details-marker {
display: none;
}
.prompt-collapsed summary::after {
content: "›";
color: var(--muted);
transition: transform 80ms ease;
}
.prompt-collapsed[open] summary::after {
transform: rotate(90deg);
}
.prompt-collapsed-title {
font-weight: 600;
word-break: break-word;
}
.prompt-outcome {
font-size: 12px;
color: var(--subtext);
word-break: break-word;
}
.prompt-outcome.approved,
.prompt-outcome.chosen {
color: var(--accent-green);
}
.prompt-outcome.approved::before,
.prompt-outcome.chosen::before {
content: "✓ ";
}
.prompt-outcome.revised,
.prompt-outcome.rejected {
color: var(--accent-amber);
}
.prompt-collapsed-body {
margin-top: 6px;
padding-left: 12px;
border-left: 2px solid var(--border);
font-size: 13px;
color: var(--subtext);
}
.prompt-collapsed-options {
margin: 6px 0 0;
padding-left: 18px;
}
.prompt-collapsed-options .sel {
color: var(--text);
font-weight: 600;
}
.prompt-collapsed-note {
margin-top: 6px;
font-style: italic;
}
/* --- plan strip (docked above the composer while a plan awaits a decision) --- */
.plan-strip {
/* Solid card floating over the composer's ::before fade. */
/* Lives INSIDE .composer (so the composer's picker popovers stack above
it). Claude-desktop shape: title line on top, a row of buttons below. */
position: relative;
z-index: 5;
max-width: var(--readable-col);
width: calc(100% - 32px);
margin: 0 auto 6px;
padding: 9px 12px;
width: 100%;
margin: 0 0 10px;
padding: 11px 13px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
flex-direction: column;
align-items: stretch;
gap: 10px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent-blue);
border-radius: 8px;
@@ -3786,52 +3867,107 @@ button.files-pill-link:hover {
}
.plan-strip-info {
display: flex;
align-items: center;
gap: 9px;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.plan-strip-open {
margin-left: auto;
padding: 0;
border: none;
background: none;
color: var(--accent-blue);
font-size: 12.5px;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.plan-strip-open:hover {
text-decoration: underline;
}
.plan-strip-spacer {
flex: 1;
}
/* Revise mode: a standalone bordered textarea (the strip itself has none —
unlike the borderless composer textarea, this needs to read as its own
input), focus ring on :focus like any other text field. */
.plan-strip-revise-input {
width: 100%;
resize: none;
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 11px;
font-size: 13px;
font-family: inherit;
background: var(--base);
color: var(--text);
}
.plan-strip-revise-input:focus {
border-color: var(--accent-blue);
}
.plan-strip-icon {
color: var(--accent-blue);
flex-shrink: 0;
}
.plan-strip-text {
display: flex;
flex-direction: column;
min-width: 0;
align-self: center;
}
.plan-strip-title {
font-size: 12.5px;
font-size: 13px;
font-weight: 600;
}
.plan-strip-excerpt {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Button styling comes from the shared `.prompt-actions` rules, but the
strip is monochrome (no brand red): black text, black borders, quiet
hover. `--text` keeps it theme-correct in dark mode. The spacer splits
reject/revise (left) from the accepts (right); when the pane is too
narrow the accept group wraps to its own right-aligned line. */
.plan-strip-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
gap: 6px 8px;
justify-content: flex-end;
}
.plan-strip-actions .btn-primary,
.plan-strip-actions .btn-ghost {
background: transparent;
border: 1px solid var(--text);
color: var(--text);
}
.plan-strip-actions .btn-primary:hover:not(:disabled),
.plan-strip-actions .btn-ghost:hover:not(:disabled) {
background: var(--surface-2, rgb(0 0 0 / 5%));
border-color: var(--text);
color: var(--text);
opacity: 1;
}
/* The accept primary: solid dark (desktop's black button), not brand red.
`--text`-on-`--base` inverts correctly in dark mode. */
.plan-strip-actions .plan-strip-primary {
background: var(--text);
border-color: var(--text);
color: var(--base);
}
.plan-strip-actions .plan-strip-primary:hover:not(:disabled) {
background: color-mix(in oklab, var(--text) 85%, var(--base));
border-color: var(--text);
color: var(--base);
}
.plan-strip-approve {
position: relative;
display: flex;
}
/* Split button: flat inner corners; the caret drops its own left border and
draws a faint base-colored seam instead. (0,3,0)+ selectors so the
`.prompt-actions`/`.plan-strip-actions` shorthands can't re-round or
re-border the seam. */
.plan-strip-approve .btn-primary:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.plan-strip-caret {
.plan-strip-actions .plan-strip-caret {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding: 0 5px;
margin-left: 1px;
padding: 0 6px;
display: flex;
align-items: center;
border-left: 1px solid color-mix(in oklab, var(--base) 35%, var(--text));
}
.plan-strip-menu {
position: absolute;