feat(chat): add conversation branching UI (M8.2)

Wire up branch-from-message handler, add BranchSwitcher glass pill
component showing "Branch X of Y" with prev/next navigation.
Clean up isDark conditionals in chat components (dark-only app).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:06:56 +00:00
co-authored by Claude Opus 4.6
parent 61a6e88667
commit e439123a61
4 changed files with 91 additions and 49 deletions
@@ -0,0 +1,55 @@
<template>
<div
v-if="branches.length > 1"
class="flex items-center justify-center px-4 py-1.5 animate-fade-up-fast"
>
<div class="glass-button-sm !h-7 !min-h-0 flex items-center gap-1.5 px-2 text-[11px]">
<button
class="w-5 h-5 flex items-center justify-center rounded-md hover:bg-white/10 transition-colors text-white/60 hover:text-white/90 disabled:opacity-30 disabled:cursor-default"
:disabled="currentIndex <= 0"
aria-label="Previous branch"
@click="switchToBranch(currentIndex - 1)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span class="text-white/70 select-none whitespace-nowrap">
Branch {{ currentIndex + 1 }} of {{ branches.length }}
</span>
<button
class="w-5 h-5 flex items-center justify-center rounded-md hover:bg-white/10 transition-colors text-white/60 hover:text-white/90 disabled:opacity-30 disabled:cursor-default"
:disabled="currentIndex >= branches.length - 1"
aria-label="Next branch"
@click="switchToBranch(currentIndex + 1)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const branches = computed(() => {
if (!chatStore.activeConversationId) return []
return chatStore.getSiblingBranches(chatStore.activeConversationId)
})
const currentIndex = computed(() => {
return branches.value.findIndex(b => b.isCurrent)
})
function switchToBranch(index: number) {
const branch = branches.value[index]
if (branch) {
chatStore.setActiveConversation(branch.id)
}
}
</script>
+15 -42
View File
@@ -6,10 +6,7 @@
<div class="flex items-center justify-between gap-2">
<button
ref="modelPickerTriggerRef"
class="w-8 h-8 rounded-xl path-glass-icon flex items-center justify-center shrink-0 transition-colors cursor-pointer"
:class="isDark
? 'text-[#fafafa] hover:text-white'
: 'text-gray-800 hover:text-gray-900'"
class="w-8 h-8 rounded-xl path-glass-icon flex items-center justify-center shrink-0 transition-colors cursor-pointer text-[#fafafa] hover:text-white"
:title="`AI model: ${modelDisplayName}`"
aria-label="Select AI model"
@click="showModelPicker = !showModelPicker; showChatList = false"
@@ -21,9 +18,7 @@
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="webSearchEnabled
? 'text-accent'
: isDark
? 'text-white/70 hover:text-white'
: 'text-gray-500 hover:text-gray-900'"
: 'text-white/70 hover:text-white'"
:title="webSearchEnabled ? 'Web search on' : 'Web search off'"
aria-label="Toggle web search"
@click="chatStore.webSearchEnabled = !chatStore.webSearchEnabled"
@@ -37,9 +32,7 @@
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="chatStore.chatCollapsed
? 'text-accent'
: isDark
? 'text-white/70 hover:text-white'
: 'text-gray-500 hover:text-gray-900'"
: 'text-white/70 hover:text-white'"
:title="chatStore.chatCollapsed ? 'Expand chat' : 'Collapse to prompts'"
aria-label="Toggle prompt index"
@click="chatStore.toggleChatCollapse()"
@@ -53,10 +46,7 @@
</button>
<button
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="isDark
? 'text-white/70 hover:text-white'
: 'text-gray-500 hover:text-gray-900'"
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors text-white/70 hover:text-white"
aria-label="New conversation"
@click="$emit('newChat')"
>
@@ -67,10 +57,7 @@
<button
v-if="showClose"
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="isDark
? 'text-white/70 hover:text-white'
: 'text-gray-500 hover:text-gray-900'"
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors text-white/70 hover:text-white"
aria-label="Close"
@click="$emit('close')"
>
@@ -87,14 +74,11 @@
:class="conversationList.length > 0 ? 'cursor-pointer' : ''"
@click="conversationList.length > 0 && (showChatList = !showChatList)"
>
<h2 class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/96' : 'text-gray-900'">{{ title }}</h2>
<h2 class="text-sm font-semibold truncate text-white/96">{{ title }}</h2>
<div class="flex items-center gap-1.5 mt-0.5">
<p class="text-[10px] truncate font-mono"
:class="isDark ? 'text-white/40' : 'text-gray-400'">{{ conversationId }}</p>
<span class="text-[10px]" :class="isDark ? 'text-white/20' : 'text-gray-300'">·</span>
<span class="text-[10px] truncate"
:class="isDark ? 'text-white/50' : 'text-gray-500'">{{ modelDisplayName }}</span>
<p class="text-[10px] truncate font-mono text-white/40">{{ conversationId }}</p>
<span class="text-[10px] text-white/20">·</span>
<span class="text-[10px] truncate text-white/50">{{ modelDisplayName }}</span>
</div>
</button>
@@ -107,17 +91,14 @@
:style="chatListDropdownStyle"
@click.stop
>
<p class="text-[10px] font-semibold uppercase tracking-wider mb-2 px-1"
:class="isDark ? 'text-white/40' : 'text-gray-400'">Saved chats</p>
<p class="text-[10px] font-semibold uppercase tracking-wider mb-2 px-1 text-white/40">Saved chats</p>
<button
v-for="c in conversationList"
:key="c.id"
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all"
:class="c.id === activeConversationId
? 'nav-tab-active'
: isDark
? 'text-white/60 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
: 'text-white/60 hover:text-white hover:bg-white/10'"
@click="selectChat(c.id)"
>
{{ c.title || 'Untitled' }}
@@ -136,8 +117,7 @@
@click.stop
>
<div v-for="provider in availableProviders" :key="provider.id">
<p class="text-[10px] font-semibold uppercase tracking-wider mb-1.5 px-1"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
<p class="text-[10px] font-semibold uppercase tracking-wider mb-1.5 px-1 text-white/40">
{{ provider.name }}
</p>
<div class="space-y-0.5">
@@ -147,9 +127,7 @@
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200"
:class="model.id === activeModel && provider.id === activeProvider
? 'nav-tab-active'
: isDark
? 'text-white/60 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
: 'text-white/60 hover:text-white hover:bg-white/10'"
@click="selectModel(provider.id, model.id)"
>
{{ model.name }}
@@ -157,12 +135,9 @@
</div>
</div>
<!-- Design System -->
<div class="border-t mt-2 pt-2" :class="isDark ? 'border-white/10' : 'border-black/10'">
<div class="border-t mt-2 pt-2 border-white/10">
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200 flex items-center gap-2"
:class="isDark
? 'text-white/60 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200 flex items-center gap-2 text-white/60 hover:text-white hover:bg-white/10"
@click="openDesignSystem"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -181,7 +156,6 @@
import { ref, computed, watch, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
defineProps<{
@@ -198,7 +172,6 @@ defineEmits<{
}>()
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
const { isDark } = useTheme()
const chatStore = useChatStore()
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
const showModelPicker = ref(false)
@@ -27,6 +27,14 @@
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" /></svg>
</button>
<button
v-if="!isUser"
class="glass-button-sm !h-7 !min-h-0 !w-7 !min-w-0 !px-0 flex items-center justify-center text-white/60 hover:text-white/90"
title="Branch from here"
@click.stop="$emit('branch', message.id)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5z" clip-rule="evenodd" /></svg>
</button>
</div>
<div
@@ -241,7 +249,6 @@ import { computed, ref, nextTick } from 'vue'
import MarkdownIt from 'markdown-it'
import type { Message, WebSearchResult } from '@aiui/core/types/message'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
import { useCodeContext } from '@/composables/useCodeContext'
import FilmCard from '@/components/content/FilmCard.vue'
@@ -267,10 +274,9 @@ const props = withDefaults(
const emit = defineEmits<{
edit: [messageId: string, newContent: string]
regenerate: []
branch: [messageId: string]
}>()
const { isDark } = useTheme()
// Editing state
const isEditing = ref(false)
const editContent = ref('')
@@ -10,6 +10,8 @@
@close="$emit('close')"
/>
<BranchSwitcher />
<!-- Collapsed: prompt index -->
<PromptIndex
v-if="chatCollapsed"
@@ -26,9 +28,9 @@
<div v-if="messages.length === 0" class="flex items-center justify-center h-full p-4">
<div class="text-center space-y-3 animate-fade-up">
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<span class="text-2xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'"></span>
<span class="text-2xl text-[#fafafa]"></span>
</div>
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
<p class="text-sm text-white/30">
Start a conversation
</p>
</div>
@@ -59,6 +61,7 @@
:triggering-query="getTriggeringQuery(messages, virtualRow.index)"
@edit="handleEdit"
@regenerate="handleRegenerate"
@branch="handleBranch"
/>
</ErrorBoundary>
</div>
@@ -85,13 +88,13 @@ import { computed, ref, watch, nextTick } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
import ChatHeader from './ChatHeader.vue'
import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
import StreamingDots from './StreamingDots.vue'
import PromptIndex from './PromptIndex.vue'
import BranchSwitcher from './BranchSwitcher.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import type { Message } from '@aiui/core/types/message'
@@ -115,7 +118,6 @@ defineEmits<{
const chatStore = useChatStore()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse } = useAI()
const { isDark } = useTheme()
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
const codeContext = useCodeContext()
@@ -173,6 +175,12 @@ async function handleRegenerate() {
await regenerateLastResponse()
}
function handleBranch(messageId: string) {
const convId = chatStore.activeConversationId
if (!convId) return
chatStore.branchFromMessage(convId, messageId)
}
function handleExtract(text: string) {
// Run content extraction without sending to AI
const convId = chatStore.activeConversationId ?? chatStore.createConversation()