From 170ae4420526b6592d696cd597d1693dbd1a660b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 19:39:20 +0800 Subject: [PATCH] style(web): polish the session sidebar (#1519) * feat(web): use sidebar fold/unfold icons for sidebar toggle * feat(web): move settings entry to a sidebar footer row * feat(web): fully collapse sidebar with animated width transition * feat(web): redesign sidebar colors, spacing and macos desktop chrome * feat(desktop): center traffic lights on the 48px header row * fix(web): restore webkit thin scrollbars and unify sidebar icon sizes * feat(web): add Kbd keycap component and justify sidebar search shortcut * style(web): rework sidebar palette and pin a resident sidebar toggle * fix(desktop): sync window appearance with web UI theme so dimmed traffic lights stay visible * feat(web): adopt Kimi design icons in the sidebar via a local icon collection * style(web): mute workspace group title color in the sidebar * style(web): refine sidebar typography, unify shortcut keycaps, float workspace row actions * style(web): cap sidebar draggable width at 480px * style(web): derive sidebar row height from type and padding, float the kebab * chore: add changeset for sidebar UI polish * fix(nix): update pnpmDeps hash * style(web): put the sidebar collapse button inside the header on non-mac * fix(nix): update pnpmDeps hash --- .changeset/polish-sidebar-ui.md | 5 + apps/kimi-desktop/src/main/index.ts | 66 +++- apps/kimi-web/package.json | 1 + apps/kimi-web/src/App.vue | 140 ++++++-- .../src/components/InternalBuildBanner.vue | 4 +- apps/kimi-web/src/components/SessionRow.vue | 72 ++-- apps/kimi-web/src/components/Sidebar.vue | 328 ++++++++++-------- .../src/components/WorkspaceGroup.vue | 183 +++++----- .../src/components/chat/ApprovalCard.vue | 18 +- .../kimi-web/src/components/ui/IconButton.vue | 6 +- apps/kimi-web/src/components/ui/Kbd.vue | 40 +++ .../src/components/ui/ShortcutKey.vue | 24 -- .../src/composables/useSidebarLayout.ts | 25 +- apps/kimi-web/src/i18n/locales/en/sidebar.ts | 3 +- apps/kimi-web/src/i18n/locales/zh/sidebar.ts | 3 +- .../src/icons/kimi/add-conversation.svg | 4 + apps/kimi-web/src/icons/kimi/folder-open.svg | 10 + apps/kimi-web/src/icons/kimi/folder.svg | 3 + apps/kimi-web/src/icons/kimi/more.svg | 5 + apps/kimi-web/src/icons/kimi/search.svg | 3 + apps/kimi-web/src/icons/kimi/setting.svg | 4 + apps/kimi-web/src/lib/icons.ts | 81 +++-- apps/kimi-web/src/lib/storage.ts | 15 - apps/kimi-web/src/style.css | 29 +- apps/kimi-web/src/views/DesignSystemView.vue | 71 ++-- apps/kimi-web/vite.config.ts | 16 +- flake.nix | 2 +- pnpm-lock.yaml | 10 + 28 files changed, 758 insertions(+), 413 deletions(-) create mode 100644 .changeset/polish-sidebar-ui.md create mode 100644 apps/kimi-web/src/components/ui/Kbd.vue delete mode 100644 apps/kimi-web/src/components/ui/ShortcutKey.vue create mode 100644 apps/kimi-web/src/icons/kimi/add-conversation.svg create mode 100644 apps/kimi-web/src/icons/kimi/folder-open.svg create mode 100644 apps/kimi-web/src/icons/kimi/folder.svg create mode 100644 apps/kimi-web/src/icons/kimi/more.svg create mode 100644 apps/kimi-web/src/icons/kimi/search.svg create mode 100644 apps/kimi-web/src/icons/kimi/setting.svg 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(() => {