From da22360c251f2a26a056cac2f5e7bbfb8fc029ff Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 3 Jul 2026 16:56:08 +0800 Subject: [PATCH] feat(web-shell): show the qwen-code version in the sidebar footer (#6222) * feat(web-shell): show the qwen-code version in the sidebar footer The Web Shell had no visible version. Show the running qwen-code version (from the daemon capabilities) in the sidebar footer, inline with the Settings button so it stays visible without taking its own row. Render the version consistently wherever it appears: - Prefix "v" only for a real semver release; a non-semver fallback such as "unknown" is shown as-is, so we never render a bogus "vunknown". Applied to the Web Shell badge and the TUI header. - Dev builds (scripts/dev.js) now report the real package version instead of the "dev" sentinel, matching scripts/start.js, so the UI shows the actual version (e.g. v0.19.4). DEV=true / NODE_ENV=development remain the signals that mark a dev build. * test: add readFileSync to node:fs mock in dev.test.js scripts/dev.js now reads package.json via readFileSync at module load to report the real CLI_VERSION, but the node:fs mock in dev.test.js did not export readFileSync, causing vitest to throw "No readFileSync export is defined on the node:fs mock" and failing the suite. --- .../cli/src/ui/components/Header.test.tsx | 9 ++ packages/cli/src/ui/components/Header.tsx | 13 ++- .../sidebar/WebShellSidebar.module.css | 10 ++ .../sidebar/WebShellSidebar.test.tsx | 101 ++++++++++++++++++ .../components/sidebar/WebShellSidebar.tsx | 13 +++ scripts/dev.js | 7 +- scripts/tests/dev.test.js | 1 + 7 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 833774d1ff..d907cbb7de 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -52,6 +52,15 @@ describe('
', () => { expect(lastFrame()).toContain('v1.0.0'); }); + it('shows a non-semver fallback like "unknown" without a bogus "v" prefix', () => { + const { lastFrame } = render( +
, + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('(unknown)'); + expect(frame).not.toContain('vunknown'); + }); + it('displays auth type and model', () => { const { lastFrame } = render(
); expect(lastFrame()).toContain('Qwen OAuth'); diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index 94ec219377..e59d49f882 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -49,6 +49,16 @@ function formatAuthDisplayType( } } +/** + * Format the version for display. Real semver releases get a "v" prefix + * ("v0.19.4"); a non-semver fallback such as "unknown" (from getCliVersion when + * the package version can't be resolved) is shown as-is so we never render a + * bogus "vunknown". + */ +function formatVersionLabel(version: string): string { + return /^\d/.test(version) ? `v${version}` : version; +} + interface HeaderProps { /** * Width-aware override for the logo column. Each tier is a sanitized @@ -88,6 +98,7 @@ export const Header: React.FC = ({ const { columns: terminalWidth } = useTerminalSize(); const formattedAuthType = formatAuthDisplayType(authDisplayType); + const versionLabel = formatVersionLabel(version); // Calculate available space properly: // First determine if logo can be shown, then use remaining space for path @@ -209,7 +220,7 @@ export const Header: React.FC = ({ {customBannerTitle ? customBannerTitle : '>_ Qwen Code'} - (v{version}) + ({versionLabel}) {/* Subtitle (when set) replaces the blank spacer row. We always emit a row here so the auth/model line stays at the same diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index 0c5e13ca44..3e81ef6b78 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -578,6 +578,16 @@ flex: 0 0 auto; } +.version { + flex: 0 0 auto; + color: var(--muted-foreground); + font-size: 12px; + line-height: 1; + letter-spacing: 0.02em; + white-space: nowrap; + user-select: text; +} + .collapsed .newChatButton, .collapsed .footerButton, .collapsed .projectRow { diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx new file mode 100644 index 0000000000..cb88fa13aa --- /dev/null +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +const { mockConnection } = vi.hoisted(() => ({ + mockConnection: { + status: 'connected', + sessionId: null as string | null, + workspaceCwd: '/tmp/project', + capabilities: { qwenCodeVersion: '1.2.3' } as + | { qwenCodeVersion?: string } + | undefined, + }, +})); + +vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ + useConnection: () => mockConnection, + useActions: () => ({ renameSession: vi.fn() }), + useSessions: () => ({ + sessions: [], + loading: false, + error: null, + reload: vi.fn(), + deleteSession: vi.fn(), + }), +})); + +const { I18nProvider } = await import('../../i18n'); +const { WebShellSidebar } = await import('./WebShellSidebar'); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const mounted: Array<{ root: Root; container: HTMLElement }> = []; + +const noop = () => {}; + +function renderSidebar(collapsed: boolean): HTMLElement { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + + false} + onLoadSession={noop} + onError={noop} + /> + , + ); + }); + mounted.push({ root, container }); + return container; +} + +beforeEach(() => { + mockConnection.capabilities = { qwenCodeVersion: '1.2.3' }; +}); + +afterEach(() => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } +}); + +describe('WebShellSidebar — version footer', () => { + it('shows the qwen-code version in the footer when expanded', () => { + const container = renderSidebar(false); + const badge = container.querySelector('[title="Qwen Code v1.2.3"]'); + expect(badge).not.toBeNull(); + expect(badge?.textContent).toBe('v1.2.3'); + }); + + it('renders a non-semver fallback (e.g. "unknown") without a bogus "v" prefix', () => { + mockConnection.capabilities = { qwenCodeVersion: 'unknown' }; + const container = renderSidebar(false); + const badge = container.querySelector('[title="Qwen Code unknown"]'); + expect(badge).not.toBeNull(); + expect(badge?.textContent).toBe('unknown'); + expect(container.textContent ?? '').not.toContain('vunknown'); + }); + + it('hides the version when the sidebar is collapsed', () => { + const container = renderSidebar(true); + expect(container.querySelector('[title="Qwen Code v1.2.3"]')).toBeNull(); + expect(container.textContent ?? '').not.toContain('v1.2.3'); + }); + + it('renders no version badge when the daemon reports none', () => { + mockConnection.capabilities = undefined; + const container = renderSidebar(false); + expect(container.textContent ?? '').not.toMatch(/v\d/); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 5b4f10af00..bc66117aab 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -214,6 +214,14 @@ export function WebShellSidebar({ const currentSessionId = connection.sessionId; const projectName = getWorkspaceName(connection.workspaceCwd) || t('sidebar.projectFallback'); + const qwenCodeVersion = connection.capabilities?.qwenCodeVersion || ''; + // Numeric releases render as "v1.2.3"; a non-semver fallback such as + // "unknown" is shown as-is so we never produce a bogus "vunknown". + const versionLabel = qwenCodeVersion + ? /^\d/.test(qwenCodeVersion) + ? `v${qwenCodeVersion}` + : qwenCodeVersion + : ''; const sidebarStyle = { '--web-shell-sidebar-width': `${sidebarWidth}px`, } as CSSProperties; @@ -940,6 +948,11 @@ export function WebShellSidebar({ {!collapsed && {t('sidebar.settings')}} + {!collapsed && versionLabel && ( + + {versionLabel} + + )} {!mobileOpen && (