mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-11-11 09:59:34 +08:00
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:
Generated
+1
-1
@@ -928,7 +928,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openresearch-cli"
|
||||
version = "0.1.60"
|
||||
version = "0.1.61"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
-32
File diff suppressed because one or more lines are too long
Vendored
+32
File diff suppressed because one or more lines are too long
+156
-157
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 & run
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={() => respond({ approve: true, resumeMode: "accept-edits" })}>
|
||||
Approve & accept edits
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={() => respond({ approve: true, resumeMode: "bypass" })}>
|
||||
Approve & 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
@@ -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 & 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 & accept edits
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onApprove("bypass");
|
||||
}}
|
||||
>
|
||||
Approve & 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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user