mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
* 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
390 lines
14 KiB
Vue
390 lines
14 KiB
Vue
<!-- 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>
|