feat(web): add copy conversation feedback

This commit is contained in:
qer 2026-06-11 17:37:04 +08:00
parent 6a6a4ba9ed
commit 943955223c
5 changed files with 161 additions and 15 deletions

View file

@ -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--) {

View file

@ -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>

View file

@ -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;

View file

@ -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',

View file

@ -10,6 +10,8 @@ export default {
options: '选项',
rename: '重命名',
copyPath: '复制路径',
shareConversation: '复制全部会话',
shareConversationCopied: '已复制',
archive: '归档',
delete: '删除',
brand: 'Kimi Code',