qwen-code/packages/webui
Shaojin Wen 22b9086678
fix(webui): fall back to history_truncated marker recordId for transcript pagination anchor (#7829)
* fix(webui): fall back to history_truncated marker recordId for transcript pagination anchor

A long in-flight turn can push the live journal past its cap with only
streaming session_updates (no turn-boundary recordId). The retained
replay window then has no `qwen.session.recordId` to anchor transcript
pagination, so `historyHasMore` collapsed to false and the
'History truncated' banner rendered with no loadMore recovery path.

The compaction engine now tracks the last-seen recordId and stamps it
on both history_truncated markers (compacted replay and live journal).
The webui's getPersistedReplayRecordId falls back to the marker's
recordId when no session_update in the retained window carries one,
unlocking transcript pagination again.

Backward compatible:
- Old daemon (no recordId field): marker is field-less, frontend
  behavior matches pre-fix (banner with no loadMore).
- New daemon + old web-shell: extra recordId field ignored by SDK
  validator/normalizer/hasFullTranscriptBeforeReplay.

Tests:
- compactionEngine: marker carries recordId on journal overflow,
  post-seed ingest rebuilds activeRecordId, seedReplayEvents captures
  evicted recordId, marker from evicted head when retained lacks one.
- DaemonSessionProvider: marker recordId used as pagination anchor
  when session_updates lack one (regression for the retained 10000 /
  dropped 7602 scenario).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(acp-bridge): freeze replay marker anchor at eviction boundary (#7829)

The replay-path history_truncated marker stamped its recordId from
activeRecordId, which ingest() advances on every post-seed turn
boundary — pushing the anchor past records the client already
displays and causing duplicate transcript blocks on pagination.

Track replayAnchorRecordId separately, captured at the first
replay-window eviction and frozen thereafter. The live-journal
marker continues to use activeRecordId (correct for in-flight
turns). On the client, prefer session_update recordIds over the
marker's stamped anchor so the earliest retained recordId wins
the pagination scan.

Also: reset activeRecordId before the seedReplayEvents pre-scan
(stale-value guard), and add the matching pre-scan to seed() so
eviction cannot lose the only recordId anchor.

* refactor(acp-bridge): extract shared lastRecordIdIn helper (#7829)

* fix(acp-bridge): backfill transcript pagination anchor for live sessions

The marker-recordId fallback only covers sessions whose retained window
holds at least one recordId-bearing event. Live sessions never do:
`qwen.session.recordId` is stamped solely during replay of the persisted
transcript (HistoryReplayer), never on the live event stream. A long
in-flight turn that caps the live journal before any turn boundary fires
leaves the retained window — and thus the truncation marker — with no
recordId at all, so `historyHasMore` still collapsed to false and the
'History truncated' banner rendered with no loadMore recovery path
(observed: retained 10000, dropped 1259, window 8388608 bytes, 20
concurrent Web Shell sessions).

The daemon now backfills a `historyAnchorRecordId` on the load response:
when the replay snapshot carries a truncation marker with no recordId
anywhere, it reads the latest recordId from the persisted transcript and
returns it as a top-level field. The webui uses it as the last-resort
`beforeRecordId` anchor (after session_update and marker recordIds), so
transcript pagination works even for first-turn / mid-turn live sessions.

Best-effort by design: any transcript read failure omits the field and
the client degrades to the pre-fix banner behavior.

Tests:
- bridge: backfills historyAnchorRecordId from the transcript when the
  marker carries no recordId (seeded replay without recordIds + attach
  via in-memory snapshot).
- DaemonSessionProvider: uses daemon historyAnchorRecordId when neither
  marker nor session_updates carry a recordId.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(acp-bridge): anchor replay marker at eviction boundary, dedup prepended transcript

Review follow-up addressing two Critical findings on the pagination
anchor and one Suggestion.

Critical — replay-path marker anchor (compactionEngine):
`replayAnchorRecordId ??= activeRecordId` froze the pre-scanned
`activeRecordId` — the LAST recordId across ALL seed events. When a
retained segment carried that recordId, the anchor sat inside the
retained window, so the client's `beforeRecordId` re-fetched records it
already displays and `prependTranscriptHistory` (no dedup) rendered them
twice. Now the anchor prefers the FIRST retained recordId (the eviction
boundary — `beforeRecordId` fetches exactly the dropped records with no
overlap), falling back to the last DROPPED recordId only when the
retained window carries no recordId at all.

Critical — prepend overlap safety net (webui):
Even a well-placed anchor can overlap the retained window in edge cases
(the daemon's transcript backfill for a live-journal overflow returns
the latest recordId by design). `prependTranscriptHistory` now drops
fetched events whose `sourceRecordIds` are already displayed, so any
anchor source yields duplicate-free history.

Suggestion — reuse `getString` for the marker recordId instead of an
inline `typeof` check.

Tests:
- compactionEngine: anchor is the first retained recordId, not the last
  overall (3-segment eviction where retained holds rec-B and rec-C).
- DaemonSessionProvider: fetched events whose records are already
  displayed are dropped, not duplicated.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(acp-bridge): correct anchor docs, skip backfill when marker has recordId, close attach race (#7829)

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
2026-07-28 04:32:17 +00:00
..
.storybook feat(webui/storybook): add full height container support for ChatViewer 2026-01-20 23:57:30 +08:00
docs chore(docs): remove obsolete documentation files 2026-01-21 20:50:45 +08:00
examples style: apply formatting and linting fixes across codebase 2026-03-06 21:58:22 +08:00
scripts feat(webui): migrate icons, Tooltip, WaitingMessage from vscode-ide-companion 2026-01-15 19:53:19 +08:00
src fix(webui): fall back to history_truncated marker recordId for transcript pagination anchor (#7829) 2026-07-28 04:32:17 +00:00
.npmignore feat(webui): Add UMD build format and CDN usage support 2026-01-22 15:47:56 +08:00
package.json chore(release): v0.21.0 (#7675) 2026-07-24 13:47:34 +00:00
postcss.config.cjs feat(webui): Infrastructure Setup (Prerequisites) 2026-01-15 14:32:21 +08:00
README.md feat(web-shell): daemon web-shell improvements — token usage, settings, retry, streaming metrics, hidden commands (#5066) 2026-06-13 02:58:08 +00:00
tailwind.config.cjs feat(webui): Infrastructure Setup (Prerequisites) 2026-01-15 14:32:21 +08:00
tailwind.preset.cjs refactor(vscode-ide-companion/webui): migrate PermissionDrawer to shared webui package 2026-01-16 19:48:44 +08:00
tsconfig.json feat(daemon): merge daemon-mode feature batch into main (#4490) 2026-06-12 00:34:49 +08:00
vite.config.ts feat(daemon): merge daemon-mode feature batch into main (#4490) 2026-06-12 00:34:49 +08:00

@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: boolean
  • leftIcon: ReactNode
  • rightIcon: ReactNode
  • fullWidth: 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: boolean
  • errorMessage: string
  • label: string
  • helperText: string
  • leftElement: ReactNode
  • rightElement: 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 wrapper
  • Header: Application header
  • Footer: Application footer
  • Sidebar: Side navigation
  • Main: Main content area

Message Components

  • Message: Chat message display
  • MessageList: List of messages
  • MessageInput: Message input field
  • WaitingMessage: Loading/waiting state
  • InterruptedMessage: 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