Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
24 changes: 12 additions & 12 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -552,18 +552,6 @@ function ChatWorkspaceInner({

const composerRef = useRef<ComposerHandle>(null);

/** Retry on a failed-turn strip: the request text was already
* recovered (`findRetryText`) rather than resent silently — a person
* may have since fixed what broke, or may not want it re-sent
* verbatim, so this hands it back into the composer ready to send
* rather than re-sending on their behalf. */
const handleRetryFailedTurn = useCallback(
(_item: TimelineMessageItem, retryText?: string) => {
if (retryText !== undefined) composerRef.current?.insertText(retryText);
},
[],
);

const feed = useWorkbenchFeed({
tenantId,
activeWorkbenchId,
Expand Down Expand Up @@ -888,6 +876,18 @@ function ChatWorkspaceInner({
restoreDraft: (text) => composerRef.current?.insertText(text),
});

/** Retry on a failed-turn strip: sends the recovered text
* (`findRetryText`) straight back through the normal send path — same
* as the person typing it and hitting Enter — rather than parking it
* in the composer for them to resend by hand. */
const handleRetryFailedTurn = useCallback(
async (_item: TimelineMessageItem, retryText?: string) => {
if (retryText === undefined) return;
await handleSend({ text: retryText, attachments: [] });
},
[handleSend],
);

// The mention popover's "Bring in…" group: only a `workbench` grows its
// participants after creation (a chat's counterpart is fixed at
// creation — see `workbench-service.ts`'s `joinHumanParticipant`/
Expand Down
20 changes: 19 additions & 1 deletion packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
min-height: 0;
align-items: center;
justify-content: center;
/* Without this, a short viewport lets the centered loader overflow past
this frame's own box and render on top of the page header above it
(CL-6624) — `overflow` makes the frame clip/scroll its own content
instead of bleeding into chrome it doesn't own. */
overflow: auto;
}

/* Stage top bar — mock `.top`: fixed 3rem row, tight title, trailing actions. */
Expand Down Expand Up @@ -718,13 +723,26 @@
}

/* The async-mint setup state: three staggered orange squares (the
brand's zero-radius language) pulsing while the launches finish. */
brand's zero-radius language) pulsing while the launches finish.
`flex: 1` claims the rest of `.chat-main`'s column instead of sitting
flush under `.chat-workbench-header` with no gap — without it the dots
render right on top of the header's bottom rule (CL-6624). Centering
in that claimed space, plus its own padding, keeps a real gap from the
header at any viewport height; `overflow: auto` is the same
containment `.chat-workspace-frame` needs for the pre-workbench
loading screen, so a very short viewport scrolls this in place rather
than bleeding past its box. */
.chat-workbench-loading {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.65rem;
padding: 1.5rem 1rem;
text-align: center;
overflow: auto;
}

.chat-workbench-loading-mark {
Expand Down
18 changes: 14 additions & 4 deletions packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -706,12 +706,15 @@ function FailedTurnStrip({
readonly onRetryFailedTurn?: (
item: TimelineMessageItem,
retryText?: string,
) => void;
) => void | Promise<void>;
readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void;
}) {
const display = senderDisplay(item.sender, participants, currentUser);
const sender = display?.label ?? CHAT_STRINGS.senderFallbackMember;
const [expanded, setExpanded] = useState(false);
// Guards the resend itself against a double-click firing two sends —
// not composer state, since Retry never touches the composer any more.
const [retrying, setRetrying] = useState(false);
return (
<div className="chat-turn-failed" role="status">
<span className="chat-turn-failed-text">
Expand All @@ -722,7 +725,14 @@ function FailedTurnStrip({
variant="ghost"
size="sm"
className="chat-turn-failed-retry"
onClick={() => onRetryFailedTurn?.(item, retryText)}
disabled={retrying}
onClick={() => {
if (retrying) return;
setRetrying(true);
void Promise.resolve(onRetryFailedTurn?.(item, retryText)).finally(
() => setRetrying(false),
);
}}
>
{CHAT_STRINGS.prThreadRetryAction}
</Button>
Expand Down Expand Up @@ -1280,7 +1290,7 @@ function MessageParts({
readonly onRetryFailedTurn?: (
item: TimelineMessageItem,
retryText?: string,
) => void;
) => void | Promise<void>;
readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void;
}) {
// A message this reader's own composer submitted and the server hasn't
Expand Down Expand Up @@ -1690,7 +1700,7 @@ export function WorkbenchTimeline({
readonly onRetryFailedTurn?: (
item: TimelineMessageItem,
retryText?: string,
) => void;
) => void | Promise<void>;
/** The failed-turn strip's "what happened" action — same undefined
* contract as `onRetryFailedTurn`. */
readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void;
Expand Down
59 changes: 56 additions & 3 deletions packages/chat-ui/test/failed-turn-strip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => {
participants={[
{ address: "ins_echo1@agents.example", handle: "echo" },
]}
onRetryFailedTurn={(item) => retried.push(item.id)}
onRetryFailedTurn={(item) => {
retried.push(item.id);
}}
onWhatHappenedFailedTurn={(item) => whatHappened.push(item.id)}
/>,
);
Expand Down Expand Up @@ -185,7 +187,7 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => {
);
});

test("Retry hands back the original request text so it isn't lost", async () => {
test("Retry auto-resends the recovered request text — no composer round trip", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
Expand All @@ -197,7 +199,9 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => {
participants={[
{ address: "ins_echo1@agents.example", handle: "echo" },
]}
onRetryFailedTurn={(_item, retryText) => retried.push(retryText)}
onRetryFailedTurn={(_item, retryText) => {
retried.push(retryText);
}}
/>,
);
});
Expand All @@ -208,6 +212,55 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => {
).click();
});

// The strip hands the recovered text straight to the host's resend
// action — the host (chat-workspace.tsx) sends it through the normal
// send path itself; the strip never touches a composer.
expect(retried).toEqual(["hi @echo"]);
});

test("Retry disables itself while the resend is in flight, and re-enables once it settles", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const calls: (string | undefined)[] = [];
let resolveSend: (() => void) | undefined;
await act(async () => {
root?.render(
<WorkbenchTimeline
items={failedTurnItem()}
participants={[
{ address: "ins_echo1@agents.example", handle: "echo" },
]}
onRetryFailedTurn={(_item, retryText) => {
calls.push(retryText);
return new Promise<void>((resolve) => {
resolveSend = resolve;
});
}}
/>,
);
});

const retryButton = () =>
container?.querySelector(".chat-turn-failed-retry") as HTMLButtonElement;

act(() => {
retryButton().click();
});
// A second click while the first resend is still in flight must not
// fire a second send.
act(() => {
retryButton().click();
});

expect(calls).toEqual(["hi @echo"]);
expect(retryButton().disabled).toBe(true);

await act(async () => {
resolveSend?.();
await Promise.resolve();
});

expect(retryButton().disabled).toBe(false);
});
});
Loading