From f5c8ba168faf3ff7a89ec7160e1945b702ac9ebd Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 31 Mar 2026 08:42:28 -0700 Subject: [PATCH] feat: persona picker with @ mention support (#24) * feat: add persona picker and @ mention support in chat Replace the provider-first model with a persona-first interaction pattern. Users now pick a Persona (with its own system prompt, default provider, and model) from a dropdown in both the home screen and chat input. Typing @ in the chat textarea opens an autocomplete to mention any persona inline, switching context for that message. Provider selection is still available as a secondary override. New components: - PersonaPicker: dropdown grouped by built-in / custom personas - MentionAutocomplete: floating autocomplete triggered by @ in textarea - ChatInputToolbar: extracted bottom bar to keep ChatInput under 500 lines - useMentionDetection: hook for detecting @ mention state in text input Co-Authored-By: Claude Opus 4.6 (1M context) * feat: wire persona system prompts through to ACP backend and fix mention UX - Pass persona system_prompt from frontend through Tauri to AcpService - Prepend persona instructions to user prompt in tags - Add keyboard navigation (arrow keys, Enter/Tab) to @ mention autocomplete - Fix PersonaPicker description extraction (was using index [1] instead of [0]) - Simplify ChatView handleSend by letting useChat handle system prompt injection Co-Authored-By: Claude Opus 4.6 (1M context) * feat: enhance persona picker with @ mentions on home screen, switch notifications, and per-message persona tracking Adds @ mention autocomplete to the HomeScreen input so users can invoke personas before starting a chat. Shows system notifications when switching personas mid-chat, and tracks which persona generated each assistant message in the timeline. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: persist persona selection across tab switches and app restarts Store the selected persona per session in ui_state.json alongside the existing tab state. When switching tabs or reopening the app, the previously selected persona is restored for each chat session. Changes: - Backend: add persona_per_session HashMap to UiState with serde(default) for backward compatibility - chatSessionStore: add personaId to ChatSession, save/restore persona mapping in persistTabState/loadTabState - ChatView: save persona changes to session store - AppShell: pass session's stored personaId to ChatView, store personaId when creating new sessions Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve 4 bugs found in persona picker audit 1. Error state stuck: acpSendMessage errors now reset chatState to "idle" so the user can continue sending messages after failures. 2. Stop button cancels backend: stopGeneration now calls acp_cancel_session to terminate the Rust ACP session, preventing orphaned streaming events after the UI resets. 3. @ mention persona race condition: when switching personas via @ mention, the message send is deferred to the next render cycle so useChat picks up the new persona's system prompt instead of the stale one. 4. Deleted persona fallback: if a persisted personaId references a deleted persona, ChatView now falls back to builtin-goose (or the first available persona) and updates the session store. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: load personas eagerly at app startup Personas were only loaded when visiting the Personas page (via the usePersonas hook in AgentsView). This meant the persona picker in HomeScreen and ChatInput showed empty until the user navigated to the Personas page at least once. Now AppShell loads personas from the backend in its startup effect, alongside sessions and tab state. This ensures personas are available immediately in all views. Co-Authored-By: Claude Opus 4.6 (1M context) * style: fix cargo fmt in acp service Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update tests for persona picker changes and silence clippy - Update ChatInput tests for new toolbar structure and persona props - Update HomeScreen tests to mock persona store instead of ACP providers - Add #[allow(clippy::too_many_arguments)] on send_prompt Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ui/goose2/src-tauri/src/commands/acp.rs | 2 + ui/goose2/src-tauri/src/commands/ui_state.rs | 7 + ui/goose2/src-tauri/src/services/acp.rs | 13 +- ui/goose2/src/app/AppShell.tsx | 44 +- ui/goose2/src/features/chat/hooks/useChat.ts | 36 +- .../features/chat/stores/chatSessionStore.ts | 27 +- ui/goose2/src/features/chat/ui/ChatInput.tsx | 423 ++++++------------ .../src/features/chat/ui/ChatInputToolbar.tsx | 290 ++++++++++++ ui/goose2/src/features/chat/ui/ChatView.tsx | 149 +++++- .../features/chat/ui/MentionAutocomplete.tsx | 241 ++++++++++ .../src/features/chat/ui/MessageTimeline.tsx | 6 +- .../src/features/chat/ui/PersonaPicker.tsx | 184 ++++++++ .../chat/ui/__tests__/ChatInput.test.tsx | 21 +- .../src/features/home/ui/HomeScreen.test.tsx | 44 +- ui/goose2/src/features/home/ui/HomeScreen.tsx | 233 ++++++---- ui/goose2/src/shared/api/acp.ts | 7 + ui/goose2/src/shared/api/chat.ts | 8 +- ui/goose2/src/shared/types/messages.ts | 3 + 18 files changed, 1316 insertions(+), 422 deletions(-) create mode 100644 ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx create mode 100644 ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx create mode 100644 ui/goose2/src/features/chat/ui/PersonaPicker.tsx 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} + + +
+ )} +