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:
co-authored by
Claude Opus 4.6
parent
61a6e88667
commit
e439123a61
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user