Skip to content

Commit 9e94c8a

Browse files
committed
Add inline retry action for failed user turns
- Show retry only on the failed user message - Cover the compact inline retry flow with a browser test
1 parent cb502e4 commit 9e94c8a

3 files changed

Lines changed: 117 additions & 11 deletions

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4909,6 +4909,38 @@ export default function ChatView(props: ChatViewProps) {
49094909
}
49104910
}, [activeThread, environmentId]);
49114911

4912+
const failedTurnRetryAction = useMemo(() => {
4913+
const failedMessage = activeThread?.messages.findLast((message) => message.role === "user");
4914+
if (
4915+
!isServerThread ||
4916+
!activeThread?.error ||
4917+
!activeThread.session?.lastError ||
4918+
!failedMessage ||
4919+
activeTurnInProgress ||
4920+
activeThread.session?.orchestrationStatus === "starting" ||
4921+
activeThread.session?.orchestrationStatus === "running"
4922+
) {
4923+
return null;
4924+
}
4925+
return {
4926+
messageId: failedMessage.id,
4927+
isRetrying: turnRetryDispatchingThreadId === activeThread.id,
4928+
onRetry: () => {
4929+
void onRetryFailedTurn();
4930+
},
4931+
};
4932+
}, [
4933+
activeThread?.error,
4934+
activeThread?.id,
4935+
activeThread?.messages,
4936+
activeThread?.session?.lastError,
4937+
activeThread?.session?.orchestrationStatus,
4938+
activeTurnInProgress,
4939+
isServerThread,
4940+
onRetryFailedTurn,
4941+
turnRetryDispatchingThreadId,
4942+
]);
4943+
49124944
const threadErrorRetryAction = useMemo(() => {
49134945
if (
49144946
!isServerThread ||
@@ -6109,6 +6141,7 @@ export default function ChatView(props: ChatViewProps) {
61096141
workspaceRoot={activeWorkspaceRoot}
61106142
skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
61116143
providerAuthReconnect={providerAuthReconnectPrompt}
6144+
failedTurnRetry={failedTurnRetryAction}
61126145
onRunProviderAuthReconnect={runProviderAuthReconnect}
61136146
mcpAuthReconnectStatusByServerName={activeMcpAuthReconnectStatusByServerName}
61146147
onRunMcpAuthReconnect={runMcpAuthReconnect}

apps/web/src/components/chat/MessagesTimeline.browser.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,13 @@ function buildLongUserMessageText(tail = "deep hidden detail only after expand")
134134
).join("\n");
135135
}
136136

137-
function buildUserTimelineEntry(text: string) {
137+
function buildUserTimelineEntry(text: string, messageId = "message-1") {
138138
return {
139-
id: "entry-1",
139+
id: `entry-${messageId}`,
140140
kind: "message" as const,
141141
createdAt: MESSAGE_CREATED_AT,
142142
message: {
143-
id: "message-1" as never,
143+
id: messageId as never,
144144
role: "user" as const,
145145
text,
146146
createdAt: MESSAGE_CREATED_AT,
@@ -690,6 +690,38 @@ describe("MessagesTimeline", () => {
690690
}
691691
});
692692

693+
it("retries only the failed user message from its compact inline action", async () => {
694+
const onRetry = vi.fn();
695+
const screen = await renderTimeline(
696+
<MessagesTimeline
697+
{...buildProps()}
698+
failedTurnRetry={{
699+
messageId: MessageId.make("message-2"),
700+
isRetrying: false,
701+
onRetry,
702+
}}
703+
timelineEntries={[
704+
buildUserTimelineEntry("Earlier request.", "message-1"),
705+
buildUserTimelineEntry("Request that failed.", "message-2"),
706+
]}
707+
/>,
708+
);
709+
710+
try {
711+
const retryButtons = document.querySelectorAll<HTMLButtonElement>(
712+
'button[aria-label="Retry this message"]',
713+
);
714+
expect(retryButtons).toHaveLength(1);
715+
expect(retryButtons[0]?.closest('[data-message-id="message-2"]')).not.toBeNull();
716+
717+
await page.getByRole("button", { name: "Retry this message" }).click();
718+
719+
expect(onRetry).toHaveBeenCalledOnce();
720+
} finally {
721+
await screen.unmount();
722+
}
723+
});
724+
693725
it("exposes a continue-in-new-thread action on completed assistant messages", async () => {
694726
const onContinueInNewThread = vi.fn();
695727
await resetBrowserHoverState();

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
KeyRoundIcon,
5656
LoaderIcon,
5757
LogInIcon,
58+
RefreshCwIcon,
5859
SearchIcon,
5960
ShieldCheckIcon,
6061
SplitIcon,
@@ -142,6 +143,7 @@ interface TimelineRowSharedState {
142143
providerAuthReconnect: ProviderAuthReconnectAction | null;
143144
resolvedProviderAuthReconnectIds: ReadonlySet<string>;
144145
mcpAuthReconnectStatusByServerName: ReadonlyMap<string, McpAuthReconnectStatus>;
146+
failedTurnRetry: FailedTurnRetryAction | null;
145147
onRevertUserMessage: (messageId: MessageId) => void;
146148
onContinueInNewThread?: (messageId: MessageId) => void;
147149
onImageExpand: (preview: ExpandedImagePreview) => void;
@@ -166,6 +168,12 @@ export interface TimelineProposedPlanState {
166168
readonly onOpenThread: (threadId: ThreadId) => void;
167169
}
168170

171+
interface FailedTurnRetryAction {
172+
readonly messageId: MessageId;
173+
readonly isRetrying: boolean;
174+
readonly onRetry: () => void;
175+
}
176+
169177
interface TimelineRowActivityState {
170178
isWorking: boolean;
171179
isRevertingCheckpoint: boolean;
@@ -515,6 +523,7 @@ interface MessagesTimelineProps {
515523
workspaceRoot: string | undefined;
516524
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
517525
providerAuthReconnect?: ProviderAuthReconnectAction | null;
526+
failedTurnRetry?: FailedTurnRetryAction | null;
518527
onRunProviderAuthReconnect?: (action: ProviderAuthReconnectAction) => void;
519528
mcpAuthReconnectStatusByServerName?: ReadonlyMap<string, McpAuthReconnectStatus>;
520529
onRunMcpAuthReconnect?: (action: McpAuthReconnectAction) => void;
@@ -571,6 +580,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
571580
workspaceRoot,
572581
skills = EMPTY_TIMELINE_SKILLS,
573582
providerAuthReconnect = null,
583+
failedTurnRetry = null,
574584
onRunProviderAuthReconnect,
575585
mcpAuthReconnectStatusByServerName = EMPTY_MCP_AUTH_RECONNECT_STATUS,
576586
onRunMcpAuthReconnect,
@@ -1133,6 +1143,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
11331143
providerAuthReconnect,
11341144
resolvedProviderAuthReconnectIds,
11351145
mcpAuthReconnectStatusByServerName,
1146+
failedTurnRetry,
11361147
onRevertUserMessage,
11371148
...(onContinueInNewThread ? { onContinueInNewThread } : {}),
11381149
onImageExpand,
@@ -1158,6 +1169,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
11581169
providerAuthReconnect,
11591170
resolvedProviderAuthReconnectIds,
11601171
mcpAuthReconnectStatusByServerName,
1172+
failedTurnRetry,
11611173
onRevertUserMessage,
11621174
onContinueInNewThread,
11631175
onImageExpand,
@@ -1806,6 +1818,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
18061818
const terminalContexts = displayedUserMessage.contexts;
18071819
const transcriptHighlights = displayedUserMessage.transcriptHighlights;
18081820
const canRevertAgentWork = typeof row.revertTurnCount === "number";
1821+
const canRetryFailedTurn = ctx.failedTurnRetry?.messageId === row.message.id;
18091822

18101823
return (
18111824
<div className="flex justify-end">
@@ -1831,14 +1844,17 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
18311844
}
18321845
footer={
18331846
<>
1834-
<div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
1835-
{displayedUserMessage.copyText && (
1836-
<MessageCopyButton text={displayedUserMessage.copyText} />
1837-
)}
1838-
{displayedUserMessage.copyText && (
1839-
<ContinueInNewThreadButton messageId={row.message.id} action="edit" />
1840-
)}
1841-
{canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />}
1847+
<div className="flex items-center gap-1.5">
1848+
{canRetryFailedTurn && <RetryUserMessageButton />}
1849+
<div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
1850+
{displayedUserMessage.copyText && (
1851+
<MessageCopyButton text={displayedUserMessage.copyText} />
1852+
)}
1853+
{displayedUserMessage.copyText && (
1854+
<ContinueInNewThreadButton messageId={row.message.id} action="edit" />
1855+
)}
1856+
{canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />}
1857+
</div>
18421858
</div>
18431859
<p className="text-right text-xs tracking-tight tabular-nums text-muted-foreground/50">
18441860
{formatTimestamp(row.message.createdAt, ctx.timestampFormat)}
@@ -1921,6 +1937,31 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
19211937
);
19221938
}
19231939

1940+
function RetryUserMessageButton() {
1941+
const ctx = use(TimelineRowCtx);
1942+
const activity = use(TimelineRowActivityCtx);
1943+
const retry = ctx.failedTurnRetry;
1944+
if (!retry) {
1945+
return null;
1946+
}
1947+
1948+
return (
1949+
<Button
1950+
type="button"
1951+
size="xs"
1952+
variant="outline"
1953+
disabled={retry.isRetrying || activity.isWorking}
1954+
onClick={retry.onRetry}
1955+
aria-label="Retry this message"
1956+
tooltip="Retry this message"
1957+
className="gap-1 px-2 enabled:cursor-pointer"
1958+
>
1959+
<RefreshCwIcon className={cn("size-3", retry.isRetrying && "animate-spin")} />
1960+
{retry.isRetrying ? "Retrying" : "Retry"}
1961+
</Button>
1962+
);
1963+
}
1964+
19241965
function ContinueInNewThreadButton({
19251966
messageId,
19261967
className,

0 commit comments

Comments
 (0)