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:
Dorian
2026-03-03 23:08:46 +00:00
co-authored by Claude Opus 4.6
parent e439123a61
commit 6454518603
3 changed files with 58 additions and 14 deletions
+27 -13
View File
@@ -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) {