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 }
]"
: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">
<!-- Header: Avatar + Name + Role + Status-Dot -->
@@ -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
const {
onClickCapture,
onPointerDown,
onPointerMove,
onPointerUp,
} = useFlowCanvasInteractions({
flowRef,
renderEdges,
updatePositions: positions => emit('updatePositions', positions),
selectAgent: id => emit('select', id),
getPositions: () => props.positions,
})
}
}
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 ── */
function handleReset() {
@@ -271,6 +197,7 @@ function handleReset() {
<div
ref="flowRef"
class="flow"
@click.capture="onClickCapture"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@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.
*/
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<Record<string, { x: number; y: number }>>({})
const enteringIds = ref<string[]>([])
const localAgentPool = ref<AgentNodeData[]>([...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<string, { x: number; y: number }>) {
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"
/>
<TaskStrip :tasks="taskStore.taskList" :loading="taskStore.loading" :error="taskStore.error" />
@@ -128,7 +92,7 @@ onUnmounted(() => {
:messages="chatStore.messageList"
:is-thinking="chatStore.isThinking"
:error="chatStore.error"
@send="handleChatSend"
@send="sendChatMessage"
/>
</div>
@@ -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"
/>
</div>
</template>