feat: add message fork to new conversation - #609
Merged
Conversation
Add POST /conversations/{id}/messages/{message_id}/fork which copies the
ancestor path from the conversation start up to the chosen message into a
new conversation. Cloned messages get fresh public IDs with parent links
remapped, attachments are referenced instead of copied, and billing, runs,
traces, and compaction snapshots are not carried over.
Frontend adds a fork button to user and assistant message meta rows with an
animated GitFork icon, renames the forked conversation with a localized
suffix, inserts it into the sidebar recents, and navigates to it.
chenyme
self-requested a review
August 18, 2026 02:14
chenyme
approved these changes
Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the fork-to-a-new-conversation behavior discussed in #600 (deletion itself will follow in a separate change).
Summary
Issue #600 asks for per-message operations inside a conversation. Besides deleting a message, the discussion also asks for a way to branch off: copy the conversation from the start up to a chosen message into a new conversation, so the original stays untouched.
This PR adds the fork capability:
POST /conversations/{id}/messages/{message_id}/forkcopies the ancestor path (conversation start → chosen message, inclusive) into a new conversation via the existing recursive ancestor CTE. Cloned messages get fresh public IDs with parent links remapped;pendingmessages are normalized tointerruptedso the fork never shows a stuck loading state.sessionKey, emptylastResponseID, and no compaction state.GitForkicon following the animate-ui icon pattern), gated like retry/edit. After a successful fork the new conversation is renamed with a localized suffix ((分叉)/(Fork)), inserted into the sidebar recents, and the user is navigated to it.Change type
Affected areas
Verification
Not run; reason:
go build ./...,go vet ./internal/application/conversation/... ./internal/transport/http/conversation/...go test ./internal/application/conversation/... ./internal/transport/http/conversation/...(includes new table-driven test fornormalizeForkedMessageStatus)pnpm api:generate+pnpm api:check(no Swagger/TypeScript drift)pnpm --filter @deeix/web typecheckpnpm --filter @deeix/web lint(biome, full workspace)pnpm --filter @deeix/web buildScreenshots, API examples, or logs
POST /api/v1/conversations/{conversationPublicID}/messages/{messagePublicID}/fork # no request body200response (standarderrorMsg + dataenvelope, same shape as the clone-shared-conversation endpoint):{ "errorMsg": "", "data": { "publicID": "b3f1c2a4d5e6f7a8b9c0d1e2f3a4b5c6", "title": "Original title", "model": "gpt-4o", "provider": "openai", "messageCount": 6, "status": "active", "isStarred": false, "lastResponseID": "", "createdAt": "2026-08-16T12:00:00Z", "updatedAt": "2026-08-16T12:00:00Z" } }Errors:
400when the target message is still generating (pending),404when the conversation or message does not exist or belongs to another user. An audit recordfork_conversation(with source conversation/message IDs) is written on success.Configuration, migration, and compatibility notes
backend/docs/) andpackages/api-contract/src/types.generated.tswere regenerated together viapnpm api:generate.Documentation
(The generated Swagger contract above is the API documentation for the new endpoint.)
Security and privacy
Ownership is enforced end to end: the conversation is loaded by
publicID + userID, the message bypublicID + userID, and the fork is rejected unless the message belongs to the requested conversation. Reused attachment files stay behind the existing cross-conversation reference counting, so the fork never exposes another user's files.Checklist
.pycfiles,.envfiles, and local storage data are not committed.