diff --git a/apps/desktop/src/main/pi-chat-engine.ts b/apps/desktop/src/main/pi-chat-engine.ts index f194b881..9858788c 100644 --- a/apps/desktop/src/main/pi-chat-engine.ts +++ b/apps/desktop/src/main/pi-chat-engine.ts @@ -1772,12 +1772,18 @@ export function registerPiChatHandlers({ serviceOverride?: string, attachments?: PreparedChatAttachment[], peerOverride?: string, - ): Promise<{ ok: boolean; error?: string; stopReason?: ChatStreamStopReason }> => { + options?: { branchBeforeLastUserMessage?: boolean }, + ): Promise<{ ok: boolean; error?: string; stopReason?: ChatStreamStopReason; editBranchPrepared?: boolean }> => { const trimmedMessage = userMessage.trim(); + const includeEditBranchState = Boolean(options?.branchBeforeLastUserMessage); + let editBranchPrepared = false; + const withEditBranchState = (result: T): T & { editBranchPrepared?: boolean } => ( + includeEditBranchState ? { ...result, editBranchPrepared } : result + ); const attachmentPromptText = buildAttachmentPromptText(attachments); const attachmentImages = extractAttachmentImages(attachments); if (trimmedMessage.length === 0 && attachmentPromptText.length === 0 && attachmentImages.length === 0) { - return { ok: false, error: 'Empty message' }; + return withEditBranchState({ ok: false, error: 'Empty message' }); } const existingRun = activeRunsByConversation.get(conversationId); @@ -1810,15 +1816,31 @@ export function registerPiChatHandlers({ } } if (!proxyAvailable) { - return { + return withEditBranchState({ ok: false, error: `Buyer proxy is not reachable on port ${proxyPort}. Start Buyer runtime or fix buyer.proxyPort in config.`, - }; + }); } const sessionManager = await store.openSessionManager(conversationId); if (!sessionManager) { - return { ok: false, error: 'Conversation not found' }; + return withEditBranchState({ 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 withEditBranchState({ ok: false, error: 'No user message found to edit' }); + } + if (lastUserEntry.parentId) { + sessionManager.branch(lastUserEntry.parentId); + } else { + sessionManager.resetLeaf(); + } + editBranchPrepared = true; } const context = sessionManager.buildSessionContext(); @@ -2280,7 +2302,7 @@ export function registerPiChatHandlers({ } pendingAssistantMessage = null; const reason = emitPaymentRequiredStreamError(conversationId, amt); - return { ok: false, error: 'Payment required', stopReason: reason }; + return withEditBranchState({ ok: false, error: 'Payment required', stopReason: reason }); } } @@ -2315,7 +2337,7 @@ export function registerPiChatHandlers({ appendSystemLog(`Conversation title generation failed: ${asErrorMessage(error)}`); } } - return { ok: true }; + return withEditBranchState({ ok: true }); } catch (error) { // Always discard any buffered assistant message on error โ€” it will not be committed. pendingAssistantMessage = null; @@ -2330,7 +2352,7 @@ export function registerPiChatHandlers({ error: 'Request aborted', stopReason: reason, }); - return { ok: false, error: 'Aborted', stopReason: reason }; + return withEditBranchState({ ok: false, error: 'Aborted', stopReason: reason }); } const message = asErrorMessage(error); // Map insufficient balance / 402 errors to payment_required format @@ -2356,7 +2378,7 @@ export function registerPiChatHandlers({ }); } const reason = emitPaymentRequiredStreamError(conversationId, amt); - return { ok: false, error: message, stopReason: reason }; + return withEditBranchState({ ok: false, error: message, stopReason: reason }); } else { const reason = classifyChatStreamFailure({ error, @@ -2369,7 +2391,7 @@ export function registerPiChatHandlers({ stopReason: reason, }); appendSystemLog(`Pi chat error: ${formatChatStreamStopForLog(reason)}`); - return { ok: false, error: message, stopReason: reason }; + return withEditBranchState({ ok: false, error: message, stopReason: reason }); } } finally { clearActiveRun(run); @@ -2724,6 +2746,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 9eea16b4..de27bdf1 100644 --- a/apps/desktop/src/main/preload.cts +++ b/apps/desktop/src/main/preload.cts @@ -125,6 +125,13 @@ type ChatAiStreamStopReason = { errorCode?: string; }; +type ChatAiSendResult = { + ok: boolean; + error?: string; + stopReason?: ChatAiStreamStopReason; + editBranchPrepared?: boolean; +}; + const api = { // Synchronous platform info from the Node side of the preload. Renderer // code can use this without a round-trip to the main process โ€” useful for @@ -233,9 +240,12 @@ const api = { chatAiSend(conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string): Promise<{ ok: boolean; error?: string }> { return ipcRenderer.invoke('chat:ai-send', conversationId, message, service, provider, attachments, peerId); }, - chatAiSendStream(conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string): Promise<{ ok: boolean; error?: string; stopReason?: ChatAiStreamStopReason }> { + chatAiSendStream(conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string): Promise { 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 { + 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 c4bc0055..a3160a46 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.peer-routing.test.ts b/apps/desktop/src/renderer/modules/chat.peer-routing.test.ts index a9c89ec9..e9cf5691 100644 --- a/apps/desktop/src/renderer/modules/chat.peer-routing.test.ts +++ b/apps/desktop/src/renderer/modules/chat.peer-routing.test.ts @@ -937,3 +937,141 @@ test('switching service mid-conversation routes the next send to the new model', } await waitFor(() => uiState.chatSendingConversationIds.length === 0); }); + +test('edit regenerate retries a stuck in-flight request without branching twice', async () => { + installDomTimers(); + + const uiState = createInitialUiState(); + uiState.chatActiveConversation = 'conv-edit'; + uiState.chatConversations = [{ + id: 'conv-edit', + title: 'Edit', + service: 'model-a', + provider: 'openai', + peerId: 'peer-a', + messageCount: 1, + createdAt: Date.now(), + updatedAt: Date.now(), + usage: { inputTokens: 0, outputTokens: 0 }, + totalTokens: 0, + totalEstimatedCostUsd: 0, + }]; + uiState.chatMessages = [{ role: 'user', content: 'original', createdAt: 1 }]; + + let editCalls = 0; + let streamCalls = 0; + let abortCalls = 0; + const bridge: DesktopBridge = { + chatAiEditLastUserMessage: async () => { + editCalls += 1; + if (editCalls === 1) { + return { ok: false, error: 'Request already in progress', editBranchPrepared: false }; + } + return { ok: true, editBranchPrepared: true }; + }, + chatAiSendStream: async () => { + streamCalls += 1; + return { ok: true }; + }, + chatAiAbort: async () => { + abortCalls += 1; + return { ok: true }; + }, + }; + + const api = initChatModule({ + bridge, + uiState, + appendSystemLog: () => undefined, + }); + + api.editLastUserMessage('conv-edit', 'edited'); + await waitFor(() => editCalls === 2); + + assert.equal(abortCalls, 1); + assert.equal(editCalls, 2); + assert.equal(streamCalls, 0); +}); + +test('manual payment retry after edit regenerate reuses edit retry context', async () => { + installDomTimers(); + + const uiState = createInitialUiState(); + uiState.chatActiveConversation = 'conv-pay-edit'; + uiState.chatConversations = [{ + id: 'conv-pay-edit', + title: 'Payment edit', + service: 'model-a', + provider: 'openai', + peerId: 'peer-a', + messageCount: 1, + createdAt: Date.now(), + updatedAt: Date.now(), + usage: { inputTokens: 0, outputTokens: 0 }, + totalTokens: 0, + totalEstimatedCostUsd: 0, + }]; + + const attachment = { + id: 'att-1', + attachmentId: 'disk-1', + name: 'note.txt', + mimeType: 'text/plain', + size: 4, + kind: 'text' as const, + status: 'ready' as const, + text: 'note', + }; + uiState.chatMessages = [{ + role: 'user', + content: [ + { type: 'text', text: 'original' }, + { type: 'file', fileName: 'note.txt', mimeType: 'text/plain', attachment }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'render-only' } }, + ], + createdAt: 1, + }]; + + let editCalls = 0; + const streamSends: Array<{ message: string; attachmentIds: string[] }> = []; + const bridge: DesktopBridge = { + creditsGetInfo: async () => ({ + ok: true, + data: { + evmAddress: null, + operatorAddress: null, + balanceUsdc: '0', + reservedUsdc: '0', + availableUsdc: '0', + creditLimitUsdc: '0', + }, + error: null, + }), + chatAiEditLastUserMessage: async () => { + editCalls += 1; + return { ok: false, error: 'payment_required:1000000', editBranchPrepared: true }; + }, + chatAiSendStream: async (_conversationId, message, _service, _provider, attachments) => { + streamSends.push({ + message, + attachmentIds: (attachments ?? []).map((item) => item.id), + }); + return { ok: true }; + }, + }; + + const api = initChatModule({ + bridge, + uiState, + appendSystemLog: () => undefined, + }); + + api.editLastUserMessage('conv-pay-edit', 'edited'); + await waitFor(() => uiState.chatPaymentApprovalVisible); + + api.retryAfterPayment(); + await waitFor(() => streamSends.length === 1); + + assert.equal(editCalls, 1); + assert.deepEqual(streamSends, [{ message: 'edited', attachmentIds: ['att-1'] }]); +}); diff --git a/apps/desktop/src/renderer/modules/chat.ts b/apps/desktop/src/renderer/modules/chat.ts index 5e153383..483173d0 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,21 +239,36 @@ export function initChatModule({ } } + type ChatRequestOptions = { + editLastUserMessage?: boolean; + /** + * Edit-regenerate branches the persisted session before sending the edited + * user message. Once that branch is prepared, retries must use the normal + * send endpoint so they don't branch one parent farther back. + */ + editBranchPrepared?: boolean; + }; + type ChatRetryContext = { convId: string; content?: string; attachments?: PreparedChatAttachment[]; selection?: ChatServiceSelection; + options?: ChatRequestOptions; }; const chatRetryTimers = new Map>(); const chatRetryAttempts = new Map(); + const paymentRetryContexts = new Map(); + const activeRequestContexts = new Map(); function clearPaymentRetry(convId: string): void { const timer = chatRetryTimers.get(convId); if (timer) clearTimeout(timer); chatRetryTimers.delete(convId); chatRetryAttempts.delete(convId); + paymentRetryContexts.delete(convId); + activeRequestContexts.delete(convId); } function scheduleChatRetry( @@ -287,7 +303,11 @@ 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 (paymentRetryContexts.has(convId)) { + const retryCtx = paymentRetryContexts.get(convId)!; + setConversationSending(convId, true); + dispatchChatRequest(convId, retryCtx.content ?? ' ', retryCtx.attachments, retryCtx.selection, retryCtx.options); } else if (convId === uiState.chatActiveConversation) { retryAfterPayment(); } else { @@ -317,6 +337,9 @@ export function initChatModule({ return; } + if (retryCtx) { + paymentRetryContexts.set(retryCtx.convId, retryCtx); + } showPaymentApprovalCard(amountBaseUnits); } @@ -1941,6 +1964,84 @@ export function initChatModule({ sendMessageToConversation(uiState.chatActiveConversation, text, rawAttachments); } + function extractPreparedAttachmentsFromContent(content: unknown): PreparedChatAttachment[] { + // `file.attachment` is the canonical prepared attachment reference used + // for edit/regenerate retries. Image blocks may carry renderer/model + // payloads, but they are not durable attachment sources. + 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,11 +2128,13 @@ export function initChatModule({ content: string, attachments?: PreparedChatAttachment[], selectionOverride?: ChatServiceSelection, + options?: ChatRequestOptions, ): void { if (!bridge) return; const selection = selectionOverride ?? getConversationServiceSelection(convId); const requestStartedAt = Date.now(); + activeRequestContexts.set(convId, { convId, content, attachments, selection, options }); streamCompletedAtByConversation.delete(convId); streamFailedAtByConversation.delete(convId); @@ -2041,9 +2144,38 @@ export function initChatModule({ return; } - if (bridge.chatAiSendStream) { - const sendStreamRequest = async () => - await bridge.chatAiSendStream!( + if (options?.editLastUserMessage && !options.editBranchPrepared && !bridge.chatAiEditLastUserMessage) { + reportChatError('Editing messages is not available in this build.', 'Request failed'); + setConversationSending(convId, false); + return; + } + if (options?.editBranchPrepared && !bridge.chatAiSendStream) { + reportChatError('Streaming chat is not available in this build.', 'Request failed'); + setConversationSending(convId, false); + return; + } + + if (bridge.chatAiSendStream || options?.editLastUserMessage) { + let editBranchPrepared = Boolean(options?.editBranchPrepared); + const getRetryOptions = (): ChatRequestOptions | undefined => ( + options?.editLastUserMessage + ? { ...options, editBranchPrepared } + : options + ); + const sendStreamRequest = async () => { + if (options?.editLastUserMessage && !editBranchPrepared) { + const result = await bridge.chatAiEditLastUserMessage!( + convId, + content || ' ', + selection.id || undefined, + selection.provider ?? undefined, + attachments, + selection.peerId, + ); + editBranchPrepared = Boolean(result.editBranchPrepared); + return result; + } + return await bridge.chatAiSendStream!( convId, content || ' ', selection.id || undefined, @@ -2051,6 +2183,7 @@ export function initChatModule({ attachments, selection.peerId, ); + }; void (async () => { try { @@ -2086,6 +2219,7 @@ export function initChatModule({ content, attachments, selection, + options: getRetryOptions(), }); } else if (errorMsg === 'Request aborted') { // User-initiated abort: the stream-error handler has already @@ -2097,7 +2231,7 @@ export function initChatModule({ setConversationSending(convId, false); } else { scheduleChatRetry( - { convId, content, attachments, selection }, + { convId, content, attachments, selection, options: getRetryOptions() }, 'request', result.stopReason?.message ?? result.error, ); @@ -2114,7 +2248,7 @@ export function initChatModule({ return; } - scheduleChatRetry({ convId, content, attachments, selection }, 'request', err); + scheduleChatRetry({ convId, content, attachments, selection, options: getRetryOptions() }, 'request', err); setConversationSending(convId, false); } })(); @@ -2153,19 +2287,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); } })(); @@ -2191,10 +2326,26 @@ export function initChatModule({ uiState.chatError = null; notifyUiStateChanged(); + const convId = uiState.chatActiveConversation; + if (!convId) return; + + const preservedRetryCtx = paymentRetryContexts.get(convId); + if (preservedRetryCtx?.content != null) { + setConversationSending(convId, true); + dispatchChatRequest( + convId, + preservedRetryCtx.content, + preservedRetryCtx.attachments, + preservedRetryCtx.selection, + preservedRetryCtx.options, + ); + return; + } + // Find the last user message to resend type MsgShape = { role?: string; content?: unknown }; const lastUserMsg = ([...uiState.chatMessages] as MsgShape[]).reverse().find(m => m.role === 'user'); - if (!lastUserMsg || !uiState.chatActiveConversation) return; + if (!lastUserMsg) return; const content = typeof lastUserMsg.content === 'string' ? lastUserMsg.content @@ -2206,16 +2357,8 @@ 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); dispatchChatRequest(convId, content, attachments.length > 0 ? attachments : undefined); } @@ -2356,11 +2499,18 @@ export function initChatModule({ const errStr = typeof data.error === 'string' ? data.error : ''; const paymentMatch = /^payment_required:(\d+)$/i.exec(errStr); if (paymentMatch) { - void handlePaymentRequired(paymentMatch[1], { convId: data.conversationId }); + void handlePaymentRequired( + paymentMatch[1], + activeRequestContexts.get(data.conversationId) ?? { convId: data.conversationId }, + ); } else if (data.error === 'Request aborted') { clearPaymentRetry(data.conversationId); } else { - scheduleChatRetry({ convId: data.conversationId }, 'request', data.error); + scheduleChatRetry( + activeRequestContexts.get(data.conversationId) ?? { convId: data.conversationId }, + 'request', + data.error, + ); appendSystemLog(`AI Chat error: ${data.error}`); } } @@ -2827,14 +2977,17 @@ export function initChatModule({ const errStr = typeof data.error === 'string' ? data.error : ''; const paymentMatch = /^payment_required:(\d+)$/i.exec(errStr); if (paymentMatch) { - void handlePaymentRequired(paymentMatch[1], { convId: data.conversationId }); + void handlePaymentRequired( + paymentMatch[1], + activeRequestContexts.get(data.conversationId) ?? { convId: data.conversationId }, + ); if (bridge.chatAiAbort) void bridge.chatAiAbort(data.conversationId).catch(() => {}); } else if (stopReason?.retryable === false) { clearPaymentRetry(data.conversationId); reportChatError(stopReason.message || data.error, 'Request failed'); } else { scheduleChatRetry( - { convId: data.conversationId }, + activeRequestContexts.get(data.conversationId) ?? { convId: data.conversationId }, 'request', stopReason?.message ?? data.error, ); @@ -2903,6 +3056,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 54aa1962..235accee 100644 --- a/apps/desktop/src/renderer/types/bridge.ts +++ b/apps/desktop/src/renderer/types/bridge.ts @@ -99,6 +99,13 @@ export type ChatAiStreamStopReason = { errorCode?: string; }; +export type ChatAiSendResult = { + ok: boolean; + error?: string; + stopReason?: ChatAiStreamStopReason; + editBranchPrepared?: boolean; +}; + export type RawChatAttachment = { id: string; name: string; @@ -172,7 +179,8 @@ export type DesktopBridge = { chatPrepareAttachments?: (conversationId: string, attachments: RawChatAttachment[]) => Promise<{ ok: boolean; data?: PreparedChatAttachment[]; error?: string }>; 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 }>; + chatAiSendStream?: (conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string) => Promise; + chatAiEditLastUserMessage?: (conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string) => Promise; 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 0e391832..3ea7f16d 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 5650d5fc..54d4bd9b 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; @@ -845,6 +851,77 @@ 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); + } +} + +.inlineEditAttachments { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 2px; +} + +.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 85f5f18f..2c5e87b9 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'; @@ -817,19 +817,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); @@ -857,7 +875,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( @@ -927,18 +966,73 @@ export function ChatBubble({ message, streaming = false, onOpenPreview, conversa return
{JSON.stringify(message.content)}
; }, [message, isStreamingBubble, messagePrefix, onOpenPreview, conversationId, searchQuery, searchActive]); + const editAttachmentContent = useMemo(() => { + if (!isEditing || !Array.isArray(message.content)) return null; + const attachmentBlocks = (message.content as ContentBlock[]) + .filter((block) => block.type !== 'text' && block.type !== 'thinking'); + if (attachmentBlocks.length === 0) return null; + return attachmentBlocks.map((block, index) => renderBlock( + block, + index, + isStreamingBubble, + `${messagePrefix}:edit-attachments`, + conversationId, + searchQuery, + searchActive, + )); + }, [conversationId, isEditing, isStreamingBubble, message.content, messagePrefix, searchActive, searchQuery]); + const bubbleMeta = metaParts.length > 0 && !isStreamingBubble ? ( {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 ? ( +
+