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
5 changes: 5 additions & 0 deletions .changeset/web-unbounded-panel-width.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Allow the web sidebar and detail panel to be resized up to the available viewport width, keeping their resize handles reachable on narrow windows.
53 changes: 31 additions & 22 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -115,22 +115,6 @@ function onGlobalKeydown(e: KeyboardEvent): void {
}
}

// ---------------------------------------------------------------------------
// Layout: resizable session column. ResizeHandle owns the column width (with
// localStorage persistence); we mirror it here to drive the App grid.
// ---------------------------------------------------------------------------
const {
SIDEBAR_WIDTH_KEY,
SIDEBAR_DEFAULT,
SIDEBAR_MIN,
SIDEBAR_MAX,
sessionColWidth,
sidebarCollapsed,
sideWidth,
loadSidebarCollapsed,
toggleSidebarCollapse,
} = useSidebarLayout();

// ---------------------------------------------------------------------------
// Unified right-side detail layer. Only one detail is open at a time. The
// shared `detailTarget` ref lives here so the file-preview and detail-panel
Expand All @@ -152,6 +136,28 @@ const {
revealPreviewFile,
} = useFilePreview({ client, detailTarget });

// True while the right-side slot is actually occupied, so the sidebar reserves
// room for it and the conversation can never be squeezed. Keyed off detailTarget
// (the real occupant) rather than previewTarget, which can stay set after the
// panel is hidden.
const previewOpen = computed(() => detailTarget.value !== null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve sidebar width only for visible side panels

On desktop, when the user runs bare /btw to close an open side chat, handleCommand calls client.closeSideChat() directly and leaves detailTarget === 'btw'. sidePanelVisible then becomes false because client.sideChatVisible is false, but this computed still stays true, so useSidebarLayout keeps subtracting PREVIEW_MIN from the sidebar maximum after the right panel is gone. That leaves the session sidebar capped at viewport - 640 until some other action clears detailTarget; base this reservation on the actually visible panel state or clear detailTarget on the /btw close path.

Useful? React with 👍 / 👎.


// ---------------------------------------------------------------------------
// Layout: resizable session column. ResizeHandle owns the column width (with
// localStorage persistence); we mirror it here to drive the App grid.
// ---------------------------------------------------------------------------
const {
SIDEBAR_WIDTH_KEY,
SIDEBAR_DEFAULT,
SIDEBAR_MIN,
sidebarMax,
sessionColWidth,
sidebarCollapsed,
sideWidth,
loadSidebarCollapsed,
toggleSidebarCollapse,
} = useSidebarLayout({ previewOpen });

// ---------------------------------------------------------------------------
// Unified right-side detail layer (thinking / compaction / agent / diff / side
// chat) plus the preview-panel width. Only one detail is open at a time.
Expand All @@ -160,8 +166,9 @@ const {
PREVIEW_WIDTH_KEY,
PREVIEW_MIN,
previewDefaultWidth,
previewMaxWidth,
previewMax,
previewWidth,
previewPanelWidth,
thinkingPanelText,
thinkingVisible,
openThinkingPanel,
Expand Down Expand Up @@ -359,7 +366,9 @@ function handleCommand(cmd: string): void {
if (cmd === '/btw' || cmd.startsWith('/btw ')) {
const arg = cmd.slice('/btw'.length).trim();
if (!arg && client.sideChatVisible.value) {
client.closeSideChat();
// Use the detail-layer close so detailTarget is cleared too; the bare
// client.closeSideChat() only hides the panel and leaves detailTarget set.
closeSideChat();
} else {
void openSideChatTab(arg || undefined);
}
Expand Down Expand Up @@ -527,13 +536,13 @@ function openPr(url: string): void {
v-else
class="app"
:class="{ mobile: isMobile, 'sidebar-collapsed': sidebarCollapsed && !isMobile }"
:style="{ '--side-w': sideWidth + 'px', '--preview-w': previewWidth + 'px' }"
:style="{ '--side-w': sideWidth + 'px', '--preview-w': previewPanelWidth + 'px' }"
>
<!-- Desktop navigation: workspace rail + resizable session column. -->
<template v-if="!isMobile">
<Sidebar
v-show="!sidebarCollapsed"
:col-width="sessionColWidth"
:col-width="sideWidth"
:active-workspace="client.visibleWorkspace.value"
:active-workspace-id="client.activeWorkspaceId.value"
:sessions="client.sessionsForView.value"
Expand Down Expand Up @@ -561,7 +570,7 @@ function openPr(url: string): void {
:storage-key="SIDEBAR_WIDTH_KEY"
:default-width="SIDEBAR_DEFAULT"
:min="SIDEBAR_MIN"
:max="SIDEBAR_MAX"
:max="sidebarMax"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make resize caps react to viewport changes

When the desktop window grows after the sidebar handle has mounted, this viewport-derived max value updates in App.vue, but ResizeHandle.vue only calls useResizable({ max: props.max }) once during setup and useResizable destructures that initial number. As a result, opening on a narrow window and then widening it still leaves the sidebar clamped to the old smaller maximum until the handle is remounted, so the new viewport-aware cap does not actually let users drag to the newly available width.

Useful? React with 👍 / 👎.

@update:width="sessionColWidth = $event"
Comment on lines 572 to 574

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the sidebar resize handle reachable after restoring wide widths

If a user widens the sidebar on a large display and later opens the app in a narrower window, the persisted sessionColWidth is restored without any upper bound. Because .app has overflow: hidden and the sidebar resize handle sits after the var(--side-w) grid track, a restored width larger than the viewport pushes both the handle and the collapse button off-screen, leaving the conversation inaccessible with no in-UI way to shrink the sidebar. This needs a viewport-aware cap or another reachable recovery path for the sidebar even if fixed 420px limits are removed.

Useful? React with 👍 / 👎.

/>
<div v-if="sidebarCollapsed" class="sidebar-rail">
Expand Down Expand Up @@ -686,7 +695,7 @@ function openPr(url: string): void {
:storage-key="PREVIEW_WIDTH_KEY"
:default-width="previewDefaultWidth"
:min="PREVIEW_MIN"
:max="previewMaxWidth"
:max="previewMax"
reverse
:aria-label="t('layout.resizePreviewAria')"
@update:width="previewWidth = $event"
Expand Down
4 changes: 3 additions & 1 deletion apps/kimi-web/src/components/ResizeHandle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ const { width, dragging, onPointerDown } = useResizable({
storageKey: props.storageKey,
defaultWidth: props.defaultWidth,
min: props.min,
max: props.max,
// Pass a getter so the cap stays reactive: a viewport-derived max can grow
// after the handle mounts and the next drag will use the new limit.
max: () => props.max,
reverse: props.reverse,
});

Expand Down
32 changes: 22 additions & 10 deletions apps/kimi-web/src/composables/useDetailPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { computed, ref, watch, type Ref } from 'vue';
import type { AgentMember } from '../types';
import type { DetailTarget } from './useFilePreview';
import type { useKimiWebClient } from './useKimiWebClient';
import { clampPanelWidth, panelMaxWidth, useViewportWidth } from './useViewportWidth';

type KimiWebClient = ReturnType<typeof useKimiWebClient>;

const PREVIEW_WIDTH_KEY = 'kimi-web.file-preview-width';
const PREVIEW_MIN = 320;
export const PREVIEW_MIN = 320;

export interface UseDetailPanelOptions {
client: KimiWebClient;
Expand All @@ -30,23 +31,33 @@ export function useDetailPanel({
// ---------------------------------------------------------------------------
// Panel width helpers
// ---------------------------------------------------------------------------
function previewAreaWidth(): number {
if (typeof window === 'undefined') return PREVIEW_MIN * 2;
return Math.max(0, window.innerWidth - sideWidth.value);
}
const { viewportWidth } = useViewportWidth();

// Area available to the right of the sidebar (conversation + preview).
const previewAreaWidth = computed(() =>
Math.max(0, viewportWidth.value - sideWidth.value),
);

// Largest preview width that still leaves the conversation pane usable.
const previewMax = computed(() =>
panelMaxWidth(previewAreaWidth.value, PREVIEW_MIN, PREVIEW_MIN),
);

function clampPreviewWidth(width: number): number {
const max = Math.max(PREVIEW_MIN, previewAreaWidth() - PREVIEW_MIN);
return Math.min(max, Math.max(PREVIEW_MIN, Math.round(width)));
return clampPanelWidth(Math.round(width), PREVIEW_MIN, previewMax.value);
}

function defaultPreviewWidth(): number {
return clampPreviewWidth(previewAreaWidth() / 2);
return clampPreviewWidth(previewAreaWidth.value / 2);
}

const previewDefaultWidth = computed(() => defaultPreviewWidth());
const previewMaxWidth = computed(() => Math.max(PREVIEW_MIN, previewAreaWidth() - PREVIEW_MIN));
const previewWidth = ref(previewDefaultWidth.value);
// Rendered width, clamped to the current cap so a restored width or a window
// shrink can never push the resize handle off-screen.
const previewPanelWidth = computed(() =>
clampPanelWidth(previewWidth.value, PREVIEW_MIN, previewMax.value),
);

// ---------------------------------------------------------------------------
// Thinking panel
Expand Down Expand Up @@ -227,8 +238,9 @@ export function useDetailPanel({
PREVIEW_WIDTH_KEY,
PREVIEW_MIN,
previewDefaultWidth,
previewMaxWidth,
previewMax,
previewWidth,
previewPanelWidth,
thinkingPanelText,
thinkingVisible,
openThinkingPanel,
Expand Down
14 changes: 9 additions & 5 deletions apps/kimi-web/src/composables/useResizable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// up pointer events (pointerdown/move/up with capture, no text-selection while
// dragging). Used by the sidebar session column drag handle.

import { onBeforeUnmount, ref, type Ref } from 'vue';
import { onBeforeUnmount, ref, toValue, type MaybeRefOrGetter, type Ref } from 'vue';
import { safeGetString, safeSetString } from '../lib/storage';

export interface UseResizableOptions {
Expand All @@ -14,8 +14,9 @@ export interface UseResizableOptions {
defaultWidth: number;
/** Smallest allowed width (px). */
min: number;
/** Largest allowed width (px). */
max: number;
/** Largest allowed width (px). Accepts a ref/getter so a cap derived from the
* viewport keeps working as the window is resized after the handle mounts. */
max: MaybeRefOrGetter<number>;
/** True when dragging right should shrink the controlled width. */
reverse?: boolean;
}
Expand Down Expand Up @@ -57,7 +58,7 @@ export function useResizable(options: UseResizableOptions): UseResizable {

function clamp(value: number): number {
if (!Number.isFinite(value)) return defaultWidth;
return Math.min(max, Math.max(min, Math.round(value)));
return Math.min(toValue(max), Math.max(min, Math.round(value)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clamp stored width before starting a drag

When max drops after the handle has mounted (for example, a wide saved sidebar/preview width is rendered clamped after the window narrows or a detail panel opens), the internal width.value can remain at the old larger value because this clamp only runs when setWidth is called. onPointerDown then captures that stale value as startWidth, so the first drag has to cover the invisible delta before the handle moves; users can appear unable to shrink the now-clamped panel until they release and start a second drag. Sync the internal width when the cap changes, or clamp it before assigning startWidth.

Useful? React with 👍 / 👎.

}

const width = ref<number>(clamp(readStored(storageKey) ?? defaultWidth));
Expand Down Expand Up @@ -107,7 +108,10 @@ export function useResizable(options: UseResizableOptions): UseResizable {
event.preventDefault();
dragging.value = true;
startX = event.clientX;
startWidth = width.value;
// The stored width can exceed the current cap (e.g. after the window narrows
// or a side panel opens). Clamp the drag start so the handle responds
// immediately instead of first covering an invisible delta.
startWidth = clamp(width.value);
activeEl = event.currentTarget as HTMLElement;
activePointerId = event.pointerId;
// Suppress text selection / show a resize cursor for the whole drag.
Expand Down
34 changes: 29 additions & 5 deletions apps/kimi-web/src/composables/useSidebarLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,45 @@
// Layout: resizable session column. ResizeHandle owns the column width (with
// localStorage persistence); we mirror it here to drive the App grid.

import { computed, ref } from 'vue';
import { computed, ref, toValue, type MaybeRefOrGetter } from 'vue';
import { safeGetString, safeSetString, STORAGE_KEYS } from '../lib/storage';
import { PREVIEW_MIN } from './useDetailPanel';
import { clampPanelWidth, panelMaxWidth, useViewportWidth } from './useViewportWidth';

const SIDEBAR_WIDTH_KEY = STORAGE_KEYS.sidebarWidth;
const SIDEBAR_COLLAPSED_KEY = STORAGE_KEYS.sidebarCollapsed;
const SIDEBAR_DEFAULT = 270;
const SIDEBAR_MIN = 170;
const SIDEBAR_MAX = 420;
const SIDEBAR_COLLAPSED_WIDTH = 36;
// Minimum width kept for the conversation pane. The sidebar is capped so the
// conversation keeps at least this much room, which also guarantees the sidebar
// resize handle and collapse button stay inside the viewport even when a width
// saved on a wider display is restored on a narrower one.
const CONVERSATION_MIN = 320;

export function useSidebarLayout() {
export interface UseSidebarLayoutOptions {
/** True while the right-side detail/preview panel is open, so the sidebar
* reserves room for it in addition to the conversation pane. */
previewOpen?: MaybeRefOrGetter<boolean>;
}

export function useSidebarLayout(options: UseSidebarLayoutOptions = {}) {
const { viewportWidth } = useViewportWidth();
const sessionColWidth = ref(SIDEBAR_DEFAULT);
const sidebarCollapsed = ref(false);

// Largest sidebar width that still leaves the conversation pane usable. When
// the right-side panel is open, also reserves its minimum width so the
// conversation column can never be squeezed to nothing.
const sidebarMax = computed(() => {
const reserve = CONVERSATION_MIN + (toValue(options.previewOpen) ? PREVIEW_MIN : 0);
return panelMaxWidth(viewportWidth.value, SIDEBAR_MIN, reserve);
});

const sideWidth = computed(() =>
sidebarCollapsed.value ? SIDEBAR_COLLAPSED_WIDTH : sessionColWidth.value,
sidebarCollapsed.value
? SIDEBAR_COLLAPSED_WIDTH
: clampPanelWidth(sessionColWidth.value, SIDEBAR_MIN, sidebarMax.value),
);
Comment on lines 40 to 44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clamp the sidebar content width too

When a saved/sidebar width is above the new viewport-derived cap, this only clamps sideWidth for the grid track; sessionColWidth remains the larger value and is still passed to Sidebar, whose .col is rendered at that width. On a narrower window or after opening the right panel, the grid column shrinks but the sidebar header/list are laid out at the stale larger width, so controls like the collapse button can be clipped or covered by the conversation pane instead of staying reachable.

Useful? React with 👍 / 👎.


function loadSidebarCollapsed(): void {
Expand Down Expand Up @@ -44,7 +68,7 @@ export function useSidebarLayout() {
SIDEBAR_WIDTH_KEY,
SIDEBAR_DEFAULT,
SIDEBAR_MIN,
SIDEBAR_MAX,
sidebarMax,
sessionColWidth,
sidebarCollapsed,
sideWidth,
Expand Down
50 changes: 50 additions & 0 deletions apps/kimi-web/src/composables/useViewportWidth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// apps/kimi-web/src/composables/useViewportWidth.ts
// Shared reactive viewport width for the resizable layout panels. A single
// window resize listener backs every consumer, so panels can cap themselves to
// the current window size without each wiring up their own listener.

import { onBeforeUnmount, onMounted, ref } from 'vue';

const viewportWidth = ref(typeof window === 'undefined' ? 0 : window.innerWidth);
let subscribers = 0;
let listening = false;

function update(): void {
viewportWidth.value = window.innerWidth;
}

function startListening(): void {
if (listening || typeof window === 'undefined') return;
window.addEventListener('resize', update);
listening = true;
update();
}

function stopListening(): void {
if (!listening || typeof window === 'undefined') return;
window.removeEventListener('resize', update);
listening = false;
}

/** Largest a panel may grow while keeping `reserve` px free for the rest of the
* layout. Never drops below the panel's own `min`. */
export function panelMaxWidth(available: number, min: number, reserve: number): number {
return Math.max(min, available - reserve);
}

/** Clamp a panel's chosen width into its allowed [min, max] range. */
export function clampPanelWidth(width: number, min: number, max: number): number {
return Math.min(max, Math.max(min, width));
}

export function useViewportWidth() {
onMounted(() => {
subscribers += 1;
startListening();
});
onBeforeUnmount(() => {
subscribers = Math.max(0, subscribers - 1);
if (subscribers === 0) stopListening();
});
return { viewportWidth };
}
Loading