mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
493 lines
14 KiB
Vue
493 lines
14 KiB
Vue
<!-- apps/kimi-web/src/components/ChatHeader.vue -->
|
|
<!-- Thin context bar above the chat: workspace / session name, git branch +
|
|
status, "open in editor", and a ⋮ more-menu that bundles copy-all plus
|
|
the same session actions available from the sidebar session row. -->
|
|
<script setup lang="ts">
|
|
import { computed, nextTick, onUnmounted, ref } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
|
|
const { t } = useI18n();
|
|
|
|
const props = defineProps<{
|
|
sessionId?: string;
|
|
workspaceName?: string;
|
|
/** Absolute path to the active workspace root. */
|
|
workspaceRoot?: string;
|
|
sessionTitle?: string;
|
|
branch?: string;
|
|
ahead?: number;
|
|
behind?: number;
|
|
changesCount?: number;
|
|
/** Git diff line stats: additions / deletions. Zero/null values are hidden. */
|
|
gitDiffStats?: { totalAdditions: number; totalDeletions: number } | null;
|
|
isGitRepo?: boolean;
|
|
/** GitHub PR for the current branch, when known (null/undefined = none). */
|
|
pr?: { number: number; state: string; url: string } | null;
|
|
/** True for ~2s after a successful copy-all, to flip the icon to a check. */
|
|
copied?: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
copyAll: [];
|
|
copyFinalSummary: [];
|
|
openChanges: [];
|
|
openPr: [url: string];
|
|
renameSession: [id: string, title: string];
|
|
forkSession: [id: string];
|
|
archiveSession: [id: string];
|
|
}>();
|
|
|
|
const ahead = computed(() => props.ahead ?? 0);
|
|
const behind = computed(() => props.behind ?? 0);
|
|
const adds = computed(() => props.gitDiffStats?.totalAdditions ?? 0);
|
|
const dels = computed(() => props.gitDiffStats?.totalDeletions ?? 0);
|
|
const hasLineStats = computed(() => adds.value > 0 || dels.value > 0);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// More-menu (kebab dropdown)
|
|
// ---------------------------------------------------------------------------
|
|
const menuOpen = ref(false);
|
|
const kebabRef = ref<HTMLButtonElement | null>(null);
|
|
const menuRef = ref<HTMLElement | null>(null);
|
|
const menuStyle = ref<Record<string, string>>({});
|
|
|
|
function onDocClick(e: MouseEvent): void {
|
|
const target = e.target as Node;
|
|
if (menuRef.value?.contains(target) || kebabRef.value?.contains(target)) return;
|
|
closeMenu();
|
|
}
|
|
|
|
function onScrollOrResize(): void {
|
|
closeMenu();
|
|
}
|
|
|
|
async function toggleMenu(e: Event): Promise<void> {
|
|
e.stopPropagation();
|
|
if (menuOpen.value) {
|
|
closeMenu();
|
|
return;
|
|
}
|
|
menuOpen.value = true;
|
|
document.addEventListener('mousedown', onDocClick);
|
|
document.addEventListener('scroll', onScrollOrResize, true);
|
|
window.addEventListener('resize', onScrollOrResize);
|
|
await nextTick();
|
|
const btn = kebabRef.value;
|
|
const menu = menuRef.value;
|
|
if (!btn || !menu) return;
|
|
const r = btn.getBoundingClientRect();
|
|
const gap = 4;
|
|
const margin = 8;
|
|
const menuW = menu.offsetWidth;
|
|
const menuH = menu.offsetHeight;
|
|
let top = r.bottom + gap;
|
|
if (top + menuH > window.innerHeight - margin) {
|
|
top = Math.max(margin, r.top - menuH - gap);
|
|
}
|
|
let left = r.left;
|
|
if (left + menuW > window.innerWidth - margin) {
|
|
left = Math.max(margin, r.right - menuW);
|
|
}
|
|
menuStyle.value = {
|
|
top: `${Math.round(top)}px`,
|
|
left: `${Math.round(left)}px`,
|
|
};
|
|
}
|
|
|
|
function closeMenu(): void {
|
|
menuOpen.value = false;
|
|
disarmDelete();
|
|
document.removeEventListener('mousedown', onDocClick);
|
|
document.removeEventListener('scroll', onScrollOrResize, true);
|
|
window.removeEventListener('resize', onScrollOrResize);
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('mousedown', onDocClick);
|
|
document.removeEventListener('scroll', onScrollOrResize, true);
|
|
window.removeEventListener('resize', onScrollOrResize);
|
|
});
|
|
|
|
function onCopyAll(): void {
|
|
emit('copyAll');
|
|
closeMenu();
|
|
}
|
|
|
|
function onCopyFinalSummary(): void {
|
|
emit('copyFinalSummary');
|
|
closeMenu();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Copy session ID
|
|
// ---------------------------------------------------------------------------
|
|
const copiedId = ref(false);
|
|
function copySessionId(): void {
|
|
if (!props.sessionId) return;
|
|
navigator.clipboard.writeText(props.sessionId).then(() => {
|
|
copiedId.value = true;
|
|
setTimeout(() => {
|
|
copiedId.value = false;
|
|
}, 1200);
|
|
}).catch(() => { /* ignore */ });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Inline rename (mirrors SessionRow)
|
|
// ---------------------------------------------------------------------------
|
|
const renaming = ref(false);
|
|
const renameValue = ref('');
|
|
const renameInputRef = ref<HTMLInputElement | null>(null);
|
|
|
|
async function startRename(): Promise<void> {
|
|
closeMenu();
|
|
if (!props.sessionId) return;
|
|
renaming.value = true;
|
|
renameValue.value = props.sessionTitle ?? '';
|
|
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 && props.sessionId && newTitle !== (props.sessionTitle ?? '').trim()) {
|
|
emit('renameSession', props.sessionId, newTitle);
|
|
}
|
|
renaming.value = false;
|
|
}
|
|
|
|
function cancelRename(): void {
|
|
renaming.value = false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fork
|
|
// ---------------------------------------------------------------------------
|
|
function forkSession(): void {
|
|
if (!props.sessionId) return;
|
|
closeMenu();
|
|
emit('forkSession', props.sessionId);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Archive (two-step confirm, same pattern as the workspace menus)
|
|
// ---------------------------------------------------------------------------
|
|
const deleteArmed = ref(false);
|
|
let deleteArmTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
function disarmDelete(): void {
|
|
clearTimeout(deleteArmTimer);
|
|
deleteArmed.value = false;
|
|
}
|
|
|
|
function startArchive(): void {
|
|
if (!props.sessionId) return;
|
|
if (deleteArmed.value) {
|
|
emit('archiveSession', props.sessionId);
|
|
closeMenu();
|
|
return;
|
|
}
|
|
deleteArmed.value = true;
|
|
deleteArmTimer = setTimeout(() => {
|
|
deleteArmed.value = false;
|
|
}, 2500);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<header class="chat-header">
|
|
<!-- Workspace / session breadcrumb -->
|
|
<div class="ch-id">
|
|
<span v-if="workspaceName" class="ch-ws">{{ workspaceName }}</span>
|
|
<span v-if="workspaceName && sessionTitle" class="ch-sep">/</span>
|
|
<input
|
|
v-if="renaming"
|
|
ref="renameInputRef"
|
|
v-model="renameValue"
|
|
class="ch-rename"
|
|
type="text"
|
|
@keydown.enter.stop="commitRename"
|
|
@keydown.esc.stop="cancelRename"
|
|
@blur="commitRename"
|
|
@click.stop
|
|
/>
|
|
<span v-else-if="sessionTitle" class="ch-ses" :title="sessionTitle">{{ sessionTitle }}</span>
|
|
</div>
|
|
|
|
<!-- More menu trigger: copy-all + session actions -->
|
|
<button
|
|
ref="kebabRef"
|
|
type="button"
|
|
class="ch-act ch-act-more"
|
|
:class="{ open: menuOpen }"
|
|
:title="t('header.options')"
|
|
:aria-label="t('header.options')"
|
|
:aria-expanded="menuOpen"
|
|
aria-haspopup="menu"
|
|
@click.stop="toggleMenu($event)"
|
|
>
|
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
|
<circle cx="3" cy="8" r="1.3" />
|
|
<circle cx="8" cy="8" r="1.3" />
|
|
<circle cx="13" cy="8" r="1.3" />
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Fixed more menu -->
|
|
<div
|
|
v-if="menuOpen"
|
|
ref="menuRef"
|
|
class="ch-menu"
|
|
:style="menuStyle"
|
|
@click.stop
|
|
>
|
|
<button type="button" class="chm-item" @click.stop="onCopyAll">
|
|
{{ copied ? t('header.copied') : t('header.copyAll') }}
|
|
</button>
|
|
<button type="button" class="chm-item" @click.stop="onCopyFinalSummary">
|
|
{{ t('header.copyFinalSummary') }}
|
|
</button>
|
|
<template v-if="sessionId">
|
|
<div class="chm-divider" />
|
|
<button type="button" class="chm-item" @click.stop="copySessionId">
|
|
<span>{{ copiedId ? t('header.copied') : t('header.copySessionId') }}</span>
|
|
</button>
|
|
<button type="button" class="chm-item" @click.stop="startRename">
|
|
{{ t('header.renameSession') }}
|
|
</button>
|
|
<button type="button" class="chm-item" @click.stop="forkSession">
|
|
{{ t('header.forkSession') }}
|
|
</button>
|
|
<button type="button" class="chm-item del" @click.stop="startArchive">
|
|
{{ deleteArmed ? t('header.confirmArchive') : t('header.archiveSession') }}
|
|
</button>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="ch-spacer" />
|
|
|
|
<!-- Git branch + status — plain text with semantic colors. Renders for any
|
|
git repo, even a detached HEAD (empty branch → "detached" label), so the
|
|
diff counter below is never hidden just because there's no branch name. -->
|
|
<button
|
|
v-if="isGitRepo"
|
|
type="button"
|
|
class="ch-git"
|
|
:title="t('header.gitTooltip')"
|
|
@click="emit('openChanges')"
|
|
>
|
|
<span
|
|
class="ch-branch"
|
|
:class="{ 'ch-detached': !branch }"
|
|
:title="branch || t('header.detached')"
|
|
>
|
|
{{ branch || t('header.detached') }}
|
|
</span>
|
|
<span v-if="ahead > 0 || behind > 0" class="ch-pill ch-sync-pill">
|
|
<span v-if="ahead > 0" class="ch-ahead">↑{{ ahead }}</span>
|
|
<span v-if="behind > 0" class="ch-behind">↓{{ behind }}</span>
|
|
</span>
|
|
<span v-if="hasLineStats" class="ch-pill ch-diff-pill">
|
|
<span v-if="adds > 0" class="ch-add">+{{ adds }}</span>
|
|
<span v-if="dels > 0" class="ch-del">-{{ dels }}</span>
|
|
</span>
|
|
</button>
|
|
|
|
<!-- GitHub PR status -->
|
|
<button
|
|
v-if="pr"
|
|
type="button"
|
|
class="ch-pill ch-pr"
|
|
:class="`pr-${pr.state}`"
|
|
:title="t('header.openPr')"
|
|
@click="pr && emit('openPr', pr.url)"
|
|
>
|
|
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="5" cy="6" r="3" />
|
|
<path d="M5 9v12" />
|
|
<circle cx="19" cy="18" r="3" />
|
|
<path d="m15 9-3-3 3-3" />
|
|
<path d="M12 6h5a2 2 0 0 1 2 2v7" />
|
|
</svg>
|
|
<span>PR #{{ pr.number }} · {{ pr.state }}</span>
|
|
</button>
|
|
|
|
</header>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.chat-header {
|
|
flex: none;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
height: 48px;
|
|
padding: 0 16px;
|
|
border-bottom: 1px solid var(--line);
|
|
background: var(--bg);
|
|
font-family: var(--sans);
|
|
min-width: 0;
|
|
}
|
|
.ch-id { display: flex; align-items: center; gap: 6px; min-width: 0; flex: none; max-width: 46%; }
|
|
.ch-ws { color: var(--muted); font-size: var(--ui-font-size-sm); flex: none; }
|
|
.ch-sep { color: var(--faint); flex: none; }
|
|
.ch-ses {
|
|
color: var(--ink);
|
|
font-size: var(--ui-font-size-sm);
|
|
font-weight: 600;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.ch-rename {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-family: var(--mono);
|
|
font-size: var(--ui-font-size-sm);
|
|
font-weight: 600;
|
|
color: var(--ink);
|
|
background: var(--bg);
|
|
border: 1px solid var(--blue);
|
|
border-radius: 3px;
|
|
padding: 2px 5px;
|
|
outline: none;
|
|
}
|
|
.ch-git {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
border: none;
|
|
background: transparent;
|
|
padding: 0;
|
|
color: var(--muted);
|
|
font-family: var(--mono);
|
|
font-size: calc(var(--ui-font-size) - 2px);
|
|
flex: 0 1 auto;
|
|
max-width: none;
|
|
min-width: 0;
|
|
cursor: pointer;
|
|
}
|
|
.ch-git:hover .ch-branch { color: var(--ink); }
|
|
.ch-branch {
|
|
color: var(--dim);
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
margin-right: 4px;
|
|
}
|
|
.ch-detached { color: var(--muted); font-style: italic; }
|
|
.ch-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
padding: 1px 5px;
|
|
border-radius: 999px;
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
font-size: calc(var(--ui-font-size) - 3px);
|
|
}
|
|
.ch-sync-pill { border-color: var(--line); }
|
|
.ch-diff-pill { border-color: color-mix(in srgb, var(--ok) 20%, var(--line)); }
|
|
.ch-ahead { color: var(--warn); flex: none; }
|
|
.ch-behind { color: var(--blue2); flex: none; }
|
|
.ch-add { color: var(--ok); flex: none; }
|
|
.ch-del { color: var(--err); flex: none; }
|
|
.ch-spacer { flex: 1; min-width: 0; }
|
|
|
|
.ch-act {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
flex: none;
|
|
border: none;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
color: var(--dim);
|
|
font-family: var(--sans);
|
|
font-size: var(--ui-font-size-xs);
|
|
padding: 0;
|
|
cursor: pointer;
|
|
}
|
|
.ch-act:hover { color: var(--ink); }
|
|
.ch-act.open { color: var(--ink); }
|
|
.ch-act svg { flex: none; }
|
|
/* Kebab is icon-only: keep the glyph small but give it a comfortable 28x28
|
|
click target (and a clear keyboard focus ring). */
|
|
.ch-act-more {
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 6px;
|
|
}
|
|
.ch-act-more:hover { background: var(--panel2); }
|
|
.ch-act-more:focus-visible {
|
|
outline: 2px solid var(--blue);
|
|
outline-offset: -2px;
|
|
}
|
|
|
|
.ch-pr {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
flex: none;
|
|
cursor: pointer;
|
|
color: var(--dim);
|
|
margin-left: -4px;
|
|
font-size: calc(var(--ui-font-size) - 2.5px);
|
|
}
|
|
.ch-pr.pr-open { color: #1a7f37; border-color: color-mix(in srgb, #1a7f37 30%, var(--line)); }
|
|
.ch-pr.pr-merged { color: #8250df; border-color: color-mix(in srgb, #8250df 30%, var(--line)); }
|
|
.ch-pr.pr-closed { color: var(--err); }
|
|
.ch-pr:hover { background: var(--soft); }
|
|
|
|
/* Fixed more-menu, anchored to the kebab trigger */
|
|
.ch-menu {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
background: var(--bg);
|
|
border: 1px solid var(--line);
|
|
border-radius: 4px;
|
|
z-index: 200;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
overflow: hidden;
|
|
min-width: 140px;
|
|
}
|
|
.chm-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
width: 100%;
|
|
text-align: left;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-family: var(--mono);
|
|
font-size: calc(var(--ui-font-size) - 3px);
|
|
color: var(--ink);
|
|
padding: 6px 12px;
|
|
}
|
|
.chm-item:hover { background: var(--panel2); }
|
|
.chm-item.del { color: var(--err); }
|
|
.chm-item.del:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
|
|
|
|
.chm-divider {
|
|
height: 1px;
|
|
background: var(--line);
|
|
margin: 2px 0;
|
|
}
|
|
|
|
/* On a narrow conversation column, the action labels collapse to icons. */
|
|
@media (max-width: 900px) {
|
|
.ch-act-label { display: none; }
|
|
}
|
|
@media (max-width: 640px) {
|
|
.chat-header { display: none; }
|
|
}
|
|
</style>
|