Skip to content

Commit 3bb1bfb

Browse files
committed
Merge branch 'feat/web-message-actions'
2 parents 9897d52 + dacb869 commit 3bb1bfb

6 files changed

Lines changed: 346 additions & 1 deletion

File tree

.changeset/web-message-actions.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pymodel/pythinker-code': minor
3+
---
4+
5+
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.

apps/pythinker-web/src/App.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,14 @@ async function handleEditMessage(text: string): Promise<void> {
750750
conversationPaneRef.value?.loadComposerForEdit(text);
751751
}
752752
753+
// Retry the last assistant reply: undo the exchange, then send its original
754+
// user prompt as a new prompt. Undo reports any failure and returns null.
755+
async function handleRegenerate(): Promise<void> {
756+
const text = await client.undo(1);
757+
if (text === null) return;
758+
await client.sendPrompt(text);
759+
}
760+
753761
// Handler for slash commands emitted by Composer (via ConversationPane)
754762
function handleCommand(cmd: string): void {
755763
// `/compact <text>` carries an optional free-text instruction steering what
@@ -1169,6 +1177,7 @@ function openPr(url: string): void {
11691177
@open-compaction="openCompactionPanel($event)"
11701178
@open-agent="openAgentPanel($event)"
11711179
@edit-message="handleEditMessage"
1180+
@regenerate="handleRegenerate"
11721181
/>
11731182

11741183
<!-- Multi-workspace selection placeholder -->

apps/pythinker-web/src/components/ChatPane.vue

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ const emit = defineEmits<{
113113
openAgent: [target: { turnId: string; blockIndex: number; memberId: string }];
114114
/** Edit + resend the last user message (parent undoes, then refills composer). */
115115
editMessage: [text: string];
116+
/** Undo the last exchange and send its user prompt again. */
117+
regenerate: [];
116118
}>();
117119
118120
// Id of the most recent user turn — the only one offered an "edit & resend"
@@ -166,6 +168,7 @@ const copiedTurn = ref<string | null>(null);
166168
167169
// Undo/edit-and-resend confirmation state (keyed by turn id)
168170
const confirmingEditTurnId = ref<string | null>(null);
171+
const confirmingRetryTurnId = ref<string | null>(null);
169172
const undoingTurnId = ref<string | null>(null);
170173
let undoTimer: ReturnType<typeof setTimeout> | null = null;
171174
@@ -201,6 +204,12 @@ function confirmEditMessage(turn: ChatTurn): void {
201204
}, 240);
202205
}
203206
207+
function confirmRegenerate(): void {
208+
if (confirmingRetryTurnId.value === null) return;
209+
confirmingRetryTurnId.value = null;
210+
emit('regenerate');
211+
}
212+
204213
// Copy-whole-conversation state
205214
const copiedConversation = ref(false);
206215
let copiedConversationTimer: ReturnType<typeof setTimeout> | null = null;
@@ -302,6 +311,38 @@ function isAssistantRunEnd(index: number): boolean {
302311
return !next || next.role !== 'assistant';
303312
}
304313
314+
function isFinalAssistantRun(index: number): boolean {
315+
if (!isAssistantRunEnd(index)) return false;
316+
for (let i = index + 1; i < props.turns.length; i += 1) {
317+
if (props.turns[i]?.role === 'assistant') return false;
318+
}
319+
return true;
320+
}
321+
322+
function canRetryAssistantRun(index: number): boolean {
323+
const turn = props.turns[index];
324+
if (!turn || turn.role !== 'assistant') return false;
325+
326+
let precedingUser: ChatTurn | null = null;
327+
for (let i = index - 1; i >= 0; i -= 1) {
328+
if (props.turns[i]?.role === 'user') {
329+
precedingUser = props.turns[i]!;
330+
break;
331+
}
332+
}
333+
334+
return (
335+
isFinalAssistantRun(index) &&
336+
turn.id !== streamingTurnId.value &&
337+
!props.running &&
338+
!props.sending &&
339+
precedingUser !== null &&
340+
precedingUser.id === lastUserTurnId.value &&
341+
!precedingUser.skillActivation &&
342+
assistantRunFinalText(index).trim().length > 0
343+
);
344+
}
345+
305346
// One shared timer: copying B within 1.4s of copying A must not let A's stale
306347
// timer hide B's checkmark early. Cleared on unmount.
307348
let copiedTimer: ReturnType<typeof setTimeout> | null = null;
@@ -320,6 +361,18 @@ function copyAssistantRun(index: number): void {
320361
}).catch(() => {/* ignore */});
321362
}
322363
364+
function copyUserTurn(turn: ChatTurn): void {
365+
if (turn.skillActivation) return;
366+
navigator.clipboard.writeText(turn.text).then(() => {
367+
copiedTurn.value = turn.id;
368+
if (copiedTimer !== null) clearTimeout(copiedTimer);
369+
copiedTimer = setTimeout(() => {
370+
copiedTimer = null;
371+
copiedTurn.value = null;
372+
}, 1400);
373+
}).catch(() => {/* ignore */});
374+
}
375+
323376
// Ordered render blocks for an assistant turn. messagesToTurns supplies `blocks`
324377
// (thinking + text + tool cards in call order); fall back to deriving them from
325378
// the aggregate fields for any turn built without blocks (e.g. unit tests).
@@ -473,7 +526,24 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string {
473526
<!-- User input renders verbatim (pre-wrap), never through Markdown -->
474527
<div v-else class="u-text">{{ turn.text }}</div>
475528
</div>
476-
<div v-if="turn.createdAt || canEditTurn(turn)" class="u-meta">
529+
<div v-if="turn.createdAt || canEditTurn(turn) || !turn.skillActivation" class="u-meta">
530+
<button
531+
v-if="!turn.skillActivation"
532+
type="button"
533+
class="a-cpbtn user-cpbtn"
534+
:aria-label="t('filePreview.copy')"
535+
:data-user-turn-id="turn.id"
536+
@click.stop="copyUserTurn(turn)"
537+
>
538+
<svg v-if="copiedTurn !== turn.id" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
539+
<rect x="3" y="3" width="9" height="9" rx="1.5"/>
540+
<path d="M6 1h7a1 1 0 0 1 1 1v7"/>
541+
</svg>
542+
<svg v-else viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
543+
<polyline points="3,8 6.5,11.5 13,5"/>
544+
</svg>
545+
<span class="a-cpbtn-text">{{ t('filePreview.copy') }}</span>
546+
</button>
477547
<div v-if="canEditTurn(turn)" class="u-edit-wrap" :class="{ undoing: undoingTurnId === turn.id }">
478548
<button
479549
v-if="confirmingEditTurnId !== turn.id"
@@ -563,6 +633,29 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string {
563633
</svg>
564634
<span class="a-cpbtn-text">{{ t('filePreview.copy') }}</span>
565635
</button>
636+
<button
637+
v-if="canRetryAssistantRun(ti) && confirmingRetryTurnId !== turn.id"
638+
type="button"
639+
class="a-cpbtn retry-btn"
640+
:aria-label="t('conversation.retry')"
641+
tabindex="-1"
642+
@click="confirmingRetryTurnId = turn.id"
643+
>
644+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
645+
<path d="M3 7a5 5 0 1 1 1.5 3.6"/>
646+
<path d="M3 3.5V7h3.5"/>
647+
</svg>
648+
<span class="a-cpbtn-text">{{ t('conversation.retry') }}</span>
649+
</button>
650+
<div v-else-if="canRetryAssistantRun(ti)" class="u-edit-confirm retry-confirm" @click.stop>
651+
<span>{{ t('conversation.retryConfirm') }}</span>
652+
<button type="button" class="u-edit-confirm-btn confirm" @click.stop="confirmRegenerate">
653+
{{ t('conversation.confirm') }}
654+
</button>
655+
<button type="button" class="u-edit-confirm-btn" @click.stop="confirmingRetryTurnId = null">
656+
{{ t('conversation.cancel') }}
657+
</button>
658+
</div>
566659
</div>
567660
</div>
568661
</template>
@@ -631,6 +724,17 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string {
631724
<span class="who"> &gt; </span>
632725
</template>
633726

727+
<button v-if="turn.role === 'user' && !turn.skillActivation" class="cpbtn user-cpbtn" :aria-label="t('filePreview.copy')" :data-user-turn-id="turn.id" @click="copyUserTurn(turn)" tabindex="-1">
728+
<svg v-if="copiedTurn !== turn.id" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
729+
<rect x="3" y="3" width="9" height="9" rx="1.5"/>
730+
<path d="M6 1h7a1 1 0 0 1 1 1v7"/>
731+
</svg>
732+
<svg v-else viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
733+
<polyline points="3,8 6.5,11.5 13,5"/>
734+
</svg>
735+
<span class="cpbtn-text">{{ t('filePreview.copy') }}</span>
736+
</button>
737+
634738
<!-- Per-message copy button (always visible, only when turn is complete) -->
635739
<button v-if="turn.id !== streamingTurnId && isAssistantRunEnd(ti) && assistantRunFinalText(ti).trim().length > 0" class="cpbtn" @click="copyAssistantRun(ti)" tabindex="-1">
636740
<svg v-if="copiedTurn !== turn.id" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -642,6 +746,28 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string {
642746
</svg>
643747
<span class="cpbtn-text">{{ t('filePreview.copy') }}</span>
644748
</button>
749+
<button
750+
v-if="canRetryAssistantRun(ti) && confirmingRetryTurnId !== turn.id"
751+
class="cpbtn retry-btn"
752+
:aria-label="t('conversation.retry')"
753+
@click="confirmingRetryTurnId = turn.id"
754+
tabindex="-1"
755+
>
756+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
757+
<path d="M3 7a5 5 0 1 1 1.5 3.6"/>
758+
<path d="M3 3.5V7h3.5"/>
759+
</svg>
760+
<span class="cpbtn-text">{{ t('conversation.retry') }}</span>
761+
</button>
762+
<div v-else-if="canRetryAssistantRun(ti)" class="u-edit-confirm retry-confirm" @click.stop>
763+
<span>{{ t('conversation.retryConfirm') }}</span>
764+
<button type="button" class="u-edit-confirm-btn confirm" @click.stop="confirmRegenerate">
765+
{{ t('conversation.confirm') }}
766+
</button>
767+
<button type="button" class="u-edit-confirm-btn" @click.stop="confirmingRetryTurnId = null">
768+
{{ t('conversation.cancel') }}
769+
</button>
770+
</div>
645771
<span v-if="turn.durationMs !== undefined && turn.role === 'assistant'" class="turn-duration" :title="`${turn.durationMs} ms`">{{ formatDuration(turn.durationMs) }}</span>
646772
</div>
647773

apps/pythinker-web/src/components/ConversationPane.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ const emit = defineEmits<{
104104
refreshGitStatus: [];
105105
/** Edit + resend the last user message (App undoes, then refills composer). */
106106
editMessage: [text: string];
107+
/** Undo the last exchange and send its user prompt again. */
108+
regenerate: [];
107109
/** Empty-composer workspace picker: start a new conversation elsewhere. */
108110
selectWorkspace: [workspaceId: string];
109111
/** Empty-composer workspace picker: create a new workspace. */
@@ -958,6 +960,7 @@ defineExpose({ loadComposerForEdit });
958960
@open-compaction="emit('openCompaction', $event)"
959961
@open-agent="emit('openAgent', $event)"
960962
@edit-message="emit('editMessage', $event)"
963+
@regenerate="emit('regenerate')"
961964
/>
962965
<div v-if="activeDynamicWorkflows.length > 0" class="dynamic-workflow-stack">
963966
<DynamicWorkflowCard v-for="group in activeDynamicWorkflows" :key="group.id" :group="group" />

apps/pythinker-web/src/i18n/locales/en/conversation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export default {
1818
undo: 'Undo',
1919
undoTooltip: 'Undoing the conversation will not roll back code changes',
2020
undoConfirm: 'Undo last message?',
21+
retry: 'Retry',
22+
retryConfirm: 'Retry last reply?',
2123
confirm: 'Confirm',
2224
cancel: 'Cancel',
2325
yesterday: 'Yesterday',

0 commit comments

Comments
 (0)