refactor: streamline flow board interactions
This commit is contained in:
@@ -33,7 +33,11 @@ defineEmits<{
|
|||||||
{ entering }
|
{ entering }
|
||||||
]"
|
]"
|
||||||
:style="{ left: left + '%', top: top + '%' }"
|
:style="{ left: left + '%', top: top + '%' }"
|
||||||
@click="$emit('select', agent.id)"
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
:aria-label="`${agent.name} öffnen`"
|
||||||
|
@keydown.enter.prevent="$emit('select', agent.id)"
|
||||||
|
@keydown.space.prevent="$emit('select', agent.id)"
|
||||||
>
|
>
|
||||||
<div class="ncard">
|
<div class="ncard">
|
||||||
<!-- Header: Avatar + Name + Role + Status-Dot -->
|
<!-- Header: Avatar + Name + Role + Status-Dot -->
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { AgentNodeData } from '../../../composables/useFlowLayout'
|
|||||||
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
|
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
|
||||||
import { icons } from '../../../composables/icons'
|
import { icons } from '../../../composables/icons'
|
||||||
import AgentNode from './AgentNode.vue'
|
import AgentNode from './AgentNode.vue'
|
||||||
|
import { useFlowCanvasInteractions } from './useFlowCanvasInteractions'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
agents: AgentNodeData[]
|
agents: AgentNodeData[]
|
||||||
@@ -172,93 +173,18 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/* ── Drag & Drop ──────────────────────────────── */
|
/* ── Drag & Drop ──────────────────────────────── */
|
||||||
const DRAG_THRESHOLD = 5
|
const {
|
||||||
|
onClickCapture,
|
||||||
interface DragState {
|
onPointerDown,
|
||||||
id: string
|
onPointerMove,
|
||||||
startX: number
|
onPointerUp,
|
||||||
startY: number
|
} = useFlowCanvasInteractions({
|
||||||
ox: number
|
flowRef,
|
||||||
oy: number
|
renderEdges,
|
||||||
moved: boolean
|
updatePositions: positions => emit('updatePositions', positions),
|
||||||
raf: number | null
|
selectAgent: id => emit('select', id),
|
||||||
}
|
getPositions: () => props.positions,
|
||||||
|
|
||||||
let drag: DragState | null = null
|
|
||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
|
||||||
const node = (e.target as HTMLElement).closest('.node') as HTMLElement | null
|
|
||||||
if (!node) return
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
const nr = node.getBoundingClientRect()
|
|
||||||
|
|
||||||
drag = {
|
|
||||||
id: node.dataset.id || '',
|
|
||||||
startX: e.clientX,
|
|
||||||
startY: e.clientY,
|
|
||||||
ox: e.clientX - (nr.left + nr.width / 2),
|
|
||||||
oy: e.clientY - (nr.top + nr.height / 2),
|
|
||||||
moved: false,
|
|
||||||
raf: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
node.setPointerCapture(e.pointerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerMove(e: PointerEvent) {
|
|
||||||
if (!drag) return
|
|
||||||
|
|
||||||
const dist = Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY)
|
|
||||||
if (!drag.moved && dist < DRAG_THRESHOLD) return
|
|
||||||
|
|
||||||
if (!drag.moved) {
|
|
||||||
drag.moved = true
|
|
||||||
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
|
||||||
if (node) node.classList.add('dragging')
|
|
||||||
}
|
|
||||||
|
|
||||||
const flow = flowRef.value
|
|
||||||
if (!flow) return
|
|
||||||
|
|
||||||
const fr = flow.getBoundingClientRect()
|
|
||||||
const x = Math.max(8, Math.min(92, ((e.clientX - drag.ox - fr.left) / fr.width) * 100))
|
|
||||||
const y = Math.max(10, Math.min(92, ((e.clientY - drag.oy - fr.top) / fr.height) * 100))
|
|
||||||
|
|
||||||
// Direct DOM manipulation for responsiveness
|
|
||||||
const node = flow.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
|
||||||
if (node) {
|
|
||||||
node.style.left = x + '%'
|
|
||||||
node.style.top = y + '%'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update positions state
|
|
||||||
const newPos = { ...props.positions }
|
|
||||||
newPos[drag.id] = { x, y }
|
|
||||||
emit('updatePositions', newPos)
|
|
||||||
|
|
||||||
// Debounced edge re-render
|
|
||||||
if (!drag.raf) {
|
|
||||||
drag.raf = requestAnimationFrame(() => {
|
|
||||||
renderEdges()
|
|
||||||
if (drag) drag.raf = null
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerUp() {
|
|
||||||
if (!drag) return
|
|
||||||
|
|
||||||
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
|
||||||
if (node) node.classList.remove('dragging')
|
|
||||||
|
|
||||||
if (!drag.moved) {
|
|
||||||
// Was a click — emit select
|
|
||||||
emit('select', drag.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
drag = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Keyboard handler for Enter key on buttons ── */
|
/* ── Keyboard handler for Enter key on buttons ── */
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
@@ -271,6 +197,7 @@ function handleReset() {
|
|||||||
<div
|
<div
|
||||||
ref="flowRef"
|
ref="flowRef"
|
||||||
class="flow"
|
class="flow"
|
||||||
|
@click.capture="onClickCapture"
|
||||||
@pointerdown="onPointerDown"
|
@pointerdown="onPointerDown"
|
||||||
@pointermove="onPointerMove"
|
@pointermove="onPointerMove"
|
||||||
@pointerup="onPointerUp"
|
@pointerup="onPointerUp"
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 5
|
||||||
|
const CLICK_SUPPRESSION_MS = 250
|
||||||
|
|
||||||
|
export interface FlowPosition {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
id: string
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
ox: number
|
||||||
|
oy: number
|
||||||
|
moved: boolean
|
||||||
|
raf: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFlowCanvasInteractionsOptions {
|
||||||
|
flowRef: { value: HTMLElement | null }
|
||||||
|
renderEdges: () => void
|
||||||
|
updatePositions: (positions: Record<string, FlowPosition>) => void
|
||||||
|
selectAgent: (id: string) => void
|
||||||
|
getPositions: () => Record<string, FlowPosition>
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNode(target: EventTarget | null) {
|
||||||
|
return (target as HTMLElement | null)?.closest('.node') as HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFlowCanvasInteractions(options: UseFlowCanvasInteractionsOptions) {
|
||||||
|
const drag = ref<DragState | null>(null)
|
||||||
|
const suppressClickUntil = ref(0)
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
const node = findNode(e.target)
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
const nr = node.getBoundingClientRect()
|
||||||
|
|
||||||
|
drag.value = {
|
||||||
|
id: node.dataset.id || '',
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
ox: e.clientX - (nr.left + nr.width / 2),
|
||||||
|
oy: e.clientY - (nr.top + nr.height / 2),
|
||||||
|
moved: false,
|
||||||
|
raf: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
node.setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!drag.value) return
|
||||||
|
|
||||||
|
const currentDrag = drag.value
|
||||||
|
const dist = Math.hypot(e.clientX - currentDrag.startX, e.clientY - currentDrag.startY)
|
||||||
|
if (!currentDrag.moved && dist < DRAG_THRESHOLD) return
|
||||||
|
|
||||||
|
if (!currentDrag.moved) {
|
||||||
|
currentDrag.moved = true
|
||||||
|
const node = options.flowRef.value?.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
|
||||||
|
if (node) node.classList.add('dragging')
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = options.flowRef.value
|
||||||
|
if (!flow) return
|
||||||
|
|
||||||
|
const fr = flow.getBoundingClientRect()
|
||||||
|
const x = Math.max(8, Math.min(92, ((e.clientX - currentDrag.ox - fr.left) / fr.width) * 100))
|
||||||
|
const y = Math.max(10, Math.min(92, ((e.clientY - currentDrag.oy - fr.top) / fr.height) * 100))
|
||||||
|
|
||||||
|
const node = flow.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
|
||||||
|
if (node) {
|
||||||
|
node.style.left = x + '%'
|
||||||
|
node.style.top = y + '%'
|
||||||
|
}
|
||||||
|
|
||||||
|
options.updatePositions({
|
||||||
|
...options.getPositions(),
|
||||||
|
[currentDrag.id]: { x, y },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentDrag.raf) {
|
||||||
|
currentDrag.raf = requestAnimationFrame(() => {
|
||||||
|
options.renderEdges()
|
||||||
|
if (drag.value) drag.value.raf = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
if (!drag.value) return
|
||||||
|
|
||||||
|
const currentDrag = drag.value
|
||||||
|
const node = options.flowRef.value?.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
|
||||||
|
if (node) node.classList.remove('dragging')
|
||||||
|
|
||||||
|
if (!currentDrag.moved) {
|
||||||
|
options.selectAgent(currentDrag.id)
|
||||||
|
} else {
|
||||||
|
suppressClickUntil.value = performance.now() + CLICK_SUPPRESSION_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
drag.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickCapture(e: MouseEvent) {
|
||||||
|
if (performance.now() >= suppressClickUntil.value) return
|
||||||
|
if (!findNode(e.target)) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onClickCapture,
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { extraAgentPool } from './useFlowLayout'
|
||||||
|
import type { AgentNodeData } from './useFlowLayout'
|
||||||
|
|
||||||
|
interface FlowBoardAgentStore {
|
||||||
|
agents: AgentNodeData[]
|
||||||
|
models: Array<{ id: string; alias: string }>
|
||||||
|
changeModel: (agentId: string, modelId: string) => void
|
||||||
|
selectAgent: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlowBoardChatStore {
|
||||||
|
sendMessage: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFlowBoardState(agentStore: FlowBoardAgentStore, chatStore: FlowBoardChatStore) {
|
||||||
|
const agentPositions = ref<Record<string, { x: number; y: number }>>({})
|
||||||
|
const enteringIds = ref<string[]>([])
|
||||||
|
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool])
|
||||||
|
|
||||||
|
function selectAgent(id: string) {
|
||||||
|
agentStore.selectAgent(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAgent() {
|
||||||
|
agentStore.selectAgent(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeModel(agentId: string, modelAlias: string) {
|
||||||
|
const model = agentStore.models.find(m => m.alias === modelAlias)
|
||||||
|
const modelId = model?.id ?? modelAlias
|
||||||
|
agentStore.changeModel(agentId, modelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAgent() {
|
||||||
|
const next = localAgentPool.value.shift()
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
enteringIds.value = [...enteringIds.value, next.id]
|
||||||
|
agentStore.agents.push(next)
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
enteringIds.value = enteringIds.value.filter(id => id !== next.id)
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLayout() {
|
||||||
|
agentPositions.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePositions(positions: Record<string, { x: number; y: number }>) {
|
||||||
|
agentPositions.value = { ...positions }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendChatMessage(text: string) {
|
||||||
|
chatStore.sendMessage(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addAgent,
|
||||||
|
agentPositions,
|
||||||
|
changeModel,
|
||||||
|
closeAgent,
|
||||||
|
enteringIds,
|
||||||
|
resetLayout,
|
||||||
|
selectAgent,
|
||||||
|
sendChatMessage,
|
||||||
|
updatePositions,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
*
|
*
|
||||||
* Polling startet bei Mount, stoppt bei Unmount.
|
* Polling startet bei Mount, stoppt bei Unmount.
|
||||||
*/
|
*/
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import { useAgentStore } from '../../stores/agents'
|
import { useAgentStore } from '../../stores/agents'
|
||||||
import { useChatStore } from '../../stores/chat'
|
import { useChatStore } from '../../stores/chat'
|
||||||
import { useTaskStore } from '../../stores/tasks'
|
import { useTaskStore } from '../../stores/tasks'
|
||||||
@@ -21,65 +21,29 @@ import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
|||||||
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
||||||
import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
|
import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
|
||||||
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
|
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
|
||||||
import type { AgentNodeData } from '../../composables/useFlowLayout'
|
import { useFlowBoardState } from '../../composables/useFlowBoardState'
|
||||||
import { extraAgentPool } from '../../composables/useFlowLayout'
|
|
||||||
|
|
||||||
/* ── Stores ──────────────────────────────────────── */
|
/* ── Stores ──────────────────────────────────────── */
|
||||||
const agentStore = useAgentStore()
|
const agentStore = useAgentStore()
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
|
|
||||||
/* ── Agent Layout State ───────────────────────────── */
|
const {
|
||||||
const agentPositions = ref<Record<string, { x: number; y: number }>>({})
|
addAgent,
|
||||||
const enteringIds = ref<string[]>([])
|
agentPositions,
|
||||||
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool])
|
changeModel,
|
||||||
|
closeAgent,
|
||||||
/* ── Event Handlers ───────────────────────────────── */
|
enteringIds,
|
||||||
|
resetLayout,
|
||||||
function handleSelect(id: string) {
|
selectAgent,
|
||||||
agentStore.selectAgent(id)
|
sendChatMessage,
|
||||||
}
|
updatePositions,
|
||||||
|
} = useFlowBoardState(agentStore, chatStore)
|
||||||
function handleCloseModal() {
|
|
||||||
agentStore.selectAgent(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChangeModel(agentId: string, modelAlias: string) {
|
|
||||||
// Modal emits the alias (display name); resolve to model ID for the API
|
|
||||||
const model = agentStore.models.find(m => m.alias === modelAlias)
|
|
||||||
const modelId = model?.id ?? modelAlias
|
|
||||||
agentStore.changeModel(agentId, modelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAdd() {
|
|
||||||
const pool = localAgentPool.value
|
|
||||||
if (pool.length === 0) return
|
|
||||||
const next = pool.shift()!
|
|
||||||
enteringIds.value.push(next.id)
|
|
||||||
agentStore.agents.push(next)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const idx = enteringIds.value.indexOf(next.id)
|
|
||||||
if (idx !== -1) enteringIds.value.splice(idx, 1)
|
|
||||||
}, 600)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleResetLayout() {
|
|
||||||
agentPositions.value = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpdatePositions(pos: Record<string, { x: number; y: number }>) {
|
|
||||||
agentPositions.value = { ...pos }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlockerClick() {
|
function handleBlockerClick() {
|
||||||
console.log('[FlowBoard] blocker clicked')
|
console.log('[FlowBoard] blocker clicked')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChatSend(text: string) {
|
|
||||||
chatStore.sendMessage(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Lifecycle ────────────────────────────────────── */
|
/* ── Lifecycle ────────────────────────────────────── */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
agentStore.startPolling()
|
agentStore.startPolling()
|
||||||
@@ -114,10 +78,10 @@ onUnmounted(() => {
|
|||||||
:agents="agentStore.agentList"
|
:agents="agentStore.agentList"
|
||||||
:positions="agentPositions"
|
:positions="agentPositions"
|
||||||
:entering-ids="enteringIds"
|
:entering-ids="enteringIds"
|
||||||
@select="handleSelect"
|
@select="selectAgent"
|
||||||
@add="handleAdd"
|
@add="addAgent"
|
||||||
@reset-layout="handleResetLayout"
|
@reset-layout="resetLayout"
|
||||||
@update-positions="handleUpdatePositions"
|
@update-positions="updatePositions"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TaskStrip :tasks="taskStore.taskList" :loading="taskStore.loading" :error="taskStore.error" />
|
<TaskStrip :tasks="taskStore.taskList" :loading="taskStore.loading" :error="taskStore.error" />
|
||||||
@@ -128,7 +92,7 @@ onUnmounted(() => {
|
|||||||
:messages="chatStore.messageList"
|
:messages="chatStore.messageList"
|
||||||
:is-thinking="chatStore.isThinking"
|
:is-thinking="chatStore.isThinking"
|
||||||
:error="chatStore.error"
|
:error="chatStore.error"
|
||||||
@send="handleChatSend"
|
@send="sendChatMessage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,9 +101,9 @@ onUnmounted(() => {
|
|||||||
v-if="agentStore.modalOpen && agentStore.selectedAgent"
|
v-if="agentStore.modalOpen && agentStore.selectedAgent"
|
||||||
:agent="agentStore.selectedAgent"
|
:agent="agentStore.selectedAgent"
|
||||||
:agent-order="agentStore.agentOrder"
|
:agent-order="agentStore.agentOrder"
|
||||||
@close="handleCloseModal"
|
@close="closeAgent"
|
||||||
@select="handleSelect"
|
@select="selectAgent"
|
||||||
@change-model="handleChangeModel"
|
@change-model="changeModel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user