Skip to content

Commit 00e83eb

Browse files
committed
fix(chat): keep focus when a waiting call lapses or is dropped
MessageParts moved focus only after a decision made through its own decide. When the server resolved a call to timeout or expired while focus was on its buttons, the call folded into its run, the buttons unmounted, and focus fell to the page. The same happened when a host recorded a decision by removing the call from parts. Track which waiting call's card holds focus from the root's focus and blur events, and give a call that stops waiting under focus the same handoff as a decided one: the next waiting call in a batch, else the run's trigger. For a call that is gone from parts, fall back to the group it stood in, then the group before it, then the first control in the message. Document the markup custom renderBatch and renderActivity must keep for the handoff, and that renderText takes precedence over renderMarkdown. Point the 0.3 upgrade rows at MessageParts, and name the image adapter's argument index.
1 parent 68f4800 commit 00e83eb

5 files changed

Lines changed: 453 additions & 78 deletions

File tree

‎README.md‎

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -335,40 +335,40 @@ The old chat family is removed. The conversation family replaces it: a message i
335335
336336
Removed exports and what replaces them:
337337
338-
| Removed | Use instead |
339-
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
340-
| `ChatThread`, `ChatThreadProps` | `Conversation` with `ConversationContent`, a `ConversationItem` per message and `ConversationScrollButton` |
341-
| `AgentTurn`, `AgentTurnProps` | `Message` with `from="agent"`, `MessageAvatar`, `MessageHeader` and `MessageContent`; thinking and tool calls through `groupActivity` and `Activity` |
342-
| `MessageBubble`, `MessageBubbleProps` | `Message` with `MessageContent`; `from="own"` draws the bubble |
343-
| `PartsRenderer`, `PartsRendererProps` | Map `groupActivity(message.parts)` to `Activity`, `MessageContent`, `ToolApproval`, `ToolGroup`, `GeneratedImages`, `Sources` and `Attachments`; `block` parts are the host's to render |
344-
| `toolTraceToBlockState` | No replacement: `Tool` reads `ToolTraceStatus` and the approval record directly |
345-
| `ReasoningBlock`, `ReasoningBlockProps` | `Reasoning` (`ReasoningTrigger`, `ReasoningContent`) on a `PartReasoning` |
346-
| `ToolBlock`, `ToolBlockProps`, `ToolBlockState` | `Tool` (`ToolHeader`, `ToolInput`, `ToolOutput`) on a `PartTool`; the state is its `ToolTraceStatus` |
347-
| `ToolNarrative`, `ToolNarrativeProps` | `Tool`, or `ToolGroup` for calls that ran together |
348-
| `ApprovalCard`, `ApprovalCardProps` | `ToolApproval` on a `PartTool` with a `ToolApprovalRecord`; `onApprove`, `onReject` and `onAllowAlways` become `onDecide(ToolApprovalDecision)`, and a busy `state` becomes `disabled` |
349-
| `ApprovalRequest`, `ApprovalDetail` | The `PartTool` itself: `name` and `input`, with the host's own account of the action as `ToolApproval`'s children |
350-
| `ApprovalState` | `ToolApprovalRecord`'s `status` for the outcome, `ToolApproval`'s `disabled` while a decision is recorded |
351-
| `ChatInput`, `ChatInputProps` | `PromptInput` with `PromptInputTextarea` and `PromptInputSubmit`; `PromptInputToolbar` for controls under the text |
352-
| `ChatAttachment` | `AttachmentData` for `Attachments`, `AttachmentItem` from `useAttachments` |
353-
| `ChatComposer`, `ChatComposerProps` | `PromptInput`, with `ConversationEmptyState` for an empty conversation |
354-
| `QuickReplyChips`, `QuickReply` | No replacement: suggested replies are the host's own buttons, for example inside `ConversationEmptyState` |
355-
| `ChatPanel`, `ChatPanelHeader`, `ChatPanelFooter` | `Panel`, `PanelHeader` and `PanelContent` for a side panel; put `Conversation` and `PromptInput` in your own frame |
356-
| `ChatMessage` | `ConversationMessage`: `role` becomes `author` (`ConversationParticipant`), `createdAt` becomes `at`, `status` is required |
357-
| `ChatRole` | `ConversationParticipant`'s `kind` (`person` or `agent`); a system line is a `PartEvent`, shown with `ConversationNotice` |
358-
| `MessageStatus` | `ConversationMessageStatus`, which adds `streaming` |
359-
| `MessagePart` | `ConversationPart`, discriminated by `kind` instead of `type` |
360-
| `TextPart`, `ReasoningPart`, `FilePart` | `PartText`, `PartReasoning`, `PartFile` |
361-
| `ToolPart` | `PartTool`: `toolName` becomes `name`, `state` becomes `status`, and `label` moves to `ToolHeader`'s `label` |
362-
| `ToolPartState` | `ToolTraceStatus`: `done` becomes `output-available` |
363-
| `AgentIdentity` | `ConversationParticipant` with `kind: "agent"`; `tagline` becomes `role`, and `MessageAvatar` takes `initials` |
364-
| `agentInitials` | `MessageAvatar`, which derives initials from `name` |
365-
| `SubagentRun`, `SubagentState` | No replacement: the conversation model has no subagent type; show a helper's calls as `PartTool`s or a `PartToolBatch` |
366-
| `ChatThreadSummary` | No replacement: a list of conversations is the host's own data |
367-
| `messageText`, `reasoningText`, `toolParts` | No replacement: filter `message.parts` by `kind` |
368-
| `isTurnWorking` | `parts.some((p) => p.kind === "tool-trace" && p.status === "running")` (any tool still running; `message.status === "streaming"` is the whole message) |
369-
| `toolLabel` | No replacement: `ToolHeader` shows the raw tool name, after the `label` you pass |
370-
| `ToolProviderMark`, `ToolProviderMarkProps` | `ProviderMark` with the provider read off the tool id (`name.split("__")[0]`, for ids shaped `provider__operation`) |
371-
| `useDelayedAutofocus` | No replacement: focus the element from an effect once its entrance has finished |
338+
| Removed | Use instead |
339+
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
340+
| `ChatThread`, `ChatThreadProps` | `Conversation` with `ConversationContent`, a `ConversationItem` per message and `ConversationScrollButton` |
341+
| `AgentTurn`, `AgentTurnProps` | `Message` with `from="agent"`, `MessageAvatar`, `MessageHeader` and `MessageContent`, with `MessageParts` for the parts |
342+
| `MessageBubble`, `MessageBubbleProps` | `Message` with `MessageContent`; `from="own"` draws the bubble |
343+
| `PartsRenderer`, `PartsRendererProps` | `MessageParts` inside a `Message`; `renderBlock` for `block` parts |
344+
| `toolTraceToBlockState` | No replacement: `Tool` reads `ToolTraceStatus` and the approval record directly |
345+
| `ReasoningBlock`, `ReasoningBlockProps` | `Reasoning` (`ReasoningTrigger`, `ReasoningContent`) on a `PartReasoning` |
346+
| `ToolBlock`, `ToolBlockProps`, `ToolBlockState` | `Tool` (`ToolHeader`, `ToolInput`, `ToolOutput`) on a `PartTool`; the state is its `ToolTraceStatus` |
347+
| `ToolNarrative`, `ToolNarrativeProps` | `Tool`, or `ToolGroup` for calls that ran together |
348+
| `ApprovalCard`, `ApprovalCardProps` | `ToolApproval` on a `PartTool` with a `ToolApprovalRecord`; `onApprove`, `onReject` and `onAllowAlways` become `onDecide(ToolApprovalDecision)`, and a busy `state` becomes `disabled` |
349+
| `ApprovalRequest`, `ApprovalDetail` | The `PartTool` itself: `name` and `input`, with the host's own account of the action as `ToolApproval`'s children |
350+
| `ApprovalState` | `ToolApprovalRecord`'s `status` for the outcome, `ToolApproval`'s `disabled` while a decision is recorded |
351+
| `ChatInput`, `ChatInputProps` | `PromptInput` with `PromptInputTextarea` and `PromptInputSubmit`; `PromptInputToolbar` for controls under the text |
352+
| `ChatAttachment` | `AttachmentData` for `Attachments`, `AttachmentItem` from `useAttachments` |
353+
| `ChatComposer`, `ChatComposerProps` | `PromptInput`, with `ConversationEmptyState` for an empty conversation |
354+
| `QuickReplyChips`, `QuickReply` | No replacement: suggested replies are the host's own buttons, for example inside `ConversationEmptyState` |
355+
| `ChatPanel`, `ChatPanelHeader`, `ChatPanelFooter` | `Panel`, `PanelHeader` and `PanelContent` for a side panel; put `Conversation` and `PromptInput` in your own frame |
356+
| `ChatMessage` | `ConversationMessage`: `role` becomes `author` (`ConversationParticipant`), `createdAt` becomes `at`, `status` is required |
357+
| `ChatRole` | `ConversationParticipant`'s `kind` (`person` or `agent`); a system line is a `PartEvent`, shown with `ConversationNotice` |
358+
| `MessageStatus` | `ConversationMessageStatus`, which adds `streaming` |
359+
| `MessagePart` | `ConversationPart`, discriminated by `kind` instead of `type` |
360+
| `TextPart`, `ReasoningPart`, `FilePart` | `PartText`, `PartReasoning`, `PartFile` |
361+
| `ToolPart` | `PartTool`: `toolName` becomes `name`, `state` becomes `status`, and `label` moves to `ToolHeader`'s `label` |
362+
| `ToolPartState` | `ToolTraceStatus`: `done` becomes `output-available` |
363+
| `AgentIdentity` | `ConversationParticipant` with `kind: "agent"`; `tagline` becomes `role`, and `MessageAvatar` takes `initials` |
364+
| `agentInitials` | `MessageAvatar`, which derives initials from `name` |
365+
| `SubagentRun`, `SubagentState` | No replacement: the conversation model has no subagent type; show a helper's calls as `PartTool`s or a `PartToolBatch` |
366+
| `ChatThreadSummary` | No replacement: a list of conversations is the host's own data |
367+
| `messageText`, `reasoningText`, `toolParts` | No replacement: filter `message.parts` by `kind` |
368+
| `isTurnWorking` | `parts.some((p) => p.kind === "tool-trace" && p.status === "running")` (any tool still running; `message.status === "streaming"` is the whole message) |
369+
| `toolLabel` | No replacement: `ToolHeader` shows the raw tool name, after the `label` you pass |
370+
| `ToolProviderMark`, `ToolProviderMarkProps` | `ProviderMark` with the provider read off the tool id (`name.split("__")[0]`, for ids shaped `provider__operation`) |
371+
| `useDelayedAutofocus` | No replacement: focus the element from an effect once its entrance has finished |
372372
373373
## Upgrading from 0.1
374374

‎e2e/fixtures.tsx‎

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -822,12 +822,35 @@ function pendingApproval(id: string): ToolApprovalRecord {
822822
return { id, status: "pending", scope: null, timeoutAt: null };
823823
}
824824

825+
/** A call that stopped waiting with no decision, as the server reports it. */
826+
type Lapse = {
827+
readonly toolCallId: string;
828+
readonly status: "timeout" | "expired";
829+
};
830+
831+
/**
832+
* Calls `onLapse` for each `approval-lapse` event on the window: the specs'
833+
* stand-in for the server resolving an approval nobody decided, without
834+
* moving focus the way a control on the page would.
835+
*/
836+
function useApprovalLapse(onLapse: (lapse: Lapse) => void) {
837+
useEffect(() => {
838+
const listener = (event: Event) =>
839+
onLapse((event as CustomEvent<Lapse>).detail);
840+
window.addEventListener("approval-lapse", listener);
841+
return () => window.removeEventListener("approval-lapse", listener);
842+
});
843+
}
844+
825845
// A host's view of one agent turn: a finished draft, then a send waiting on a
826846
// person. Deciding records the outcome on the call, which `MessageParts` then
827847
// folds into the run before it.
828848
function ToolApprovalScenario() {
829849
const { log, record } = useEventLog();
830850
const [approval, setApproval] = useState(pendingApproval("approval-1"));
851+
useApprovalLapse(({ status }) =>
852+
setApproval((current) => ({ ...current, status })),
853+
);
831854
const parts: PartTool[] = [
832855
{
833856
kind: "tool-trace",
@@ -941,6 +964,13 @@ function ToolBatchApprovalScenario() {
941964
notice: pendingApproval("approval-notice"),
942965
calendar: pendingApproval("approval-calendar"),
943966
});
967+
const update = (toolCallId: string, change: Partial<ToolApprovalRecord>) =>
968+
setApprovals((current) => {
969+
const approval = current[toolCallId];
970+
if (approval === undefined) return current;
971+
return { ...current, [toolCallId]: { ...approval, ...change } };
972+
});
973+
useApprovalLapse(({ toolCallId, status }) => update(toolCallId, { status }));
944974
const parts: ConversationPart[] = [
945975
{
946976
kind: "tool-trace",
@@ -978,17 +1008,9 @@ function ToolBatchApprovalScenario() {
9781008
<MessageParts
9791009
parts={parts}
9801010
onDecide={(tool, decision) =>
981-
setApprovals((current) => {
982-
const approval = current[tool.toolCallId];
983-
if (approval === undefined) return current;
984-
return {
985-
...current,
986-
[tool.toolCallId]: {
987-
...approval,
988-
status: decision.outcome,
989-
scope: decision.scope,
990-
},
991-
};
1011+
update(tool.toolCallId, {
1012+
status: decision.outcome,
1013+
scope: decision.scope,
9921014
})
9931015
}
9941016
/>

0 commit comments

Comments
 (0)