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
This commit is contained in:
liruifengv 2026-07-09 19:39:20 +08:00 committed by GitHub
parent 1bf2c9afee
commit 170ae44205
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 758 additions and 413 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Polish the session sidebar layout, colors, icons, and typography.

View file

@ -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 <html data-color-scheme>.
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 <html data-color-scheme> ('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);
});

View file

@ -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",

View file

@ -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 {
<div
v-else
class="app"
:class="{ mobile: isMobile, 'sidebar-collapsed': sidebarCollapsed && !isMobile }"
:style="{ '--side-w': sideWidth + 'px', '--preview-w': previewPanelWidth + 'px' }"
:class="{
mobile: isMobile,
'sidebar-collapsed': sidebarCollapsed && !isMobile,
'macos-desktop': isMacosDesktop,
}"
:style="{ '--preview-w': previewPanelWidth + 'px' }"
>
<!-- Desktop navigation: workspace rail + resizable session column. -->
<template v-if="!isMobile">
<Sidebar
v-show="!sidebarCollapsed"
:collapsed="sidebarCollapsed"
:dragging="sidebarDragging"
:col-width="sideWidth"
:active-workspace="client.visibleWorkspace.value"
:active-workspace-id="client.activeWorkspaceId.value"
@ -681,21 +689,14 @@ function openPr(url: string): void {
/>
<ResizeHandle
v-show="!sidebarCollapsed"
class="side-handle"
:storage-key="SIDEBAR_WIDTH_KEY"
:default-width="SIDEBAR_DEFAULT"
:min="SIDEBAR_MIN"
:max="sidebarMax"
@update:width="sessionColWidth = $event"
@update:dragging="sidebarDragging = $event"
/>
<div v-if="sidebarCollapsed" class="sidebar-rail">
<IconButton
size="sm"
:label="t('sidebar.expandSidebar')"
@click="toggleSidebarCollapse"
>
<Icon name="panel-expand" size="sm" />
</IconButton>
</div>
</template>
<!-- Mobile navigation: slim top bar (switcher + settings sheets). -->
@ -792,8 +793,29 @@ function openPr(url: string): void {
@edit-message="handleEditMessage"
/>
<!-- Sidebar toggle floating only when the in-header control can't serve:
on macOS desktop it's RESIDENT (always rendered beside the traffic
lights, the sidebar slides underneath and only the glyph swaps, so it
never moves or flashes); on Windows/web the collapse button lives
inside the sidebar header, so this floating button only appears while
COLLAPSED (to re-expand the sidebar). It must come AFTER
ConversationPane in the DOM: Electron computes the window-drag region
in tree order (drag rects union, no-drag rects subtract), so a no-drag
element placed before the ChatHeader drag region would have its hole
painted back over making the button an inert drag area. -->
<IconButton
v-if="!isMobile && (isMacosDesktop || sidebarCollapsed)"
class="sidebar-toggle-btn"
size="sm"
:label="sidebarCollapsed ? t('sidebar.expandSidebar') : t('sidebar.collapseSidebar')"
@click="toggleSidebarCollapse"
>
<Icon :name="sidebarCollapsed ? 'panel-expand' : 'panel-collapse'" />
</IconButton>
<ResizeHandle
v-if="sidePanelVisible && !isMobile"
class="preview-handle"
:storage-key="PREVIEW_WIDTH_KEY"
:default-width="previewDefaultWidth"
:min="PREVIEW_MIN"
@ -875,6 +897,11 @@ function openPr(url: string): void {
/>
</aside>
<!-- Internal-build tag pinned to the app's bottom-right corner, above
whatever pane happens to be there. Purely informational: pointer
events pass through so it never blocks clicks. -->
<InternalBuildBanner class="internal-build-fab" />
<!-- Model Picker overlay -->
<ModelPicker
v-if="showModelPicker"
@ -1101,18 +1128,20 @@ function openPr(url: string): void {
color: var(--dim);
}
.app {
--side-w: 248px;
--preview-w: 460px;
flex: 1;
min-height: 0;
position: relative;
display: grid;
/* sidebar (rail + resizable session column) | 0-width handle | conversation.
The 4px ResizeHandle overflows its zero-width track via negative margins so
the whole strip is grabbable without consuming layout space. */
/* The right-panel track is PERMANENT (auto = follows the aside's width, 0
when closed) opening animates the aside's width, so the conversation
column is squeezed over smoothly instead of snapping to a new template. */
grid-template-columns: var(--side-w) 0 minmax(0, 1fr) 0 auto;
/* sidebar | 0-width handle | conversation | 0-width handle | right panel.
The 4px ResizeHandles overflow their zero-width tracks via negative margins
so the whole strip is grabbable without consuming layout space. */
/* Both side tracks are PERMANENT (auto = follows the aside's width, 0 when
closed/collapsed) opening or collapsing animates the aside's width, so
the conversation column is squeezed over smoothly instead of snapping to a
new template. Every column is pinned explicitly (grid-column 15) so a
display:none handle can't shift auto-placement. */
grid-template-columns: auto 0 minmax(0, 1fr) 0 auto;
background: var(--bg);
color: var(--color-text);
overflow: hidden;
@ -1126,20 +1155,50 @@ function openPr(url: string): void {
min-width: 0;
}
/* Collapsed sidebar rail: keeps a slim, dedicated grid track so the expand
button never overlaps the conversation header or squeezes the main pane. */
.sidebar-rail {
grid-column: 1;
display: flex;
justify-content: center;
padding-top: 8px;
background: var(--panel);
border-right: 1px solid var(--line);
/* Pin every desktop grid child to its track so auto-placement can never
reshuffle columns when a handle is display:none (v-show/v-if). */
.app > .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;
}
</style>

View file

@ -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;
</script>

View file

@ -254,7 +254,7 @@ defineExpose({ closeMenu });
:label="t('sidebar.options')"
@click.stop="toggleMenu($event)"
>
<Icon name="dots-horizontal" size="sm" />
<Icon name="dots-horizontal" />
</IconButton>
</span>
</div>
@ -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.2516px) + 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 <body> 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); }

View file

@ -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<string, boolean>;
/** 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<boolean>(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(() => {
</script>
<template>
<aside class="side" :class="{ 'macos-desktop': isMacosDesktop }">
<aside
class="side"
:class="{ 'macos-desktop': isMacosDesktop, collapsed, 'no-anim': dragging }"
:style="{ width: collapsed ? '0px' : colWidth + 'px' }"
>
<!-- Session column -->
<div class="col" :style="{ width: colWidth + 'px' }">
<!-- Header: logo + settings (no hard border flows into workspace list) -->
<!-- Header: brand + collapse. The collapse button lives INSIDE the header
on non-mac platforms (right-aligned); on macOS desktop the brand is
hidden (traffic lights own that corner) and the header is just a
window-drag strip there the toggle is App.vue's resident floating
button beside the traffic lights. -->
<div class="ch">
<div class="ch-brand">
<svg ref="logoRef" class="ch-logo" :class="{ 'is-dev': isDev }" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @click="onLogoClick" @pointerdown="onLogoPointerDown" @pointerup="onLogoPointerUp" @pointercancel="onLogoPointerUp">
<defs>
<mask id="kimiEyes" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="32" height="22" fill="#fff" />
<g class="ch-eyes" fill="#000">
<rect class="ch-eye" x="11.8" y="7" width="2.8" height="8" rx="1.4" />
<rect class="ch-eye" x="17.4" y="7" width="2.8" height="8" rx="1.4" />
</g>
</mask>
</defs>
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" mask="url(#kimiEyes)" />
</svg>
<span class="ch-name">Kimi Code<span v-if="isDev" class="ch-endpoint"> · {{ endpoint }}</span></span>
<InternalBuildBanner />
<template v-if="!isMacosDesktop">
<svg ref="logoRef" class="ch-logo" :class="{ 'is-dev': isDev }" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @click="onLogoClick" @pointerdown="onLogoPointerDown" @pointerup="onLogoPointerUp" @pointercancel="onLogoPointerUp">
<defs>
<mask id="kimiEyes" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="32" height="22" fill="#fff" />
<g class="ch-eyes" fill="#000">
<rect class="ch-eye" x="11.8" y="7" width="2.8" height="8" rx="1.4" />
<rect class="ch-eye" x="17.4" y="7" width="2.8" height="8" rx="1.4" />
</g>
</mask>
</defs>
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" mask="url(#kimiEyes)" />
</svg>
<span class="ch-name">Kimi Code<span v-if="isDev" class="ch-endpoint"> · {{ endpoint }}</span></span>
</template>
</div>
<IconButton
v-if="!isMacosDesktop"
class="ch-collapse"
size="sm"
:label="t('sidebar.collapseSidebar')"
@click.stop="emit('collapse')"
>
<Icon name="panel-collapse" />
</IconButton>
<IconButton
size="sm"
:label="t('settings.title')"
@click.stop="emit('openSettings')"
>
<Icon name="settings" />
</IconButton>
</div>
<!-- Session search opens the Spotlight-style search dialog -->
<button class="search" type="button" @click="openSearch">
<Icon class="search-icon" name="search" />
<span class="search-input">
<span class="search-label">{{ t('sidebar.searchPlaceholder') }}</span>
<ShortcutKey>{{ sessionSearchShortcut }}</ShortcutKey>
</span>
</button>
<!-- New chat + new workspace buttons -->
<div class="btn-wrap" :class="{ 'btn-wrap--scrolled': sessionsScrolled }">
<div class="btn-wrap">
<button class="btn-new-chat" type="button" @click.stop="emit('create')">
<Icon name="chat-new" />
<span>{{ t('sidebar.newChat') }}</span>
@ -622,6 +605,16 @@ onBeforeUnmount(() => {
</IconButton>
</div>
<!-- Session search opens the Spotlight-style search dialog. Last fixed
row above the list, so it carries the scroll-linked seam. -->
<div class="search-wrap" :class="{ 'search-wrap--scrolled': sessionsScrolled }">
<button class="search" type="button" @click="openSearch">
<Icon class="search-icon" name="search" />
<span class="search-input">{{ t('sidebar.search') }}</span>
<Kbd :keys="sessionSearchKeys" />
</button>
</div>
<!-- Session list grouped by workspace -->
<div class="sessions" @scroll="onSessionsScroll">
<!-- Empty state only when no workspace is registered at all; empty
@ -679,7 +672,6 @@ onBeforeUnmount(() => {
:dragging="draggingWsId === g.workspace.id"
:is-collapsed="isCollapsed"
:is-expanded="isExpanded"
:show-path="showWorkspacePaths"
@group-click="handleGhClick"
@group-contextmenu="openGhMenu"
@toggle-ws-menu="toggleWsMenu"
@ -699,6 +691,14 @@ onBeforeUnmount(() => {
</div>
</template>
</div>
<!-- Footer: settings entry pinned under the session list -->
<div class="side-footer">
<button class="btn-settings" type="button" @click.stop="emit('openSettings')">
<Icon name="settings" />
<span>{{ t('settings.title') }}</span>
</button>
</div>
</div>
<!-- Workspace right-click menu (position:fixed) -->
@ -749,13 +749,6 @@ onBeforeUnmount(() => {
</span>
{{ t('sidebar.sortRecent') }}
</MenuItem>
<MenuItem separator />
<MenuItem @click="toggleShowWorkspacePathsFromMenu()">
<span class="section-menu-check">
<Icon v-if="showWorkspacePaths" name="check" size="sm" />
</span>
{{ t('sidebar.showWorkspacePaths') }}
</MenuItem>
</Menu>
<!-- Session search dialog (Cmd/Ctrl+K) -->
<SearchSessionsDialog
@ -776,24 +769,48 @@ onBeforeUnmount(() => {
<style scoped>
.side {
border-right: 1px solid var(--line);
background: var(--panel);
/* Sidebar sits on its own surface (--color-sidebar-bg, one step off --bg);
the 1px hairline on .col still separates it from the conversation pane. */
background: var(--color-sidebar-bg);
display: flex;
flex-direction: row;
/* Anchor content to the right edge: while the container width animates to 0
the fixed-width column slides out to the left and is clipped, instead of
reflowing. Mirrors the right-side preview panel (App.vue .global-preview). */
justify-content: flex-end;
overflow: hidden;
min-width: 0;
height: 100%;
/* Alignment contract, inherited by SessionRow and the de-terminalization
rules in style.css: text in the workspace header, the path line and session
rows all starts at --sb-pad-x + --sb-gutter + --sb-gap from the sidebar edge. */
--sb-pad-x: var(--space-4); /* row horizontal padding */
--sb-gutter: 20px; /* leading icon slot (14px folder icon + 6px margin) */
transition:
width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
visibility 0.28s;
/* Alignment contract, inherited by SessionRow and WorkspaceGroup:
- row boxes (hover/selected pills) sit --sb-inset from the sidebar edges;
- text/icons start at --sb-pad-x = --sb-inset + 8px row padding;
- row titles start at --sb-pad-x + --sb-gutter + --sb-gap. */
--sb-inset: var(--space-3); /* row box inset from the sidebar edge */
--sb-pad-x: var(--space-5); /* content start x (inset + row padding) */
--sb-gutter: 16px; /* leading icon slot (matches the 16px folder icon, so the session title aligns under the workspace name) */
--sb-gap: var(--space-2); /* gap between the icon slot and the text */
/* Sidebar stays one step above compact UI chrome, but still follows the
user-controlled font-size preference. */
--ui-font-size: var(--sidebar-ui-font-size);
/* Row hover wash global --color-hover (lighter than the selected fill;
both translucent, so they sit on any surface). */
--sb-hover: var(--color-hover);
}
/* While dragging the resize handle, follow the pointer 1:1 (same pattern as
.global-preview.no-anim in App.vue). */
.side.no-anim {
transition: none;
}
/* Fully collapsed: width 0 (animated), then drop out of hit-testing / tab
order once the transition ends (visibility interpolates to hidden at the
end when collapsing, and back to visible immediately when expanding). */
.side.collapsed {
visibility: hidden;
}
/* Session column. Width is set inline from the App resize handle. */
/* Session column. Width is set inline from the App resize handle; it stays
fixed while the collapsing container clips it. Carries the sidebar's right
hairline so the border is clipped away together with the content. */
.col {
flex: none;
min-width: 0;
@ -801,35 +818,38 @@ onBeforeUnmount(() => {
flex-direction: column;
min-height: 0;
width: 100%;
box-sizing: border-box;
border-right: 1px solid var(--line);
container-type: inline-size;
container-name: sidebar-col;
}
/* Header: logo + settings (no border — flows into the workspace list). */
/* Header: brand strip (no border flows into the workspace list). On non-mac
platforms the brand sits on the left and the collapse button on the right
(justify-content: space-between); on macOS desktop the brand is hidden and
the header is a window-drag strip (see below). min-height keeps the 26px
control row (50px total with padding) so the list below starts at a stable
y. */
.ch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: var(--space-3) var(--space-3) var(--space-2);
padding: var(--space-3);
min-height: calc(26px + 2 * var(--space-3));
width: 100%;
box-sizing: border-box;
}
/* macOS desktop: the window uses a hidden title bar, so the traffic lights float
over the top-left of the sidebar. Push the header content right to clear them,
and turn the whole header into the window-drag region matching the chat
header. The action buttons and the logo opt out with no-drag so they stay
clickable: this is the same no-drag-inside-drag pattern ChatHeader.vue relies
on (the previous "drag only the brand area" approach still captured the
sibling buttons, because Electron treats a flex-grown drag item's hit area as
covering the whole flex line). */
/* macOS desktop: the window uses a hidden title bar, so the traffic lights
float over the top-left of the sidebar and the resident toggle sits beside
them. The header renders no content here (brand hidden) it is purely a
window-drag strip. */
.side.macos-desktop .ch {
padding-left: 80px;
-webkit-app-region: drag;
}
.side.macos-desktop .ch button,
.side.macos-desktop .ch-logo {
-webkit-app-region: no-drag;
.side.macos-desktop .ch-brand {
display: none;
}
.ch-logo {
height: 22px;
@ -884,22 +904,14 @@ onBeforeUnmount(() => {
.ch-name { display: none; }
}
/* Action buttons */
.btn-wrap {
/* Action buttons first row of the actions group (New chat + search): rows
inside the group stack flush (0 gap, same rhythm as the session list rows);
the group's bottom gap lives on .search-wrap. */
.btn-wrap {
display: flex;
align-items: center;
gap: 8px;
padding: 0 var(--space-2) var(--space-2);
position: relative;
z-index: 1;
background: var(--panel);
border-bottom: 1px solid transparent;
transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out);
}
.btn-wrap--scrolled {
border-bottom-color: var(--line);
box-shadow: var(--shadow-sm);
padding: 0 var(--sb-inset);
}
.btn-new-chat {
display: flex;
@ -907,46 +919,61 @@ onBeforeUnmount(() => {
gap: 12px;
flex: 1;
min-width: 0;
min-height: 26px;
padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font-family: var(--font-ui);
font-size: var(--ui-font-size);
font-weight: var(--weight-medium);
font-size: var(--ui-font-size-sm);
line-height: var(--leading-tight);
cursor: pointer;
text-align: left;
}
.btn-new-chat:hover { background: var(--color-surface-sunken); }
.btn-new-chat:hover { background: var(--sb-hover); }
.btn-new-chat:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.btn-new-chat svg { flex: none; width: 16px; height: 16px; }
.btn-new-chat svg { flex: none; }
.btn-new-chat span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Session search */
/* Session search the wrapper is the last fixed row above the list and
carries the scroll-linked seam: its bottom border/shadow only appear once
the session list has actually scrolled, so an unscrolled list shows no
abrupt boundary. */
.search-wrap {
padding: 0 var(--sb-inset);
position: relative;
z-index: 1;
background: var(--color-sidebar-bg);
border-bottom: 1px solid transparent;
transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out);
}
.search-wrap--scrolled {
border-bottom-color: var(--line);
box-shadow: var(--shadow-sm);
}
.search {
display: flex;
align-items: center;
gap: 12px;
min-height: 26px;
margin: 0 var(--space-2) var(--space-2);
padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
width: 100%;
margin: 0;
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.search:hover { background: var(--color-surface-sunken); }
.search:hover { background: var(--sb-hover); }
.search:focus-visible {
background: var(--color-surface-sunken);
background: var(--sb-hover);
color: var(--color-text);
outline: 2px solid var(--color-accent-bd);
outline-offset: -2px;
@ -955,42 +982,71 @@ onBeforeUnmount(() => {
flex: none;
}
.search-input {
display: inline-flex;
align-items: center;
gap: var(--space-1);
flex: 1;
min-width: 0;
color: var(--color-text);
font-family: var(--font-ui);
font-size: var(--ui-font-size);
font-size: var(--ui-font-size-sm);
line-height: var(--leading-tight);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-input > span {
overflow: hidden;
text-overflow: ellipsis;
}
.search-label {
font-weight: var(--weight-medium);
}
/* Sessions */
/* Sessions owns the vertical padding around the list (the 12px gap to the
search row above and the bottom breathing room). Scrolled content passes
through the top padding and clips at the .search-wrap seam. Scrollbar: the
4px ::-webkit-scrollbar below; standard scrollbar-width would kill it on
Chromium (see the global scrollbar block in style.css). */
.sessions {
flex: 1;
overflow-y: auto;
padding: 0 var(--space-2) var(--space-2);
padding: var(--space-3) var(--sb-inset);
min-height: 0;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
}
.sessions::-webkit-scrollbar { width: 4px; }
.sessions::-webkit-scrollbar-track { background: transparent; }
.sessions::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: var(--radius-xs);
/* Neutral, text-derived translucency adapts to both schemes and sits
quietly on the sidebar surface (no accent tint on hover). */
background: color-mix(in srgb, var(--color-text) 12%, transparent);
border-radius: var(--radius-full);
}
.sessions::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--color-text) 25%, transparent); }
/* Footer settings entry pinned under the session list. Same list-style
control family as search / New chat (full-width, left-aligned, hover
sunken not a Button). */
.side-footer {
flex: none;
padding: var(--space-2) var(--sb-inset);
border-top: 1px solid var(--line);
}
.btn-settings {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-width: 0;
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font-family: var(--font-ui);
font-size: var(--ui-font-size-sm);
line-height: var(--leading-tight);
cursor: pointer;
text-align: left;
}
.btn-settings:hover { background: var(--sb-hover); }
.btn-settings:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.btn-settings svg { flex: none; }
.btn-settings span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sessions::-webkit-scrollbar-thumb:hover { background: var(--color-accent-bd); }
/* Section label heads the workspace list below the action buttons. Aligns
with the rows' leading inset (--sb-pad-x) so it reads as the list's title. */
@ -1000,9 +1056,9 @@ onBeforeUnmount(() => {
justify-content: space-between;
gap: 8px;
padding: 0 var(--space-3) var(--space-1) var(--space-2);
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: .08em;
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-regular);
text-transform: uppercase;
color: var(--faint);
user-select: none;

View file

@ -27,8 +27,6 @@ const props = defineProps<{
wsMenuOpenId: string | null;
/** True while this group is the active drag source (drag-to-reorder). */
dragging: boolean;
/** When true, render the workspace root path as a stable subtitle line. */
showPath: boolean;
isCollapsed: (id: string) => boolean;
/** When true, render all loaded sessions; otherwise only the first page
* (`group.initialCount`). Drives the in-group show-more / show-less toggle. */
@ -109,7 +107,7 @@ function onHeaderDragStart(event: DragEvent): void {
<div class="group" :class="{ dragging }">
<div
class="gh"
:class="{ on: group.workspace.id === activeWorkspaceId, collapsed: isCollapsed(group.workspace.id), 'show-path': showPath }"
:class="{ on: group.workspace.id === activeWorkspaceId, collapsed: isCollapsed(group.workspace.id) }"
draggable="true"
@click.stop="emit('groupClick', group.workspace.id, $event)"
@contextmenu="emit('groupContextmenu', group.workspace, $event)"
@ -118,14 +116,13 @@ function onHeaderDragStart(event: DragEvent): void {
>
<div class="gh-top">
<!-- Folder icon -->
<Icon v-if="isCollapsed(group.workspace.id)" class="gh-folder" name="folder-closed" size="sm" />
<Icon v-else class="gh-folder" name="folder" size="sm" />
<Icon v-if="isCollapsed(group.workspace.id)" class="gh-folder" name="folder-closed" />
<Icon v-else class="gh-folder" name="folder" />
<!-- Workspace name -->
<span
v-if="renamingId !== group.workspace.id"
class="gh-name"
>{{ group.workspace.name }}</span>
<!-- Workspace name hover reveals the full root path -->
<Tooltip v-if="renamingId !== group.workspace.id" :text="group.workspace.root">
<span class="gh-name">{{ group.workspace.name }}</span>
</Tooltip>
<input
v-else
:ref="setRenameInputRef"
@ -138,31 +135,36 @@ function onHeaderDragStart(event: DragEvent): void {
@click.stop
/>
<IconButton
class="gh-more"
<!-- Hover actions float over the row's right edge (no reserved
layout space, the name gets the full row width when idle). Hidden
while renaming so the floating buttons can't cover the input. -->
<div
v-if="renamingId !== group.workspace.id"
class="gh-actions"
:class="{ open: wsMenuOpenId === group.workspace.id }"
size="sm"
:label="t('sidebar.options')"
aria-haspopup="menu"
:aria-expanded="wsMenuOpenId === group.workspace.id"
@click.stop="emit('toggleWsMenu', group.workspace, $event)"
>
<Icon name="dots-horizontal" size="sm" />
</IconButton>
<IconButton
class="gh-more"
:class="{ open: wsMenuOpenId === group.workspace.id }"
size="sm"
:label="t('sidebar.options')"
aria-haspopup="menu"
:aria-expanded="wsMenuOpenId === group.workspace.id"
@click.stop="emit('toggleWsMenu', group.workspace, $event)"
>
<Icon name="dots-horizontal" />
</IconButton>
<IconButton
class="gh-add"
size="sm"
:label="t('workspace.newInGroup')"
@click.stop="emit('createInWorkspace', group.workspace.id)"
>
<Icon name="chat-new" />
</IconButton>
<IconButton
class="gh-add"
size="sm"
:label="t('workspace.newInGroup')"
@click.stop="emit('createInWorkspace', group.workspace.id)"
>
<Icon name="chat-new" />
</IconButton>
</div>
</div>
<Tooltip :text="group.workspace.root">
<div class="gh-path">{{ group.workspace.shortPath || group.workspace.root }}</div>
</Tooltip>
</div>
<div
class="group-sessions"
@ -212,8 +214,8 @@ function onHeaderDragStart(event: DragEvent): void {
<style scoped>
/* Workspace group. The --sb-* custom properties are inherited from .side in
Sidebar.vue, so they don't need to be redeclared here. */
.group { padding-bottom: var(--space-2); }
Sidebar.vue, so they don't need to be redeclared here. Groups stack flush
no bottom gap. */
.group.dragging { opacity: 0.45; }
/* Session list: collapses/expands via a height transition. `interpolate-size:
@ -230,15 +232,14 @@ function onHeaderDragStart(event: DragEvent): void {
}
/* Workspace header an inset rounded row that mirrors the session-row inset
(6px margin + 10px padding), so the folder icon lands at --sb-pad-x and the
name lines up with the session titles below. Hover washes the whole header
(name row + path) in the sunken surface. */
(container --sb-inset + row padding), so the folder icon lands at --sb-pad-x
and the name lines up with the session titles below. Hover washes the whole
header in the row hover fill. */
.gh {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: var(--space-1) var(--space-2);
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--text-xs);
@ -249,24 +250,28 @@ function onHeaderDragStart(event: DragEvent): void {
cursor: grab;
}
.gh:active { cursor: grabbing; }
.gh:hover { background: var(--color-surface-sunken); }
.gh:hover { background: var(--sb-hover, var(--color-surface-sunken)); }
.gh-top {
position: relative;
display: flex;
align-items: center;
gap: var(--sb-gap);
/* Header height is font-driven: name line-height (13×1.2516px) + 2×5px
.gh padding 26px. The floating .gh-actions never contribute to height. */
}
.gh-folder {
flex: none;
color: var(--color-text-muted);
/* 14px icon + margin fills the --sb-gutter icon slot */
margin-right: calc(var(--sb-gutter) - 14px);
}
/* Group title quiet by design: regular weight (no bold), muted color (one
step lighter than the session titles), so group heads read as grouping
labels rather than list content. */
.gh-name {
font-size: var(--ui-font-size-lg);
font-weight: 550;
color: var(--color-text);
font-size: var(--ui-font-size-sm);
line-height: var(--leading-tight);
color: var(--color-text-muted);
flex: 1;
min-width: 0;
overflow: hidden;
@ -274,51 +279,59 @@ function onHeaderDragStart(event: DragEvent): void {
white-space: nowrap;
cursor: pointer;
}
.gh-path {
color: var(--color-text-faint);
font-weight: 425;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: calc(var(--sb-gutter) + var(--sb-gap));
font-size: var(--ui-font-size-xs);
max-height: 0;
opacity: 0;
transition: max-height var(--duration-base) var(--ease-out),
opacity var(--duration-base) var(--ease-out);
}
/* Path subtitle revealed only when the section-header "show paths" toggle is
on (`.show-path`) or the group is collapsed. Driven by explicit toggle state
rather than hover/focus, so the header height never shifts under the pointer
(a11y: stable layout) and the path stays reachable for touch/keyboard users. */
.gh.show-path .gh-path,
.gh.collapsed .gh-path {
max-height: 1.4em;
opacity: 1;
}
/* More + add buttons — hidden until hover (or while the more menu is open /
focused). `.gh .gh-more` / `.gh .gh-add` out-specificity IconButton's display
so the hidden default wins. */
.gh .gh-more,
.gh .gh-add {
/* More + add buttons float over the row's right edge instead of reserving
layout space, so the name can use the full row width when idle (no
truncation caused by invisible buttons). Revealed on hover / keyboard focus
/ while the more menu is open; the backing stacks the row hover wash on the
sidebar surface so the overlapped title tail doesn't bleed through. */
.gh-actions {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: var(--space-1);
padding-left: var(--space-1);
border-radius: var(--radius-sm);
isolation: isolate;
/* Opaque sidebar surface hides the overlapped name tail. The ::after
hover wash sits above this (still behind the buttons) so the layer reads
seamless with the row. */
background: var(--color-sidebar-bg);
opacity: 0;
pointer-events: none;
}
.gh:hover .gh-more,
.gh:hover .gh-add,
.gh:focus-within .gh-more,
.gh:focus-within .gh-add,
.gh-more.open,
.gh-more:focus-visible,
.gh-add:focus-visible {
/* Row hover wash only while the row is actually hovered. Painted above the
element background (z-index 0) but below the buttons (z-index 1). */
.gh-actions::after {
content: '';
position: absolute;
inset: 0;
z-index: 0;
border-radius: var(--radius-sm);
background: transparent;
}
.gh:hover .gh-actions::after {
background: var(--sb-hover, var(--color-surface-sunken));
}
.gh-actions > * {
position: relative;
z-index: 1;
}
.gh:hover .gh-actions,
.gh:focus-within .gh-actions,
.gh-actions.open {
opacity: 1;
pointer-events: auto;
}
.gh-more.open { color: var(--color-text); background: var(--color-line); }
.group-empty {
padding: var(--space-1) var(--space-2) var(--space-1) calc(var(--sb-pad-x) - var(--space-2) + var(--sb-gutter) + var(--sb-gap));
/* Left padding lands the text at the same x as session titles / the
show-more label: (pad-x inset) row padding + gutter + gap. */
padding: var(--space-1) var(--space-2) var(--space-1) calc(var(--sb-pad-x) - var(--sb-inset) + var(--sb-gutter) + var(--sb-gap));
font-size: var(--text-xs);
color: var(--color-text-faint);
font-family: var(--font-ui);
@ -326,26 +339,26 @@ function onHeaderDragStart(event: DragEvent): void {
/* Show-more / show-less a session-row-shaped compact list control (§07). The
empty lead slot mirrors a session row's status gutter, so the label text lands
at the exact same x as the session titles (--sb-pad-x + --sb-gutter + --sb-gap
from the sidebar edge). Hover washes the row in the sunken surface, matching
New chat / session rows; no text recolor. */
from the sidebar edge). Hover washes the row in the shared row hover fill,
matching New chat / session rows; no text recolor. */
.show-more {
display: flex;
align-items: center;
gap: var(--sb-gap);
width: 100%;
min-height: 26px;
margin: 0;
padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: var(--text-xs);
line-height: var(--leading-tight);
text-align: left;
cursor: pointer;
}
.show-more:hover { background: var(--color-surface-sunken); }
.show-more:hover { background: var(--sb-hover, var(--color-surface-sunken)); }
.show-more:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.show-more-lead { width: var(--sb-gutter); flex: none; }
.show-more-label {

View file

@ -10,7 +10,7 @@ import Badge from '../ui/Badge.vue';
import Button from '../ui/Button.vue';
import IconButton from '../ui/IconButton.vue';
import Icon from '../ui/Icon.vue';
import ShortcutKey from '../ui/ShortcutKey.vue';
import Kbd from '../ui/Kbd.vue';
import Tooltip from '../ui/Tooltip.vue';
const props = defineProps<{
@ -315,20 +315,20 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
:loading="pendingAction === `option:${opt.label}`"
:disabled="busy"
@click="approveOption(opt.label)"
>{{ opt.label }}<ShortcutKey class="k">[{{ i + 1 }}]</ShortcutKey></Button>
>{{ opt.label }}<Kbd class="k" :keys="[String(i + 1)]" /></Button>
</Tooltip>
</template>
<Button v-else class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approvePlan'" :disabled="busy" @click="approvePlan">{{ t('approval.approvePlan') }}<ShortcutKey class="k">[1]</ShortcutKey></Button>
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="revisePlan">{{ t('approval.revise') }}<ShortcutKey v-if="planReview.options.length === 0" class="k">[2]</ShortcutKey></Button>
<Button class="kbtn" size="sm" variant="danger-soft" :loading="pendingAction === 'rejectAndExit'" :disabled="busy" @click="rejectAndExitPlan">{{ t('approval.rejectAndExit') }}<ShortcutKey v-if="planReview.options.length === 0" class="k">[3]</ShortcutKey></Button>
<Button v-else class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approvePlan'" :disabled="busy" @click="approvePlan">{{ t('approval.approvePlan') }}<Kbd class="k" :keys="['1']" /></Button>
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="revisePlan">{{ t('approval.revise') }}<Kbd v-if="planReview.options.length === 0" class="k" :keys="['2']" /></Button>
<Button class="kbtn" size="sm" variant="danger-soft" :loading="pendingAction === 'rejectAndExit'" :disabled="busy" @click="rejectAndExitPlan">{{ t('approval.rejectAndExit') }}<Kbd v-if="planReview.options.length === 0" class="k" :keys="['3']" /></Button>
</div>
<!-- default actions row -->
<div v-else class="abtn">
<Button class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approve'" :disabled="busy" @click="approve">{{ t('approval.approve') }}<ShortcutKey class="k">[1]</ShortcutKey></Button>
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'approveSession'" :disabled="busy" @click="approveSession">{{ t('approval.approveSession') }}<ShortcutKey class="k">[2]</ShortcutKey></Button>
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'reject'" :disabled="busy" @click="reject">{{ t('approval.reject') }}<ShortcutKey class="k">[3]</ShortcutKey></Button>
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="openFeedback">{{ t('approval.feedback') }}<ShortcutKey class="k">[4]</ShortcutKey></Button>
<Button class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approve'" :disabled="busy" @click="approve">{{ t('approval.approve') }}<Kbd class="k" :keys="['1']" /></Button>
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'approveSession'" :disabled="busy" @click="approveSession">{{ t('approval.approveSession') }}<Kbd class="k" :keys="['2']" /></Button>
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'reject'" :disabled="busy" @click="reject">{{ t('approval.reject') }}<Kbd class="k" :keys="['3']" /></Button>
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="openFeedback">{{ t('approval.feedback') }}<Kbd class="k" :keys="['4']" /></Button>
</div>
</template>
</Card>

View file

@ -49,7 +49,11 @@ defineExpose({ el });
transition: background var(--duration-base) var(--ease-out),
color var(--duration-base) var(--ease-out);
}
.ui-icon-button:hover:not(:disabled) { background: var(--color-surface-sunken); color: var(--color-text); }
/* Translucent text-mix instead of the sunken surface: stays visible on ANY
backdrop the sunken token equals the page bg in dark mode, which made
hover feedback vanish for icon buttons sitting directly on --color-bg
(chat header, flat sidebar). */
.ui-icon-button:hover:not(:disabled) { background: color-mix(in srgb, var(--color-text) 8%, transparent); color: var(--color-text); }
.ui-icon-button:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.ui-icon-button:disabled { opacity: 0.5; cursor: not-allowed; }

View file

@ -0,0 +1,40 @@
<!-- apps/kimi-web/src/components/ui/Kbd.vue -->
<!-- Design-system §03 Kbd: keyboard shortcut rendered as keycaps one <kbd>
block per key (e.g. ['⌘', 'K'] renders two caps). Keycap look: sunken
surface + 2px bottom border, 18px tall to match Badge sm. -->
<script setup lang="ts">
defineProps<{
keys: string[];
}>();
</script>
<template>
<span class="ui-kbd">
<kbd v-for="key in keys" :key="key" class="ui-kbd__key">{{ key }}</kbd>
</span>
</template>
<style scoped>
.ui-kbd {
display: inline-flex;
align-items: center;
gap: 3px;
flex: none;
}
.ui-kbd__key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border: 1px solid var(--color-line);
border-bottom-width: 2px;
border-radius: var(--radius-xs);
background: var(--color-surface-sunken);
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: 11px;
line-height: 1;
}
</style>

View file

@ -1,24 +0,0 @@
<!-- Token-styled keyboard shortcut. Keeps the semantic <kbd> element reusable
across compact UI surfaces. -->
<template>
<kbd class="shortcut-key">
<slot />
</kbd>
</template>
<style scoped>
.shortcut-key {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
padding: 0 var(--space-1);
border: 1px solid var(--color-line);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: var(--ui-font-size-xs);
line-height: var(--leading-tight);
}
</style>

View file

@ -11,7 +11,9 @@ const SIDEBAR_WIDTH_KEY = STORAGE_KEYS.sidebarWidth;
const SIDEBAR_COLLAPSED_KEY = STORAGE_KEYS.sidebarCollapsed;
const SIDEBAR_DEFAULT = 270;
const SIDEBAR_MIN = 170;
const SIDEBAR_COLLAPSED_WIDTH = 36;
// Hard cap on how wide the sidebar can be dragged, regardless of viewport.
// Below this, the conversation-reserve rule still wins (narrow windows).
const SIDEBAR_MAX = 480;
// 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
@ -28,19 +30,25 @@ export function useSidebarLayout(options: UseSidebarLayoutOptions = {}) {
const { viewportWidth } = useViewportWidth();
const sessionColWidth = ref(SIDEBAR_DEFAULT);
const sidebarCollapsed = ref(false);
// True while the sidebar ResizeHandle is being dragged — the sidebar disables
// its width transition so it follows the pointer 1:1 (mirrors panelDragging
// in useDetailPanel).
const sidebarDragging = 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.
// Largest sidebar width that still leaves the conversation pane usable, then
// clamped to SIDEBAR_MAX so it can never be dragged absurdly wide on large
// displays. 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);
return Math.min(SIDEBAR_MAX, panelMaxWidth(viewportWidth.value, SIDEBAR_MIN, reserve));
});
// Expanded width of the sidebar. Collapsing does NOT change this value: the
// sidebar keeps its content at this fixed width and animates its container
// width to 0 (clip, not reflow), mirroring the right-side preview panel.
const sideWidth = computed(() =>
sidebarCollapsed.value
? SIDEBAR_COLLAPSED_WIDTH
: clampPanelWidth(sessionColWidth.value, SIDEBAR_MIN, sidebarMax.value),
clampPanelWidth(sessionColWidth.value, SIDEBAR_MIN, sidebarMax.value),
);
function loadSidebarCollapsed(): void {
@ -71,6 +79,7 @@ export function useSidebarLayout(options: UseSidebarLayoutOptions = {}) {
sidebarMax,
sessionColWidth,
sidebarCollapsed,
sidebarDragging,
sideWidth,
loadSidebarCollapsed,
toggleSidebarCollapse,

View file

@ -7,7 +7,6 @@ export default {
sortRecent: 'Last edited',
collapseAll: 'Collapse all workspaces',
expandAll: 'Expand all workspaces',
showWorkspacePaths: 'Show workspace paths',
newSession: 'New Session',
newChat: 'New Chat',
newWorkspace: 'New Workspace',
@ -38,7 +37,7 @@ export default {
collapseSidebar: 'Collapse sidebar',
expandSidebar: 'Expand sidebar',
searchPlaceholder: 'Search sessions',
searchShortcut: 'Search sessions ({shortcut})',
search: 'Search',
searchHint: '↑↓ navigate · ↵ open · Esc close',
searchNoResults: 'No matching sessions',
} as const;

View file

@ -7,7 +7,6 @@ export default {
sortRecent: '按最后编辑时间',
collapseAll: '折叠全部工作区',
expandAll: '展开全部工作区',
showWorkspacePaths: '显示工作区路径',
newSession: '新建会话',
newChat: '新建对话',
newWorkspace: '新建工作区',
@ -38,7 +37,7 @@ export default {
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
searchPlaceholder: '搜索会话',
searchShortcut: '搜索会话 ({shortcut})',
search: '搜索',
searchHint: '↑↓ 选择 · ↵ 打开 · Esc 关闭',
searchNoResults: '没有匹配的会话',
};

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.1 12.9001V15.0909C11.1 15.593 11.5029 16 12 16C12.4971 16 12.9 15.593 12.9 15.0909V12.9001H15.0909C15.593 12.9001 16 12.4972 16 12.0001C16 11.5031 15.593 11.1001 15.0909 11.1001H12.9V8.90909C12.9 8.40701 12.4971 8 12 8C11.5029 8 11.1 8.40701 11.1 8.90909V11.1001H8.90909C8.40701 11.1001 8 11.5031 8 12.0001C8 12.4972 8.40701 12.9001 8.90909 12.9001H11.1Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9996 2.1001C6.53199 2.1001 2.09961 6.53248 2.09961 12.0001C2.09961 13.9226 2.64847 15.7192 3.59804 17.2391L2.517 19.8207C2.10313 20.8091 2.82908 21.9001 3.90059 21.9001H11.9996C17.4672 21.9001 21.8996 17.4677 21.8996 12.0001C21.8996 6.53248 17.4672 2.1001 11.9996 2.1001ZM3.89961 12.0001C3.89961 7.52659 7.5261 3.9001 11.9996 3.9001C16.4731 3.9001 20.0996 7.52659 20.0996 12.0001C20.0996 16.4736 16.4724 20.1001 11.9989 20.1001H4.35146L5.63494 17.0351L5.35165 16.6291C4.43632 15.3172 3.89961 13.7227 3.89961 12.0001Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4626_2033)">
<path d="M18.3623 9.99976L18.209 8.48999C18.2031 8.43161 18.2004 8.37289 18.2002 8.31421C18.1988 8.31196 18.1956 8.30842 18.1904 8.30347C18.1718 8.28559 18.1302 8.26245 18.0713 8.26245H11C10.261 8.26245 9.59753 7.81016 9.32617 7.1228L8.9082 6.06421C8.88101 5.9953 8.85737 5.92501 8.83887 5.85327C8.83778 5.85099 8.833 5.84268 8.81836 5.83179C8.79454 5.81475 8.7549 5.79939 8.70605 5.80054H3.92871C3.86986 5.80054 3.82825 5.82368 3.80957 5.84155C3.80816 5.8429 3.80675 5.84428 3.80566 5.84546L4.47559 14.0955L3.62109 17.5154L5.12109 11.5154C5.34367 10.6251 6.1438 9.99977 7.06152 9.99976H18.3623ZM7.06152 11.7996C6.96976 11.7996 6.88944 11.8629 6.86719 11.9519L5.36719 17.9519C5.33598 18.078 5.43158 18.1999 5.56152 18.2H19.4385C19.5302 18.1999 19.6106 18.1376 19.6328 18.0486L21.1328 12.0486C21.1644 11.9224 21.0686 11.7996 20.9385 11.7996H7.06152ZM20.9385 9.99976C22.2396 9.99977 23.1945 11.2228 22.8789 12.4851L21.3789 18.4851C21.1563 19.3754 20.3562 19.9997 19.4385 19.9998H4.92871C4.41722 19.9998 3.92613 19.8059 3.56445 19.4597C3.20281 19.1135 3.00004 18.6436 3 18.1541L2 5.84644C2.00006 5.35711 2.20311 4.88786 2.56445 4.54175C2.92613 4.19554 3.41722 4.00073 3.92871 4.00073H8.66406C9.10133 3.99051 9.5296 4.1225 9.87793 4.37573C10.2285 4.63118 10.4767 4.99457 10.582 5.40405L11 6.46167H18.0713C18.5828 6.46167 19.0739 6.65648 19.4355 7.00269C19.7971 7.34888 20 7.81883 20 8.30835L20.1719 9.99976H20.9385Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_4626_2033">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.2373 3.7002C10.4169 3.7002 11.5297 4.24779 12.249 5.18262L12.4424 5.43359H18C20.0987 5.43359 21.7998 7.13472 21.7998 9.2334V16.5C21.7998 18.5987 20.0987 20.2998 18 20.2998H6C3.90132 20.2998 2.2002 18.5987 2.2002 16.5V7.5C2.2002 5.40132 3.90132 3.7002 6 3.7002H9.2373ZM6 5.5C4.89543 5.5 4 6.39543 4 7.5V16.5C4 17.6046 4.89543 18.5 6 18.5H18C19.0357 18.5 19.887 17.7128 19.9893 16.7041L20 16.5V9.2334C20 8.19775 19.2128 7.34641 18.2041 7.24414L18 7.2334H12.0479L11.9326 7.22656C11.666 7.19561 11.4205 7.05812 11.2549 6.84277L10.8223 6.28027C10.4437 5.78834 9.85808 5.5 9.2373 5.5H6ZM16 9.59961C16.4971 9.59961 16.9004 10.0029 16.9004 10.5C16.9004 10.9971 16.4971 11.4004 16 11.4004H8C7.50294 11.4004 7.09961 10.9971 7.09961 10.5C7.09961 10.0029 7.50294 9.59961 8 9.59961H16Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 911 B

View file

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 12C6 12.8283 5.32834 13.5 4.5 13.5C3.67166 13.5 3 12.8283 3 12C3 11.1717 3.67166 10.5 4.5 10.5C5.32834 10.5 6 11.1717 6 12Z" fill="currentColor"/>
<path d="M13.5 12C13.5 12.8283 12.8283 13.5 12 13.5C11.1717 13.5 10.5 12.8283 10.5 12C10.5 11.1717 11.1717 10.5 12 10.5C12.8283 10.5 13.5 11.1717 13.5 12Z" fill="currentColor"/>
<path d="M19.5002 13.5C20.3287 13.5 21 12.8287 21 12.0002C21 11.1718 20.3287 10.5 19.5002 10.5C18.6718 10.5 18 11.1718 18 12.0002C18 12.8287 18.6718 13.5 19.5002 13.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 631 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 3C16.1944 3 20 6.80558 20 11.5C20 13.523 19.2933 15.381 18.1132 16.8404L21.1364 19.8636C21.4879 20.2151 21.4879 20.7849 21.1364 21.1364C20.7849 21.4879 20.2151 21.4879 19.8636 21.1364L16.8404 18.1132C15.381 19.2933 13.523 20 11.5 20C6.80558 20 3 16.1944 3 11.5C3 6.80558 6.80558 3 11.5 3ZM11.5 18.2C15.2003 18.2 18.2 15.2003 18.2 11.5C18.2 7.79969 15.2003 4.8 11.5 4.8C7.79969 4.8 4.8 7.79969 4.8 11.5C4.8 15.2003 7.79969 18.2 11.5 18.2Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 579 B

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.0404 12C16.0404 9.76874 14.2313 7.9596 12.0001 7.9596C9.76883 7.9596 7.95972 9.76874 7.95972 12C7.95972 14.2313 9.76883 16.0404 12.0001 16.0404C14.2313 16.0404 16.0404 14.2313 16.0404 12ZM14.2222 12C14.2222 13.2271 13.2271 14.2222 12 14.2222C10.7729 14.2222 9.77783 13.2271 9.77783 12C9.77783 10.7729 10.7729 9.77778 12 9.77778C13.2271 9.77778 14.2222 10.7729 14.2222 12Z" fill="currentColor"/>
<path d="M9.91145 21.8009C9.29001 21.6797 8.76914 21.2612 8.50632 20.6922L8.07372 19.7556C7.88838 19.3544 7.43553 19.1048 6.95371 19.1549L5.89572 19.2647C5.2733 19.3293 4.64823 19.114 4.22298 18.6611C3.74343 18.1504 3.32454 17.6037 2.97033 17.0181C2.61571 16.4318 2.32839 15.8106 2.10407 15.1566C1.89769 14.5549 2.02148 13.8954 2.4089 13.3902L3.0376 12.5704C3.30043 12.2277 3.30042 11.7722 3.03758 11.4295L2.40413 10.6035C2.01474 10.0958 1.891 9.43198 2.10208 8.82826C2.55037 7.54612 3.27017 6.35997 4.22 5.34259C4.64518 4.8872 5.27275 4.67067 5.89701 4.73544L6.95383 4.84514C7.43561 4.89515 7.88844 4.6456 8.07377 4.24441L8.50266 3.31593C8.76494 2.74818 9.28448 2.33019 9.90423 2.20761C11.2916 1.9332 12.7148 1.93127 14.0885 2.19913C14.7099 2.32029 15.2308 2.73881 15.4937 3.3078L15.9263 4.24441C16.1116 4.6456 16.5644 4.89514 17.0462 4.84514L18.1043 4.73532C18.7267 4.67072 19.3518 4.88603 19.777 5.33886C20.2566 5.84953 20.6755 6.3963 21.0297 6.98193C21.3843 7.56823 21.6716 8.18942 21.8959 8.84339C22.1023 9.44509 21.9785 10.1046 21.5911 10.6098L20.9624 11.4295C20.6996 11.7722 20.6996 12.2278 20.9624 12.5705L21.5959 13.3964C21.9853 13.9042 22.109 14.568 21.8979 15.1717C21.4497 16.4538 20.7299 17.6399 19.7801 18.6573C19.3549 19.1128 18.7273 19.3294 18.103 19.2646L17.0462 19.1549C16.5645 19.1049 16.1116 19.3544 15.9263 19.7556L15.4974 20.6841C15.2351 21.2518 14.7156 21.6698 14.0958 21.7924C12.7083 22.0668 11.2852 22.0687 9.91145 21.8009ZM13.7432 20.0088C13.7844 20.0006 13.8259 19.9673 13.847 19.9216L14.2758 18.9931C14.7915 17.8768 15.9886 17.2171 17.2341 17.3464L18.2909 17.4561C18.3649 17.4638 18.4272 17.4423 18.4512 17.4166C19.2296 16.5828 19.8171 15.6146 20.1817 14.5716C20.1845 14.5636 20.1796 14.5373 20.1532 14.5029L19.5198 13.677C18.7564 12.6815 18.7564 11.3185 19.5198 10.323L20.1485 9.5033C20.1746 9.46927 20.1795 9.4429 20.1762 9.43327C19.9932 8.89965 19.7603 8.39623 19.4741 7.92293C19.1873 7.4489 18.846 7.00333 18.4517 6.58351C18.4272 6.55739 18.3656 6.53616 18.2921 6.54378L17.234 6.65361C15.9886 6.78287 14.7915 6.12317 14.2758 5.00689L13.8432 4.07027C13.822 4.02448 13.7811 3.9916 13.7406 3.98371C12.5983 3.76097 11.4132 3.76258 10.2571 3.99124C10.2158 3.99941 10.1744 4.03271 10.1533 4.07842L9.72441 5.00689C9.20875 6.12317 8.01164 6.7829 6.76619 6.6536L5.70942 6.54391C5.63535 6.53623 5.573 6.55774 5.54905 6.5834C4.77067 7.41713 4.18312 8.38534 3.81845 9.42835C3.81564 9.43637 3.82054 9.46265 3.84693 9.49706L4.48038 10.323C5.24381 11.3185 5.24383 12.6815 4.48041 13.6769L3.85171 14.4967C3.82561 14.5307 3.82066 14.5571 3.82396 14.5667C4.00701 15.1004 4.23986 15.6038 4.52613 16.0771C4.81284 16.5511 5.15421 16.9967 5.54845 17.4165C5.57298 17.4426 5.63461 17.4638 5.70811 17.4562L6.76608 17.3464C8.01157 17.2171 9.20871 17.8768 9.72438 18.9932L10.157 19.9297C10.1781 19.9755 10.2191 20.0084 10.2595 20.0163C11.4018 20.239 12.587 20.2374 13.7432 20.0088Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1,13 +1,22 @@
// apps/kimi-web/src/lib/icons.ts
// Single source of truth for apps/kimi-web icons (design-system §02).
//
// Icons come from Remix Icon (https://remixicon.com/, Apache-2.0) and are
// bundled by unplugin-icons at build time — only the icons listed below end up
// in the production bundle. Each icon is imported twice: once as a Vue
// component (for <Icon name=... />) and once as a `?raw` SVG string (for
// iconSvg() in v-html contexts such as lib/toolMeta.ts).
// Icons come from three collections, all bundled by unplugin-icons at build
// time — only the icons listed below end up in the production bundle:
// - `~icons/kimi/*` — Kimi Design System icons (24×24 outlined,
// fill="currentColor"), local SVGs under src/icons/kimi/ registered as a
// custom collection in vite.config.ts. Preferred when a Kimi icon exists
// for the intent.
// - `~icons/tabler/*` — Tabler Icons (https://tabler.io/icons, MIT),
// 24×24 stroke-based (stroke="currentColor"); used for the sidebar
// panel toggle, which neither pack above covers well.
// - `~icons/ri/*` — Remix Icon (https://remixicon.com/, Apache-2.0) for
// the remaining intents.
// Each icon is imported twice: once as a Vue component (for <Icon name=... />)
// and once as a `?raw` SVG string (for iconSvg() in v-html contexts such as
// lib/toolMeta.ts).
//
// Remix icons are fill-based (fill="currentColor") on a 24x24 source grid; the
// All collections share the 24x24 source grid and follow currentColor; the
// rendered size comes from the size token prop. Colour follows text.
//
// Two consumers share this registry:
@ -16,7 +25,19 @@
import type { Component } from 'vue';
// Components (Vue) ---------------------------------------------------------
// Components (Kimi collection) ----------------------------------------------
import KimiAddConversation from '~icons/kimi/add-conversation';
import KimiFolder from '~icons/kimi/folder';
import KimiFolderOpen from '~icons/kimi/folder-open';
import KimiMore from '~icons/kimi/more';
import KimiSearch from '~icons/kimi/search';
import KimiSetting from '~icons/kimi/setting';
// Components (Tabler) ---------------------------------------------------------
import TablerSidebarLeftCollapse from '~icons/tabler/layout-sidebar-left-collapse';
import TablerSidebarLeftExpand from '~icons/tabler/layout-sidebar-left-expand';
// Components (Remix) ---------------------------------------------------------
import RiAddLine from '~icons/ri/add-line';
import RiAlertLine from '~icons/ri/alert-line';
import RiArrowDownLine from '~icons/ri/arrow-down-line';
@ -29,17 +50,14 @@ import RiBracesLine from '~icons/ri/braces-line';
import RiCalendarCloseLine from '~icons/ri/calendar-close-line';
import RiCalendarScheduleLine from '~icons/ri/calendar-schedule-line';
import RiCalendarTodoLine from '~icons/ri/calendar-todo-line';
import RiChatNewLine from '~icons/ri/chat-new-line';
import RiCheckLine from '~icons/ri/check-line';
import RiCloseLine from '~icons/ri/close-line';
import RiCodeLine from '~icons/ri/code-line';
import RiCollapseDiagonalLine from '~icons/ri/collapse-diagonal-line';
import RiContractLeftLine from '~icons/ri/contract-left-line';
import RiDownloadLine from '~icons/ri/download-line';
import RiDraggable from '~icons/ri/draggable';
import RiEqualizerLine from '~icons/ri/equalizer-line';
import RiExpandDiagonalLine from '~icons/ri/expand-diagonal-line';
import RiExpandRightLine from '~icons/ri/expand-right-line';
import RiExternalLinkLine from '~icons/ri/external-link-line';
import RiFileAddLine from '~icons/ri/file-add-line';
import RiFileCopyLine from '~icons/ri/file-copy-line';
@ -49,8 +67,6 @@ import RiFileTextLine from '~icons/ri/file-text-line';
import RiFlashlightLine from '~icons/ri/flashlight-line';
import RiFolderAddLine from '~icons/ri/folder-add-line';
import RiFolderFill from '~icons/ri/folder-fill';
import RiFolderLine from '~icons/ri/folder-line';
import RiFolderOpenLine from '~icons/ri/folder-open-line';
import RiGitPullRequestLine from '~icons/ri/git-pull-request-line';
import RiGlobalLine from '~icons/ri/global-line';
import RiImageLine from '~icons/ri/image-line';
@ -61,13 +77,10 @@ import RiListUnordered from '~icons/ri/list-unordered';
import RiLoginBoxLine from '~icons/ri/login-box-line';
import RiMailLine from '~icons/ri/mail-line';
import RiMessageLine from '~icons/ri/message-line';
import RiMoreLine from '~icons/ri/more-line';
import RiPauseFill from '~icons/ri/pause-fill';
import RiPencilLine from '~icons/ri/pencil-line';
import RiPlayFill from '~icons/ri/play-fill';
import RiQuestionLine from '~icons/ri/question-line';
import RiSearchLine from '~icons/ri/search-line';
import RiSettings3Line from '~icons/ri/settings-3-line';
import RiSortDesc from '~icons/ri/sort-desc';
import RiSparklingLine from '~icons/ri/sparkling-line';
import RiStarFill from '~icons/ri/star-fill';
@ -80,7 +93,19 @@ import RiTimeLine from '~icons/ri/time-line';
import RiToolsLine from '~icons/ri/tools-line';
import RiUserLine from '~icons/ri/user-line';
// Raw SVG strings ----------------------------------------------------------
// Raw SVG strings (Kimi collection) -----------------------------------------
import RawKimiAddConversation from '~icons/kimi/add-conversation?raw';
import RawKimiFolder from '~icons/kimi/folder?raw';
import RawKimiFolderOpen from '~icons/kimi/folder-open?raw';
import RawKimiMore from '~icons/kimi/more?raw';
import RawKimiSearch from '~icons/kimi/search?raw';
import RawKimiSetting from '~icons/kimi/setting?raw';
// Raw SVG strings (Tabler) ----------------------------------------------------
import RawTablerSidebarLeftCollapse from '~icons/tabler/layout-sidebar-left-collapse?raw';
import RawTablerSidebarLeftExpand from '~icons/tabler/layout-sidebar-left-expand?raw';
// Raw SVG strings (Remix) ----------------------------------------------------
import RawAddLine from '~icons/ri/add-line?raw';
import RawAlertLine from '~icons/ri/alert-line?raw';
import RawArrowDownLine from '~icons/ri/arrow-down-line?raw';
@ -93,17 +118,14 @@ import RawBracesLine from '~icons/ri/braces-line?raw';
import RawCalendarCloseLine from '~icons/ri/calendar-close-line?raw';
import RawCalendarScheduleLine from '~icons/ri/calendar-schedule-line?raw';
import RawCalendarTodoLine from '~icons/ri/calendar-todo-line?raw';
import RawChatNewLine from '~icons/ri/chat-new-line?raw';
import RawCheckLine from '~icons/ri/check-line?raw';
import RawCloseLine from '~icons/ri/close-line?raw';
import RawCodeLine from '~icons/ri/code-line?raw';
import RawCollapseDiagonalLine from '~icons/ri/collapse-diagonal-line?raw';
import RawContractLeftLine from '~icons/ri/contract-left-line?raw';
import RawDownloadLine from '~icons/ri/download-line?raw';
import RawDraggable from '~icons/ri/draggable?raw';
import RawEqualizerLine from '~icons/ri/equalizer-line?raw';
import RawExpandDiagonalLine from '~icons/ri/expand-diagonal-line?raw';
import RawExpandRightLine from '~icons/ri/expand-right-line?raw';
import RawExternalLinkLine from '~icons/ri/external-link-line?raw';
import RawFileAddLine from '~icons/ri/file-add-line?raw';
import RawFileCopyLine from '~icons/ri/file-copy-line?raw';
@ -113,8 +135,6 @@ import RawFileTextLine from '~icons/ri/file-text-line?raw';
import RawFlashlightLine from '~icons/ri/flashlight-line?raw';
import RawFolderAddLine from '~icons/ri/folder-add-line?raw';
import RawFolderFill from '~icons/ri/folder-fill?raw';
import RawFolderLine from '~icons/ri/folder-line?raw';
import RawFolderOpenLine from '~icons/ri/folder-open-line?raw';
import RawGitPullRequestLine from '~icons/ri/git-pull-request-line?raw';
import RawGlobalLine from '~icons/ri/global-line?raw';
import RawImageLine from '~icons/ri/image-line?raw';
@ -125,13 +145,10 @@ import RawListUnordered from '~icons/ri/list-unordered?raw';
import RawLoginBoxLine from '~icons/ri/login-box-line?raw';
import RawMailLine from '~icons/ri/mail-line?raw';
import RawMessageLine from '~icons/ri/message-line?raw';
import RawMoreLine from '~icons/ri/more-line?raw';
import RawPauseFill from '~icons/ri/pause-fill?raw';
import RawPencilLine from '~icons/ri/pencil-line?raw';
import RawPlayFill from '~icons/ri/play-fill?raw';
import RawQuestionLine from '~icons/ri/question-line?raw';
import RawSearchLine from '~icons/ri/search-line?raw';
import RawSettings3Line from '~icons/ri/settings-3-line?raw';
import RawSortDesc from '~icons/ri/sort-desc?raw';
import RawSparklingLine from '~icons/ri/sparkling-line?raw';
import RawStarFill from '~icons/ri/star-fill?raw';
@ -229,13 +246,13 @@ function entry(component: Component, svg: string): IconEntry {
export const ICONS: Record<IconName, IconEntry> = {
plus: entry(RiAddLine, RawAddLine),
'chat-new': entry(RiChatNewLine, RawChatNewLine),
'chat-new': entry(KimiAddConversation, RawKimiAddConversation),
'calendar-close': entry(RiCalendarCloseLine, RawCalendarCloseLine),
'calendar-schedule': entry(RiCalendarScheduleLine, RawCalendarScheduleLine),
'calendar-todo': entry(RiCalendarTodoLine, RawCalendarTodoLine),
close: entry(RiCloseLine, RawCloseLine),
check: entry(RiCheckLine, RawCheckLine),
search: entry(RiSearchLine, RawSearchLine),
search: entry(KimiSearch, RawKimiSearch),
copy: entry(RiFileCopyLine, RawFileCopyLine),
link: entry(RiLinksLine, RawLinksLine),
'external-link': entry(RiExternalLinkLine, RawExternalLinkLine),
@ -243,7 +260,7 @@ export const ICONS: Record<IconName, IconEntry> = {
undo: entry(RiArrowGoBackLine, RawArrowGoBackLine),
send: entry(RiArrowUpLine, RawArrowUpLine),
image: entry(RiImageLine, RawImageLine),
settings: entry(RiSettings3Line, RawSettings3Line),
settings: entry(KimiSetting, RawKimiSetting),
sliders: entry(RiEqualizerLine, RawEqualizerLine),
'log-in': entry(RiLoginBoxLine, RawLoginBoxLine),
'chevron-down': entry(RiArrowDownSLine, RawArrowDownSLine),
@ -252,15 +269,15 @@ export const ICONS: Record<IconName, IconEntry> = {
'arrow-down': entry(RiArrowDownLine, RawArrowDownLine),
'arrow-right': entry(RiArrowRightLine, RawArrowRightLine),
minus: entry(RiSubtractLine, RawSubtractLine),
'panel-collapse': entry(RiContractLeftLine, RawContractLeftLine),
'panel-expand': entry(RiExpandRightLine, RawExpandRightLine),
'panel-collapse': entry(TablerSidebarLeftCollapse, RawTablerSidebarLeftCollapse),
'panel-expand': entry(TablerSidebarLeftExpand, RawTablerSidebarLeftExpand),
expand: entry(RiExpandDiagonalLine, RawExpandDiagonalLine),
collapse: entry(RiCollapseDiagonalLine, RawCollapseDiagonalLine),
list: entry(RiListUnordered, RawListUnordered),
sort: entry(RiSortDesc, RawSortDesc),
grip: entry(RiDraggable, RawDraggable),
folder: entry(RiFolderOpenLine, RawFolderOpenLine),
'folder-closed': entry(RiFolderLine, RawFolderLine),
folder: entry(KimiFolderOpen, RawKimiFolderOpen),
'folder-closed': entry(KimiFolder, RawKimiFolder),
'folder-plus': entry(RiFolderAddLine, RawFolderAddLine),
'folder-solid': entry(RiFolderFill, RawFolderFill),
file: entry(RiFileLine, RawFileLine),
@ -292,7 +309,7 @@ export const ICONS: Record<IconName, IconEntry> = {
stop: entry(RiStopFill, RawStopFill),
star: entry(RiStarFill, RawStarFill),
'star-outline': entry(RiStarLine, RawStarLine),
'dots-horizontal': entry(RiMoreLine, RawMoreLine),
'dots-horizontal': entry(KimiMore, RawKimiMore),
};
export function getIcon(name: IconName): IconEntry {

View file

@ -22,7 +22,6 @@ export const STORAGE_KEYS = {
colorScheme: 'kimi-web.color-scheme',
hiddenWorkspaces: 'kimi-web.hidden-workspaces',
collapsedWorkspaces: 'kimi-web.collapsed-workspaces',
showWorkspacePaths: 'kimi-web.show-workspace-paths',
workspaceOrder: 'kimi-web.workspace-order',
workspaceNameOverrides: 'kimi-web.workspace-name-overrides',
workspaceSort: 'kimi-web.workspace-sort',
@ -149,20 +148,6 @@ export function saveCollapsedWorkspaces(ids: Iterable<string>): void {
safeSetJson(STORAGE_KEYS.collapsedWorkspaces, Array.from(ids));
}
/**
* Whether the sidebar shows each workspace's root path under its name. Off by
* default to keep the list compact; toggled from the Workspaces section header.
* Stored as a JSON boolean; any missing/unset value reads as false. UI-only
* state with no server-side source of truth.
*/
export function loadShowWorkspacePaths(): boolean {
return safeGetJson<boolean>(STORAGE_KEYS.showWorkspacePaths) === true;
}
export function saveShowWorkspacePaths(show: boolean): void {
safeSetJson(STORAGE_KEYS.showWorkspacePaths, show);
}
/**
* Display order of workspace ids in the sidebar. Persisted as a JSON array so
* the user can drag workspaces into a custom order that survives a page

View file

@ -204,7 +204,6 @@ summary {
--ui-font-size-lg: calc(var(--ui-font-size) + 1px);
--ui-font-size-xl: calc(var(--ui-font-size) + 2px);
--content-font-size: calc(var(--base-ui-font-size) + 1px);
--sidebar-ui-font-size: calc(var(--base-ui-font-size) + 1px);
--mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
/* Body/UI font follows the design-system canonical token. Mirrors --font-ui
so the legacy alias can never drift from the current text face. */
@ -387,6 +386,15 @@ html[data-color-scheme="dark"][data-accent="mono"] {
--color-text-on-accent: #ffffff;
--color-line: #e7eaee;
--color-line-strong: #d4d9e0;
/* Neutral selected fill (sidebar rows, list pickers) deliberately NOT
accent-tinted, so selection reads as "where I am", not as an action. */
--color-selected: #00000014;
/* Row hover wash lighter than the selected fill (hover < selected); both
are translucent black so they sit naturally on any light surface. */
--color-hover: #0000000d;
/* Sidebar surface one step off --color-bg (warm off-white / near-black)
so the session column reads as its own plane. */
--color-sidebar-bg: #fbfaf9;
--color-accent: var(--accent-primary);
--color-accent-hover: #0f6fe0;
--color-accent-soft: #e8f3ff;
@ -511,6 +519,9 @@ html[data-color-scheme="dark"] {
--color-text-faint: #6b7280;
--color-line: #2d333b;
--color-line-strong: #3d444d;
--color-selected: #ffffff14;
--color-hover: #ffffff0d;
--color-sidebar-bg: #181817;
--color-accent: #58a6ff;
--color-accent-hover: #79b8ff;
--color-accent-soft: rgba(88, 166, 255, 0.14);
@ -550,6 +561,9 @@ html[data-color-scheme="dark"] {
--color-text-faint: #6b7280;
--color-line: #2d333b;
--color-line-strong: #3d444d;
--color-selected: #ffffff14;
--color-hover: #ffffff0d;
--color-sidebar-bg: #181817;
--color-accent: #58a6ff;
--color-accent-hover: #79b8ff;
--color-accent-soft: rgba(88, 166, 255, 0.14);
@ -614,9 +628,16 @@ body {
A single mid-gray works on either background, so no per-theme tokens needed.
Components may still override locally (e.g. the sidebar's even-thinner rule).
--------------------------------------------------------------------------- */
* {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
/* Firefox-only fallback: the standard scrollbar properties DISABLE the whole
::-webkit-scrollbar customisation in Chromium 121+ (any non-auto value wins
over the pseudo-element styles), silently replacing the 6px/4px custom bars
with the ~11px native thin gutter. Scope them to engines that don't know
the webkit pseudo-element instead. */
@supports not selector(::-webkit-scrollbar) {
* {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
}
}
*::-webkit-scrollbar {
width: 6px;

View file

@ -176,6 +176,7 @@ onUnmounted(() => {
<div class="color-card"><div class="color-chip" style="background:#ffffff"></div><div class="color-meta"><div class="cn">bg</div><div class="cv">#ffffff / #0d1117</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#fafbfc"></div><div class="color-meta"><div class="cn">surface</div><div class="cv">#fafbfc / #161b22</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#f3f5f8"></div><div class="color-meta"><div class="cn">surface-sunken</div><div class="cv">#f3f5f8 / #0d1117</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#eceff3"></div><div class="color-meta"><div class="cn">selected</div><div class="cv">#eceff3 / #2d333b</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#14171c"></div><div class="color-meta"><div class="cn">fg</div><div class="cv">#14171c / #e8eaed</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#6b7280"></div><div class="color-meta"><div class="cn">fg-muted</div><div class="cv">#6b7280 / #9aa0a8</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#e7eaee"></div><div class="color-meta"><div class="cn">line</div><div class="cv">#e7eaee / #2d333b</div></div></div>
@ -191,6 +192,9 @@ onUnmounted(() => {
<tr><td class="tk">--color-text</td><td class="val"><span class="swatch" style="background:#14171c"></span>#14171c</td><td class="val"><span class="swatch" style="background:#e8eaed"></span>#e8eaed</td><td>Body text / headings</td></tr>
<tr><td class="tk">--color-text-muted</td><td class="val"><span class="swatch" style="background:#6b7280"></span>#6b7280</td><td class="val"><span class="swatch" style="background:#9aa0a8"></span>#9aa0a8</td><td>Secondary text / placeholder</td></tr>
<tr><td class="tk">--color-line</td><td class="val"><span class="swatch" style="background:#e7eaee"></span>#e7eaee</td><td class="val"><span class="swatch" style="background:#2d333b"></span>#2d333b</td><td>Divider / card border</td></tr>
<tr><td class="tk">--color-selected</td><td class="val"><span class="swatch" style="background:#00000014"></span>#00000014</td><td class="val"><span class="swatch" style="background:#ffffff14"></span>#ffffff14</td><td>Neutral selected fill (sidebar rows, list pickers) translucent, never accent-tinted</td></tr>
<tr><td class="tk">--color-hover</td><td class="val"><span class="swatch" style="background:#0000000d"></span>#0000000d</td><td class="val"><span class="swatch" style="background:#ffffff0d"></span>#ffffff0d</td><td>Row hover wash lighter than the selected fill (hover &lt; selected); translucent, sits on any surface</td></tr>
<tr><td class="tk">--color-sidebar-bg</td><td class="val"><span class="swatch" style="background:#fbfaf9"></span>#fbfaf9</td><td class="val"><span class="swatch" style="background:#181817"></span>#181817</td><td>Sidebar surface one step off <code>--color-bg</code> so the session column reads as its own plane</td></tr>
<tr><td class="tk">--color-accent</td><td class="val"><span class="swatch" style="background:#1783ff"></span>#1783ff</td><td class="val"><span class="swatch" style="background:#58a6ff"></span>#58a6ff</td><td>Primary action / link / focus</td></tr>
<tr><td class="tk">--color-success</td><td class="val"><span class="swatch" style="background:#0e7a38"></span>#0e7a38</td><td class="val"><span class="swatch" style="background:#3fb950"></span>#3fb950</td><td>Success / pass</td></tr>
<tr><td class="tk">--color-warning</td><td class="val"><span class="swatch" style="background:#a9610a"></span>#a9610a</td><td class="val"><span class="swatch" style="background:#d29922"></span>#d29922</td><td>Warning / pending</td></tr>
@ -269,7 +273,7 @@ onUnmounted(() => {
</ul>
<h3 class="sub">Type scale &amp; weight</h3>
<p>The user font-size preference writes <code>--base-ui-font-size</code>. Compact UI chrome follows it through <code>--ui-font-size</code>, while chat reading surfaces and the sidebar derive one readable step above it through <code>--content-font-size</code> and <code>--sidebar-ui-font-size</code>.</p>
<p>The user font-size preference writes <code>--base-ui-font-size</code>. Compact UI chrome and the sidebar follow it through <code>--ui-font-size</code>, while chat reading surfaces derive one readable step above it through <code>--content-font-size</code>.</p>
<p>The fixed product type tokens still define component defaults: <b>UI controls / buttons / forms</b> use <code>--text-base</code> (14px); <b>reading body including chat Markdown, message bubbles, etc.</b> stays one step larger than compact chrome for readability; the <b>sidebar session list</b> follows that same readable step while keeping list density.
Drop stray <code>font-weight: 650 / 750</code>; converge on two weights, 400 / 500 (regular / emphasis).</p>
<div class="panel panel-pad" style="margin:16px 0">
@ -287,7 +291,6 @@ onUnmounted(() => {
<tr><td class="tk">--font-mono</td><td class="val">JetBrains Mono</td><td>code, tool names, line numbers, diffs</td></tr>
<tr><td class="tk">--base-ui-font-size</td><td class="val">14px user preference</td><td>root setting that drives UI, reading body, and sidebar font sizes</td></tr>
<tr><td class="tk">--content-font-size</td><td class="val">calc(base + 1px)</td><td>chat Markdown, message bubbles, composer</td></tr>
<tr><td class="tk">--sidebar-ui-font-size</td><td class="val">calc(base + 1px)</td><td>sidebar brand, search, workspace and session rows</td></tr>
<tr><td class="tk">--leading-tight/normal/relaxed</td><td class="val">1.25 / 1.5 / 1.7</td><td>headings / UI / long text</td></tr>
<tr><td class="tk">--weight-regular/medium</td><td class="val">400 / 500</td><td>body / emphasis</td></tr>
</tbody>
@ -567,6 +570,18 @@ onUnmounted(() => {
</div>
</div>
<!-- ===== Kbd ===== -->
<h3 class="sub">Kbd · keyboard shortcut</h3>
<p><b>Kbd</b> renders a shortcut as keycaps one block per key, never inline text like <code>(K)</code>. Caps are 18px tall (Badge sm rhythm): sunken surface, 1px border with a 2px bottom edge, 11px UI font, muted text. Typical placement: pushed to the row's trailing edge, opposite the label (e.g. the sidebar search row).</p>
<div class="stage-wrap">
<div class="stage-bar"><span class="st">Kbd · keycaps</span></div>
<div class="stage p">
<span class="p-kbd"><kbd></kbd><kbd>K</kbd></span>
<span class="p-kbd"><kbd>Ctrl</kbd><kbd>K</kbd></span>
<span class="p-kbd"><kbd></kbd><kbd></kbd><kbd>P</kbd></span>
</div>
</div>
<!-- ===== Card / Surface ===== -->
<h3 class="sub">Card / Surface</h3>
<p>All cards across the site share <b>one shell</b>: flat, <code>1px</code> border, <code>--radius-md</code> radius, <b>no shadow</b>. The structure is split into three parts <code>head / body / foot</code>. Cards differ <b>only in the head</b> in two tiers by visual weight, while the shell stays consistent:</p>
@ -1338,13 +1353,13 @@ onUnmounted(() => {
</p>
<h3 class="sub">Layout grid</h3>
<p>On desktop it is a single-row 5-track grid: the sidebar and the right panel each occupy a permanent track, with the conversation column in the middle; two 0-width tracks are for the ResizeHandles.</p>
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">App.vue · .app</span></div><pre>grid-template-columns: var(--side-w) 0 minmax(0, 1fr) 0 auto;
/* sidebar ↑ ↑handle ↑conversation ↑handle ↑right panel (auto) */</pre></div>
<p>On desktop it is a single-row 5-track grid: the sidebar and the right panel each occupy a permanent <code>auto</code> track, with the conversation column in the middle; two 0-width tracks are for the ResizeHandles.</p>
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">App.vue · .app</span></div><pre>grid-template-columns: auto 0 minmax(0, 1fr) 0 auto;
/* sidebar ↑ ↑handle ↑conversation ↑handle ↑right panel (auto) */</pre></div>
<table class="dt">
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
<tbody>
<tr><td class="tk">--side-w</td><td class="val">248px (adjustable)</td><td>left conversation column width, changed by dragging the ResizeHandle; should approach §02's <code>--p-sidebar-w</code> (264px)</td></tr>
<tr><td class="tk">sidebar width</td><td class="val">270px default (adjustable)</td><td>expanded sidebar width, changed by dragging the ResizeHandle; should approach §02's <code>--p-sidebar-w</code> (264px)</td></tr>
<tr><td class="tk">--preview-w</td><td class="val">460px</td><td>width of the right preview panel when open</td></tr>
<tr><td class="tk">--panel-head-h</td><td class="val">48px</td><td>unified height for all right panel heads + the conversation column head, so the hairline runs as one line</td></tr>
<tr><td class="tk">--p-bp-sm</td><td class="val">640px</td><td>640 switches to a mobile single column (top bar + conversation), no sidebar / handle / right panel</td></tr>
@ -1352,17 +1367,18 @@ onUnmounted(() => {
</table>
<ul class="clean">
<li>The right panel track exists permanently, with its width transitioning between <code>0 var(--preview-w)</code> (when open it squeezes the conversation column, rather than switching templates).</li>
<li>When the sidebar is collapsed, track 1 becomes a thin "rail" holding only an expand IconButton, avoiding crushing the conversation column head.</li>
<li>The sidebar collapses SYMMETRICALLY to the right panel: its container width animates to 0 while the content keeps its fixed width anchored to the right edge (clipped, sliding out left no reflow, hairline stays on the clipped content). No rail remains. The collapse control differs by platform: on <b>macOS desktop</b> the toggle is a single resident floating IconButton pinned beside the traffic lights (rendered in both states, only the glyph swaps the sidebar slides underneath it, never moves or flashes); on <b>Windows / web</b> the collapse button lives inside the sidebar header (right-aligned), and a floating expand button appears at the top-left only while collapsed. The conversation header pads left in step with the transition while collapsed.</li>
<li>All grid children must have <code>min-height:0; min-width:0</code>, so only the inner scroll containers scroll and the page itself does not scroll.</li>
</ul>
<h3 class="sub">Sidebar alignment system (<code>--sb-*</code>)</h3>
<p>All sidebar rows (group head, session row, New chat button) share 3 custom properties, so the "session title" aligns precisely under the "workspace name".</p>
<p>All sidebar rows (group head, session row, New chat button) share 4 custom properties, so the "session title" aligns precisely under the "workspace name".</p>
<table class="dt">
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
<tbody>
<tr><td class="tk">--sb-pad-x</td><td class="val">16px</td><td>row horizontal padding</td></tr>
<tr><td class="tk">--sb-gutter</td><td class="val">20px</td><td>leading icon slot width (14px icon + 6px whitespace)</td></tr>
<tr><td class="tk">--sb-inset</td><td class="val">12px</td><td>row box (hover/selected pill) inset from the sidebar edges matches the brand header's 12px padding</td></tr>
<tr><td class="tk">--sb-pad-x</td><td class="val">20px</td><td>content start x (= --sb-inset + 8px row padding)</td></tr>
<tr><td class="tk">--sb-gutter</td><td class="val">16px</td><td>leading icon slot width matches the workspace folder icon so the session title aligns under the workspace name</td></tr>
<tr><td class="tk">--sb-gap</td><td class="val">6px</td><td>gap between the icon slot and the text</td></tr>
</tbody>
</table>
@ -1371,15 +1387,16 @@ onUnmounted(() => {
</div></div>
<h3 class="sub">Sidebar structure</h3>
<p>The sidebar from top to bottom: brand header search New chat grouped list (workspace head + session rows). Controls reuse the §03 primitives as much as possible.</p>
<p>The sidebar from top to bottom: brand header New chat search grouped list (workspace head + session rows) settings footer. Controls reuse the §03 primitives as much as possible. The sidebar sits on <code>--color-sidebar-bg</code> (one step off <code>--color-bg</code>: warm off-white in light, near-black in dark the session column reads as its own plane; the hairline still separates it from the conversation pane). Vertical rhythm: the brand header keeps 12px padding (on macOS desktop the left padding grows to 80px to clear the traffic lights); rows inside the actions group (New chat + search) stack flush (0 gap, same rhythm as the list rows); adjacent groups are separated by 12px. Row hover uses <code>--sb-hover</code> (= the global <code>--color-hover</code> wash); the selected row uses <code>--color-selected</code> neutral, never the accent.</p>
<table class="dt">
<thead><tr><th>Block</th><th>Use</th><th>Note</th></tr></thead>
<tbody>
<tr><td>Brand header</td><td>logo + name + IconButton</td><td>collapse / settings use IconButton sm; the logo is animated (a blinking eye)</td></tr>
<tr><td>Search</td><td>bare search row (custom)</td><td>no border, hover/focus shows a sunken background; icon + input + clear IconButton. <b>Do not</b> use Input (the 38px bordered version is too heavy)</td></tr>
<tr><td>New chat</td><td>full-width left-aligned button (custom)</td><td>same rhythm as the session rows in the list (left-aligned, hover sunken). <b>Do not</b> use Button (centered, breaks the rhythm)</td></tr>
<tr><td>Brand header</td><td>logo + name + collapse IconButton (right-aligned)</td><td>on Windows / web the brand is left and the collapse IconButton sm is right-aligned inside the header; the logo is animated (a blinking eye). On macOS desktop the header is a bare drag strip (brand hidden, traffic lights + resident floating toggle over it)</td></tr>
<tr><td>New chat</td><td>full-width left-aligned button (custom)</td><td>same rhythm as the session rows in the list (left-aligned, hover = <code>--sb-hover</code>). <b>Do not</b> use Button (centered, breaks the rhythm)</td></tr>
<tr><td>Search</td><td>bare search row (custom)</td><td>no border, hover/focus shows a sunken background; icon + label, with the <code>Kbd</code> keycaps (K / Ctrl K) pushed to the trailing edge label and shortcut are justified apart. <b>Do not</b> use Input (the 38px bordered version is too heavy). Last fixed row above the list its wrapper carries the scroll-linked seam</td></tr>
<tr><td>Section label</td><td><code>.p-section-label</code></td><td>uppercase muted small titles like "Workspaces"</td></tr>
<tr><td>Workspace head / session row</td><td>see next two sections</td><td>share <code>--sb-*</code> alignment</td></tr>
<tr><td>Settings footer</td><td>full-width left-aligned button (custom)</td><td>pinned row under the session list, separated by a 1px <code>--line</code> top border; icon + label, same list-style family as New chat</td></tr>
</tbody>
</table>
<div class="callout warn"><span class="ico">!</span><div>
@ -1391,7 +1408,7 @@ onUnmounted(() => {
<table class="dt">
<thead><tr><th>Part</th><th>Rule</th></tr></thead>
<tbody>
<tr><td>Container</td><td><code>margin: 1px 6px; padding: 7px 10px; radius-md</code>; hover = <code>surface-sunken</code>; active = <code>accent-soft</code> + <code>inset 0 0 0 1px accent-bd</code></td></tr>
<tr><td>Container</td><td><code>padding: 8px 8px</code> inside the list's <code>--sb-inset</code> gutter, <code>radius-sm</code>; <b>no fixed/min height</b> row height is font-driven (title <code>line-height: --leading-tight</code>, 16px) 32px total, the sidebar-wide row rhythm. The hover kebab is absolutely positioned so it never forces the row taller (no hover jitter). hover = <code>--sb-hover</code> (the global <code>--color-hover</code> wash); active = <code>--color-selected</code> neutral, no accent tint, no border, no weight change</td></tr>
<tr><td>Status slot (lead)</td><td>fixed <code>--sb-gutter</code> width; running = <code>Spinner</code> sm, otherwise unread = 7px accent dot</td></tr>
<tr><td>Title</td><td>flex:1 with truncation; double-click enters inline rename (compact input, not Input)</td></tr>
<tr><td>Time</td><td>mono xs, <code>fg-faint</code>; yields to the kebab on hover</td></tr>
@ -1402,11 +1419,11 @@ onUnmounted(() => {
</table>
<h3 class="sub">Workspace group</h3>
<p>The group head and session rows share <code>--sb-*</code>: folder icon (open/closed) name path subtitle, with the kebab and "+" revealed on hover.</p>
<p>The group head and session rows share <code>--sb-*</code>: folder icon (open/closed) name, with the kebab and "+" revealed on hover.</p>
<ul class="clean">
<li>The folder icon sits in the <code>--sb-gutter</code> slot, switching icons between open and closed states.</li>
<li>A small <code>fg-muted</code> path line sits below the name.</li>
<li>The kebab (menu) and "+" (new chat in this workspace) both use <code>IconButton</code> sm, shown on hover or keyboard focus (when not hovered they stay in the tab order via <code>opacity:0</code>, keeping them keyboard-reachable).</li>
<li>The folder icon leads the row (switching icons between open and closed states) with the plain <code>--sb-gap</code> before the name it does not pad out the <code>--sb-gutter</code> slot.</li>
<li>The name is quiet by design regular weight, muted color (<code>--color-text-muted</code>, one step lighter than session titles), so group heads read as grouping labels. No path subtitle; hovering the name shows the full root path in a <code>Tooltip</code>.</li>
<li>The kebab (menu) and "+" (new chat in this workspace) both use <code>IconButton</code> sm inside a floating actions layer anchored to the row's right edge — no reserved layout space, so the name uses the full row width when idle. Shown on hover, keyboard focus, or while the menu is open; the layer backs itself with the sidebar surface (container background) plus the row hover wash (an <code>::after</code> shown only while the row is hovered), so its color exactly equals the row's current background and the overlapped name tail doesn't bleed through (hidden via <code>opacity:0</code>, staying in the tab order).</li>
<li>The group is collapsible; when collapsed its session list is hidden.</li>
</ul>
@ -1415,7 +1432,7 @@ onUnmounted(() => {
<table class="dt">
<thead><tr><th>Part</th><th>Rule</th></tr></thead>
<tbody>
<tr><td class="tk">Container</td><td>session-row pill: <code>display:flex; gap:--sb-gap; min-height:26px</code>, same padding as a session row, <code>radius-md</code>; hover = <code>surface-sunken</code> (no text recolor); <code>:focus-visible</code> uses <code>--p-focus-ring</code></td></tr>
<tr><td class="tk">Container</td><td>session-row pill: <code>display:flex; gap:--sb-gap; padding:8px </code>, <b>no fixed/min height</b> (font-driven, 32px like a session row), same padding as a session row, <code>radius-sm</code>; hover = <code>--sb-hover</code> (no text recolor); <code>:focus-visible</code> uses <code>--p-focus-ring</code></td></tr>
<tr><td class="tk">Lead slot</td><td>empty, <code>--sb-gutter</code> wide, so the label's start x aligns with the session titles (<code>--sb-pad-x + --sb-gutter + --sb-gap</code>)</td></tr>
<tr><td class="tk">Label</td><td><code>font-ui</code>, <code>text-xs</code>, <code>--color-text</code>; flex:1, truncated</td></tr>
<tr><td class="tk">Behavior</td><td>"Load more" fetches the next page and auto-expands; once more than the first page is loaded, "Show less" appears and collapses back to the first page (view-layer trim data is kept, no refetch); "Show all" re-expands</td></tr>
@ -1444,7 +1461,7 @@ onUnmounted(() => {
</ul>
<div class="callout info"><span class="ico">i</span><div>
<b>One-sentence principle:</b> the sidebar / shell is a "list + grid" skeleton that reuses the §02 tokens and §03 primitives (Button / IconButton / Badge / Menu / Spinner / PanelHeader); compact list controls that don't fit a primitive (search, New chat, inline rename, show-more) keep their custom form, governed by this section.
<b>One-sentence principle:</b> the sidebar / shell is a "list + grid" skeleton that reuses the §02 tokens and §03 primitives (Button / IconButton / Badge / Kbd / Menu / Spinner / PanelHeader); compact list controls that don't fit a primitive (search, New chat, inline rename, show-more) keep their custom form, governed by this section.
</div></div>
</section>
@ -1602,7 +1619,7 @@ onUnmounted(() => {
.brand-name { font-weight: 700; font-size: 15px; letter-spacing: -.01em; }
.brand-sub { font-size: 12px; color: var(--d-fg-faint); margin-bottom: 26px; padding-left: 36px; }
.nav-group { margin: 22px 0 8px; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--d-fg-faint); }
.p-section-label { font-size: 13px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--d-fg-faint); }
.p-section-label { font-size: 12px; font-weight: 400; text-transform: uppercase; color: var(--d-fg-faint); }
.nav a {
display: flex; align-items: center; gap: 9px; padding: 7px 10px; border-radius: 7px;
font-size: 13.5px; font-weight: 500; color: var(--d-fg-soft); margin: 1px 0;
@ -1934,6 +1951,16 @@ onUnmounted(() => {
.p-badge.solid { background: var(--p-text); color: var(--p-bg); border-color: var(--p-text); }
.p-badge .p-ic { width: 12px; height: 12px; }
/* Kbd — shortcut keycaps (one <kbd> block per key) */
.p-kbd { display: inline-flex; align-items: center; gap: 3px; }
.p-kbd kbd {
display: inline-flex; align-items: center; justify-content: center;
min-width: 18px; height: 18px; padding: 0 5px;
border: 1px solid var(--p-line); border-bottom-width: 2px; border-radius: var(--p-r-xs);
background: var(--p-surface-sunken); color: var(--p-text-muted);
font-family: var(--p-font-sans); font-size: 11px; line-height: 1;
}
/* model / mode pill (composer toolbar) */
.p-pill {
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px;

View file

@ -1,7 +1,9 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Icons from 'unplugin-icons/vite';
import { FileSystemIconLoader } from 'unplugin-icons/loaders';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const webPort = Number(process.env.WEB_PORT) || 5175;
// Where the dev proxy forwards server traffic. Defaults to the local server
@ -12,7 +14,19 @@ const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url),
};
export default defineConfig({
plugins: [vue(), Icons({ compiler: 'vue3' })],
plugins: [
vue(),
Icons({
compiler: 'vue3',
// Local Kimi Design System icons (24×24 outlined, fill="currentColor"),
// copied from the design-system icon pack into src/icons/kimi/ and
// imported as `~icons/kimi/<file-name>` (plus `?raw`), same as the ri
// collection. Registered in src/lib/icons.ts only.
customCollections: {
kimi: FileSystemIconLoader(fileURLToPath(new URL('./src/icons/kimi', import.meta.url))),
},
}),
],
// Expose the dev proxy's upstream server target to the client so the UI can
// show which server it is connected to (the browser otherwise only sees its
// own same-origin URL). Unused by the same-origin production build.

View file

@ -152,7 +152,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-i3T30dxNQ7DTWOmUX47kmqpIVtDYby0buPo58+Jhhmw=";
hash = "sha256-iBk+TV+rIhmd7bYnVFbW3kTGltojJl3pL2hhmsGO+Fk=";
};
nativeBuildInputs = [

10
pnpm-lock.yaml generated
View file

@ -206,6 +206,9 @@ importers:
'@iconify-json/ri':
specifier: ^1.2.10
version: 1.2.10
'@iconify-json/tabler':
specifier: ^1.2.35
version: 1.2.35
'@vitejs/plugin-vue':
specifier: ^5.2.4
version: 5.2.4(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.35(typescript@6.0.2))
@ -1627,6 +1630,9 @@ packages:
'@iconify-json/simple-icons@1.2.84':
resolution: {integrity: sha512-v4JVu6xIewGoETD4mm2k6UAdFAbTlY1duw5ZNSxYORfs2yFsHDhoU9Omn/BgrV0nR/ptWkF3ZIr/ZHoYXI/6Jw==}
'@iconify-json/tabler@1.2.35':
resolution: {integrity: sha512-/sJMqHvh5ZWrEERVfDCT5NjVDeKJdhosFtKjJofAVl+P/3AzLiryOQw7WvrfDF25Xa5N/eoOQ15Y1jnhYXxBoQ==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@ -8563,6 +8569,10 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/tabler@1.2.35':
dependencies:
'@iconify/types': 2.0.0
'@iconify/types@2.0.0': {}
'@iconify/utils@3.1.3':