mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
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:
@@ -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())?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user