feat (ui): chat reply UI with ACP (last part) (#9857)

This commit is contained in:
Lifei Zhou
2026-06-18 21:13:35 +10:00
committed by GitHub
parent ddf23a0b64
commit 7130cbf33e
14 changed files with 623 additions and 107 deletions
+45 -87
View File
@@ -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<ReturnType<typeof setTimeout> | 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<void> => {
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<string, NotificationEvent[]>(), []);
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<string, NotificationEvent[]>());
}, [state.notifications]);
return {
sessionLoadError: state.sessionLoadError,