* fix(goals): persist goal cards and restore the hook on daemon resume In daemon mode a `/goal` was silently lost whenever its session was reloaded or `qwen serve` restarted: the goal card vanished from the transcript and the Stop hook was never re-registered, so the loop simply stopped advancing. The TUI does neither of these things wrong; the ACP path was missing both halves. Goal cards were only ever emitted as live SSE `_meta` (MessageEmitter's emitGoalStatus / emitGoalTerminal) and never written to the transcript, so the one durable store — the ChatRecord JSONL — had nothing to restore from. Record them from Session.emitGoalStatus, the single choke point for `set` and `cleared` (the sessionGoalClear ext method routes through it too), and from the goal terminal observer for `achieved` / `failed` / `aborted`. Persisting `cleared` matters on its own: without it the last stored card stays `set`, and a later resume would revive a goal the user explicitly dropped. HistoryReplayer dropped those records on the way back out — it reads only `item['text']`, and a goal card has no `text` field — so re-emit them as `_meta.goalStatus`. Per-iteration `checking` cards are skipped: a TUI transcript stores one per stop-hook turn and clients suppress them as noise. That costs no fidelity, because restore reads the records directly rather than the replay output. With the transcript carrying the goal again, add #restoreGoalOnResume to loadSession and unstable_resumeSession, alongside #restoreWorktreeOnResume. It rebuilds the goal cards from the resumed ChatRecords (they live inside system/slash_command records' outputHistoryItems) and reuses the existing findGoalToRestore / findLastTerminalGoal / registerGoalHook logic, trust and hook-policy gates included. * feat(web-shell): add a workspace Goals page `/goal` had no visual surface in the web shell. You could set and clear one from the composer, but the only feedback was a status-bar pill and a transcript card, and there was no way to see every goal running in the workspace at once. Add a full-pane Goals page alongside Scheduled Tasks. Each row shows the condition, the session driving it, whether the loop is mid-turn, the judge's turn count and last verdict, and how long the goal has been running. A row opens its session — the transcript IS the goal's history — or clears the goal. A form starts a new goal in a fresh session, so the loop doesn't take over a conversation already in progress. Reading the goals needs a round trip. They live in the owning `qwen --acp` child's in-memory store, and serve runs in a separate process holding only a bridge, so there is nothing local to read. Add a `sessionGoalGet` ext method that reports one session's goal state, wrap it in bridge.getSessionGoal (mirroring clearSessionGoal), and have `GET /goals` fan out over the workspace's live sessions concurrently — one timeout for a wedged child rather than one per session. A session whose probe rejects is dropped rather than failing the whole list. Clearing reuses `POST /session/:id/goal/clear`, so the page and a `/goal clear` typed in chat take the same path through the daemon. Only loaded sessions appear, which is the honest answer rather than a limitation: a goal advances only while its session is resident. Three entry points: a sidebar button, the status-bar goal pill (now a button), and a bare `/goal`, which opens the page instead of asking the daemon to print its status as text — matching how `/schedule` behaves. It sends no prompt and touches no session, so it works mid-turn too. `/goal <condition>` and `/goal clear` are unchanged. The integration test exercises the whole chain against a real daemon: `GET /goals` -> bridge -> ext method in a spawned `qwen --acp` child. * fix(web-shell): stop the Goals poll from overlapping itself `GET /goals` fans out one ext-method probe per live session, and a wedged child holds it for the bridge's 10s `initTimeoutMs` — the same order as the 10s poll interval. `withActionTimeout` rejects the wait at 30s but never aborts the underlying fetch, so a fixed `setInterval` could stack several fan-outs against an already-struggling daemon. `reloadSeqRef` only keeps a stale response from overwriting state; it does nothing about the pile-up. Replace the interval with a single self-chaining loop that owns both the initial load and the polling, scheduling each fetch only once the previous one has settled. Folding the mount load into the chain matters: left in its own effect, the first timer would still fire while it was in flight. Reported by Copilot on #6561. * fix(goals): address review — clear-keyword condition, silent failures, theme vars From the /review suggestions on #6561. Applied the ones that held up under verification; the rest are answered in the PR thread with evidence. - The New goal form accepted a clear keyword as a condition. It travels as `/goal <condition>`, so "clear" (or stop/off/reset/none/cancel) reached the daemon as a clear command: the fresh session dropped its own goal the instant it was set, with nothing to show for it. Reject it in the form. The keyword list and `/goal` arg parsing move to `utils/goalCondition.ts` so the page and App share one definition instead of the page reaching into App. - Starting a goal failed silently. `onCreateGoal` switches to the chat view first, which unmounts the Goals page, so the inline form error that `sendPrompt` rejection produced was dropped by the page's own unmount guard. Surface it as a toast instead. - `GoalsDialog.module.css` used `var(--destructive, #dc2626)`, but nothing defines `--destructive`; the hardcoded fallback stayed the same red in both themes. Use `--error-color` and match ScheduledTasksDialog's focus outline. - `recordGoalStatusItem` swallowed recording failures with a bare `catch {}`. Silently losing that write is precisely the failure this recording exists to prevent, so log it. - `GET /goals` dropped failed probes silently — an empty page and a page whose probes all failed look identical to the client. Log the dropped sessions and their reasons. Tests: clear-keyword and MAX_GOAL_LENGTH form validation, goalCondition unit tests, `sessionGoalGet` argument validation, session load surviving a throwing goal restore, `/goals` drop logging, and a regression test showing `/goal clear` sent as a prompt does persist its cleared card (a reviewer flagged this as missing; it is not). * fix(goals): cap restored conditions, keep goal-creation errors on screen Second round of review on #6561. - `restoreGoalFromHistory` re-registered whatever condition the transcript held, skipping the 4000-char cap `/goal` enforces at set time. A transcript is a file: a corrupted or hand-edited `condition` would ride along in every judge call and continuation prompt for the rest of the session. Gate it alongside the existing trust and hook-policy gates. `MAX_GOAL_LENGTH` moves to `restoreGoal.ts` and `goalCommand.ts` imports it — the reverse direction would be a cycle, since goalCommand already depends on this module. - Starting a goal switched to the chat view before awaiting `sendPrompt`, which unmounted the Goals page. The previous commit routed the rejection to a toast, but the better fix is not to leave: switch views only once the prompt is admitted, so the error lands in the form the user is looking at. `GoalsDialog` keeps a toast fallback for the case where the page is closed while the prompt is still in flight. - Move the `debugLogger` declaration below the imports in `restoreGoal.ts`. Imports are hoisted so this compiled, but a statement wedged between two import blocks is not something to leave behind. * fix(goals): surface restore/record failures, report unprobed sessions Third round of review on #6561. - `debugLogger.warn` no-ops unless a debug session is active (`debugLogger.ts:216`), so a failed goal restore and a failed goal-card write were both invisible in production — the two failure modes this PR exists to fix. Promote them to `writeStderrLine`, which both `ui/App.tsx` and `session/Session.ts` already use. - `GET /goals` now returns `droppedCount`. A brownout in which every probe fails returned `{ goals: [] }`, indistinguishable from a workspace with no goals — so the user re-creates goals that are already running. The Goals page shows a notice when the list is incomplete. - `running` on the wire is really "the owning session is mid-turn", which a manual prompt in that session also sets. Renamed to `hasActivePrompt` so the field reports what the daemon actually knows. The UI still maps it to Working/Waiting. - Fix the stale "keep in sync" pointer in `goalCommand.ts`: the clear keywords moved from `App.tsx` to `utils/goalCondition.ts` in the previous commit. Tests for the four coverage gaps the review named: the `systemMessage` fallback in `goalTerminalEventToHistoryItem` (including the known lossy collapse when both fields are set), `#restoreGoalOnResume` on an empty transcript, `listGoals`/`clearGoal` in `actions.ts`, and the `sendPrompt`-after- `createNewSession` failure path (added last commit). Plus `droppedCount` projection and the degradation notice. * test(goals): update the /goals integration test for droppedCount Adding `droppedCount` to the `GET /goals` payload broke the end-to-end assertions, which still expected `{ v: 1, goals: [] }`. Caught in review, not by CI: the Integration Tests job is gated off for this PR, so nothing ran these against a real daemon after the shape changed. `droppedCount: 0` is the load-bearing half of the live-session assertion. A dropped probe also yields an empty `goals`, so the old assertion could not tell a successful ext-method round trip from a silently failed one. Re-ran against a spawned `qwen serve` + `qwen --acp` child: green with the fix, red without it. * fix(goals): refuse to replay an oversized goal card `restoreGoalFromHistory` gates the condition at MAX_GOAL_LENGTH, but `HistoryReplayer` did not: a corrupted or hand-edited transcript could still ship an unbounded `condition` to every client inside `_meta.goalStatus`. Apply the same gate at the replay emit site, so neither the card nor the hook survives an oversized condition. The gate deliberately does NOT move into `parseGoalStatusItem`, which would be the tidier-looking place. `findGoalToRestore` and `findLastTerminalGoal` scan backwards and stop at the FIRST goal card they meet, so dropping a card at parse time silently promotes the card before it. A transcript ending in an oversized `cleared` would then restore the `set` that preceded it — resurrecting a goal the user explicitly cleared, the exact failure persisting `cleared` was added to prevent. Parsing therefore stays lossless and the length check lives at each consumer. Tests pin both halves: replay refuses at 4001 and emits at exactly 4000, and three scanner tests show an oversized card still wins the scan so restore can fail closed on it. * fix(goals): keep the terminal observer alive across ACP resume Addresses the latest review round on #6561. `registerGoalHook` calls `unregisterGoalHook`, which clears the session's goal-terminal observer. The ACP restore path passes no `addItem`, so nothing reinstalled it: a restored goal reached achieved/failed/aborted with no wire update and no persisted terminal card, and the next reload revived a goal that had already finished. The no-goal branch unregisters too, so every ACP resume lost the observer, not just ones with a goal. `#restoreGoalOnResume` now reinstalls it unconditionally. A restore blocked by trust or hook policy left the client showing an active goal that nothing drives. Restore now reports `blockedBy`, and history replay emits a trailing `cleared` card naming the reason. The card is emitted, not recorded, so a later resume in a trusted folder still restores the goal. It is emitted from inside replay because `loadSession` batches replay updates into its response, and a notification sent afterwards would reach the client first. Gated behind a `HistoryReplayer` option: export and `restoreSessionHistory` render a transcript rather than resume it, and the export config is a stub that throws on any method it does not implement. Transcript payloads are now treated as untrusted. `outputHistoryItems` is checked with `Array.isArray` before iteration and each entry for being a plain object before any field is read; a hand-edited record could otherwise throw and take the whole restore down, skipping the hook while replay still showed the goal as active. Also: - Carry `setAt` across resume instead of restarting the clock, scanning back to the run's `set` card when the newest card is a `checking` card (which had no `setAt`; they now persist one). - Refuse to restore an empty condition, as `/goal` does. - Warn instead of silently no-opping when no chat recording service is present. - Cap `GET /goals` session probes at 10 in flight. - Drop `lastTerminal` from the `sessionGoalGet` response and `BridgeSessionGoal` — no consumer reads it, and it was returned unprojected. - `GoalsDialog` keeps the form and the typed condition when creation fails, and clears a stale dropped-session count when a reload fails outright. - Cross-package test pinning `GOAL_CLEAR_KEYWORDS` and `MAX_GOAL_LENGTH` against the CLI sources they mirror. * fix(goals): drop the condition length cap on restore and in the web shell #6665 removed the 4,000-character cap `/goal` applied when setting a goal, but the restore path and the Web Shell form still enforced it. After merging main that split the surfaces: a long condition `/goal` now accepts was persisted as a `set` card, then refused by `restoreGoalFromHistory` on the next resume and dropped from the replay entirely — the goal died on reload and the user never saw a card explaining why. Remove the cap everywhere rather than reinstate it at set time. A corrupted or hand-edited transcript can now restore an arbitrarily long condition, but that is exactly what `/goal` itself permits, so it is no longer a distinct risk. The empty-condition gate stays: it is the one case that is meaningless rather than merely large. - `goalConditionBlockedBy` rejects only an empty condition. - `HistoryReplayer` no longer skips long goal cards. - `GoalsDialog` drops the form check and the `maxLength` attribute, which had been silently truncating a long condition before the user could submit it. - `MAX_GOAL_LENGTH` and the now-orphaned `goals.error.tooLong` i18n strings are deleted, along with the drift test's length half; the clear-keyword half of that test still guards the constant that is genuinely duplicated. Also drops the `MAX_GOAL_LENGTH` import #6665 left unused in `goalCommand.ts`, which failed `eslint --max-warnings 0`. * fix(web-shell): reuse the empty session a failed goal attempt leaves behind Setting a goal starts a fresh session and then sends `/goal <condition>` into it. The daemon session is not created by the "new session" step, though — `clearSession` only detaches and clears local state. `ensureSessionForPrompt` creates the session lazily inside `sendPrompt`, so a prompt that fails after the session exists leaves a created-but-empty one behind. The Goals form keeps the condition and invites a retry, and the retry called `createNewSession()` again: the empty session from the previous attempt was abandoned and another created in its place. A user retrying a few times against a busy daemon ended up with a column of blank chats in the sidebar. Remember the stranded session and reuse it when it is still the current one, rather than creating another. Nothing is deleted — a session is only reused when the failed attempt left it empty and it has not been switched away from. Once a goal actually lands, the session belongs to it, so the next goal starts a fresh one as before. * fix(goals): forget the stranded goal session on leaving the Goals page Addresses the latest review round on #6561. The stranded-session reuse added in bee3295aa was only safe while the Goals page stayed up. Leaving it (Back button) and then talking to that session from the composer turned it into a real conversation, but the ref still pointed at it: returning to Goals and setting a goal would reuse it and drop the goal loop on top of the user's conversation — the exact thing starting a fresh session exists to prevent. The ref is now cleared whenever the view leaves 'goals', so reuse can only ever hit a session the failed attempt itself created. Also: - `registerGoalHook` rejects a `setAt` in the future, not just a non-finite or non-positive one. Every duration downstream is `Date.now() - setAt`, so a transcript claiming the goal starts tomorrow rendered negative elapsed times. - `makeRestoreInnerConfig` gains `isTrustedFolder`. Without it, `goalRestoreBlockedBy` threw `config.isTrustedFolder is not a function` on every resume in these tests, and `#restoreGoalOnResume` swallowed it — so the goal-gate assertions passed through the catch rather than the branch each one names. The hooks-disabled test now pins the branch it took, and fails if the config regresses. - The status-bar goal pill names the goal in its accessible label. The visible pill is only "◎ /goal active (2m)" and the condition lived solely in `title`, a hover tooltip screen readers do not reliably announce. - `.iconAction` gains a `:focus-visible` rule, matching `.iconButton` in DialogShell.module.css; keyboard users had no focus indicator on the clear-goal button. - `GoalsDialog.test.tsx` restores real timers in `afterEach` rather than inline per test, so a failing assertion can no longer leak fake timers into the rest of the file. - Tests for the Goals form's Cancel button and for the status-bar pill, neither of which had any coverage. * fix(goals): identify a goal run by its condition, not just its card kinds Addresses the latest review round on #6561. `findSetAtOfRun` walked back from the active card for the `setAt` on the `set` card that opened the run, stopping at any card that was not `set`/`checking`. That assumed a terminal card always separates two goals, and a transcript is a file: hand-edited, truncated, or written by a version that did not persist terminal cards, it can hold two goals back to back. The scan then walked past the second goal's cards into the first and returned ITS start time, so the active goal's elapsed time was measured from a goal that had already ended. The condition is what identifies a run, so the scan now stops when it changes. Also: - A malformed condition is reported once on resume, not twice. `restoreGoalFromHistory` is the only caller that knows the condition is bad, and three of its four callers (the TUI ones) discard the result entirely, so it stays the reporter; `#restoreGoalOnResume` no longer adds a second line for `condition-invalid`. The env gates were already reporting exactly once. - Goal-restore stderr can no longer take down a session load. `writeStderrLine` reaches `process.stderr.write`, which throws on EPIPE or a closed fd; a throw from the catch block would have escaped into `loadSession`, so a best-effort restore would fail the very load it promises not to block. - `isGoalClearCommand` checks the `/goal` prefix instead of assuming it. `goalArgOf` returns unrecognised text unchanged, so a bare `"clear"` — an ordinary thing to type into a chat box — answered true. Latent today because every caller pre-validates the prefix, but the contract was a trap. - Tests for the throw path reinstalling the terminal observer, and for the Goals page opening a goal's session (success and failure), neither of which had any coverage. * fix(web-shell): announce Goals dialog errors and give its buttons a focus ring Addresses the latest review round on #6561. The form-validation error and the goal-list load error were painted but never announced: `role="alert"` puts them in a live region, so a screen-reader user learns the submit was rejected instead of believing the goal was created, and learns the list went stale on a poll that failed after the page was already up. Matches the existing pattern in RewindDialog. `.primaryButton` / `.secondaryButton` had no `:focus-visible` rule, so keyboard users tabbing to Set goal / Cancel saw no focus indicator — an inconsistency with `.iconAction` and `.sessionLink` in the same file. They now take the ring the form controls already use (`outline: 2px solid var(--primary)`), offset outwards rather than inset: `.primaryButton` is filled with `--primary`, so an inset ring in that colour would be invisible on it. * fix(cli): stop a broken stderr from abandoning a transcript replay Addresses the latest review round on #6561. `process.stderr.write` throws on EPIPE or a closed fd — reachable whenever the reader goes away (`qwen … | head`) or a daemon redirects its stderr. The goal path writes diagnostics from inside work that must not be destroyed by a failed diagnostic, and `bee3295aa` only guarded one of the five sites. The worst of the rest was in `HistoryReplayer`: the "skipping a goal card whose condition is empty" line sits inside the loop over a record's cards. A throw there abandoned that record's remaining cards, propagated to the record loop, and aborted the whole replay — the user lost their transcript because we failed to complain about one bad card. Add `writeStderrLineSafe` to stdioHelpers and route the goal path's five sites through it, replacing the one-off `#warnGoalRestore` wrapper in acpAgent so there is a single implementation. It is deliberately not the default: `writeStderrLine` still throws, because most of the CLI wants a broken stderr to be loud. This variant is for writes that are incidental to real work. Also adds the first tests for `stdioHelpers`, and covers two untested Goals dialog behaviours: the Refresh button, and the clear button disabling itself while its clear is in flight (a double-click otherwise fired two concurrent clears at the same session). * fix(web-shell): keep the Goals page mounted across createNewSession main's `createNewSession` gained a `setMainView('chat')` of its own, fired synchronously before any await. That silently defeated the Goals handler's deferred switch: by the time `sendPrompt` rejected, the page — and the form that renders the error — was already gone, dropping the user into an empty chat with no explanation. This is the exact failure the deferred switch was written to prevent; the two changes only had to meet for it to come back. `createNewSession` takes a `keepView` opt-out, and the Goals handler uses it, so the page survives until the prompt is admitted. Saving and restoring `mainView` around the call would also work but flips the view to chat and back, which the user would see. A test pins the page staying mounted across a failed submit; it fails if `keepView` stops being honoured. Also from the same round: - `registerGoalHook`'s `initialSetAt` guards are now tested — a future timestamp, NaN, Infinity, 0 and a negative all fall back to now, and a usable value survives. The future case is the one with teeth: `Date.now() - setAt` renders a negative elapsed time rather than failing loudly, and nothing covered it. - The goals list carries `role="list"` / `role="listitem"`. They are divs, and even a real `<ul>` loses its implicit role under `display: flex` in Safari. - The open-session button names the action *and* the session. Its visible text is only the session name, which says nothing about what activating it does; the name stays in the accessible name so it still contains the visible label. - `.fieldLabel` matches ScheduledTasksDialog's `--muted-foreground`. The two dialogs sit side by side and had drifted. Not taken: deferring `setMainView` in `onOpenSession` until the load resolves. The sibling `handleOpenSessionFromOverview` switches first by the same pattern, and `loadSidebarSession` clears the transcript and shows a loading skeleton — which is the feedback for the common success path. Deferring would leave a click looking dead until the load lands, and would make Goals diverge from the Session Overview panel. If we want that behaviour it should change both. * fix(web-shell): stop the visuals spec asserting a badge #7035 removed The "Capture web-shell visuals" job fails on this PR at `screenshots.spec.ts:395`, asserting the sidebar's "Primary" badge is visible: Error: expect(locator).toBeVisible() failed Error: element(s) not found Not from this branch. The chain is on main: - 2026-07-15 #6880 adds the visuals spec, asserting the "Primary" badge — correct at the time. - 2026-07-17 #7035 drops that badge as redundant (the workspace selector's checkmark already conveys the default target), removing the `primaryLabel` prop and its `<span className={styles.badge}>` render, and updates the *unit* test to assert its absence — but leaves this spec asserting it is visible. The capture job only runs on pull requests (it needs a PR head and a merge-base), so main never went red for it and the breakage surfaces on the next PR to merge main — this one. Assert the badge's absence instead of deleting the check, mirroring the unit test #7035 added, so a regression re-adding it still fails here. --------- Co-authored-by: 易良 <1204183885@qq.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com> |
||
|---|---|---|
| .. | ||
| .storybook | ||
| docs | ||
| examples | ||
| scripts | ||
| src | ||
| .npmignore | ||
| package.json | ||
| postcss.config.cjs | ||
| README.md | ||
| tailwind.config.cjs | ||
| tailwind.preset.cjs | ||
| tsconfig.json | ||
| vite.config.ts | ||
@qwen-code/webui
A shared React component library for Qwen Code applications, providing cross-platform UI components with consistent styling and behavior.
Features
- Cross-platform support: Components work seamlessly across VS Code extension, web, and other platforms
- Platform Context: Abstraction layer for platform-specific capabilities
- Tailwind CSS: Shared styling preset for consistent design
- TypeScript: Full type definitions for all components
- Storybook: Interactive component documentation and development
- Multiple Build Formats: Supports ESM, CJS, and UMD formats for different environments
- CDN Usage: Can be loaded directly in browsers via CDN
Installation
npm install @qwen-code/webui
CDN Usage
You can also use this library directly in the browser via CDN:
Option 1: With JSX Support (using Babel)
<!DOCTYPE html>
<html>
<head>
<!-- Load React -->
<script
crossorigin
src="https://unpkg.com/react@18/umd/react.production.min.js"
></script>
<script
crossorigin
src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
></script>
<!-- Load Babel Standalone for JSX processing -->
<script src="https://unpkg.com/@babel/standalone@7.23.6/babel.min.js"></script>
<!-- Manually create the jsxRuntime object to satisfy the dependency -->
<script>
// Provide a minimal JSX runtime for builds that expect react/jsx-runtime globals.
const withKey = (props, key) =>
key == null ? props : Object.assign({}, props, { key });
const jsx = (type, props, key) =>
React.createElement(type, withKey(props, key));
const jsxRuntime = {
Fragment: React.Fragment,
jsx,
jsxs: jsx,
jsxDEV: jsx,
};
window.ReactJSXRuntime = jsxRuntime;
window['react/jsx-runtime'] = jsxRuntime;
window['react/jsx-dev-runtime'] = jsxRuntime;
</script>
<!-- Load the webui library -->
<script src="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/index.umd.js"></script>
<!-- Load the CSS -->
<link
rel="stylesheet"
href="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/styles.css"
/>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
// Access components from the global QwenCodeWebUI object
const { ChatViewer } = QwenCodeWebUI;
// Use the components with JSX support
const App = () => (
<ChatViewer messages={/* your messages */} />
);
ReactDOM.render(<App />, document.getElementById('root'));
</script>
</body>
</html>
Option 2: Without JSX (using React.createElement directly)
<!DOCTYPE html>
<html>
<head>
<!-- Load React -->
<script
crossorigin
src="https://unpkg.com/react@18/umd/react.production.min.js"
></script>
<script
crossorigin
src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
></script>
<!-- Manually create the jsxRuntime object to satisfy the dependency -->
<script>
// Provide a minimal JSX runtime for builds that expect react/jsx-runtime globals.
const withKey = (props, key) =>
key == null ? props : Object.assign({}, props, { key });
const jsx = (type, props, key) =>
React.createElement(type, withKey(props, key));
const jsxRuntime = {
Fragment: React.Fragment,
jsx,
jsxs: jsx,
jsxDEV: jsx,
};
window.ReactJSXRuntime = jsxRuntime;
window['react/jsx-runtime'] = jsxRuntime;
window['react/jsx-dev-runtime'] = jsxRuntime;
</script>
<!-- Load the webui library -->
<script src="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/index.umd.js"></script>
<!-- Load the CSS -->
<link
rel="stylesheet"
href="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/styles.css"
/>
</head>
<body>
<div id="root"></div>
<script>
// Access components from the global QwenCodeWebUI object
const { ChatViewer } = QwenCodeWebUI;
// Use the components with React.createElement (no JSX)
const App = React.createElement(ChatViewer, {
messages: [
/* your messages */
],
});
ReactDOM.render(App, document.getElementById('root'));
</script>
</body>
</html>
For a complete working example, see examples/cdn-usage-demo.html.
Quick Start
import { Button, Input, Tooltip } from '@qwen-code/webui';
import { PlatformProvider } from '@qwen-code/webui/context';
function App() {
return (
<PlatformProvider value={platformContext}>
<Button variant="primary" onClick={handleClick}>
Click me
</Button>
</PlatformProvider>
);
}
Daemon React SDK (@qwen-code/webui/daemon-react-sdk)
All daemon-related React bindings (Providers, hooks, types) are published under the daemon-react-sdk sub-path. The main entry (@qwen-code/webui) is purely UI components with zero daemon dependency.
import {
DaemonSessionProvider,
DaemonWorkspaceProvider,
useTranscriptBlocks,
useConnection,
useActions,
useStreamingState,
} from '@qwen-code/webui/daemon-react-sdk';
Architecture
Two providers, split by lifecycle axis:
DaemonSessionProvider— per-conversation: SSE connection, transcript store, prompt/cancel/model/approval-mode/permission actions.DaemonWorkspaceProvider— per-workspace (outlives sessions): MCP, skills, tools, memory, agents, files.
<DaemonWorkspaceProvider> ← owns DaemonClient + capabilities
useMcp / useAgents / useMemory / useTools / ...
├── <DaemonSessionProvider> ← owns session + SSE + transcript store
│ useTranscriptBlocks / useActions / useConnection / useStreamingState / ...
│ ├── <ChatPanel />
│ └── <TerminalPanel />
Basic usage
import {
DaemonSessionProvider,
DaemonWorkspaceProvider,
useTranscriptBlocks,
useActions,
useConnection,
} from '@qwen-code/webui/daemon-react-sdk';
function App() {
return (
<DaemonWorkspaceProvider baseUrl="http://127.0.0.1:4170" token={token}>
<DaemonSessionProvider autoReconnect>
<ChatView />
</DaemonSessionProvider>
</DaemonWorkspaceProvider>
);
}
function ChatView() {
const blocks = useTranscriptBlocks();
const { sendPrompt, cancel } = useActions();
const { status, sessionId, currentModel } = useConnection();
// render blocks, handle input...
}
Dual-mode usage (chat + terminal share one session)
Wrap both views with a single <DaemonSessionProvider>. Both panels share one SSE connection and one transcript store.
<DaemonWorkspaceProvider baseUrl={baseUrl} token={token}>
<DaemonSessionProvider autoReconnect>
<ChatPanel />
<TerminalPanel />
</DaemonSessionProvider>
</DaemonWorkspaceProvider>
Do NOT nest multiple <DaemonSessionProvider> for the same session — that creates two SSE connections and potential state divergence.
Session hooks
| Hook | Returns |
|---|---|
useTranscriptBlocks() |
readonly DaemonTranscriptBlock[] (raw blocks) |
useTranscriptState() |
Full DaemonTranscriptState (blocks + metadata) |
useActions() |
{ sendPrompt, cancel, setModel, setApprovalMode, respondToPermission, loadSession, newSession, ... } |
useConnection() |
{ status, sessionId, currentModel, currentMode, commands, skills, models, tokenCount, tokenUsage, contextWindow } |
useStreamingState() |
'idle' | 'waiting' | 'responding' | 'thinking' |
usePromptStatus() |
'idle' | 'waiting' | 'streaming' |
usePendingPermissions() |
Unresolved permission blocks |
useActiveTodoList() |
Latest todo list, only when it still has active items |
Workspace hooks
Require an ancestor <DaemonWorkspaceProvider>:
| Hook | Description |
|---|---|
useMcp(options?) |
MCP server list + restart + tools |
useSkills(options?) |
Available skills (read-only) |
useTools(options?) |
Workspace tools + enable/disable |
useMemory(options?) |
Memory files + read/write |
useAgents(options?) |
Agent CRUD |
useSessions(options?) |
Session list (switch/new/release require nested DaemonSessionProvider) |
useFiles() |
File operations: glob, read, write, edit, stat |
useGlob() |
globWorkspace(pattern, opts) |
useWorkspace() |
Full workspace context value |
useWorkspaceActions() |
All workspace-level actions |
All resource hooks accept { autoLoad?: boolean, enabled?: boolean } and return { data, loading, error, reload }. When nested under an active DaemonSessionProvider, resource hooks also refresh from daemon workspace events that are already broadcast on the session stream (memory_changed, agent_changed, tool_toggled, MCP restart events, and workspace init events). Without an active session, hooks remain pull-based.
Props
DaemonSessionProviderProps:
| Prop | Type | Default | Description |
|---|---|---|---|
baseUrl |
string? |
inherited | Daemon HTTP base URL (inherited from DaemonWorkspaceProvider when nested; required in standalone mode) |
token |
string? |
inherited | Bearer token (inherited from DaemonWorkspaceProvider when nested) |
workspaceCwd |
string? |
— | Override workspace path (uses capabilities if omitted) |
initialSessionId |
string? |
— | Restore a specific session on mount |
clientId |
string? |
— | Override stable client ID (auto-generated if omitted) |
autoConnect |
boolean |
true |
Connect on mount |
autoReconnect |
boolean |
true |
Auto-reconnect on disconnect |
reconnectDelayMs |
number |
1000 |
Initial reconnect backoff |
maxReconnectDelayMs |
number |
10000 |
Max reconnect backoff |
suppressOwnUserEcho |
boolean |
true |
Suppress own user message echoes |
DaemonWorkspaceProviderProps:
| Prop | Type | Default | Description |
|---|---|---|---|
baseUrl |
string |
required | Daemon HTTP base URL |
token |
string? |
— | Bearer token |
workspaceCwd |
string? |
— | Override workspace path |
autoConnect |
boolean |
true |
Connect and fetch capabilities on mount |
Components
UI Components
Button
import { Button } from '@qwen-code/webui';
<Button variant="primary" size="md" loading={false}>
Submit
</Button>;
Props:
variant: 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'size: 'sm' | 'md' | 'lg'loading: booleanleftIcon: ReactNoderightIcon: ReactNodefullWidth: boolean
Input
import { Input } from '@qwen-code/webui';
<Input
label="Email"
placeholder="Enter email"
error={hasError}
errorMessage="Invalid email"
/>;
Props:
size: 'sm' | 'md' | 'lg'error: booleanerrorMessage: stringlabel: stringhelperText: stringleftElement: ReactNoderightElement: ReactNode
Tooltip
import { Tooltip } from '@qwen-code/webui';
<Tooltip content="Helpful tip">
<span>Hover me</span>
</Tooltip>;
Icons
import { FileIcon, FolderIcon, CheckIcon } from '@qwen-code/webui/icons';
<FileIcon size={16} className="text-gray-500" />;
Available icon categories:
- FileIcons: FileIcon, FolderIcon, SaveDocumentIcon
- StatusIcons: CheckIcon, ErrorIcon, WarningIcon, LoadingIcon
- NavigationIcons: ArrowLeftIcon, ArrowRightIcon, ChevronIcon
- EditIcons: EditIcon, DeleteIcon, CopyIcon
- SpecialIcons: SendIcon, StopIcon, CloseIcon
Layout Components
Container: Main layout wrapperHeader: Application headerFooter: Application footerSidebar: Side navigationMain: Main content area
Message Components
Message: Chat message displayMessageList: List of messagesMessageInput: Message input fieldWaitingMessage: Loading/waiting stateInterruptedMessage: Interrupted state display
Platform Context
The Platform Context provides an abstraction layer for platform-specific capabilities:
import { PlatformProvider, usePlatform } from '@qwen-code/webui/context';
const platformContext = {
postMessage: (message) => vscode.postMessage(message),
onMessage: (handler) => {
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
},
openFile: (path) => {
/* platform-specific */
},
platform: 'vscode',
};
function App() {
return (
<PlatformProvider value={platformContext}>
<YourApp />
</PlatformProvider>
);
}
function Component() {
const { postMessage, platform } = usePlatform();
// Use platform capabilities
}
Tailwind Preset
Use the shared Tailwind preset for consistent styling:
// tailwind.config.js
module.exports = {
presets: [require('@qwen-code/webui/tailwind.preset.cjs')],
// your customizations
};
Development
Running Storybook
cd packages/webui
npm run storybook
Building
npm run build
Type Checking
npm run typecheck
Project Structure
packages/webui/
├── src/
│ ├── components/
│ │ ├── icons/ # Icon components
│ │ ├── layout/ # Layout components
│ │ ├── messages/ # Message components
│ │ └── ui/ # UI primitives
│ ├── context/ # Platform context
│ ├── hooks/ # Custom hooks
│ └── types/ # Type definitions
├── .storybook/ # Storybook config
├── tailwind.preset.cjs # Shared Tailwind preset
└── vite.config.ts # Build configuration
License
Apache-2.0