diff --git a/.changeset/polish-sidebar-ui.md b/.changeset/polish-sidebar-ui.md
new file mode 100644
index 000000000..da0cefa73
--- /dev/null
+++ b/.changeset/polish-sidebar-ui.md
@@ -0,0 +1,5 @@
+---
+"@moonshot-ai/kimi-code": patch
+---
+
+web: Polish the session sidebar layout, colors, icons, and typography.
diff --git a/apps/kimi-desktop/src/main/index.ts b/apps/kimi-desktop/src/main/index.ts
index 7c440a1ce..2a765274b 100644
--- a/apps/kimi-desktop/src/main/index.ts
+++ b/apps/kimi-desktop/src/main/index.ts
@@ -1,7 +1,7 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
-import { app, BrowserWindow, Menu, shell } from 'electron';
+import { app, BrowserWindow, Menu, nativeTheme, shell } from 'electron';
import type { MenuItemConstructorOptions } from 'electron';
import { ensureServer, kimiHome, serverLogPath } from './ensure-server';
@@ -152,8 +152,13 @@ function createWindow(): void {
title: 'Kimi Code Desktop',
// macOS: hide the native title bar and float the traffic lights over the
// content; the web UI reserves a draggable strip at the top to clear them.
+ // 'hidden' (not 'hiddenInset') so trafficLightPosition can pin the lights
+ // to the vertical center of the web UI's 48px header row (y 18 + 12px
+ // button height / 2 = 24 = the header's midline — same line as the
+ // sidebar-expand button and the conversation title).
// 'default' on other platforms (they keep their native title bar).
- titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
+ titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
+ trafficLightPosition: { x: 16, y: 18 },
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
@@ -165,6 +170,63 @@ function createWindow(): void {
win.webContents.on('page-title-updated', (event) => {
event.preventDefault();
});
+ // macOS traffic lights.
+ //
+ // 1) Visibility across transitions: with titleBarStyle 'hidden' + a custom
+ // trafficLightPosition, the buttons can vanish (or lose their custom
+ // position) after a full-screen round-trip or on re-focus. Re-assert both
+ // on those transitions (observed on Electron 33; belt-and-braces).
+ //
+ // 2) Blur is NOT such a case: unfocused traffic lights are merely DIMMED by
+ // AppKit, and the dimmed color follows the WINDOW appearance, not the
+ // page (electron#27295) — with the OS in dark mode but the web UI on a
+ // light theme, the light-gray dimmed dots become invisible against the
+ // light sidebar. That is fixed by the theme sync below, which keeps the
+ // window appearance aligned with the web UI's .
+ if (process.platform === 'darwin') {
+ const showTrafficLights = (): void => {
+ if (win.isDestroyed()) return;
+ win.setWindowButtonPosition({ x: 16, y: 18 });
+ win.setWindowButtonVisibility(true);
+ };
+ win.on('enter-full-screen', showTrafficLights);
+ win.on('leave-full-screen', showTrafficLights);
+ win.on('focus', showTrafficLights);
+
+ // Theme sync: no preload/IPC channel exists, so inject a tiny observer
+ // that reports ('light' | 'dark' | 'system')
+ // through a tagged console message, and mirror it into
+ // nativeTheme.themeSource (same three states). The startup/error screens
+ // (data: URLs) have no such attribute and harmlessly report 'system'.
+ const THEME_TAG = '__kimi_desktop_theme__:';
+ win.webContents.on('console-message', (_event, _level, message) => {
+ if (!message.startsWith(THEME_TAG)) return;
+ const scheme = message.slice(THEME_TAG.length);
+ if (scheme === 'light' || scheme === 'dark' || scheme === 'system') {
+ nativeTheme.themeSource = scheme;
+ }
+ });
+ win.webContents.on('did-finish-load', () => {
+ win.webContents
+ .executeJavaScript(
+ `(() => {
+ const report = () => {
+ const v = document.documentElement.dataset.colorScheme;
+ console.info(${JSON.stringify(THEME_TAG)} + (v === 'light' || v === 'dark' ? v : 'system'));
+ };
+ new MutationObserver(report).observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['data-color-scheme'],
+ });
+ report();
+ })();`,
+ )
+ .catch(() => {
+ // Navigation can tear the page down mid-injection; theme sync is
+ // cosmetic, so ignore.
+ });
+ });
+ }
win.on('close', () => {
saveBounds(win);
});
diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json
index f7d2f2370..300d1848d 100644
--- a/apps/kimi-web/package.json
+++ b/apps/kimi-web/package.json
@@ -28,6 +28,7 @@
},
"devDependencies": {
"@iconify-json/ri": "^1.2.10",
+ "@iconify-json/tabler": "^1.2.35",
"@vitejs/plugin-vue": "^5.2.4",
"typescript": "6.0.2",
"unplugin-icons": "^23.0.0",
diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue
index f41127fb7..e46e8a2fc 100644
--- a/apps/kimi-web/src/App.vue
+++ b/apps/kimi-web/src/App.vue
@@ -43,6 +43,8 @@ import { stripSkillPrefix } from './lib/slashCommands';
import Button from './components/ui/Button.vue';
import IconButton from './components/ui/IconButton.vue';
import Icon from './components/ui/Icon.vue';
+import InternalBuildBanner from './components/InternalBuildBanner.vue';
+import { isMacosDesktop } from './lib/desktopFlag';
// Hydrate the server-transport credential (fragment token or sessionStorage)
// BEFORE the client connects, so the first REST/WS calls already carry it.
@@ -213,6 +215,7 @@ const {
sidebarMax,
sessionColWidth,
sidebarCollapsed,
+ sidebarDragging,
sideWidth,
loadSidebarCollapsed,
toggleSidebarCollapse,
@@ -645,13 +648,18 @@ function openPr(url: string): void {
-
-
-
-
-
@@ -792,8 +793,29 @@ function openPr(url: string): void {
@edit-message="handleEditMessage"
/>
+
+
+
+
+
+
+
+
.side { grid-column: 1; }
+.side-handle { grid-column: 2; }
+.app:not(.mobile) > .con { grid-column: 3; }
+.preview-handle { grid-column: 4; }
+
+/* Sidebar toggle — floating button pinned to the top-left corner. On macOS
+ desktop it is resident (rendered in both states beside the traffic lights);
+ on Windows/web it only appears while the sidebar is collapsed (the collapse
+ button lives inside the sidebar header). While collapsed the conversation
+ header pads left so its content clears the button (global block below). */
+.sidebar-toggle-btn {
+ position: absolute;
+ /* Vertically centered in the 48px conversation header. */
+ top: 11px;
+ left: 16px;
+ z-index: var(--z-sticky);
+ /* Fade in on appearance (Windows/web: only rendered while collapsed, so
+ this plays as the sidebar finishes sliding away). macOS disables it. */
+ animation: sidebar-toggle-btn-in 0.18s var(--ease-out) 0.12s backwards;
+ /* Floats over the macOS-desktop window-drag header; keep it clickable. */
+ -webkit-app-region: no-drag;
}
-/* The collapsed rail occupies track 1; keep the main pane pinned to the
- conversation track even though the sidebar/handle are display:none. */
-.app.sidebar-collapsed > .con {
- grid-column: 3;
+/* macOS desktop (hidden title bar): resident beside the floating traffic
+ lights (green light's right edge ≈ 68px; 72 keeps a gap that matches the
+ lights' own 8px rhythm); no entrance animation since it never appears. */
+.app.macos-desktop .sidebar-toggle-btn {
+ left: 72px;
+ animation: none;
+}
+@keyframes sidebar-toggle-btn-in {
+ from { opacity: 0; }
+}
+
+/* Internal-build tag pinned to the app's bottom-right corner (desktop app
+ only — the component renders nothing elsewhere). Informational: never
+ intercepts pointer input. */
+.internal-build-fab {
+ position: absolute;
+ right: var(--space-3);
+ bottom: var(--space-3);
+ z-index: var(--z-sticky);
+ pointer-events: none;
}
/* Mobile single-column shell: slim top bar (auto) over the full-width
@@ -1208,4 +1267,19 @@ function openPr(url: string): void {
one continuous line across the layout. */
--panel-head-h: 48px;
}
+
+/* Sidebar collapsed (desktop): the conversation header pads left so its
+ content clears the floating sidebar toggle (.sidebar-toggle-btn) — and the
+ macOS traffic lights on desktop builds. Animated in step with the sidebar
+ width transition. Cross-component rule (ChatHeader renders the header), so
+ it lives in this global block. */
+.app:not(.mobile) .chat-header {
+ transition: padding-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.app.sidebar-collapsed .chat-header {
+ padding-left: 52px;
+}
+.app.sidebar-collapsed.macos-desktop .chat-header {
+ padding-left: 108px;
+}
diff --git a/apps/kimi-web/src/components/InternalBuildBanner.vue b/apps/kimi-web/src/components/InternalBuildBanner.vue
index e2638aaad..a45748f7b 100644
--- a/apps/kimi-web/src/components/InternalBuildBanner.vue
+++ b/apps/kimi-web/src/components/InternalBuildBanner.vue
@@ -5,8 +5,8 @@ import { isDesktop } from '../lib/desktopFlag';
const { t } = useI18n();
-// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders an inline
-// tag meant to sit next to the "Kimi Code" brand in the sidebar header.
+// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders a small
+// tag pinned to the app's bottom-right corner (positioned by App.vue).
const show = isDesktop;
diff --git a/apps/kimi-web/src/components/SessionRow.vue b/apps/kimi-web/src/components/SessionRow.vue
index 2291a2681..30158be01 100644
--- a/apps/kimi-web/src/components/SessionRow.vue
+++ b/apps/kimi-web/src/components/SessionRow.vue
@@ -254,7 +254,7 @@ defineExpose({ closeMenu });
:label="t('sidebar.options')"
@click.stop="toggleMenu($event)"
>
-
+
@@ -287,22 +287,24 @@ defineExpose({ closeMenu });
.se {
/* --sb-* vars come from .side in Sidebar.vue: the title starts at
--sb-pad-x + --sb-gutter + --sb-gap, exactly under the workspace name.
- The row is an inset pill: a 6px horizontal margin + 10px padding lands the
- leading icon at --sb-pad-x (16px), aligned with the workspace header. */
+ The row is an inset pill: the .sessions container's --sb-inset padding +
+ the row's own padding land the leading slot at --sb-pad-x, aligned with
+ the workspace header. */
display: block;
margin: 0;
- padding: var(--space-1) var(--space-2);
- border-radius: var(--radius-md);
+ padding: 8px var(--space-2);
+ border-radius: var(--radius-sm);
font-family: var(--font-ui);
color: var(--color-text);
cursor: pointer;
position: relative;
}
-.se:hover { background: var(--color-surface-sunken); color: var(--color-text); }
+.se:hover { background: var(--sb-hover, var(--color-surface-sunken)); color: var(--color-text); }
+/* Selected: neutral fill (NOT accent-tinted — selection reads as "where I
+ am", the accent stays reserved for actions and status). */
.se.on {
- background: var(--color-accent-soft);
- color: var(--color-accent-hover);
- box-shadow: inset 0 0 0 1px var(--color-accent-bd);
+ background: var(--color-selected);
+ color: var(--color-text);
}
.row {
@@ -310,9 +312,9 @@ defineExpose({ closeMenu });
align-items: center;
gap: var(--sb-gap, 6px);
min-width: 0;
- /* Floor the row at the hover-kebab height (IconButton sm = 26px) so swapping
- the timestamp for the kebab on hover doesn't grow the row. */
- min-height: 26px;
+ /* Row height is font-driven: title line-height (13×1.25≈16px) + 2×5px
+ .se padding ≈ 26px. The hover kebab is absolutely positioned (see .act)
+ so it never contributes to row height and can't cause hover jitter. */
}
.left {
@@ -341,7 +343,7 @@ defineExpose({ closeMenu });
.t {
color: inherit;
- font-size: var(--text-base);
+ font-size: var(--ui-font-size-sm);
font-weight: 450;
line-height: var(--leading-tight);
flex: 1;
@@ -356,30 +358,37 @@ defineExpose({ closeMenu });
font-size: var(--text-xs);
font-family: var(--font-ui);
font-weight: 475;
+ line-height: var(--leading-tight);
font-variant-numeric: tabular-nums;
text-align: right;
}
-/* Trailing action slot: time and kebab share one grid cell (grid-area:1/1).
- Both stay in the layout and swap via `visibility` (never display:none), so
- the slot width = max(time width, IconButton sm 26px) is identical in hover
- and rest — the badges and title don't reflow, eliminating hover jitter.
- `.act .kebab` out-specificities IconButton's own display so the hidden
- default wins. */
+/* Trailing action slot: the relative time (in flow) sets the slot size; the
+ kebab is absolutely positioned over it and swapped via `visibility`, so it
+ contributes neither height (the row stays font-driven) nor width changes
+ (min-width reserves the kebab's footprint, the title doesn't reflow). */
.act {
- display: inline-grid;
+ position: relative;
flex: none;
+ display: inline-flex;
align-items: center;
- justify-items: end;
+ justify-content: flex-end;
+ /* Reserve the kebab's width so the trailing slot (and thus the title) never
+ shifts between the time and the kebab, even for short times like "2m". */
+ min-width: 26px;
+}
+.act .kebab {
+ position: absolute;
+ right: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ visibility: hidden;
}
-.act .ts,
-.act .kebab { grid-area: 1 / 1; }
-.act .kebab { visibility: hidden; }
.se:hover .act .kebab,
.act:has(.kebab.open) .kebab { visibility: visible; }
.se:hover .act .ts,
.act:has(.kebab.open) .ts { visibility: hidden; }
-.kebab.open { color: var(--color-text); background: var(--color-surface-sunken); }
+.kebab.open { color: var(--color-text); background: var(--sb-hover, var(--color-surface-sunken)); }
/* Fixed + anchored to the ⋯ button via inline style (see positionMenu); the menu
is teleported to so the collapsing list's `overflow: hidden` can't clip it. */
@@ -413,15 +422,10 @@ defineExpose({ closeMenu });
.sessions .se {
margin: 0;
- border-radius: var(--radius-md);
- /* Trim the row padding by the inset margin so the title still starts at the
- same x as the workspace name (whose header has no inset). */
- padding: var(--space-1) calc(var(--sb-pad-x, 12px) - var(--space-2));
-}
-.sessions .se:hover { background: var(--panel2); }
-.sessions .se.on {
- background: var(--color-accent-soft);
- box-shadow: inset 0 0 0 1px var(--color-accent-bd);
+ border-radius: var(--radius-sm);
+ /* Trim the row padding by the container inset so the title still starts at
+ the same x as the workspace name (whose header has no inset). */
+ padding: 8px calc(var(--sb-pad-x, 20px) - var(--sb-inset, 12px));
}
.sessions .se .rename-input { border-radius: var(--radius-sm); font-family: var(--sans); }
.sessions .se .kebab { border-radius: var(--radius-sm); }
diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue
index 987465dbb..8c9127e2e 100644
--- a/apps/kimi-web/src/components/Sidebar.vue
+++ b/apps/kimi-web/src/components/Sidebar.vue
@@ -9,21 +9,18 @@ import { serverEndpointLabel } from '../api/config';
import { copyTextToClipboard } from '../lib/clipboard';
import {
loadCollapsedWorkspaces,
- loadShowWorkspacePaths,
saveCollapsedWorkspaces,
- saveShowWorkspacePaths,
} from '../lib/storage';
import { moveInOrder, type DropPosition, type WorkspaceSortMode } from '../lib/workspaceOrder';
import type { Session, WorkspaceGroup as WorkspaceGroupType, WorkspaceView } from '../types';
import SearchSessionsDialog from './dialogs/SearchSessionsDialog.vue';
import WorkspaceGroup from './WorkspaceGroup.vue';
-import InternalBuildBanner from './InternalBuildBanner.vue';
import { isMacosDesktop } from '../lib/desktopFlag';
import IconButton from './ui/IconButton.vue';
import Icon from './ui/Icon.vue';
+import Kbd from './ui/Kbd.vue';
import Menu from './ui/Menu.vue';
import MenuItem from './ui/MenuItem.vue';
-import ShortcutKey from './ui/ShortcutKey.vue';
import { useConfirmDialog } from '../composables/useConfirmDialog';
const { t } = useI18n();
@@ -50,6 +47,12 @@ const props = withDefaults(
unreadBySession?: Record;
/** Width (px) of the session column, driven by the App resize handle. */
colWidth?: number;
+ /** True when the sidebar is collapsed: the container animates to width 0
+ * (content keeps `colWidth` and is clipped), then hides itself. */
+ collapsed?: boolean;
+ /** True while the resize handle is dragged — disables the width transition
+ * so the sidebar follows the pointer 1:1. */
+ dragging?: boolean;
}>(),
{
activeWorkspace: null,
@@ -58,6 +61,8 @@ const props = withDefaults(
pendingBySession: () => ({}),
unreadBySession: () => ({}),
colWidth: 220,
+ collapsed: false,
+ dragging: false,
},
);
@@ -84,7 +89,7 @@ const emit = defineEmits<{
// Session search dialog (Spotlight-style; filters title + last prompt)
// ---------------------------------------------------------------------------
const showSearch = ref(false);
-const sessionSearchShortcut = isAppleShortcutPlatform() ? '⌘K' : 'Ctrl K';
+const sessionSearchKeys = isAppleShortcutPlatform() ? ['⌘', 'K'] : ['Ctrl', 'K'];
function openSearch(): void {
// Sessions are loaded per-workspace (first page only); lazily drain the rest
@@ -111,7 +116,7 @@ function isAppleShortcutPlatform(): boolean {
return userAgentData?.platform === 'macOS' || userAgentData?.platform === 'iOS';
}
-// Scroll-linked header seam: the .btn-wrap bottom border/shadow only appears
+// Scroll-linked header seam: the .search-wrap bottom border/shadow only appears
// once the session list has actually scrolled, so an unscrolled list shows no
// abrupt boundary.
const sessionsScrolled = ref(false);
@@ -186,18 +191,6 @@ function onLoadMore(id: string): void {
emit('loadMoreSessions', id);
}
-// ---------------------------------------------------------------------------
-// Workspace path display (toggle in the Workspaces section header)
-// ---------------------------------------------------------------------------
-// Off by default so the list stays compact; turning it on reveals every
-// workspace's root path as a stable subtitle (no hover-induced layout shift).
-const showWorkspacePaths = ref(loadShowWorkspacePaths());
-
-function toggleShowWorkspacePaths(): void {
- showWorkspacePaths.value = !showWorkspacePaths.value;
- saveShowWorkspacePaths(showWorkspacePaths.value);
-}
-
// ---------------------------------------------------------------------------
// Workspace drag-to-reorder
// ---------------------------------------------------------------------------
@@ -487,11 +480,6 @@ function chooseSortMode(mode: WorkspaceSortMode): void {
closeSectionMenu();
}
-function toggleShowWorkspacePathsFromMenu(): void {
- toggleShowWorkspacePaths();
- closeSectionMenu();
-}
-
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onGhMenuDocClick, true);
document.removeEventListener('mousedown', onWsMenuDocClick);
@@ -560,54 +548,49 @@ onBeforeUnmount(() => {
-