diff --git a/ui/goose2/src-tauri/src/commands/acp.rs b/ui/goose2/src-tauri/src/commands/acp.rs index 03536dedf4..f96b1b8bc1 100644 --- a/ui/goose2/src-tauri/src/commands/acp.rs +++ b/ui/goose2/src-tauri/src/commands/acp.rs @@ -38,6 +38,7 @@ pub async fn acp_send_message( session_id: String, provider_id: String, prompt: String, + system_prompt: Option, ) -> Result<(), String> { let working_dir = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp")); @@ -49,6 +50,7 @@ pub async fn acp_send_message( provider_id, prompt, working_dir, + system_prompt, ) .await } diff --git a/ui/goose2/src-tauri/src/commands/ui_state.rs b/ui/goose2/src-tauri/src/commands/ui_state.rs index a339b58a0e..3d678a0993 100644 --- a/ui/goose2/src-tauri/src/commands/ui_state.rs +++ b/ui/goose2/src-tauri/src/commands/ui_state.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] @@ -5,6 +7,8 @@ use serde::{Deserialize, Serialize}; pub struct UiState { pub open_tab_ids: Vec, pub active_tab_id: Option, + #[serde(default)] + pub persona_per_session: HashMap, } fn ui_state_path() -> std::path::PathBuf { @@ -18,10 +22,12 @@ fn ui_state_path() -> std::path::PathBuf { pub fn save_ui_state( open_tab_ids: Vec, active_tab_id: Option, + persona_per_session: HashMap, ) -> Result<(), String> { let state = UiState { open_tab_ids, active_tab_id, + persona_per_session, }; let path = ui_state_path(); // Ensure parent directory exists @@ -39,6 +45,7 @@ pub fn load_ui_state() -> Result { return Ok(UiState { open_tab_ids: Vec::new(), active_tab_id: None, + persona_per_session: HashMap::new(), }); } let json = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; diff --git a/ui/goose2/src-tauri/src/services/acp.rs b/ui/goose2/src-tauri/src/services/acp.rs index dd574beea0..d93fb589bf 100644 --- a/ui/goose2/src-tauri/src/services/acp.rs +++ b/ui/goose2/src-tauri/src/services/acp.rs @@ -370,6 +370,7 @@ pub struct AcpService; impl AcpService { /// Send a prompt to the given ACP provider and stream the response via /// Tauri events. + #[allow(clippy::too_many_arguments)] pub async fn send_prompt( app_handle: tauri::AppHandle, registry: Arc, @@ -378,6 +379,7 @@ impl AcpService { provider_id: String, prompt: String, working_dir: PathBuf, + system_prompt: Option, ) -> Result<(), String> { // Ensure the session exists in the SessionStore (create if needed) session_store.ensure_session(&session_id, Some(provider_id.clone())); @@ -409,6 +411,15 @@ impl AcpService { let store: Arc = Arc::new(TauriStore::new(session_store)); let cancel_token = registry.register(&session_id, &provider_id); + // Prepend the persona's system prompt to the user's message so the + // agent sees the persona instructions as context for this turn. + let effective_prompt = match &system_prompt { + Some(sp) if !sp.is_empty() => { + format!("\n{sp}\n\n\n{prompt}") + } + _ => prompt.clone(), + }; + // AcpDriver::run may use !Send futures internally, so we run it on a // dedicated thread with a LocalSet. let session_id_inner = session_id.clone(); @@ -424,7 +435,7 @@ impl AcpService { driver .run( &session_id_inner, - &prompt, + &effective_prompt, &[], &working_dir, &store, diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index 7e6e895245..fd2362c174 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -69,9 +69,22 @@ export function AppShell({ children }: { children?: React.ReactNode }) { } }, []); - // Load sessions and tab state on mount + // Load sessions, personas, and tab state on mount useEffect(() => { (async () => { + // Load personas eagerly so they're available in HomeScreen and ChatView + const personaStore = useAgentStore.getState(); + personaStore.setPersonasLoading(true); + try { + const { listPersonas } = await import("@/shared/api/agents"); + const personas = await listPersonas(); + personaStore.setPersonas(personas); + } catch (err) { + console.error("Failed to load personas on startup:", err); + } finally { + personaStore.setPersonasLoading(false); + } + const { loadSessions, loadTabState } = useChatSessionStore.getState(); await loadSessions(); await loadTabState(); @@ -121,6 +134,13 @@ export function AppShell({ children }: { children?: React.ReactNode }) { ? ("connected" as const) : ("disconnected" as const); + const [pendingInitialMessage, setPendingInitialMessage] = useState< + string | undefined + >(); + const [homeSelectedPersonaId, setHomeSelectedPersonaId] = useState< + string | undefined + >(); + const createNewTab = useCallback( async (title = "New Chat", project?: ProjectInfo) => { const agentId = agentStore.activeAgentId ?? undefined; @@ -129,6 +149,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { title, projectId: project?.id, agentId, + personaId: homeSelectedPersonaId, }); sessionStore.openTab(session.id); @@ -156,7 +177,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { return session; }, - [chatStore, sessionStore, agentStore.activeAgentId], + [chatStore, sessionStore, agentStore.activeAgentId, homeSelectedPersonaId], ); const handleStartChatFromProject = useCallback( @@ -224,17 +245,15 @@ export function AppShell({ children }: { children?: React.ReactNode }) { createNewTab(); }, [createNewTab]); - const [pendingInitialMessage, setPendingInitialMessage] = useState< - string | undefined - >(); - const handleHomeStartChat = useCallback( - (initialMessage?: string, providerId?: string) => { + (initialMessage?: string, providerId?: string, personaId?: string) => { setHomeSelectedProvider(providerId); + setHomeSelectedPersonaId(personaId); setPendingInitialMessage(initialMessage); createNewTab(initialMessage?.slice(0, 40) || "New Chat").catch(() => { // Clear pending message if tab creation fails to avoid stale state setPendingInitialMessage(undefined); + setHomeSelectedPersonaId(undefined); }); }, [createNewTab], @@ -286,6 +305,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) { return () => window.removeEventListener("keydown", handler); }, []); + // Derive stored persona for active session + const activeSessionPersonaId = activeTab + ? sessionStore.getSession(activeTab.sessionId)?.personaId + : undefined; + const renderContent = () => { switch (activeView) { case "skills": @@ -301,8 +325,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) { key={activeTab.sessionId} sessionId={activeTab.sessionId} initialProvider={homeSelectedProvider} + initialPersonaId={activeSessionPersonaId ?? homeSelectedPersonaId} initialMessage={pendingInitialMessage} - onInitialMessageConsumed={() => setPendingInitialMessage(undefined)} + onInitialMessageConsumed={() => { + setPendingInitialMessage(undefined); + setHomeSelectedPersonaId(undefined); + }} /> ) : ( diff --git a/ui/goose2/src/features/chat/hooks/useChat.ts b/ui/goose2/src/features/chat/hooks/useChat.ts index a09069cf77..8f4db7ef10 100644 --- a/ui/goose2/src/features/chat/hooks/useChat.ts +++ b/ui/goose2/src/features/chat/hooks/useChat.ts @@ -3,7 +3,7 @@ import { useChatStore } from "../stores/chatStore"; import { createUserMessage } from "@/shared/types/messages"; import type { Message } from "@/shared/types/messages"; import type { ChatState, TokenState } from "@/shared/types/chat"; -import { acpSendMessage } from "@/shared/api/acp"; +import { acpSendMessage, acpCancelSession } from "@/shared/api/acp"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { findLastIndex } from "@/shared/lib/arrays"; @@ -11,7 +11,12 @@ import { findLastIndex } from "@/shared/lib/arrays"; * Hook for managing a chat session -- sending messages, handling streaming, * and managing chat lifecycle. */ -export function useChat(sessionId: string, providerOverride?: string) { +export function useChat( + sessionId: string, + providerOverride?: string, + systemPromptOverride?: string, + personaInfo?: { id: string; name: string }, +) { const store = useChatStore(); const abortRef = useRef(null); @@ -42,7 +47,12 @@ export function useChat(sessionId: string, providerOverride?: string) { role: "assistant", created: Date.now(), content: [], - metadata: { userVisible: true, agentVisible: true }, + metadata: { + userVisible: true, + agentVisible: true, + personaId: personaInfo?.id, + personaName: personaInfo?.name, + }, }; store.addMessage(sessionId, assistantMessage); store.setStreamingMessageId(assistantMessage.id); @@ -53,12 +63,14 @@ export function useChat(sessionId: string, providerOverride?: string) { try { const agent = useAgentStore.getState().getActiveAgent(); const providerId = providerOverride ?? agent?.provider ?? "goose"; + const systemPrompt = + systemPromptOverride ?? agent?.systemPrompt ?? undefined; // Send via ACP — response streams back through Tauri events // which are handled by useAcpStream in ChatView store.setChatState("streaming"); store.appendToStreamingMessage(sessionId, { type: "text", text: "" }); - await acpSendMessage(sessionId, providerId, text); + await acpSendMessage(sessionId, providerId, text, systemPrompt); // Note: setChatState("idle") is handled by useAcpStream on "acp:done" } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { @@ -67,20 +79,32 @@ export function useChat(sessionId: string, providerOverride?: string) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; store.setError(errorMessage); + store.setChatState("idle"); store.setStreamingMessageId(null); } } finally { abortRef.current = null; } }, - [sessionId, chatState, store, providerOverride], + [ + sessionId, + chatState, + store, + providerOverride, + systemPromptOverride, + personaInfo, + ], ); const stopGeneration = useCallback(() => { abortRef.current?.abort(); store.setChatState("idle"); store.setStreamingMessageId(null); - }, [store]); + // Cancel the backend ACP session to stop orphaned streaming events + acpCancelSession(sessionId).catch(() => { + // Best-effort cancellation — ignore errors + }); + }, [store, sessionId]); const retryLastMessage = useCallback(async () => { const sessionMessages = store.messagesBySession[sessionId] ?? []; diff --git a/ui/goose2/src/features/chat/stores/chatSessionStore.ts b/ui/goose2/src/features/chat/stores/chatSessionStore.ts index af856a001d..2fa823cb3a 100644 --- a/ui/goose2/src/features/chat/stores/chatSessionStore.ts +++ b/ui/goose2/src/features/chat/stores/chatSessionStore.ts @@ -15,6 +15,7 @@ export interface ChatSession { projectId?: string; agentId?: string; providerId?: string; + personaId?: string; createdAt: string; // ISO timestamp updatedAt: string; } @@ -33,6 +34,7 @@ interface ChatSessionStoreActions { projectId?: string; agentId?: string; providerId?: string; + personaId?: string; }) => Promise; loadSessions: () => Promise; updateSession: (id: string, patch: Partial) => void; @@ -86,6 +88,7 @@ export const useChatSessionStore = create((set, get) => ({ projectId: opts?.projectId, agentId: opts?.agentId ?? backendSession.agentId, providerId: opts?.providerId, + personaId: opts?.personaId, createdAt: backendSession.createdAt ?? now, updatedAt: backendSession.updatedAt ?? now, }; @@ -188,15 +191,21 @@ export const useChatSessionStore = create((set, get) => ({ // Persistence persistTabState: () => { - const { openTabIds, activeTabId } = get(); - apiSaveUiState(openTabIds, activeTabId).catch((err) => { + const { openTabIds, activeTabId, sessions } = get(); + // Build persona mapping from sessions + const personaPerSession: Record = {}; + for (const s of sessions) { + if (s.personaId) personaPerSession[s.id] = s.personaId; + } + apiSaveUiState(openTabIds, activeTabId, personaPerSession).catch((err) => { console.error("Failed to persist tab state:", err); }); }, loadTabState: async () => { try { - const { openTabIds, activeTabId } = await apiLoadUiState(); + const { openTabIds, activeTabId, personaPerSession } = + await apiLoadUiState(); const { sessions } = get(); const sessionIds = new Set(sessions.map((s) => s.id)); @@ -207,7 +216,17 @@ export const useChatSessionStore = create((set, get) => ({ ? activeTabId : (validTabIds[0] ?? null); - set({ openTabIds: validTabIds, activeTabId: validActiveTabId }); + // Restore persona IDs from persisted mapping + const updatedSessions = sessions.map((s) => { + const personaId = personaPerSession?.[s.id]; + return personaId ? { ...s, personaId } : s; + }); + + set({ + sessions: updatedSessions, + openTabIds: validTabIds, + activeTabId: validActiveTabId, + }); } catch (err) { console.error("Failed to load tab state:", err); } diff --git a/ui/goose2/src/features/chat/ui/ChatInput.tsx b/ui/goose2/src/features/chat/ui/ChatInput.tsx index aaa614c648..063507d7db 100644 --- a/ui/goose2/src/features/chat/ui/ChatInput.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInput.tsx @@ -1,31 +1,14 @@ import { useState, useRef, useCallback, useEffect } from "react"; -import { - ArrowUp, - Square, - Paperclip, - Mic, - ChevronDown, - Bot, - FolderOpen, - Check, -} from "lucide-react"; +import { X } from "lucide-react"; import type { AcpProvider } from "@/shared/api/acp"; +import type { Persona } from "@/shared/types/agents"; import { cn } from "@/shared/lib/cn"; -import { ContextRing } from "./ContextRing"; import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, -} from "@/shared/ui/dropdown-menu"; -import { - Tooltip, - TooltipTrigger, - TooltipContent, - TooltipProvider, -} from "@/shared/ui/tooltip"; + MentionAutocomplete, + useMentionDetection, +} from "./MentionAutocomplete"; +import { ChatInputToolbar } from "./ChatInputToolbar"; +import { TooltipProvider } from "@/shared/ui/tooltip"; // --------------------------------------------------------------------------- // Types @@ -39,13 +22,18 @@ export interface ModelOption { } interface ChatInputProps { - onSend: (text: string) => void; + onSend: (text: string, personaId?: string) => void; onStop?: () => void; isStreaming?: boolean; disabled?: boolean; placeholder?: string; className?: string; - // Provider + // Personas + personas?: Persona[]; + selectedPersonaId?: string | null; + onPersonaChange?: (personaId: string) => void; + onCreatePersona?: () => void; + // Provider (secondary -- auto-set by persona but overridable) providers?: AcpProvider[]; selectedProvider?: string; onProviderChange?: (providerId: string) => void; @@ -73,70 +61,108 @@ export function ChatInput({ disabled = false, placeholder = "Message Goose...", className, - // Provider + personas = [], + selectedPersonaId = null, + onPersonaChange, + onCreatePersona, providers = [], selectedProvider = "goose", onProviderChange, - // Model currentModel = "Claude Sonnet 4", availableModels = [], onModelChange, - // Folder folder = null, availableFolders = [], onFolderChange, - // Context contextTokens = 0, contextLimit = 0, }: ChatInputProps) { const [text, setText] = useState(""); const [isCompact, setIsCompact] = useState(false); + const [mentionPersonaId, setMentionPersonaId] = useState(null); const textareaRef = useRef(null); const containerRef = useRef(null); const canSend = text.trim().length > 0 && !isStreaming && !disabled; - // ----------------------------------------------------------------------- - // Responsive: detect container width - // ----------------------------------------------------------------------- + const { + mentionOpen, + mentionQuery, + mentionStartIndex, + mentionSelectedIndex, + detectMention, + closeMention, + navigateMention, + confirmMention, + } = useMentionDetection(personas); useEffect(() => { const el = containerRef.current; - if (!el) return; - - if (typeof ResizeObserver === "undefined") return; - + if (!el || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { setIsCompact(entry.contentRect.width < 580); } }); - observer.observe(el); return () => observer.disconnect(); }, []); - // ----------------------------------------------------------------------- - // Handlers - // ----------------------------------------------------------------------- - - const handleProviderChange = useCallback( - (providerId: string) => { - onProviderChange?.(providerId); - }, - [onProviderChange], - ); - const handleSend = useCallback(() => { if (!canSend) return; - onSend(text.trim()); + const effectivePersonaId = + mentionPersonaId ?? selectedPersonaId ?? undefined; + onSend(text.trim(), effectivePersonaId ?? undefined); setText(""); + setMentionPersonaId(null); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } - }, [canSend, text, onSend]); + }, [canSend, text, onSend, mentionPersonaId, selectedPersonaId]); + + const handleMentionSelect = useCallback( + (persona: Persona) => { + const before = text.slice(0, mentionStartIndex); + const after = text.slice(mentionStartIndex + 1 + mentionQuery.length); + const newText = `${before}@${persona.displayName} ${after}`; + setText(newText); + setMentionPersonaId(persona.id); + closeMention(); + onPersonaChange?.(persona.id); + + requestAnimationFrame(() => { + const ta = textareaRef.current; + if (ta) { + ta.focus(); + const cursorPos = before.length + persona.displayName.length + 2; + ta.setSelectionRange(cursorPos, cursorPos); + } + }); + }, + [text, mentionStartIndex, mentionQuery, closeMention, onPersonaChange], + ); const handleKeyDown = (e: React.KeyboardEvent) => { + if (mentionOpen) { + if (e.key === "Escape") { + e.preventDefault(); + closeMention(); + return; + } + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + navigateMention(e.key === "ArrowDown" ? "down" : "up"); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + const persona = confirmMention(); + if (persona) { + e.preventDefault(); + handleMentionSelect(persona); + return; + } + } + } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); @@ -144,264 +170,87 @@ export function ChatInput({ }; const handleInput = (e: React.ChangeEvent) => { - setText(e.target.value); + const value = e.target.value; + setText(value); + const cursorPos = e.target.selectionStart ?? value.length; + detectMention(value, cursorPos); const textarea = e.target; textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; }; - // ----------------------------------------------------------------------- - // Derived - // ----------------------------------------------------------------------- - - const activeProvider = providers.find((p) => p.id === selectedProvider); - const providerLabel = activeProvider?.label ?? selectedProvider; - const selectedFolder = availableFolders.find((f) => f.id === folder); - const folderLabel = selectedFolder?.name ?? "Folder"; - - // ----------------------------------------------------------------------- - // Render - // ----------------------------------------------------------------------- + const activePersona = personas.find((p) => p.id === selectedPersonaId); + const personaDisplayName = activePersona?.displayName ?? "Goose"; + const effectivePlaceholder = + placeholder === "Message Goose..." + ? `Message ${personaDisplayName}... (type @ to mention)` + : placeholder; return (
- {/* Textarea */} + + + {mentionPersonaId && ( +
+ + @ + {personas.find((p) => p.id === mentionPersonaId)?.displayName} + + +
+ )} +