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.
This commit is contained in:
Shaojin Wen 2026-07-03 16:56:08 +08:00 committed by GitHub
parent 23c6c7032a
commit da22360c25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 152 additions and 2 deletions

View file

@ -52,6 +52,15 @@ describe('<Header />', () => {
expect(lastFrame()).toContain('v1.0.0');
});
it('shows a non-semver fallback like "unknown" without a bogus "v" prefix', () => {
const { lastFrame } = render(
<Header {...defaultProps} version="unknown" />,
);
const frame = lastFrame() ?? '';
expect(frame).toContain('(unknown)');
expect(frame).not.toContain('vunknown');
});
it('displays auth type and model', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
expect(lastFrame()).toContain('Qwen OAuth');

View file

@ -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<HeaderProps> = ({
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<HeaderProps> = ({
<Text bold color={theme.text.accent}>
{customBannerTitle ? customBannerTitle : '>_ Qwen Code'}
</Text>
<Text color={theme.text.secondary}> (v{version})</Text>
<Text color={theme.text.secondary}> ({versionLabel})</Text>
</Text>
{/* Subtitle (when set) replaces the blank spacer row. We always
emit a row here so the auth/model line stays at the same

View file

@ -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 {

View file

@ -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(
<I18nProvider language="en">
<WebShellSidebar
collapsed={collapsed}
onCollapsedChange={noop}
onOpenSettings={noop}
onNewSession={() => false}
onLoadSession={noop}
onError={noop}
/>
</I18nProvider>,
);
});
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/);
});
});

View file

@ -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({
</span>
{!collapsed && <span>{t('sidebar.settings')}</span>}
</button>
{!collapsed && versionLabel && (
<span className={styles.version} title={`Qwen Code ${versionLabel}`}>
{versionLabel}
</span>
)}
{!mobileOpen && (
<button
className={styles.collapseButton}

View file

@ -24,12 +24,14 @@ import {
existsSync,
symlinkSync,
mkdirSync,
readFileSync,
} from 'node:fs';
import { tmpdir, platform } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const cliPackageDir = join(root, 'packages', 'cli');
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
// Ensure qc-helper bundled skill can find user docs in dev mode.
// In dev, import.meta.url resolves to the source tree, so the bundled skill
@ -102,7 +104,10 @@ const importFlag = `--import ${pathToFileURL(registerPath).href}`;
const env = {
...process.env,
DEV: 'true',
CLI_VERSION: 'dev',
// Report the real package version (like scripts/start.js) so the UI shows
// e.g. "v0.19.4" instead of "dev". DEV=true / NODE_ENV=development remain the
// signals that distinguish a dev build.
CLI_VERSION: pkg.version,
NODE_ENV: 'development',
NODE_OPTIONS: `${existingNodeOptions} --expose-gc ${importFlag}`.trim(),
};

View file

@ -32,6 +32,7 @@ vi.mock('node:fs', () => ({
existsSync: existsSyncMock,
symlinkSync: vi.fn(),
mkdirSync: vi.fn(),
readFileSync: vi.fn(() => JSON.stringify({ version: '0.0.0-test' })),
}));
const normalizePath = (path) => String(path).replaceAll('\\', '/');