Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,14 @@ app.whenReady().then(() => {
ipcMain.handle('remove-message', async (_event, { threadId, messageId }) => {
return await threadManager.removeMessageBranchFromThread(threadId, messageId)
})

ipcMain.handle('remove-single-message', async (_event, { threadId, messageId }) => {
return await threadManager.removeSingleMessage(threadId, messageId)
})

ipcMain.handle('update-message', async (_event, { threadId, messageId, updates }) => {
return await threadManager.updateMessageInThread(threadId, messageId, updates)
})

ipcMain.handle('save-node-positions', async (_event, { threadId, positions }) => {
return await threadManager.updateThreadNodePositions(threadId, positions)
Expand Down
40 changes: 39 additions & 1 deletion src/main/threads/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ class ThreadManager {
return !!result
}

async updateThreadNodePositions(threadId: string, positions: Record<string, { x: number; y: number }>): Promise<boolean> {
async updateThreadNodePositions(threadId: string, positions: Record<string, { x: number; y: number; width?: number }>): Promise<boolean> {
const result = await this.updateThread(threadId, {
nodePositions: positions
})
Expand Down Expand Up @@ -615,6 +615,44 @@ class ThreadManager {

return !!result
}

// NEW: Remove a single message from a thread and re-chain its children
async removeSingleMessage(threadId: string, messageId: string): Promise<boolean> {
const thread = this.getThread(threadId)
if (!thread) return false

const msg = thread.messages.find(m => m.id === messageId)
if (!msg) return false

// 1. Delete associated files for THIS message only (except original and protected reference/analysis files)
if (msg.files) {
for (const file of msg.files) {
if (file.type !== FileType.Original) {
const cleanPath = file.url.replace('file://', '').replace('media://', '')

// Only allow deletion if it belongs to generated directories
const isGenerated = cleanPath.includes(`/${THREAD_DIRS.GENERATED_IMAGES}/`) ||
cleanPath.includes(`/${THREAD_DIRS.GENERATED_VIDEOS}/`)

if (isGenerated) {
this.deleteFile(file.url)
}
}
}
}

// 2. Re-chain children to point to this message's parent (editRefId)
const parentId = msg.editRefId
const updatedMessages = thread.messages
.filter(m => m.id !== messageId)
.map(m => m.editRefId === messageId ? { ...m, editRefId: parentId } : m)

const result = await this.updateThread(threadId, {
messages: updatedMessages
})

return !!result
}
}

export const threadManager = new ThreadManager()
4 changes: 4 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ const api = {
ipcRenderer.invoke('save-node-positions', { threadId, positions }),
removeMessage: (threadId: string, messageId: string) =>
ipcRenderer.invoke('remove-message', { threadId, messageId }),
removeSingleMessage: (threadId: string, messageId: string) =>
ipcRenderer.invoke('remove-single-message', { threadId, messageId }),
updateMessage: (threadId: string, messageId: string, updates: any) =>
ipcRenderer.invoke('update-message', { threadId, messageId, updates }),
showConfirmation: (options: { title: string, message: string, detail?: string, type?: string, buttons?: string[], defaultId?: number, cancelId?: number }) =>
ipcRenderer.invoke('show-confirmation', options),
saveVideo: (sourcePath: string) => ipcRenderer.invoke('save-video', sourcePath),
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,11 @@
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-zinc-300/50 dark:bg-zinc-700/50 rounded-full hover:bg-zinc-400/50 dark:hover:bg-zinc-600/50 transition-colors;
}

.custom-textarea-full {
@apply h-full flex flex-col;
}
.custom-textarea-full textarea {
@apply h-full min-h-[50vh] !resize-none;
}
}
69 changes: 57 additions & 12 deletions src/renderer/src/components/chat/ChatMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@
<!-- Role Indicator -->
<div class="flex items-center space-x-2 mb-2 px-1"
:class="message.role === MessageRole.User ? 'flex-row-reverse space-x-reverse' : 'flex-row'">
<span class="text-[10px] font-bold text-zinc-400/80 dark:text-zinc-500 uppercase tracking-[0.2em] font-heading">
<span
class="text-[10px] font-bold text-zinc-400/80 dark:text-zinc-500 uppercase tracking-[0.2em] font-heading">
{{ message.role === MessageRole.User ? 'You' : 'AI Assistant' }}
</span>

<!-- Action Buttons (Remove/Retry) -->
<div v-if="!message.isPending && (!isFirst || (isLatestUser && message.role === MessageRole.User))"
<!-- Action Buttons (Remove/Retry/Edit) -->
<div v-if="!message.isPending"
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity scale-75">
<IconButton v-if="isLatestUser && message.role === MessageRole.User" icon="IconRefresh" size="xs"
rounded="full" title="Retry from this message" @click="handleRetry" />
<IconButton v-if="!isFirst" icon="IconTrashLines" size="xs" rounded="full" title="Remove Message"
<IconButton v-if="message.role === MessageRole.User" icon="IconPencil" size="xs" rounded="full"
title="Edit Message" @click="handleStartEdit" />
<IconButton icon="IconTrashLines" size="xs" rounded="full" title="Remove Message"
@click="handleRemove" />
</div>
</div>
Expand All @@ -31,23 +34,27 @@
]">
<div class="flex items-start space-x-4">
<div v-if="message.isPending" class="mt-1 flex-shrink-0">
<div class="h-4 w-4 border-2 border-primary border-t-transparent rounded-[4px] animate-spin"></div>
<div class="h-4 w-4 border-2 border-primary border-t-transparent rounded-[4px] animate-spin">
</div>
</div>
<div class="flex flex-col gap-1 w-full min-w-0">
<div class="flex flex-col gap-1 w-full min-w-0">
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2 mb-1" v-if="message.isPending && videoStore.isBackgroundProcessingActive && isPipelineWaiting">
<div class="flex items-center gap-2 mb-1"
v-if="message.isPending && videoStore.isBackgroundProcessingActive && isPipelineWaiting">
<div class="flex flex-wrap gap-1">
<span v-for="task in videoStore.activeBackgroundTasks" :key="task.id"
<span v-for="task in videoStore.activeBackgroundTasks" :key="task.id"
class="text-[9px] font-medium bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded flex items-center gap-1 border border-blue-200 dark:border-blue-500/20">
<div class="h-1.5 w-1.5 border-[1.5px] border-current border-t-transparent rounded-full animate-spin"></div>
<div
class="h-1.5 w-1.5 border-[1.5px] border-current border-t-transparent rounded-full animate-spin">
</div>
{{ task.name }}
</span>
</div>
</div>

<div class="prose prose-sm max-w-none dark:prose-invert prose-p:leading-relaxed prose-p:my-0.5 prose-pre:bg-zinc-800 prose-pre:rounded-lg prose-pre:text-zinc-100 prose-headings:font-heading"
v-html="renderedContent"></div>

<!-- Meta/Status Row -->
<div v-if="!message.isPending && (message.role === MessageRole.AI || (message.role === MessageRole.User && (hasOriginalVideo || referencedVersion)))"
class="flex items-center justify-end gap-2 pt-0.5 opacity-60 hover:opacity-100 transition-opacity">
Expand Down Expand Up @@ -103,12 +110,30 @@
</div>
</Card>
</div>

<!-- Edit Modal -->
<Modal v-model="isModalOpen" title="Edit Message" size="xl" :custom-class="{ panel: '!h-[80vh] flex flex-col' }"
@close="isModalOpen = false">

<div class="flex flex-col gap-4 h-full overflow-hidden">
<div class="flex-1 overflow-hidden">
<TextArea v-model="editText" placeholder="Enter your message..."
class="h-full font-sans text-base custom-textarea-full" />
</div>
<div class="flex justify-end gap-3 mt-2">
<Button variant="outline" @click="isModalOpen = false">Cancel</Button>
<Button variant="primary" @click="handleSaveEdit">Save Changes</Button>
</div>
</div>
</Modal>
</div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { Card, IconButton } from 'pilotui/elements'
import { Card, IconButton, Button } from 'pilotui/elements'
import { Modal } from 'pilotui/complex'
import { TextArea } from 'pilotui/form'
import { MessageRole, Message, FileType } from '@shared/types'
import VideoResult from './VideoResult.vue'
import TimelineResult from './TimelineResult.vue'
Expand All @@ -125,19 +150,39 @@ const emit = defineEmits(['edit', 'save-video', 'scroll-to-reference', 'remove',

const videoStore = useVideoStore()
const isTimelineExpanded = ref(false)
const isModalOpen = ref(false)
const editText = ref('')

const handleStartEdit = () => {
editText.value = props.message.content
isModalOpen.value = true
}

const handleSaveEdit = async () => {
if (editText.value.trim() === props.message.content) {
isModalOpen.value = false
return
}

const success = await videoStore.updateMessageContent(props.message.id, editText.value)
if (success) {
isModalOpen.value = false
}
}

const handleRemove = async () => {
const response = await (window as any).api.showConfirmation({
title: 'Remove Message',
message: 'Are you sure you want to remove this message?',
detail: 'This will permanently remove this message and all its generated videos. This action cannot be undone.',
detail: 'This will permanently remove this message and its associated generated artifacts. Subsequent messages in this thread will be preserved.',
type: 'warning',
buttons: ['Cancel', 'Remove'],
defaultId: 1,
cancelId: 0
})

if (response === 1) {
await videoStore.removeSingleMessage(props.message.id)
emit('remove', props.message.id)
}
}
Expand Down
Loading
Loading