mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
@@ -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>>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user