From 6d4e8e7927ef8d569b268abe39c0b1ce7f67b071 Mon Sep 17 00:00:00 2001 From: AzuTear Date: Sun, 14 Jun 2026 15:11:05 +0200 Subject: [PATCH] refactor: streamline flow board interactions --- .../src/components/dashboard/v2/AgentNode.vue | 6 +- .../components/dashboard/v2/FlowCanvas.vue | 101 ++------------ .../dashboard/v2/useFlowCanvasInteractions.ts | 125 ++++++++++++++++++ frontend/src/composables/useFlowBoardState.ts | 70 ++++++++++ frontend/src/views/Dashboard/FlowBoard.vue | 78 +++-------- 5 files changed, 235 insertions(+), 145 deletions(-) create mode 100644 frontend/src/components/dashboard/v2/useFlowCanvasInteractions.ts create mode 100644 frontend/src/composables/useFlowBoardState.ts diff --git a/frontend/src/components/dashboard/v2/AgentNode.vue b/frontend/src/components/dashboard/v2/AgentNode.vue index fc2aeb6..2c1f3e6 100644 --- a/frontend/src/components/dashboard/v2/AgentNode.vue +++ b/frontend/src/components/dashboard/v2/AgentNode.vue @@ -33,7 +33,11 @@ defineEmits<{ { entering } ]" :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)" >
diff --git a/frontend/src/components/dashboard/v2/FlowCanvas.vue b/frontend/src/components/dashboard/v2/FlowCanvas.vue index 258cb81..b93e46d 100644 --- a/frontend/src/components/dashboard/v2/FlowCanvas.vue +++ b/frontend/src/components/dashboard/v2/FlowCanvas.vue @@ -16,6 +16,7 @@ import type { AgentNodeData } from '../../../composables/useFlowLayout' import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout' import { icons } from '../../../composables/icons' import AgentNode from './AgentNode.vue' +import { useFlowCanvasInteractions } from './useFlowCanvasInteractions' const props = defineProps<{ agents: AgentNodeData[] @@ -172,93 +173,18 @@ watch( ) /* ── Drag & Drop ──────────────────────────────── */ -const DRAG_THRESHOLD = 5 - -interface DragState { - id: string - startX: number - startY: number - ox: number - oy: number - moved: boolean - raf: number | null -} - -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 -} +const { + onClickCapture, + onPointerDown, + onPointerMove, + onPointerUp, +} = useFlowCanvasInteractions({ + flowRef, + renderEdges, + updatePositions: positions => emit('updatePositions', positions), + selectAgent: id => emit('select', id), + getPositions: () => props.positions, +}) /* ── Keyboard handler for Enter key on buttons ── */ function handleReset() { @@ -271,6 +197,7 @@ function handleReset() {
void + updatePositions: (positions: Record) => void + selectAgent: (id: string) => void + getPositions: () => Record +} + +function findNode(target: EventTarget | null) { + return (target as HTMLElement | null)?.closest('.node') as HTMLElement | null +} + +export function useFlowCanvasInteractions(options: UseFlowCanvasInteractionsOptions) { + const drag = ref(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, + } +} diff --git a/frontend/src/composables/useFlowBoardState.ts b/frontend/src/composables/useFlowBoardState.ts new file mode 100644 index 0000000..bb69a68 --- /dev/null +++ b/frontend/src/composables/useFlowBoardState.ts @@ -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>({}) + const enteringIds = ref([]) + const localAgentPool = ref([...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) { + agentPositions.value = { ...positions } + } + + function sendChatMessage(text: string) { + chatStore.sendMessage(text) + } + + return { + addAgent, + agentPositions, + changeModel, + closeAgent, + enteringIds, + resetLayout, + selectAgent, + sendChatMessage, + updatePositions, + } +} diff --git a/frontend/src/views/Dashboard/FlowBoard.vue b/frontend/src/views/Dashboard/FlowBoard.vue index 70fdb0b..c5af6fc 100644 --- a/frontend/src/views/Dashboard/FlowBoard.vue +++ b/frontend/src/views/Dashboard/FlowBoard.vue @@ -12,7 +12,7 @@ * * Polling startet bei Mount, stoppt bei Unmount. */ -import { ref, onMounted, onUnmounted } from 'vue' +import { onMounted, onUnmounted } from 'vue' import { useAgentStore } from '../../stores/agents' import { useChatStore } from '../../stores/chat' 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 TaskStrip from '../../components/dashboard/v2/TaskStrip.vue' import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue' -import type { AgentNodeData } from '../../composables/useFlowLayout' -import { extraAgentPool } from '../../composables/useFlowLayout' +import { useFlowBoardState } from '../../composables/useFlowBoardState' /* ── Stores ──────────────────────────────────────── */ const agentStore = useAgentStore() const chatStore = useChatStore() const taskStore = useTaskStore() -/* ── Agent Layout State ───────────────────────────── */ -const agentPositions = ref>({}) -const enteringIds = ref([]) -const localAgentPool = ref([...extraAgentPool]) - -/* ── Event Handlers ───────────────────────────────── */ - -function handleSelect(id: string) { - agentStore.selectAgent(id) -} - -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) { - agentPositions.value = { ...pos } -} +const { + addAgent, + agentPositions, + changeModel, + closeAgent, + enteringIds, + resetLayout, + selectAgent, + sendChatMessage, + updatePositions, +} = useFlowBoardState(agentStore, chatStore) function handleBlockerClick() { console.log('[FlowBoard] blocker clicked') } -function handleChatSend(text: string) { - chatStore.sendMessage(text) -} - /* ── Lifecycle ────────────────────────────────────── */ onMounted(() => { agentStore.startPolling() @@ -114,10 +78,10 @@ onUnmounted(() => { :agents="agentStore.agentList" :positions="agentPositions" :entering-ids="enteringIds" - @select="handleSelect" - @add="handleAdd" - @reset-layout="handleResetLayout" - @update-positions="handleUpdatePositions" + @select="selectAgent" + @add="addAgent" + @reset-layout="resetLayout" + @update-positions="updatePositions" /> @@ -128,7 +92,7 @@ onUnmounted(() => { :messages="chatStore.messageList" :is-thinking="chatStore.isThinking" :error="chatStore.error" - @send="handleChatSend" + @send="sendChatMessage" />
@@ -137,9 +101,9 @@ onUnmounted(() => { v-if="agentStore.modalOpen && agentStore.selectedAgent" :agent="agentStore.selectedAgent" :agent-order="agentStore.agentOrder" - @close="handleCloseModal" - @select="handleSelect" - @change-model="handleChangeModel" + @close="closeAgent" + @select="selectAgent" + @change-model="changeModel" />