Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -950,8 +950,35 @@
}

.footerButtonLabel {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.footerCompact,
.footerTight {
gap: 4px;
}

.footerCompact .footerButton,
.footerTight .footerButton {
width: 26px;
height: 28px;
flex: 0 0 26px;
justify-content: center;
padding: 0;
}

.footerCompact .footerButtonLabel,
.footerTight .footerButtonLabel {
display: none;
}

.footerCompact .collapseButton,
.footerTight .collapseButton {
width: 26px;
height: 28px;
}

.collapseButton {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ const { WebShellSidebar } = await import('./WebShellSidebar');
const mounted: Array<{ root: Root; container: HTMLElement }> = [];

const noop = () => {};
const SIDEBAR_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-sidebar-width';

function setStoredSidebarWidth(width: number): void {
window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(width));
}

function pointerEvent(type: string, clientX: number): MouseEvent {
return new MouseEvent(type, { bubbles: true, clientX });
}

function renderSidebar(
collapsed: boolean,
Expand All @@ -116,6 +125,7 @@ function renderSidebar(
canOpenSessionsOverview: boolean;
onOpenSplitView: () => void;
canOpenSplitView: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onNewSession: () => Promise<boolean> | boolean;
onLoadSession: (sessionId: string) => Promise<void> | void;
onError: (error: unknown, message: string) => void;
Expand Down Expand Up @@ -153,6 +163,7 @@ function renderSidebar(

beforeEach(() => {
mockUseSessions.mockClear();
window.localStorage.clear();
mockConnection.sessionId = null;
mockConnection.capabilities = { qwenCodeVersion: '1.2.3', features: [] };
for (const store of [mockActive, mockArchived]) {
Expand Down Expand Up @@ -196,13 +207,61 @@ afterEach(() => {
});

describe('WebShellSidebar — version footer', () => {
it('shows the settings label and qwen-code version at full footer width', () => {
setStoredSidebarWidth(360);
const { container } = renderSidebar(false, {
canOpenSessionsOverview: true,
canOpenSplitView: true,
});
const settingsButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Settings"]',
);
const badge = container.querySelector('[title="Qwen Code v1.2.3"]');
expect(settingsButton).not.toBeNull();
expect(settingsButton?.textContent).toContain('Settings');
expect(badge).not.toBeNull();
expect(badge?.textContent).toBe('v1.2.3');
});

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('hides the settings label first while keeping the settings button accessible', () => {
setStoredSidebarWidth(260);
const { container } = renderSidebar(false, {
canOpenSessionsOverview: true,
canOpenSplitView: true,
});
const settingsButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Settings"]',
);
const badge = container.querySelector('[title="Qwen Code v1.2.3"]');
expect(settingsButton).not.toBeNull();
expect(settingsButton?.title).toBe('Settings');
expect(settingsButton?.textContent).not.toContain('Settings');
expect(settingsButton?.querySelector('svg')).not.toBeNull();
expect(badge).not.toBeNull();
});

it('hides the version at tight footer width', () => {
setStoredSidebarWidth(220);
const { container } = renderSidebar(false, {
canOpenSessionsOverview: true,
canOpenSplitView: true,
});
const settingsButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Settings"]',
);
expect(settingsButton).not.toBeNull();
expect(settingsButton?.textContent).not.toContain('Settings');
expect(container.querySelector('[title="Qwen Code v1.2.3"]')).toBeNull();
expect(container.textContent ?? '').not.toContain('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);
Expand Down Expand Up @@ -331,6 +390,42 @@ describe('WebShellSidebar — split view entry', () => {
});
});

describe('WebShellSidebar — resize behavior', () => {
it('persists normal drag widths without collapsing', () => {
setStoredSidebarWidth(260);
const onCollapsedChange = vi.fn();
const { container } = renderSidebar(false, { onCollapsedChange });
const handle = container.querySelector<HTMLElement>('[role="separator"]');
expect(handle).not.toBeNull();

act(() => {
handle!.dispatchEvent(pointerEvent('pointerdown', 260));
window.dispatchEvent(pointerEvent('pointermove', 230));
window.dispatchEvent(pointerEvent('pointerup', 230));
});

expect(onCollapsedChange).not.toHaveBeenCalled();
expect(window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)).toBe('230');
});

it('collapses when dragged past the compact threshold and restores the expanded width', () => {
setStoredSidebarWidth(260);
const onCollapsedChange = vi.fn();
const { container } = renderSidebar(false, { onCollapsedChange });
const handle = container.querySelector<HTMLElement>('[role="separator"]');
expect(handle).not.toBeNull();

act(() => {
handle!.dispatchEvent(pointerEvent('pointerdown', 260));
window.dispatchEvent(pointerEvent('pointermove', 130));
});

expect(onCollapsedChange).toHaveBeenCalledWith(true);
expect(onCollapsedChange).toHaveBeenCalledTimes(1);
expect(window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)).toBe('260');
});
});

function click(el: Element | null): void {
expect(el).not.toBeNull();
act(() => {
Expand Down
80 changes: 62 additions & 18 deletions packages/web-shell/client/components/sidebar/WebShellSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ const SIDEBAR_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-sidebar-width';
const SIDEBAR_DEFAULT_WIDTH = 260;
const SIDEBAR_MIN_WIDTH = 220;
const SIDEBAR_MAX_WIDTH = 420;
const SIDEBAR_FOOTER_COMPACT_WIDTH = 344;
const SIDEBAR_FOOTER_TIGHT_WIDTH = 250;
const SIDEBAR_DRAG_VISUAL_MIN_WIDTH = 200;
const SIDEBAR_COLLAPSE_DRAG_THRESHOLD = 56;
const SIDEBAR_COLLAPSE_DRAG_WIDTH =
SIDEBAR_DRAG_VISUAL_MIN_WIDTH - SIDEBAR_COLLAPSE_DRAG_THRESHOLD;
const ACTIVE_SESSION_POLL_INTERVAL_MS = 2000;
const IDLE_SESSION_POLL_INTERVAL_MS = 30_000;
const DIALOG_SESSION_LABEL_MAX_LENGTH = 96;
Expand Down Expand Up @@ -170,6 +176,13 @@ function clampSidebarWidth(width: number): number {
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, width));
}

function clampSidebarVisualWidth(width: number): number {
return Math.min(
SIDEBAR_MAX_WIDTH,
Math.max(SIDEBAR_DRAG_VISUAL_MIN_WIDTH, width),
);
}

function readSidebarWidth(): number {
if (typeof window === 'undefined') return SIDEBAR_DEFAULT_WIDTH;
try {
Expand Down Expand Up @@ -600,6 +613,9 @@ export function WebShellSidebar({
? `v${qwenCodeVersion}`
: qwenCodeVersion
: '';
const footerCompact =
!collapsed && sidebarWidth < SIDEBAR_FOOTER_COMPACT_WIDTH;
const footerTight = !collapsed && sidebarWidth < SIDEBAR_FOOTER_TIGHT_WIDTH;
const sidebarStyle = {
'--web-shell-sidebar-width': `${sidebarWidth}px`,
} as CSSProperties;
Expand Down Expand Up @@ -1479,20 +1495,39 @@ export function WebShellSidebar({
const startWidth = sidebarWidth;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
let collapsedByDrag = false;
let teardown: (updateState: boolean) => void = () => undefined;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Pointer capture is best-effort; window listeners still handle drag.
}
const handlePointerMove = (moveEvent: PointerEvent) => {
const nextWidth = clampSidebarWidth(
startWidth + moveEvent.clientX - startX,
);
setSidebarWidth(nextWidth);
};
const teardown = (updateState: boolean) => {
function getRawWidth(clientX: number) {
return startWidth + clientX - startX;
}
function restoreExpandedWidth() {
const restoredWidth = clampSidebarWidth(startWidth);
setSidebarWidth(restoredWidth);
writeSidebarWidth(restoredWidth);
}
function collapseFromDrag() {
if (collapsedByDrag) return;
collapsedByDrag = true;
restoreExpandedWidth();
teardown(true);
onCollapsedChange(true);
}
function handlePointerMove(moveEvent: PointerEvent) {
const rawWidth = getRawWidth(moveEvent.clientX);
if (rawWidth <= SIDEBAR_COLLAPSE_DRAG_WIDTH) {
collapseFromDrag();
return;
}
setSidebarWidth(clampSidebarVisualWidth(rawWidth));
}
teardown = function resizeTeardown(updateState: boolean) {
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
window.removeEventListener('pointermove', handlePointerMove);
Expand All @@ -1503,25 +1538,28 @@ export function WebShellSidebar({
setIsResizing(false);
}
};
const handlePointerUp = (upEvent: PointerEvent) => {
const nextWidth = clampSidebarWidth(
startWidth + upEvent.clientX - startX,
);
function handlePointerUp(upEvent: PointerEvent) {
const rawWidth = getRawWidth(upEvent.clientX);
if (rawWidth <= SIDEBAR_COLLAPSE_DRAG_WIDTH) {
collapseFromDrag();
return;
}
const nextWidth = clampSidebarWidth(rawWidth);
setSidebarWidth(nextWidth);
writeSidebarWidth(nextWidth);
teardown(true);
};
const handlePointerCancel = () => {
}
function handlePointerCancel() {
teardown(true);
};
}
resizeTeardownRef.current = teardown;
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp, { once: true });
window.addEventListener('pointercancel', handlePointerCancel, {
once: true,
});
},
[collapsed, sidebarWidth],
[collapsed, onCollapsedChange, sidebarWidth],
);

const deleteCandidateLabel = deleteCandidate
Expand Down Expand Up @@ -2506,7 +2544,13 @@ export function WebShellSidebar({
</div>
</div>

<div className={styles.footer}>
<div
className={cx(
styles.footer,
footerCompact && styles.footerCompact,
footerTight && styles.footerTight,
)}
>
<button
className={styles.footerButton}
type="button"
Expand All @@ -2517,13 +2561,13 @@ export function WebShellSidebar({
<span className={`${styles.navIcon} ${styles.settingsIcon}`}>
<IconSettings />
</span>
{!collapsed && (
{!collapsed && !footerCompact && (
<span className={styles.footerButtonLabel}>
{t('sidebar.settings')}
</span>
)}
</button>
{!collapsed && versionLabel && (
{!collapsed && !footerTight && versionLabel && (
<span className={styles.version} title={`Qwen Code ${versionLabel}`}>
{versionLabel}
</span>
Expand Down
Loading