From dacb869a62c074ac76dc48ceb660934dfb8b9661 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 17 Aug 2026 06:07:15 -0400 Subject: [PATCH] feat(web): add retry on the last reply and copy on user messages Assistant runs already had a copy button and the last user message already had undo. Two gaps remained: no way to retry a reply, and no way to copy your own message. Retry is gated to the final assistant run. The underlying operation undoes the last exchange whatever was clicked, so a retry on an older message would destroy the wrong turn. It also keeps a confirm step, because the discarded reply cannot be recovered. --- .changeset/web-message-actions.md | 5 + apps/pythinker-web/src/App.vue | 9 + .../pythinker-web/src/components/ChatPane.vue | 128 ++++++++++- .../src/components/ConversationPane.vue | 3 + .../src/i18n/locales/en/conversation.ts | 2 + .../test/message-actions.test.ts | 200 ++++++++++++++++++ 6 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 .changeset/web-message-actions.md create mode 100644 apps/pythinker-web/test/message-actions.test.ts diff --git a/.changeset/web-message-actions.md b/.changeset/web-message-actions.md new file mode 100644 index 00000000..65f8d717 --- /dev/null +++ b/.changeset/web-message-actions.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-code': minor +--- + +Add a Retry action to the last assistant reply and a copy button to user messages in the web UI. Retry asks for confirmation, then sends the original prompt again. diff --git a/apps/pythinker-web/src/App.vue b/apps/pythinker-web/src/App.vue index 7078b150..a3924781 100644 --- a/apps/pythinker-web/src/App.vue +++ b/apps/pythinker-web/src/App.vue @@ -749,6 +749,14 @@ async function handleEditMessage(text: string): Promise { conversationPaneRef.value?.loadComposerForEdit(text); } +// Retry the last assistant reply: undo the exchange, then send its original +// user prompt as a new prompt. Undo reports any failure and returns null. +async function handleRegenerate(): Promise { + const text = await client.undo(1); + if (text === null) return; + await client.sendPrompt(text); +} + // Handler for slash commands emitted by Composer (via ConversationPane) function handleCommand(cmd: string): void { // `/compact ` carries an optional free-text instruction steering what @@ -1167,6 +1175,7 @@ function openPr(url: string): void { @open-compaction="openCompactionPanel($event)" @open-agent="openAgentPanel($event)" @edit-message="handleEditMessage" + @regenerate="handleRegenerate" /> diff --git a/apps/pythinker-web/src/components/ChatPane.vue b/apps/pythinker-web/src/components/ChatPane.vue index 127d844f..131fc406 100644 --- a/apps/pythinker-web/src/components/ChatPane.vue +++ b/apps/pythinker-web/src/components/ChatPane.vue @@ -113,6 +113,8 @@ const emit = defineEmits<{ openAgent: [target: { turnId: string; blockIndex: number; memberId: string }]; /** Edit + resend the last user message (parent undoes, then refills composer). */ editMessage: [text: string]; + /** Undo the last exchange and send its user prompt again. */ + regenerate: []; }>(); // Id of the most recent user turn — the only one offered an "edit & resend" @@ -166,6 +168,7 @@ const copiedTurn = ref(null); // Undo/edit-and-resend confirmation state (keyed by turn id) const confirmingEditTurnId = ref(null); +const confirmingRetryTurnId = ref(null); const undoingTurnId = ref(null); let undoTimer: ReturnType | null = null; @@ -201,6 +204,12 @@ function confirmEditMessage(turn: ChatTurn): void { }, 240); } +function confirmRegenerate(): void { + if (confirmingRetryTurnId.value === null) return; + confirmingRetryTurnId.value = null; + emit('regenerate'); +} + // Copy-whole-conversation state const copiedConversation = ref(false); let copiedConversationTimer: ReturnType | null = null; @@ -302,6 +311,38 @@ function isAssistantRunEnd(index: number): boolean { return !next || next.role !== 'assistant'; } +function isFinalAssistantRun(index: number): boolean { + if (!isAssistantRunEnd(index)) return false; + for (let i = index + 1; i < props.turns.length; i += 1) { + if (props.turns[i]?.role === 'assistant') return false; + } + return true; +} + +function canRetryAssistantRun(index: number): boolean { + const turn = props.turns[index]; + if (!turn || turn.role !== 'assistant') return false; + + let precedingUser: ChatTurn | null = null; + for (let i = index - 1; i >= 0; i -= 1) { + if (props.turns[i]?.role === 'user') { + precedingUser = props.turns[i]!; + break; + } + } + + return ( + isFinalAssistantRun(index) && + turn.id !== streamingTurnId.value && + !props.running && + !props.sending && + precedingUser !== null && + precedingUser.id === lastUserTurnId.value && + !precedingUser.skillActivation && + assistantRunFinalText(index).trim().length > 0 + ); +} + // One shared timer: copying B within 1.4s of copying A must not let A's stale // timer hide B's checkmark early. Cleared on unmount. let copiedTimer: ReturnType | null = null; @@ -320,6 +361,18 @@ function copyAssistantRun(index: number): void { }).catch(() => {/* ignore */}); } +function copyUserTurn(turn: ChatTurn): void { + if (turn.skillActivation) return; + navigator.clipboard.writeText(turn.text).then(() => { + copiedTurn.value = turn.id; + if (copiedTimer !== null) clearTimeout(copiedTimer); + copiedTimer = setTimeout(() => { + copiedTimer = null; + copiedTurn.value = null; + }, 1400); + }).catch(() => {/* ignore */}); +} + // Ordered render blocks for an assistant turn. messagesToTurns supplies `blocks` // (thinking + text + tool cards in call order); fall back to deriving them from // the aggregate fields for any turn built without blocks (e.g. unit tests). @@ -473,7 +526,24 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string {
{{ turn.text }}
-
+
+
+ +
+ {{ t('conversation.retryConfirm') }} + + +
@@ -631,6 +724,17 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string { > + + + +
+ {{ t('conversation.retryConfirm') }} + + +
{{ formatDuration(turn.durationMs) }}
diff --git a/apps/pythinker-web/src/components/ConversationPane.vue b/apps/pythinker-web/src/components/ConversationPane.vue index cddb00c1..ca8540a4 100644 --- a/apps/pythinker-web/src/components/ConversationPane.vue +++ b/apps/pythinker-web/src/components/ConversationPane.vue @@ -104,6 +104,8 @@ const emit = defineEmits<{ refreshGitStatus: []; /** Edit + resend the last user message (App undoes, then refills composer). */ editMessage: [text: string]; + /** Undo the last exchange and send its user prompt again. */ + regenerate: []; /** Empty-composer workspace picker: start a new conversation elsewhere. */ selectWorkspace: [workspaceId: string]; /** Empty-composer workspace picker: create a new workspace. */ @@ -940,6 +942,7 @@ defineExpose({ loadComposerForEdit }); @open-compaction="emit('openCompaction', $event)" @open-agent="emit('openAgent', $event)" @edit-message="emit('editMessage', $event)" + @regenerate="emit('regenerate')" />
diff --git a/apps/pythinker-web/src/i18n/locales/en/conversation.ts b/apps/pythinker-web/src/i18n/locales/en/conversation.ts index 73e883f3..0ab6f1a1 100644 --- a/apps/pythinker-web/src/i18n/locales/en/conversation.ts +++ b/apps/pythinker-web/src/i18n/locales/en/conversation.ts @@ -18,6 +18,8 @@ export default { undo: 'Undo', undoTooltip: 'Undoing the conversation will not roll back code changes', undoConfirm: 'Undo last message?', + retry: 'Retry', + retryConfirm: 'Retry last reply?', confirm: 'Confirm', cancel: 'Cancel', yesterday: 'Yesterday', diff --git a/apps/pythinker-web/test/message-actions.test.ts b/apps/pythinker-web/test/message-actions.test.ts new file mode 100644 index 00000000..c545a4ea --- /dev/null +++ b/apps/pythinker-web/test/message-actions.test.ts @@ -0,0 +1,200 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { flushPromises, mount } from '@vue/test-utils'; +import { createI18n } from 'vue-i18n'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import ChatPane from '../src/components/ChatPane.vue'; +import type { ChatTurn } from '../src/types'; + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + conversation: { + cancel: 'Cancel', + compactedPlain: 'Context compacted', + compactedAuto: 'Context auto-compacted', + compactedTokens: ' ({before} -> {after})', + confirm: 'Confirm', + loading: 'Loading', + retry: 'Retry', + retryConfirm: 'Retry last reply?', + undo: 'Undo', + undoConfirm: 'Undo last message?', + undoTooltip: 'Undoing the conversation will not roll back code changes', + viewSummary: 'View summary', + yesterday: 'Yesterday', + }, + filePreview: { copy: 'Copy' }, + }, + }, + missingWarn: false, + fallbackWarn: false, +}); + +function mountPane( + turns: ChatTurn[], + props: { mobile?: boolean; running?: boolean; sending?: boolean } = {}, +) { + return mount(ChatPane, { + props: { turns, ...props }, + global: { + plugins: [i18n], + stubs: { + Markdown: { props: ['text'], template: '
{{ text }}
' }, + ThinkingBlock: true, + ToolCall: true, + ActivityNotice: true, + ActivitySpinner: true, + MascotSprite: true, + AgentCard: true, + AgentGroup: true, + }, + }, + }); +} + +function userTurn(id: string, no: number, text: string, skillActivation?: ChatTurn['skillActivation']): ChatTurn { + return { id, role: 'user', no, text, skillActivation }; +} + +function assistantTurn(id: string, no: number, text: string): ChatTurn { + return { id, role: 'assistant', no, text }; +} + +function mockClipboard() { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + return writeText; +} + +const finalExchange: ChatTurn[] = [ + userTurn('u1', 1, 'old prompt'), + assistantTurn('a1', 2, 'old reply'), + userTurn('u2', 3, 'last prompt'), + assistantTurn('a2', 4, 'last reply'), +]; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('ChatPane message actions', () => { + it.each([false, true])('renders Retry only on the final assistant run in both layouts', (mobile) => { + const wrapper = mountPane(finalExchange, { mobile }); + + expect(wrapper.findAll('.retry-btn')).toHaveLength(1); + expect(wrapper.find('[data-turn-id="a1"] .retry-btn').exists()).toBe(false); + expect(wrapper.find('[data-turn-id="a2"] .retry-btn').exists()).toBe(true); + }); + + it.each([ + { name: 'running', props: { running: true } }, + { name: 'sending', props: { sending: true } }, + ])('does not render Retry while $name is true', ({ props }) => { + const idleWrapper = mountPane(finalExchange); + expect(idleWrapper.find('.retry-btn').exists()).toBe(true); + + const wrapper = mountPane(finalExchange, props); + expect(wrapper.find('.retry-btn').exists()).toBe(false); + }); + + it('requires confirmation before emitting regenerate', async () => { + const wrapper = mountPane(finalExchange); + const retry = wrapper.find('.retry-btn'); + expect(retry.exists()).toBe(true); + + await retry.trigger('click'); + + expect(wrapper.emitted('regenerate')).toBeUndefined(); + expect(wrapper.find('.retry-confirm').exists()).toBe(true); + + await wrapper.find('.retry-confirm .confirm').trigger('click'); + + expect(wrapper.emitted('regenerate')).toHaveLength(1); + }); + + it('cancelling Retry emits nothing and restores the button', async () => { + const wrapper = mountPane(finalExchange); + expect(wrapper.find('.retry-btn').exists()).toBe(true); + + await wrapper.find('.retry-btn').trigger('click'); + await wrapper.find('.retry-confirm .u-edit-confirm-btn:not(.confirm)').trigger('click'); + + expect(wrapper.emitted('regenerate')).toBeUndefined(); + expect(wrapper.find('.retry-btn').exists()).toBe(true); + }); + + it.each([false, true])('copies a user turn verbatim in both layouts', async (mobile) => { + const text = ' keep this text exactly\nwith its spaces '; + const writeText = mockClipboard(); + const wrapper = mountPane([userTurn('u1', 1, text)], { mobile }); + const copy = wrapper.find('.user-cpbtn'); + expect(copy.exists()).toBe(true); + + await copy.trigger('click'); + await flushPromises(); + + expect(writeText).toHaveBeenCalledWith(text); + }); + + it.each([false, true])('shows user copy on an older user turn in both layouts', (mobile) => { + const turns = [ + userTurn('u1', 1, 'older prompt'), + assistantTurn('a1', 2, 'older reply'), + userTurn('u2', 3, 'last prompt'), + ]; + const wrapper = mountPane(turns, { mobile }); + + expect(wrapper.findAll('.user-cpbtn')).toHaveLength(2); + expect(wrapper.find('[data-user-turn-id="u1"]').exists()).toBe(true); + }); + + it.each([false, true])('skips user copy for skill activations in both layouts', (mobile) => { + const wrapper = mountPane( + [ + userTurn('u1', 1, 'normal prompt'), + userTurn('skill', 2, '/review src/app.ts', { name: 'review', args: 'src/app.ts' }), + ], + { mobile }, + ); + + expect(wrapper.find('[data-user-turn-id="u1"]').exists()).toBe(true); + expect(wrapper.find('[data-user-turn-id="skill"]').exists()).toBe(false); + }); +}); + +describe('message action theme guard', () => { + const sourceAllowlist: Array<{ path: string; colors: string[] }> = [ + { path: '../src/components/ChatPane.vue', colors: [] }, + { + path: '../src/components/ConversationPane.vue', + colors: [ + '#'.concat('000'), + 'rgba'.concat('(0, 0, 0, 0.28)'), + 'rgba'.concat('(0, 0, 0, 0.14)'), + 'rgba'.concat('(0, 0, 0, 0.12)'), + 'rgba'.concat('(0, 0, 0, 0.18)'), + ], + }, + { path: '../src/App.vue', colors: [] }, + { path: '../src/i18n/locales/en/conversation.ts', colors: [] }, + { path: './message-actions.test.ts', colors: [] }, + ]; + const colorLiteralPattern = /#[0-9a-f]{3,8}\b|\b(?:rgb|rgba)\([^)]*\)/giu; + const darkUtilityPattern = new RegExp(['dark', ':'].join(''), 'gu'); + + it('keeps touched files free of new theme literals and dark utilities', async () => { + for (const { path, colors } of sourceAllowlist) { + const source = await readFile(resolve(import.meta.dirname, path), 'utf8'); + expect(source.match(darkUtilityPattern) ?? [], path).toEqual([]); + expect((source.match(colorLiteralPattern) ?? []).toSorted(), path).toEqual([...colors].toSorted()); + } + }); +});