From 7130cbf33eab07f24229f19d9d2f2d0c0ef8cc80 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Thu, 18 Jun 2026 21:13:35 +1000 Subject: [PATCH] feat (ui): chat reply UI with ACP (last part) (#9857) --- crates/goose/src/acp/server.rs | 11 ++ .../src/acp/server/tool_notifications.rs | 138 ++++++++++++++++++ .../acp/__tests__/chatNotifications.test.ts | 134 +++++++++++++++++ .../acp/__tests__/chatSessionStore.test.ts | 47 +++++- .../acp/__tests__/elicitationRequests.test.ts | 5 + .../sessionNotificationAdapter.test.ts | 94 ++++++++++++ ui/desktop/src/acp/adapter/shared.ts | 4 +- .../src/acp/adapter/toolNotifications.ts | 110 ++++++++++++++ ui/desktop/src/acp/adapter/tools.ts | 4 +- ui/desktop/src/acp/chatNotifications.ts | 16 ++ ui/desktop/src/acp/chatSessionStore.ts | 8 + ui/desktop/src/acp/elicitationRequests.ts | 5 + .../src/components/Layout/NavigationPanel.tsx | 22 +-- ui/desktop/src/hooks/useAcpChatSession.ts | 132 ++++++----------- 14 files changed, 623 insertions(+), 107 deletions(-) create mode 100644 crates/goose/src/acp/server/tool_notifications.rs create mode 100644 ui/desktop/src/acp/__tests__/chatNotifications.test.ts create mode 100644 ui/desktop/src/acp/adapter/toolNotifications.ts diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index ef2e28c9c..f65d833fc 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -94,6 +94,7 @@ mod onboarding; mod providers; mod resources; mod sources; +mod tool_notifications; mod tools; pub type AcpProviderFactory = Arc< @@ -2539,6 +2540,16 @@ impl GooseAcpAgent { break; } } + Ok(crate::agents::AgentEvent::McpNotification((request_id, notification))) => { + if let Some(update) = + tool_notifications::tool_notification_update(request_id, notification) + { + cx.send_notification(SessionNotification::new( + args.session_id.clone(), + update, + ))?; + } + } Ok(_) => {} Err(e) => { stream_error = Some( diff --git a/crates/goose/src/acp/server/tool_notifications.rs b/crates/goose/src/acp/server/tool_notifications.rs new file mode 100644 index 000000000..412dd16c3 --- /dev/null +++ b/crates/goose/src/acp/server/tool_notifications.rs @@ -0,0 +1,138 @@ +use agent_client_protocol::schema::{ + Meta, SessionUpdate, ToolCallId, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, +}; +use rmcp::model::{LoggingMessageNotificationParam, ProgressNotificationParam, ServerNotification}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ToolNotification { + Message { + params: LoggingMessageNotificationParam, + }, + Progress { + params: ProgressNotificationParam, + }, +} + +pub(super) fn tool_notification_update( + tool_call_id: impl Into, + notification: ServerNotification, +) -> Option { + let tool_notification = match notification { + ServerNotification::LoggingMessageNotification(notification) => ToolNotification::Message { + params: notification.params, + }, + ServerNotification::ProgressNotification(notification) => ToolNotification::Progress { + params: notification.params, + }, + _ => return None, + }; + + let mut meta = Meta::new(); + meta.insert( + "toolNotification".to_string(), + serde_json::to_value(tool_notification).ok()?, + ); + + Some(SessionUpdate::ToolCallUpdate( + ToolCallUpdate::new( + tool_call_id, + ToolCallUpdateFields::new().status(ToolCallStatus::InProgress), + ) + .meta(meta), + )) +} + +#[cfg(test)] +mod tests { + use super::tool_notification_update; + use rmcp::model::{ + CancelledNotificationParam, LoggingLevel, LoggingMessageNotificationParam, Notification, + NumberOrString, ProgressNotificationParam, ProgressToken, ServerNotification, + }; + use serde_json::json; + use std::sync::Arc; + + #[test] + fn maps_logging_message_notification_to_tool_update_meta() { + let notification = ServerNotification::LoggingMessageNotification(Notification::new( + LoggingMessageNotificationParam::new( + LoggingLevel::Info, + json!({ + "type": "subagent_tool_request", + "subagent_id": "session_1", + "tool_call": { + "name": "developer__shell" + } + }), + ) + .with_logger("subagent:session_1"), + )); + + let update = tool_notification_update("tool_1", notification).expect("expected update"); + let value = serde_json::to_value(update).expect("update should serialize"); + + assert_eq!(value["sessionUpdate"], "tool_call_update"); + assert_eq!(value["toolCallId"], "tool_1"); + assert_eq!(value["status"], "in_progress"); + assert_eq!(value["_meta"]["toolNotification"]["type"], "message"); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["level"], + "info" + ); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["logger"], + "subagent:session_1" + ); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["data"]["tool_call"]["name"], + "developer__shell" + ); + } + + #[test] + fn maps_progress_notification_to_tool_update_meta() { + let notification = ServerNotification::ProgressNotification(Notification::new( + ProgressNotificationParam::new( + ProgressToken(NumberOrString::String(Arc::from("scan-repo"))), + 3.0, + ) + .with_total(10.0) + .with_message("Scanned 3 of 10 directories"), + )); + + let update = tool_notification_update("tool_1", notification).expect("expected update"); + let value = serde_json::to_value(update).expect("update should serialize"); + + assert_eq!(value["sessionUpdate"], "tool_call_update"); + assert_eq!(value["toolCallId"], "tool_1"); + assert_eq!(value["status"], "in_progress"); + assert_eq!(value["_meta"]["toolNotification"]["type"], "progress"); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["progressToken"], + "scan-repo" + ); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["progress"], + 3.0 + ); + assert_eq!(value["_meta"]["toolNotification"]["params"]["total"], 10.0); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["message"], + "Scanned 3 of 10 directories" + ); + } + + #[test] + fn ignores_non_tool_live_notification_variants() { + let notification = ServerNotification::CancelledNotification(Notification::new( + CancelledNotificationParam { + request_id: NumberOrString::String(Arc::from("request_1")), + reason: None, + }, + )); + + assert!(tool_notification_update("tool_1", notification).is_none()); + } +} diff --git a/ui/desktop/src/acp/__tests__/chatNotifications.test.ts b/ui/desktop/src/acp/__tests__/chatNotifications.test.ts new file mode 100644 index 000000000..3575ffa6c --- /dev/null +++ b/ui/desktop/src/acp/__tests__/chatNotifications.test.ts @@ -0,0 +1,134 @@ +import type { SessionNotification } from '@agentclientprotocol/sdk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Session } from '../../api'; +import { AppEvents } from '../../constants/events'; +import { ChatState } from '../../types/chatState'; +import { handleAcpSessionNotification } from '../chatNotifications'; +import type { AcpChatSessionSnapshot } from '../chatSessionStore'; +import { acpChatSessionStore } from '../chatSessionStore'; + +vi.mock('../../acpChatFeatureFlag', () => ({ + USE_ACP_CHAT: true, +})); + +vi.mock('../chatSessionStore', () => ({ + acpChatSessionStore: { + getSnapshot: vi.fn(), + applyAcpSessionNotification: vi.fn(), + }, +})); + +const SESSION_ID = 'session-1'; + +function sessionInfoUpdate(title: string): SessionNotification { + return { + sessionId: SESSION_ID, + update: { + sessionUpdate: 'session_info_update', + title, + }, + }; +} + +function sessionWithName(name: string): Session { + return { + id: SESSION_ID, + name, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + working_dir: '/tmp', + message_count: 0, + extension_data: {}, + source: 'test', + } as Session; +} + +function snapshotWithName(name: string): AcpChatSessionSnapshot { + return { + session: sessionWithName(name), + messages: [], + tokenState: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, + }, + notifications: [], + chatState: ChatState.Idle, + sessionLoadError: undefined, + activePromptAttemptId: null, + }; +} + +function snapshotWithoutSession(): AcpChatSessionSnapshot { + return { + session: undefined, + messages: [], + tokenState: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, + }, + notifications: [], + chatState: ChatState.Idle, + sessionLoadError: undefined, + activePromptAttemptId: null, + }; +} + +describe('handleAcpSessionNotification', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('dispatches SESSION_RENAMED when a session info notification changes the name', async () => { + const dispatchEvent = vi.spyOn(window, 'dispatchEvent'); + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValueOnce(snapshotWithName('Old name')); + vi.mocked(acpChatSessionStore.applyAcpSessionNotification).mockReturnValueOnce( + snapshotWithName('New name') + ); + + await handleAcpSessionNotification(sessionInfoUpdate('New name')); + + expect(dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: AppEvents.SESSION_RENAMED, + detail: { sessionId: SESSION_ID, newName: 'New name' }, + }) + ); + }); + + it('does not dispatch SESSION_RENAMED when the name is unchanged', async () => { + const dispatchEvent = vi.spyOn(window, 'dispatchEvent'); + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValueOnce(snapshotWithName('Same name')); + vi.mocked(acpChatSessionStore.applyAcpSessionNotification).mockReturnValueOnce( + snapshotWithName('Same name') + ); + + await handleAcpSessionNotification(sessionInfoUpdate('Same name')); + + expect(dispatchEvent).not.toHaveBeenCalled(); + }); + + it('dispatches SESSION_RENAMED from the notification title when the session is not loaded', async () => { + const dispatchEvent = vi.spyOn(window, 'dispatchEvent'); + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValueOnce(snapshotWithoutSession()); + vi.mocked(acpChatSessionStore.applyAcpSessionNotification).mockReturnValueOnce( + snapshotWithoutSession() + ); + + await handleAcpSessionNotification(sessionInfoUpdate('Generated name')); + + expect(dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: AppEvents.SESSION_RENAMED, + detail: { sessionId: SESSION_ID, newName: 'Generated name' }, + }) + ); + }); +}); diff --git a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts index 48694cc38..ef0f92342 100644 --- a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts +++ b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts @@ -1,4 +1,8 @@ -import type { CreateElicitationRequest, RequestPermissionRequest } from '@agentclientprotocol/sdk'; +import type { + CreateElicitationRequest, + RequestPermissionRequest, + SessionNotification, +} from '@agentclientprotocol/sdk'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Message, Session } from '../../api'; import { ChatState } from '../../types/chatState'; @@ -86,6 +90,26 @@ function elicitationRequest(sessionId: string): { }; } +function toolProgressNotification(sessionId: string): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'in_progress', + _meta: { + toolNotification: { + type: 'progress', + params: { + progressToken: 'scan-repo', + progress: 3, + }, + }, + }, + }, + }; +} + describe('acpChatSessionStore', () => { let store: AcpChatSessionStore; @@ -182,6 +206,27 @@ describe('acpChatSessionStore', () => { expect(snapshot.chatState).toBe(ChatState.Streaming); }); + it('stores ACP tool notifications and clears them for a new prompt attempt', () => { + const snapshot = store.applyAcpSessionNotification(toolProgressNotification('session-1')); + + expect(snapshot.notifications).toHaveLength(1); + expect(snapshot.notifications[0]).toMatchObject({ + type: 'Notification', + request_id: 'tool-1', + message: { + method: 'notifications/progress', + params: { + progressToken: 'scan-repo', + progress: 3, + }, + }, + }); + + const nextSnapshot = store.startPromptAttempt('session-1', 'attempt-1'); + + expect(nextSnapshot.notifications).toEqual([]); + }); + it('applies permission requests as waiting action-required messages', () => { const snapshot = store.applyPermissionRequest(permissionRequest('session-1', 'tool-1')); diff --git a/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts b/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts index cadd94f03..64e178779 100644 --- a/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts +++ b/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts @@ -147,6 +147,11 @@ describe('ACP elicitation requests', () => { await vi.advanceTimersByTimeAsync(ACP_ELICITATION_TIMEOUT_SECONDS * 1000); + expect(acpChatSessionStore.setElicitationStatus).toHaveBeenCalledWith( + 'session-1', + appliedRequest.id, + 'cancelled' + ); await expect(response).resolves.toEqual({ action: 'cancel' }); expect(resolveAcpElicitationRequest('session-1', appliedRequest.id, {})).toBe(false); } finally { diff --git a/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts b/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts index 99211e3f4..9a26e45b8 100644 --- a/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts +++ b/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts @@ -2,6 +2,7 @@ import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk'; import { describe, expect, it } from 'vitest'; import type { Message, MessageContent } from '../../api'; +import type { NotificationEvent } from '../../types/message'; import { createAcpSessionNotificationAdapter, type AcpChatStateChange, @@ -66,6 +67,21 @@ function expectOnlyMessagesChange(chatStateChanges: AcpChatStateChange[]): Messa return chatStateChange.messages; } +function expectOnlyNotificationChange( + chatStateChanges: AcpChatStateChange[] +): NotificationEvent { + expect(chatStateChanges).toHaveLength(1); + + const [chatStateChange] = chatStateChanges; + expect(chatStateChange.type).toBe('notification'); + + if (chatStateChange.type !== 'notification') { + throw new Error('expected notification state change'); + } + + return chatStateChange.notification; +} + function firstContent(message: Message): MessageContent { const content = message.content[0]; expect(content).toBeDefined(); @@ -286,6 +302,84 @@ describe('createAcpSessionNotificationAdapter', () => { }, }); }); + + it('maps in-progress tool message notifications', () => { + const adapter = createAcpSessionNotificationAdapter(); + + const notificationStateChanges = adapter.apply( + acpUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'in_progress', + _meta: { + toolNotification: { + type: 'message', + params: { + level: 'info', + logger: 'subagent:session-1', + data: { + text: 'Running search...', + }, + }, + }, + }, + }) + ); + const notification = expectOnlyNotificationChange(notificationStateChanges); + + expect(notification).toMatchObject({ + type: 'Notification', + request_id: 'tool-1', + message: { + method: 'notifications/message', + params: { + level: 'info', + logger: 'subagent:session-1', + data: { + text: 'Running search...', + }, + }, + }, + }); + }); + + it('maps in-progress tool progress notifications', () => { + const adapter = createAcpSessionNotificationAdapter(); + + const notificationStateChanges = adapter.apply( + acpUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'in_progress', + _meta: { + toolNotification: { + type: 'progress', + params: { + progressToken: 'scan-repo', + progress: 3, + total: 10, + message: 'Scanned 3 of 10 directories', + }, + }, + }, + }) + ); + const notification = expectOnlyNotificationChange(notificationStateChanges); + + expect(notification).toMatchObject({ + type: 'Notification', + request_id: 'tool-1', + message: { + method: 'notifications/progress', + params: { + progressToken: 'scan-repo', + progress: 3, + total: 10, + message: 'Scanned 3 of 10 directories', + }, + }, + }); + }); }); }); diff --git a/ui/desktop/src/acp/adapter/shared.ts b/ui/desktop/src/acp/adapter/shared.ts index ded6e47f8..49e0444a8 100644 --- a/ui/desktop/src/acp/adapter/shared.ts +++ b/ui/desktop/src/acp/adapter/shared.ts @@ -1,10 +1,12 @@ import type { ToolCall, ToolCallUpdate } from '@agentclientprotocol/sdk'; import type { Message, TokenState } from '../../api'; +import type { NotificationEvent } from '../../types/message'; export type AcpChatStateChange = | { type: 'messages'; messages: Message[] } | { type: 'tokenState'; tokenState: Partial } - | { type: 'sessionInfo'; name?: string }; + | { type: 'sessionInfo'; name?: string } + | { type: 'notification'; notification: NotificationEvent }; export interface AdapterState { messages: Message[]; diff --git a/ui/desktop/src/acp/adapter/toolNotifications.ts b/ui/desktop/src/acp/adapter/toolNotifications.ts new file mode 100644 index 000000000..9bd5a7042 --- /dev/null +++ b/ui/desktop/src/acp/adapter/toolNotifications.ts @@ -0,0 +1,110 @@ +import type { ToolCallUpdate } from '@agentclientprotocol/sdk'; +import type { NotificationEvent } from '../../types/message'; +import type { AcpChatStateChange } from './shared'; +import { isRecord } from './shared'; + +type ToolNotification = + | { + type: 'message'; + params: LoggingMessageNotificationParams; + } + | { + type: 'progress'; + params: ProgressNotificationParams; + }; + +type LoggingMessageNotificationParams = { + level: string; + logger?: string; + data: unknown; +}; + +type ProgressNotificationParams = { + progressToken: string | number; + progress: number; + total?: number; + message?: string; +}; + +export function toolNotificationChange( + update: ToolCallUpdate +): Extract | undefined { + const toolNotification = parseToolNotification(update._meta); + if (!toolNotification) { + return undefined; + } + + return { + type: 'notification', + notification: toNotificationEvent(update.toolCallId, toolNotification), + }; +} + +function parseToolNotification(meta: unknown): ToolNotification | undefined { + if (!isRecord(meta)) { + return undefined; + } + + const toolNotification = meta.toolNotification; + if (!isRecord(toolNotification)) { + return undefined; + } + + if (toolNotification.type === 'message') { + const params = parseLoggingMessageParams(toolNotification.params); + return params ? { type: 'message', params } : undefined; + } + + if (toolNotification.type === 'progress') { + const params = parseProgressParams(toolNotification.params); + return params ? { type: 'progress', params } : undefined; + } + + return undefined; +} + +function parseLoggingMessageParams(value: unknown): LoggingMessageNotificationParams | undefined { + if (!isRecord(value) || typeof value.level !== 'string' || !('data' in value)) { + return undefined; + } + + return { + level: value.level, + ...(typeof value.logger === 'string' ? { logger: value.logger } : {}), + data: value.data, + }; +} + +function parseProgressParams(value: unknown): ProgressNotificationParams | undefined { + if ( + !isRecord(value) || + (typeof value.progressToken !== 'string' && typeof value.progressToken !== 'number') || + typeof value.progress !== 'number' + ) { + return undefined; + } + + return { + progressToken: value.progressToken, + progress: value.progress, + ...(typeof value.total === 'number' ? { total: value.total } : {}), + ...(typeof value.message === 'string' ? { message: value.message } : {}), + }; +} + +function toNotificationEvent( + toolCallId: string, + toolNotification: ToolNotification +): NotificationEvent { + return { + type: 'Notification', + request_id: toolCallId, + message: { + method: + toolNotification.type === 'message' + ? 'notifications/message' + : 'notifications/progress', + params: toolNotification.params, + }, + }; +} diff --git a/ui/desktop/src/acp/adapter/tools.ts b/ui/desktop/src/acp/adapter/tools.ts index b79cf07c8..5bbcb74d7 100644 --- a/ui/desktop/src/acp/adapter/tools.ts +++ b/ui/desktop/src/acp/adapter/tools.ts @@ -5,6 +5,7 @@ import type { } from '@agentclientprotocol/sdk'; import type { CallToolResponse, ContentBlock as ApiContentBlock, Message } from '../../api'; import { findMessageForChunk } from './messages'; +import { toolNotificationChange } from './toolNotifications'; import { type AcpChatStateChange, type AdapterState, @@ -55,7 +56,8 @@ export function applyToolCallUpdate( update: ToolCallUpdate ): AcpChatStateChange[] { if (update.status !== 'completed' && update.status !== 'failed') { - return []; + const notificationChange = toolNotificationChange(update); + return notificationChange ? [notificationChange] : []; } if (hasToolResponse(state, update.toolCallId)) { diff --git a/ui/desktop/src/acp/chatNotifications.ts b/ui/desktop/src/acp/chatNotifications.ts index 8f2958f46..b70e8c02d 100644 --- a/ui/desktop/src/acp/chatNotifications.ts +++ b/ui/desktop/src/acp/chatNotifications.ts @@ -1,11 +1,27 @@ import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; import type { SessionNotification } from '@agentclientprotocol/sdk'; import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; +import { AppEvents } from '../constants/events'; import { acpChatSessionStore } from './chatSessionStore'; export function handleAcpSessionNotification(notification: SessionNotification): Promise { if (USE_ACP_CHAT) { + const sessionNameBeforeNotification = acpChatSessionStore.getSnapshot( + notification.sessionId + )?.session?.name; + const updatedName = + notification.update.sessionUpdate === 'session_info_update' + ? notification.update.title + : undefined; acpChatSessionStore.applyAcpSessionNotification(notification); + + if (updatedName && updatedName !== sessionNameBeforeNotification) { + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_RENAMED, { + detail: { sessionId: notification.sessionId, newName: updatedName }, + }) + ); + } } return Promise.resolve(); } diff --git a/ui/desktop/src/acp/chatSessionStore.ts b/ui/desktop/src/acp/chatSessionStore.ts index fd8cae4bd..93ef805d2 100644 --- a/ui/desktop/src/acp/chatSessionStore.ts +++ b/ui/desktop/src/acp/chatSessionStore.ts @@ -2,6 +2,7 @@ import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk'; import type { Message, Session, TokenState } from '../api'; import { ChatState } from '../types/chatState'; +import type { NotificationEvent } from '../types/message'; import { createAcpSessionNotificationAdapter, type AcpChatStateChange, @@ -15,6 +16,7 @@ export interface AcpChatSessionSnapshot { session: Session | undefined; messages: Message[]; tokenState: TokenState; + notifications: NotificationEvent[]; chatState: ChatState; sessionLoadError: string | undefined; activePromptAttemptId: string | null; @@ -116,6 +118,7 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { session: undefined, messages: [], tokenState: { ...initialTokenState }, + notifications: [], chatState: ChatState.Idle, sessionLoadError: undefined, activePromptAttemptId: null, @@ -193,6 +196,7 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { entry.activePromptAttemptId = promptAttemptId; entry.chatState = ChatState.Streaming; entry.sessionLoadError = undefined; + entry.notifications = []; return notify(sessionId, entry); }; @@ -333,6 +337,9 @@ function applyChatStateChanges(entry: StoreEntry, changes: AcpChatStateChange[]) entry.session = { ...entry.session, name: change.name }; } break; + case 'notification': + entry.notifications = [...entry.notifications, change.notification]; + break; } } } @@ -342,6 +349,7 @@ function snapshotFromEntry(entry: StoreEntry): AcpChatSessionSnapshot { session: entry.session, messages: cloneMessages(entry.messages), tokenState: { ...entry.tokenState }, + notifications: [...entry.notifications], chatState: entry.chatState, sessionLoadError: entry.sessionLoadError, activePromptAttemptId: entry.activePromptAttemptId, diff --git a/ui/desktop/src/acp/elicitationRequests.ts b/ui/desktop/src/acp/elicitationRequests.ts index a7ff6832d..775f6fc61 100644 --- a/ui/desktop/src/acp/elicitationRequests.ts +++ b/ui/desktop/src/acp/elicitationRequests.ts @@ -51,6 +51,11 @@ export async function requestAcpElicitation( } pendingRequests.delete(key); + acpChatSessionStore.setElicitationStatus( + elicitationRequest.sessionId, + elicitationRequest.id, + 'cancelled' + ); pending.resolve(cancelledElicitationResponse()); }, ACP_ELICITATION_TIMEOUT_SECONDS * 1000); diff --git a/ui/desktop/src/components/Layout/NavigationPanel.tsx b/ui/desktop/src/components/Layout/NavigationPanel.tsx index f9140eca3..8104e116b 100644 --- a/ui/desktop/src/components/Layout/NavigationPanel.tsx +++ b/ui/desktop/src/components/Layout/NavigationPanel.tsx @@ -15,8 +15,7 @@ import { AppEvents } from '../../constants/events'; import { Goose } from '../icons/Goose'; import { InlineEditText } from '../common/InlineEditText'; import { SessionIndicators } from '../SessionIndicators'; -import { updateSessionName } from '../../api'; -import type { SessionListItem } from '../../acp/sessions'; +import { acpRenameSession, type SessionListItem } from '../../acp/sessions'; import { cn } from '../../utils'; import { defineMessages, useIntl } from '../../i18n'; @@ -102,10 +101,7 @@ const SessionRow: React.FC = ({ session, active, status, onClic { - await updateSessionName({ - path: { session_id: session.id }, - body: { name: newName }, - }); + await acpRenameSession(session.id, newName); window.dispatchEvent( new CustomEvent(AppEvents.SESSION_RENAMED, { detail: { sessionId: session.id, newName, userInitiated: true }, @@ -143,13 +139,8 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => { const isActive = useCallback((path: string) => location.pathname === path, [location.pathname]); - const { - recentSessions, - activeSessionId, - fetchSessions, - handleNavClick, - handleSessionClick, - } = useNavigationSessions(); + const { recentSessions, activeSessionId, fetchSessions, handleNavClick, handleSessionClick } = + useNavigationSessions(); const [sessionStatuses, setSessionStatuses] = useState>(new Map()); @@ -205,10 +196,7 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }} - className={cn( - 'bg-background-primary outline-none flex flex-col h-full', - className - )} + className={cn('bg-background-primary outline-none flex flex-col h-full', className)} > {/* Header: logo + collapse button. Top padding clears the macOS traffic lights. */}
diff --git a/ui/desktop/src/hooks/useAcpChatSession.ts b/ui/desktop/src/hooks/useAcpChatSession.ts index 8db3035eb..85805f204 100644 --- a/ui/desktop/src/hooks/useAcpChatSession.ts +++ b/ui/desktop/src/hooks/useAcpChatSession.ts @@ -5,7 +5,6 @@ import { AppEvents } from '../constants/events'; import { ChatState } from '../types/chatState'; import { - getSession, Message, resumeAgent, Session, @@ -35,6 +34,7 @@ interface StreamState { chatState: ChatState; sessionLoadError: string | undefined; tokenState: TokenState; + notifications: NotificationEvent[]; } type StreamAction = @@ -64,6 +64,7 @@ const initialState: StreamState = { chatState: ChatState.Idle, sessionLoadError: undefined, tokenState: initialTokenState, + notifications: [], }; function streamReducer(state: StreamState, action: StreamAction): StreamState { @@ -89,6 +90,7 @@ function streamReducer(state: StreamState, action: StreamAction): StreamState { session: action.payload.session, messages: action.payload.messages, tokenState: action.payload.tokenState, + notifications: action.payload.notifications, chatState: action.payload.chatState, sessionLoadError: action.payload.sessionLoadError, }; @@ -99,6 +101,7 @@ function streamReducer(state: StreamState, action: StreamAction): StreamState { messages: [], session: undefined, sessionLoadError: undefined, + notifications: [], chatState: ChatState.LoadingConversation, }; @@ -106,6 +109,7 @@ function streamReducer(state: StreamState, action: StreamAction): StreamState { return { ...state, chatState: ChatState.Streaming, + notifications: [], }; case 'STREAM_ERROR': @@ -127,6 +131,10 @@ function streamReducer(state: StreamState, action: StreamAction): StreamState { } } +function isClearCommand(message: string): boolean { + return message.trim() === '/clear'; +} + function createAcpCreditsExhaustedMessage(error: AcpCreditsExhaustedError): Message { return { id: uuidv7(), @@ -163,8 +171,6 @@ export function useAcpChatSession({ const intl = useIntl(); const [state, dispatch] = useReducer(streamReducer, initialState); - const namePollingRef = useRef | null>(null); - // Ref to access latest state in callbacks (avoids stale closures) const stateRef = useRef(state); stateRef.current = state; @@ -185,21 +191,31 @@ export function useAcpChatSession({ }, [sessionId]); useEffect(() => { - return () => { - if (namePollingRef.current) { - clearTimeout(namePollingRef.current); - namePollingRef.current = null; + const handleSessionRenamed = (event: Event) => { + const { sessionId: renamedSessionId, newName } = ( + event as CustomEvent<{ sessionId: string; newName: string }> + ).detail; + + if (renamedSessionId !== sessionId) { + return; } + + const currentSession = stateRef.current.session; + if (!currentSession || currentSession.name === newName) { + return; + } + + const updatedSession = { ...currentSession, name: newName }; + acpChatSessionStore.setSessionMetadata(sessionId, updatedSession); + dispatch({ type: 'SET_SESSION', payload: updatedSession }); }; + + window.addEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed); + return () => window.removeEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed); }, [sessionId]); const onFinish = useCallback( async (error?: string): Promise => { - if (namePollingRef.current) { - clearTimeout(namePollingRef.current); - namePollingRef.current = null; - } - acpChatSessionStore.setSessionLoadError(sessionId, error); acpChatSessionStore.setChatState(sessionId, ChatState.Idle); dispatch({ type: 'STREAM_FINISH', payload: error }); @@ -226,35 +242,6 @@ export function useAcpChatSession({ window.dispatchEvent(new CustomEvent(AppEvents.MESSAGE_STREAM_FINISHED)); } - // Refresh session name after each reply for the first 3 user messages - if (!error && sessionId) { - const currentState = stateRef.current; - const userMessageCount = currentState.messages.filter((m) => m.role === 'user').length; - - if (userMessageCount <= 3) { - try { - const response = await getSession({ - path: { session_id: sessionId }, - throwOnError: true, - }); - if (response.data?.name) { - const updatedSession = currentState.session - ? { ...currentState.session, name: response.data.name } - : undefined; - acpChatSessionStore.setSessionMetadata(sessionId, updatedSession); - dispatch({ type: 'SET_SESSION', payload: updatedSession }); - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_RENAMED, { - detail: { sessionId, newName: response.data.name }, - }) - ); - } - } catch (refreshError) { - console.warn('Failed to refresh session name:', refreshError); - } - } - } - onStreamFinish(); }, [intl, onStreamFinish, sessionId] @@ -393,6 +380,7 @@ export function useAcpChatSession({ const hasExistingMessages = currentState.messages.length > 0; const hasNewMessage = userMessage.trim().length > 0 || images.length > 0; + const clearsConversation = hasNewMessage && isClearCommand(userMessage); if (!hasNewMessage && !hasExistingMessages) { return; @@ -401,57 +389,18 @@ export function useAcpChatSession({ // Emit session-created event for first message in a new session if (!hasExistingMessages && hasNewMessage) { window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); - - const pollForName = async (attempts = 0) => { - if (attempts >= 20) return; - - try { - const response = await getSession({ - path: { session_id: sessionId }, - throwOnError: true, - }); - const currentState = stateRef.current; - const currentName = currentState.session?.name; - const newName = response.data?.name; - - if (newName && newName !== currentName) { - const updatedSession = currentState.session - ? { ...currentState.session, name: newName } - : undefined; - acpChatSessionStore.setSessionMetadata(sessionId, updatedSession); - dispatch({ type: 'SET_SESSION', payload: updatedSession }); - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_RENAMED, { - detail: { sessionId, newName }, - }) - ); - return; - } - } catch { - // Silently continue polling - } - - const latestState = stateRef.current; - if ( - latestState.chatState === ChatState.Streaming || - latestState.chatState === ChatState.Thinking || - latestState.chatState === ChatState.Compacting - ) { - namePollingRef.current = setTimeout(() => pollForName(attempts + 1), 500); - } - }; - - namePollingRef.current = setTimeout(() => pollForName(0), 1000); } const newMessage = hasNewMessage ? createUserMessage(userMessage, images) : currentState.messages[currentState.messages.length - 1]; - const currentMessages = hasNewMessage - ? [...currentState.messages, newMessage] - : [...currentState.messages]; + const currentMessages = clearsConversation + ? [] + : hasNewMessage + ? [...currentState.messages, newMessage] + : [...currentState.messages]; - if (hasNewMessage) { + if (clearsConversation || hasNewMessage) { acpChatSessionStore.setMessages(sessionId, currentMessages); dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); } @@ -622,7 +571,16 @@ export function useAcpChatSession({ const maybe_cached_messages = state.session ? state.messages : cached?.messages || []; const maybe_cached_session = state.session ?? cached?.session; - const notificationsMap = useMemo(() => new Map(), []); + const notificationsMap = useMemo(() => { + return state.notifications.reduce((map, notification) => { + const key = notification.request_id; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key)!.push(notification); + return map; + }, new Map()); + }, [state.notifications]); return { sessionLoadError: state.sessionLoadError,