diff --git a/core/http/react-ui/e2e/forking-chat.spec.js b/core/http/react-ui/e2e/forking-chat.spec.js
new file mode 100644
index 000000000..c1625de7e
--- /dev/null
+++ b/core/http/react-ui/e2e/forking-chat.spec.js
@@ -0,0 +1,133 @@
+import { test, expect } from './coverage-fixtures.js'
+
+// Seeds two-message chat into localStorage so we don't need a live model.
+async function seedChat(page, history) {
+ await page.addInitScript((h) => {
+ const chat = {
+ id: 'seed1', name: 'Seeded Chat', model: 'test-model',
+ history: h, systemPrompt: '', mcpMode: false, mcpServers: [],
+ clientMCPServers: [], temperature: null, topP: null, topK: null,
+ tokenUsage: { prompt: 0, completion: 0, total: 0 },
+ contextSize: null, createdAt: Date.now(), updatedAt: Date.now(),
+ }
+ localStorage.setItem('localai_chats_data', JSON.stringify({
+ chats: [chat], activeChatId: 'seed1', lastSaved: Date.now(),
+ }))
+ }, history)
+}
+
+async function mockModels(page) {
+ await page.route('**/api/models/capabilities', (route) => route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({ data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }] }),
+ }))
+ await page.route('**/api/operations', (route) => route.fulfill({
+ contentType: 'application/json', body: JSON.stringify({ operations: [] }),
+ }))
+}
+
+const TWO_TURNS = [
+ { role: 'user', content: 'first question' },
+ { role: 'assistant', content: 'first answer' },
+ { role: 'user', content: 'second question' },
+ { role: 'assistant', content: 'second answer' },
+]
+
+test('duplicate creates an independent copy and switches to it', async ({ page }) => {
+ await mockModels(page)
+ await seedChat(page, TWO_TURNS)
+ await page.goto('/app/chat')
+
+ // Open the chats menu (Ctrl/Cmd+K) and duplicate the seeded chat.
+ // Wait for the menu trigger to mount so its global keydown listener is armed
+ // before we dispatch the shortcut.
+ await page.getByTitle('Conversations (Ctrl/Cmd+K)').waitFor()
+ await page.keyboard.press('Control+k')
+ await page.getByTitle('Duplicate chat').first().click()
+
+ // A new active chat named "Seeded Chat (fork)" with the same 4 messages.
+ await expect(page.locator('.chat-header-title')).toHaveText('Seeded Chat (fork)')
+ await expect(page.locator('.chat-message-user')).toHaveCount(2)
+ await expect(page.locator('.chat-message-assistant')).toHaveCount(2)
+})
+
+async function mockCompletion(page, replyText) {
+ await page.route('**/v1/chat/completions', (route) => {
+ const sse =
+ `data: ${JSON.stringify({ choices: [{ delta: { content: replyText } }] })}\n\n` +
+ `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n` +
+ `data: [DONE]\n\n`
+ route.fulfill({ status: 200, contentType: 'text/event-stream', body: sse })
+ })
+}
+
+test('retry regenerates the first answer and drops the later turn', async ({ page }) => {
+ await mockModels(page)
+ // Capture the outbound request body so we can assert the model receives the
+ // truncated history (not the stale downstream turns).
+ let sentMessages = null
+ await page.route('**/v1/chat/completions', (route) => {
+ sentMessages = route.request().postDataJSON()?.messages || []
+ const sse =
+ `data: ${JSON.stringify({ choices: [{ delta: { content: 'REGENERATED first answer' } }] })}\n\n` +
+ `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n` +
+ `data: [DONE]\n\n`
+ route.fulfill({ status: 200, contentType: 'text/event-stream', body: sse })
+ })
+ await seedChat(page, TWO_TURNS)
+ await page.goto('/app/chat')
+
+ // Hover the FIRST assistant message and click its retry button.
+ const firstAssistant = page.locator('.chat-message-assistant').first()
+ await firstAssistant.hover()
+ await firstAssistant.getByTitle('Regenerate').click()
+
+ // History is truncated to the first user turn, then the new answer streams in;
+ // the second Q/A turn is gone.
+ await expect(page.locator('.chat-message-assistant')).toContainText(['REGENERATED first answer'])
+ await expect(page.locator('.chat-message-user')).toHaveCount(1)
+ await expect(page.locator('.chat-message-assistant')).toHaveCount(1)
+
+ // The OUTBOUND payload must also be truncated: the resent user turn is present,
+ // but the downstream turn and the stale first answer must be gone.
+ const contents = (sentMessages || []).map(m =>
+ typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
+ )
+ expect(contents.join('\n')).toContain('first question')
+ expect(contents.join('\n')).not.toContain('second question')
+ expect(contents.join('\n')).not.toContain('first answer')
+})
+
+test('copy chat puts the whole conversation on the clipboard', async ({ page, context }) => {
+ await context.grantPermissions(['clipboard-read', 'clipboard-write'])
+ await mockModels(page)
+ await seedChat(page, TWO_TURNS)
+ await page.goto('/app/chat')
+
+ // Wait for the menu trigger to mount so its global keydown listener is armed
+ // before we dispatch the shortcut (same mount-race guard as the duplicate test).
+ await page.getByTitle('Conversations (Ctrl/Cmd+K)').waitFor()
+ await page.keyboard.press('Control+k')
+ await page.getByTitle('Copy chat').first().click()
+
+ const clip = await page.evaluate(() => navigator.clipboard.readText())
+ expect(clip).toContain('# Seeded Chat')
+ expect(clip).toContain('first answer')
+ expect(clip).toContain('second answer')
+})
+
+test('branch from the first answer forks history up to that point', async ({ page }) => {
+ await mockModels(page)
+ await seedChat(page, TWO_TURNS)
+ await page.goto('/app/chat')
+
+ const firstAssistant = page.locator('.chat-message-assistant').first()
+ await firstAssistant.hover()
+ await firstAssistant.getByTitle('Branch from here').click()
+
+ // New active chat "Seeded Chat (fork)" contains only the first Q/A turn.
+ await expect(page.locator('.chat-header-title')).toHaveText('Seeded Chat (fork)')
+ await expect(page.locator('.chat-message-user')).toHaveCount(1)
+ await expect(page.locator('.chat-message-assistant')).toHaveCount(1)
+ await expect(page.locator('.chat-message-assistant')).toContainText(['first answer'])
+})
diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json
index ffda226db..4bdc2a3d0 100644
--- a/core/http/react-ui/public/locales/en/chat.json
+++ b/core/http/react-ui/public/locales/en/chat.json
@@ -72,6 +72,7 @@
"actions": {
"copy": "Copy",
"regenerate": "Regenerate",
+ "branch": "Branch from here",
"jumpToLatest": "Jump to latest"
},
"streaming": {
@@ -100,7 +101,9 @@
"toasts": {
"selectModel": "Please select a model",
"copied": "Copied to clipboard",
- "copyFailed": "Could not copy to clipboard"
+ "copyFailed": "Could not copy to clipboard",
+ "chatCopied": "Chat copied to clipboard",
+ "forked": "Created a new chat"
},
"menu": {
"trigger": "Chats",
@@ -110,6 +113,8 @@
"noMatch": "No conversations match your search",
"noConversations": "No conversations yet",
"rename": "Rename",
+ "duplicate": "Duplicate chat",
+ "copyChat": "Copy chat",
"exportMarkdown": "Export as Markdown",
"deleteChat": "Delete chat",
"newChat": "New chat",
diff --git a/core/http/react-ui/src/components/ChatsMenu.jsx b/core/http/react-ui/src/components/ChatsMenu.jsx
index ed0e85dee..4b5d72821 100644
--- a/core/http/react-ui/src/components/ChatsMenu.jsx
+++ b/core/http/react-ui/src/components/ChatsMenu.jsx
@@ -24,6 +24,8 @@ const ChatsMenu = forwardRef(function ChatsMenu({
onDeleteAll,
onRename,
onExport,
+ onCopyChat,
+ onDuplicate,
}, ref) {
const { t } = useTranslation('chat')
const [open, setOpen] = useState(false)
@@ -230,6 +232,24 @@ const ChatsMenu = forwardRef(function ChatsMenu({
>
+ {onDuplicate && (
+
+ )}
+ {(chat.history?.length || 0) > 0 && onCopyChat && (
+
+ )}
{(chat.history?.length || 0) > 0 && onExport && (
- {msg.role === 'assistant' && i === activeChat.history.length - 1 && !isStreaming && (
-