feat(chat): add reply-to threading (M8.3)
Reply button on all messages shows quoted excerpt above input. On send, quote is prepended as `> excerpt`. Clean up isDark patterns in ChatInput (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
e439123a61
commit
6454518603
@@ -6,23 +6,37 @@
|
||||
:is-searching="isSearching"
|
||||
@select="handleSearchSelect"
|
||||
/>
|
||||
|
||||
<!-- Reply-to quote -->
|
||||
<div
|
||||
v-if="replyTo"
|
||||
class="mb-2 flex items-start gap-2 rounded-xl bg-white/5 border border-white/10 px-3 py-2 animate-fade-up-fast"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[10px] text-accent/70 font-medium mb-0.5">Replying to</p>
|
||||
<p class="text-xs text-white/50 truncate">{{ replyTo.excerpt }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 w-5 h-5 flex items-center justify-center rounded-md hover:bg-white/10 transition-colors text-white/40 hover:text-white/70"
|
||||
aria-label="Cancel reply"
|
||||
@click="$emit('clearReply')"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="path-glass-bubble rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300"
|
||||
:class="focused
|
||||
? isDark
|
||||
? 'border-white/30'
|
||||
: 'border-black/20'
|
||||
: ''"
|
||||
:class="focused ? 'border-white/30' : ''"
|
||||
>
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="text"
|
||||
rows="1"
|
||||
:placeholder="placeholder"
|
||||
class="flex-1 resize-none bg-transparent text-sm outline-none min-h-[24px] max-h-[120px]"
|
||||
:class="isDark
|
||||
? 'text-white/90 placeholder:text-white/25'
|
||||
: 'text-gray-800 placeholder:text-gray-400'"
|
||||
class="flex-1 resize-none bg-transparent text-sm outline-none min-h-[24px] max-h-[120px] text-white/90 placeholder:text-white/25"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
@input="autoResize"
|
||||
@paste="onPaste"
|
||||
@@ -44,8 +58,7 @@
|
||||
<button
|
||||
v-if="hasPasted && canSend"
|
||||
:disabled="!canSend"
|
||||
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200 hover:opacity-80 active:scale-95"
|
||||
:class="isDark ? 'text-accent/80' : 'text-accent'"
|
||||
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200 hover:opacity-80 active:scale-95 text-accent/80"
|
||||
aria-label="Extract content"
|
||||
title="Contextualize — extract media without sending to AI"
|
||||
@click="extract"
|
||||
@@ -75,7 +88,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useFederatedSearch, type SearchResult } from '@/composables/useFederatedSearch'
|
||||
import SearchResults from '@/components/ui/SearchResults.vue'
|
||||
|
||||
@@ -84,11 +96,13 @@ const props = withDefaults(
|
||||
disabled?: boolean
|
||||
streaming?: boolean
|
||||
placeholder?: string
|
||||
replyTo?: { messageId: string; excerpt: string } | null
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
streaming: false,
|
||||
placeholder: 'Message AIUI...',
|
||||
replyTo: null,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -96,9 +110,9 @@ const emit = defineEmits<{
|
||||
send: [text: string]
|
||||
extract: [text: string]
|
||||
stop: []
|
||||
clearReply: []
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const text = ref('')
|
||||
const focused = ref(false)
|
||||
const hasPasted = ref(false)
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
>
|
||||
<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
|
||||
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="Reply"
|
||||
@click.stop="$emit('reply', message.id, message.content)"
|
||||
>
|
||||
<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="M7.707 3.293a1 1 0 010 1.414L5.414 7H11a7 7 0 017 7v2a1 1 0 11-2 0v-2a5 5 0 00-5-5H5.414l2.293 2.293a1 1 0 11-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" 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"
|
||||
@@ -275,6 +282,7 @@ const emit = defineEmits<{
|
||||
edit: [messageId: string, newContent: string]
|
||||
regenerate: []
|
||||
branch: [messageId: string]
|
||||
reply: [messageId: string, content: string]
|
||||
}>()
|
||||
|
||||
// Editing state
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
@edit="handleEdit"
|
||||
@regenerate="handleRegenerate"
|
||||
@branch="handleBranch"
|
||||
@reply="handleReply"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
@@ -76,9 +77,11 @@
|
||||
:disabled="isStreaming"
|
||||
:streaming="isStreaming"
|
||||
:placeholder="isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
|
||||
:reply-to="replyTo"
|
||||
@send="handleSend"
|
||||
@extract="handleExtract"
|
||||
@stop="handleStop"
|
||||
@clear-reply="clearReply"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -123,6 +126,18 @@ import { useCodeContext } from '@/composables/useCodeContext'
|
||||
const codeContext = useCodeContext()
|
||||
const messageListRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Reply-to threading state
|
||||
const replyTo = ref<{ messageId: string; excerpt: string } | null>(null)
|
||||
|
||||
function handleReply(messageId: string, content: string) {
|
||||
const excerpt = content.slice(0, 100) + (content.length > 100 ? '...' : '')
|
||||
replyTo.value = { messageId, excerpt }
|
||||
}
|
||||
|
||||
function clearReply() {
|
||||
replyTo.value = null
|
||||
}
|
||||
|
||||
const messages = computed(() => chatStore.messages)
|
||||
|
||||
const virtualizer = useVirtualizer(computed(() => ({
|
||||
@@ -244,7 +259,14 @@ async function handleSend(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
await sendMessage(text)
|
||||
// Prepend quote if replying to a message
|
||||
let finalText = text
|
||||
if (replyTo.value) {
|
||||
const quoteLine = replyTo.value.excerpt.split('\n').map(l => `> ${l}`).join('\n')
|
||||
finalText = `${quoteLine}\n\n${text}`
|
||||
clearReply()
|
||||
}
|
||||
await sendMessage(finalText)
|
||||
}
|
||||
|
||||
function handlePromptSelect(_userMsg: Message, assistantMsg: Message | null) {
|
||||
|
||||
Reference in New Issue
Block a user