@@ -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)
168170const confirmingEditTurnId = ref <string | null >(null );
171+ const confirmingRetryTurnId = ref <string | null >(null );
169172const undoingTurnId = ref <string | null >(null );
170173let 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
205214const copiedConversation = ref (false );
206215let 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.
307348let 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" > > ; </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
0 commit comments