Skip to content
40 changes: 40 additions & 0 deletions src/renderer/src/components/graph/ConversationNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@
:class="msg.role === 'user' ? 'prose-invert text-white' : 'dark:prose-invert'"></div>
</template>

<!-- Attached Images -->
<div v-if="msg.attachedImages && msg.attachedImages.length > 0" class="mt-2 mb-1 flex flex-wrap gap-1.5">
<div v-for="(img, idx) in msg.attachedImages" :key="idx"
class="relative w-10 h-10 rounded-lg overflow-hidden group/img cursor-pointer transition-all duration-300 hover:scale-[1.1] shadow-sm border border-black/5 dark:border-white/10"
@click="openPreview(img)">
<img :src="mediaUrl(img)" class="w-full h-full object-cover" />
<div
class="absolute inset-0 bg-black/20 opacity-0 group-hover/img:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"></path>
</svg>
</div>
</div>
</div>

<!-- Files -->
<div v-if="msg.files && msg.files.filter((f: any) => f.type !== 'original').length > 0"
class="mt-3 space-y-2">
Expand Down Expand Up @@ -183,6 +199,22 @@
</div>
</template>
</Modal>

<!-- Full Screen Modal -->
<Teleport to="body">
<div v-if="isFullScreen"
class="fixed inset-0 z-[9999] bg-black/95 flex flex-col items-center justify-center p-8 backdrop-blur-2xl animate-in fade-in duration-300"
@click="isFullScreen = false">
<button @click="isFullScreen = false"
class="absolute top-8 right-8 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-all active:scale-90 z-50">
<svg class="w-8 h-8" 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"></path>
</svg>
</button>
<img :src="fullScreenImageUrl"
class="max-w-full max-h-full rounded-2xl shadow-2xl ring-1 ring-white/20 object-contain" @click.stop />
</div>
</Teleport>
</div>
</template>

Expand Down Expand Up @@ -250,6 +282,14 @@ const editingMessageId = ref<string | null>(null)
const editText = ref('')
const isModalOpen = ref(false)

const isFullScreen = ref(false)
const fullScreenImageUrl = ref('')

const openPreview = (url: string) => {
fullScreenImageUrl.value = mediaUrl(url)
isFullScreen.value = true
}

const startEditing = (msg: any) => {
editingMessageId.value = msg.id
editText.value = msg.content
Expand Down
185 changes: 185 additions & 0 deletions src/renderer/src/components/graph/FrameNode.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<template>
<div
class="frame-node rounded-[2rem] border-2 border-dashed relative group"
:class="[
selected ? 'border-primary bg-primary/5 ring-4 ring-primary/10' : 'border-zinc-300 dark:border-zinc-700 bg-zinc-400/5 dark:bg-zinc-400/5',
isResizing ? 'cursor-nwse-resize' : ''
]"
:style="{
width: `${nodeWidth}px`,
height: `${nodeHeight}px`,
}"
>
<!-- Zoom-Sensitive Title -->
<div
class="absolute -top-10 left-0 flex items-center gap-2 px-1"
:style="{
transform: `scale(${1 / Math.max(0.4, viewport.zoom)})`,
transformOrigin: 'bottom left'
}"
>
<div
v-if="isEditing"
class="flex items-center bg-white dark:bg-zinc-800 rounded-lg shadow-xl ring-1 ring-black/5 dark:ring-white/10 overflow-hidden"
>
<input
ref="inputRef"
v-model="editTitle"
class="bg-transparent border-none outline-none px-3 py-1 font-bold text-sm text-zinc-900 dark:text-zinc-100 min-w-[120px]"
@blur="saveTitle"
@keydown.enter="saveTitle"
@keydown.esc="cancelEditing"
/>
</div>
<div
v-else
class="flex items-center gap-2 cursor-text group/title"
@dblclick="startEditing"
>
<div class="px-3 py-1 bg-zinc-100/80 dark:bg-zinc-900/80 backdrop-blur-md rounded-lg border border-zinc-200 dark:border-zinc-800 shadow-sm flex items-center gap-2 hover:border-primary/50 transition-colors">
<div class="w-2 h-2 rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.5)]"></div>
<span class="text-xs font-black uppercase tracking-widest text-zinc-600 dark:text-zinc-300 whitespace-nowrap">
{{ data.title || 'Untitled Group' }}
</span>
</div>
<button
class="p-1 opacity-0 group-hover/title:opacity-100 text-zinc-400 hover:text-primary transition-all duration-200"
@click="startEditing"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
</div>
</div>

<!-- Delete Button -->
<button
v-if="selected"
class="absolute top-4 right-4 p-1.5 bg-red-500/10 hover:bg-red-500 text-red-500 hover:text-white rounded-lg transition-all opacity-0 group-hover:opacity-100 z-50 pointer-events-auto"
@click="data.onDelete"
title="Delete Frame (ungroup children)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>

<!-- Resize Handle -->
<div
v-if="selected"
class="absolute bottom-4 right-4 w-6 h-6 flex items-center justify-center cursor-nwse-resize text-zinc-400 dark:text-zinc-600 hover:text-primary transition-colors opacity-0 group-hover:opacity-100"
@mousedown.stop.prevent="startResizing"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
</svg>
</div>

<!-- Selection Area Label -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none opacity-10 select-none">
<span class="text-4xl font-black uppercase tracking-[1em] text-zinc-400 dark:text-zinc-600 rotate-12">Frame</span>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useVueFlow } from '@vue-flow/core'

const props = defineProps<{
id: string
data: {
title: string
width: number
height: number
onUpdate?: (id: string, metadata: any) => void
onDelete?: () => void
}
selected?: boolean
}>()

const { viewport } = useVueFlow()

const nodeWidth = ref(props.data.width || 400)
const nodeHeight = ref(props.data.height || 300)

const isResizing = ref(false)

const startResizing = (e: MouseEvent) => {
isResizing.value = true
document.body.style.cursor = 'nwse-resize'
document.body.style.userSelect = 'none'

const startX = e.clientX
const startY = e.clientY
const startWidth = nodeWidth.value
const startHeight = nodeHeight.value

const onMouseMove = (moveEvent: MouseEvent) => {
if (!isResizing.value) return
const zoom = viewport.value.zoom || 1
const deltaX = (moveEvent.clientX - startX) / zoom
const deltaY = (moveEvent.clientY - startY) / zoom

nodeWidth.value = Math.max(200, startWidth + deltaX)
nodeHeight.value = Math.max(150, startHeight + deltaY)
}

const onMouseUp = () => {
isResizing.value = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)

if (props.data.onUpdate) {
props.data.onUpdate(props.id, {
width: nodeWidth.value,
height: nodeHeight.value
})
}
}

window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
}

// Title Editing logic
const isEditing = ref(false)
const editTitle = ref('')
const inputRef = ref<HTMLInputElement | null>(null)

const startEditing = () => {
editTitle.value = props.data.title || 'Untitled Group'
isEditing.value = true
nextTick(() => {
inputRef.value?.focus()
inputRef.value?.select()
})
}

const saveTitle = () => {
if (!isEditing.value) return
isEditing.value = false
if (props.data.onUpdate) {
props.data.onUpdate(props.id, { title: editTitle.value })
}
}

const cancelEditing = () => {
isEditing.value = false
}

// Sync from props
watch(() => props.data.width, (v) => { if (v) nodeWidth.value = v })
watch(() => props.data.height, (v) => { if (v) nodeHeight.value = v })

</script>

<style scoped>
.frame-node {
/* Ensure the node is behind others but not interactive for background clicks unless needed */
z-index: -1;
}
</style>
57 changes: 57 additions & 0 deletions src/renderer/src/components/graph/GraphHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<template>
<div
class="px-5 py-3 z-20 w-full flex items-center justify-between bg-zinc-50 dark:bg-zinc-950/80 backdrop-blur-md border-b border-zinc-200 dark:border-zinc-800 shadow-sm flex-shrink-0">
<div class="flex items-center gap-4 flex-1 min-w-0">
<button @click="$emit('back')"
class="flex items-center justify-center w-8 h-8 bg-zinc-100 dark:bg-zinc-800 rounded-full border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition text-zinc-600 dark:text-zinc-300 flex-shrink-0">
<svg class="w-4 h-4 ml-[-2px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7"></path>
</svg>
</button>

<h1
class="text-xl font-bold font-heading text-zinc-900 dark:text-zinc-100 truncate tracking-tight min-w-[200px] max-w-[40%] ml-2">
{{ title || 'Graph Task Manager' }}
</h1>

<!-- Vertical Separator -->
<div v-if="totalCost > 0" class="h-6 w-px bg-zinc-200 dark:bg-zinc-800 mx-1 flex-shrink-0"></div>

<!-- Project Cost -->
<div v-if="totalCost > 0"
class="flex items-center px-3 py-1.5 bg-zinc-100 dark:bg-zinc-900/40 rounded-xl border border-zinc-200 dark:border-zinc-800/40 transition-all hover:border-green-500/30 group flex-shrink-0">
<div class="flex flex-col items-start leading-none gap-0.5">
<span
class="text-[8px] font-black uppercase tracking-widest text-zinc-500 group-hover:text-green-500 transition-colors leading-none">Project
Cost</span>
<span
class="text-xs font-mono font-bold text-zinc-900 dark:text-zinc-100 italic transition-transform group-hover:scale-105 leading-none">${{
totalCost.toFixed(4) }}</span>
</div>
<div class="ml-3 p-1 bg-green-500/10 rounded-lg group-hover:bg-green-500/20 transition-colors">
<svg class="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z">
</path>
</svg>
</div>
</div>
</div>

<!-- Right Action Area -->
<div class="flex items-center justify-end gap-3 min-w-[20px] mr-12">
<slot name="actions" />
</div>
</div>
</template>

<script setup lang="ts">
defineProps<{
title?: string
totalCost: number
}>()

defineEmits<{
(e: 'back'): void
}>()
</script>
53 changes: 53 additions & 0 deletions src/renderer/src/components/graph/GraphToolbar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<template>
<div class="absolute left-6 top-6 z-30 flex items-center bg-zinc-100/80 dark:bg-zinc-900/80 backdrop-blur-md rounded-xl p-1 border border-zinc-200 dark:border-zinc-800 shadow-lg">
<button
@click="$emit('update:modelValue', 'pan')"
title="Pan View"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300"
:class="modelValue === 'pan' ? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 shadow-sm ring-1 ring-zinc-200 dark:ring-zinc-700' : 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-zinc-200/50 dark:hover:bg-zinc-800/50'"
>
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5"></path>
</svg>
</button>

<div class="w-px h-4 bg-zinc-200 dark:bg-zinc-800 mx-1"></div>

<button
@click="$emit('update:modelValue', 'select')"
title="Select Tool"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300"
:class="modelValue === 'select' ? 'bg-primary text-white shadow-md shadow-primary/20 ring-1 ring-primary/20' : 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-zinc-200/50 dark:hover:bg-zinc-800/50'"
>
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2" stroke-width="2.5" stroke="currentColor" fill="none" stroke-dasharray="4 2"></rect>
<path d="M7 7h1v1H7zM16 7h1v1h-1zM7 16h1v1H7zM16 16h1v1h-1z" fill="currentColor"></path>
</svg>
</button>

<div class="w-px h-4 bg-zinc-200 dark:bg-zinc-800 mx-1"></div>

<button
@click="$emit('update:modelValue', 'frame')"
title="Group Frames"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300"
:class="modelValue === 'frame' ? 'bg-secondary text-white shadow-md shadow-secondary/20 ring-1 ring-secondary/20' : 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-zinc-200/50 dark:hover:bg-zinc-800/50'"
>
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5zM14 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V5zM4 15a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-4zM14 15a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-4z" />
</svg>
</button>
</div>
</template>

<script setup lang="ts">
type ToolMode = 'pan' | 'select' | 'frame'

defineProps<{
modelValue: ToolMode
}>()

defineEmits<{
(e: 'update:modelValue', value: ToolMode): void
}>()
</script>
Loading
Loading