Reasoning and Shared State: a Collapsible Thinking Panel and Incremental Sync
First render the model's reasoning as a collapsed-by-default panel, then introduce what really separates an agent frontend from a chat interface: a state shared with the backend, seeded by full snapshots and updated by JSON Patch, so the interface can show progress that lives outside the transcript.
Today's Goals
- Render reasoning events as a collapsible panel, and explain why reasoning must stay separate from the final answer
- Sync shared state with snapshots plus deltas, and say when each is the right choice
- Apply JSON Patch deltas, and recover when a patch does not match local state
Yesterday was about seeing what the agent intends to do. Today we go two steps further: seeing what it is thinking, and seeing the data in its hands. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
The interpreter's scratch pad: worth showing, but not on the big screen
Every good simultaneous interpreter keeps a scratch pad: numbers caught on the fly, a term jotted down, half a sentence they have not settled on. That pad has value to the audience — if the interpreter stalls, a glance at it tells you which word they are stuck on rather than leaving you to wonder whether the equipment broke.
But no conference projects the scratch pad onto the main screen. It is full of crossings-out, false starts, and rejected translations. An audience staring at it would be misled by the working-out, mistaking struck-through attempts for the actual translation.
Reasoning is that scratch pad: worth offering, but offered carefully.
All three design decisions in this section follow from that: collapsed by default, a live indicator while streaming, and clear visual separation from the final answer.
Why reasoning events are separate from message events
AG-UI gives reasoning its own event group, structurally identical to text messages:
| Reasoning | Text message |
|---|---|
REASONING_START | TEXT_MESSAGE_START |
REASONING_MESSAGE_CONTENT | TEXT_MESSAGE_CONTENT |
REASONING_END | TEXT_MESSAGE_END |
Same shape — so why not reuse one group with a flag to distinguish them?
Because once they are mixed, any place that forgets to check the flag renders reasoning as the answer. And reasoning routinely contains "wait, that basis is wrong" and "assume calendar quarters for now". Rendered as a final answer, the user reads a self-contradicting assistant that keeps changing its mind.
Two event groups nail this down with the type system: there is no code path that lets reasoning content flow into a message bubble. Errors made structurally impossible are far more reliable than errors avoided by discipline.
Collapsed or expanded by default
A small decision with a real effect on trust, and there is no universal answer — it depends on your product.
Collapsed (this course's choice): reasoning is process, not conclusion, and most users most of the time want the answer. Expanding by default pushes the actual answer off-screen and makes them scroll for the thing they came for.
Expanded: right for debugging tools, or where the reasoning is itself the product value — math tutoring, code review, diagnostics.
Whichever you pick, two things are mandatory:
A live indicator during streaming. Reasoning is often the longest silence in a run, easily ten or more seconds. A collapsed header that never changes reads as frozen. This course shows "thinking... (137 characters)" with a number that climbs, so the user can see it is alive.
Clear visual separation from the final answer. Lighter color, smaller type, a left border — anything that makes "this is not the conclusion" obvious at a glance.
Shared state: the progress that does not belong in the transcript
Now the more important half of today.
Picture the agent compiling a quarterly report. Some of what it does is worth saying out loud ("let me confirm the basis first"), and some does not belong in a conversation at all:
- A form being filled in field by field
- A report outline taking shape
- A workflow on step three, with the first two ticked off
- A set of metrics filling in as queries return
What these share is that they update in place. As chat messages they become a dozen "step 1 complete" and "step 2 complete" posts, leaving the user to assemble the current state in their head.
Shared state is the dedicated place for this: data maintained by both sides, which the interface renders as a panel that updates in place. It is the most fundamental difference between an agent interface and a chat interface — chat appends, state overwrites.
Snapshots to seed, deltas to advance
The protocol offers two update mechanisms:
// Snapshot: the whole state, authoritative
{ type: 'STATE_SNAPSHOT', snapshot: { task: 'compile quarterly report', steps: [...], metrics: {} } }
// Delta: only what changed, as JSON Patch operations
{ type: 'STATE_DELTA', delta: [
{ op: 'replace', path: '/steps/1/done', value: true },
{ op: 'add', path: '/metrics/revenue', value: 1284000 },
]}When to use which has clear criteria:
| Situation | Use | Why |
|---|---|---|
| Session start, first time the UI sees state | Snapshot | Nothing to patch onto |
| Routine updates as work progresses | Delta | State can be large; resending wastes bandwidth |
| A patch failed, divergence detected | Snapshot | The only reliable way to realign |
| User refreshed or reconnected | Snapshot | Same |
A rule of thumb: deltas are an optimization, snapshots are the correctness guarantee. When in doubt, an extra snapshot is always safe, whereas a missing one can leave the two sides permanently out of sync.
Two engineering properties of JSON Patch
The operations in STATE_DELTA follow RFC 6902. The implementation is not hard (this course hand-writes one, zero dependencies), but two properties must be right, and they are the real engineering content.
First, all or nothing.
// Apply to a clone; if anything fails, discard the whole draft and return the original
export function applyPatch(state, operations) {
const draft = structuredClone(state)
try {
let current = draft
for (const op of operations) current = applyOne(current, op)
return { ok: true, state: current }
} catch (error) {
// Note: return the ORIGINAL, not the half-modified draft
return { ok: false, error, state }
}
}If the third operation in a batch fails, the first two must leave no trace, because a half-applied state is worse than none: you end up holding data neither the server nor the client has ever seen, and every subsequent patch builds on that fiction.
Second, failure must not throw.
A patch failing to apply is expected, not a bug: packet loss, reordering, and version drift after a server restart all cause it. So the right signature returns a result object and lets the caller decide, rather than throwing and taking the interface down.
Why replace on a missing key must fail
One place this course is deliberately strict, and worth its own section.
The spec requires replace to target an existing path. It is tempting to be lenient — if the path is missing, treat it as add, the result is about the same.
Do not. The server sending replace means it believes that key exists and you do not have it — that is itself the signal that the two sides have diverged. Quietly filling it in hides the divergence until it resurfaces somewhere much harder to debug: the user sees data that does not add up, and the logs say nothing.
Strict failure is the better outcome: it converts a hidden data problem into an explicit, immediately recoverable signal.
Should the state panel be editable?
A natural product question: if the state is "shared", can the user edit it directly?
They can, and this is one of the most valuable uses of shared state — a user who notices the agent using the wrong quarter basis can change it to "fiscal" right on the panel, far faster than typing an explanation.
But one thing needs handling: the user's edit and a server patch may collide. The user is editing a field while a patch arrives changing the same field. Who wins?
This course recommends the user wins, and the edit is sent to the server immediately. The user's intent is explicit while the model's update is speculative. In practice that means an optimistic update: apply locally at once and fire a request so the server can adjust what it does next.
If your scenario should not allow edits, make the panel visibly read-only — no inputs, nothing clickable — rather than something that looks editable but silently discards changes.
Recovery: flag desync and wait to realign
With that groundwork, the recovery strategy follows naturally:
const result = applyPatch(prev, event.delta)
if (!result.ok) {
setStateStale(true) // tell the user plainly: this data may be out of date
return prev // keep the old data, never show a half-applied one
}Why this over the alternatives:
- Throw and crash — the user loses the entire session over one patch, wildly disproportionate.
- Silently ignore and keep showing old data — the user sees possibly wrong data and does not know it. The most dangerous option precisely because everything looks fine.
- Flag desync and await realignment — the user knows the data is temporarily untrustworthy, and it recovers automatically when the server sends a snapshot.
In the UI: the panel keeps showing the old data with a notice on top reading "out of sync with the server, realigning...". Showing possibly wrong data is worse than showing "syncing" — a judgment that holds across every interface involving data synchronization.
Keeping the panel readable as state grows
A state panel that starts with four fields can end a long session with forty. Rendering whatever arrives, in arrival order, degrades into an unreadable dump.
Two cheap measures help. Give the panel a fixed shape driven by your own layout rather than by key order in the payload — you know which fields matter most, the model does not. And collapse what has stopped changing: a step finished ten minutes ago rarely needs the same prominence as the one running now.
The general point is that shared state is data the model owns but the interface presents. Those are separate responsibilities, and letting the payload dictate layout confuses them.
Source Reading
Hands-On Lab
You hand-write JSON Patch today, but do not chase completeness — move and copy barely appear in agent scenarios, so this course does not support them and routes them to the failure path. Getting the two engineering properties right matters far more than covering all six operations.
- Render reasoning events as a collapsible panel with an in-progress indicator
- Receive a state snapshot and render it as a structured panel
- Implement JSON Patch application covering add, remove, and replace
- Construct a patch that cannot apply and verify the UI falls back to requesting a snapshot
- Place the state panel alongside the chat stream in one layout
Interview Questions
Four questions below, focused on the trade-offs of exposing reasoning, weighing snapshots against deltas, and recovering from state divergence. Open each one and read the analysis before the answer points — practicing the derivation beats memorizing bullets. The "common in China / common globally" tags let you filter by target market.
Checklist and Tomorrow
- Render reasoning events as a collapsible panel, and explain why reasoning must stay separate from the final answer
- Sync shared state with snapshots plus deltas, and say when each is the right choice
- Apply JSON Patch deltas, and recover when a patch does not match local state
- Explain why
replaceon a missing key should fail rather than be handled leniently - All 6 lab acceptance criteria pass
- Answer at least 3 of the 4 interview questions without looking at the answer points
Tomorrow (D5) we hand the model still more control: letting it decide what the interface looks like. Generative UI comes in three paradigms, handing control progressively from the frontend to the model, and the most flexible one is usually the most dangerous in production. Today's shared state is really its precursor — once the data is decided by the model, the natural next question is whether the components rendering it can be too.
Interview questions
Should the model's reasoning be shown to users? Collapsed or expanded by default? Justify your choice.模型的推理过程要不要展示给用户?默认收起还是默认展开?说出你的理由。
Common in ChinaCommon overseasIntermediate#reasoning-ui#ux#protocol-designHow to reason about it · think before answering
- There is no single right answer here; the question tests whether you can articulate the criteria. Answering 'collapsed' or 'expanded' without reasoning scores nothing.
- Establish a fact first: reasoning contains false starts and self-correction, things like 'wait, that basis is wrong' or 'assume calendar quarters for now'. That alone rules out mixing it with the final answer, or the user reads an assistant that keeps contradicting itself.
- The case for collapsed by default: reasoning is process, not conclusion, and most users most of the time just want the answer. Expanding by default pushes the actual answer off-screen so they have to scroll for the thing they came for. General-purpose products should pick this.
- The case for expanded: debugging tools, or products where the reasoning is the value — math tutoring, code review, diagnostics. There the derivation is what the user is paying for.
- Either way, two things are mandatory. First, a live indicator during streaming: reasoning is often the longest silence in a run, easily ten seconds or more, and a static collapsed header reads as frozen. Show 'thinking... (N characters)' with a number that climbs. Second, visual separation from the final answer via lighter text or a left border, so nobody mistakes it for the conclusion.
- Expect the follow-up on how the protocol prevents mixing: AG-UI gives reasoning its own event group rather than a flag on text messages, so there is no code path that lets reasoning flow into a message bubble. **Errors made structurally impossible beat errors avoided by discipline.**
分析过程 · 先想清楚再作答
- 这题没有唯一答案,考的是你能不能说出判断依据。直接答「收起」或「展开」而不给理由,等于没答。
- 先确立一个事实:推理内容里常有试错和自我否定,比如「等等,这个口径不对」「先假设是自然季度」。这决定了它不能和正式回答混在一起显示,否则用户读到的是一个来回改口的助手。
- 默认收起的理由:推理是过程不是结论,多数用户多数时候只想要答案。默认展开会把正式回答挤到屏幕外,用户还得往下滚才能看到自己真正要的东西。通用产品应该选这个。
- 默认展开的适用场景:调试工具,或者推理本身就是产品价值的一部分——数学解题、代码审查、诊断类工具,用户买的就是那个推导过程。
- 不管选哪个,有两件事都要做。一是流式期间必须给一个活的提示:推理往往是整次运行里最长的一段静默,可能十几秒,折叠标题一动不动用户会以为卡住了,所以要显示「正在思考…(已 N 字)」让那个数字涨起来。二是视觉上必须和正式回答明确区分,用更浅的颜色或一道左边框,让人一眼看出这不是最终答案。
- 可预期的追问是「协议层面怎么保证不混」——AG-UI 给推理单独开了一组事件而不是在文本消息上加标记。这样渲染代码里根本没有路径能让推理流进消息气泡,**结构上做不到的错误比靠纪律避免的错误可靠**。
Key points
- Reasoning contains false starts, so it must be separated from the final answer.
- Collapse by default in general products: reasoning is process, and expanding pushes the answer off-screen.
- Expand by default for debugging tools or products where the derivation is the value.
- Always show a live indicator while streaming, or a ten-second silence reads as frozen.
- Use a separate event group rather than a flag, making the mixing error structurally impossible.
答题要点
- 推理含试错和自我否定,必须与正式回答分开,否则用户读到一个来回改口的助手。
- 通用产品默认收起:推理是过程不是结论,默认展开会把答案挤到屏幕外。
- 调试工具或推理本身即价值的产品(解题、审查、诊断)可以默认展开。
- 流式期间必须有活的提示(正在思考加字数),否则十几秒静默会被当成卡住。
- 协议层用独立事件组而不是加标记,让「推理流进消息气泡」在结构上不可能发生。
Shared state can be sent as full snapshots or as incremental patches. How do you decide which?共享状态既可以每次发完整快照,也可以发增量补丁,你怎么决定用哪种?
Common in ChinaCommon overseasIntermediate#state-sync#protocol-design#architectureHow to reason about it · think before answering
- This asks for a decision rule, not a symmetric list of pros and cons. 'Snapshots are simple, patches save bandwidth' leaves the interviewer unsure what you would actually do.
- Offer an actionable principle: **patches are an optimization, snapshots are the correctness guarantee**. When in doubt, an extra snapshot is always safe, while a missing one can leave the two sides permanently out of sync.
- Then split by situation: session start and first load must be a snapshot, since there is nothing to patch; routine progress updates use patches because resending a large state is wasteful; and detecting a failed patch or reconnecting after a refresh calls for a snapshot to realign.
- One easily missed case worth raising unprompted: **receiving a patch with no local snapshot at all**. That means you missed the beginning. The right response is to flag desync and request a snapshot, not to invent an empty object and patch onto it, which fabricates state the server has never seen.
- If you want to go deeper on cost: patches cost more than bandwidth, they add implementation complexity and a whole class of failure modes (reordering, loss, version drift). When state is small, sending snapshots exclusively is a perfectly sound engineering choice.
- Expect the follow-up on detecting divergence: a patch that fails to apply is the most direct signal, which is why failure handling has to be right.
分析过程 · 先想清楚再作答
- 这题考的是你会不会给出判据,而不是罗列两者的优缺点。「快照简单、增量省流量」这种对称的罗列,面试官听完不知道你到底会怎么选。
- 给一条能落地的原则:**增量是优化,快照是正确性的保障**。拿不准的时候多发一次快照永远是安全的,而少发一次快照可能让两端永久性地对不上。
- 然后按场合分:会话开始、界面第一次拿到状态,必须用快照——没有底就没法打补丁;工作推进中的常规更新用增量,状态可能很大,每次重发浪费带宽;检测到补丁打不上、或者用户刷新页面重新连接,用快照重新对齐。
- 还有一个容易漏的场景值得主动提:**收到增量但本地根本没有快照**。这说明漏了开头,正确反应是标记不同步并请求快照,而不是凭空造一个空对象往上打补丁——那样会造出一份服务端从没见过的数据。
- 如果要展开成本讨论:增量的代价不只是带宽,还有实现复杂度和一整类新的失败模式(乱序、丢包、版本漂移)。状态本身很小的时候,一律发快照是完全合理的工程选择,不要为了显得先进而引入增量。
- 可预期的追问是「怎么知道两端对不上了」——补丁打不上就是最直接的信号,这也是为什么补丁的失败处理必须做对,见下一题。
Key points
- The rule: patches optimize, snapshots guarantee correctness; an extra snapshot is always safe.
- Snapshot on session start and reconnect; patch for routine progress updates.
- Use a snapshot to realign whenever a patch fails or divergence is detected.
- A patch with no local snapshot means you missed the start: request a snapshot rather than inventing empty state.
- For small state, snapshots only is a sound choice; patches add reordering, loss, and drift as failure modes.
答题要点
- 原则是增量为优化、快照为正确性保障;拿不准时多发快照永远安全。
- 会话开始与重连用快照,工作推进中的常规更新用增量。
- 补丁打不上或检测到不一致时,用快照重新对齐。
- 收到增量但本地没有快照,说明漏了开头,要请求快照而不是凭空造一个空状态。
- 状态本身很小时一律发快照是合理选择,增量会带来乱序丢包漂移这一整类失败模式。
Your frontend receives a JSON Patch that cannot be applied. What does that tell you, and how do you recover?前端收到一个打不上的 JSON Patch,说明发生了什么?你的恢复策略是什么?
Common in ChinaCommon overseasDeep dive#state-sync#error-handling#json-patchHow to reason about it · think before answering
- The discriminator is whether you treat a failed patch as a bug or as an expected event. People who see it as a bug answer 'add logging' and then have no recovery story.
- Characterize it first: failed patches are **expected**, not programming errors. Packet loss, reordering, and version drift after a server restart all cause them. The meaning is singular — **the two sides have diverged**.
- So requirement one: `applyPatch` must **return a result rather than throw**, letting the caller decide. Throwing escalates a recoverable sync problem into a crashed interface.
- Requirement two is **all or nothing**: if the third operation in a batch fails, the first two must leave no trace. Apply to a deep-cloned draft and discard the whole draft on failure, returning the original. A half-applied state is worse than none, because it is data neither side has ever seen, and every subsequent patch builds on that fiction.
- For recovery, weigh three options: throwing and crashing (the user loses the whole session over one patch); silently ignoring and showing stale data (the most dangerous, since the user sees possibly wrong data and does not know it); and **flagging desync while awaiting a fresh snapshot** (the right answer). Keep the old data visible but add a clear notice, because showing possibly wrong data is worse than showing 'syncing'.
- Expect the follow-up on whether `replace` to a missing key should be leniently treated as `add`: no. The server sending replace means it believes the key exists and you do not have it, which is itself the divergence signal. Quietly patching over it hides the problem until it surfaces somewhere harder to debug.
分析过程 · 先想清楚再作答
- 这题的区分度在于你把打不上当成 bug 还是当成可预期事件。当成 bug 的人会答「加日志排查」,然后就没有恢复策略了。
- 先定性:补丁打不上是**可预期的**,不是程序错误。丢包、乱序、服务端重启导致的版本漂移都会造成这种情况。它的含义只有一个——**两端状态已经不一致了**。
- 所以实现上第一条要求是:`applyPatch` 失败时**返回结果而不是抛异常**,让调用方决定怎么办。抛异常等于把一个可恢复的同步问题升级成界面崩溃。
- 第二条要求是**全有或全无**:一组补丁里第三条失败了,前两条也不能留下痕迹。做法是在深拷贝的副本上执行,失败就整个丢弃、返回原状态。半应用的状态比不应用更糟——你会得到一份服务端和客户端谁都没见过的数据,之后所有补丁都建立在这份幻觉上。
- 恢复策略在三个选项里选:抛异常崩掉(用户丢掉整个会话,代价远大于一条补丁);静默忽略继续显示旧数据(最危险,用户看到可能错的数据而且不知道它错了);**标记不同步并等服务端补发快照重新对齐**(正确答案)。界面上保留旧数据但加一条明确提示,因为显示一份可能是错的数据比显示「正在同步」更糟。
- 可预期的追问是「replace 到不存在的键要不要宽容处理成 add」——不要。服务端发 replace 说明它认为那个键存在而本地没有,这本身就是不一致的信号,悄悄补上等于把问题藏到更难查的时候。严格失败反而把隐蔽的数据问题变成可立刻恢复的明确信号。
Key points
- A failed patch is an expected event, not a bug; it means the two sides have diverged.
- applyPatch should return a failure result rather than throw, so a sync issue does not become a crash.
- All or nothing: apply to a clone, discard the whole batch on failure, never leave half-applied data.
- Recover by flagging desync and awaiting a snapshot, keeping old data visible with a clear notice.
- Silent ignoring is the most dangerous option, since users see possibly wrong data unknowingly.
答题要点
- 打不上是可预期事件而非 bug,含义是两端状态已经不一致。
- applyPatch 失败要返回结果而不是抛异常,别把同步问题升级成界面崩溃。
- 必须全有或全无:在副本上执行,失败整组丢弃返回原状态,不留半应用数据。
- 恢复策略是标记不同步并等服务端补发快照,界面保留旧数据但加明确提示。
- 静默忽略是最危险的选项,因为用户看到可能错的数据却不知道它是错的。
What belongs in a shared state panel versus in the conversation itself?什么样的信息该放进共享状态面板,什么样的该留在对话里?
Common in ChinaCommon overseasIntermediate#information-architecture#ux#agent-uiHow to reason about it · think before answering
- It looks like a product question but rests on a sharply technical criterion, and stating it shows you understand the underlying difference.
- The rule in one line: **chat appends, state overwrites**. Information that updates in place belongs in the state panel; information that happens once belongs in the conversation.
- Apply it and it works cleanly: a workflow on step three, a metrics set filling in, a form being completed — all update in place, so they belong in the panel. As chat messages they become a dozen 'step 1 done' and 'step 2 done' posts, leaving the user to reconstruct the current state mentally.
- Conversely, the model's explanations, questions, and final conclusions are one-time utterances and belong in the conversation. Forcing them into a panel destroys temporal order, which is precisely what the transcript is for.
- There is a middle ground worth raising: tool calls. They have both process and result, and this course places them as cards on the **conversation timeline**, because a call is an action initiated at a moment and its position in time is meaningful, while its state changes happen inside the card. So a third form exists: mutable cards on a timeline.
- Expect the follow-up on whether the state panel should show history: usually not. Its value is 'what things are now'. When history matters, build a separate timeline or diff view rather than mixing two mental models on one screen.
分析过程 · 先想清楚再作答
- 这题看着像产品问题,其实有一条很技术的判据,答出来就说明你理解了两者的本质差别。
- 判据一句话:**聊天是追加的,状态是覆盖的**。会原地更新的信息放状态面板,只发生一次的信息放对话。
- 套上去很好用:一个走到第三步的流程、一份逐步补全的指标、一个正在被填写的表单,都会原地更新,所以属于状态面板。如果做成聊天消息,你会得到十几条「已完成第 1 步」「已完成第 2 步」的刷屏,用户还得自己在脑子里拼出当前状态。
- 反过来,模型的解释、提问、最终结论都是一次性的表达,属于对话。硬塞进状态面板会丢掉时间顺序,而对话的价值恰恰在于它记录了「什么时候说了什么」。
- 有个中间地带值得主动提:工具调用。它既有过程(参数准备、执行中)又有结果,本课的做法是把它作为一张卡片放在**对话时间线**上,因为它是「某个时刻发起的一次动作」,时间位置有意义;而它的状态变化发生在卡片内部,不影响时间线。这说明第三种形态是存在的——时间线上的可变卡片。
- 可预期的追问是「那状态面板要不要显示历史」——通常不要。面板的价值就是「当前是什么样」,需要历史时应该是一个独立的时间线视图或者版本对比,不要把两种心智模型混在一块屏幕里。
Key points
- The rule: chat appends, state overwrites. In-place updates go to the panel; one-time events go to the conversation.
- Workflow steps, accumulating metrics, and forms belong in the panel; as messages they spam the transcript.
- Explanations, questions, and conclusions are one-time utterances and belong in the conversation.
- Tool calls are the middle form: mutable cards on the timeline, since when they were initiated matters.
- Panels generally should not show history; build a separate timeline or diff view when history matters.
答题要点
- 判据是聊天追加、状态覆盖:会原地更新的进状态面板,只发生一次的进对话。
- 流程步骤、逐步补全的指标、被填写的表单属于状态面板,做成消息会刷屏。
- 模型的解释、提问、结论是一次性表达,属于对话,塞进面板会丢掉时间顺序。
- 工具调用是中间形态:作为可变卡片放在对话时间线上,因为发起时刻有意义。
- 状态面板通常不显示历史,需要历史应另做时间线或版本对比视图。