Skip to content

feat(desktop): add user message copy and edit actions#553

Closed
SheferKagan wants to merge 5 commits into
mainfrom
feat/issues-500-501-chat-message-actions
Closed

feat(desktop): add user message copy and edit actions#553
SheferKagan wants to merge 5 commits into
mainfrom
feat/issues-500-501-chat-message-actions

Conversation

@SheferKagan
Copy link
Copy Markdown
Collaborator

@SheferKagan SheferKagan commented May 17, 2026

Summary

  • add copy action to user chat messages while preserving assistant copy behavior
  • add inline edit for only the latest user message, with regenerate support
  • preserve attachments and normal send/payment/retry routing when regenerating from an edit
  • refactor copy text extraction into extractPlainText

Closes #500
Closes #501

Main-process / session behavior changed

  • Edit-regenerate routes through the same main-process runStreamingPrompt path as normal sends.
  • The IPC shape intentionally remains ABI-compatible with normal send arguments, but edit-regenerate requests prepare the branch once and retries continue through the normal stream send path so they do not branch one parent too far back.
  • Payment-required handling stores the edit-regenerate retry context; both auto retry and manual payment-card retry reuse that preserved context instead of reconstructing from the latest rendered user message.
  • UI guardrails restrict editing to the latest user message and disable edit actions while a request is in flight, but reviewers should still treat this PR as touching session mutation/regeneration behavior, not only renderer UI.

Reviewer focus

  • Confirm edit + regenerate does not create duplicate user messages.
  • Confirm edit + payment-required/retry resumes from the edited message rather than the pre-edit parent.
  • Confirm normal sends and existing payment/retry flow remain unchanged.

Validation

  • pnpm --filter=@antseed/desktop exec tsc -p tsconfig.renderer.json --noEmit
  • pnpm --filter=@antseed/desktop exec tsc -p tsconfig.main.json --noEmit
  • tsx --test --test-force-exit apps/desktop/src/renderer/modules/chat.peer-routing.test.ts

Regression coverage added

  • edit-regenerate retries a stuck in-flight request without invoking the edit/branch endpoint twice
  • manual payment retry after edit-regenerate reuses the preserved edit retry context and canonical file.attachment attachments

@Augustas11
Copy link
Copy Markdown
Contributor

Solid execution. The main thing I wanted to see is right: edit-regenerate flows through the same runStreamingPrompt path as a normal send (pi-chat-engine.ts:2584-2592), so buyer-proxy/payment routing and channel reuse are untouched. Attachments are pulled via extractPreparedAttachmentsFromContent and threaded through both the local UI rebuild (chat.ts:174-180) and the dispatch payload (chat.ts:188-194), and options is propagated into every scheduleChatRetry call (chat.ts:2184/2196/2213/2266/2270) so a PAYMENT_REQUIRED retry replays the edit path rather than degrading to a fresh send. Streaming guard is belt-and-suspenders: canEdit excludes snap.chatSending (ChatView.tsx:1107) and editLastUserMessage re-checks isConversationSending (chat.ts:134-137). Copy uses the shared extractPlainText — newlines preserved, parts arrays joined with \n\n.

A few things worth a look before merge:

  1. Branch-truncation idempotency under retry (pi-chat-engine.ts:1689-1700). The truncation reads sessionManager.getBranch() and branches to the last user message's parent. If a PAYMENT_REQUIRED retry re-enters this path, behavior depends on whether the new user message was already committed on the first attempt — if not, each retry could walk the leaf one step further back. Worth a regression test (two consecutive edit dispatches converge to the same leaf) or a small guard ("skip if current leaf already equals last-user.parent").

  2. Attachment filter is type === 'file' only (chat.ts:126). If attachments can arrive as image/other block types from buildUserMessageContent, they would be silently dropped on regenerate. Either loosen the filter or pin the contract in a comment.

  3. PR body says renderer-only, but the change adds a new IPC (chat:ai-edit-last-user-message) plus a new option on runStreamingPrompt. Not blocking, but worth noting in the description so reviewers know the main-process surface is touched.

  4. Minor: inline edit <textarea> (ChatBubble.tsx:589-596) has no aria-label; edit also can't modify attachments, only text — if that's intentional, worth pinning in the spec so future work doesn't have to re-derive intent.

Review assisted by Claude Code agent (@Augustas11).

Copy link
Copy Markdown
Contributor

@kc-zero-lab kc-zero-lab left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR — the feature direction is solid, and I like that edit-regenerate reuses the existing runStreamingPrompt path instead of creating a parallel chat send flow. That should preserve buyer proxy/payment routing, pinned peer/service selection, streaming behavior, and channel reuse much better than a separate implementation.

I would still hold merge on a couple of correctness issues around retry/session behavior.

Should fix before merge

1. Edit branching should be idempotent under retry

In apps/desktop/src/main/pi-chat-engine.ts, the new edit path does:

if (options?.branchBeforeLastUserMessage) {
  const branch = sessionManager.getBranch();
  const lastUserEntry = [...branch]
    .reverse()
    .find((entry) => entry.type === 'message' && entry.message?.role === 'user');
  ...
  sessionManager.branch(lastUserEntry.parentId);
}

This recalculates “last user message” from the current branch every time the request enters runStreamingPrompt.

That is risky for payment/request retry paths. If the edited user message has already been committed before a retry re-enters this path, the retry can discover the newly edited message as the last user entry and branch to its parent. In the worst case, repeated retries can walk the leaf one parent too far back instead of converging on the same branch point.

Recommendation: make the operation target explicit, e.g. pass the original user message id/parent id from the renderer/main state, or add a guard so retrying the same edit is idempotent. At minimum, add regression coverage proving two consecutive edit dispatches/retries converge to the same leaf and do not truncate extra history.

2. Manual/payment retry should preserve edit context

The auto retry path now carries options, which is good:

dispatchChatRequest(convId, ctx.content, ctx.attachments, ctx.selection, ctx.options);

But retryAfterPayment() still reconstructs from uiState.chatMessages and calls:

dispatchChatRequest(convId, content, attachments.length > 0 ? attachments : undefined);

That loses:

  • options: { editLastUserMessage: true }
  • the original selection override
  • any explicit retry context from the failed edit request

So an edit-regenerate flow that hits the payment approval/manual retry path can degrade into a normal send/regenerate path. I’d store and reuse the original ChatRetryContext for the payment card action rather than reconstructing from the visible messages.

3. Attachment extraction contract is fragile

extractPreparedAttachmentsFromContent() only pulls attachments from type === 'file' blocks:

if (block.type === 'file' && block.attachment && typeof block.attachment === 'object')

That appears to work because buildUserMessageContent() stores the canonical prepared attachment on the file block and adds image blocks separately for image payload/rendering. But this is not obvious from the helper, and future changes could easily break edit-regenerate for images/other attachment block types.

Please either:

  • add a short comment saying file.attachment is the canonical prepared attachment source and image blocks are derived payload/render blocks, or
  • centralize/strongly type this contract so extraction and message construction cannot drift.

Nice-to-have polish

  • ChatView.tsx / modules/chat.ts: edit submit trims the draft twice. For editing, consider validating with draft.trim() but sending the original draft so intentional whitespace/code-block formatting is preserved.
  • ChatBubble.tsx: the edit textarea could use an explicit aria-label, e.g. aria-label="Edit message text".
  • Product wording: “Send” in the inline edit UI might be clearer as “Save & regenerate” because this action does more than save local text.
  • Longer term: consider an editedAt/edited marker. Keeping original createdAt is acceptable for now, but users may expect edited messages to be distinguishable.

What looks good

  • Reusing runStreamingPrompt for edit-regenerate is the right architectural choice.
  • Preload/bridge/action plumbing is straightforward and scoped.
  • Restricting edit to the latest user message and blocking while sending are good guardrails.
  • Moving extractPlainText into shared chat utils improves copy/edit reuse.
  • Preserving attachments visually while editing is a good UX improvement.

Overall: good feature, but I’d like to see #1 and #2 fixed or covered with regression tests before merge.

@SheferKagan SheferKagan force-pushed the feat/issues-500-501-chat-message-actions branch from cc41b7d to 400408c Compare May 20, 2026 07:06
Copy link
Copy Markdown
Contributor

@kc-zero-lab kc-zero-lab left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing the conflicts. I did a focused review for merge safety, code quality/simplicity, duplication, and test coverage.

I would not merge this as-is yet.

Critical issue:

  • In apps/desktop/src/renderer/modules/chat.ts, the edit-regenerate stuck-request retry path can mark the edit branch as prepared even when chatAiEditLastUserMessage(...) returns an immediate failure such as Request already in progress.
  • Flow: first attempt calls the edit endpoint, sendStreamRequest() sets editBranchPrepared = true, caller aborts the stuck request, retry then goes through normal chatAiSendStream(...) instead of the edit endpoint.
  • That avoids branching twice, but it can also avoid branching at all after the abort/retry, so the edited message may be appended to the current branch instead of replacing the last user turn.
  • Suggested fix: only mark the edit branch prepared after the edit endpoint actually succeeded / started the edit branch, or after stream/error evidence proves the main side already performed the branch. For a plain immediate already in progress result, abort and retry the edit endpoint again.

Code quality / simplicity:

  • The retry-context additions are directionally right, but the editLastUserMessage + editBranchPrepared state is subtle and easy to misuse. I’d make the state transition explicit: needsEditBranch -> editBranchPrepared -> normalRetry, rather than deriving it inside getRetryOptions() before knowing whether the edit request actually took effect.
  • There is some duplicated retry construction across normal request, payment retry, stream error, and non-stream paths. Not blocking by itself, but this PR makes retry semantics more complex; a small helper for “current retry context with safe edit options” would reduce future regressions.
  • extractPreparedAttachmentsFromContent() is a good extraction, but tests should cover mixed text/file/image content so we don’t accidentally resend renderer-only image blocks as durable attachments.

Tests required before merge:

  • Add/adjust a test for the stuck in-flight edit path where the first chatAiEditLastUserMessage returns Request already in progress; after abort, the retry should call chatAiEditLastUserMessage again, not chatAiSendStream directly.
  • Add/keep a payment retry test proving that once the edit branch is genuinely prepared, manual retry uses normal send and does not branch one parent farther back.
  • Add a regression test for attachment preservation on edit: file attachments are preserved, renderer-only image blocks are not treated as durable attachment sources.

Safe to merge score: 2/5.

The feature shape is good, and the UI/API integration looks reasonable, but the retry branching bug can corrupt conversation history in exactly the edge case this PR is trying to harden. I’d fix that before merging.

Copy link
Copy Markdown
Contributor

@kc-zero-lab kc-zero-lab left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed latest head df6dc247.

The previous merge-blocking edit retry issue looks fixed:

  • edit branch preparation is now confirmed by the main process, not assumed by the renderer
  • stuck in-flight edit retry now retries the edit endpoint when editBranchPrepared: false
  • payment retry correctly carries editBranchPrepared: true and falls back to normal send only after the edit branch is confirmed

Code quality looks acceptable for this feature. The retry flow is still somewhat subtle, but the source-of-truth boundary is much clearer now and the added tests cover the important branching/payment cases.

Validation checked:

  • renderer typecheck
  • desktop main build
  • peer-routing regression tests, all 11 passing
  • diff whitespace check

Safe to merge score: 4/5.

Approving. I’d still consider a future cleanup to centralize retry-context construction, but I don’t see that as merge-blocking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(desktop): add edit action for user messages feat(desktop): add copy action for user messages

3 participants