Skip to content

feat(goose2): inline edit & retry#8538

Open
tellaho wants to merge 19 commits intomainfrom
donatello/edit-retry
Open

feat(goose2): inline edit & retry#8538
tellaho wants to merge 19 commits intomainfrom
donatello/edit-retry

Conversation

@tellaho
Copy link
Copy Markdown
Collaborator

@tellaho tellaho commented Apr 14, 2026

Category: new-feature
User Impact: Users can now edit sent messages in-place and retry from any point in a conversation, with all attachments, images, and persona overrides preserved through the re-send.

Problem: The Goose 2 desktop app had no way to correct a sent message or retry a failed exchange. Users who made a typo or wanted to refine their prompt had to start a new conversation.

Solution: Adds client-side inline edit and per-message retry using truncate-and-resend. Edit mode renders a textarea over the original message bubble with Save/Cancel controls and keyboard shortcuts (Enter to save, Escape to cancel, Shift+Enter for newline). Retry identifies the originating user message, reconstructs its full context (text, images, file attachments, persona), and re-sends after truncation. A Radix HoverCard replaces the old CSS group-hover for the action toolbar, fixing z-index and animation issues. Session forking support can be layered on as a follow-up.

File changes

ui/goose2/src/features/chat/hooks/useChat.ts
Added retryMessage, editMessage, and cancelEdit to the chat hook. retryMessage validates content and reconstructs attachment drafts before truncating history, then forwards text, persona, and attachments to sendMessage. editMessage enters non-destructive edit mode; truncation only happens on save.

ui/goose2/src/features/chat/hooks/tests/useChat.test.ts
Added 14 hook-level tests: 5 for retryMessage (truncation, persona preservation, assistant-to-user lookup, streaming/thinking guards), 3 for editMessage (state, streaming guard, assistant-role guard), 1 for cancelEdit, 3 for attachment preservation (image-only, file metadata, mixed content), and 2 for retryLastMessage delegation.

ui/goose2/src/features/chat/lib/attachments.ts
Added rebuildAttachmentDrafts() — reconstructs ChatAttachmentDraft[] from a stored message's ImageContent blocks and metadata.attachments, enabling retry and edit to preserve the original message's full attachment context.

ui/goose2/src/features/chat/stores/chatStore.ts
Added editingMessageIdBySession state and setEditingMessageId action. Editing state is scoped per-session and cleaned up on session cleanup.

ui/goose2/src/features/chat/stores/tests/chatStore.test.ts
Added tests for setEditingMessageId set, clear, and session cleanup.

ui/goose2/src/features/chat/ui/ChatView.tsx
Added handleSaveEdit — stops streaming if active, reads persona and attachments from the original message before truncation, then defers the re-send via a ref until chat state returns to idle. Wires onRetryMessage, onEditMessage, onSaveEdit, onCancelEdit, and editingMessageId to MessageTimeline.

ui/goose2/src/features/chat/ui/ChatInput.tsx
Syncs textarea text state when initialValue prop changes (needed for edit mode to populate the input). Added initialValue sync effect.

ui/goose2/src/features/chat/ui/MessageBubble.tsx
Replaced CSS group-hover action toolbar with Radix HoverCard. Added inline edit UI: textarea overlay with Save/Cancel buttons, keyboard handling (Enter/Escape/Shift+Enter), empty-text guard. User bubbles get muted background and max-width constraint.

ui/goose2/src/features/chat/ui/MessageTimeline.tsx
Passes onRetryMessage (assistant messages), onEditMessage (user messages), editingMessageId, onSaveEdit, and onCancelEdit through to MessageBubble.

ui/goose2/src/features/chat/ui/tests/ChatInput.test.tsx
Added test for initialValue sync — verifies textarea updates when the prop changes.

ui/goose2/src/features/chat/ui/tests/MessageBubble.test.tsx
Added 10 tests: inline edit keyboard shortcuts (Enter saves, Escape cancels, Shift+Enter allows newline), empty-text guard, edit/cancel button rendering, assistant edit-button exclusion, user edit-button presence, and HoverCard hover behavior.

ui/goose2/src/shared/i18n/locales/en/chat.json
Added edit.hint, edit.textareaAriaLabel keys for the inline edit UI.

ui/goose2/src/shared/i18n/locales/en/common.json
Removed dead messageBranch i18n keys (unused forking infrastructure).

ui/goose2/src/shared/i18n/locales/es/chat.json
Added Spanish translations for edit.hint, edit.textareaAriaLabel.

ui/goose2/src/shared/i18n/locales/es/common.json
Removed dead messageBranch i18n keys (unused forking infrastructure).

ui/goose2/src/shared/ui/ai-elements/message.tsx
Removed MessageBranch and 5 sub-components (~210 lines of dead code). Cleaned 11 unused imports.

ui/goose2/src/shared/ui/hover-card.tsx
Added bare variant to HoverCard — no background, border, or shadow — used by the message action toolbar.

Reproduction Steps

  1. Open the Goose 2 desktop app and start or resume a conversation.
  2. Send a user message, then hover over it — an edit (pencil) button appears in the HoverCard toolbar.
  3. Click edit — the message text appears in an inline textarea with Save and Cancel buttons.
  4. Modify the text and press Enter (or click Save) — the original message and all responses below it are removed, and the edited message is re-sent.
  5. Press Escape (or click Cancel) to exit edit mode without changes.
  6. Hover over an assistant message — a retry button appears but no edit button.
  7. Click retry on an assistant message — the preceding user message is re-sent with its original attachments and persona intact.

Demo

Screen.Recording.2026-04-14.at.12.47.56.PM.mov

tellaho added 18 commits April 14, 2026 12:36
- Add retryMessage(messageId) to useChat — truncates from the target
  message onward and resends the preceding user message. Works for
  both user and assistant messages.
- Add editMessage(messageId) to useChat — truncates from the target
  user message onward and populates the chat input draft with the
  original text for revision.
- Wire onRetryMessage and onEditMessage from ChatView through
  MessageTimeline to MessageBubble.
- Remove role gate so retry icon shows on both user and assistant
  messages (edit remains user-only).
ChatInput initialized local text via useState(initialValue) which only
reads the prop on mount. Subsequent changes (e.g. cancel edit then
re-edit) were ignored. Added useEffect to re-sync setTextRaw when
initialValue changes. Uses setTextRaw (not setText) to avoid redundant
onDraftChange callback since the store already holds the correct value.
The focus effect only ran on mount. Now keyed on editingMessageId so the
textarea receives focus whenever the user clicks edit.
Pivot from bottom-ChatInput editing to inline editing. When clicking
edit, the message bubble transforms into a textarea with Save and
Cancel buttons. Enter saves, Escape cancels.

- MessageBubble: add isEditing mode with inline textarea, auto-resize,
  focus, and keyboard shortcuts (Enter=save, Escape=cancel)
- MessageTimeline: pass editingMessageId, onSaveEdit, onCancelEdit
- ChatView: add handleSaveEdit (truncate + send), wire to timeline
- ChatInput: remove edit indicator bar, editingMessageId prop, and
  edit-mode focus effect
When editing, the container breaks out of the 80% user bubble constraint
to span the full timeline width with a subtle bg-muted/40 background.
Hides the user avatar during edit. Adds hint text indicating editing
will start a new conversation.
1. handleSaveEdit now forces chatState to idle via store and defers
   sendMessage via pendingEditSend ref + useEffect, avoiding the stale
   closure where sendMessage would bail on old chatState.
2. Split focus into a separate useEffect keyed only on isEditing so it
   doesn't fire on every keystroke and reset cursor position.
3. Removed dead setDraft call in editMessage — inline edit reads text
   directly from the message, not the draft store.
4. Removed unused edit.label and edit.cancel i18n keys.
User messages render as rounded bg-muted bubbles on the content div
only — action buttons and timestamp sit outside. Max width capped at
640px. Avatar removed. Outer group/gap-3 hover structure preserved.
…tions

Swap the fragile CSS group-hover opacity pattern for Radix HoverCard
which handles hover intent, open/close delays, and portal rendering.
Actions appear below the bubble on hover via stripped HoverCardContent
(no border, shadow, padding, or background). Removes group class.
Force-override base HoverCardContent styles (shadow, border, bg,
animations, rounded, padding) with !important. Add mb-6 to user
content div so the portal actions float in the reserved gap.
Remove !animate-none override so fade and slide transitions from
the base HoverCardContent are preserved. All other chrome overrides
(shadow, border, bg, padding, rounded) remain.
8 new tests: inline textarea pre-fill, save/cancel clicks, Enter/Escape
keys, disabled save on empty text, edit hint visibility, retry click.
Updated existing hover test to trigger HoverCard via userEvent.hover.
All 49 tests green (25 MessageBubble + 24 ChatInput).
If editIndex is -1 (message deleted between click and save), bail
early instead of sending to untruncated history. Clears edit state.
…cale

1. Added variant='bare' to HoverCardContent — strips all chrome.
   MessageBubble uses it instead of six !important overrides.
2. Removed duplicate height calc from ChatInput handleInput —
   useLayoutEffect([text]) already covers it.
3. Edit state (editText, editTextareaRef, effects) gated on
   role==='user' via canEdit flag.
4. Added edit.hint to Spanish locale.
…pped

Replace py-4 with pt-4 pb-12 on the timeline inner container to
give the last message enough room for HoverCard actions to render.
Bare variant now z-40 instead of z-50. Popovers and dropdowns
at z-50 will always paint on top of message action hovercards.
Removed size=sm and h-7/px-3/text-xs overrides from Save/Cancel.
Hint text now left-aligned, buttons right-anchored via ml-auto.
Replace px-4 py-2.5 with p-3 for consistent 12px padding on all sides.
@tellaho tellaho changed the title feat(goose2): inline edit & retry UX with attachment/persona preservation feat(goose2): inline edit & retry UX with attachment and persona preservation Apr 14, 2026
@tellaho tellaho force-pushed the donatello/edit-retry branch from f7e3502 to 6a5d19f Compare April 14, 2026 22:55
@tellaho tellaho changed the title feat(goose2): inline edit & retry UX with attachment and persona preservation feat(goose2): inline edit & retry Apr 14, 2026
@tellaho tellaho marked this pull request as ready for review April 14, 2026 23:39
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a5d19ff3d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +420 to +423
const cancelEdit = useCallback(() => {
store.setEditingMessageId(sessionId, null);
store.clearDraft(sessionId);
}, [sessionId, store]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep unsent draft when cancelling inline edit

cancelEdit() now calls clearDraft(sessionId), which wipes the compose draft for the whole session, not just edit mode. Because ChatInput is driven from draftsBySession, a user who has partially typed a new message and then presses Cancel/Escape in inline edit will lose that unsent draft text unexpectedly.

Useful? React with 👍 / 👎.

Comment on lines +105 to +109
} else if (att.type === "file" && att.path) {
drafts.push({
id: crypto.randomUUID(),
kind: "file",
name: att.name,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip image metadata when rebuilding retry attachments

This branch rebuilds every metadata.attachments file with a path as kind: "file", even when the same image was already reconstructed from content image blocks above. For path-based image uploads, retry/edit now resends both an image and an extra file attachment, which changes the prompt context (adds a file-path reference) compared with the original send.

Useful? React with 👍 / 👎.

…dead code

Bug 1: retryMessage no longer truncates history on image-only messages.
Content is validated BEFORE truncation — if there's nothing to re-send
(no text, no images, no attachments), the operation bails cleanly.

Bug 2: retryMessage and handleSaveEdit now forward attachments and persona
from the original message. Added rebuildAttachmentDrafts() to reconstruct
ChatAttachmentDraft[] from stored message content (ImageContent blocks)
and metadata.attachments (file/directory refs).

Dead code: Removed MessageBranch components (~210 lines) from message.tsx
and messageBranch i18n keys from en/es common.json. Unused forking artifacts.

Tests: +6 new tests covering image-only retry safety, attachment preservation,
assistant edit-button exclusion, Shift+Enter multiline, and chatStore
editingMessageId set/clear/cleanup. 430 tests green.
@tellaho tellaho force-pushed the donatello/edit-retry branch from 6a5d19f to 5374dfb Compare April 14, 2026 23:48
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5374dfbc92

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

name: att.name,
path: att.path,
});
} else if (att.type === "file" && att.path) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rehydrate pathless file attachments on retry/edit

rebuildAttachmentDrafts() currently only rebuilds file attachments when att.path is present, so any message metadata entry like { type: "file", name, mimeType } is dropped during retry/edit. That pathless shape is produced by browser-file attachments (see useChatInputAttachments.addBrowserFiles), so users who resend edited/retried messages lose those attachments and send a different prompt context than the original message.

Useful? React with 👍 / 👎.

});
} else if (att.type === "file" && att.path) {
// Skip image file entries when content blocks already provide the base64
if (hasImageDrafts && att.mimeType?.startsWith("image/")) continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Deduplicate image attachments per file, not per message

The global hasImageDrafts check drops all image metadata attachments whenever any base64 image block exists, which can remove legitimate image file attachments that were not reconstructed as image drafts (for example, image paths that fell back to kind: "file" in addPathAttachments). In mixed cases, retry/edit silently omits those files, so the resent message no longer matches the original attachment set.

Useful? React with 👍 / 👎.

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.

1 participant