Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-split-sidebar-workspace-group.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Extract the workspace group rendering out of the sidebar into its own component.
287 changes: 44 additions & 243 deletions apps/kimi-web/src/components/Sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Session, WorkspaceGroup, WorkspaceView } from '../types';
import type { Session, WorkspaceGroup as WorkspaceGroupType, WorkspaceView } from '../types';
import SessionRow from './SessionRow.vue';
import WorkspaceGroup from './WorkspaceGroup.vue';

const { t } = useI18n();

Expand All @@ -15,7 +16,7 @@ const props = withDefaults(
activeWorkspace: WorkspaceView | null;
activeWorkspaceId: string | null;
sessions: Session[];
groups: WorkspaceGroup[];
groups: WorkspaceGroupType[];
activeId: string;
attentionBySession?: Record<string, number>;
/** Per-session pending counts split by kind, for the coloured tags. */
Expand Down Expand Up @@ -165,6 +166,14 @@ const renamingId = ref<string | null>(null);
const renameValue = ref('');
const renameInputRef = ref<HTMLInputElement | null>(null);

// Hand the rename-input ref OBJECT (not its unwrapped value) down to
// WorkspaceGroup: top-level refs are auto-unwrapped in templates, so a getter
// keeps the ref intact. The child writes its input element back, and Sidebar
// keeps owning focus (startRenameWorkspace focuses it on nextTick).
function getRenameInputRef() {
return renameInputRef;
}

function startRenameWorkspace(id: string, name: string): void {
renamingId.value = id;
renameValue.value = name;
Expand All @@ -184,6 +193,10 @@ function cancelRenameWorkspace(): void {
renamingId.value = null;
}

function onUpdateRenameValue(value: string): void {
renameValue.value = value;
}

// ---------------------------------------------------------------------------
// Workspace right-click menu (copy path, rename)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -506,108 +519,35 @@ function blinkOnce(): void {
</div>

<template v-else>
<div v-for="g in groups" :key="g.workspace.id" class="group">
<div
class="gh"
:class="{ on: g.workspace.id === activeWorkspaceId, sel: selectedIds.has(g.workspace.id) }"
@click.stop="handleGhClick(g.workspace.id, $event)"
@contextmenu="openGhMenu(g.workspace, $event)"
>
<div class="gh-top">
<!-- Folder icon -->
<svg
class="gh-folder"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
stroke-width="1.2"
aria-hidden="true"
>
<template v-if="isCollapsed(g.workspace.id)">
<rect x="1" y="3.5" width="12" height="8.5" rx="1"/>
<path d="M1 5V3.5A1 1 0 0 1 2 2.5h3.5l1.3 2"/>
</template>
<template v-else>
<path d="M1 3.5V2.5A1 1 0 0 1 2 1.5h3.5l1.3 2h5.2a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/>
<path d="M1 5.5h12"/>
</template>
</svg>

<!-- Workspace name -->
<span
v-if="renamingId !== g.workspace.id"
class="gh-name"
>{{ g.workspace.name }}</span>
<input
v-else
ref="renameInputRef"
v-model="renameValue"
class="gh-rename"
type="text"
@keydown.enter="confirmRenameWorkspace"
@keydown.esc="cancelRenameWorkspace"
@blur="cancelRenameWorkspace"
@click.stop
/>

<button
type="button"
class="gh-more"
:class="{ open: wsMenuOpenId === g.workspace.id }"
:title="t('sidebar.options')"
:aria-label="t('sidebar.options')"
aria-haspopup="menu"
:aria-expanded="wsMenuOpenId === g.workspace.id"
@click.stop="toggleWsMenu(g.workspace, $event)"
>
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<circle cx="8" cy="3" r="1.3" />
<circle cx="8" cy="8" r="1.3" />
<circle cx="8" cy="13" r="1.3" />
</svg>
</button>

<button
type="button"
class="gh-add"
:title="t('workspace.newInGroup')"
:aria-label="t('workspace.newInGroup')"
@click.stop="emit('createInWorkspace', g.workspace.id)"
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 3v10M3 8h10"/>
</svg>
</button>
</div>

<div class="gh-path" :title="g.workspace.root">{{ g.workspace.shortPath || g.workspace.root }}</div>
</div>
<div v-show="!isCollapsed(g.workspace.id)" class="group-sessions">
<SessionRow
v-for="s in visibleSessions(g.sessions, isExpanded(g.workspace.id), activeId)"
: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="onSelectSession($event)"
@rename="(id, title) => emit('rename', id, title)"
@archive="emit('archive', $event)"
@fork="emit('fork', $event)"
/>
<button
v-if="!isExpanded(g.workspace.id) && visibleSessions(g.sessions, false, activeId).length < g.sessions.length"
class="show-more"
@click.stop="toggleExpand(g.workspace.id)"
>
{{ t('sidebar.showMore', { count: g.sessions.length - visibleSessions(g.sessions, false, activeId).length }) }}
</button>
<div v-if="g.sessions.length === 0" class="group-empty">{{ t('sidebar.noSessions') }}</div>
</div>
</div>
<WorkspaceGroup
v-for="g in groups"
:key="g.workspace.id"
:group="g"
:active-workspace-id="activeWorkspaceId"
:active-id="activeId"
:selected-ids="selectedIds"
:renaming-id="renamingId"
:rename-value="renameValue"
:rename-input-ref="getRenameInputRef()"
:pending-by-session="pendingBySession"
:unread-by-session="unreadBySession"
:ws-menu-open-id="wsMenuOpenId"
:is-collapsed="isCollapsed"
:is-expanded="isExpanded"
:visible-sessions="visibleSessions"
@group-click="handleGhClick"
@group-contextmenu="openGhMenu"
@toggle-ws-menu="toggleWsMenu"
@create-in-workspace="(id) => emit('createInWorkspace', id)"
@select-session="onSelectSession"
@rename-session="(id, title) => emit('rename', id, title)"
@archive-session="(id) => emit('archive', id)"
@fork-session="(id) => emit('fork', id)"
@toggle-expand="toggleExpand"
@confirm-rename="confirmRenameWorkspace"
@cancel-rename="cancelRenameWorkspace"
@update-rename-value="onUpdateRenameValue"
/>
</template>
</div>
</div>
Expand Down Expand Up @@ -883,105 +823,6 @@ function blinkOnce(): void {
line-height: 1.6;
}

/* Workspace group */
.group { padding-bottom: 6px; }
.gh {
display: flex;
flex-direction: column;
gap: 1px;
padding: 0 var(--sb-pad-x) 4px;
font-size: max(9px, calc(var(--ui-font-size) - 3.5px));
user-select: none;
position: relative;
}
.gh-top {
display: flex;
align-items: center;
gap: var(--sb-gap);
}
.gh.sel {
background: var(--soft);
border-radius: 4px;
}

.gh-folder {
flex: none;
color: var(--muted);
/* 14px icon + 2px margin fills the --sb-gutter icon slot */
margin-right: calc(var(--sb-gutter) - 14px);
}

.gh-name {
font-size: var(--ui-font-size);
font-weight: 500;
color: var(--ink);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.gh-path {
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: calc(var(--sb-gutter) + var(--sb-gap));
font-size: var(--ui-font-size-xs);
}
.gh-add {
background: transparent;
border: none;
color: var(--faint);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
/* Keep the icon small but give the button a ≥24px tap target. Extra padding
is vertical only so the right-rail alignment below is preserved. */
padding: 5px 6px;
border-radius: 4px;
flex: none;
/* Pull the glyph onto the right rail: its right edge lands at --sb-pad-x
from the sidebar edge, mirroring the folder icon's left gap and lining
up with the session timestamps below. */
margin-right: -6px;
}
.gh-add:hover { color: var(--dim); }
.gh-add:focus-visible {
outline: 2px solid var(--blue);
outline-offset: -2px;
}

/* More button — hidden until hover */
.gh-more {
display: none;
flex: none;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--muted);
border-radius: 4px;
}
.gh:hover .gh-more,
.gh-more.open {
display: inline-flex;
}
.gh-more:hover,
.gh-more.open { color: var(--ink); background: var(--line2); }
.gh-more:focus-visible {
outline: 2px solid var(--blue);
outline-offset: -2px;
/* Keyboard users can't hover, so the focused kebab must be visible. */
display: inline-flex;
}

/* Workspace kebab dropdown menu — fixed so the scroll container can't clip it;
anchored to the ⋯ trigger from toggleWsMenu(). */
.ws-menu {
Expand Down Expand Up @@ -1024,46 +865,6 @@ function blinkOnce(): void {
margin: 2px 0;
}

.group-empty {
padding: 8px 10px 8px calc(var(--sb-pad-x) + var(--sb-gutter) + var(--sb-gap));
font-size: calc(var(--ui-font-size) - 1.5px);
color: var(--faint);
font-family: var(--mono);
}
.show-more {
display: block;
width: 100%;
padding: 6px 10px 6px calc(var(--sb-pad-x) + var(--sb-gutter) + var(--sb-gap));
background: none;
border: none;
color: var(--dim);
font-size: calc(var(--ui-font-size) - 1.5px);
font-family: var(--mono);
cursor: pointer;
text-align: left;
}
.show-more:hover {
color: var(--blue2);
background: var(--soft);
}

/* Inline workspace rename input */
.gh-rename {
flex: 1;
min-width: 0;
font-family: var(--mono);
font-size: var(--ui-font-size-xs);
font-weight: 400;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--blue);
border-radius: 3px;
padding: 2px 5px;
outline: none;
}



/* ---------------------------------------------------------------------------
Workspace right-click menu (position:fixed)
--------------------------------------------------------------------------- */
Expand Down
Loading
Loading