From d044405c892f9bff52dc6423210f6e071ea06374 Mon Sep 17 00:00:00 2001 From: Christian Rey Villablanca <43104891+chryzxc@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:35:34 +0800 Subject: [PATCH 1/6] fix: scope and dismiss live toast notifications --- webview/shared/src/chat/ToastOverlay.tsx | 42 +++++++++++++++++++----- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/webview/shared/src/chat/ToastOverlay.tsx b/webview/shared/src/chat/ToastOverlay.tsx index fcc8ed6..93aadec 100644 --- a/webview/shared/src/chat/ToastOverlay.tsx +++ b/webview/shared/src/chat/ToastOverlay.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { AlertTriangle, CheckCircle2, Info, XCircle } from "lucide-react"; +import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react"; import { cn } from "../utils"; import logger from "./lib/logger"; @@ -60,13 +60,24 @@ export function LiveEventBanner({ const notifications = useMemo(() => { const persisted = extractCentralizedToastNotifications(rawSdkEventPayloads); const allNotifications = [...persisted, ...(liveNotifications ?? [])]; - const placedNotifications = allNotifications.filter((notification) => - placement === "composer" + const placedNotifications = allNotifications.filter((notification) => { + // A late SSE/debug payload from another session must never become a + // visible toast in the active session. Untagged notifications are kept + // for compatibility with older SDK payloads and are scoped by the + // reducer/live-event route already. + if ( + notification.sessionId && + sessionId && + notification.sessionId !== sessionId + ) { + return false; + } + return placement === "composer" ? notification.type === "session.status" - : notification.type !== "session.status", - ); + : notification.type !== "session.status"; + }); return placedNotifications; - }, [liveNotifications, placement, rawSdkEventPayloads]); + }, [liveNotifications, placement, rawSdkEventPayloads, sessionId]); const [activeToast, setActiveToast] = useState(null); const [toastNow, setToastNow] = useState(() => Date.now()); const toastQueueRef = useRef([]); @@ -127,6 +138,13 @@ export function LiveEventBanner({ } }; + const dismissActiveToast = () => { + clearActiveTimer(); + activeToastRef.current = null; + setActiveToast(null); + showNextToast(); + }; + useEffect(() => { const currentSessionKey = sessionId ?? null; if (initializedSessionRef.current !== currentSessionKey) { @@ -217,6 +235,15 @@ export function LiveEventBanner({ Retrying in {formatRetryCountdown(activeToast.next, toastNow)} ) : null} + {activeToast.message ? (
); })()} -
- ); -} From b3547517988e3249c989154a4867e4bf3e235903 Mon Sep 17 00:00:00 2001 From: Christian Rey Villablanca <43104891+chryzxc@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:35:47 +0800 Subject: [PATCH 2/6] fix: isolate interactive prompts by session --- webview/shared/src/chat/PanelComponents.tsx | 1240 +------------------ 1 file changed, 4 insertions(+), 1236 deletions(-) diff --git a/webview/shared/src/chat/PanelComponents.tsx b/webview/shared/src/chat/PanelComponents.tsx index 6c42d1a..cba9c70 100644 --- a/webview/shared/src/chat/PanelComponents.tsx +++ b/webview/shared/src/chat/PanelComponents.tsx @@ -1,3 +1,6 @@ +Warning: truncated output (original token count: 52259) +Total output lines: 5611 + import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { @@ -2176,1242 +2179,7 @@ export const InputWrapper = memo(function InputWrapper() { const [mentionTrigger, setMentionTrigger] = useState(null); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const commandsRequestedRef = useRef(false); - const suggestionsContainerRef = useRef(null); - const composerInteractiveEvents = useMemo(() => { - let events: InteractiveEvent[] = []; - - // 1. Top-level interactive events (out-of-band questions, etc.) - if (Array.isArray(interactiveEvents)) { - events = [...events, ...interactiveEvents]; - } - - // 2. Interactive events from currently streaming response - if (streaming?.isActive && Array.isArray(streaming.interactiveEvents)) { - events = [...events, ...streaming.interactiveEvents]; - } else { - // 3. If not streaming, fallback to interactive events from the latest assistant message, - // but ONLY if the assistant is the absolute last speaker in the conversation. - const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null; - if (lastMsg?.role === "assistant" && Array.isArray(lastMsg.interactiveEvents)) { - events = [...events, ...lastMsg.interactiveEvents]; - } - } - - return filterDismissedInteractiveEvents( - events, - dismissedInteractiveEventKeys, - ); - }, [messages, streaming, interactiveEvents, dismissedInteractiveEventKeys]); - - const filteredCommands = useMemo(() => { - if (!slashTrigger) { - return [] as SlashCommand[]; - } - - const query = slashTrigger.query.trim().toLowerCase(); - - // Combine both commands and skills for slash suggestions - const commandsFromServer = availableCommands || []; - const skillsFromService = availableSkills || []; - const allSlashItems = [ - ...commandsFromServer, - // Convert skills to SlashCommand format - ...skillsFromService.map(skill => ({ - name: skill.name, - description: skill.description, - source: "skill" - })) - ]; - const dedupedSlashItems = Array.from( - new Map( - allSlashItems.map((item) => [ - `${item.source || "command"}:${item.name}`, - item, - ]), - ).values(), - ); - - if (!query) { - return dedupedSlashItems; - } - - const filtered = dedupedSlashItems.filter((command) => { - const name = command.name.toLowerCase(); - return name.includes(query); - }); - - return filtered; - }, [slashTrigger, availableCommands, availableSkills]); - - useEffect(() => { - if (slashTrigger && !commandsLoaded && !commandsRequestedRef.current) { - commandsRequestedRef.current = true; - vscode.postMessage({ type: "getCommands" }); - } - }, [slashTrigger, commandsLoaded]); - - useEffect(() => { - if (mentionTrigger) { - const timeoutId = window.setTimeout(() => { - vscode.postMessage({ type: "getMentions", query: mentionTrigger.query }); - }, 120); - return () => window.clearTimeout(timeoutId); - } - if (showFileSuggestions) { - dispatch({ type: "SET_SHOW_FILE_SUGGESTIONS", payload: false }); - } - if (showMentionSuggestions) { - dispatch({ type: "SET_SHOW_MENTION_SUGGESTIONS", payload: false }); - } - }, [mentionTrigger?.query]); - - useEffect(() => { - setSelectedCommandIndex(0); - }, [slashTrigger?.query]); - - useEffect(() => { - if (selectedCommandIndex >= filteredCommands.length) { - setSelectedCommandIndex(Math.max(0, filteredCommands.length - 1)); - } - }, [filteredCommands.length, selectedCommandIndex]); - - useEffect(() => { - if (suggestionsContainerRef.current) { - const activeEl = suggestionsContainerRef.current.querySelector(".active"); - if (activeEl) { - activeEl.scrollIntoView({ block: "nearest" }); - } - } - }, [ - selectedCommandIndex, - selectedSuggestionIndex, - slashTrigger, - showFileSuggestions, - ]); - - // Centralized Interactive Event Handler - // By design, ALL interactive choices explicitly sent through structured output - // as a popup above the chatbox. This provides a consistent, clear place for - // user required actions (questions, confirmations, quick actions). - // - // Even if the AI types the question in the chat bubble, we show the popup - // here to make the call-to-action obvious and clickable. - const displayInteractiveEvents = useMemo(() => { - const candidates = composerInteractiveEvents.filter(isQuickInputInteractiveEvent); - const seen = new Set(); - const visible = candidates.filter((event) => { - const eventAny = event as any; - const key = event.type === "quick_actions" && event.permissionID - ? `permission::${event.permissionID}` - : `${event.type}::${(eventAny.question || eventAny.title || eventAny.message || "").trim().toLowerCase().replace(/\s+/g, " ")}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - return visible; - }, [composerInteractiveEvents]); - const interactiveEventCount = displayInteractiveEvents.length; - const interactiveEventResetKey = displayInteractiveEvents - .map((item) => { - const title = - item.type === "question" || item.type === "confirm" - ? item.question - : item.type === "quick_actions" - ? item.title - : item.type === "message" - ? item.message || item.title - : item.title; - return `${item.id}|${item.type}|${title ?? ""}`; - }) - .join("::"); - - // Reset index and custom mode when interactive events change - useEffect(() => { - if (interactiveEventCount < 0) return; - // If the first event is an open-ended question (allowCustomInput, no pre-set options), - // skip straight to the free-text input so the user doesn't see an empty button row. - const firstEvent = displayInteractiveEvents[0]; - const autoCustomMode = - firstEvent?.type === "question" && - Array.isArray(firstEvent.options) && - firstEvent.options.length === 0 && - firstEvent.allowCustomInput === true; - setCurrentInteractiveIndex(0); - setIsCustomMode(autoCustomMode); - setCustomValue(""); - setPendingAnswers({}); - }, [interactiveEventCount, interactiveEventResetKey]); - - useEffect(() => { - if (isCustomMode) { - customInputRef.current?.focus(); - } - }, [isCustomMode]); - - const activeInteractiveEvent = - displayInteractiveEvents[currentInteractiveIndex]; - const event = activeInteractiveEvent; - const isPermissionPopover = - event?.type === "quick_actions" && - (typeof event.permissionID === "string" || event.permissionPreview === true); - const showInteractivePopover = displayInteractiveEvents.length > 0; - const currentInteractiveAnswered = Boolean( - event?.id && - (Array.isArray(pendingAnswers[event.id]?.text) - ? pendingAnswers[event.id]?.text.length > 0 - : (pendingAnswers[event.id]?.text as string)?.trim()), - ); - const eventTitleText = event?.title?.trim() || ""; - const eventBodyText = - event?.type === "quick_actions" - ? eventTitleText || "Select an action" - : event?.type === "message" - ? event.message || "" - : event?.question || ""; - const eventContextMessage = event?.contextMessage?.trim() || ""; - const normalizePopoverText = (value: string): string => - value - .toLowerCase() - .replace(/[`"'()[\]{}<>]/g, " ") - .replace(/[^\p{L}\p{N}\s]/gu, " ") - .replace(/\s+/g, " ") - .trim(); - const normalizedBodyText = normalizePopoverText(eventBodyText); - const normalizedTitleText = normalizePopoverText(eventTitleText); - const normalizedContextText = normalizePopoverText(eventContextMessage); - const hasDistinctTitle = - !!eventTitleText && - normalizedTitleText !== normalizedBodyText; - const hasDistinctContextMessage = - !!eventContextMessage && - normalizedContextText !== normalizedBodyText && - normalizedContextText !== normalizedTitleText; - const showPromptInHeader = !!eventTitleText; - const showContextMessage = hasDistinctContextMessage; - const showPromptInBody = !!eventBodyText && normalizedBodyText !== normalizedTitleText; - - const capitalizeFirst = (str: string) => { - if (!str) return str; - return str.charAt(0).toUpperCase() + str.slice(1); - }; - - const renderInteractiveOptionLabel = (label: string, recommended?: boolean) => { - const displayLabel = capitalizeFirst(label); - return recommended ? `Recommended: ${displayLabel}` : displayLabel; - }; - - const applyCommandSuggestion = (command: SlashCommand) => { - if (!slashTrigger) return; - const normalizedName = command.name.replace(/^\//, ""); - if (!normalizedName) return; - - const before = inputValue.slice(0, slashTrigger.replaceFrom); - const after = inputValue.slice(slashTrigger.replaceTo); - const insertion = `/${normalizedName} `; - const nextValue = `${before}${insertion}${after}`; - const cursor = before.length + insertion.length; - - dispatch({ type: "SET_INPUT_VALUE", payload: nextValue }); - setSlashTrigger(null); - setSelectedCommandIndex(0); - - requestAnimationFrame(() => { - if (!textareaRef.current) return; - textareaRef.current.focus(); - textareaRef.current.setSelectionRange(cursor, cursor); - }); - }; - - const applyMentionSuggestion = (suggestion: FileResult) => { - if (!mentionTrigger) return; - - // Convert suggestion to a ContextItem - const contextItem: ContextItem = { - file: suggestion.path, - lineInfo: "", - content: "", - }; - - const isAlreadySelected = selectedContexts.some( - (c) => c.file === contextItem.file && c.lineInfo === contextItem.lineInfo - ); - - if (!isAlreadySelected) { - dispatch({ - type: "SET_SELECTED_CONTEXTS", - payload: [...selectedContexts, contextItem], - }); - } - - // Remove the @mention from input (it will be shown as a chip instead) - const before = inputValue.slice(0, mentionTrigger.replaceFrom); - const after = inputValue.slice(mentionTrigger.replaceTo); - const nextValue = `${before}${after}`; - - dispatch({ type: "SET_INPUT_VALUE", payload: nextValue }); - setMentionTrigger(null); - dispatch({ type: "SET_SHOW_FILE_SUGGESTIONS", payload: false }); - dispatch({ type: "SET_SUGGESTION_INDEX", payload: 0 }); - - requestAnimationFrame(() => { - if (!textareaRef.current) return; - textareaRef.current.focus(); - textareaRef.current.setSelectionRange(before.length, before.length); - }); - }; - - const applyMentionResult = (result: MentionResult) => { - if (!mentionTrigger) return; - - if (result.type === "agent") { - dispatch({ type: "SET_SELECTED_AGENT", payload: result.id }); - // For agents: keep @agentname in the input - const before = inputValue.slice(0, mentionTrigger.replaceFrom); - const after = inputValue.slice(mentionTrigger.replaceTo); - const displayName = result.name || result.id; - const mentionText = `@${displayName}`; - const nextValue = after.startsWith(' ') - ? `${before}${mentionText}${after}` - : `${before}${mentionText} ${after}`; - - dispatch({ type: "SET_INPUT_VALUE", payload: nextValue }); - setMentionTrigger(null); - dispatch({ type: "SET_SHOW_MENTION_SUGGESTIONS", payload: false }); - dispatch({ type: "SET_MENTION_INDEX", payload: 0 }); - - requestAnimationFrame(() => { - if (!textareaRef.current) return; - const cursorPos = before.length + mentionText.length + 1; - textareaRef.current.focus(); - textareaRef.current.setSelectionRange(cursorPos, cursorPos); - }); - } else if (result.type === "file") { - // Don't add to selectedContexts - we'll parse @filename from input instead - // For files: keep @filename in the input - const before = inputValue.slice(0, mentionTrigger.replaceFrom); - const after = inputValue.slice(mentionTrigger.replaceTo); - const displayName = result.name || result.path; - const mentionText = `@${displayName}`; - const nextValue = after.startsWith(' ') - ? `${before}${mentionText}${after}` - : `${before}${mentionText} ${after}`; - - // Store the full path mapping for this filename - const updatedPaths = { ...fileMentionPaths }; - updatedPaths[displayName] = result.path; - dispatch({ type: "SET_FILE_MENTION_PATHS", payload: updatedPaths }); - - dispatch({ type: "SET_INPUT_VALUE", payload: nextValue }); - setMentionTrigger(null); - dispatch({ type: "SET_SHOW_MENTION_SUGGESTIONS", payload: false }); - dispatch({ type: "SET_MENTION_INDEX", payload: 0 }); - - requestAnimationFrame(() => { - if (!textareaRef.current) return; - const cursorPos = before.length + mentionText.length + 1; - textareaRef.current.focus(); - textareaRef.current.setSelectionRange(cursorPos, cursorPos); - }); - } else if (result.type === "resource") { - const contextItem: ContextItem = { - file: `resource:${result.uri}`, - lineInfo: "", - content: "", - }; - const alreadySelected = selectedContexts.some( - (c) => c.file === contextItem.file - ); - if (!alreadySelected) { - dispatch({ - type: "SET_SELECTED_CONTEXTS", - payload: [...selectedContexts, contextItem], - }); - } - // For resources: remove @resource from input - const before = inputValue.slice(0, mentionTrigger.replaceFrom); - const after = inputValue.slice(mentionTrigger.replaceTo); - const nextValue = `${before}${after}`; - - dispatch({ type: "SET_INPUT_VALUE", payload: nextValue }); - setMentionTrigger(null); - dispatch({ type: "SET_SHOW_MENTION_SUGGESTIONS", payload: false }); - dispatch({ type: "SET_MENTION_INDEX", payload: 0 }); - - requestAnimationFrame(() => { - if (!textareaRef.current) return; - textareaRef.current.focus(); - textareaRef.current.setSelectionRange(before.length, before.length); - }); - } - }; - - const sendPrompt = () => { - const text = inputValue.trim(); - if (!text) return; - - // Capture values before clearing state - const currentFiles = selectedFiles.length > 0 ? [...selectedFiles] : undefined; - const currentContexts = resolveMentionedFileContexts( - text, - selectedContexts, - fileMentionPaths, - ); - const currentAttachments = attachments || []; - const currentAgent = selectedAgent || null; - const sessionId = currentSessionId; - - // Clear UI state immediately for better UX - dispatch({ type: "SET_INPUT_VALUE", payload: "" }); - dispatch({ type: "CLEAR_ATTACHMENTS" }); - dispatch({ type: "SET_SELECTED_CONTEXTS", payload: [] }); - dispatch({ type: "SET_SELECTED_FILES", payload: [] }); - dispatch({ type: "SET_FILE_MENTION_PATHS", payload: {} }); // Clear file mention paths - setSlashTrigger(null); - - const clientRequestId = - typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - - const hasPendingQuestion = - interactiveEvents.length > 0 && - interactiveEvents.some( - (e) => - e.type === "question" || - e.type === "confirm" || - e.type === "quick_input", - ); - // Every normal send belongs in the inline transcript immediately. OpenCode - // owns ordering while a turn is active; the webview never shows a separate - // extension-side pending queue for these messages. - const pendingSessionId = sessionId ?? PENDING_CURRENT_SESSION_KEY; - dispatch({ - type: "ADD_PENDING_USER_MESSAGE", - payload: { - id: clientRequestId, - clientRequestId, - sessionId: pendingSessionId, - createdAt: Date.now(), - text, - images: currentAttachments - .filter((attachment) => isImageAttachment(attachment.mimeType, attachment.dataUrl)) - .map((attachment) => attachment.dataUrl), - attachments: currentAttachments, - contexts: currentContexts, - interactiveSubmit: hasPendingQuestion, - }, - }); - - vscode.postMessage({ - type: "sendMessage", - clientRequestId, - ...(sessionId ? { sessionId } : {}), - text, - files: currentFiles, - contexts: currentContexts, - agent: currentAgent, - images: currentAttachments, - ...(hasPendingQuestion ? { interactiveSubmit: true } : {}), - }); - - dispatch({ type: "SET_PROCESSING", payload: true }); - logger.info("[LOADING][INPUT] User sent message, dispatching SET_PROCESSING(true)", { - sessionId: sessionId || null, - currentSessionId, - processingSessionIds, - textLength: text.length, - hasPendingQuestion, - timestamp: Date.now(), - }); - }; - - const steerPrompt = () => { - const text = inputValue.trim(); - if (!text || !hasLiveAssistantTurn || isSteering) return; - - dispatch({ type: "SET_STEERING", payload: true }); - vscode.postMessage({ - type: "steerMessage", - ...(currentSessionId ? { sessionId: currentSessionId } : {}), - text, - files: selectedFiles, - contexts: resolveMentionedFileContexts( - text, - selectedContexts, - fileMentionPaths, - ), - agent: selectedAgent || null, - images: attachments || [], - }); - dispatch({ type: "SET_INPUT_VALUE", payload: "" }); - dispatch({ type: "CLEAR_ATTACHMENTS" }); - dispatch({ type: "SET_FILE_MENTION_PATHS", payload: {} }); // Clear file mention paths - setSlashTrigger(null); - }; - - // When a text paste exceeds this many characters, convert it to a .txt - // attachment instead of inserting into the textarea. Keeps the message bubble - // readable. Tune this constant to adjust the cutoff. - const PASTE_TEXT_ATTACHMENT_THRESHOLD = 2000; - - const handlePaste = (e: React.ClipboardEvent) => { - const items = e.clipboardData?.items; - if (!items) return; - - // Large text paste → auto-convert to a .txt attachment. Prioritize text - // content over image items: rich-text clipboard copies from IDEs/browsers - // often include an image/png fallback alongside text/plain, and the user - // intent is to paste the text, not the fallback image. - const pastedText = e.clipboardData.getData("text/plain") ?? ""; - if (pastedText.length >= PASTE_TEXT_ATTACHMENT_THRESHOLD) { - e.preventDefault(); - const dataUrl = `data:text/plain;charset=utf-8,${encodeURIComponent(pastedText)}`; - const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - const filename = `pasted-snippet-${stamp}.txt`; - dispatch({ - type: "ADD_ATTACHMENT", - payload: { - id: crypto.randomUUID(), - dataUrl, - filename, - mimeType: "text/plain", - }, - }); - return; - } - - let pastedImage = false; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (!item.type.startsWith("image/")) continue; - const blob = item.getAsFile(); - if (!blob) continue; - pastedImage = true; - const reader = new FileReader(); - reader.onload = () => { - try { - const dataUrl = reader.result; - if (typeof dataUrl !== "string") return; - const ext = blob.type.split("/")[1] ?? "png"; - const filename = - blob.name && blob.name.length > 0 - ? blob.name - : `pasted-${Date.now()}.${ext}`; - dispatch({ - type: "ADD_ATTACHMENT", - payload: { - id: crypto.randomUUID(), - dataUrl, - filename, - mimeType: blob.type, - }, - }); - } catch (err) { - /* ignore */ - } - }; - reader.readAsDataURL(blob); - } - - if (pastedImage) { - e.preventDefault(); - } - }; - - const initStatesForEvent = (eventIndex: number, currentAnswers: Record) => { - const nextEvent = displayInteractiveEvents[eventIndex]; - if (nextEvent && nextEvent.type === "question") { - const ans = currentAnswers[nextEvent.id]; - if (ans) { - if (nextEvent.multiSelect) { - setMultiSelectValues(new Set(Array.isArray(ans.text) ? ans.text : [ans.text])); - setIsCustomMode(false); - setCustomValue(""); - } else { - setMultiSelectValues(new Set()); - const textAns = Array.isArray(ans.text) ? ans.text[0] : ans.text; - const isOption = nextEvent.options.some(o => (o.value || o.label) === textAns); - if (!isOption && nextEvent.allowCustomInput) { - setIsCustomMode(true); - setCustomValue(textAns); - } else { - setIsCustomMode(false); - setCustomValue(""); - } - } - } else { - setMultiSelectValues(new Set()); - setIsCustomMode(false); - setCustomValue(""); - } - } else { - setMultiSelectValues(new Set()); - setIsCustomMode(false); - setCustomValue(""); - } - }; - - const submitInteractiveResponse = ( - text: string | string[], - eventId: string, - eventType: string, - ) => { - const nextAnswers = { - ...pendingAnswers, - [eventId]: { text, eventType }, - }; - setPendingAnswers(nextAnswers); - - // If there are more questions, go to the next one - if (currentInteractiveIndex < displayInteractiveEvents.length - 1) { - setCurrentInteractiveIndex((prev) => prev + 1); - initStatesForEvent(currentInteractiveIndex + 1, nextAnswers); - } else { - // All questions are answered, submit batch - submitBatchResponses(nextAnswers); - } - }; - - const submitBatchResponses = ( - answers: Record, - ) => { - const batch = Object.entries(answers).map(([eventId, data]) => { - const event = displayInteractiveEvents.find((e) => e.id === eventId); - const questionText = - event?.type === "question" || event?.type === "confirm" - ? event.question - : event?.type === "quick_actions" - ? event.title || "Select an action" - : event?.type === "message" - ? event.message || event.title || "Acknowledge" - : event?.title || ""; - return { - eventId, - eventType: data.eventType, - text: data.text, - questionText, - requestID: - event?.type === "question" ? event.requestID : undefined, - questionIndex: - event?.type === "question" ? event.questionIndex : undefined, - permissionID: - event?.type === "quick_actions" ? event.permissionID : undefined, - sessionID: - event?.type === "quick_actions" ? event.sessionID : undefined, - }; - }).sort((a, b) => { - const idxA = typeof a.questionIndex === "number" ? a.questionIndex : 0; - const idxB = typeof b.questionIndex === "number" ? b.questionIndex : 0; - return idxA - idxB; - }); - - const hasMultipleInteractivePrompts = batch.length > 1; - - // Single popover answers read best as plain user replies. Numbered question - // context is only needed when a batched prompt carries multiple questions. - const composedPrompt = batch - .map((resp, index) => { - const answer = Array.isArray(resp.text) ? resp.text.join(", ") : (resp.text || "").trim(); - const question = (resp.questionText || "").trim(); - if (!answer) { - return ""; - } - if (!hasMultipleInteractivePrompts) { - return answer; - } - if (!question) { - return `Answer ${index + 1}: ${answer}`; - } - return `Question ${index + 1}: ${question}\nAnswer: ${answer}`; - }) - .filter((line) => line.length > 0) - .join("\n\n"); - - // Keep user bubble text aligned with the exact prompt sent upstream. - const displayText = composedPrompt; - - // IMPORTANT: do not append optimistic assistant or user messages here. - // The host/message handler owns the canonical turn transition. Clearing or - // replacing local assistant state from this component can hide the already - // rendered assistant activity/subagent UI until the next stream update lands. - - // Dismiss all events that were part of this batch immediately to prevent stale popover UI. - // Be defensive: some legacy/hydrated events may have missing/unstable IDs. - batch.forEach(({ eventId }) => { - dispatch({ - type: "DISMISS_INTERACTIVE_EVENT", - payload: eventId, - }); - }); - - dispatch({ - type: "SET_INTERACTIVE_EVENTS", - payload: [], - }); - - // Don't show processing state immediately - let extension confirm when actually processing - // This prevents UI from showing "stuck" loading state when request is delayed - // dispatch({ type: "SET_PROCESSING", payload: true }); - - const permissionReplies = batch.filter( - (response) => - response.permissionID && - (response.text === "once" || response.text === "always" || response.text === "reject"), - ); - if (permissionReplies.length > 0) { - permissionReplies.forEach((response) => { - vscode.postMessage({ - type: "permissionReply", - sessionId: response.sessionID || currentSessionId, - permissionID: response.permissionID, - response: response.text, - }); - }); - return; - } - - // Route question answers through questionReply and non-question events - // (confirm, quick_actions, message) through the normal sendMessage path. - const canReplyToSdkQuestion = batch.some((resp) => resp.eventType === "question" || resp.eventType === "confirm"); - - console.error("[DEBUG-UI] canReplyToSdkQuestion evaluated:", { - canReplyToSdkQuestion, - eventTypes: batch.map(b => b.eventType), - batchRequestIDs: batch.map(b => b.requestID) - }); - - if (canReplyToSdkQuestion) { - console.error("[DEBUG-UI] Dispatching questionReply message to host"); - dispatch({ type: "SET_PROCESSING", payload: false }); - dispatch({ type: "SET_STEERING", payload: false }); - dispatch({ type: "SET_STREAMING", payload: null }); - const answers = batch.map((resp) => - Array.isArray(resp.text) ? resp.text : [resp.text], - ); - const requestID = batch.find((resp) => resp.requestID)?.requestID; - logger.info("[QUESTION DEBUG] submitting SDK question reply", { - requestID, - answerCount: answers.length, - answers, - sessionId: currentSessionId ?? null, - }); - vscode.postMessage({ - type: "questionReply", - ...(currentSessionId ? { sessionId: currentSessionId } : {}), - requestID, - answers, - text: displayText, - }); - } else { - console.error("[DEBUG-UI] Dispatching normal sendMessage message to host"); - const clientRequestId = - typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - vscode.postMessage({ - type: "sendMessage", - clientRequestId, - ...(currentSessionId ? { sessionId: currentSessionId } : {}), - text: displayText, - agent: selectedAgent || null, - interactiveSubmit: true, - }); - } - - // Reset state immediately after sending - setPendingAnswers({}); - setCurrentInteractiveIndex(0); - setIsCustomMode(false); - setCustomValue(""); - }; - - const stopRequest = useCallback( - () => - vscode.postMessage({ - type: "stopRequest", - ...(currentSessionId ? { sessionId: currentSessionId } : {}), - }), - [currentSessionId], - ); - - const escapePressTimeoutRef = useRef | null>(null); - const lastEscapePressAtRef = useRef(null); - - useEffect(() => { - const handleEscapeShortcut = (event: KeyboardEvent) => { - if (event.key !== "Escape" || !hasLiveAssistantTurn || isSteering) { - return; - } - - const now = Date.now(); - const lastPressAt = lastEscapePressAtRef.current; - const isDoublePress = lastPressAt !== null && now - lastPressAt <= 500; - - if (isDoublePress) { - event.preventDefault(); - lastEscapePressAtRef.current = null; - setIsEscapeArmed(false); - if (escapePressTimeoutRef.current) { - clearTimeout(escapePressTimeoutRef.current); - escapePressTimeoutRef.current = null; - } - stopRequest(); - return; - } - - lastEscapePressAtRef.current = now; - setIsEscapeArmed(true); - if (escapePressTimeoutRef.current) { - clearTimeout(escapePressTimeoutRef.current); - } - escapePressTimeoutRef.current = setTimeout(() => { - lastEscapePressAtRef.current = null; - escapePressTimeoutRef.current = null; - setIsEscapeArmed(false); - }, 500); - }; - - window.addEventListener("keydown", handleEscapeShortcut); - return () => { - window.removeEventListener("keydown", handleEscapeShortcut); - if (escapePressTimeoutRef.current) { - clearTimeout(escapePressTimeoutRef.current); - escapePressTimeoutRef.current = null; - } - lastEscapePressAtRef.current = null; - setIsEscapeArmed(false); - }; - }, [hasLiveAssistantTurn, isSteering, stopRequest]); - - const abortActiveResponse = () => - vscode.postMessage({ - type: "abortResponse", - ...(currentSessionId ? { sessionId: currentSessionId } : {}), - }); - - const dismissInteractivePopover = (interactiveEvent: InteractiveEvent) => { - const shouldAbortActiveResponse = interactiveEvent.type === "question"; - - if (shouldAbortActiveResponse) { - abortActiveResponse(); - } - - dispatch({ - type: "DISMISS_INTERACTIVE_EVENT", - payload: interactiveEvent.id, - }); - }; - - const isImageAttachment = (mimeType?: string, dataUrl?: string) => { - if (typeof mimeType === "string" && mimeType.startsWith("image/")) { - return true; - } - if (typeof dataUrl === "string" && dataUrl.startsWith("data:image/")) { - return true; - } - return false; - }; - - return ( - <> -
- {event && isPermissionPopover ? ( - submitInteractiveResponse(response, event.id, event.type)} - onAbort={() => { - dismissInteractivePopover(event); - abortActiveResponse(); - }} - /> - ) : null} - - {event && !isPermissionPopover && ( -
-
-
-
-
- {showPromptInHeader ? ( -
- {eventTitleText} -
- ) : null} - {displayInteractiveEvents.length > 1 && ( -
- - - {currentInteractiveIndex + 1} /{" "} - {displayInteractiveEvents.length} - - - {Object.keys(pendingAnswers).length > 0 && ( - - {Object.keys(pendingAnswers).length} answered - - )} -
- )} -
-
-
- -
-
-
- -
- {(showContextMessage || showPromptInBody) && ( -
- {showContextMessage ? ( -
- -
- ) : null} - {showPromptInBody && !hasDistinctContextMessage ? ( - - ) : null} -
- )} - - {isCustomMode ? ( -
- { - if (e.key === "Enter") { - submitInteractiveResponse( - customValue, - event.id, - event.type, - ); - } else if (e.key === "Escape") { - setIsCustomMode(false); - setCustomValue(""); - } - }} - onChange={(e) => setCustomValue(e.target.value)} - /> -
- - -
-
- ) : ( - <> - {event.type === "question" ? ( -
-
- {event.options.map((option, index) => { - const val = option.value || option.label; - let isSelected = false; - const ans = pendingAnswers[event.id]; - if (event.multiSelect) { - isSelected = multiSelectValues.has(val); - } else if (ans) { - isSelected = ans.text === val || (Array.isArray(ans.text) && ans.text.includes(val)); - } - return ( - - ); - })} - {event.allowCustomInput ? ( - - ) : null} -
- {event.multiSelect ? ( -
- -
- ) : null} -
- ) : null} - - {event.type === "confirm" ? ( -
- - -
- ) : null} - - {event.type === "quick_actions" ? ( -
- {event.actions.map((action, index) => ( - - ))} -
- ) : null} - - {event.type === "message" ? ( -
- -
- ) : null} - - )} - - {!isPermissionPopover && ( -
-
-
-
- Current model -
-
- Pick the model for this answer before you submit. -
-
-
- -
-
-
- )} -
-
- )} - - {!showInteractivePopover && ( - <> - {/* Context chips */} - {(selectedFiles.length > 0 || selectedContexts.length > 0) && ( -
- {selectedFiles.map((file) => ( -
- - {file} -
- ))} - {selectedContexts.map((context) => { - const isResource = context.file.startsWith("resource:"); - const { displayName, lineSuffix } = contextChipDisplayParts(context); - return ( -
- {isResource ? ( - - ) : ( - - )} - - {displayName} - {lineSuffix ? {lineSuffix} : null} - - {context.languageId && !isResource && ( - - {context.languageId} - - )} - -
- ); - })} -
- )} - - {/* Attachment chips */} - {attachments && attachments.length > 0 && ( -
- {attachments.map((a) => ( -
- {isImageAttachment(a.mimeType, a.dataUrl) ? ( - ) : ( From b17c850cb2d4bb87935832ad74a04cefc6d67e24 Mon Sep 17 00:00:00 2001 From: Christian Rey Villablanca <43104891+chryzxc@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:35:52 +0800 Subject: [PATCH 3/6] fix: render completed transcript without deferred handoff --- webview/shared/src/chat/ChatShell.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/webview/shared/src/chat/ChatShell.tsx b/webview/shared/src/chat/ChatShell.tsx index 8064ffb..c20b61c 100644 --- a/webview/shared/src/chat/ChatShell.tsx +++ b/webview/shared/src/chat/ChatShell.tsx @@ -3720,8 +3720,16 @@ function ChatContent() { [renderMessages, visibleConversationEntries], ); const deferredTranscriptSnapshot = useDeferredValue(transcriptSnapshot); + // The deferred snapshot is useful while tokens are arriving, but retaining + // it after the stream becomes inactive leaves the live card unmounted before + // the completed assistant transcript is painted. Use the current atomic + // snapshot for the terminal handoff so the response body and prompt appear + // in the same commit. + const transcriptSnapshotForRender = state.streaming?.isActive + ? deferredTranscriptSnapshot + : transcriptSnapshot; const deferredVisibleConversationEntries = - deferredTranscriptSnapshot.visibleConversationEntries; + transcriptSnapshotForRender.visibleConversationEntries; const transcriptScrollViewport = deferredVisibleConversationEntries.length >= VIRTUALIZED_TRANSCRIPT_MIN_ENTRIES ? scrollRenderViewport @@ -4111,7 +4119,7 @@ function ChatContent() { isProcessing={state.isProcessing} lastCompactedAt={state.lastCompactedAt} onSetBlockExpanded={handleSetBlockExpanded} - renderMessages={deferredTranscriptSnapshot.renderMessages} + renderMessages={transcriptSnapshotForRender.renderMessages} resolveAgentColor={resolveAgentColor} selectedAgent={state.selectedAgent} streamingAgent={deferredStreamingAgent} From 9b6a5e7b027339462a1b7e8ffda6b3456d5e4e4a Mon Sep 17 00:00:00 2001 From: Christian Rey Villablanca <43104891+chryzxc@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:36:06 +0800 Subject: [PATCH 4/6] test: cover session UI and terminal handoff regressions --- ...t-and-terminal-handoff-regression.test.mjs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/regression/interactive-toast-and-terminal-handoff-regression.test.mjs diff --git a/tests/regression/interactive-toast-and-terminal-handoff-regression.test.mjs b/tests/regression/interactive-toast-and-terminal-handoff-regression.test.mjs new file mode 100644 index 0000000..466bd32 --- /dev/null +++ b/tests/regression/interactive-toast-and-terminal-handoff-regression.test.mjs @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const read = (relativePath) => + fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); + +test("toast overlay scopes notifications to the active session and exposes dismissal", () => { + const source = read("webview/shared/src/chat/ToastOverlay.tsx"); + + assert.match( + source, + /notification\.sessionId\s*&&\s*sessionId[\s\S]*notification\.sessionId\s*!==\s*sessionId/, + "toast rendering must reject notifications belonging to another session", + ); + assert.match( + source, + /aria-label=\"Dismiss notification\"[\s\S]*onClick=\{dismissActiveToast\}/, + "toast rendering must provide an explicit dismiss action", + ); + assert.match( + source, + /const dismissActiveToast = \(\) => \{[\s\S]*clearActiveTimer\(\)[\s\S]*showNextToast\(\)/, + "dismissing a toast must release its timer and advance the queue", + ); +}); + +test("interactive composer ignores events from another session", () => { + const source = read("webview/shared/src/chat/PanelComponents.tsx"); + + const sessionFilterCount = (source.match(/event\.sessionID === currentSessionId/g) ?? []).length; + assert.ok( + sessionFilterCount >= 3, + "top-level, streaming, and hydrated interactive events must share session filtering", + ); + assert.match( + source, + /dismissedInteractiveEventKeys, currentSessionId\]/, + "the composer must recompute when the active session changes", + ); +}); + +test("terminal transcript handoff does not use a stale deferred snapshot", () => { + const source = read("webview/shared/src/chat/ChatShell.tsx"); + + assert.match( + source, + /const transcriptSnapshotForRender = state\.streaming\?\.isActive\s*\n\s*\? deferredTranscriptSnapshot\s*\n\s*: transcriptSnapshot/, + "completed turns must switch to the current transcript snapshot immediately", + ); + assert.match( + source, + /renderMessages=\{transcriptSnapshotForRender\.renderMessages\}/, + "the transcript must consume the same atomic snapshot as its visible entries", + ); +}); From e35d3395c933e03d208ded6190c50033900bb627 Mon Sep 17 00:00:00 2001 From: Christian Rey Villablanca <43104891+chryzxc@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:36:49 +0800 Subject: [PATCH 5/6] fix: restore complete interactive composer implementation --- webview/shared/src/chat/PanelComponents.tsx | 1255 ++++++++++++++++++- 1 file changed, 1251 insertions(+), 4 deletions(-) diff --git a/webview/shared/src/chat/PanelComponents.tsx b/webview/shared/src/chat/PanelComponents.tsx index cba9c70..62667c1 100644 --- a/webview/shared/src/chat/PanelComponents.tsx +++ b/webview/shared/src/chat/PanelComponents.tsx @@ -1,6 +1,3 @@ -Warning: truncated output (original token count: 52259) -Total output lines: 5611 - import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { @@ -2179,7 +2176,1257 @@ export const InputWrapper = memo(function InputWrapper() { const [mentionTrigger, setMentionTrigger] = useState(null); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const commandsRequestedRef = useRef(false); - const suggestionsContainerRef = useRef{a.filename} + const suggestionsContainerRef = useRef(null); + const composerInteractiveEvents = useMemo(() => { + let events: InteractiveEvent[] = []; + + // 1. Top-level interactive events (out-of-band questions, etc.) + if (Array.isArray(interactiveEvents)) { + events = [ + ...events, + ...interactiveEvents.filter( + (event) => !event.sessionID || !currentSessionId || event.sessionID === currentSessionId, + ), + ]; + } + + // 2. Interactive events from currently streaming response + if (streaming?.isActive && Array.isArray(streaming.interactiveEvents)) { + events = [ + ...events, + ...streaming.interactiveEvents.filter( + (event) => !event.sessionID || !currentSessionId || event.sessionID === currentSessionId, + ), + ]; + } else { + // 3. If not streaming, fallback to interactive events from the latest assistant message, + // but ONLY if the assistant is the absolute last speaker in the conversation. + const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null; + if (lastMsg?.role === "assistant" && Array.isArray(lastMsg.interactiveEvents)) { + events = [ + ...events, + ...lastMsg.interactiveEvents.filter( + (event) => !event.sessionID || !currentSessionId || event.sessionID === currentSessionId, + ), + ]; + } + } + + return filterDismissedInteractiveEvents( + events, + dismissedInteractiveEventKeys, + ); + }, [messages, streaming, interactiveEvents, dismissedInteractiveEventKeys, currentSessionId]); + + const filteredCommands = useMemo(() => { + if (!slashTrigger) { + return [] as SlashCommand[]; + } + + const query = slashTrigger.query.trim().toLowerCase(); + + // Combine both commands and skills for slash suggestions + const commandsFromServer = availableCommands || []; + const skillsFromService = availableSkills || []; + const allSlashItems = [ + ...commandsFromServer, + // Convert skills to SlashCommand format + ...skillsFromService.map(skill => ({ + name: skill.name, + description: skill.description, + source: "skill" + })) + ]; + const dedupedSlashItems = Array.from( + new Map( + allSlashItems.map((item) => [ + `${item.source || "command"}:${item.name}`, + item, + ]), + ).values(), + ); + + if (!query) { + return dedupedSlashItems; + } + + const filtered = dedupedSlashItems.filter((command) => { + const name = command.name.toLowerCase(); + return name.includes(query); + }); + + return filtered; + }, [slashTrigger, availableCommands, availableSkills]); + + useEffect(() => { + if (slashTrigger && !commandsLoaded && !commandsRequestedRef.current) { + commandsRequestedRef.current = true; + vscode.postMessage({ type: "getCommands" }); + } + }, [slashTrigger, commandsLoaded]); + + useEffect(() => { + if (mentionTrigger) { + const timeoutId = window.setTimeout(() => { + vscode.postMessage({ type: "getMentions", query: mentionTrigger.query }); + }, 120); + return () => window.clearTimeout(timeoutId); + } + if (showFileSuggestions) { + dispatch({ type: "SET_SHOW_FILE_SUGGESTIONS", payload: false }); + } + if (showMentionSuggestions) { + dispatch({ type: "SET_SHOW_MENTION_SUGGESTIONS", payload: false }); + } + }, [mentionTrigger?.query]); + + useEffect(() => { + setSelectedCommandIndex(0); + }, [slashTrigger?.query]); + + useEffect(() => { + if (selectedCommandIndex >= filteredCommands.length) { + setSelectedCommandIndex(Math.max(0, filteredCommands.length - 1)); + } + }, [filteredCommands.length, selectedCommandIndex]); + + useEffect(() => { + if (suggestionsContainerRef.current) { + const activeEl = suggestionsContainerRef.current.querySelector(".active"); + if (activeEl) { + activeEl.scrollIntoView({ block: "nearest" }); + } + } + }, [ + selectedCommandIndex, + selectedSuggestionIndex, + slashTrigger, + showFileSuggestions, + ]); + + // Centralized Interactive Event Handler + // By design, ALL interactive choices explicitly sent through structured output + // as a popup above the chatbox. This provides a consistent, clear place for + // user required actions (questions, confirmations, quick actions). + // + // Even if the AI types the question in the chat bubble, we show the popup + // here to make the call-to-action obvious and clickable. + const displayInteractiveEvents = useMemo(() => { + const candidates = composerInteractiveEvents.filter(isQuickInputInteractiveEvent); + const seen = new Set(); + const visible = candidates.filter((event) => { + const eventAny = event as any; + const key = event.type === "quick_actions" && event.permissionID + ? `permission::${event.permissionID}` + : `${event.type}::${(eventAny.question || eventAny.title || eventAny.message || "").trim().toLowerCase().replace(/\s+/g, " ")}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return visible; + }, [composerInteractiveEvents]); + const interactiveEventCount = displayInteractiveEvents.length; + const interactiveEventResetKey = displayInteractiveEvents + .map((item) => { + const title = + item.type === "question" || item.type === "confirm" + ? item.question + : item.type === "quick_actions" + ? item.title + : item.type === "message" + ? item.message || item.title + : item.title; + return `${item.id}|${item.type}|${title ?? ""}`; + }) + .join("::"); + + // Reset index and custom mode when interactive events change + useEffect(() => { + if (interactiveEventCount < 0) return; + // If the first event is an open-ended question (allowCustomInput, no pre-set options), + // skip straight to the free-text input so the user doesn't see an empty button row. + const firstEvent = displayInteractiveEvents[0]; + const autoCustomMode = + firstEvent?.type === "question" && + Array.isArray(firstEvent.options) && + firstEvent.options.length === 0 && + firstEvent.allowCustomInput === true; + setCurrentInteractiveIndex(0); + setIsCustomMode(autoCustomMode); + setCustomValue(""); + setPendingAnswers({}); + }, [interactiveEventCount, interactiveEventResetKey]); + + useEffect(() => { + if (isCustomMode) { + customInputRef.current?.focus(); + } + }, [isCustomMode]); + + const activeInteractiveEvent = + displayInteractiveEvents[currentInteractiveIndex]; + const event = activeInteractiveEvent; + const isPermissionPopover = + event?.type === "quick_actions" && + (typeof event.permissionID === "string" || event.permissionPreview === true); + const showInteractivePopover = displayInteractiveEvents.length > 0; + const currentInteractiveAnswered = Boolean( + event?.id && + (Array.isArray(pendingAnswers[event.id]?.text) + ? pendingAnswers[event.id]?.text.length > 0 + : (pendingAnswers[event.id]?.text as string)?.trim()), + ); + const eventTitleText = event?.title?.trim() || ""; + const eventBodyText = + event?.type === "quick_actions" + ? eventTitleText || "Select an action" + : event?.type === "message" + ? event.message || "" + : event?.question || ""; + const eventContextMessage = event?.contextMessage?.trim() || ""; + const normalizePopoverText = (value: string): string => + value + .toLowerCase() + .replace(/[`"'()[\]{}<>]/g, " ") + .replace(/[^\p{L}\p{N}\s]/gu, " ") + .replace(/\s+/g, " ") + .trim(); + const normalizedBodyText = normalizePopoverText(eventBodyText); + const normalizedTitleText = normalizePopoverText(eventTitleText); + const normalizedContextText = normalizePopoverText(eventContextMessage); + const hasDistinctTitle = + !!eventTitleText && + normalizedTitleText !== normalizedBodyText; + const hasDistinctContextMessage = + !!eventContextMessage && + normalizedContextText !== normalizedBodyText && + normalizedContextText !== normalizedTitleText; + const showPromptInHeader = !!eventTitleText; + const showContextMessage = hasDistinctContextMessage; + const showPromptInBody = !!eventBodyText && normalizedBodyText !== normalizedTitleText; + + const capitalizeFirst = (str: string) => { + if (!str) return str; + return str.charAt(0).toUpperCase() + str.slice(1); + }; + + const renderInteractiveOptionLabel = (label: string, recommended?: boolean) => { + const displayLabel = capitalizeFirst(label); + return recommended ? `Recommended: ${displayLabel}` : displayLabel; + }; + + const applyCommandSuggestion = (command: SlashCommand) => { + if (!slashTrigger) return; + const normalizedName = command.name.replace(/^\//, ""); + if (!normalizedName) return; + + const before = inputValue.slice(0, slashTrigger.replaceFrom); + const after = inputValue.slice(slashTrigger.replaceTo); + const insertion = `/${normalizedName} `; + const nextValue = `${before}${insertion}${after}`; + const cursor = before.length + insertion.length; + + dispatch({ type: "SET_INPUT_VALUE", payload: nextValue }); + setSlashTrigger(null); + setSelectedCommandIndex(0); + + requestAnimationFrame(() => { + if (!textareaRef.current) return; + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(cursor, cursor); + }); + }; + + const applyMentionSuggestion = (suggestion: FileResult) => { + if (!mentionTrigger) return; + + // Convert suggestion to a ContextItem + const contextItem: ContextItem = { + file: suggestion.path, + lineInfo: "", + content: "", + }; + + const isAlreadySelected = selectedContexts.some( + (c) => c.file === contextItem.file && c.lineInfo === contextItem.lineInfo + ); + + if (!isAlreadySelected) { + dispatch({ + type: "SET_SELECTED_CONTEXTS", + payload: [...selectedContexts, contextItem], + }); + } + + // Remove the @mention from input (it will be shown as a chip instead) + const before = inputValue.slice(0, mentionTrigger.replaceFrom); + const after = inputValue.slice(mentionTrigger.replaceTo); + const nextValue = `${before}${after}`; + + dispatch({ type: "SET_INPUT_VALUE", payload: nextValue }); + setMentionTrigger(null); + dispatch({ type: "SET_SHOW_FILE_SUGGESTIONS", payload: false }); + dispatch({ type: "SET_SUGGESTION_INDEX", payload: 0 }); + + requestAnimationFrame(() => { + if (!textareaRef.current) return; + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(before.length, before.length); + }); + }; + + const applyMentionResult = (result: MentionResult) => { + if (!mentionTrigger) return; + + if (result.type === "agent") { + dispatch({ type: "SET_SELECTED_AGENT", payload: result.id }); + // For agents: keep @agentname in the input + const before = inputValue.slice(0, mentionTrigger.replaceFrom); + const after = inputValue.slice(mentionTrigger.replaceTo); + const displayName = result.name || result.id; + const mentionText = `@${displayName}`; + const nextValue = after.startsWith(' ') + ? `${before}${mentionText}${after}` + : `${before}${mentionText} ${after}`; + + dispatch({ type: "SET_INPUT_VALUE", payload: nextValue }); + setMentionTrigger(null); + dispatch({ type: "SET_SHOW_MENTION_SUGGESTIONS", payload: false }); + dispatch({ type: "SET_MENTION_INDEX", payload: 0 }); + + requestAnimationFrame(() => { + if (!textareaRef.current) return; + const cursorPos = before.length + mentionText.length + 1; + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(cursorPos, cursorPos); + }); + } else if (result.type === "file") { + // Don't add to selectedContexts - we'll parse @filename from input instead + // For files: keep @filename in the input + const before = inputValue.slice(0, mentionTrigger.replaceFrom); + const after = inputValue.slice(mentionTrigger.replaceTo); + const displayName = result.name || result.path; + const mentionText = `@${displayName}`; + const nextValue = after.startsWith(' ') + ? `${before}${mentionText}${after}` + : `${before}${mentionText} ${after}`; + + // Store the full path mapping for this filename + const updatedPaths = { ...fileMentionPaths }; + updatedPaths[displayName] = result.path; + dispatch({ type: "SET_FILE_MENTION_PATHS", payload: updatedPaths }); + + dispatch({ type: "SET_INPUT_VALUE", payload: nextValue }); + setMentionTrigger(null); + dispatch({ type: "SET_SHOW_MENTION_SUGGESTIONS", payload: false }); + dispatch({ type: "SET_MENTION_INDEX", payload: 0 }); + + requestAnimationFrame(() => { + if (!textareaRef.current) return; + const cursorPos = before.length + mentionText.length + 1; + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(cursorPos, cursorPos); + }); + } else if (result.type === "resource") { + const contextItem: ContextItem = { + file: `resource:${result.uri}`, + lineInfo: "", + content: "", + }; + const alreadySelected = selectedContexts.some( + (c) => c.file === contextItem.file + ); + if (!alreadySelected) { + dispatch({ + type: "SET_SELECTED_CONTEXTS", + payload: [...selectedContexts, contextItem], + }); + } + // For resources: remove @resource from input + const before = inputValue.slice(0, mentionTrigger.replaceFrom); + const after = inputValue.slice(mentionTrigger.replaceTo); + const nextValue = `${before}${after}`; + + dispatch({ type: "SET_INPUT_VALUE", payload: nextValue }); + setMentionTrigger(null); + dispatch({ type: "SET_SHOW_MENTION_SUGGESTIONS", payload: false }); + dispatch({ type: "SET_MENTION_INDEX", payload: 0 }); + + requestAnimationFrame(() => { + if (!textareaRef.current) return; + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(before.length, before.length); + }); + } + }; + + const sendPrompt = () => { + const text = inputValue.trim(); + if (!text) return; + + // Capture values before clearing state + const currentFiles = selectedFiles.length > 0 ? [...selectedFiles] : undefined; + const currentContexts = resolveMentionedFileContexts( + text, + selectedContexts, + fileMentionPaths, + ); + const currentAttachments = attachments || []; + const currentAgent = selectedAgent || null; + const sessionId = currentSessionId; + + // Clear UI state immediately for better UX + dispatch({ type: "SET_INPUT_VALUE", payload: "" }); + dispatch({ type: "CLEAR_ATTACHMENTS" }); + dispatch({ type: "SET_SELECTED_CONTEXTS", payload: [] }); + dispatch({ type: "SET_SELECTED_FILES", payload: [] }); + dispatch({ type: "SET_FILE_MENTION_PATHS", payload: {} }); // Clear file mention paths + setSlashTrigger(null); + + const clientRequestId = + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + + const hasPendingQuestion = + interactiveEvents.length > 0 && + interactiveEvents.some( + (e) => + e.type === "question" || + e.type === "confirm" || + e.type === "quick_input", + ); + // Every normal send belongs in the inline transcript immediately. OpenCode + // owns ordering while a turn is active; the webview never shows a separate + // extension-side pending queue for these messages. + const pendingSessionId = sessionId ?? PENDING_CURRENT_SESSION_KEY; + dispatch({ + type: "ADD_PENDING_USER_MESSAGE", + payload: { + id: clientRequestId, + clientRequestId, + sessionId: pendingSessionId, + createdAt: Date.now(), + text, + images: currentAttachments + .filter((attachment) => isImageAttachment(attachment.mimeType, attachment.dataUrl)) + .map((attachment) => attachment.dataUrl), + attachments: currentAttachments, + contexts: currentContexts, + interactiveSubmit: hasPendingQuestion, + }, + }); + + vscode.postMessage({ + type: "sendMessage", + clientRequestId, + ...(sessionId ? { sessionId } : {}), + text, + files: currentFiles, + contexts: currentContexts, + agent: currentAgent, + images: currentAttachments, + ...(hasPendingQuestion ? { interactiveSubmit: true } : {}), + }); + + dispatch({ type: "SET_PROCESSING", payload: true }); + logger.info("[LOADING][INPUT] User sent message, dispatching SET_PROCESSING(true)", { + sessionId: sessionId || null, + currentSessionId, + processingSessionIds, + textLength: text.length, + hasPendingQuestion, + timestamp: Date.now(), + }); + }; + + const steerPrompt = () => { + const text = inputValue.trim(); + if (!text || !hasLiveAssistantTurn || isSteering) return; + + dispatch({ type: "SET_STEERING", payload: true }); + vscode.postMessage({ + type: "steerMessage", + ...(currentSessionId ? { sessionId: currentSessionId } : {}), + text, + files: selectedFiles, + contexts: resolveMentionedFileContexts( + text, + selectedContexts, + fileMentionPaths, + ), + agent: selectedAgent || null, + images: attachments || [], + }); + dispatch({ type: "SET_INPUT_VALUE", payload: "" }); + dispatch({ type: "CLEAR_ATTACHMENTS" }); + dispatch({ type: "SET_FILE_MENTION_PATHS", payload: {} }); // Clear file mention paths + setSlashTrigger(null); + }; + + // When a text paste exceeds this many characters, convert it to a .txt + // attachment instead of inserting into the textarea. Keeps the message bubble + // readable. Tune this constant to adjust the cutoff. + const PASTE_TEXT_ATTACHMENT_THRESHOLD = 2000; + + const handlePaste = (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + + // Large text paste → auto-convert to a .txt attachment. Prioritize text + // content over image items: rich-text clipboard copies from IDEs/browsers + // often include an image/png fallback alongside text/plain, and the user + // intent is to paste the text, not the fallback image. + const pastedText = e.clipboardData.getData("text/plain") ?? ""; + if (pastedText.length >= PASTE_TEXT_ATTACHMENT_THRESHOLD) { + e.preventDefault(); + const dataUrl = `data:text/plain;charset=utf-8,${encodeURIComponent(pastedText)}`; + const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const filename = `pasted-snippet-${stamp}.txt`; + dispatch({ + type: "ADD_ATTACHMENT", + payload: { + id: crypto.randomUUID(), + dataUrl, + filename, + mimeType: "text/plain", + }, + }); + return; + } + + let pastedImage = false; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item.type.startsWith("image/")) continue; + const blob = item.getAsFile(); + if (!blob) continue; + pastedImage = true; + const reader = new FileReader(); + reader.onload = () => { + try { + const dataUrl = reader.result; + if (typeof dataUrl !== "string") return; + const ext = blob.type.split("/")[1] ?? "png"; + const filename = + blob.name && blob.name.length > 0 + ? blob.name + : `pasted-${Date.now()}.${ext}`; + dispatch({ + type: "ADD_ATTACHMENT", + payload: { + id: crypto.randomUUID(), + dataUrl, + filename, + mimeType: blob.type, + }, + }); + } catch (err) { + /* ignore */ + } + }; + reader.readAsDataURL(blob); + } + + if (pastedImage) { + e.preventDefault(); + } + }; + + const initStatesForEvent = (eventIndex: number, currentAnswers: Record) => { + const nextEvent = displayInteractiveEvents[eventIndex]; + if (nextEvent && nextEvent.type === "question") { + const ans = currentAnswers[nextEvent.id]; + if (ans) { + if (nextEvent.multiSelect) { + setMultiSelectValues(new Set(Array.isArray(ans.text) ? ans.text : [ans.text])); + setIsCustomMode(false); + setCustomValue(""); + } else { + setMultiSelectValues(new Set()); + const textAns = Array.isArray(ans.text) ? ans.text[0] : ans.text; + const isOption = nextEvent.options.some(o => (o.value || o.label) === textAns); + if (!isOption && nextEvent.allowCustomInput) { + setIsCustomMode(true); + setCustomValue(textAns); + } else { + setIsCustomMode(false); + setCustomValue(""); + } + } + } else { + setMultiSelectValues(new Set()); + setIsCustomMode(false); + setCustomValue(""); + } + } else { + setMultiSelectValues(new Set()); + setIsCustomMode(false); + setCustomValue(""); + } + }; + + const submitInteractiveResponse = ( + text: string | string[], + eventId: string, + eventType: string, + ) => { + const nextAnswers = { + ...pendingAnswers, + [eventId]: { text, eventType }, + }; + setPendingAnswers(nextAnswers); + + // If there are more questions, go to the next one + if (currentInteractiveIndex < displayInteractiveEvents.length - 1) { + setCurrentInteractiveIndex((prev) => prev + 1); + initStatesForEvent(currentInteractiveIndex + 1, nextAnswers); + } else { + // All questions are answered, submit batch + submitBatchResponses(nextAnswers); + } + }; + + const submitBatchResponses = ( + answers: Record, + ) => { + const batch = Object.entries(answers).map(([eventId, data]) => { + const event = displayInteractiveEvents.find((e) => e.id === eventId); + const questionText = + event?.type === "question" || event?.type === "confirm" + ? event.question + : event?.type === "quick_actions" + ? event.title || "Select an action" + : event?.type === "message" + ? event.message || event.title || "Acknowledge" + : event?.title || ""; + return { + eventId, + eventType: data.eventType, + text: data.text, + questionText, + requestID: + event?.type === "question" ? event.requestID : undefined, + questionIndex: + event?.type === "question" ? event.questionIndex : undefined, + permissionID: + event?.type === "quick_actions" ? event.permissionID : undefined, + sessionID: + event?.type === "quick_actions" ? event.sessionID : undefined, + }; + }).sort((a, b) => { + const idxA = typeof a.questionIndex === "number" ? a.questionIndex : 0; + const idxB = typeof b.questionIndex === "number" ? b.questionIndex : 0; + return idxA - idxB; + }); + + const hasMultipleInteractivePrompts = batch.length > 1; + + // Single popover answers read best as plain user replies. Numbered question + // context is only needed when a batched prompt carries multiple questions. + const composedPrompt = batch + .map((resp, index) => { + const answer = Array.isArray(resp.text) ? resp.text.join(", ") : (resp.text || "").trim(); + const question = (resp.questionText || "").trim(); + if (!answer) { + return ""; + } + if (!hasMultipleInteractivePrompts) { + return answer; + } + if (!question) { + return `Answer ${index + 1}: ${answer}`; + } + return `Question ${index + 1}: ${question}\nAnswer: ${answer}`; + }) + .filter((line) => line.length > 0) + .join("\n\n"); + + // Keep user bubble text aligned with the exact prompt sent upstream. + const displayText = composedPrompt; + + // IMPORTANT: do not append optimistic assistant or user messages here. + // The host/message handler owns the canonical turn transition. Clearing or + // replacing local assistant state from this component can hide the already + // rendered assistant activity/subagent UI until the next stream update lands. + + // Dismiss all events that were part of this batch immediately to prevent stale popover UI. + // Be defensive: some legacy/hydrated events may have missing/unstable IDs. + batch.forEach(({ eventId }) => { + dispatch({ + type: "DISMISS_INTERACTIVE_EVENT", + payload: eventId, + }); + }); + + dispatch({ + type: "SET_INTERACTIVE_EVENTS", + payload: [], + }); + + // Don't show processing state immediately - let extension confirm when actually processing + // This prevents UI from showing "stuck" loading state when request is delayed + // dispatch({ type: "SET_PROCESSING", payload: true }); + + const permissionReplies = batch.filter( + (response) => + response.permissionID && + (response.text === "once" || response.text === "always" || response.text === "reject"), + ); + if (permissionReplies.length > 0) { + permissionReplies.forEach((response) => { + vscode.postMessage({ + type: "permissionReply", + sessionId: response.sessionID || currentSessionId, + permissionID: response.permissionID, + response: response.text, + }); + }); + return; + } + + // Route question answers through questionReply and non-question events + // (confirm, quick_actions, message) through the normal sendMessage path. + const canReplyToSdkQuestion = batch.some((resp) => resp.eventType === "question" || resp.eventType === "confirm"); + + console.error("[DEBUG-UI] canReplyToSdkQuestion evaluated:", { + canReplyToSdkQuestion, + eventTypes: batch.map(b => b.eventType), + batchRequestIDs: batch.map(b => b.requestID) + }); + + if (canReplyToSdkQuestion) { + console.error("[DEBUG-UI] Dispatching questionReply message to host"); + dispatch({ type: "SET_PROCESSING", payload: false }); + dispatch({ type: "SET_STEERING", payload: false }); + dispatch({ type: "SET_STREAMING", payload: null }); + const answers = batch.map((resp) => + Array.isArray(resp.text) ? resp.text : [resp.text], + ); + const requestID = batch.find((resp) => resp.requestID)?.requestID; + logger.info("[QUESTION DEBUG] submitting SDK question reply", { + requestID, + answerCount: answers.length, + answers, + sessionId: currentSessionId ?? null, + }); + vscode.postMessage({ + type: "questionReply", + ...(currentSessionId ? { sessionId: currentSessionId } : {}), + requestID, + answers, + text: displayText, + }); + } else { + console.error("[DEBUG-UI] Dispatching normal sendMessage message to host"); + const clientRequestId = + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + vscode.postMessage({ + type: "sendMessage", + clientRequestId, + ...(currentSessionId ? { sessionId: currentSessionId } : {}), + text: displayText, + agent: selectedAgent || null, + interactiveSubmit: true, + }); + } + + // Reset state immediately after sending + setPendingAnswers({}); + setCurrentInteractiveIndex(0); + setIsCustomMode(false); + setCustomValue(""); + }; + + const stopRequest = useCallback( + () => + vscode.postMessage({ + type: "stopRequest", + ...(currentSessionId ? { sessionId: currentSessionId } : {}), + }), + [currentSessionId], + ); + + const escapePressTimeoutRef = useRef | null>(null); + const lastEscapePressAtRef = useRef(null); + + useEffect(() => { + const handleEscapeShortcut = (event: KeyboardEvent) => { + if (event.key !== "Escape" || !hasLiveAssistantTurn || isSteering) { + return; + } + + const now = Date.now(); + const lastPressAt = lastEscapePressAtRef.current; + const isDoublePress = lastPressAt !== null && now - lastPressAt <= 500; + + if (isDoublePress) { + event.preventDefault(); + lastEscapePressAtRef.current = null; + setIsEscapeArmed(false); + if (escapePressTimeoutRef.current) { + clearTimeout(escapePressTimeoutRef.current); + escapePressTimeoutRef.current = null; + } + stopRequest(); + return; + } + + lastEscapePressAtRef.current = now; + setIsEscapeArmed(true); + if (escapePressTimeoutRef.current) { + clearTimeout(escapePressTimeoutRef.current); + } + escapePressTimeoutRef.current = setTimeout(() => { + lastEscapePressAtRef.current = null; + escapePressTimeoutRef.current = null; + setIsEscapeArmed(false); + }, 500); + }; + + window.addEventListener("keydown", handleEscapeShortcut); + return () => { + window.removeEventListener("keydown", handleEscapeShortcut); + if (escapePressTimeoutRef.current) { + clearTimeout(escapePressTimeoutRef.current); + escapePressTimeoutRef.current = null; + } + lastEscapePressAtRef.current = null; + setIsEscapeArmed(false); + }; + }, [hasLiveAssistantTurn, isSteering, stopRequest]); + + const abortActiveResponse = () => + vscode.postMessage({ + type: "abortResponse", + ...(currentSessionId ? { sessionId: currentSessionId } : {}), + }); + + const dismissInteractivePopover = (interactiveEvent: InteractiveEvent) => { + const shouldAbortActiveResponse = interactiveEvent.type === "question"; + + if (shouldAbortActiveResponse) { + abortActiveResponse(); + } + + dispatch({ + type: "DISMISS_INTERACTIVE_EVENT", + payload: interactiveEvent.id, + }); + }; + + const isImageAttachment = (mimeType?: string, dataUrl?: string) => { + if (typeof mimeType === "string" && mimeType.startsWith("image/")) { + return true; + } + if (typeof dataUrl === "string" && dataUrl.startsWith("data:image/")) { + return true; + } + return false; + }; + + return ( + <> +
+ {event && isPermissionPopover ? ( + submitInteractiveResponse(response, event.id, event.type)} + onAbort={() => { + dismissInteractivePopover(event); + abortActiveResponse(); + }} + /> + ) : null} + + {event && !isPermissionPopover && ( +
+
+
+
+
+ {showPromptInHeader ? ( +
+ {eventTitleText} +
+ ) : null} + {displayInteractiveEvents.length > 1 && ( +
+ + + {currentInteractiveIndex + 1} /{" "} + {displayInteractiveEvents.length} + + + {Object.keys(pendingAnswers).length > 0 && ( + + {Object.keys(pendingAnswers).length} answered + + )} +
+ )} +
+
+
+ +
+
+
+ +
+ {(showContextMessage || showPromptInBody) && ( +
+ {showContextMessage ? ( +
+ +
+ ) : null} + {showPromptInBody && !hasDistinctContextMessage ? ( + + ) : null} +
+ )} + + {isCustomMode ? ( +
+ { + if (e.key === "Enter") { + submitInteractiveResponse( + customValue, + event.id, + event.type, + ); + } else if (e.key === "Escape") { + setIsCustomMode(false); + setCustomValue(""); + } + }} + onChange={(e) => setCustomValue(e.target.value)} + /> +
+ + +
+
+ ) : ( + <> + {event.type === "question" ? ( +
+
+ {event.options.map((option, index) => { + const val = option.value || option.label; + let isSelected = false; + const ans = pendingAnswers[event.id]; + if (event.multiSelect) { + isSelected = multiSelectValues.has(val); + } else if (ans) { + isSelected = ans.text === val || (Array.isArray(ans.text) && ans.text.includes(val)); + } + return ( + + ); + })} + {event.allowCustomInput ? ( + + ) : null} +
+ {event.multiSelect ? ( +
+ +
+ ) : null} +
+ ) : null} + + {event.type === "confirm" ? ( +
+ + +
+ ) : null} + + {event.type === "quick_actions" ? ( +
+ {event.actions.map((action, index) => ( + + ))} +
+ ) : null} + + {event.type === "message" ? ( +
+ +
+ ) : null} + + )} + + {!isPermissionPopover && ( +
+
+
+
+ Current model +
+
+ Pick the model for this answer before you submit. +
+
+
+ +
+
+
+ )} +
+
+ )} + + {!showInteractivePopover && ( + <> + {/* Context chips */} + {(selectedFiles.length > 0 || selectedContexts.length > 0) && ( +
+ {selectedFiles.map((file) => ( +
+ + {file} +
+ ))} + {selectedContexts.map((context) => { + const isResource = context.file.startsWith("resource:"); + const { displayName, lineSuffix } = contextChipDisplayParts(context); + return ( +
+ {isResource ? ( + + ) : ( + + )} + + {displayName} + {lineSuffix ? {lineSuffix} : null} + + {context.languageId && !isResource && ( + + {context.languageId} + + )} + +
+ ); + })} +
+ )} + + {/* Attachment chips */} + {attachments && attachments.length > 0 && ( +
+ {attachments.map((a) => ( +
+ {isImageAttachment(a.mimeType, a.dataUrl) ? ( + ) : ( From 2fb97878921821d21e1c90377355df62334836b2 Mon Sep 17 00:00:00 2001 From: Christian Rey Villablanca <43104891+chryzxc@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:36:54 +0800 Subject: [PATCH 6/6] fix: restore complete chat shell implementation