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) <noreply@anthropic.com>

* 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 <persona-instructions> 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* style: fix cargo fmt in acp service

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-03-31 08:42:28 -07:00
committed by GitHub
parent 20f9ce4163
commit f5c8ba168f
18 changed files with 1316 additions and 422 deletions
+2
View File
@@ -38,6 +38,7 @@ pub async fn acp_send_message(
session_id: String,
provider_id: String,
prompt: String,
system_prompt: Option<String>,
) -> 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
}
@@ -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<String>,
pub active_tab_id: Option<String>,
#[serde(default)]
pub persona_per_session: HashMap<String, String>,
}
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<String>,
active_tab_id: Option<String>,
persona_per_session: HashMap<String, String>,
) -> 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<UiState, String> {
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())?;
+12 -1
View File
@@ -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<AcpSessionRegistry>,
@@ -378,6 +379,7 @@ impl AcpService {
provider_id: String,
prompt: String,
working_dir: PathBuf,
system_prompt: Option<String>,
) -> 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<dyn Store> = 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!("<persona-instructions>\n{sp}\n</persona-instructions>\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,
+36 -8
View File
@@ -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);
}}
/>
) : (
<HomeScreen onStartChat={handleHomeStartChat} />
+30 -6
View File
@@ -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<AbortController | null>(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] ?? [];
@@ -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<ChatSession>;
loadSessions: () => Promise<void>;
updateSession: (id: string, patch: Partial<ChatSession>) => void;
@@ -86,6 +88,7 @@ export const useChatSessionStore = create<ChatSessionStore>((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<ChatSessionStore>((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<string, string> = {};
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<ChatSessionStore>((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);
}
+136 -287
View File
@@ -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<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
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 (
<TooltipProvider delayDuration={300}>
<div className={cn("px-4 pb-6 pt-2", className)} ref={containerRef}>
<div className="mx-auto max-w-3xl">
<div className="relative rounded-2xl border border-border bg-background-secondary px-4 pb-3 pt-4 shadow-lg">
{/* Textarea */}
<MentionAutocomplete
personas={personas}
query={mentionQuery}
isOpen={mentionOpen}
onSelect={handleMentionSelect}
onClose={closeMention}
selectedIndex={mentionSelectedIndex}
/>
{mentionPersonaId && (
<div className="mb-2 flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 rounded-full bg-accent/10 px-2.5 py-0.5 text-[11px] font-medium text-accent">
@
{personas.find((p) => p.id === mentionPersonaId)?.displayName}
<button
type="button"
className="ml-0.5 inline-flex items-center opacity-60 hover:opacity-100"
onClick={() => setMentionPersonaId(null)}
aria-label="Remove mention"
>
<X className="h-3 w-3" />
</button>
</span>
</div>
)}
<textarea
ref={textareaRef}
value={text}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
placeholder={effectivePlaceholder}
disabled={disabled || isStreaming}
rows={1}
className="mb-3 min-h-[36px] max-h-[200px] w-full resize-none bg-transparent px-1 text-[14px] leading-relaxed text-foreground placeholder:text-foreground-tertiary/60 focus:outline-none disabled:opacity-60"
aria-label="Chat message input"
/>
{/* Bottom bar */}
<div className="flex items-center justify-between gap-2">
{/* Left side: pickers */}
<div className="flex items-center gap-0.5">
{/* Provider picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-background-tertiary"
aria-label="Select provider"
>
<Bot className="h-3.5 w-3.5" />
{!isCompact && <span>{providerLabel}</span>}
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Provider</DropdownMenuLabel>
<DropdownMenuSeparator />
{providers.length > 0 ? (
providers.map((provider) => (
<DropdownMenuItem
key={provider.id}
onSelect={() => handleProviderChange(provider.id)}
className="flex items-center justify-between"
>
<span className="text-sm font-medium">
{provider.label}
</span>
{provider.id === selectedProvider && (
<Check className="h-4 w-4 shrink-0 text-foreground-secondary" />
)}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>
<span className="text-xs text-foreground-tertiary">
No providers detected
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Model picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-foreground-tertiary transition-colors hover:bg-background-tertiary hover:text-foreground"
aria-label="Select model"
>
{!isCompact && <span>{currentModel}</span>}
{isCompact && (
<span className="max-w-[60px] truncate">
{currentModel}
</span>
)}
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Model</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableModels.length > 0 ? (
availableModels.map((model) => (
<DropdownMenuItem
key={model.id}
onSelect={() => onModelChange?.(model.id)}
className="flex items-center justify-between"
>
<div className="flex flex-col gap-0.5">
<span className="text-sm">
{model.displayName ?? model.name}
</span>
{model.provider && (
<span className="text-xs text-foreground-tertiary">
{model.provider}
</span>
)}
</div>
{model.id === currentModel && (
<Check className="h-4 w-4 shrink-0 text-foreground-secondary" />
)}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>
<span className="text-xs text-foreground-tertiary">
{currentModel}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Divider */}
<div className="mx-0.5 h-4 w-px bg-border" />
{/* Folder picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-foreground-tertiary transition-colors hover:bg-background-tertiary hover:text-foreground"
aria-label="Select folder"
>
<FolderOpen className="h-3.5 w-3.5" />
{!isCompact && <span>{folderLabel}</span>}
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Working Directory</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableFolders.length > 0 ? (
availableFolders.map((f) => (
<DropdownMenuItem
key={f.id}
onSelect={() => onFolderChange?.(f.id)}
className="flex items-center justify-between"
>
<span className="text-sm">{f.name}</span>
{f.id === folder && (
<Check className="h-4 w-4 shrink-0 text-foreground-secondary" />
)}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>
<span className="text-xs text-foreground-tertiary">
No folders available
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Right side: actions */}
<div className="flex items-center gap-1">
{/* Context ring */}
{contextLimit > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded-lg p-2 text-foreground-tertiary transition-colors hover:bg-background-tertiary hover:text-foreground"
aria-label="Context usage"
>
<ContextRing
tokens={contextTokens}
limit={contextLimit}
/>
</button>
</TooltipTrigger>
<TooltipContent>
{contextTokens.toLocaleString()} /{" "}
{contextLimit.toLocaleString()} tokens
</TooltipContent>
</Tooltip>
)}
{/* Voice button (disabled) */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="cursor-not-allowed rounded-lg p-2 text-foreground-tertiary/50 transition-colors"
disabled
aria-label="Voice input (coming soon)"
>
<Mic className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Voice input (coming soon)</TooltipContent>
</Tooltip>
{/* Attach file */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded-lg p-2 text-foreground-tertiary transition-colors hover:bg-background-tertiary hover:text-foreground"
aria-label="Attach file"
>
<Paperclip className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Attach file</TooltipContent>
</Tooltip>
{/* Stop / Send */}
{isStreaming ? (
<button
type="button"
onClick={onStop}
className="flex h-8 w-8 items-center justify-center rounded-full bg-background-danger/10 text-foreground-danger transition-colors hover:bg-background-danger/20"
aria-label="Stop generation"
>
<Square className="h-3.5 w-3.5" />
</button>
) : (
<button
type="button"
onClick={handleSend}
disabled={!canSend}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full transition-colors",
canSend
? "bg-foreground text-background-primary hover:opacity-90"
: "cursor-default bg-foreground/10 text-foreground-tertiary",
)}
aria-label="Send message"
>
<ArrowUp className="h-4 w-4" />
</button>
)}
</div>
</div>
<ChatInputToolbar
personas={personas}
selectedPersonaId={selectedPersonaId}
onPersonaChange={onPersonaChange}
onCreatePersona={onCreatePersona}
providers={providers}
selectedProvider={selectedProvider}
onProviderChange={(id) => onProviderChange?.(id)}
currentModel={currentModel}
availableModels={availableModels}
onModelChange={onModelChange}
folder={folder}
availableFolders={availableFolders}
onFolderChange={onFolderChange}
contextTokens={contextTokens}
contextLimit={contextLimit}
canSend={canSend}
isStreaming={isStreaming}
onSend={handleSend}
onStop={onStop}
isCompact={isCompact}
/>
</div>
</div>
</div>
@@ -0,0 +1,290 @@
import {
Paperclip,
Mic,
ChevronDown,
FolderOpen,
Check,
ArrowUp,
Square,
} 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 { PersonaPicker } from "./PersonaPicker";
import type { ModelOption } from "./ChatInput";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/shared/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip";
interface ChatInputToolbarProps {
// Personas
personas: Persona[];
selectedPersonaId: string | null;
onPersonaChange?: (personaId: string) => void;
onCreatePersona?: () => void;
// Provider
providers: AcpProvider[];
selectedProvider: string;
onProviderChange: (providerId: string) => void;
// Model
currentModel: string;
availableModels: ModelOption[];
onModelChange?: (modelId: string) => void;
// Folder
folder: string | null;
availableFolders: Array<{ id: string; name: string }>;
onFolderChange?: (folderId: string | null) => void;
// Context
contextTokens: number;
contextLimit: number;
// Actions
canSend: boolean;
isStreaming: boolean;
onSend: () => void;
onStop?: () => void;
// Layout
isCompact: boolean;
}
export function ChatInputToolbar({
personas,
selectedPersonaId,
onPersonaChange,
onCreatePersona,
providers,
selectedProvider,
onProviderChange,
currentModel,
availableModels,
onModelChange,
folder,
availableFolders,
onFolderChange,
contextTokens,
contextLimit,
canSend,
isStreaming,
onSend,
onStop,
isCompact,
}: ChatInputToolbarProps) {
const selectedFolder = availableFolders.find((f) => f.id === folder);
const folderLabel = selectedFolder?.name ?? "Folder";
return (
<div className="flex items-center justify-between gap-2">
{/* Left side: pickers */}
<div className="flex items-center gap-0.5">
{personas.length > 0 && (
<PersonaPicker
personas={personas}
selectedPersonaId={selectedPersonaId}
onPersonaChange={(id) => onPersonaChange?.(id)}
onCreatePersona={onCreatePersona}
compact={isCompact}
/>
)}
{providers.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] text-foreground-tertiary transition-colors hover:bg-background-tertiary hover:text-foreground"
aria-label="Override provider"
>
{!isCompact && (
<span>
{providers.find((p) => p.id === selectedProvider)?.label ??
selectedProvider}
</span>
)}
<ChevronDown className="h-2.5 w-2.5 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Provider Override</DropdownMenuLabel>
<DropdownMenuSeparator />
{providers.map((provider) => (
<DropdownMenuItem
key={provider.id}
onSelect={() => onProviderChange(provider.id)}
className="flex items-center justify-between"
>
<span className="text-sm font-medium">{provider.label}</span>
{provider.id === selectedProvider && (
<Check className="h-4 w-4 shrink-0 text-foreground-secondary" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{availableModels.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-foreground-tertiary transition-colors hover:bg-background-tertiary hover:text-foreground"
aria-label="Select model"
>
{!isCompact && <span>{currentModel}</span>}
{isCompact && (
<span className="max-w-[60px] truncate">{currentModel}</span>
)}
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Model</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableModels.map((model) => (
<DropdownMenuItem
key={model.id}
onSelect={() => onModelChange?.(model.id)}
className="flex items-center justify-between"
>
<div className="flex flex-col gap-0.5">
<span className="text-sm">
{model.displayName ?? model.name}
</span>
{model.provider && (
<span className="text-xs text-foreground-tertiary">
{model.provider}
</span>
)}
</div>
{model.id === currentModel && (
<Check className="h-4 w-4 shrink-0 text-foreground-secondary" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="mx-0.5 h-4 w-px bg-border" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-foreground-tertiary transition-colors hover:bg-background-tertiary hover:text-foreground"
aria-label="Select folder"
>
<FolderOpen className="h-3.5 w-3.5" />
{!isCompact && <span>{folderLabel}</span>}
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Working Directory</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableFolders.length > 0 ? (
availableFolders.map((f) => (
<DropdownMenuItem
key={f.id}
onSelect={() => onFolderChange?.(f.id)}
className="flex items-center justify-between"
>
<span className="text-sm">{f.name}</span>
{f.id === folder && (
<Check className="h-4 w-4 shrink-0 text-foreground-secondary" />
)}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>
<span className="text-xs text-foreground-tertiary">
No folders available
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Right side: actions */}
<div className="flex items-center gap-1">
{contextLimit > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded-lg p-2 text-foreground-tertiary transition-colors hover:bg-background-tertiary hover:text-foreground"
aria-label="Context usage"
>
<ContextRing tokens={contextTokens} limit={contextLimit} />
</button>
</TooltipTrigger>
<TooltipContent>
{contextTokens.toLocaleString()} / {contextLimit.toLocaleString()}{" "}
tokens
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="cursor-not-allowed rounded-lg p-2 text-foreground-tertiary/50 transition-colors"
disabled
aria-label="Voice input (coming soon)"
>
<Mic className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Voice input (coming soon)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded-lg p-2 text-foreground-tertiary transition-colors hover:bg-background-tertiary hover:text-foreground"
aria-label="Attach file"
>
<Paperclip className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Attach file</TooltipContent>
</Tooltip>
{isStreaming ? (
<button
type="button"
onClick={onStop}
className="flex h-8 w-8 items-center justify-center rounded-full bg-background-danger/10 text-foreground-danger transition-colors hover:bg-background-danger/20"
aria-label="Stop generation"
>
<Square className="h-3.5 w-3.5" />
</button>
) : (
<button
type="button"
onClick={onSend}
disabled={!canSend}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full transition-colors",
canSend
? "bg-foreground text-background-primary hover:opacity-90"
: "cursor-default bg-foreground/10 text-foreground-tertiary",
)}
aria-label="Send message"
>
<ArrowUp className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
+141 -8
View File
@@ -1,16 +1,20 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { MessageTimeline } from "./MessageTimeline";
import { ChatInput } from "./ChatInput";
import { StreamingIndicator } from "./StreamingIndicator";
import { useChat } from "../hooks/useChat";
import { useAcpStream } from "../hooks/useAcpStream";
import { useChatStore } from "../stores/chatStore";
import { discoverAcpProviders, type AcpProvider } from "@/shared/api/acp";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useChatSessionStore } from "../stores/chatSessionStore";
interface ChatViewProps {
sessionId?: string;
agentName?: string;
agentAvatarUrl?: string;
initialProvider?: string;
initialPersonaId?: string;
initialMessage?: string;
onInitialMessageConsumed?: () => void;
}
@@ -20,6 +24,7 @@ export function ChatView({
agentName = "Goose",
agentAvatarUrl,
initialProvider,
initialPersonaId,
initialMessage,
onInitialMessageConsumed,
}: ChatViewProps) {
@@ -29,6 +34,14 @@ export function ChatView({
initialProvider ?? "goose",
);
// Persona state
const personas = useAgentStore((s) => s.personas);
const [selectedPersonaId, setSelectedPersonaId] = useState<string>(
initialPersonaId ?? "builtin-goose",
);
const selectedPersona = personas.find((p) => p.id === selectedPersonaId);
useEffect(() => {
discoverAcpProviders()
.then((discovered) => {
@@ -46,8 +59,61 @@ export function ChatView({
.catch(() => setProviders([]));
}, []);
const providerLabel = providers.find((p) => p.id === selectedProvider)?.label;
const displayAgentName = providerLabel ?? agentName;
// When persona changes, update the provider to match persona's default
const handlePersonaChange = useCallback(
(personaId: string) => {
setSelectedPersonaId(personaId);
const persona = personas.find((p) => p.id === personaId);
if (persona?.provider) {
const matchingProvider = providers.find(
(p) =>
p.id === persona.provider ||
p.label.toLowerCase().includes(persona.provider ?? ""),
);
if (matchingProvider) {
setSelectedProvider(matchingProvider.id);
}
}
// Update the active agent to match persona
const agentStore = useAgentStore.getState();
const matchingAgent = agentStore.agents.find(
(a) => a.personaId === personaId,
);
if (matchingAgent) {
agentStore.setActiveAgent(matchingAgent.id);
}
// Persist persona selection to session store
useChatSessionStore
.getState()
.updateSession(activeSessionId, { personaId });
},
[personas, providers, activeSessionId],
);
// Validate persona still exists — fall back to default if deleted
useEffect(() => {
if (
personas.length > 0 &&
!personas.find((p) => p.id === selectedPersonaId)
) {
const fallback =
personas.find((p) => p.id === "builtin-goose") ?? personas[0];
if (fallback) {
setSelectedPersonaId(fallback.id);
useChatSessionStore
.getState()
.updateSession(activeSessionId, { personaId: fallback.id });
}
}
}, [personas, selectedPersonaId, activeSessionId]);
const displayAgentName = selectedPersona?.displayName ?? agentName;
const personaInfo = selectedPersona
? { id: selectedPersona.id, name: selectedPersona.displayName }
: undefined;
const {
messages,
@@ -55,24 +121,85 @@ export function ChatView({
sendMessage,
stopStreaming,
streamingMessageId,
} = useChat(activeSessionId, selectedProvider);
} = useChat(
activeSessionId,
selectedProvider,
selectedPersona?.systemPrompt,
personaInfo,
);
// Listen for ACP streaming events
useAcpStream(activeSessionId, true);
// Ref for deferred sends after persona switch (Bug 1 fix: avoid stale system prompt)
const deferredSend = useRef<string | null>(null);
// Wrap sendMessage to handle @ mentioned persona overrides
const chatStore = useChatStore();
const handleSend = useCallback(
(text: string, personaId?: string) => {
if (personaId && personaId !== selectedPersonaId) {
const newPersona = personas.find((p) => p.id === personaId);
if (newPersona) {
// Inject a system notification about the persona switch
chatStore.addMessage(activeSessionId, {
id: crypto.randomUUID(),
role: "system",
created: Date.now(),
content: [
{
type: "systemNotification",
notificationType: "info",
text: `Switched to ${newPersona.displayName}`,
},
],
metadata: { userVisible: true, agentVisible: false },
});
}
handlePersonaChange(personaId);
// Defer the send until after persona state updates
deferredSend.current = text;
return;
}
sendMessage(text);
},
[
sendMessage,
selectedPersonaId,
handlePersonaChange,
personas,
chatStore,
activeSessionId,
],
);
// Effect to send deferred message after persona switch completes
useEffect(() => {
if (deferredSend.current && selectedPersona) {
const text = deferredSend.current;
deferredSend.current = null;
sendMessage(text);
}
}, [sendMessage, selectedPersona]);
// Auto-send initial message from HomeScreen on mount
const initialMessageSent = useRef(false);
useEffect(() => {
if (initialMessage && !initialMessageSent.current) {
initialMessageSent.current = true;
sendMessage(initialMessage);
handleSend(initialMessage);
onInitialMessageConsumed?.();
}
}, [initialMessage, sendMessage, onInitialMessageConsumed]);
}, [initialMessage, handleSend, onInitialMessageConsumed]);
const isStreaming = chatState === "streaming";
const showIndicator = chatState === "thinking" || chatState === "compacting";
// Open persona editor
const handleCreatePersona = useCallback(() => {
useAgentStore.getState().openPersonaEditor();
}, []);
return (
<div className="flex h-full flex-col">
<MessageTimeline
@@ -80,7 +207,7 @@ export function ChatView({
streamingMessageId={streamingMessageId}
isStreaming={isStreaming}
agentName={displayAgentName}
agentAvatarUrl={agentAvatarUrl}
agentAvatarUrl={selectedPersona?.avatarUrl ?? agentAvatarUrl}
/>
{showIndicator && (
@@ -91,10 +218,16 @@ export function ChatView({
)}
<ChatInput
onSend={sendMessage}
onSend={handleSend}
onStop={stopStreaming}
isStreaming={isStreaming || chatState === "thinking"}
placeholder={`Message ${displayAgentName}...`}
// Personas
personas={personas}
selectedPersonaId={selectedPersonaId}
onPersonaChange={handlePersonaChange}
onCreatePersona={handleCreatePersona}
// Providers (secondary)
providers={providers}
selectedProvider={selectedProvider}
onProviderChange={setSelectedProvider}
@@ -0,0 +1,241 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Sparkles, User } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import type { Persona } from "@/shared/types/agents";
interface MentionAutocompleteProps {
personas: Persona[];
query: string;
isOpen: boolean;
onSelect: (persona: Persona) => void;
/** Optional close handler (called on Escape). */
onClose?: (() => void) | undefined;
anchorRect?: DOMRect | null;
/** Index of the currently highlighted item (controlled by parent). */
selectedIndex?: number;
}
export function MentionAutocomplete({
personas,
query,
isOpen,
onSelect,
anchorRect,
selectedIndex: controlledIndex,
}: MentionAutocompleteProps) {
const [internalIndex, setInternalIndex] = useState(0);
const selectedIndex = controlledIndex ?? internalIndex;
const listRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
const q = query.toLowerCase();
if (!q) return personas;
return personas.filter((p) => p.displayName.toLowerCase().includes(q));
}, [personas, query]);
// Reset index when results change
// biome-ignore lint/correctness/useExhaustiveDependencies: reset on query/result changes
useEffect(() => {
setInternalIndex(0);
}, [filtered.length, query]);
const handleSelect = useCallback(
(persona: Persona) => {
onSelect(persona);
},
[onSelect],
);
if (!isOpen || filtered.length === 0) return null;
return (
<div
ref={listRef}
className="absolute z-50 w-64 rounded-lg border border-border bg-background shadow-lg"
style={{
bottom: anchorRect ? "calc(100% + 4px)" : undefined,
left: anchorRect ? 16 : undefined,
}}
role="listbox"
aria-label="Mention suggestions"
>
<div className="px-2 py-1.5 text-[10px] font-medium uppercase tracking-wider text-foreground-tertiary">
Mention a persona
</div>
<div className="max-h-48 overflow-y-auto px-1 pb-1">
{filtered.map((persona, index) => (
<button
key={persona.id}
type="button"
role="option"
aria-selected={index === selectedIndex}
className={cn(
"flex w-full items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors",
index === selectedIndex
? "bg-background-tertiary text-foreground"
: "text-foreground-secondary hover:bg-background-tertiary/50",
)}
onClick={() => handleSelect(persona)}
onMouseEnter={() => setInternalIndex(index)}
>
<MentionAvatar persona={persona} />
<div className="flex min-w-0 flex-col">
<span className="text-sm font-medium">{persona.displayName}</span>
{persona.provider && (
<span className="text-[10px] text-foreground-tertiary">
{persona.provider}
{persona.model
? ` / ${persona.model.split("-").slice(0, 2).join("-")}`
: ""}
</span>
)}
</div>
</button>
))}
</div>
</div>
);
}
function MentionAvatar({ persona }: { persona: Persona }) {
if (persona.avatarUrl) {
return (
<img
src={persona.avatarUrl}
alt={persona.displayName}
className="h-7 w-7 rounded-full object-cover"
/>
);
}
return (
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full",
persona.isBuiltin
? "bg-foreground/10 text-foreground"
: "bg-accent/10 text-accent",
)}
>
{persona.isBuiltin ? (
<Sparkles className="h-3.5 w-3.5" />
) : (
<User className="h-3.5 w-3.5" />
)}
</div>
);
}
// Hook to manage mention detection and keyboard navigation in a textarea
export function useMentionDetection(personas: Persona[] = []) {
const [mentionState, setMentionState] = useState<{
isOpen: boolean;
query: string;
startIndex: number;
selectedIndex: number;
}>({ isOpen: false, query: "", startIndex: -1, selectedIndex: 0 });
const filtered = useMemo(() => {
if (!mentionState.isOpen) return personas;
const q = mentionState.query.toLowerCase();
if (!q) return personas;
return personas.filter((p) => p.displayName.toLowerCase().includes(q));
}, [personas, mentionState.isOpen, mentionState.query]);
const detectMention = useCallback(
(value: string, cursorPos: number) => {
// Look backwards from cursor for an unmatched @
const beforeCursor = value.slice(0, cursorPos);
const lastAt = beforeCursor.lastIndexOf("@");
if (lastAt === -1) {
if (mentionState.isOpen) {
setMentionState({
isOpen: false,
query: "",
startIndex: -1,
selectedIndex: 0,
});
}
return;
}
// @ must be at start of input or preceded by whitespace
if (lastAt > 0 && !/\s/.test(beforeCursor[lastAt - 1])) {
if (mentionState.isOpen) {
setMentionState({
isOpen: false,
query: "",
startIndex: -1,
selectedIndex: 0,
});
}
return;
}
const query = beforeCursor.slice(lastAt + 1);
// Close if there's a space after the query (mention completed) or too long
if (query.includes(" ") || query.length > 30) {
if (mentionState.isOpen) {
setMentionState({
isOpen: false,
query: "",
startIndex: -1,
selectedIndex: 0,
});
}
return;
}
setMentionState((prev) => ({
isOpen: true,
query,
startIndex: lastAt,
selectedIndex: prev.query !== query ? 0 : prev.selectedIndex,
}));
},
[mentionState.isOpen],
);
const closeMention = useCallback(() => {
setMentionState({
isOpen: false,
query: "",
startIndex: -1,
selectedIndex: 0,
});
}, []);
/** Move highlight up/down. Returns true if the event was consumed. */
const navigateMention = useCallback(
(direction: "up" | "down"): boolean => {
if (!mentionState.isOpen || filtered.length === 0) return false;
setMentionState((prev) => {
const delta = direction === "down" ? 1 : -1;
const next =
(prev.selectedIndex + delta + filtered.length) % filtered.length;
return { ...prev, selectedIndex: next };
});
return true;
},
[mentionState.isOpen, filtered.length],
);
/** Confirm the currently highlighted item. Returns the persona or null. */
const confirmMention = useCallback((): Persona | null => {
if (!mentionState.isOpen || filtered.length === 0) return null;
return filtered[mentionState.selectedIndex] ?? null;
}, [mentionState.isOpen, mentionState.selectedIndex, filtered]);
return {
mentionOpen: mentionState.isOpen,
mentionQuery: mentionState.query,
mentionStartIndex: mentionState.startIndex,
mentionSelectedIndex: mentionState.selectedIndex,
detectMention,
closeMention,
navigateMention,
confirmMention,
};
}
@@ -117,7 +117,11 @@ export function MessageTimeline({
)}
<MessageBubble
message={message}
agentName={message.role === "assistant" ? agentName : undefined}
agentName={
message.role === "assistant"
? (message.metadata?.personaName ?? agentName)
: undefined
}
agentAvatarUrl={
message.role === "assistant" ? agentAvatarUrl : undefined
}
@@ -0,0 +1,184 @@
import { useMemo } from "react";
import { ChevronDown, Check, Plus, Sparkles, User } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/shared/ui/dropdown-menu";
import type { Persona } from "@/shared/types/agents";
interface PersonaPickerProps {
personas: Persona[];
selectedPersonaId: string | null;
onPersonaChange: (personaId: string) => void;
onCreatePersona?: () => void;
compact?: boolean;
className?: string;
}
export function PersonaPicker({
personas,
selectedPersonaId,
onPersonaChange,
onCreatePersona,
compact = false,
className,
}: PersonaPickerProps) {
const selected = useMemo(
() => personas.find((p) => p.id === selectedPersonaId) ?? personas[0],
[personas, selectedPersonaId],
);
const builtinPersonas = useMemo(
() => personas.filter((p) => p.isBuiltin),
[personas],
);
const customPersonas = useMemo(
() => personas.filter((p) => !p.isBuiltin),
[personas],
);
const label = selected?.displayName ?? "Select persona";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-background-tertiary",
className,
)}
aria-label="Select persona"
>
<PersonaAvatar persona={selected} size="sm" />
{!compact && <span>{label}</span>}
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
{builtinPersonas.length > 0 && (
<>
<DropdownMenuLabel className="text-[11px] uppercase tracking-wider text-foreground-tertiary">
Built-in
</DropdownMenuLabel>
{builtinPersonas.map((persona) => (
<PersonaMenuItem
key={persona.id}
persona={persona}
isSelected={persona.id === selectedPersonaId}
onSelect={() => onPersonaChange(persona.id)}
/>
))}
</>
)}
{customPersonas.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[11px] uppercase tracking-wider text-foreground-tertiary">
Custom
</DropdownMenuLabel>
{customPersonas.map((persona) => (
<PersonaMenuItem
key={persona.id}
persona={persona}
isSelected={persona.id === selectedPersonaId}
onSelect={() => onPersonaChange(persona.id)}
/>
))}
</>
)}
{onCreatePersona && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onCreatePersona}>
<Plus className="mr-2 h-3.5 w-3.5 text-foreground-tertiary" />
<span className="text-sm">Create persona...</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function PersonaMenuItem({
persona,
isSelected,
onSelect,
}: {
persona: Persona;
isSelected: boolean;
onSelect: () => void;
}) {
// Extract a short description from the system prompt (first sentence)
const shortDesc = useMemo(() => {
const first = persona.systemPrompt.split(/\.\s/)[0] ?? "";
return first.length > 60 ? `${first.slice(0, 57)}...` : first;
}, [persona.systemPrompt]);
return (
<DropdownMenuItem
onSelect={onSelect}
className="flex items-start gap-2.5 py-2"
>
<PersonaAvatar persona={persona} size="md" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">{persona.displayName}</span>
{shortDesc && (
<span className="text-[11px] leading-snug text-foreground-tertiary">
{shortDesc}
</span>
)}
</div>
{isSelected && (
<Check className="mt-0.5 h-4 w-4 shrink-0 text-foreground-secondary" />
)}
</DropdownMenuItem>
);
}
function PersonaAvatar({
persona,
size = "sm",
}: {
persona?: Persona;
size?: "sm" | "md";
}) {
const dim = size === "sm" ? "h-4 w-4" : "h-6 w-6";
const iconDim = size === "sm" ? "h-2.5 w-2.5" : "h-3.5 w-3.5";
if (persona?.avatarUrl) {
return (
<img
src={persona.avatarUrl}
alt={persona.displayName}
className={cn(dim, "rounded-full object-cover")}
/>
);
}
const isBuiltin = persona?.isBuiltin ?? true;
return (
<div
className={cn(
dim,
"flex items-center justify-center rounded-full",
isBuiltin
? "bg-foreground/10 text-foreground"
: "bg-accent/10 text-accent",
)}
>
{isBuiltin ? (
<Sparkles className={iconDim} />
) : (
<User className={iconDim} />
)}
</div>
);
}
@@ -11,7 +11,9 @@ describe("ChatInput", () => {
it("renders with default placeholder", () => {
render(<ChatInput onSend={vi.fn()} />);
expect(screen.getByPlaceholderText("Message Goose...")).toBeInTheDocument();
expect(
screen.getByPlaceholderText("Message Goose... (type @ to mention)"),
).toBeInTheDocument();
});
it("calls onSend when Enter is pressed", async () => {
@@ -23,7 +25,7 @@ describe("ChatInput", () => {
await user.type(input, "hello");
await user.keyboard("{Enter}");
expect(onSend).toHaveBeenCalledWith("hello");
expect(onSend).toHaveBeenCalledWith("hello", undefined);
});
it("does not call onSend on Shift+Enter (newline)", async () => {
@@ -39,12 +41,23 @@ describe("ChatInput", () => {
});
it("shows current model name in model picker", () => {
render(<ChatInput onSend={vi.fn()} currentModel="GPT-4o" />);
render(
<ChatInput
onSend={vi.fn()}
currentModel="GPT-4o"
availableModels={[{ id: "gpt-4o", name: "GPT-4o" }]}
/>,
);
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
});
it("shows default model name in model picker", () => {
render(<ChatInput onSend={vi.fn()} />);
render(
<ChatInput
onSend={vi.fn()}
availableModels={[{ id: "claude-sonnet-4", name: "Claude Sonnet 4" }]}
/>,
);
expect(screen.getByText("Claude Sonnet 4")).toBeInTheDocument();
});
@@ -2,12 +2,36 @@ import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { HomeScreen } from "./HomeScreen";
vi.mock("@/shared/api/acp", () => ({
discoverAcpProviders: vi.fn().mockResolvedValue([
{ id: "goose", label: "Goose" },
{ id: "claude", label: "Claude Code" },
]),
}));
// HomeScreen now reads personas from the agent store, not from ACP providers
vi.mock("@/features/agents/stores/agentStore", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/features/agents/stores/agentStore")
>();
return {
...actual,
useAgentStore: Object.assign(
(selector?: (s: unknown) => unknown) => {
const state = {
personas: [
{
id: "builtin-goose",
displayName: "Goose",
systemPrompt: "You are Goose.",
isBuiltin: true,
createdAt: "",
updatedAt: "",
},
],
personasLoading: false,
openPersonaEditor: vi.fn(),
};
return selector ? selector(state) : state;
},
{ getState: () => ({ openPersonaEditor: vi.fn() }) },
),
};
});
describe("HomeScreen", () => {
beforeEach(() => {
@@ -26,17 +50,15 @@ describe("HomeScreen", () => {
expect(screen.getByText("Good afternoon")).toBeInTheDocument();
});
it("renders the chat input placeholder", async () => {
it("renders the chat input placeholder with persona name", () => {
render(<HomeScreen />);
await vi.advanceTimersByTimeAsync(0);
expect(
screen.getByPlaceholderText("Ask Goose anything..."),
screen.getByPlaceholderText("Ask Goose anything... (type @ to mention)"),
).toBeInTheDocument();
});
it("renders the provider selector with default provider", async () => {
it("renders the persona picker with default persona", () => {
render(<HomeScreen />);
await vi.advanceTimersByTimeAsync(0);
expect(screen.getByText("Goose")).toBeInTheDocument();
});
});
+142 -91
View File
@@ -1,15 +1,13 @@
import { useState, useEffect, useRef } from "react";
import { ArrowUp, Paperclip, Bot, ChevronDown, Check } from "lucide-react";
import { useState, useEffect, useRef, useCallback } from "react";
import { ArrowUp, Paperclip, X } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { discoverAcpProviders, type AcpProvider } from "@/shared/api/acp";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { PersonaPicker } from "@/features/chat/ui/PersonaPicker";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/shared/ui/dropdown-menu";
MentionAutocomplete,
useMentionDetection,
} from "@/features/chat/ui/MentionAutocomplete";
import type { Persona } from "@/shared/types/agents";
function HomeClock() {
const [time, setTime] = useState(new Date());
@@ -44,99 +42,160 @@ function getGreeting(hour: number): string {
}
interface HomeInputProps {
onStartChat?: (msg?: string, providerId?: string) => void;
providers: AcpProvider[];
selectedProvider: string;
onProviderChange: (id: string) => void;
onStartChat?: (msg?: string, providerId?: string, personaId?: string) => void;
personas: Persona[];
selectedPersonaId: string;
onPersonaChange: (id: string) => void;
onCreatePersona?: () => void;
}
function HomeInput({
onStartChat,
providers,
selectedProvider,
onProviderChange,
personas,
selectedPersonaId,
onPersonaChange,
onCreatePersona,
}: HomeInputProps) {
const [value, setValue] = useState("");
const [mentionPersonaId, setMentionPersonaId] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const hasContent = value.trim().length > 0;
const {
mentionOpen,
mentionQuery,
mentionStartIndex,
mentionSelectedIndex,
detectMention,
closeMention,
navigateMention,
confirmMention,
} = useMentionDetection(personas);
const providerLabel =
providers.find((p) => p.id === selectedProvider)?.label ?? selectedProvider;
const hasContent = value.trim().length > 0;
const effectivePersonaId = mentionPersonaId ?? selectedPersonaId;
const selectedPersona = personas.find((p) => p.id === effectivePersonaId);
const personaName = selectedPersona?.displayName ?? "Goose";
const handleSubmit = () => {
const trimmed = value.trim();
if (!trimmed) return;
onStartChat?.(trimmed, selectedProvider);
onStartChat?.(
trimmed,
selectedPersona?.provider ?? "goose",
effectivePersonaId,
);
setValue("");
setMentionPersonaId(null);
};
const handleMentionSelect = useCallback(
(persona: Persona) => {
const before = value.slice(0, mentionStartIndex);
const after = value.slice(mentionStartIndex + 1 + mentionQuery.length);
const newText = `${before}@${persona.displayName} ${after}`;
setValue(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);
}
});
},
[value, 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();
handleSubmit();
}
};
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setValue(val);
const cursorPos = e.target.selectionStart ?? val.length;
detectMention(val, cursorPos);
};
return (
<div className="px-4 pb-6 pt-2">
<div className="relative max-w-3xl mx-auto rounded-2xl px-4 pt-4 pb-3 bg-background-secondary border border-border shadow-lg">
<div className="relative mx-auto max-w-3xl rounded-2xl border border-border bg-background-secondary px-4 pb-3 pt-4 shadow-lg">
<MentionAutocomplete
personas={personas}
query={mentionQuery}
isOpen={mentionOpen}
onSelect={handleMentionSelect}
onClose={closeMention}
selectedIndex={mentionSelectedIndex}
/>
{mentionPersonaId && (
<div className="mb-2 flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 rounded-full bg-accent/10 px-2.5 py-0.5 text-[11px] font-medium text-accent">
@{personas.find((p) => p.id === mentionPersonaId)?.displayName}
<button
type="button"
className="ml-0.5 inline-flex items-center opacity-60 hover:opacity-100"
onClick={() => setMentionPersonaId(null)}
aria-label="Remove mention"
>
<X className="h-3 w-3" />
</button>
</span>
</div>
)}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={`Ask ${providerLabel} anything...`}
placeholder={`Ask ${personaName} anything... (type @ to mention)`}
rows={1}
className="w-full resize-none bg-transparent text-[14px] leading-relaxed px-1 placeholder:text-muted-foreground/60 focus:outline-none min-h-[36px] max-h-[200px] mb-3"
className="mb-3 min-h-[36px] max-h-[200px] w-full resize-none bg-transparent px-1 text-[14px] leading-relaxed placeholder:text-foreground-tertiary/60 focus:outline-none"
/>
{/* Bottom bar */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-background-tertiary"
aria-label="Select provider"
>
<Bot className="h-3 w-3" />
<span>{providerLabel}</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Provider</DropdownMenuLabel>
<DropdownMenuSeparator />
{providers.length > 0 ? (
providers.map((provider) => (
<DropdownMenuItem
key={provider.id}
onSelect={() => onProviderChange(provider.id)}
className="flex items-center justify-between"
>
<span className="text-sm font-medium">
{provider.label}
</span>
{provider.id === selectedProvider && (
<Check className="h-4 w-4 shrink-0 text-foreground-secondary" />
)}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>
<span className="text-xs text-foreground-tertiary">
No providers detected
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<PersonaPicker
personas={personas}
selectedPersonaId={selectedPersonaId}
onPersonaChange={onPersonaChange}
onCreatePersona={onCreatePersona}
className="rounded-md border border-border px-2 py-0.5"
/>
</div>
<div className="flex items-center gap-1">
<button
type="button"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
className="rounded-lg p-2 text-foreground-tertiary transition-colors hover:bg-background-tertiary hover:text-foreground"
>
<Paperclip className="h-4 w-4" />
</button>
@@ -145,10 +204,10 @@ function HomeInput({
onClick={handleSubmit}
disabled={!hasContent}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center",
"flex h-8 w-8 items-center justify-center rounded-full",
hasContent
? "bg-foreground text-background hover:opacity-90"
: "bg-foreground/10 text-muted-foreground cursor-default",
? "bg-foreground text-background-primary hover:opacity-90"
: "cursor-default bg-foreground/10 text-foreground-tertiary",
)}
>
<ArrowUp className="h-4 w-4" />
@@ -156,39 +215,30 @@ function HomeInput({
</div>
</div>
</div>
<p className="text-[10px] text-muted-foreground/40 text-center mt-2">
⏎ to send · ⇧⏎ for newline
<p className="mt-2 text-center text-[10px] text-foreground-tertiary/40">
⏎ to send · ⇧⏎ for newline · @ to mention a persona
</p>
</div>
);
}
interface HomeScreenProps {
onStartChat?: (initialMessage?: string, providerId?: string) => void;
onStartChat?: (
initialMessage?: string,
providerId?: string,
personaId?: string,
) => void;
}
export function HomeScreen({ onStartChat }: HomeScreenProps) {
const [hour] = useState(() => new Date().getHours());
const greeting = getGreeting(hour);
const [providers, setProviders] = useState<AcpProvider[]>([]);
const [selectedProvider, setSelectedProvider] = useState("goose");
const personas = useAgentStore((s) => s.personas);
const [selectedPersonaId, setSelectedPersonaId] = useState("builtin-goose");
useEffect(() => {
discoverAcpProviders()
.then((discovered) => {
setProviders(discovered);
setSelectedProvider((current) => {
if (
discovered.length > 0 &&
!discovered.some((p) => p.id === current)
) {
return discovered[0].id;
}
return current;
});
})
.catch(() => setProviders([]));
const handleCreatePersona = useCallback(() => {
useAgentStore.getState().openPersonaEditor();
}, []);
return (
@@ -206,9 +256,10 @@ export function HomeScreen({ onStartChat }: HomeScreenProps) {
{/* Chat input */}
<HomeInput
onStartChat={onStartChat}
providers={providers}
selectedProvider={selectedProvider}
onProviderChange={setSelectedProvider}
personas={personas}
selectedPersonaId={selectedPersonaId}
onPersonaChange={setSelectedPersonaId}
onCreatePersona={handleCreatePersona}
/>
</div>
</div>
+7
View File
@@ -15,10 +15,17 @@ export async function acpSendMessage(
sessionId: string,
providerId: string,
prompt: string,
systemPrompt?: string,
): Promise<void> {
return invoke("acp_send_message", {
sessionId,
providerId,
prompt,
systemPrompt: systemPrompt ?? null,
});
}
/** Cancel an in-progress ACP session so the backend stops streaming. */
export async function acpCancelSession(sessionId: string): Promise<boolean> {
return invoke("acp_cancel_session", { sessionId });
}
+7 -1
View File
@@ -33,13 +33,19 @@ export async function deleteSession(sessionId: string): Promise<void> {
export async function saveUiState(
openTabIds: string[],
activeTabId: string | null,
personaPerSession: Record<string, string>,
): Promise<void> {
return invoke("save_ui_state", { openTabIds, activeTabId });
return invoke("save_ui_state", {
openTabIds,
activeTabId,
personaPerSession,
});
}
export async function loadUiState(): Promise<{
openTabIds: string[];
activeTabId: string | null;
personaPerSession: Record<string, string>;
}> {
return invoke("load_ui_state");
}
+3
View File
@@ -95,6 +95,9 @@ export interface MessageMetadata {
agentVisible?: boolean;
attachments?: MessageAttachment[];
chips?: MessageChip[];
/** Persona that generated this assistant message (set on send). */
personaId?: string;
personaName?: string;
}
export interface Message {