mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
* style(web): polish sidebar and tool typography - Use UI font and medium weight for sidebar and composer controls - Add reusable shortcut and tool output blocks - Cap long tool output at 50 lines with a scrollbar - Update sidebar show-more copy and muted styling * style(web): refine workspace picker sizing * style(web): align composer mode menus * style(web): tune list and question typography * style(web): reuse shortcut keys in approvals * style(web): size workspace picker from content * feat(web): localize chat status labels * style(web): refine composer toolbar controls * style(web): use complete Inter variable font * style(web): tune sidebar workspace typography * style(web): polish composer and workspace picker * style(web): refine markdown and thinking typography * chore: add web UI polish changeset * fix(web): pin Inter package for Nix build * style(web): polish goal tool calls * style: polish goal mode display * fix: layer latest message pill below menus * style: align goal strip content
428 lines
14 KiB
Vue
428 lines
14 KiB
Vue
<!-- apps/kimi-web/src/components/SessionRow.vue -->
|
|
<!-- A single session row: status dot + title + time + attention pill + kebab. -->
|
|
<!-- Inline rename (dblclick) and delete-confirm live here. -->
|
|
<script setup lang="ts">
|
|
import { computed, nextTick, onUnmounted, ref } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import type { Session } from '../types';
|
|
import { copyTextToClipboard } from '../lib/clipboard';
|
|
import Spinner from './ui/Spinner.vue';
|
|
import Badge from './ui/Badge.vue';
|
|
import { useConfirmDialog } from '../composables/useConfirmDialog';
|
|
import IconButton from './ui/IconButton.vue';
|
|
import Menu from './ui/Menu.vue';
|
|
import MenuItem from './ui/MenuItem.vue';
|
|
import Icon from './ui/Icon.vue';
|
|
import Tooltip from './ui/Tooltip.vue';
|
|
|
|
const { t } = useI18n();
|
|
const { confirm } = useConfirmDialog();
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
session: Session;
|
|
active: boolean;
|
|
/** Pending permission requests waiting for the user's approval. */
|
|
approvalCount?: number;
|
|
/** Pending askUserQuestion prompts waiting for the user's answer. */
|
|
questionCount?: number;
|
|
/** A background turn finished here that the user hasn't opened — blue dot. */
|
|
unread?: boolean;
|
|
}>(),
|
|
{ approvalCount: 0, questionCount: 0, unread: false },
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
select: [id: string];
|
|
rename: [id: string, title: string];
|
|
archive: [id: string];
|
|
fork: [id: string];
|
|
}>();
|
|
|
|
// Full, absolute timestamp shown on hover (the row's `time` is a short relative
|
|
// string like "2h"/"1d" — see formatTime in useKimiWebClient).
|
|
function formatFullTime(iso: string): string {
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return iso;
|
|
const pad = (n: number): string => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
const fullTime = computed(() =>
|
|
props.session.updatedAt ? formatFullTime(props.session.updatedAt) : props.session.time,
|
|
);
|
|
|
|
// Kebab menu
|
|
const menuOpen = ref(false);
|
|
const kebabRef = ref<InstanceType<typeof IconButton> | null>(null);
|
|
const menuRef = ref<InstanceType<typeof Menu> | null>(null);
|
|
// Fixed-position style for the teleported kebab menu, anchored to the ⋯ button.
|
|
const menuStyle = ref<Record<string, string>>({});
|
|
|
|
function onDocClick(e: MouseEvent): void {
|
|
const target = e.target as Node;
|
|
if (menuRef.value?.el?.contains(target) || kebabRef.value?.el?.contains(target)) return;
|
|
closeMenu();
|
|
}
|
|
|
|
// Anchor the menu to the ⋯ button with a viewport flip (open upward when there
|
|
// isn't room below), mirroring the workspace kebab menu in Sidebar.vue. The menu
|
|
// is rendered through a body teleport so ancestor `overflow: hidden` (notably the
|
|
// collapsing `.group-sessions` list) can't clip it.
|
|
function positionMenu(): void {
|
|
const btn = kebabRef.value?.el;
|
|
if (!btn) return;
|
|
const menu = menuRef.value?.el;
|
|
const r = btn.getBoundingClientRect();
|
|
const gap = 4;
|
|
const margin = 8;
|
|
const menuH = menu?.offsetHeight ?? 0;
|
|
const menuW = menu?.offsetWidth ?? 0;
|
|
let top = r.bottom + gap;
|
|
if (top + menuH > window.innerHeight - margin) {
|
|
top = Math.max(margin, r.top - menuH - gap);
|
|
}
|
|
let left = r.right - menuW;
|
|
if (left < margin) left = margin;
|
|
menuStyle.value = {
|
|
top: `${Math.round(top)}px`,
|
|
left: `${Math.round(left)}px`,
|
|
};
|
|
}
|
|
|
|
async function toggleMenu(e: Event): Promise<void> {
|
|
e.stopPropagation();
|
|
if (menuOpen.value) {
|
|
closeMenu();
|
|
return;
|
|
}
|
|
menuOpen.value = true;
|
|
// Defer so the current click doesn't immediately close the menu.
|
|
setTimeout(() => document.addEventListener('mousedown', onDocClick), 0);
|
|
window.addEventListener('resize', closeMenu);
|
|
// Wait for the teleported menu to mount so its size can be measured.
|
|
await nextTick();
|
|
positionMenu();
|
|
}
|
|
function closeMenu(): void {
|
|
menuOpen.value = false;
|
|
document.removeEventListener('mousedown', onDocClick);
|
|
window.removeEventListener('resize', closeMenu);
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('mousedown', onDocClick);
|
|
window.removeEventListener('resize', closeMenu);
|
|
});
|
|
|
|
// Inline rename
|
|
const renaming = ref(false);
|
|
const renameValue = ref('');
|
|
const renameInputRef = ref<HTMLInputElement | null>(null);
|
|
async function startRename(): Promise<void> {
|
|
closeMenu();
|
|
renaming.value = true;
|
|
renameValue.value = props.session.title;
|
|
await nextTick();
|
|
try {
|
|
renameInputRef.value?.focus();
|
|
renameInputRef.value?.select();
|
|
} catch {
|
|
// jsdom may not implement focus/select
|
|
}
|
|
}
|
|
function commitRename(): void {
|
|
const newTitle = renameValue.value.trim();
|
|
if (newTitle) emit('rename', props.session.id, newTitle);
|
|
renaming.value = false;
|
|
}
|
|
function cancelRename(): void {
|
|
renaming.value = false;
|
|
}
|
|
|
|
// Copy session ID
|
|
const copiedId = ref(false);
|
|
const copyFailed = ref(false);
|
|
async function copySessionId(): Promise<void> {
|
|
const ok = await copyTextToClipboard(props.session.id);
|
|
copiedId.value = ok;
|
|
copyFailed.value = !ok;
|
|
// Keep the menu open briefly so the result text is visible, then close.
|
|
setTimeout(() => {
|
|
copiedId.value = false;
|
|
copyFailed.value = false;
|
|
closeMenu();
|
|
}, 1500);
|
|
}
|
|
|
|
// Fork this session into a new child session
|
|
function forkRow(): void {
|
|
closeMenu();
|
|
emit('fork', props.session.id);
|
|
}
|
|
|
|
// Archive confirm — modal, consistent with remove-workspace.
|
|
async function startArchive(): Promise<void> {
|
|
closeMenu();
|
|
if (
|
|
await confirm({
|
|
title: t('sidebar.archive'),
|
|
message: t('sidebar.archiveConfirm'),
|
|
variant: 'danger',
|
|
})
|
|
) {
|
|
emit('archive', props.session.id);
|
|
}
|
|
}
|
|
|
|
// Expose closeMenu so the parent can close on outside-click.
|
|
defineExpose({ closeMenu });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="se" :class="{ on: active }" @click="emit('select', session.id)">
|
|
<div class="row">
|
|
<!-- Leading status slot (in the gutter left of the title): a spinner
|
|
while the session runs, otherwise an unread blue dot. Fixed width
|
|
so the title start never shifts. -->
|
|
<span class="lead" aria-hidden="true">
|
|
<Spinner v-if="session.busy" size="sm" />
|
|
<span v-else-if="unread" class="unread-dot" />
|
|
</span>
|
|
|
|
<div class="left">
|
|
<!-- Inline rename input -->
|
|
<input
|
|
v-if="renaming"
|
|
ref="renameInputRef"
|
|
v-model="renameValue"
|
|
class="rename-input"
|
|
@click.stop
|
|
@keydown.enter.stop="commitRename"
|
|
@keydown.esc.stop="cancelRename"
|
|
@blur="commitRename"
|
|
/>
|
|
<span v-else class="t" @dblclick.stop="startRename">{{ session.title }}</span>
|
|
</div>
|
|
|
|
<!-- Pending tags — coloured per kind, shown even when the row isn't
|
|
active. "Answer" = an askUserQuestion is waiting; "Approve" = a
|
|
permission request is waiting. The session's lifecycle status drives
|
|
the same tags as a fallback for background sessions whose pending
|
|
lists aren't loaded yet (status known, counts not). -->
|
|
<Tooltip :text="t('workspace.awaitingAnswerTitle')">
|
|
<Badge
|
|
v-if="!renaming && (questionCount > 0 || session.status === 'awaitingQuestion')"
|
|
variant="info"
|
|
size="sm"
|
|
>
|
|
{{ t('workspace.awaitingAnswer') }}
|
|
</Badge>
|
|
</Tooltip>
|
|
<Tooltip :text="t('workspace.awaitingPermissionTitle')">
|
|
<Badge
|
|
v-if="!renaming && (approvalCount > 0 || session.status === 'awaitingApproval')"
|
|
variant="warning"
|
|
size="sm"
|
|
>
|
|
{{ t('workspace.awaitingPermission') }}
|
|
</Badge>
|
|
</Tooltip>
|
|
<!-- Aborted: a distinct, low-key error tag (not collapsed into idle). -->
|
|
<Tooltip :text="t('workspace.abortedTitle')">
|
|
<Badge
|
|
v-if="!renaming && session.status === 'aborted'"
|
|
variant="danger"
|
|
size="sm"
|
|
>
|
|
{{ t('workspace.aborted') }}
|
|
</Badge>
|
|
</Tooltip>
|
|
|
|
<!-- Trailing action slot: the relative time and the kebab share one grid
|
|
cell and swap via `visibility` (never display:none), so the slot
|
|
width is identical in hover and rest. The badges and title therefore
|
|
don't reflow on hover — see design-system §07 "Session row". -->
|
|
<span class="act">
|
|
<span class="ts">{{ session.time }}</span>
|
|
<IconButton
|
|
ref="kebabRef"
|
|
v-if="!renaming"
|
|
class="kebab"
|
|
:class="{ open: menuOpen }"
|
|
size="sm"
|
|
:label="t('sidebar.options')"
|
|
@click.stop="toggleMenu($event)"
|
|
>
|
|
<Icon name="dots-horizontal" size="sm" />
|
|
</IconButton>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Kebab dropdown — teleported to <body> and position:fixed so it escapes
|
|
the `overflow: hidden` on the collapsing `.group-sessions` list. -->
|
|
<Teleport to="body">
|
|
<Menu ref="menuRef" v-if="menuOpen" class="menu" :style="menuStyle" @click.stop>
|
|
<MenuItem :danger="copyFailed" @click="copySessionId">
|
|
{{
|
|
copyFailed
|
|
? t('sidebar.copyFailed')
|
|
: copiedId
|
|
? t('sidebar.copied')
|
|
: t('sidebar.copySessionId')
|
|
}}
|
|
</MenuItem>
|
|
<MenuItem separator />
|
|
<MenuItem @click="startRename">{{ t('sidebar.rename') }}</MenuItem>
|
|
<MenuItem @click="forkRow">{{ t('sidebar.fork') }}</MenuItem>
|
|
<MenuItem danger @click="startArchive">{{ t('sidebar.archive') }}</MenuItem>
|
|
<MenuItem separator />
|
|
<div class="menu-time">{{ fullTime }}</div>
|
|
</Menu>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.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. */
|
|
display: block;
|
|
margin: 0;
|
|
padding: var(--space-1) var(--space-2);
|
|
border-radius: var(--radius-md);
|
|
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.on {
|
|
background: var(--color-accent-soft);
|
|
color: var(--color-accent-hover);
|
|
box-shadow: inset 0 0 0 1px var(--color-accent-bd);
|
|
}
|
|
|
|
.row {
|
|
display: flex;
|
|
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;
|
|
}
|
|
|
|
.left {
|
|
display: flex;
|
|
align-items: center;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* Leading status slot — mirrors the workspace header's icon slot (so the title
|
|
aligns under the workspace name) AND carries the running spinner / unread dot.
|
|
Fixed width keeps the title start fixed whether or not an indicator shows. */
|
|
.lead {
|
|
width: var(--sb-gutter, 16px);
|
|
flex: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.unread-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: var(--radius-full);
|
|
background: var(--color-accent);
|
|
}
|
|
|
|
.t {
|
|
color: inherit;
|
|
font-size: var(--text-base);
|
|
font-weight: 450;
|
|
line-height: var(--leading-tight);
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.ts {
|
|
color: var(--color-text-faint);
|
|
font-size: var(--text-xs);
|
|
font-family: var(--font-ui);
|
|
font-weight: 475;
|
|
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. */
|
|
.act {
|
|
display: inline-grid;
|
|
flex: none;
|
|
align-items: center;
|
|
justify-items: end;
|
|
}
|
|
.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); }
|
|
|
|
/* 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. */
|
|
.menu {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
z-index: var(--z-dropdown);
|
|
}
|
|
.menu-time {
|
|
padding: 6px 10px;
|
|
color: var(--color-text-faint);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
cursor: default;
|
|
user-select: text;
|
|
}
|
|
|
|
.rename-input {
|
|
flex: 1;
|
|
font-family: var(--font-ui);
|
|
font-size: var(--text-sm);
|
|
color: var(--color-text);
|
|
background: var(--color-bg);
|
|
border: 1px solid var(--color-accent);
|
|
border-radius: var(--radius-xs);
|
|
padding: 1px 4px;
|
|
outline: none;
|
|
min-width: 0;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
.sessions .se .rename-input { border-radius: var(--radius-sm); font-family: var(--sans); }
|
|
.sessions .se .kebab { border-radius: var(--radius-sm); }
|
|
</style>
|