From f29cebb50519612e6aef99069f192c7efe237025 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 31 Mar 2026 10:42:09 -0700 Subject: [PATCH] feat: wire up ACP session metadata and persist across restarts (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: surface ACP session title, model state, and session ID in UI Wire up new acp-client MessageWriter callbacks (on_session_info_update, on_model_state_update) to Tauri events and frontend listeners. Session titles and model names from ACP providers now update the sidebar, tabs, and status bar reactively. Fallback title generation from the first user message when the provider doesn't send SessionInfoUpdate. Add session ID display with copy button to the status bar. Fix doubled streaming text caused by React StrictMode double-mounting with async listener cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: persist session metadata to backend across app restarts Extend the backend Session struct with provider_id, persona_id, and model_name fields. Add a generic update_session Tauri command so the frontend can write-through any metadata change to disk. The frontend chatSessionStore now persists title, provider, persona, and model on every updateSession call and restores them from the backend on load. Remove the persona-per-session hack from ui_state — persona now lives on the session itself. Hide token count in status bar when zero. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update e2e test for removed model badge default The home screen no longer shows a hardcoded "Claude Sonnet 4" text since model info now comes from ACP. Update the smoke test to check for the persona picker ("Goose") which is always visible on the home screen. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove stale model badge e2e test The model badge test matched a hidden SVG Goose element instead of visible UI. The underlying feature (static model name on home screen) no longer exists — model info is now dynamic from ACP. The chat input placeholder is already covered by an existing test. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ui/goose2/src-tauri/src/commands/sessions.rs | 11 ++- ui/goose2/src-tauri/src/lib.rs | 1 + ui/goose2/src-tauri/src/services/acp.rs | 45 +++++++++++- ui/goose2/src-tauri/src/services/sessions.rs | 24 +++++-- ui/goose2/src-tauri/src/types/agents.rs | 16 +++++ ui/goose2/src/app/AppShell.tsx | 7 +- .../src/features/chat/hooks/useAcpStream.ts | 71 ++++++++++++++++++- .../features/chat/stores/chatSessionStore.ts | 52 +++++++++----- .../src/features/status/ui/StatusBar.tsx | 32 ++++++++- ui/goose2/src/shared/api/chat.ts | 12 ++++ ui/goose2/src/shared/types/chat.ts | 3 + ui/goose2/tests/e2e/smoke.spec.ts | 8 --- 12 files changed, 244 insertions(+), 38 deletions(-) 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, - }); - }); });