From d146ddc02b021c5f40bc9a62fae5d77fc7dbbc92 Mon Sep 17 00:00:00 2001 From: Kang Date: Wed, 29 Apr 2026 01:56:34 +0800 Subject: [PATCH] feat: Enhance AIAssistantPanel with reasoning content and markdown support This commit introduces the ability to display reasoning content in the AIAssistantPanel, allowing users to view AI-generated explanations alongside responses. It also integrates markdown rendering using react-markdown, enabling formatted text output for improved readability. Additionally, new UI components for collapsible reasoning sections and animated status indicators are added to enhance user interaction. --- src-tauri/src/core/ai.rs | 217 +++++++- src/components/panel/AIAssistantPanel.tsx | 615 +++++++++++++++++++--- src/types/global.d.ts | 4 +- 3 files changed, 755 insertions(+), 81 deletions(-) diff --git a/src-tauri/src/core/ai.rs b/src-tauri/src/core/ai.rs index 2b3597c5e..57fe96de1 100644 --- a/src-tauri/src/core/ai.rs +++ b/src-tauri/src/core/ai.rs @@ -85,6 +85,8 @@ pub struct AiRequestOptions { pub language: String, #[serde(default = "default_safety_mode")] pub safety_mode: String, + #[serde(default = "default_history_turns")] + pub history_turns: u16, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -120,6 +122,8 @@ pub struct AiStreamEventPayload { #[serde(default)] pub text_delta: Option, #[serde(default)] + pub reasoning_delta: Option, + #[serde(default)] pub message: Option, #[serde(default)] pub command_cards: Vec, @@ -176,6 +180,8 @@ pub struct AiMessage { pub content: String, pub created_at: String, #[serde(default)] + pub reasoning_content: Option, + #[serde(default)] pub command_cards: Vec, } @@ -241,6 +247,8 @@ struct AiModelOutput { #[serde(default)] text: String, #[serde(default)] + reasoning: Option, + #[serde(default)] command_cards: Vec, } @@ -271,6 +279,10 @@ fn default_safety_mode() -> String { "strict".to_string() } +fn default_history_turns() -> u16 { + 20 +} + fn now_rfc3339() -> String { time::OffsetDateTime::now_utc() .format(&time::format_description::well_known::Rfc3339) @@ -338,6 +350,7 @@ async fn run_chat_stream( stream_id: stream_id.clone(), session_id: Some(session_id.clone()), text_delta: None, + reasoning_delta: None, message: None, command_cards: vec![], usage: None, @@ -366,8 +379,9 @@ async fn run_chat_stream( active_streams().lock().unwrap().remove(&stream_id); match result { - Ok(raw_text) => { - let (text, mut command_cards) = parse_model_output(&raw_text); + Ok(stream_result) => { + let (text, reasoning_content, mut command_cards) = + parse_model_output(&stream_result.text, stream_result.reasoning_content); for card in &mut command_cards { let risk = check_command_risk(CommandRiskRequest { command: card.command.clone(), @@ -383,6 +397,7 @@ async fn run_chat_stream( role: AiMessageRole::Assistant, content: text, created_at: now_rfc3339(), + reasoning_content, command_cards: command_cards.clone(), }; @@ -398,6 +413,7 @@ async fn run_chat_stream( stream_id: stream_id.clone(), session_id: Some(session_id), text_delta: None, + reasoning_delta: None, message: Some(message), command_cards, usage: None, @@ -414,6 +430,7 @@ async fn run_chat_stream( stream_id: stream_id.clone(), session_id: Some(session_id), text_delta: None, + reasoning_delta: None, message: None, command_cards: vec![], usage: None, @@ -424,22 +441,60 @@ async fn run_chat_stream( } } +#[derive(Debug, Clone)] +struct AiStreamResult { + text: String, + reasoning_content: Option, +} + async fn run_model_stream( app: &AppHandle, stream_id: &str, request: &AiChatRequest, settings: &AiSettings, cancel_rx: &mut oneshot::Receiver<()>, -) -> AppResult { +) -> AppResult { let profile = active_profile(settings)?; let client = build_client(profile)?; let prompt = build_prompt(request, settings); - let chat_req = ChatRequest::new(vec![ - ChatMessage::system(SYSTEM_PROMPT), - ChatMessage::user(prompt), - ]); - let chat_options = ChatOptions::default().with_max_tokens(settings.max_output_tokens); + let mut messages = vec![ChatMessage::system(SYSTEM_PROMPT)]; + + if let Some(session_id) = &request.session_id { + let max_turns = request.options.history_turns as usize; + if max_turns > 0 { + if let Ok(history) = load_history(app) { + let history_msgs: Vec<&AiMessage> = history + .messages + .iter() + .filter(|m| m.session_id == *session_id) + .collect(); + let skip = history_msgs.len().saturating_sub(max_turns); + for msg in history_msgs.into_iter().skip(skip) { + match msg.role { + AiMessageRole::User => { + messages.push(ChatMessage::user(&msg.content)); + } + AiMessageRole::Assistant => { + let content = extract_text_from_assistant(&msg.content); + if !content.is_empty() { + messages.push(ChatMessage::assistant(&content)); + } + } + AiMessageRole::System => {} + } + } + } + } + } + + messages.push(ChatMessage::user(prompt)); + + let chat_req = ChatRequest::new(messages); + let chat_options = ChatOptions::default() + .with_max_tokens(settings.max_output_tokens) + .with_capture_reasoning_content(true) + .with_normalize_reasoning_content(true); let stream_result = tokio::time::timeout( Duration::from_millis(settings.timeout_ms), @@ -451,6 +506,7 @@ async fn run_model_stream( let mut stream = stream_result.stream; let mut output = String::new(); + let mut reasoning_output = String::new(); let timeout = tokio::time::sleep(Duration::from_millis(settings.timeout_ms)); tokio::pin!(timeout); @@ -473,6 +529,7 @@ async fn run_model_stream( stream_id: stream_id.to_string(), session_id: request.session_id.clone(), text_delta: Some(text_delta), + reasoning_delta: None, message: None, command_cards: vec![], usage: None, @@ -480,7 +537,32 @@ async fn run_model_stream( }); } } - Some(Ok(ChatStreamEvent::End(_))) | None => break, + Some(Ok(ChatStreamEvent::ReasoningChunk(chunk))) => { + let reasoning_delta = chunk.content; + if !reasoning_delta.is_empty() { + reasoning_output.push_str(&reasoning_delta); + emit_stream_event(app, stream_id, AiStreamEventPayload { + event_type: "reasoning_delta".to_string(), + stream_id: stream_id.to_string(), + session_id: request.session_id.clone(), + text_delta: None, + reasoning_delta: Some(reasoning_delta), + message: None, + command_cards: vec![], + usage: None, + error: None, + }); + } + } + Some(Ok(ChatStreamEvent::End(end))) => { + if reasoning_output.is_empty() { + if let Some(captured_reasoning_content) = end.captured_reasoning_content { + reasoning_output = captured_reasoning_content; + } + } + break; + } + None => break, Some(Ok(_)) => {} Some(Err(error)) => { return Err(AppError::Config(format!("AI stream failed: {error}"))); @@ -490,7 +572,10 @@ async fn run_model_stream( } } - Ok(output) + Ok(AiStreamResult { + text: output, + reasoning_content: trim_string_to_option(reasoning_output), + }) } fn active_profile(settings: &AiSettings) -> AppResult<&AiProviderProfile> { @@ -627,7 +712,22 @@ fn build_prompt(request: &AiChatRequest, settings: &AiSettings) -> String { ) } -fn parse_model_output(raw_text: &str) -> (String, Vec) { +fn extract_text_from_assistant(content: &str) -> String { + let trimmed = content.trim(); + if let Some(json_str) = extract_json_object(trimmed) { + if let Ok(output) = serde_json::from_str::(&json_str) { + if !output.text.trim().is_empty() { + return output.text; + } + } + } + trimmed.to_string() +} + +fn parse_model_output( + raw_text: &str, + stream_reasoning: Option, +) -> (String, Option, Vec) { let candidate = extract_json_object(raw_text).unwrap_or_else(|| raw_text.trim().to_string()); match serde_json::from_str::(&candidate) { Ok(output) => { @@ -636,9 +736,24 @@ fn parse_model_output(raw_text: &str) -> (String, Vec) { } else { output.text }; - (text, output.command_cards) + let reasoning_content = trim_optional_to_option(output.reasoning) + .or_else(|| trim_optional_to_option(stream_reasoning)); + let (text, extracted_reasoning) = extract_think_block(&text); + ( + text, + extracted_reasoning.or(reasoning_content), + output.command_cards, + ) + } + Err(_) => { + let normalized_reasoning = trim_optional_to_option(stream_reasoning); + let (text, extracted_reasoning) = extract_think_block(raw_text); + ( + text, + extracted_reasoning.or(normalized_reasoning), + vec![], + ) } - Err(_) => (raw_text.trim().to_string(), vec![]), } } @@ -660,6 +775,40 @@ fn extract_json_object(raw_text: &str) -> Option { Some(trimmed[start..=end].to_string()) } +fn extract_think_block(raw_text: &str) -> (String, Option) { + static THINK_REGEX: OnceLock = OnceLock::new(); + let regex = THINK_REGEX.get_or_init(|| Regex::new(r"(?is)(.*?)").unwrap()); + + let mut reasoning_parts = Vec::new(); + for captures in regex.captures_iter(raw_text) { + if let Some(value) = captures.get(1) { + let reasoning = value.as_str().trim(); + if !reasoning.is_empty() { + reasoning_parts.push(reasoning.to_string()); + } + } + } + + let visible_text = regex.replace_all(raw_text, "").to_string(); + ( + visible_text.trim().to_string(), + trim_string_to_option(reasoning_parts.join("\n\n")), + ) +} + +fn trim_string_to_option(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn trim_optional_to_option(value: Option) -> Option { + value.and_then(trim_string_to_option) +} + fn redact_context(context: &mut AiContext) { context.recent_output = redact_sensitive_text(&context.recent_output); context.selected_text = redact_sensitive_text(&context.selected_text); @@ -998,6 +1147,7 @@ fn save_user_message(app: &AppHandle, session_id: &str, request: &AiChatRequest) role: AiMessageRole::User, content: request.user_input.clone(), created_at: now, + reasoning_content: None, command_cards: vec![], }); save_history(app, &history) @@ -1138,15 +1288,52 @@ mod tests { #[test] fn parses_json_command_cards() { let raw = r#"{"text":"ok","commandCards":[{"id":"1","title":"CPU","command":"ps aux","explanation":"x","riskLevel":"low","riskReason":"read only","expectedEffect":"list","rollback":"none"}]}"#; - let (text, cards) = parse_model_output(raw); + let (text, reasoning, cards) = parse_model_output(raw, None); assert_eq!(text, "ok"); + assert_eq!(reasoning, None); assert_eq!(cards.len(), 1); } #[test] fn parse_failure_returns_text_without_cards() { - let (text, cards) = parse_model_output("plain text"); + let (text, reasoning, cards) = parse_model_output("plain text", None); assert_eq!(text, "plain text"); + assert_eq!(reasoning, None); assert!(cards.is_empty()); } + + #[test] + fn extracts_think_block_into_reasoning() { + let (text, reasoning, cards) = + parse_model_output("step 1\nstep 2final answer", None); + assert_eq!(text, "final answer"); + assert_eq!(reasoning.as_deref(), Some("step 1\nstep 2")); + assert!(cards.is_empty()); + } + + #[test] + fn keeps_markdown_text_when_json_parse_fails() { + let markdown = "## Summary\n\n- item 1\n- item 2"; + let (text, reasoning, cards) = parse_model_output(markdown, None); + assert_eq!(text, markdown); + assert_eq!(reasoning, None); + assert!(cards.is_empty()); + } + + #[test] + fn prefers_json_reasoning_when_present() { + let raw = r#"{"text":"answer","reasoning":"first\nsecond","commandCards":[]}"#; + let (text, reasoning, cards) = parse_model_output(raw, None); + assert_eq!(text, "answer"); + assert_eq!(reasoning.as_deref(), Some("first\nsecond")); + assert!(cards.is_empty()); + } + + #[test] + fn old_history_without_reasoning_defaults_cleanly() { + let raw = r#"{"sessions":[],"messages":[{"id":"m1","sessionId":"s1","role":"assistant","content":"hello","createdAt":"2026-04-28T00:00:00Z","commandCards":[]}]}"#; + let history: AiHistoryFile = serde_json::from_str(raw).unwrap(); + assert_eq!(history.messages.len(), 1); + assert_eq!(history.messages[0].reasoning_content, None); + } } diff --git a/src/components/panel/AIAssistantPanel.tsx b/src/components/panel/AIAssistantPanel.tsx index 00f6bb108..f9ad14acd 100644 --- a/src/components/panel/AIAssistantPanel.tsx +++ b/src/components/panel/AIAssistantPanel.tsx @@ -1,9 +1,12 @@ import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdAutoAwesome, + MdClose, MdContentCopy, + MdExpandLess, + MdExpandMore, MdHistory, MdInput, MdOutlineSettings, @@ -12,9 +15,13 @@ import { MdSend, MdStop, } from "react-icons/md"; +import { LuMessageSquarePlus } from "react-icons/lu"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { toast } from "sonner"; import PanelHeader from "@/components/layout/PanelHeader"; import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { useApp } from "@/context/AppContext"; @@ -24,9 +31,11 @@ import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; import { buildAIContext, getTerminalContextProvider } from "@/lib/terminalContext"; import { openSettings } from "@/lib/windowManager"; +import { collectSessionPanes } from "@/lib/workspaceTabs"; import type { AIAction, AICommandCard, + AIContext, AIMessage, AISession, AIStreamEventPayload, @@ -74,6 +83,7 @@ function createLocalMessage(role: "user" | "assistant", content: string, session role, content, createdAt: new Date().toISOString(), + reasoningContent: null, commandCards: [], } satisfies AIMessage; } @@ -102,6 +112,155 @@ function mapRiskColor(riskLevel: RiskLevel) { } } +type MarkdownNodeProps = { + children?: ReactNode; + href?: string; +}; + +function looksLikeStructuredJsonOutput(content: string) { + const trimmed = content.trimStart(); + return ( + trimmed.startsWith("{") || + trimmed.startsWith("```json") || + trimmed.startsWith("```") || + (trimmed.includes('"text"') && trimmed.includes('"commandCards"')) + ); +} + +function AnimatedStatusText({ label }: { label: string }) { + return {label}; +} + +function MarkdownContent({ content }: { content: string }) { + return ( +
+ ( +

{children}

+ ), + ul: ({ children }: MarkdownNodeProps) => ( +
    {children}
+ ), + ol: ({ children }: MarkdownNodeProps) => ( +
    {children}
+ ), + li: ({ children }: MarkdownNodeProps) =>
  • {children}
  • , + a: ({ children, href }: MarkdownNodeProps) => ( + + {children} + + ), + blockquote: ({ children }: MarkdownNodeProps) => ( +
    + {children} +
    + ), + pre: ({ children }: MarkdownNodeProps) => ( +
    +              {children}
    +            
    + ), + code: ({ children }: MarkdownNodeProps) => ( + + {children} + + ), + table: ({ children }: MarkdownNodeProps) => ( +
    + {children}
    +
    + ), + th: ({ children }: MarkdownNodeProps) => ( + {children} + ), + td: ({ children }: MarkdownNodeProps) => ( + {children} + ), + }} + > + {content} +
    +
    + ); +} + +function AssistantReasoning({ message, loading }: { message: AIMessage; loading: boolean }) { + const { t } = useTranslation(); + const reasoningContent = message.reasoningContent?.trim(); + const [open, setOpen] = useState(false); + + if (!reasoningContent) { + return loading ? ( +
    +
    + +
    +
    +
    + ) : null; + } + + return ( + + + + + +
    + +
    +
    +
    + ); +} + +function AssistantResponse({ message, loading }: { message: AIMessage; loading: boolean }) { + const { t } = useTranslation(); + + if (loading && looksLikeStructuredJsonOutput(message.content)) { + return ( +
    +
    + +
    +
    +
    + ); + } + + return ; +} + function AICommandCardView({ card, onInsert, @@ -159,7 +318,7 @@ function AICommandCardView({ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantPanelProps) { const { t } = useTranslation(); - const { appSettings } = useApp(); + const { appSettings, tabs, savedConnections } = useApp(); const aiSettings = appSettings.ai; const [input, setInput] = useState(""); const [messages, setMessages] = useState([]); @@ -169,11 +328,21 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP const [loading, setLoading] = useState(false); const [currentSessionId, setCurrentSessionId] = useState(null); const [streamId, setStreamId] = useState(null); + const [streamingAssistantId, setStreamingAssistantId] = useState(null); const [detectedError, setDetectedError] = useState(null); + const [targetPanes, setTargetPanes] = useState([]); + const [showMentionPopover, setShowMentionPopover] = useState(false); + const [mentionQuery, setMentionQuery] = useState(""); + const [mentionIndex, setMentionIndex] = useState(0); const handledIntentIdRef = useRef(null); const historyButtonRef = useRef(null); const historyCardRef = useRef(null); + const mentionPopoverRef = useRef(null); + const textareaRef = useRef(null); const streamUnlistenRef = useRef(null); + const scrollContainerRef = useRef(null); + const shouldAutoScrollRef = useRef(true); + const cancelledRef = useRef(false); const activeProfile = useMemo( () => @@ -182,6 +351,36 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP [aiSettings.active_profile_id, aiSettings.provider_profiles], ); + const allSessionPanes = useMemo(() => { + const panes: SessionPane[] = []; + for (const tab of tabs) { + for (const pane of collectSessionPanes(tab.root)) { + if (!pane.connecting && !pane.connectError) { + panes.push(pane); + } + } + } + return panes; + }, [tabs]); + + const filteredMentionPanes = useMemo(() => { + const q = mentionQuery.toLowerCase(); + if (!q) return allSessionPanes; + return allSessionPanes.filter( + (p) => + p.name.toLowerCase().includes(q) || + p.sessionId.toLowerCase().includes(q) || + p.type.toLowerCase().includes(q), + ); + }, [allSessionPanes, mentionQuery]); + + useEffect(() => { + setMentionIndex(0); + }, [filteredMentionPanes]); + + const effectivePanes = targetPanes.length > 0 ? targetPanes : activePane ? [activePane] : []; + const effectiveSessionId = effectivePanes[0]?.sessionId ?? null; + const activeSessionId = activePane?.sessionId ?? null; const filteredSessions = useMemo(() => { const keyword = historyQuery.trim().toLowerCase(); @@ -201,14 +400,30 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP }, []); useEffect(() => { + const watchedIds = new Set(effectivePanes.map((p) => p.sessionId)); + if (activeSessionId) watchedIds.add(activeSessionId); const handler = (event: Event) => { const detail = (event as CustomEvent).detail; - if (!detail || detail.sessionId !== activeSessionId) return; + if (!detail || !watchedIds.has(detail.sessionId)) return; setDetectedError(detail); }; window.addEventListener(AI_ERROR_DETECTED_EVENT, handler); return () => window.removeEventListener(AI_ERROR_DETECTED_EVENT, handler); - }, [activeSessionId]); + }, [activeSessionId, effectivePanes]); + + const handleMessagesScroll = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) return; + shouldAutoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 60; + }, []); + + useEffect(() => { + if (!shouldAutoScrollRef.current) return; + requestAnimationFrame(() => { + const el = scrollContainerRef.current; + if (el) el.scrollTop = el.scrollHeight; + }); + }, [messages]); const loadSessions = useCallback(async () => { try { @@ -258,9 +473,71 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP [activeConnection?.id], ); + const buildMergedContext = useCallback( + async (panes: SessionPane[], selectedText?: string): Promise => { + if (panes.length === 0) { + return buildAIContext({ + pane: null, + connection: null, + lineLimit: aiSettings.context_line_limit, + selectedText, + }); + } + if (panes.length === 1) { + const conn = panes[0].connectionId + ? (savedConnections.find((c) => c.id === panes[0].connectionId) ?? null) + : activeConnection; + return buildAIContext({ + pane: panes[0], + connection: conn, + lineLimit: aiSettings.context_line_limit, + selectedText, + }); + } + const contexts = await Promise.all( + panes.map((p) => { + const conn = p.connectionId + ? (savedConnections.find((c) => c.id === p.connectionId) ?? null) + : null; + return buildAIContext({ + pane: p, + connection: conn, + lineLimit: Math.floor(aiSettings.context_line_limit / panes.length), + }); + }), + ); + const merged: AIContext = { + connectionName: contexts.map((c) => c.connectionName ?? "-").join(", "), + host: contexts.map((c) => c.host ?? "-").join(", "), + port: contexts[0]?.port ?? null, + username: contexts.map((c) => c.username ?? "-").join(", "), + cwd: contexts.map((c) => c.cwd ?? "-").join(", "), + os: contexts[0]?.os ?? null, + arch: contexts[0]?.arch ?? null, + recentOutput: contexts + .map((c, i) => `[${panes[i].name}]\n${c.recentOutput}`) + .filter((s) => s.trim().length > panes[0].name.length + 4) + .join("\n---\n"), + selectedText: + selectedText ?? + contexts + .map((c) => c.selectedText) + .filter(Boolean) + .join("\n"), + inputBuffer: contexts + .map((c) => c.inputBuffer) + .filter(Boolean) + .join("\n"), + }; + return merged; + }, + [activeConnection, aiSettings.context_line_limit, savedConnections], + ); + const startChat = useCallback( async (action: AIAction, userInput: string, selectedText?: string) => { - if (!activePane || activePane.connecting || activePane.connectError) { + const panes = effectivePanes; + if (panes.length === 0) { toast.error(t("panel.noActiveSessions")); return; } @@ -271,6 +548,7 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP setDetectedError(null); setLoading(true); + cancelledRef.current = false; streamUnlistenRef.current?.(); streamUnlistenRef.current = null; @@ -285,22 +563,22 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP role: "assistant", content: "", createdAt: new Date().toISOString(), + reasoningContent: null, commandCards: [], }, ]); + setStreamingAssistantId(assistantId); try { - const context = await buildAIContext({ - pane: activePane, - connection: activeConnection, - lineLimit: aiSettings.context_line_limit, - selectedText, - }); + const context = await buildMergedContext(panes, selectedText); + const primaryConn = panes[0].connectionId + ? (savedConnections.find((c) => c.id === panes[0].connectionId) ?? null) + : activeConnection; const result = await invoke("start_ai_chat_stream", { request: { sessionId: currentSessionId, - connectionId: activeConnection?.id ?? null, + connectionId: primaryConn?.id ?? null, action, userInput, context, @@ -329,9 +607,24 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP return; } + if (payload.type === "reasoning_delta" && payload.reasoningDelta) { + setMessages((prev) => + prev.map((message) => + message.id === assistantId + ? { + ...message, + reasoningContent: `${message.reasoningContent ?? ""}${payload.reasoningDelta}`, + } + : message, + ), + ); + return; + } + if (payload.type === "done") { setLoading(false); setStreamId(null); + setStreamingAssistantId(null); setMessages((prev) => prev.map((message) => message.id === assistantId && payload.message ? payload.message : message, @@ -342,19 +635,23 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP } if (payload.type === "error") { + const wasCancelled = cancelledRef.current; setLoading(false); setStreamId(null); - setMessages((prev) => - prev.map((message) => - message.id === assistantId - ? { - ...message, - content: payload.error ?? t("ai.requestFailed"), - } - : message, - ), - ); - toast.error(payload.error ?? t("ai.requestFailed")); + setStreamingAssistantId(null); + if (!wasCancelled) { + setMessages((prev) => + prev.map((message) => + message.id === assistantId + ? { + ...message, + content: payload.error ?? t("ai.requestFailed"), + } + : message, + ), + ); + toast.error(payload.error ?? t("ai.requestFailed")); + } } }, ); @@ -363,22 +660,28 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP } catch (error) { setLoading(false); setStreamId(null); - setMessages((prev) => - prev.map((message) => - message.id === assistantId ? { ...message, content: getErrorMessage(error) } : message, - ), - ); - toast.error(getErrorMessage(error)); + setStreamingAssistantId(null); + if (!cancelledRef.current) { + setMessages((prev) => + prev.map((message) => + message.id === assistantId + ? { ...message, content: getErrorMessage(error) } + : message, + ), + ); + toast.error(getErrorMessage(error)); + } } }, [ activeConnection, - activePane, - aiSettings.context_line_limit, aiSettings.enabled, appendAudit, + buildMergedContext, currentSessionId, + effectivePanes, loadSessions, + savedConnections, t, ], ); @@ -394,19 +697,23 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP const value = input.trim(); if (!value || loading) return; setInput(""); + shouldAutoScrollRef.current = true; void startChat("generate_command", value); }, [input, loading, startChat]); const cancelStream = useCallback(() => { if (!streamId) return; + cancelledRef.current = true; void invoke("cancel_ai_chat_stream", { streamId }).catch(() => {}); setLoading(false); setStreamId(null); + setStreamingAssistantId(null); }, [streamId]); const insertCommand = useCallback( (card: AICommandCard) => { - const provider = getTerminalContextProvider(activeSessionId); + const insertSessionId = effectiveSessionId ?? activeSessionId; + const provider = getTerminalContextProvider(insertSessionId); if (!provider) { toast.error(t("ai.noTerminal")); return; @@ -424,7 +731,7 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP }) .catch((error) => toast.error(getErrorMessage(error))); }, - [activeSessionId, appendAudit, t], + [activeSessionId, appendAudit, effectiveSessionId, t], ); const saveQuickCommand = useCallback( @@ -478,20 +785,77 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP await loadSessions(); }, [loadSessions]); + const newChat = useCallback(() => { + if (loading) return; + setMessages([]); + setCurrentSessionId(null); + setInput(""); + setDetectedError(null); + setTargetPanes([]); + setShowMentionPopover(false); + shouldAutoScrollRef.current = true; + }, [loading]); + + const handleInputChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + setInput(value); + + const cursorPos = event.target.selectionStart; + const textBeforeCursor = value.slice(0, cursorPos); + const atMatch = textBeforeCursor.match(/@(\S*)$/); + if (atMatch) { + setMentionQuery(atMatch[1]); + if (!showMentionPopover) setMentionIndex(0); + setShowMentionPopover(true); + } else { + setShowMentionPopover(false); + setMentionQuery(""); + } + }, + [showMentionPopover], + ); + + const selectMentionPane = useCallback( + (pane: SessionPane) => { + setTargetPanes((prev) => { + const exists = prev.some((p) => p.sessionId === pane.sessionId); + return exists ? prev.filter((p) => p.sessionId !== pane.sessionId) : [...prev, pane]; + }); + + const cursorPos = textareaRef.current?.selectionStart ?? input.length; + const textBeforeCursor = input.slice(0, cursorPos); + const textAfterCursor = input.slice(cursorPos); + const cleaned = textBeforeCursor.replace(/@\S*$/, ""); + setInput(`${cleaned}${textAfterCursor}`); + setShowMentionPopover(false); + setMentionQuery(""); + textareaRef.current?.focus(); + }, + [input], + ); + + const removeTargetPane = useCallback((sessionId: string) => { + setTargetPanes((prev) => prev.filter((p) => p.sessionId !== sessionId)); + }, []); + return (
    { - if (!showHistory) return; const target = event.target as Node; - if ( - historyCardRef.current?.contains(target) || - historyButtonRef.current?.contains(target) - ) { - return; + if (showHistory) { + if ( + !historyCardRef.current?.contains(target) && + !historyButtonRef.current?.contains(target) + ) { + setShowHistory(false); + } + } + if (showMentionPopover && !mentionPopoverRef.current?.contains(target)) { + setShowMentionPopover(false); } - setShowHistory(false); }} > + } /> @@ -598,7 +971,11 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
    ) : null} -
    +
    {messages.length === 0 ? (
    @@ -618,7 +995,20 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
    {message.role === "user" ? "User" : "AI"}
    -
    {message.content}
    + {message.role === "assistant" ? ( + + ) : null} + {message.role === "assistant" ? ( + + ) : ( +
    {message.content}
    + )} {message.commandCards?.length ? (
    {message.commandCards.map((card) => ( @@ -638,29 +1028,124 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP
    -
    -