From 3db2e9db34f71aa2b71aa9d6aa669a72079f3b9e Mon Sep 17 00:00:00 2001 From: sheferkagan Date: Sun, 17 May 2026 19:32:01 +0300 Subject: [PATCH 1/4] feat(desktop): add user message chat actions --- apps/desktop/src/main/pi-chat-engine.ts | 28 ++++ apps/desktop/src/main/preload.cts | 3 + apps/desktop/src/renderer/app.ts | 1 + apps/desktop/src/renderer/modules/chat.ts | 127 +++++++++++++++--- apps/desktop/src/renderer/types/bridge.ts | 1 + apps/desktop/src/renderer/ui/actions.ts | 1 + .../ui/components/chat/ChatBubble.module.scss | 70 ++++++++++ .../ui/components/chat/ChatBubble.tsx | 112 ++++++++++++--- .../ui/components/chat/chat-utils.tsx | 13 ++ .../renderer/ui/components/views/ChatView.tsx | 41 +++++- 10 files changed, 361 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/main/pi-chat-engine.ts b/apps/desktop/src/main/pi-chat-engine.ts index 50cc88eb5..deda382cf 100644 --- a/apps/desktop/src/main/pi-chat-engine.ts +++ b/apps/desktop/src/main/pi-chat-engine.ts @@ -1773,6 +1773,7 @@ export function registerPiChatHandlers({ serviceOverride?: string, attachments?: PreparedChatAttachment[], peerOverride?: string, + options?: { branchBeforeLastUserMessage?: boolean }, ): Promise<{ ok: boolean; error?: string; stopReason?: ChatStreamStopReason }> => { const trimmedMessage = userMessage.trim(); const attachmentPromptText = buildAttachmentPromptText(attachments); @@ -1822,6 +1823,21 @@ export function registerPiChatHandlers({ return { ok: false, error: 'Conversation not found' }; } + if (options?.branchBeforeLastUserMessage) { + const branch = sessionManager.getBranch(); + const lastUserEntry = [...branch] + .reverse() + .find((entry) => entry.type === 'message' && entry.message?.role === 'user'); + if (!lastUserEntry) { + return { ok: false, error: 'No user message found to edit' }; + } + if (lastUserEntry.parentId) { + sessionManager.branch(lastUserEntry.parentId); + } else { + sessionManager.resetLeaf(); + } + } + const context = sessionManager.buildSessionContext(); const serviceId = normalizeServiceId(serviceOverride || context.model?.modelId); @@ -2725,6 +2741,18 @@ export function registerPiChatHandlers({ }, ); + ipcMain.handle( + 'chat:ai-edit-last-user-message', + async (_event, conversationId: string, userMessage: string, service?: string, _provider?: string, attachments?: PreparedChatAttachment[], peerId?: string) => { + // `_provider` is accepted for IPC ABI compatibility with normal sends + // but ignored — the buyer proxy resolves the route plan from the pinned + // peer + service ID without a provider hint. + return await runStreamingPrompt(conversationId, userMessage, service, attachments, peerId, { + branchBeforeLastUserMessage: true, + }); + }, + ); + ipcMain.handle('chat:ai-abort', async (_event, conversationId?: string) => { const trimmedConversationId = typeof conversationId === 'string' ? conversationId.trim() : ''; const activeRuns = trimmedConversationId diff --git a/apps/desktop/src/main/preload.cts b/apps/desktop/src/main/preload.cts index 6ba42e47b..695acb585 100644 --- a/apps/desktop/src/main/preload.cts +++ b/apps/desktop/src/main/preload.cts @@ -236,6 +236,9 @@ const api = { chatAiSendStream(conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string): Promise<{ ok: boolean; error?: string; stopReason?: ChatAiStreamStopReason }> { return ipcRenderer.invoke('chat:ai-send-stream', conversationId, message, service, provider, attachments, peerId); }, + chatAiEditLastUserMessage(conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string): Promise<{ ok: boolean; error?: string; stopReason?: ChatAiStreamStopReason }> { + return ipcRenderer.invoke('chat:ai-edit-last-user-message', conversationId, message, service, provider, attachments, peerId); + }, chatAiAbort(conversationId?: string): Promise<{ ok: boolean }> { return ipcRenderer.invoke('chat:ai-abort', conversationId); }, diff --git a/apps/desktop/src/renderer/app.ts b/apps/desktop/src/renderer/app.ts index c4bc00557..a3160a46d 100644 --- a/apps/desktop/src/renderer/app.ts +++ b/apps/desktop/src/renderer/app.ts @@ -442,6 +442,7 @@ registerActions({ openConversation: chatApi.openConversation, sendMessage: chatApi.sendMessage, sendMessageToConversation: chatApi.sendMessageToConversation, + editLastUserMessage: chatApi.editLastUserMessage, abortChat: chatApi.abortChat, deleteConversation: chatApi.deleteConversation, renameConversation: chatApi.renameConversation, diff --git a/apps/desktop/src/renderer/modules/chat.ts b/apps/desktop/src/renderer/modules/chat.ts index 5e1533837..20a462535 100644 --- a/apps/desktop/src/renderer/modules/chat.ts +++ b/apps/desktop/src/renderer/modules/chat.ts @@ -79,6 +79,7 @@ export type ChatModuleApi = { openConversation: (convId: string) => Promise; sendMessage: (text: string, attachments?: RawChatAttachment[]) => void; sendMessageToConversation: (convId: string, text: string, attachments?: RawChatAttachment[]) => void; + editLastUserMessage: (convId: string, text: string) => void; retryAfterPayment: () => void; abortChat: () => Promise; handleServiceChange: (value: string, explicitPeerId?: string) => void; @@ -238,11 +239,16 @@ export function initChatModule({ } } + type ChatRequestOptions = { + editLastUserMessage?: boolean; + }; + type ChatRetryContext = { convId: string; content?: string; attachments?: PreparedChatAttachment[]; selection?: ChatServiceSelection; + options?: ChatRequestOptions; }; const chatRetryTimers = new Map>(); @@ -287,7 +293,7 @@ export function initChatModule({ uiState.chatError = null; if (ctx?.content != null) { setConversationSending(convId, true); - dispatchChatRequest(convId, ctx.content, ctx.attachments, ctx.selection); + dispatchChatRequest(convId, ctx.content, ctx.attachments, ctx.selection, ctx.options); } else if (convId === uiState.chatActiveConversation) { retryAfterPayment(); } else { @@ -1941,6 +1947,81 @@ export function initChatModule({ sendMessageToConversation(uiState.chatActiveConversation, text, rawAttachments); } + function extractPreparedAttachmentsFromContent(content: unknown): PreparedChatAttachment[] { + const attachments: PreparedChatAttachment[] = []; + if (!Array.isArray(content)) return attachments; + for (const block of content as ContentBlock[]) { + if (block.type === 'file' && block.attachment && typeof block.attachment === 'object') { + attachments.push(block.attachment as PreparedChatAttachment); + } + } + return attachments; + } + + function editLastUserMessage(convId: string, text: string): void { + if (isConversationSending(convId)) { + showChatError('This conversation already has a request in progress.'); + return; + } + if (!bridge?.chatAiEditLastUserMessage) { + showChatError('Editing messages is not available in this build.'); + return; + } + + const content = text.trim(); + if (!content) return; + + const localMessages = getLocalConversationMessages(convId) ?? ( + convId === uiState.chatActiveConversation + ? (uiState.chatMessages as ChatMessage[]) + : [] + ); + let lastUserIndex = -1; + for (let index = localMessages.length - 1; index >= 0; index -= 1) { + if (localMessages[index]?.role === 'user') { + lastUserIndex = index; + break; + } + } + if (lastUserIndex < 0) { + showChatError('No user message found to edit.'); + return; + } + + uiState.chatError = null; + setConversationStreamingMessage(convId, null); + setConversationSending(convId, true); + + const originalUserMessage = localMessages[lastUserIndex]; + const attachments = extractPreparedAttachmentsFromContent(originalUserMessage?.content); + const nextMessages = [ + ...localMessages.slice(0, lastUserIndex), + { + role: 'user' as const, + content: buildUserMessageContent(content, attachments), + createdAt: originalUserMessage?.createdAt ?? Date.now(), + }, + ]; + setLocalConversationMessages(convId, nextMessages); + if (convId === uiState.chatActiveConversation) { + uiState.chatMessages = nextMessages; + } + if (activeConversation && activeConversation.id === convId) { + activeConversation.messages = nextMessages; + activeConversation.updatedAt = Date.now(); + updateThreadMeta(activeConversation); + } + notifyUiStateChanged(); + + dispatchChatRequest( + convId, + content, + attachments.length > 0 ? attachments : undefined, + undefined, + { editLastUserMessage: true }, + ); + } + function sendMessageToConversation( convId: string, text: string, @@ -2027,6 +2108,7 @@ export function initChatModule({ content: string, attachments?: PreparedChatAttachment[], selectionOverride?: ChatServiceSelection, + options?: ChatRequestOptions, ): void { if (!bridge) return; @@ -2041,9 +2123,25 @@ export function initChatModule({ return; } - if (bridge.chatAiSendStream) { - const sendStreamRequest = async () => - await bridge.chatAiSendStream!( + if (options?.editLastUserMessage && !bridge.chatAiEditLastUserMessage) { + reportChatError('Editing messages is not available in this build.', 'Request failed'); + setConversationSending(convId, false); + return; + } + + if (bridge.chatAiSendStream || options?.editLastUserMessage) { + const sendStreamRequest = async () => { + if (options?.editLastUserMessage) { + return await bridge.chatAiEditLastUserMessage!( + convId, + content || ' ', + selection.id || undefined, + selection.provider ?? undefined, + attachments, + selection.peerId, + ); + } + return await bridge.chatAiSendStream!( convId, content || ' ', selection.id || undefined, @@ -2051,6 +2149,7 @@ export function initChatModule({ attachments, selection.peerId, ); + }; void (async () => { try { @@ -2086,6 +2185,7 @@ export function initChatModule({ content, attachments, selection, + options, }); } else if (errorMsg === 'Request aborted') { // User-initiated abort: the stream-error handler has already @@ -2097,7 +2197,7 @@ export function initChatModule({ setConversationSending(convId, false); } else { scheduleChatRetry( - { convId, content, attachments, selection }, + { convId, content, attachments, selection, options }, 'request', result.stopReason?.message ?? result.error, ); @@ -2114,7 +2214,7 @@ export function initChatModule({ return; } - scheduleChatRetry({ convId, content, attachments, selection }, 'request', err); + scheduleChatRetry({ convId, content, attachments, selection, options }, 'request', err); setConversationSending(convId, false); } })(); @@ -2153,19 +2253,20 @@ export function initChatModule({ content, attachments, selection, + options, }); } else if (errorMsg === 'Request aborted') { // User-initiated abort: don't auto-retry. clearPaymentRetry(convId); } else { - scheduleChatRetry({ convId, content, attachments, selection }, 'request', result.error); + scheduleChatRetry({ convId, content, attachments, selection, options }, 'request', result.error); } } else { clearPaymentRetry(convId); } setConversationSending(convId, false); } catch (err) { - scheduleChatRetry({ convId, content, attachments, selection }, 'request', err); + scheduleChatRetry({ convId, content, attachments, selection, options }, 'request', err); setConversationSending(convId, false); } })(); @@ -2206,14 +2307,7 @@ export function initChatModule({ .join('\n\n') : ''; - const attachments: PreparedChatAttachment[] = []; - if (Array.isArray(lastUserMsg.content)) { - for (const block of lastUserMsg.content as ContentBlock[]) { - if (block.type === 'file' && block.attachment && typeof block.attachment === 'object') { - attachments.push(block.attachment as PreparedChatAttachment); - } - } - } + const attachments = extractPreparedAttachmentsFromContent(lastUserMsg.content); const convId = uiState.chatActiveConversation; setConversationSending(convId, true); @@ -2903,6 +2997,7 @@ export function initChatModule({ openConversation, sendMessage, sendMessageToConversation, + editLastUserMessage, retryAfterPayment, abortChat, handleServiceChange, diff --git a/apps/desktop/src/renderer/types/bridge.ts b/apps/desktop/src/renderer/types/bridge.ts index bfc3f9eb3..af84e2100 100644 --- a/apps/desktop/src/renderer/types/bridge.ts +++ b/apps/desktop/src/renderer/types/bridge.ts @@ -173,6 +173,7 @@ export type DesktopBridge = { attachmentDownload?: (conversationId: string, attachmentId: string, suggestedName: string) => Promise<{ ok: boolean; path?: string; error?: string }>; chatAiSend?: (conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string) => Promise<{ ok: boolean; error?: string }>; chatAiSendStream?: (conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string) => Promise<{ ok: boolean; error?: string; stopReason?: ChatAiStreamStopReason }>; + chatAiEditLastUserMessage?: (conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string) => Promise<{ ok: boolean; error?: string; stopReason?: ChatAiStreamStopReason }>; chatAiAbort?: (conversationId?: string) => Promise<{ ok: boolean }>; chatAiSelectPeer?: (payload: { conversationId?: string | null; peerId?: string | null }) => Promise<{ ok: boolean; error?: string }>; chatAiGetProxyStatus?: () => Promise<{ ok: boolean; data: { running: boolean; port: number } }>; diff --git a/apps/desktop/src/renderer/ui/actions.ts b/apps/desktop/src/renderer/ui/actions.ts index 0e3918322..3ea7f16d8 100644 --- a/apps/desktop/src/renderer/ui/actions.ts +++ b/apps/desktop/src/renderer/ui/actions.ts @@ -15,6 +15,7 @@ export type AppActions = { openConversation: (id: string) => Promise; sendMessage: (text: string, attachments?: RawChatAttachment[]) => void; sendMessageToConversation: (convId: string, text: string, attachments?: RawChatAttachment[]) => void; + editLastUserMessage: (convId: string, text: string) => void; abortChat: () => Promise; deleteConversation: (convId?: string) => Promise; renameConversation: (convId: string, newTitle: string) => void; diff --git a/apps/desktop/src/renderer/ui/components/chat/ChatBubble.module.scss b/apps/desktop/src/renderer/ui/components/chat/ChatBubble.module.scss index f0270bb84..6a0f0ca75 100644 --- a/apps/desktop/src/renderer/ui/components/chat/ChatBubble.module.scss +++ b/apps/desktop/src/renderer/ui/components/chat/ChatBubble.module.scss @@ -238,6 +238,12 @@ font-size: 11px; } + .messageActions { + justify-content: flex-end; + margin-left: 0; + margin-top: 4px; + } + :global { .chat-image-preview { max-width: 280px; @@ -773,6 +779,70 @@ button.fileAttachment { color: var(--accent-green, #1fd87a); } +.inlineEditWrap { + display: flex; + flex-direction: column; + gap: 8px; + min-width: min(520px, calc(100vw - 96px)); +} + +.inlineEditInput { + width: 100%; + max-height: 220px; + resize: vertical; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-card); + color: var(--text-primary); + font: inherit; + line-height: 1.5; + padding: 8px 10px; + outline: none; + + &:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); + } +} + +.inlineEditActions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.inlineEditCancelBtn, +.inlineEditSaveBtn { + appearance: none; + border-radius: 6px; + font: inherit; + font-size: 12px; + font-weight: 600; + padding: 5px 10px; + cursor: pointer; +} + +.inlineEditCancelBtn { + border: 1px solid var(--border); + background: transparent; + color: var(--text-secondary); + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } +} + +.inlineEditSaveBtn { + border: 1px solid var(--accent); + background: var(--accent); + color: var(--text-on-accent, #fff); + + &:hover { + filter: brightness(1.05); + } +} + /* =================================================================== Radix tooltip =================================================================== */ diff --git a/apps/desktop/src/renderer/ui/components/chat/ChatBubble.tsx b/apps/desktop/src/renderer/ui/components/chat/ChatBubble.tsx index 33901b8d5..c285dc28a 100644 --- a/apps/desktop/src/renderer/ui/components/chat/ChatBubble.tsx +++ b/apps/desktop/src/renderer/ui/components/chat/ChatBubble.tsx @@ -1,10 +1,10 @@ import { createPortal } from 'react-dom'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { HugeiconsIcon } from '@hugeicons/react'; -import { Copy01Icon, Tick02Icon, BrowserIcon } from '@hugeicons/core-free-icons'; +import { Copy01Icon, Tick02Icon, BrowserIcon, PencilEdit02Icon } from '@hugeicons/core-free-icons'; import * as Tooltip from '@radix-ui/react-tooltip'; -import type { ReactNode } from 'react'; -import { MarkdownContent } from './chat-utils.js'; +import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'; +import { extractPlainText, MarkdownContent } from './chat-utils.js'; import styles from './ChatBubble.module.scss'; import { AttachmentViewer, type ViewerAttachment } from './AttachmentViewer'; import type { ChatMessage, ContentBlock } from './chat-shared'; @@ -739,19 +739,37 @@ function formatFileSize(bytes: number): string { return `${(mb / 1024).toFixed(1)} GB`; } -function extractPlainText(content: unknown): string { - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return (content as ContentBlock[]) - .filter((block) => block.type === 'text' || block.type === 'thinking') - .map((block) => (block.type === 'thinking' ? String(block.thinking || '') : String(block.text || ''))) - .filter(Boolean) - .join('\n\n'); - } - return ''; +function EditMessageButton({ onClick }: { onClick: () => void }) { + return ( + + + + + + + + Edit + + + + + + ); } -function CopyResponseButton({ content }: { content: unknown }) { +function CopyMessageButton({ content, ariaLabel }: { content: unknown; ariaLabel: string }) { const [copied, setCopied] = useState(false); const timerRef = useRef | null>(null); @@ -779,7 +797,7 @@ function CopyResponseButton({ content }: { content: unknown }) { type="button" className={`${styles.copyResponseBtn}${copied ? ` ${styles.copyResponseBtnCopied}` : ''}`} onClick={handleCopy} - aria-label={copied ? 'Copied!' : 'Copy response'} + aria-label={copied ? 'Copied!' : ariaLabel} > void; + onEditValueChange?: (value: string) => void; + onSubmitEdit?: () => void; + onCancelEdit?: () => void; }; -export function ChatBubble({ message, streaming = false, onOpenPreview, conversationId, searchQuery, searchActive }: ChatBubbleProps) { +export function ChatBubble({ + message, + streaming = false, + onOpenPreview, + conversationId, + searchQuery, + searchActive, + canEdit = false, + isEditing = false, + editValue = '', + onEditMessage, + onEditValueChange, + onSubmitEdit, + onCancelEdit, +}: ChatBubbleProps) { const [metaExpanded, setMetaExpanded] = useState(false); const metaParts = useMemo(() => buildChatMetaParts(message), [message]); const hasStreamingBlocks = useMemo( @@ -854,13 +893,48 @@ export function ChatBubble({ message, streaming = false, onOpenPreview, conversa {metaParts.join(' · ')} ) : null; + const handleEditKeyDown = useCallback((event: ReactKeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onCancelEdit?.(); + return; + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + onSubmitEdit?.(); + } + }, [onCancelEdit, onSubmitEdit]); + return (
{bubbleMeta} -
{content}
- {message.role !== 'user' && !isStreamingBubble ? ( + {isEditing ? ( +
+