Skip to content

Commit 768eb4e

Browse files
秦奇claude
andcommitted
docs(daemon-ui): add developer guide + migration cookbook (PR-I)
Closes the final "Documentation" item in PR QwenLM#4353's TODO §A. Brings the unified daemon UI surface to ~95% SDK-side completion. ## Files added - `docs/developers/daemon-ui/README.md` — full API reference - Three-layer model (normalizer → reducer → render helpers) - Quick start with idiomatic event-loop pattern - Event taxonomy (28+ types categorized: chat-stream / session-meta / workspace / auth device-flow) - Render contract cookbook (markdown / HTML / plainText) - Tool preview taxonomy (13 kinds with use cases) - State selectors (currentTool / approvalMode / toolProgress / ordering) - Cancellation propagation explanation - Time semantics (eventId > serverTimestamp > clientReceivedAt precedence) - Adapter conformance usage - ErrorKind dispatch pattern - Tool provenance dispatch pattern - Forward-compat principles - `docs/developers/daemon-ui/MIGRATION.md` — adapter author migration cookbook - Step-by-step recommended adoption order (9 steps, value-ranked) - Before/after code examples for each step - Backward-compat checklist (everything is additive — no breaking changes) - Cross-references to PR-A through PR-H commits ## Roadmap PR-I of the unified follow-up to PR QwenLM#4328. Documentation-only — no code changes; no tests affected. Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1785f5f commit 768eb4e

2 files changed

Lines changed: 593 additions & 0 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
# Migrating to `@qwen-code/sdk/daemon` v2
2+
3+
PR #4328 shipped the v1 daemon UI layer. PR #4353 (this PR) ships v2 with
4+
seven additive feature commits. This guide walks through the changes for
5+
adapter authors (TUI / web / IDE / channel / mobile maintainers).
6+
7+
## TL;DR for existing consumers
8+
9+
**No breaking changes.** Every commit in this PR is additive:
10+
11+
- v1 fields still work (`createdAt` preserved as `@deprecated` alias for
12+
`clientReceivedAt`)
13+
- v1 normalizer still maps the same 13 event types the same way
14+
- v1 reducer still produces the same blocks for chat events
15+
- New API is opt-in via additional parameters and helpers
16+
17+
The PR is safe to merge without any consumer changes. **Adoption of the
18+
new features is incremental.**
19+
20+
## Recommended adoption order
21+
22+
For each adapter, in order of effort/value ratio:
23+
24+
### 1. Ordering: switch sort key from `createdAt` to `eventId`
25+
26+
**Before:**
27+
28+
```ts
29+
const ordered = [...state.blocks].sort((a, b) => a.createdAt - b.createdAt);
30+
```
31+
32+
**After:**
33+
34+
```ts
35+
import { selectTranscriptBlocksOrderedByEventId } from '@qwen-code/sdk/daemon';
36+
const ordered = selectTranscriptBlocksOrderedByEventId(state);
37+
```
38+
39+
**Why**: `eventId` is daemon-monotonic; survives SSE replay-after-reconnect.
40+
`createdAt` is client clock and shifts under replay.
41+
42+
### 2. Display: switch `createdAt` to `serverTimestamp ?? clientReceivedAt`
43+
44+
**Before:**
45+
46+
```tsx
47+
<TimeLabel ms={block.createdAt} />
48+
```
49+
50+
**After:**
51+
52+
```tsx
53+
import { formatBlockTimestamp } from '@qwen-code/sdk/daemon';
54+
<TimeLabel text={formatBlockTimestamp(block, { locale })} />
55+
```
56+
57+
**Why**: Multiple clients see consistent "X minutes ago" only when both
58+
read daemon clock. Renderer plus `formatBlockTimestamp` handles tz +
59+
locale.
60+
61+
**Note**: Daemon needs to stamp `_meta.serverTimestamp` on envelopes for
62+
this to take effect. SDK forward-compat-ready; falls back to
63+
`clientReceivedAt` until then.
64+
65+
### 3. Listen for new event types — pick subset to render
66+
67+
The 16 new event types (session-meta, workspace, auth) don't push transcript
68+
blocks. They are sidechannel observations. Each adapter picks which to surface:
69+
70+
```ts
71+
// In your SSE consumer
72+
const uiEvents = normalizeDaemonEvent(envelope, { clientId, suppressOwnUserEcho: true });
73+
store.dispatch(uiEvents);
74+
75+
// Then in your UI side
76+
for (const event of uiEvents) {
77+
switch (event.type) {
78+
case 'session.approval_mode.changed':
79+
myApprovalModeBadge.update(event.next);
80+
break;
81+
case 'workspace.mcp.budget_warning':
82+
myToast.show(`MCP servers approaching budget: ${event.liveCount}/${event.budget}`);
83+
break;
84+
case 'auth.device_flow.started':
85+
myAuthModal.show({
86+
deviceFlowId: event.deviceFlowId,
87+
providerId: event.providerId,
88+
expiresAt: event.expiresAt,
89+
});
90+
break;
91+
// ... etc, opt into what your UI needs
92+
}
93+
}
94+
```
95+
96+
Or use selectors for state-mirrored sidechannels:
97+
98+
```ts
99+
import { selectApprovalMode, selectCurrentTool } from '@qwen-code/sdk/daemon';
100+
101+
const mode = selectApprovalMode(state); // mirrored from approval_mode.changed
102+
const currentTool = selectCurrentTool(state); // current in-flight tool
103+
```
104+
105+
### 4. Render contract: use `daemonBlockToMarkdown` (or HTML / plainText)
106+
107+
**Before** (each adapter does its own projection):
108+
109+
```ts
110+
function blockToString(block: DaemonTranscriptBlock): string {
111+
switch (block.kind) {
112+
case 'user': return `You: ${block.text}`;
113+
case 'assistant': return block.text;
114+
case 'tool': return `[${block.title}]\n${block.status}`;
115+
// ... etc
116+
}
117+
}
118+
```
119+
120+
**After** (delegate to SDK):
121+
122+
```ts
123+
import { daemonBlockToMarkdown } from '@qwen-code/sdk/daemon';
124+
const md = daemonBlockToMarkdown(block);
125+
```
126+
127+
For HTML SSR:
128+
129+
```ts
130+
import MarkdownIt from 'markdown-it';
131+
import DOMPurify from 'dompurify';
132+
const html = DOMPurify.sanitize(md.render(daemonBlockToMarkdown(block)));
133+
```
134+
135+
For plain text:
136+
137+
```ts
138+
import { daemonBlockToPlainText } from '@qwen-code/sdk/daemon';
139+
const plain = daemonBlockToPlainText(block);
140+
```
141+
142+
### 5. Conformance test
143+
144+
Add to your adapter's test suite:
145+
146+
```ts
147+
import { runAdapterConformanceSuite } from '@qwen-code/sdk/daemon';
148+
149+
it('adapter projects daemon UI corpus correctly', () => {
150+
const result = runAdapterConformanceSuite({
151+
reduce: (events) => myReduce(events),
152+
renderToText: (state) => myRender(state),
153+
});
154+
expect(result.failed).toEqual([]);
155+
});
156+
```
157+
158+
This will run your adapter against 10 fixture scenarios and surface any
159+
projection drift before it reaches users.
160+
161+
### 6. Tool icon dispatch via `provenance`
162+
163+
**Before** (string match on toolName):
164+
165+
```tsx
166+
const isMcp = toolName?.startsWith('mcp__');
167+
const isBuiltin = ['Bash', 'Edit', 'Read'].includes(toolName);
168+
```
169+
170+
**After** (typed provenance from PR-A):
171+
172+
```tsx
173+
import type { DaemonUiToolUpdateEvent } from '@qwen-code/sdk/daemon';
174+
175+
function toolIcon(event: DaemonUiToolUpdateEvent): React.ReactNode {
176+
switch (event.provenance) {
177+
case 'mcp': return <McpIcon server={event.serverId} />;
178+
case 'subagent': return <SubagentIcon />;
179+
case 'builtin': return <BuiltinIcon name={event.toolName} />;
180+
case 'unknown':
181+
default: return <GenericIcon />;
182+
}
183+
}
184+
```
185+
186+
SDK has a `mcp__<server>__<tool>` naming heuristic fallback — works today
187+
even when daemon doesn't explicitly stamp provenance.
188+
189+
### 7. Error categorization via `errorKind`
190+
191+
**Before** (regex on text):
192+
193+
```ts
194+
if (error.text.includes('auth')) showAuthRetry();
195+
else if (error.text.includes('file not found')) showFilePicker();
196+
```
197+
198+
**After** (closed enum from PR-A):
199+
200+
```ts
201+
import type { DaemonErrorKind } from '@qwen-code/sdk/daemon';
202+
203+
function errorAction(errorKind?: DaemonErrorKind): React.ReactNode {
204+
switch (errorKind) {
205+
case 'auth_env_error': return <RetryAuthButton />;
206+
case 'missing_file': return <FilePicker />;
207+
case 'blocked_egress': return <CheckProxyHint />;
208+
case 'init_timeout': return <RestartDaemonButton />;
209+
default: return null;
210+
}
211+
}
212+
```
213+
214+
**Note**: Daemon needs to stamp `data.errorKind` on session_died /
215+
stream_error for this to populate. SDK already reads it.
216+
217+
### 8. Cancellation handling — already automatic
218+
219+
In v1, cancelled prompts left in-flight tool blocks spinning forever.
220+
In v2 (PR-E), `propagateCancellationToInFlightTools` runs automatically
221+
on `assistant.done.reason === 'cancelled'`.
222+
223+
**No adapter changes needed** — your spinners will resolve correctly.
224+
225+
### 9. Tool preview taxonomy — pick subset to render with custom components
226+
227+
PR-D + PR-F bring 13 preview kinds:
228+
229+
- 4 file-shaped: `file_diff`, `file_read`, `web_fetch`, `mcp_invocation`
230+
- 5 content-shaped: `code_block`, `search`, `tabular`, `image_generation`, `subagent_delegation`
231+
- 2 control: `ask_user_question`, `command`
232+
- 2 generic: `key_value`, `generic`
233+
234+
Each adapter dispatches on `preview.kind`:
235+
236+
```tsx
237+
function ToolPreviewComponent({ preview }: { preview: DaemonToolPreview }) {
238+
switch (preview.kind) {
239+
case 'file_diff': return <UnifiedDiffView path={preview.path} old={preview.oldText} new={preview.newText} />;
240+
case 'mcp_invocation': return <McpCard serverId={preview.serverId} toolName={preview.toolName} />;
241+
case 'tabular': return <DataTable columns={preview.columns} rows={preview.rows} />;
242+
case 'image_generation': return <ImagePreview thumbnailUrl={preview.thumbnailUrl} prompt={preview.prompt} />;
243+
// ... or fall back to:
244+
default: return <Markdown text={daemonToolPreviewToMarkdown(preview)} />;
245+
}
246+
}
247+
```
248+
249+
Adapters without custom components for all 13 kinds can fall back to the
250+
SDK's `daemonToolPreviewToMarkdown` for any unhandled kind.
251+
252+
## Backward-compat checklist
253+
254+
| Concern | Status |
255+
|---|---|
256+
| Existing `block.createdAt` reads | ✅ still works (alias for `clientReceivedAt`) |
257+
| Existing reducer event handling | ✅ unchanged for v1 event types |
258+
| `daemonTranscriptToUnifiedMessages(blocks)` call sites | ✅ new options param is optional |
259+
| Existing `selectTranscriptBlocks` consumers | ✅ unchanged |
260+
| New event types in v1 reducer | ✅ no-op, `lastEventId` still advances |
261+
262+
## Cross-references
263+
264+
- [PR #4353 SUMMARY](https://github.com/QwenLM/qwen-code/pull/4353)
265+
- [Daemon UI README](./README.md) — full API reference
266+
- [PR #4328](https://github.com/QwenLM/qwen-code/pull/4328) — base PR with shared UI transcript layer

0 commit comments

Comments
 (0)