diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index bcecab05bc6..f9e247d1354 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -3,7 +3,7 @@ import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { MessageRow } from './NativeChatMessageRow' +import { MessageRow, type NativeChatDeliveryNotice } from './NativeChatMessageRow' afterEach(cleanup) @@ -163,8 +163,8 @@ describe('MessageRow send mode', () => { }) }) -describe('a user message that did not go through', () => { - function renderUser(deliveryNotice?: { text: string; onRetry?: () => void }) { +describe('what a user message says about its delivery', () => { + function renderUser(deliveryNotice?: NativeChatDeliveryNotice) { return render( { renderUser() expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() }) + + // Muted, in the time's place, and shown without hover: a message nothing confirmed yet never + // looks like one that went through. Copy keeps its hover reveal, and the row its height. + it('says quietly that it is still sending in place of its time, with no Retry', () => { + renderUser({ sending: true }) + + const sending = screen.getByText('Sending…') + const copy = screen.getByRole('button', { name: 'Copy message' }) + expect(sending).toHaveClass('text-xs', 'text-muted-foreground') + expect(Array.from(sending.parentElement!.children)).toEqual([copy, sending]) + expect(sending.parentElement).not.toHaveClass('can-hover:opacity-0') + expect(sending.parentElement!.parentElement).toHaveClass('group') + expect(copy).toHaveClass('can-hover:opacity-0', 'group-hover:opacity-100') + expect(screen.queryByRole('time')).toBeNull() + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() + }) + + it('keeps the same row when the message is confirmed, with the time back in its place', () => { + const { rerender } = renderUser({ sending: true }) + const meta = screen.getByText('Sending…').parentElement + rerender( + + ) + expect(screen.queryByText('Sending…')).toBeNull() + expect(screen.getByRole('time').parentElement).toBe(meta) + expect(meta).toHaveClass('can-hover:opacity-0') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 234934664f4..10c8a68de07 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -28,12 +28,45 @@ import type { } from './native-chat-subagent-sections' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' -/** What a user message says under it when it did not go through, with its own Retry when the - * surface can send it again. */ -export type NativeChatDeliveryNotice = { - text: string - onRetry?: () => void - onDismiss?: () => void +/** What a user message says about its delivery: that nothing has confirmed it yet, quietly in + * place of its time, or that it did not go through, with its own Retry when the surface can send + * it again. */ +export type NativeChatDeliveryNotice = + | { sending: true; text?: never; onRetry?: never; onDismiss?: never } + | { sending?: never; text: string; onRetry?: () => void; onDismiss?: () => void } + +const USER_META_REVEAL = + 'transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100' + +/** Under a user message: copy + timestamp, revealed together like the agent controls row. Until + * confirmed, a quiet "Sending…" stays visible in the time's place and copy keeps its own reveal, + * so the row keeps its height when it clears. Image-only prompts have no text to copy. */ +function UserMessageMeta({ + markdown, + timestamp, + sending +}: { + markdown: string + timestamp: number | null + sending: boolean +}): React.JSX.Element | null { + if (!markdown && timestamp === null && !sending) { + return null + } + return ( +
+ {markdown ? ( + + ) : null} + {sending ? ( + + {translate('components.native-chat.messageSending', 'Sending…')} + + ) : ( + + )} +
+ ) } /** One message: its prose first, then a collapsible run folding all of the @@ -178,15 +211,12 @@ export const MessageRow = memo(function MessageRow({ {translate('components.native-chat.goal.sentAsGoal', 'Sent as goal')} ) : null} - {/* Copy + timestamp reveal together, mirroring the agent controls row. - Image-only prompts have no text to copy, so the button is omitted. */} - {markdown || message.timestamp !== null ? ( -
- {markdown ? : null} - -
- ) : null} - {deliveryNotice ? ( + + {deliveryNotice?.text !== undefined ? (
{deliveryNotice.text} {deliveryNotice.onDismiss ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 6e39ec0129d..9ce0a000634 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -27,7 +27,8 @@ function absent(): T | undefined { return undefined } -/** Stands in for the transcript: renders only each message's delivery notice and its Retry. */ +/** Stands in for the transcript: renders only each message's delivery notice and its Retry, or the + * row's quiet "Sending…" while nothing has confirmed it. */ export function DeliveryNoticesMock({ notices }: { @@ -37,7 +38,7 @@ export function DeliveryNoticesMock({
{[...(notices ?? [])].map(([id, notice]) => (
- {notice.text} + {notice.sending ? 'Sending…' : notice.text} {notice.onRetry ? (