feat: wire up ACP session metadata and persist across restarts (#25)

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

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

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

* fix: remove stale model badge e2e test

The model badge test matched a hidden SVG <title>Goose</title> 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-03-31 10:42:09 -07:00
committed by GitHub
parent f5c8ba168f
commit f29cebb505
12 changed files with 244 additions and 38 deletions
+10 -1
View File
@@ -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<SessionStore>>,
session_id: String,
update: SessionUpdate,
) -> Result<(), String> {
store.update_session(&session_id, update)
}
#[tauri::command]
pub fn delete_session(
store: State<'_, Arc<SessionStore>>,
+1
View File
@@ -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,
+44 -1
View File
@@ -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<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ModelStatePayload {
session_id: String,
current_model_id: String,
current_model_name: Option<String>,
}
// ---------------------------------------------------------------------------
// 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,
},
);
}
}
// ---------------------------------------------------------------------------
+20 -4
View File
@@ -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(())
+16
View File
@@ -77,6 +77,12 @@ pub struct Session {
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub provider_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub persona_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub model_name: Option<String>,
pub created_at: String,
pub updated_at: String,
pub message_count: u32,
@@ -84,6 +90,16 @@ pub struct Session {
pub last_message_preview: Option<String>,
}
/// Partial update for a session — only provided fields are applied.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionUpdate {
pub title: Option<String>,
pub provider_id: Option<String>,
pub persona_id: Option<String>,
pub model_name: Option<String>,
}
/// Built-in persona definitions
pub fn builtin_personas() -> Vec<Persona> {
let now = chrono::Utc::now().to_rfc3339();
+5 -2
View File
@@ -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 }) {
>
<StatusBar
modelName={modelName}
sessionId={activeTabId ?? undefined}
tokenCount={tokenCount}
status={connectionStatus}
/>
@@ -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<AcpTextPayload>("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<AcpDonePayload>("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<AcpToolCallPayload>("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<AcpToolTitlePayload>("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<AcpToolResultPayload>("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<AcpSessionInfoPayload>("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<AcpModelStatePayload>("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());
}
@@ -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<ChatSessionStore>((set, get) => ({
createdAt: backendSession.createdAt ?? now,
updatedAt: backendSession.updatedAt ?? now,
};
// Persist initial metadata (title, persona, provider) to backend
const initialUpdate: Record<string, string> = {};
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<ChatSessionStore>((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<string, string> = {};
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<ChatSessionStore>((set, get) => ({
// Persistence
persistTabState: () => {
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) => {
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<ChatSessionStore>((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,
});
+29 -3
View File
@@ -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 (
<div
className={cn(
@@ -24,10 +37,23 @@ export function StatusBar({
"bg-background/80 px-3 text-xs text-foreground-secondary",
)}
>
<span>{modelName}</span>
{modelName ? <span>{modelName}</span> : <span />}
<div className="flex items-center gap-2">
<span>{tokenCount.toLocaleString()} tokens</span>
{sessionId && (
<div className="flex items-center gap-0.5">
<span className="text-foreground-tertiary">{sessionId}</span>
<button
type="button"
onClick={handleCopySessionId}
className="rounded p-0.5 text-foreground-tertiary hover:text-foreground-primary transition-colors"
aria-label={copied ? "Copied" : "Copy session ID"}
>
{copied ? <Check size={10} /> : <Copy size={10} />}
</button>
</div>
)}
{tokenCount > 0 && <span>{tokenCount.toLocaleString()} tokens</span>}
<div
role="status"
aria-label={status}
+12
View File
@@ -26,6 +26,18 @@ export async function sendMessage(
return invoke("chat_send_message", { sessionId, message });
}
export async function updateSession(
sessionId: string,
update: {
title?: string;
providerId?: string;
personaId?: string;
modelName?: string;
},
): Promise<void> {
return invoke("update_session", { sessionId, update });
}
export async function deleteSession(sessionId: string): Promise<void> {
return invoke("delete_session", { sessionId });
}
+3
View File
@@ -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;
-8
View File
@@ -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,
});
});
});