refactor: streamline flow board interactions
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s

This commit is contained in:
AzuTear
2026-06-14 15:11:05 +02:00
parent 0f8939306d
commit 6d4e8e7927
5 changed files with 235 additions and 145 deletions
@@ -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,
}
}
+21 -57
View File
@@ -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>