mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 08:34:39 +00:00
feat(web): add copy conversation feedback
This commit is contained in:
parent
6a6a4ba9ed
commit
943955223c
5 changed files with 161 additions and 15 deletions
|
|
@ -33,6 +33,10 @@ onUnmounted(() => {
|
|||
clearTimeout(copiedTimer);
|
||||
copiedTimer = null;
|
||||
}
|
||||
if (copiedConversationTimer !== null) {
|
||||
clearTimeout(copiedConversationTimer);
|
||||
copiedConversationTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
|
|
@ -99,6 +103,7 @@ const emit = defineEmits<{
|
|||
approvalDecide: [approvalId: string, response: { decision: ApprovalDecision; scope?: 'session'; feedback?: string }];
|
||||
openFile: [target: FilePreviewRequest];
|
||||
openMedia: [media: ToolMedia];
|
||||
copyConversationCopied: [];
|
||||
/** Show a thinking block's full text in the right-side panel. */
|
||||
openThinking: [target: { turnId: string; blockIndex: number }];
|
||||
}>();
|
||||
|
|
@ -116,7 +121,9 @@ const compactionLabel = computed<string>(() => {
|
|||
// Per-turn copy button state (keyed by turn id)
|
||||
const copiedTurn = ref<string | null>(null);
|
||||
|
||||
|
||||
// Copy-whole-conversation state
|
||||
const copiedConversation = ref(false);
|
||||
let copiedConversationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Assemble the full content of a turn for copying — follows the ordered
|
||||
blocks so thinking/text/tool output copy in the order they happened. */
|
||||
|
|
@ -132,6 +139,47 @@ function turnPlainText(turn: ChatTurn): string {
|
|||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/** Convert a single turn to Markdown. */
|
||||
function turnToMarkdown(turn: ChatTurn): string {
|
||||
const parts: string[] = [];
|
||||
for (const blk of turnBlocks(turn)) {
|
||||
if (blk.kind === 'thinking' && blk.thinking) {
|
||||
parts.push(`> **Thinking**\n> ${blk.thinking.split('\n').join('\n> ')}`);
|
||||
} else if (blk.kind === 'text' && blk.text) {
|
||||
parts.push(blk.text);
|
||||
} else if (blk.kind === 'tool' && blk.tool.output && blk.tool.output.length > 0) {
|
||||
const output = blk.tool.output.join('\n');
|
||||
parts.push(`\`\`\`\n[${blk.tool.name}]\n${output}\n\`\`\``);
|
||||
}
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/** Convert the entire conversation to Markdown and copy to clipboard. */
|
||||
function copyConversation(): void {
|
||||
if (props.turns.length === 0) return;
|
||||
const lines: string[] = [];
|
||||
for (const turn of props.turns) {
|
||||
const roleLabel = turn.role === 'user' ? 'User' : 'Assistant';
|
||||
const content = turnToMarkdown(turn);
|
||||
if (content.trim()) {
|
||||
lines.push(`**${roleLabel}**\n\n${content}`);
|
||||
}
|
||||
}
|
||||
const markdown = lines.join('\n\n---\n\n');
|
||||
navigator.clipboard.writeText(markdown).then(() => {
|
||||
copiedConversation.value = true;
|
||||
emit('copyConversationCopied');
|
||||
if (copiedConversationTimer !== null) clearTimeout(copiedConversationTimer);
|
||||
copiedConversationTimer = setTimeout(() => {
|
||||
copiedConversationTimer = null;
|
||||
copiedConversation.value = false;
|
||||
}, 2000);
|
||||
}).catch(() => {/* ignore */});
|
||||
}
|
||||
|
||||
defineExpose({ copyConversation });
|
||||
|
||||
function assistantRunEndingAt(index: number): ChatTurn[] {
|
||||
const run: ChatTurn[] = [];
|
||||
for (let i = index; i >= 0; i--) {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,9 @@ try {
|
|||
|
||||
// expose a way for App.vue to imperatively switch to tasks tab
|
||||
const active = ref<PaneKey>('chat');
|
||||
const chatPaneRef = ref<InstanceType<typeof ChatPane> | null>(null);
|
||||
const copyConversationCopied = ref(false);
|
||||
let copyConversationCopiedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Called by App.vue via command routing to switch to a specific tab */
|
||||
function switchTab(tab: PaneKey): void {
|
||||
|
|
@ -104,6 +107,15 @@ function switchTab(tab: PaneKey): void {
|
|||
}
|
||||
defineExpose({ switchTab });
|
||||
|
||||
function handleCopyConversationCopied(): void {
|
||||
copyConversationCopied.value = true;
|
||||
if (copyConversationCopiedTimer !== null) clearTimeout(copyConversationCopiedTimer);
|
||||
copyConversationCopiedTimer = setTimeout(() => {
|
||||
copyConversationCopiedTimer = null;
|
||||
copyConversationCopied.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// The TabBar is hidden for an empty session (the centred quick-start composer
|
||||
// takes the whole pane) — if the user was parked on tasks/todo/files when the
|
||||
// session emptied out, they'd be trapped with no tabs AND no composer. Snap
|
||||
|
|
@ -527,6 +539,10 @@ onUnmounted(() => {
|
|||
if (resizeObserver) resizeObserver.disconnect();
|
||||
if (scrollRaf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(scrollRaf);
|
||||
if (abortToastTimer !== null) clearTimeout(abortToastTimer);
|
||||
if (copyConversationCopiedTimer !== null) {
|
||||
clearTimeout(copyConversationCopiedTimer);
|
||||
copyConversationCopiedTimer = null;
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
|
|
@ -543,7 +559,10 @@ onUnmounted(() => {
|
|||
:changes-count="changesCount"
|
||||
:todos="todos ?? []"
|
||||
:mobile="mobile"
|
||||
:show-copy-conversation="turns.length > 0"
|
||||
:copy-conversation-copied="copyConversationCopied"
|
||||
@select="active = $event"
|
||||
@copy-conversation="chatPaneRef?.copyConversation()"
|
||||
/>
|
||||
|
||||
<!-- Wide-screen floating stack (codex-style): todos + running background
|
||||
|
|
@ -599,6 +618,7 @@ onUnmounted(() => {
|
|||
</template>
|
||||
<template v-else>
|
||||
<ChatPane
|
||||
ref="chatPaneRef"
|
||||
:key="fileReloadKey ?? 'no-session'"
|
||||
:turns="turns"
|
||||
:approvals="approvals"
|
||||
|
|
@ -611,6 +631,7 @@ onUnmounted(() => {
|
|||
@approval-decide="handleApprovalDecide"
|
||||
@open-file="emit('openFile', $event)"
|
||||
@open-media="emit('openMedia', $event)"
|
||||
@copy-conversation-copied="handleCopyConversationCopied"
|
||||
@open-thinking="emit('openThinking', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { useI18n } from 'vue-i18n';
|
||||
import type { PaneKey, TodoView } from '../types';
|
||||
|
||||
defineProps<{ active: PaneKey; runningTasks: number; changesCount?: number; todos?: TodoView[]; mobile?: boolean }>();
|
||||
const emit = defineEmits<{ select: [pane: PaneKey] }>();
|
||||
defineProps<{ active: PaneKey; runningTasks: number; changesCount?: number; todos?: TodoView[]; mobile?: boolean; showCopyConversation?: boolean; copyConversationCopied?: boolean }>();
|
||||
const emit = defineEmits<{ select: [pane: PaneKey]; copyConversation: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -19,18 +19,42 @@ const tabs: { key: PaneKey; labelKey: string }[] = [
|
|||
|
||||
<template>
|
||||
<div class="tabs" :class="{ mobile }">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tb"
|
||||
:class="{ on: active === tab.key }"
|
||||
@click="emit('select', tab.key)"
|
||||
>
|
||||
{{ t(tab.labelKey) }}
|
||||
<!-- TODO: restore when files tab is re-enabled -->
|
||||
<!-- <span v-if="tab.key === 'files' && (changesCount ?? 0) > 0" class="d"></span> -->
|
||||
<span v-if="tab.key === 'tasks' && runningTasks > 0" class="cnt">{{ runningTasks }}</span>
|
||||
<span v-if="tab.key === 'todo' && (todos?.length ?? 0) > 0" class="cnt">{{ (todos?.filter((t) => t.status === 'done').length ?? 0) }}/{{ todos!.length }}</span>
|
||||
<div class="tabs-left">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tb"
|
||||
:class="{ on: active === tab.key }"
|
||||
@click="emit('select', tab.key)"
|
||||
>
|
||||
{{ t(tab.labelKey) }}
|
||||
<!-- TODO: restore when files tab is re-enabled -->
|
||||
<!-- <span v-if="tab.key === 'files' && (changesCount ?? 0) > 0" class="d"></span> -->
|
||||
<span v-if="tab.key === 'tasks' && runningTasks > 0" class="cnt">{{ runningTasks }}</span>
|
||||
<span v-if="tab.key === 'todo' && (todos?.length ?? 0) > 0" class="cnt">{{ (todos?.filter((t) => t.status === 'done').length ?? 0) }}/{{ todos!.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showCopyConversation && active === 'chat'" class="tabs-right">
|
||||
<button
|
||||
class="share-conversation-btn"
|
||||
:class="{ 'is-copied': copyConversationCopied }"
|
||||
type="button"
|
||||
:aria-label="copyConversationCopied ? t('sidebar.shareConversationCopied') : t('sidebar.shareConversation')"
|
||||
:title="copyConversationCopied ? t('sidebar.shareConversationCopied') : t('sidebar.shareConversation')"
|
||||
@click="emit('copyConversation')"
|
||||
>
|
||||
<svg v-if="!copyConversationCopied" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5.5 2.5h6A1.5 1.5 0 0 1 13 4v7.5"/>
|
||||
<rect x="3" y="4.5" width="8" height="9" rx="1.4"/>
|
||||
<path d="M5.3 7.1h3.4"/>
|
||||
<path d="M5.3 9.3h2.8"/>
|
||||
<path d="M5.3 11.5h3.4"/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="3,8 6.5,11.5 13,5"/>
|
||||
</svg>
|
||||
<span class="share-conversation-label">{{ copyConversationCopied ? t('sidebar.shareConversationCopied') : t('sidebar.shareConversation') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -40,9 +64,58 @@ const tabs: { key: PaneKey; labelKey: string }[] = [
|
|||
height: 32px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.tabs-left {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.tabs-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.share-conversation-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-family: var(--sans);
|
||||
cursor: pointer;
|
||||
transition: color 0.12s, background 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.share-conversation-btn:hover {
|
||||
color: var(--ink);
|
||||
background: var(--panel2);
|
||||
}
|
||||
.share-conversation-btn.is-copied {
|
||||
color: var(--ok);
|
||||
}
|
||||
.share-conversation-btn svg {
|
||||
flex: none;
|
||||
}
|
||||
.share-conversation-label {
|
||||
opacity: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
transition: opacity 0.15s ease, max-width 0.2s ease;
|
||||
}
|
||||
.share-conversation-btn:hover .share-conversation-label {
|
||||
opacity: 1;
|
||||
max-width: 120px;
|
||||
}
|
||||
.share-conversation-btn.is-copied .share-conversation-label {
|
||||
opacity: 1;
|
||||
max-width: 120px;
|
||||
}
|
||||
.tb {
|
||||
padding: 0 14px;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export default {
|
|||
options: 'Options',
|
||||
rename: 'Rename',
|
||||
copyPath: 'Copy path',
|
||||
shareConversation: 'Copy conversation',
|
||||
shareConversationCopied: 'Copied',
|
||||
archive: 'Archive',
|
||||
delete: 'Delete',
|
||||
brand: 'Kimi Code',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export default {
|
|||
options: '选项',
|
||||
rename: '重命名',
|
||||
copyPath: '复制路径',
|
||||
shareConversation: '复制全部会话',
|
||||
shareConversationCopied: '已复制',
|
||||
archive: '归档',
|
||||
delete: '删除',
|
||||
brand: 'Kimi Code',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue