mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
c144148d20
* feat(desktop): edit a sent prompt and rerun from there (#1343) Hovering a prompt the rewind API can already target now offers "Edit and resend". The bubble turns into an inline editor; sending dry-runs the existing rewind, confirms when later turns or restorable files are at stake, rewinds with the same conversation/both modes as undo, reloads history and sends the edited prompt. A failed rewind changes nothing and keeps the draft; if the edit cannot be sent after a successful rewind it is handed back to the composer. Undo and edit share one rewind routine, the unused per-message rewindAction prop is removed, and TextArea forwards its ref. * fix(desktop): preserve edit-resend session and context
242 lines
9.2 KiB
TypeScript
242 lines
9.2 KiB
TypeScript
import { Button } from '@/components/ui/Button'
|
|
import { openSessionSource, sessionSourceTitle } from '@/lib/sessionNavigation'
|
|
import { memo, useCallback, useMemo } from 'react'
|
|
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
|
import { UsersRound } from 'lucide-react'
|
|
import type { UIAttachment } from '../../types/chat'
|
|
import { useTranslation } from '../../i18n'
|
|
import { openPreviewLink } from '../../lib/openPreviewLink'
|
|
import { splitTextByUrls } from '../../lib/urlBoundary'
|
|
import { AttachmentGallery } from './AttachmentGallery'
|
|
import { MessageActionBar, type MessageBranchAction, type MessageEditAction } from './MessageActionBar'
|
|
import { UserMessageEditor } from './UserMessageEditor'
|
|
import type { UserMessageEditDraft } from './userMessageEdit'
|
|
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
|
|
|
/**
|
|
* Edit-and-resend for one prompt. The draft lives with the caller, not in this
|
|
* component, so it survives the row being virtualized away and remounted.
|
|
*/
|
|
export type UserMessageEditAction = {
|
|
label: string
|
|
editing: boolean
|
|
submitting: boolean
|
|
disabled: boolean
|
|
getDraft: () => UserMessageEditDraft
|
|
onStart: () => void
|
|
onCancel: () => void
|
|
onDraftChange: (draft: UserMessageEditDraft) => void
|
|
onSubmit: (draft: UserMessageEditDraft) => void
|
|
}
|
|
|
|
type Props = {
|
|
content: string
|
|
sessionReferences?: Array<{ sessionId: string }>
|
|
/** Set when this message was delivered from another collaborating session. */
|
|
collaboration?: { sourceSessionId: string; messageId?: string }
|
|
attachments?: UIAttachment[]
|
|
branchAction?: MessageBranchAction
|
|
editAction?: UserMessageEditAction
|
|
timestamp?: number
|
|
sessionId?: string
|
|
/** Set when this turn came from another agent rather than from the user. */
|
|
teammateFrom?: string
|
|
teammateAvatarSrc?: string
|
|
teammateAvatarKey?: string
|
|
teammateAccent?: string
|
|
}
|
|
|
|
export const UserMessage = memo(function UserMessage({
|
|
content,
|
|
sessionReferences,
|
|
collaboration,
|
|
attachments,
|
|
branchAction,
|
|
editAction,
|
|
timestamp,
|
|
sessionId,
|
|
teammateFrom,
|
|
teammateAvatarSrc,
|
|
teammateAvatarKey,
|
|
teammateAccent,
|
|
}: Props) {
|
|
const t = useTranslation()
|
|
const hasText = content.trim().length > 0
|
|
const actionBarEditAction = useMemo<MessageEditAction | undefined>(
|
|
() => editAction
|
|
? { label: editAction.label, disabled: editAction.disabled, onEdit: editAction.onStart }
|
|
: undefined,
|
|
[editAction],
|
|
)
|
|
|
|
// The operator's prompt is literal text, NOT markdown — `**`, `#` and file
|
|
// paths have to stay exactly as typed. Teammate traffic is rendered separately
|
|
// below because agent-to-agent messages intentionally use Markdown.
|
|
const segments = useMemo(() => splitTextByUrls(content), [content])
|
|
|
|
const handleLinkClick = useCallback(
|
|
(href: string, event: ReactMouseEvent<HTMLElement>): boolean => {
|
|
if (!sessionId) return false
|
|
const handled = openPreviewLink(href, sessionId)
|
|
if (handled) event.preventDefault()
|
|
return handled
|
|
},
|
|
[sessionId],
|
|
)
|
|
|
|
const body: ReactNode = segments.map((segment, index) =>
|
|
segment.type === 'url' ? (
|
|
<a
|
|
key={index}
|
|
href={segment.value}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
className="text-[var(--color-text-accent)] underline decoration-[1px] underline-offset-[3px] decoration-[var(--color-text-accent)] [overflow-wrap:anywhere] hover:decoration-[2px]"
|
|
onClick={(event) => handleLinkClick(segment.value, event)}
|
|
>
|
|
{segment.value}
|
|
</a>
|
|
) : (
|
|
segment.value
|
|
),
|
|
)
|
|
|
|
// A teammate's instruction is not the user speaking, so it does not take the
|
|
// user's right-aligned bubble. Attributing and left-aligning it is what gives
|
|
// a member transcript the same read-at-a-glance structure the main session
|
|
// has: prompt right, everything the agents said left.
|
|
if (teammateFrom) {
|
|
return (
|
|
<div className="flex justify-start">
|
|
<div
|
|
data-message-shell="teammate"
|
|
data-teammate-from={teammateFrom}
|
|
className="group flex min-w-0 max-w-[82%] flex-col items-start sm:max-w-[78%] lg:max-w-[680px]"
|
|
>
|
|
<div className="mb-1 flex min-w-0 items-center gap-2 px-0.5 text-[11px] text-[var(--color-text-tertiary)]">
|
|
{teammateAvatarSrc ? (
|
|
<span
|
|
data-testid="teammate-message-avatar"
|
|
data-avatar-key={teammateAvatarKey}
|
|
aria-hidden="true"
|
|
className="relative h-8 w-7 shrink-0"
|
|
>
|
|
<img
|
|
src={teammateAvatarSrc}
|
|
alt=""
|
|
draggable={false}
|
|
className="h-full w-full select-none object-contain drop-shadow-[0_2px_2px_rgba(0,0,0,0.14)]"
|
|
/>
|
|
<span
|
|
className="absolute bottom-0 left-1/2 h-1 w-4 -translate-x-1/2 rounded-full border border-[var(--color-surface)]"
|
|
style={{ background: teammateAccent }}
|
|
/>
|
|
</span>
|
|
) : (
|
|
<UsersRound size={12} strokeWidth={2.2} aria-hidden="true" className="shrink-0 text-[var(--color-brand)]" />
|
|
)}
|
|
<span className="min-w-0 truncate font-mono font-bold text-[var(--color-text-secondary)]">
|
|
{teammateFrom}
|
|
</span>
|
|
<span className="shrink-0">{t('chat.teammateMessage')}</span>
|
|
</div>
|
|
|
|
<div className="flex max-w-full flex-col items-start gap-2">
|
|
{attachments && attachments.length > 0 && (
|
|
<AttachmentGallery attachments={attachments} variant="message" />
|
|
)}
|
|
{hasText && (
|
|
<div
|
|
data-message-body="teammate"
|
|
className="min-w-0 max-w-full rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-[16px] py-[12px] chat-reading-text leading-relaxed text-[var(--color-text-primary)]"
|
|
style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
|
|
>
|
|
<MarkdownRenderer
|
|
content={content}
|
|
onLinkClick={sessionId ? handleLinkClick : undefined}
|
|
className="chat-reading-markdown [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_h4]:text-sm"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{hasText && (
|
|
<MessageActionBar
|
|
copyText={content}
|
|
copyLabel={t('chat.copyPrompt')}
|
|
align="start"
|
|
timestamp={timestamp}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (editAction?.editing) {
|
|
return (
|
|
<div className="flex justify-end">
|
|
<div
|
|
data-message-shell="user"
|
|
data-editing="true"
|
|
className="flex w-full min-w-0 max-w-[82%] flex-col items-stretch sm:max-w-[78%] lg:max-w-[640px]"
|
|
>
|
|
<UserMessageEditor
|
|
initialDraft={editAction.getDraft()}
|
|
submitting={editAction.submitting}
|
|
disabled={editAction.disabled}
|
|
onDraftChange={editAction.onDraftChange}
|
|
onCancel={editAction.onCancel}
|
|
onSubmit={editAction.onSubmit}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex justify-end">
|
|
<div
|
|
data-message-shell="user"
|
|
className="group flex min-w-0 max-w-[82%] flex-col items-end sm:max-w-[78%] lg:max-w-[640px]"
|
|
>
|
|
<div className="flex max-w-full flex-col items-end gap-2">
|
|
{collaboration ? <div className="px-0.5 text-[11px] text-[var(--color-text-tertiary)]">
|
|
<Button size="sm" variant="ghost" onClick={() => openSessionSource(collaboration.sourceSessionId)}>{t('chat.collaborationMessageFrom', { id: sessionSourceTitle(collaboration.sourceSessionId) })}</Button>
|
|
</div> : null}
|
|
{sessionReferences?.length ? <div className="flex max-w-full flex-wrap gap-1" aria-label={t('chat.referenceSessions')}>
|
|
{sessionReferences.map(reference => <Button key={reference.sessionId} size="sm" variant="ghost" onClick={() => openSessionSource(reference.sessionId)}>{t('chat.openReferencedSession', { id: sessionSourceTitle(reference.sessionId) })}</Button>)}
|
|
</div> : null}
|
|
{attachments && attachments.length > 0 && (
|
|
<AttachmentGallery attachments={attachments} variant="message" />
|
|
)}
|
|
|
|
{hasText && (
|
|
<div
|
|
data-message-body="user"
|
|
className="min-w-0 max-w-full rounded-[var(--radius-lg)] bg-[var(--color-surface-user-msg)] px-[18px] py-[13px] chat-reading-text leading-relaxed text-[var(--color-text-primary)] whitespace-pre-wrap break-words"
|
|
style={{
|
|
overflowWrap: 'anywhere',
|
|
wordBreak: 'break-word',
|
|
}}
|
|
>
|
|
{body}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{(hasText || actionBarEditAction) && (
|
|
<MessageActionBar
|
|
copyText={content}
|
|
copyLabel={t('chat.copyPrompt')}
|
|
branchAction={branchAction}
|
|
editAction={actionBarEditAction}
|
|
align="end"
|
|
timestamp={timestamp}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|