kimi-code/apps/kimi-web/src/components/WorkspaceGroup.vue
liruifengv 170ae44205
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
2026-07-09 19:39:20 +08:00

390 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- apps/kimi-web/src/components/WorkspaceGroup.vue -->
<!-- One workspace group in the sidebar: the workspace header (folder icon,
name / inline rename, kebab, add button), the path line, and that group's
session rows (with show-more truncation + empty state). State, menus,
search and the header stay in Sidebar; this component renders a single
group and forwards every interaction back up. -->
<script setup lang="ts">
import { computed, type ComponentPublicInstance, type Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { WorkspaceGroup, WorkspaceView } from '../types';
import SessionRow from './SessionRow.vue';
import IconButton from './ui/IconButton.vue';
import Icon from './ui/Icon.vue';
import Tooltip from './ui/Tooltip.vue';
const { t } = useI18n();
const props = defineProps<{
group: WorkspaceGroup;
activeWorkspaceId: string | null;
activeId: string;
renamingId: string | null;
renameValue: string;
renameInputRef: Ref<HTMLInputElement | null>;
pendingBySession: Record<string, { approvals: number; questions: number }>;
unreadBySession: Record<string, boolean>;
wsMenuOpenId: string | null;
/** True while this group is the active drag source (drag-to-reorder). */
dragging: 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. */
isExpanded: (id: string) => boolean;
}>();
const emit = defineEmits<{
groupClick: [workspaceId: string, event: MouseEvent];
groupContextmenu: [workspace: WorkspaceView, event: MouseEvent];
toggleWsMenu: [workspace: WorkspaceView, event: MouseEvent];
createInWorkspace: [workspaceId: string];
selectSession: [sessionId: string];
renameSession: [id: string, title: string];
archiveSession: [id: string];
forkSession: [id: string];
loadMore: [workspaceId: string];
toggleExpand: [workspaceId: string];
confirmRename: [];
cancelRename: [];
updateRenameValue: [value: string];
wsDragstart: [workspaceId: string];
wsDragend: [];
}>();
// v-model bridge: Sidebar owns renameValue (confirmRenameWorkspace reads it),
// so the input mirrors the prop and pushes every edit back up — identical to
// the previous `v-model="renameValue"` against a local ref.
const renameValueModel = computed<string>({
get: () => props.renameValue,
set: (value: string) => emit('updateRenameValue', value),
});
// Sessions to render: all when expanded, otherwise only the first page. The
// collapse is a pure view-layer trim — data, cursor and hasMore stay intact, so
// re-expanding never refetches. When collapsed, the active session is always
// kept visible: an older session selected via Cmd/Ctrl-K search or a URL deep
// link would otherwise be hidden past the first page, so navigation would land
// on a missing row. It appends in newest-first order (older than the head).
const visibleSessions = computed(() => {
if (props.isExpanded(props.group.workspace.id)) return props.group.sessions;
const head = props.group.sessions.slice(0, props.group.initialCount);
if (props.activeId && !head.some((s) => s.id === props.activeId)) {
const active = props.group.sessions.find((s) => s.id === props.activeId);
if (active) return [...head, active];
}
return head;
});
// True once more than the first page is loaded — gates the show-less/show-all toggle.
const canToggleExpand = computed(
() => props.group.sessions.length > props.group.initialCount,
);
function showMoreCount(): number {
return Math.max(0, props.group.workspace.sessionCount - props.group.sessions.length);
}
function showAllCount(): number {
return props.group.sessions.length - props.group.initialCount;
}
// Hand the rename input element back to the parent's ref so Sidebar keeps
// owning focus (startRenameWorkspace focuses renameInputRef on nextTick). Only
// one group's input is mounted at a time, so sibling groups never collide.
function setRenameInputRef(el: Element | ComponentPublicInstance | null): void {
props.renameInputRef.value = el instanceof HTMLInputElement ? el : null;
}
// Drag-to-reorder: the group header is the drag handle. We stash the workspace
// id on the dataTransfer (so drop targets elsewhere could read it) and tell the
// sidebar which group is being dragged so it can compute the new order on drop.
function onHeaderDragStart(event: DragEvent): void {
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', props.group.workspace.id);
emit('wsDragstart', props.group.workspace.id);
}
</script>
<template>
<div class="group" :class="{ dragging }">
<div
class="gh"
: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)"
@dragstart="onHeaderDragStart"
@dragend="emit('wsDragend')"
>
<div class="gh-top">
<!-- Folder icon -->
<Icon v-if="isCollapsed(group.workspace.id)" class="gh-folder" name="folder-closed" />
<Icon v-else class="gh-folder" name="folder" />
<!-- 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"
v-model="renameValueModel"
class="gh-rename"
type="text"
@keydown.enter="emit('confirmRename')"
@keydown.esc="emit('cancelRename')"
@blur="emit('cancelRename')"
@click.stop
/>
<!-- 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 }"
>
<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>
</div>
</div>
</div>
<div
class="group-sessions"
:class="{ collapsed: isCollapsed(group.workspace.id) }"
:inert="isCollapsed(group.workspace.id)"
>
<SessionRow
v-for="s in visibleSessions"
:key="s.id"
:session="s"
:active="s.id === activeId"
:approval-count="pendingBySession[s.id]?.approvals ?? 0"
:question-count="pendingBySession[s.id]?.questions ?? 0"
:unread="unreadBySession[s.id] ?? false"
@select="emit('selectSession', $event)"
@rename="(id, title) => emit('renameSession', id, title)"
@archive="emit('archiveSession', $event)"
@fork="emit('forkSession', $event)"
/>
<button
v-if="group.hasMore || group.loadingMore"
class="show-more"
:disabled="group.loadingMore"
@click.stop="emit('loadMore', group.workspace.id)"
>
<span class="show-more-lead" aria-hidden="true"></span>
<span class="show-more-label">{{
group.loadingMore ? t('sidebar.loadingMore') : t('sidebar.showMore', { count: showMoreCount() })
}}</span>
</button>
<button
v-if="canToggleExpand"
class="show-more"
@click.stop="emit('toggleExpand', group.workspace.id)"
>
<span class="show-more-lead" aria-hidden="true"></span>
<span class="show-more-label">{{
isExpanded(group.workspace.id)
? t('sidebar.showLess')
: t('sidebar.showAll', { count: showAllCount() })
}}</span>
</button>
<div v-if="group.sessions.length === 0" class="group-empty">{{ t('sidebar.noSessions') }}</div>
</div>
</div>
</template>
<style scoped>
/* Workspace group. The --sb-* custom properties are inherited from .side in
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:
allow-keywords` (set on :root) lets `height: auto` interpolate instead of
snap. `inert` (set in the template when collapsed) keeps the hidden rows out
of the tab order / a11y tree, matching the old `v-show` behavior. */
.group-sessions {
height: auto;
overflow: hidden;
transition: height var(--duration-base) var(--ease-out);
}
.group-sessions.collapsed {
height: 0;
}
/* Workspace header — an inset rounded row that mirrors the session-row inset
(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;
margin: 0;
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);
color: var(--color-text);
user-select: none;
position: relative;
/* The header doubles as the drag handle for reordering. */
cursor: grab;
}
.gh:active { cursor: grabbing; }
.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.25≈16px) + 2×5px
.gh padding ≈ 26px. The floating .gh-actions never contribute to height. */
}
.gh-folder {
flex: none;
color: var(--color-text-muted);
}
/* 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-sm);
line-height: var(--leading-tight);
color: var(--color-text-muted);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
/* 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;
}
/* 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 {
/* 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);
}
/* 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 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%;
margin: 0;
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
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(--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 {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Inline workspace rename input */
.gh-rename {
flex: 1;
min-width: 0;
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--weight-regular);
color: var(--color-text);
background: var(--color-bg);
border: 1px solid var(--color-accent);
border-radius: var(--radius-xs);
padding: 2px 5px;
outline: none;
}
.gh-rename { border-radius: var(--radius-sm); font-family: var(--sans); }
.gh-add { color: var(--faint); }
.gh-add:hover { color: var(--dim); }
</style>