diff --git a/apps/kimi-web/src/components/ChatPane.vue b/apps/kimi-web/src/components/ChatPane.vue index 28472b81d..857d3279e 100644 --- a/apps/kimi-web/src/components/ChatPane.vue +++ b/apps/kimi-web/src/components/ChatPane.vue @@ -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(() => { // Per-turn copy button state (keyed by turn id) const copiedTurn = ref(null); - +// Copy-whole-conversation state +const copiedConversation = ref(false); +let copiedConversationTimer: ReturnType | 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--) { diff --git a/apps/kimi-web/src/components/ConversationPane.vue b/apps/kimi-web/src/components/ConversationPane.vue index 9b40b3c34..5507cee88 100644 --- a/apps/kimi-web/src/components/ConversationPane.vue +++ b/apps/kimi-web/src/components/ConversationPane.vue @@ -97,6 +97,9 @@ try { // expose a way for App.vue to imperatively switch to tasks tab const active = ref('chat'); +const chatPaneRef = ref | null>(null); +const copyConversationCopied = ref(false); +let copyConversationCopiedTimer: ReturnType | 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()" /> - - {{ runningTasks }} - {{ (todos?.filter((t) => t.status === 'done').length ?? 0) }}/{{ todos!.length }} +
+
+ {{ t(tab.labelKey) }} + + + {{ runningTasks }} + {{ (todos?.filter((t) => t.status === 'done').length ?? 0) }}/{{ todos!.length }} +
+
+
+
@@ -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; diff --git a/apps/kimi-web/src/i18n/locales/en/sidebar.ts b/apps/kimi-web/src/i18n/locales/en/sidebar.ts index 0530e8809..88c4328c7 100644 --- a/apps/kimi-web/src/i18n/locales/en/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/en/sidebar.ts @@ -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', diff --git a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts index 733b6b441..99c91a619 100644 --- a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts @@ -10,6 +10,8 @@ export default { options: '选项', rename: '重命名', copyPath: '复制路径', + shareConversation: '复制全部会话', + shareConversationCopied: '已复制', archive: '归档', delete: '删除', brand: 'Kimi Code',