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"
/>