diff --git a/ui/goose2/src-tauri/src/commands/sessions.rs b/ui/goose2/src-tauri/src/commands/sessions.rs index 1980e2b21e..3e14a82243 100644 --- a/ui/goose2/src-tauri/src/commands/sessions.rs +++ b/ui/goose2/src-tauri/src/commands/sessions.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use crate::services::sessions::SessionStore; -use crate::types::agents::Session; +use crate::types::agents::{Session, SessionUpdate}; use crate::types::messages::Message; use tauri::State; @@ -27,6 +27,15 @@ pub fn get_session_messages( store.get_messages(&session_id) } +#[tauri::command] +pub fn update_session( + store: State<'_, Arc>, + session_id: String, + update: SessionUpdate, +) -> Result<(), String> { + store.update_session(&session_id, update) +} + #[tauri::command] pub fn delete_session( store: State<'_, Arc>, diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index 1d8697b387..c28593d344 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -46,6 +46,7 @@ pub fn run() { commands::sessions::create_session, commands::sessions::list_sessions, commands::sessions::get_session_messages, + commands::sessions::update_session, commands::sessions::delete_session, commands::ui_state::save_ui_state, commands::ui_state::load_ui_state, diff --git a/ui/goose2/src-tauri/src/services/acp.rs b/ui/goose2/src-tauri/src/services/acp.rs index d93fb589bf..3b95704bad 100644 --- a/ui/goose2/src-tauri/src/services/acp.rs +++ b/ui/goose2/src-tauri/src/services/acp.rs @@ -7,7 +7,9 @@ use serde::Serialize; use tauri::Emitter; use tokio_util::sync::CancellationToken; -use acp_client::{AcpDriver, AgentDriver, MessageWriter, Store}; +use acp_client::{ + AcpDriver, AgentDriver, MessageWriter, SessionInfoUpdate, SessionModelState, Store, +}; use crate::services::sessions::SessionStore; use crate::types::messages::{MessageContent, MessageRole}; @@ -57,6 +59,21 @@ struct ToolResultPayload { content: String, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct SessionInfoPayload { + session_id: String, + title: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ModelStatePayload { + session_id: String, + current_model_id: String, + current_model_name: Option, +} + // --------------------------------------------------------------------------- // TauriMessageWriter // --------------------------------------------------------------------------- @@ -169,6 +186,32 @@ impl MessageWriter for TauriMessageWriter { }, ); } + + async fn on_session_info_update(&self, info: &SessionInfoUpdate) { + let _ = self.app_handle.emit( + "acp:session_info", + SessionInfoPayload { + session_id: self.session_id.clone(), + title: info.title.value().cloned(), + }, + ); + } + + async fn on_model_state_update(&self, state: &SessionModelState) { + let current_model_name = state + .available_models + .iter() + .find(|m| m.model_id == state.current_model_id) + .map(|m| m.name.clone()); + let _ = self.app_handle.emit( + "acp:model_state", + ModelStatePayload { + session_id: self.session_id.clone(), + current_model_id: state.current_model_id.to_string(), + current_model_name, + }, + ); + } } // --------------------------------------------------------------------------- diff --git a/ui/goose2/src-tauri/src/services/sessions.rs b/ui/goose2/src-tauri/src/services/sessions.rs index abb974529c..54b3493c91 100644 --- a/ui/goose2/src-tauri/src/services/sessions.rs +++ b/ui/goose2/src-tauri/src/services/sessions.rs @@ -1,4 +1,4 @@ -use crate::types::agents::Session; +use crate::types::agents::{Session, SessionUpdate}; use crate::types::messages::Message; use std::collections::HashMap; use std::path::PathBuf; @@ -93,6 +93,9 @@ impl SessionStore { title: "New Chat".to_string(), agent_id, project_id, + provider_id: None, + persona_id: None, + model_name: None, created_at: now.clone(), updated_at: now, message_count: 0, @@ -129,6 +132,9 @@ impl SessionStore { title: "New Chat".to_string(), agent_id, project_id: None, + provider_id: None, + persona_id: None, + model_name: None, created_at: now.clone(), updated_at: now, message_count: 0, @@ -190,14 +196,24 @@ impl SessionStore { self.load_messages(session_id) } - #[allow(dead_code)] - pub fn update_session_title(&self, id: &str, title: &str) -> Result<(), String> { + pub fn update_session(&self, id: &str, update: SessionUpdate) -> Result<(), String> { let mut sessions = self.sessions.lock().unwrap(); let session = sessions .get_mut(id) .ok_or_else(|| format!("Session '{}' not found", id))?; - session.title = title.to_string(); + if let Some(title) = update.title { + session.title = title; + } + if let Some(provider_id) = update.provider_id { + session.provider_id = Some(provider_id); + } + if let Some(persona_id) = update.persona_id { + session.persona_id = Some(persona_id); + } + if let Some(model_name) = update.model_name { + session.model_name = Some(model_name); + } session.updated_at = chrono::Utc::now().to_rfc3339(); self.save_metadata(&sessions); Ok(()) diff --git a/ui/goose2/src-tauri/src/types/agents.rs b/ui/goose2/src-tauri/src/types/agents.rs index ef7eeae2ef..6e5030aad5 100644 --- a/ui/goose2/src-tauri/src/types/agents.rs +++ b/ui/goose2/src-tauri/src/types/agents.rs @@ -77,6 +77,12 @@ pub struct Session { pub agent_id: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub project_id: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub provider_id: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub persona_id: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub model_name: Option, pub created_at: String, pub updated_at: String, pub message_count: u32, @@ -84,6 +90,16 @@ pub struct Session { pub last_message_preview: Option, } +/// Partial update for a session — only provided fields are applied. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionUpdate { + pub title: Option, + pub provider_id: Option, + pub persona_id: Option, + pub model_name: Option, +} + /// Built-in persona definitions pub fn builtin_personas() -> Vec { let now = chrono::Utc::now().to_rfc3339(); diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index fd2362c174..f81de46ff9 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -127,8 +127,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const activeTab = tabs.find((t) => t.id === activeTabId) ?? null; // Derive status bar values from stores - const activeAgent = agentStore.getActiveAgent(); - const modelName = activeAgent?.model ?? "Claude Sonnet 4"; + const activeSession = activeTabId + ? sessionStore.getSession(activeTabId) + : undefined; + const modelName = activeSession?.modelName; const tokenCount = chatStore.tokenState.totalTokens; const connectionStatus = chatStore.isConnected ? ("connected" as const) @@ -396,6 +398,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { > diff --git a/ui/goose2/src/features/chat/hooks/useAcpStream.ts b/ui/goose2/src/features/chat/hooks/useAcpStream.ts index f8808a9db7..f900d59132 100644 --- a/ui/goose2/src/features/chat/hooks/useAcpStream.ts +++ b/ui/goose2/src/features/chat/hooks/useAcpStream.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { useChatStore } from "../stores/chatStore"; +import { useChatSessionStore } from "../stores/chatSessionStore"; import type { ToolRequestContent, ToolResponseContent, @@ -34,6 +35,17 @@ interface AcpToolResultPayload { content: string; } +interface AcpSessionInfoPayload { + sessionId: string; + title?: string; +} + +interface AcpModelStatePayload { + sessionId: string; + currentModelId: string; + currentModelName?: string; +} + /** * Hook that listens to Tauri events for ACP streaming responses. * @@ -52,6 +64,10 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { useEffect(() => { if (!enabled || !sessionId) return; + // Guard against duplicate listeners from React StrictMode double-mounting. + // Each listener checks this flag before acting; cleanup sets it immediately. + let active = true; + // Reset accumulated text when the session changes or streaming restarts. accumulatedTextRef.current = ""; @@ -60,6 +76,7 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { // acp:text — accumulate and update the full text in the streaming message unlisteners.push( listen("acp:text", (event) => { + if (!active) return; if (event.payload.sessionId !== sessionIdRef.current) return; accumulatedTextRef.current += event.payload.text; useChatStore @@ -74,17 +91,37 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { // acp:done — finalize the message, set chat state to idle unlisteners.push( listen("acp:done", (event) => { + if (!active) return; if (event.payload.sessionId !== sessionIdRef.current) return; accumulatedTextRef.current = ""; const store = useChatStore.getState(); store.setStreamingMessageId(null); store.setChatState("idle"); + + // Generate a title from the first user message if the session still + // has the default "New Chat" title (i.e. no ACP title was received). + const sessionStore = useChatSessionStore.getState(); + const session = sessionStore.getSession(event.payload.sessionId); + if (session && session.title === "New Chat") { + const messages = store.messagesBySession[event.payload.sessionId]; + const firstUserMsg = messages?.find((m) => m.role === "user"); + if (firstUserMsg) { + const textContent = firstUserMsg.content.find( + (c) => c.type === "text" && "text" in c, + ); + if (textContent && "text" in textContent) { + const title = textContent.text.slice(0, 40); + sessionStore.updateSession(event.payload.sessionId, { title }); + } + } + } }), ); // acp:tool_call — add a tool request to the streaming message unlisteners.push( listen("acp:tool_call", (event) => { + if (!active) return; if (event.payload.sessionId !== sessionIdRef.current) return; const toolRequest: ToolRequestContent = { type: "toolRequest", @@ -102,6 +139,7 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { // acp:tool_title — update a tool call's title unlisteners.push( listen("acp:tool_title", (event) => { + if (!active) return; if (event.payload.sessionId !== sessionIdRef.current) return; const { sessionId: sid, toolCallId, title } = event.payload; useChatStore.getState().updateMessage(sid, toolCallId, (msg) => ({ @@ -118,6 +156,7 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { // acp:tool_result — add a tool response unlisteners.push( listen("acp:tool_result", (event) => { + if (!active) return; if (event.payload.sessionId !== sessionIdRef.current) return; const toolResponse: ToolResponseContent = { type: "toolResponse", @@ -132,8 +171,38 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { }), ); - // Cleanup: unlisten from all events on unmount or when deps change + // acp:session_info — update session title from ACP provider + unlisteners.push( + listen("acp:session_info", (event) => { + if (!active) return; + if (event.payload.sessionId !== sessionIdRef.current) return; + if (event.payload.title) { + useChatSessionStore + .getState() + .updateSession(event.payload.sessionId, { + title: event.payload.title, + }); + } + }), + ); + + // acp:model_state — update model name from ACP provider + unlisteners.push( + listen("acp:model_state", (event) => { + if (!active) return; + if (event.payload.sessionId !== sessionIdRef.current) return; + const modelName = + event.payload.currentModelName ?? event.payload.currentModelId; + useChatSessionStore + .getState() + .updateSession(event.payload.sessionId, { modelName }); + }), + ); + + // Cleanup: mark inactive immediately to prevent stale listeners from + // firing during the async unlisten, then tear down actual subscriptions. return () => { + active = false; for (const unlistenPromise of unlisteners) { unlistenPromise.then((unlisten) => unlisten()); } diff --git a/ui/goose2/src/features/chat/stores/chatSessionStore.ts b/ui/goose2/src/features/chat/stores/chatSessionStore.ts index 2fa823cb3a..354e3754ce 100644 --- a/ui/goose2/src/features/chat/stores/chatSessionStore.ts +++ b/ui/goose2/src/features/chat/stores/chatSessionStore.ts @@ -3,6 +3,7 @@ import { createSession as apiCreateSession, listSessions as apiListSessions, deleteSession as apiDeleteSession, + updateSession as apiUpdateSession, saveUiState as apiSaveUiState, loadUiState as apiLoadUiState, } from "@/shared/api/chat"; @@ -16,6 +17,7 @@ export interface ChatSession { agentId?: string; providerId?: string; personaId?: string; + modelName?: string; createdAt: string; // ISO timestamp updatedAt: string; } @@ -63,6 +65,9 @@ function sessionToChatSession(session: Session): ChatSession { title: session.title, agentId: session.agentId, projectId: session.projectId, + providerId: session.providerId, + personaId: session.personaId, + modelName: session.modelName, createdAt: session.createdAt, updatedAt: session.updatedAt, }; @@ -92,6 +97,16 @@ export const useChatSessionStore = create((set, get) => ({ createdAt: backendSession.createdAt ?? now, updatedAt: backendSession.updatedAt ?? now, }; + // Persist initial metadata (title, persona, provider) to backend + const initialUpdate: Record = {}; + if (opts?.title) initialUpdate.title = opts.title; + if (opts?.providerId) initialUpdate.providerId = opts.providerId; + if (opts?.personaId) initialUpdate.personaId = opts.personaId; + if (Object.keys(initialUpdate).length > 0) { + apiUpdateSession(backendSession.id, initialUpdate).catch((err) => { + console.error("Failed to persist initial session metadata:", err); + }); + } set((state) => ({ sessions: [...state.sessions, chatSession], })); @@ -109,14 +124,26 @@ export const useChatSessionStore = create((set, get) => ({ } }, - updateSession: (id, patch) => + updateSession: (id, patch) => { set((state) => ({ sessions: state.sessions.map((s) => s.id === id ? { ...s, ...patch, updatedAt: new Date().toISOString() } : s, ), - })), + })); + // Persist persistable fields to the backend + const backendPatch: Record = {}; + if (patch.title) backendPatch.title = patch.title; + if (patch.providerId) backendPatch.providerId = patch.providerId; + if (patch.personaId) backendPatch.personaId = patch.personaId; + if (patch.modelName) backendPatch.modelName = patch.modelName; + if (Object.keys(backendPatch).length > 0) { + apiUpdateSession(id, backendPatch).catch((err) => { + console.error("Failed to persist session update:", err); + }); + } + }, archiveSession: async (id) => { // Remove from open tabs immediately for responsive UI @@ -191,21 +218,17 @@ export const useChatSessionStore = create((set, get) => ({ // Persistence persistTabState: () => { - const { openTabIds, activeTabId, sessions } = get(); - // Build persona mapping from sessions - const personaPerSession: Record = {}; - for (const s of sessions) { - if (s.personaId) personaPerSession[s.id] = s.personaId; - } - apiSaveUiState(openTabIds, activeTabId, personaPerSession).catch((err) => { + const { openTabIds, activeTabId } = get(); + // personaPerSession kept for backward compat with ui_state.json but + // persona is now persisted on the session itself via update_session. + apiSaveUiState(openTabIds, activeTabId, {}).catch((err) => { console.error("Failed to persist tab state:", err); }); }, loadTabState: async () => { try { - const { openTabIds, activeTabId, personaPerSession } = - await apiLoadUiState(); + const { openTabIds, activeTabId } = await apiLoadUiState(); const { sessions } = get(); const sessionIds = new Set(sessions.map((s) => s.id)); @@ -216,14 +239,7 @@ export const useChatSessionStore = create((set, get) => ({ ? activeTabId : (validTabIds[0] ?? null); - // 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, }); diff --git a/ui/goose2/src/features/status/ui/StatusBar.tsx b/ui/goose2/src/features/status/ui/StatusBar.tsx index e07f3fe4fd..93ff024b42 100644 --- a/ui/goose2/src/features/status/ui/StatusBar.tsx +++ b/ui/goose2/src/features/status/ui/StatusBar.tsx @@ -1,7 +1,10 @@ +import { useState } from "react"; +import { Copy, Check } from "lucide-react"; import { cn } from "@/shared/lib/cn"; interface StatusBarProps { modelName?: string; + sessionId?: string; tokenCount?: number; status?: "connected" | "disconnected" | "loading"; } @@ -13,10 +16,20 @@ const statusColor = { } as const; export function StatusBar({ - modelName = "Claude Sonnet 4", + modelName, + sessionId, tokenCount = 0, status = "disconnected", }: StatusBarProps) { + const [copied, setCopied] = useState(false); + + const handleCopySessionId = () => { + if (!sessionId) return; + navigator.clipboard.writeText(sessionId); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + return (
- {modelName} + {modelName ? {modelName} : }
- {tokenCount.toLocaleString()} tokens + {sessionId && ( +
+ {sessionId} + +
+ )} + {tokenCount > 0 && {tokenCount.toLocaleString()} tokens}
{ + return invoke("update_session", { sessionId, update }); +} + export async function deleteSession(sessionId: string): Promise { return invoke("delete_session", { sessionId }); } diff --git a/ui/goose2/src/shared/types/chat.ts b/ui/goose2/src/shared/types/chat.ts index 1706d305d6..b39f3c1e9b 100644 --- a/ui/goose2/src/shared/types/chat.ts +++ b/ui/goose2/src/shared/types/chat.ts @@ -35,6 +35,9 @@ export interface Session { title: string; agentId?: string; projectId?: string; + providerId?: string; + personaId?: string; + modelName?: string; createdAt: string; updatedAt: string; messageCount: number; diff --git a/ui/goose2/tests/e2e/smoke.spec.ts b/ui/goose2/tests/e2e/smoke.spec.ts index eac46caea7..0b29668031 100644 --- a/ui/goose2/tests/e2e/smoke.spec.ts +++ b/ui/goose2/tests/e2e/smoke.spec.ts @@ -24,12 +24,4 @@ test.describe("Smoke tests", () => { timeout: 10_000, }); }); - - test("home screen shows model badge", async ({ page }) => { - await page.goto("/"); - - await expect(page.getByText("Claude Sonnet 4").first()).toBeVisible({ - timeout: 10_000, - }); - }); });