From 3a430dfaa26dcde754f6d0c1cdcd424acf5d9e00 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 14 Mar 2026 18:09:30 +0000 Subject: [PATCH] story-kit: done 240_story_btw_side_question_slash_command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement /btw side question slash command — lets users ask quick questions from conversation context without disrupting the main chat. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/api/client.ts | 37 +++- frontend/src/components/Chat.tsx | 47 ++++++ .../src/components/SideQuestionOverlay.tsx | 159 ++++++++++++++++++ server/src/http/ws.rs | 76 +++++++++ server/src/llm/chat.rs | 77 +++++++++ 5 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/SideQuestionOverlay.tsx diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d05d384..763a17b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -13,7 +13,13 @@ export type WsRequest = approved: boolean; always_allow: boolean; } - | { type: "ping" }; + | { type: "ping" } + | { + type: "side_question"; + question: string; + context_messages: Message[]; + config: ProviderConfig; + }; export interface AgentAssignment { agent_name: string; @@ -73,7 +79,11 @@ export type WsResponse = /** Sent on connect when the project still needs onboarding (specs are placeholders). */ | { type: "onboarding_status"; needs_onboarding: boolean } /** Streaming thinking token from an extended-thinking block, separate from regular text. */ - | { type: "thinking_token"; content: string }; + | { type: "thinking_token"; content: string } + /** Streaming token from a /btw side question response. */ + | { type: "side_question_token"; content: string } + /** Final signal that the /btw side question has been fully answered. */ + | { type: "side_question_done"; response: string }; export interface ProviderConfig { provider: string; @@ -324,6 +334,8 @@ export class ChatWebSocket { private onAgentConfigChanged?: () => void; private onAgentStateChanged?: () => void; private onOnboardingStatus?: (needsOnboarding: boolean) => void; + private onSideQuestionToken?: (content: string) => void; + private onSideQuestionDone?: (response: string) => void; private connected = false; private closeTimer?: number; private wsPath = DEFAULT_WS_PATH; @@ -405,6 +417,10 @@ export class ChatWebSocket { if (data.type === "agent_state_changed") this.onAgentStateChanged?.(); if (data.type === "onboarding_status") this.onOnboardingStatus?.(data.needs_onboarding); + if (data.type === "side_question_token") + this.onSideQuestionToken?.(data.content); + if (data.type === "side_question_done") + this.onSideQuestionDone?.(data.response); if (data.type === "pong") { window.clearTimeout(this.heartbeatTimeout); this.heartbeatTimeout = undefined; @@ -458,6 +474,8 @@ export class ChatWebSocket { onAgentConfigChanged?: () => void; onAgentStateChanged?: () => void; onOnboardingStatus?: (needsOnboarding: boolean) => void; + onSideQuestionToken?: (content: string) => void; + onSideQuestionDone?: (response: string) => void; }, wsPath = DEFAULT_WS_PATH, ) { @@ -473,6 +491,8 @@ export class ChatWebSocket { this.onAgentConfigChanged = handlers.onAgentConfigChanged; this.onAgentStateChanged = handlers.onAgentStateChanged; this.onOnboardingStatus = handlers.onOnboardingStatus; + this.onSideQuestionToken = handlers.onSideQuestionToken; + this.onSideQuestionDone = handlers.onSideQuestionDone; this.wsPath = wsPath; this.shouldReconnect = true; @@ -498,6 +518,19 @@ export class ChatWebSocket { this.send({ type: "chat", messages, config }); } + sendSideQuestion( + question: string, + contextMessages: Message[], + config: ProviderConfig, + ) { + this.send({ + type: "side_question", + question, + context_messages: contextMessages, + config, + }); + } + cancel() { this.send({ type: "cancel" }); } diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 3a9cb82..1ad976c 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -12,6 +12,7 @@ import type { ChatInputHandle } from "./ChatInput"; import { ChatInput } from "./ChatInput"; import { LozengeFlyProvider } from "./LozengeFlyContext"; import { MessageItem } from "./MessageItem"; +import { SideQuestionOverlay } from "./SideQuestionOverlay"; import { StagePanel } from "./StagePanel"; import { WorkItemDetailPanel } from "./WorkItemDetailPanel"; @@ -197,6 +198,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [queuedMessages, setQueuedMessages] = useState< { id: string; text: string }[] >([]); + const [sideQuestion, setSideQuestion] = useState<{ + question: string; + response: string; + loading: boolean; + } | null>(null); // Ref so stale WebSocket callbacks can read the current queued messages const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]); const queueIdCounterRef = useRef(0); @@ -360,6 +366,16 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { onOnboardingStatus: (onboarding: boolean) => { setNeedsOnboarding(onboarding); }, + onSideQuestionToken: (content) => { + setSideQuestion((prev) => + prev ? { ...prev, response: prev.response + content } : prev, + ); + }, + onSideQuestionDone: (response) => { + setSideQuestion((prev) => + prev ? { ...prev, response, loading: false } : prev, + ); + }, }); return () => { @@ -459,6 +475,28 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const sendMessage = async (messageText: string) => { if (!messageText.trim()) return; + // /btw — answered from context without disrupting main chat + const btwMatch = messageText.match(/^\/btw\s+(.+)/s); + if (btwMatch) { + const question = btwMatch[1].trim(); + setSideQuestion({ question, response: "", loading: true }); + + const isClaudeCode = model === "claude-code-pty"; + const provider = isClaudeCode + ? "claude-code" + : model.startsWith("claude-") + ? "anthropic" + : "ollama"; + const config: ProviderConfig = { + provider, + model, + base_url: "http://localhost:11434", + enable_tools: false, + }; + wsRef.current?.sendSideQuestion(question, messages, config); + return; + } + // Agent is busy — queue the message instead of dropping it if (loading) { const newItem = { @@ -1154,6 +1192,15 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { )} + + {sideQuestion && ( + setSideQuestion(null)} + /> + )} ); } diff --git a/frontend/src/components/SideQuestionOverlay.tsx b/frontend/src/components/SideQuestionOverlay.tsx new file mode 100644 index 0000000..07a75d2 --- /dev/null +++ b/frontend/src/components/SideQuestionOverlay.tsx @@ -0,0 +1,159 @@ +import * as React from "react"; +import Markdown from "react-markdown"; + +const { useEffect, useRef } = React; + +interface SideQuestionOverlayProps { + question: string; + /** Streaming response text. Empty while loading. */ + response: string; + loading: boolean; + onDismiss: () => void; +} + +/** + * Dismissible overlay that shows a /btw side question and its streamed response. + * The question and response are NOT part of the main conversation history. + * Dismiss with Escape, Enter, or Space. + */ +export function SideQuestionOverlay({ + question, + response, + loading, + onDismiss, +}: SideQuestionOverlayProps) { + const dismissRef = useRef(onDismiss); + dismissRef.current = onDismiss; + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape" || e.key === "Enter" || e.key === " ") { + e.preventDefault(); + dismissRef.current(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss is supplementary; keyboard handled via window keydown + // biome-ignore lint/a11y/useKeyWithClickEvents: keyboard dismiss handled via window keydown listener +
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation only; no real interaction */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: stop-propagation only; no real interaction */} +
e.stopPropagation()} + style={{ + background: "#2f2f2f", + border: "1px solid #444", + borderRadius: "12px", + padding: "24px", + maxWidth: "640px", + width: "90vw", + maxHeight: "60vh", + display: "flex", + flexDirection: "column", + gap: "16px", + boxShadow: "0 8px 32px rgba(0,0,0,0.5)", + }} + > + {/* Header */} +
+
+ + /btw + + + {question} + +
+ +
+ + {/* Response area */} +
+ {loading && !response && ( + + Thinking… + + )} + {response && {response}} +
+ + {/* Footer hint */} + {!loading && ( +
+ Press Escape, Enter, or Space to dismiss +
+ )} +
+
+ ); +} diff --git a/server/src/http/ws.rs b/server/src/http/ws.rs index 9c942cb..d5eae43 100644 --- a/server/src/http/ws.rs +++ b/server/src/http/ws.rs @@ -35,6 +35,14 @@ enum WsRequest { /// Heartbeat ping from the client. The server responds with `Pong` so the /// client can detect stale (half-closed) connections. Ping, + /// A quick side question answered from current conversation context. + /// The question and response are NOT added to the conversation history + /// and no tool calls are made. + SideQuestion { + question: String, + context_messages: Vec, + config: chat::ProviderConfig, + }, } #[derive(Serialize)] @@ -116,6 +124,14 @@ enum WsResponse { OnboardingStatus { needs_onboarding: bool, }, + /// Streaming token from a `/btw` side question response. + SideQuestionToken { + content: String, + }, + /// Final signal that the `/btw` side question has been fully answered. + SideQuestionDone { + response: String, + }, } impl From for Option { @@ -344,6 +360,33 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc>) -> impl poem Ok(WsRequest::Ping) => { let _ = tx.send(WsResponse::Pong); } + Ok(WsRequest::SideQuestion { question, context_messages, config }) => { + let tx_side = tx.clone(); + let store = ctx.store.clone(); + tokio::spawn(async move { + let result = chat::side_question( + context_messages, + question, + config, + store.as_ref(), + |token| { + let _ = tx_side.send(WsResponse::SideQuestionToken { + content: token.to_string(), + }); + }, + ).await; + match result { + Ok(response) => { + let _ = tx_side.send(WsResponse::SideQuestionDone { response }); + } + Err(err) => { + let _ = tx_side.send(WsResponse::SideQuestionDone { + response: format!("Error: {err}"), + }); + } + } + }); + } _ => {} } } @@ -370,6 +413,39 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc>) -> impl poem Ok(WsRequest::PermissionResponse { .. }) => { // Permission responses outside an active chat are ignored. } + Ok(WsRequest::SideQuestion { + question, + context_messages, + config, + }) => { + let tx_side = tx.clone(); + let store = ctx.store.clone(); + tokio::spawn(async move { + let result = chat::side_question( + context_messages, + question, + config, + store.as_ref(), + |token| { + let _ = tx_side.send(WsResponse::SideQuestionToken { + content: token.to_string(), + }); + }, + ) + .await; + match result { + Ok(response) => { + let _ = tx_side + .send(WsResponse::SideQuestionDone { response }); + } + Err(err) => { + let _ = tx_side.send(WsResponse::SideQuestionDone { + response: format!("Error: {err}"), + }); + } + } + }); + } Err(err) => { let _ = tx.send(WsResponse::Error { message: format!("Invalid request: {err}"), diff --git a/server/src/llm/chat.rs b/server/src/llm/chat.rs index ec9e4a7..6aa6a3f 100644 --- a/server/src/llm/chat.rs +++ b/server/src/llm/chat.rs @@ -409,6 +409,83 @@ where }) } +/// Answer a one-off side question using the existing conversation as context. +/// +/// Unlike `chat`, this function: +/// - Does NOT perform tool calls. +/// - Does NOT modify the main conversation history. +/// - Does NOT touch the shared cancel signal. +/// - Performs a single LLM call and returns the response text. +pub async fn side_question( + context_messages: Vec, + question: String, + config: ProviderConfig, + store: &dyn StoreOps, + mut on_token: U, +) -> Result +where + U: FnMut(&str) + Send, +{ + use crate::llm::providers::anthropic::AnthropicProvider; + use crate::llm::providers::ollama::OllamaProvider; + + // Use a local cancel channel that is never cancelled, so the side question + // runs to completion independently of any main chat cancel signal. + // Keep `_cancel_tx` alive for the duration of the function so the channel + // stays open and `changed()` inside the providers does not spuriously fire. + let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + let mut cancel_rx = cancel_rx; + cancel_rx.borrow_and_update(); + + let base_url = config + .base_url + .clone() + .unwrap_or_else(|| "http://localhost:11434".to_string()); + + let is_claude_code = config.provider == "claude-code"; + let is_claude = !is_claude_code && config.model.starts_with("claude-"); + + // Build a minimal history: existing context + the side question. + let mut history = context_messages; + history.push(Message { + role: Role::User, + content: question, + tool_calls: None, + tool_call_id: None, + }); + + // No tools for side questions. + let tools: &[ToolDefinition] = &[]; + + let response = if is_claude { + let api_key = get_anthropic_api_key_impl(store)?; + let provider = AnthropicProvider::new(api_key); + provider + .chat_stream( + &config.model, + &history, + tools, + &mut cancel_rx, + |token| on_token(token), + |_tool_name| {}, + ) + .await + .map_err(|e| format!("Anthropic Error: {e}"))? + } else if is_claude_code { + return Err("Claude Code provider does not support side questions".to_string()); + } else { + let provider = OllamaProvider::new(base_url); + provider + .chat_stream(&config.model, &history, tools, &mut cancel_rx, |token| { + on_token(token) + }) + .await + .map_err(|e| format!("Ollama Error: {e}"))? + }; + + Ok(response.content.unwrap_or_default()) +} + async fn execute_tool(call: &ToolCall, state: &SessionState) -> String { use crate::io::{fs, search, shell};