Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ const mockAgentChat = jest.fn(
jest.mock(
'@automattic/agenttic-client',
() => ( {
getStoredSessionIds: async () => [],
getAgentManager: () => ( {
updateSessionId: mockUpdateSessionId,
hasAgent: () => mockManagerHasAgent,
Expand Down
110 changes: 109 additions & 1 deletion packages/agents-manager/src/components/orchestrator-chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { usePageOrSiteEditorSurface } from '../../hooks/use-empty-view-suggestio
import useFeedbackAction from '../../hooks/use-feedback-action';
import { useImageUpload } from '../../hooks/use-image-upload';
import { useNavigationContinuation } from '../../hooks/use-navigation-continuation';
import useReconcileDeliveryStatus from '../../hooks/use-reconcile-delivery-status';
import useRegenerateAction from '../../hooks/use-regenerate-action';
import useSourcesAction from '../../hooks/use-sources-action';
import {
Expand Down Expand Up @@ -69,6 +70,7 @@ import { isBlockEditToolId } from '../../utils/tool-message-utils';
import { recordAgentsManagerTracksEvent, recordBigSkyTracksEvent } from '../../utils/tracks';
import AgentChat from '../agent-chat';
import { type Options as ChatHeaderOptions } from '../chat-header';
import RetryFailedMessage from '../retry-failed-message';
import type { BigSkyMessage } from '../../types';
import type {
AbilitiesSetupHook,
Expand Down Expand Up @@ -792,6 +794,30 @@ export default function OrchestratorChat( {
},
} );

// Recover a first-message turn orphaned by a page change before the server
// assigned a session (WOOAI-872 / WOOAI-847): show it as `failed` with a
// retry instead of losing it.
const reconcileResult = useReconcileDeliveryStatus();
const [ failedRetries, setFailedRetries ] = useState< string[] >( [] );
const reconcileDismissedRef = useRef( false );
useEffect( () => {
setFailedRetries( reconcileResult?.failedTexts ?? [] );
}, [ reconcileResult ] );
useEffect( () => {
// Show the reconciled transcript, and re-assert it if `useAgentChat`'s own
// mount-init clears the panel out from under us before the merchant has
// interacted. Once messages are present, the merchant sends, or a
// provider deliberately clears the chat, stop.
if (
reconcileResult &&
messages.length === 0 &&
! hasUserSentMessage &&
! reconcileDismissedRef.current
) {
loadMessages( reconcileResult.messages );
}
}, [ reconcileResult, messages.length, hasUserSentMessage, loadMessages ] );

// Use dynamic suggestions from the external provider (e.g., Big Sky block-based suggestions)
const maxDynamicSuggestions = isDocked ? undefined : 3;
const dynamicSuggestions = useSuggestions?.( maxDynamicSuggestions );
Expand Down Expand Up @@ -1378,6 +1404,35 @@ export default function OrchestratorChat( {

useRegisterCustomActions( { setChatInput, submitChatMessage } );

// Retry a failed turn: drop its affordance and the failed bubble, then re-send
// the original prompt as a fresh turn. Deliberately does not repopulate the
// composer. If the send never dispatches, put both back so the question is
// not lost a second time.
const handleRetryFailed = useCallback(
async ( text: string, failedMessageId?: string ) => {
setFailedRetries( ( previous ) => previous.filter( ( retry ) => retry !== text ) );
if ( failedMessageId ) {
setDeletedMessageIds( ( previous ) => new Set( previous ).add( failedMessageId ) );
}
submitDispatchedRef.current = false;
await submitChatMessage( text );
if ( submitDispatchedRef.current ) {
return;
Comment thread
AnnaMag marked this conversation as resolved.
Outdated
}
setFailedRetries( ( previous ) =>
previous.includes( text ) ? previous : [ ...previous, text ]
);
if ( failedMessageId ) {
setDeletedMessageIds( ( previous ) => {
const next = new Set( previous );
next.delete( failedMessageId );
return next;
} );
}
},
[ submitChatMessage ]
);

const handleContextCardAction = useCallback(
( card: ExternalContextCard, action: ExternalContextCardAction ) => {
if ( ! action.prompt ) {
Expand Down Expand Up @@ -1500,7 +1555,10 @@ export default function OrchestratorChat( {
// Transform Big Sky message format to `UIMessage` format and add to chat.
addMessage( convertBigSkyMessageToUIMessage( message ) );
},
clearMessages: () => loadMessages( [] ),
clearMessages: () => {
reconcileDismissedRef.current = true;
loadMessages( [] );
Comment on lines +1545 to +1547
},
clearSuggestions,
getAgentManager,
isProcessing,
Expand Down Expand Up @@ -1690,19 +1748,69 @@ export default function OrchestratorChat( {
};
} );

// Render an inline retry affordance beneath each failed user turn. Matched
// by text to its user message; any left over (text not on screen) trail the
// transcript so the affordance is never lost.
if ( failedRetries.length > 0 ) {
const makeRetryMessage = (
retry: string,
anchor?: AgentsManagerUIMessage,
failedMessageId?: string
): AgentsManagerUIMessage => ( {
id: `failed-retry-${ retry }`,
role: 'agent',
content: [
{
type: 'component',
component: RetryFailedMessage as React.ComponentType,
componentProps: {
onRetry: () => handleRetryFailed( retry, failedMessageId ),
},
},
],
timestamp: ( anchor?.timestamp ?? Date.now() ) + 1,
archived: false,
showIcon: false,
suppressThinking: true,
} );

const remainingRetries = [ ...failedRetries ];
const messagesWithRetries: AgentsManagerUIMessage[] = [];
for ( const message of currentMessages ) {
messagesWithRetries.push( message );
if ( message.role !== 'user' ) {
continue;
}
const text = message.content?.find( ( content ) => content.type === 'text' )?.text;
const matchIndex = remainingRetries.indexOf( text ?? '' );
if ( matchIndex !== -1 ) {
const [ retry ] = remainingRetries.splice( matchIndex, 1 );
messagesWithRetries.push( makeRetryMessage( retry, message, message.id ) );
}
}
for ( const retry of remainingRetries ) {
messagesWithRetries.push(
makeRetryMessage( retry, currentMessages[ currentMessages.length - 1 ] )
);
}
currentMessages = messagesWithRetries;
}

return currentMessages;
}, [
checkpointActionRevision,
checkpointSessionIdentity,
currentPostId,
deletedMessageIds,
failedRetries,
getChatComponent,
getCopyActionsForMessage,
getCheckpointActionsForMessage,
getShowComponentOrder,
getFeedbackActionsForMessage,
getTraceIdForMessage,
getRegenerateActionsForMessage,
handleRetryFailed,
hasEditorRedo,
isBuildingSite,
isProcessing,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { __ } from '@wordpress/i18n';
import './style.scss';

interface Props {
onRetry: () => void;
}

/**
* Plain `<button>`, not `@wordpress/components`: importing that package here
* pulls the whole component index into `orchestrator-chat`'s module graph and
* breaks its test suite on `@wordpress/data`.
*/
export default function RetryFailedMessage( { onRetry }: Props ) {
return (
<div className="agents-manager__retry-failed-message">
<p className="agents-manager__retry-failed-message-text">
{ __( "This message didn't reach the assistant.", __i18n_text_domain__ ) }
</p>
<button

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have been using @wordpress/components > <Button> to maintain a consistent UI. Could you double-check whether we could use the component here as well, where appropriate? Thanks.

type="button"
className="agents-manager__retry-failed-message-button"
onClick={ onRetry }
>
{ __( 'Retry', __i18n_text_domain__ ) }
</button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.agents-manager__retry-failed-message {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}

.agents-manager__retry-failed-message-text {
margin: 0;
color: var( --color-error );
font-size: 14px;
}

.agents-manager__retry-failed-message-button {
padding: 4px 12px;
border: 1px solid var( --color-muted );
border-radius: 2px;
background: transparent;
color: var( --color-foreground );
font-size: 14px;
cursor: pointer;

&:hover:not( :disabled ) {
background-color: color-mix( in srgb, var( --color-foreground ) 8%, transparent );
}

&:focus-visible {
outline: 2px solid var( --color-primary );
outline-offset: 2px;
}
}
Loading
Loading