Skip to content

Commit 5d79732

Browse files
Merge pull request #22142 from ahmedhamidawan/add_focus_mode_to_workflow_graph
Add an "Isolate" (focus) mode to workflow graph
2 parents a86f56b + 699d557 commit 5d79732

File tree

6 files changed

+255
-4
lines changed

6 files changed

+255
-4
lines changed

client/src/components/Workflow/Editor/Node.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ const props = defineProps({
212212
isInvocation: { type: Boolean, default: false },
213213
readonly: { type: Boolean, default: false },
214214
populatedInputs: { type: Boolean, default: false },
215+
isOutOfFocus: { type: Boolean, default: false },
215216
});
216217
217218
const emit = defineEmits([
@@ -275,6 +276,7 @@ const classes = computed(() => {
275276
"node-highlight": props.highlight || isActive.value,
276277
"is-active": isActive.value,
277278
"node-multi-selected": stateStore.getStepMultiSelected(props.id),
279+
"node-not-in-focus": props.isOutOfFocus && !isActive.value,
278280
};
279281
});
280282
@@ -447,6 +449,12 @@ function toggleSelected() {
447449
448450
$multi-selected: lighten($brand-info, 20%);
449451
452+
transition: opacity 0.2s ease;
453+
454+
&.node-not-in-focus {
455+
opacity: 0.7;
456+
}
457+
450458
&.node-multi-selected {
451459
box-shadow:
452460
0 0 0 2px $white,

client/src/components/Workflow/Editor/SVGConnection.vue

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ const props = defineProps({
1717
type: Object as PropType<TerminalPosition | null>,
1818
default: null,
1919
},
20+
focusedNodeIds: {
21+
type: Object as PropType<Set<number> | null>,
22+
default: null,
23+
},
2024
});
2125
2226
const ribbonMargin = 4;
@@ -172,6 +176,27 @@ const lineWidth = computed(() => {
172176
}
173177
});
174178
179+
/**
180+
* The connection is considered out of focus if either the input or output node is not focused.
181+
* The connection will never be considered out of focus if there are no focused nodes
182+
* (i.e. in non-focus mode).
183+
*/
184+
const isOutOfFocus = computed(() => {
185+
const ids = props.focusedNodeIds;
186+
if (!ids) {
187+
return false;
188+
}
189+
190+
// make sure dragging connections are never dimmed (though we aren't passing the `focusedNodeIds`
191+
// prop to dragging connections for now, we add this check just in case)
192+
if (props.terminalPosition) {
193+
return false;
194+
}
195+
196+
const { output, input } = props.connection;
197+
return !ids.has(output.stepId) || !ids.has(input.stepId);
198+
});
199+
175200
const connectionClass = computed(() => {
176201
const classList = ["connection"];
177202
@@ -183,6 +208,10 @@ const connectionClass = computed(() => {
183208
classList.push("invalid");
184209
}
185210
211+
if (isOutOfFocus.value) {
212+
classList.push("out-of-focus");
213+
}
214+
186215
return classList.join(" ");
187216
});
188217
@@ -239,6 +268,7 @@ function keyForIndex(index: number) {
239268
.workflow-editor-drawable-connection {
240269
.connection {
241270
stroke: #{$brand-primary};
271+
transition: opacity 0.2s ease;
242272
243273
&.optional {
244274
stroke-dasharray: 5 3;
@@ -247,6 +277,10 @@ function keyForIndex(index: number) {
247277
&.invalid {
248278
stroke: #{$brand-warning};
249279
}
280+
281+
&.out-of-focus {
282+
opacity: 0.2;
283+
}
250284
}
251285
}
252286
</style>

client/src/components/Workflow/Editor/WorkflowEdges.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const props = defineProps<{
1515
draggingConnection: TerminalPosition | null;
1616
draggingTerminal: OutputTerminals | null;
1717
transform: WorkflowTransform;
18+
focusedNodeIds: Set<number> | null;
1819
}>();
1920
2021
const { connectionStore } = useWorkflowStores();
@@ -57,7 +58,8 @@ function id(connection: Connection) {
5758
v-for="connection in connections"
5859
:id="id(connection)"
5960
:key="key(connection)"
60-
:connection="connection" />
61+
:connection="connection"
62+
:focused-node-ids="props.focusedNodeIds" />
6163
</svg>
6264
</div>
6365
</template>

client/src/components/Workflow/Editor/WorkflowGraph.vue

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { useElementBounding, useScroll } from "@vueuse/core";
2+
import { useElementBounding, useEventListener, useScroll } from "@vueuse/core";
33
import { storeToRefs } from "pinia";
44
import { computed, type PropType, provide, reactive, type Ref, ref, watch, watchEffect } from "vue";
55
@@ -10,6 +10,7 @@ import type { Step } from "@/stores/workflowStepStore";
1010
import { assertDefined } from "@/utils/assertions";
1111
1212
import { useD3Zoom } from "./composables/d3Zoom";
13+
import { useFocusedNodes } from "./composables/useFocusedNodes";
1314
import { useViewportBoundingBox } from "./composables/viewportBoundingBox";
1415
import { useWorkflowBoundingBox } from "./composables/workflowBoundingBox";
1516
import type { Rectangle, Vector } from "./modules/geometry";
@@ -44,8 +45,10 @@ const props = defineProps({
4445
detailedView: { type: Boolean, default: false },
4546
});
4647
47-
const { stateStore, stepStore } = useWorkflowStores();
48+
const { stateStore, stepStore, connectionStore } = useWorkflowStores();
4849
const { scale, activeNodeId, draggingPosition, draggingTerminal } = storeToRefs(stateStore);
50+
51+
const { focusedNodeIds } = useFocusedNodes(activeNodeId, connectionStore);
4952
const canvas: Ref<HTMLElement | null> = ref(null);
5053
5154
const elementBounding = useElementBounding(canvas, { windowResize: false, windowScroll: false });
@@ -148,6 +151,46 @@ function onDeactivate() {
148151
stateStore.activeNodeId = null;
149152
}
150153
154+
/**
155+
* Max total pixel movement (|dx| + |dy|) between pointerdown and pointerup
156+
* that still counts as a "click" rather than a pan/drag.
157+
*/
158+
const mouseMovementThreshold = 9;
159+
160+
/** The position of the last pointerdown event, or null if there hasn't been one yet. */
161+
let canvasPointerDownPos: { x: number; y: number } | null = null;
162+
163+
// capture: true makes these fire in the capture phase, before D3 zoom's bubble-phase
164+
// listeners on the same element — so D3 cannot stopImmediatePropagation us out.
165+
useEventListener(
166+
canvas,
167+
"pointerdown",
168+
(e: PointerEvent) => {
169+
canvasPointerDownPos = { x: e.clientX, y: e.clientY };
170+
},
171+
{ capture: true },
172+
);
173+
174+
useEventListener(
175+
canvas,
176+
"pointerup",
177+
(e: PointerEvent) => {
178+
if (!canvasPointerDownPos) {
179+
return;
180+
}
181+
const dx = Math.abs(e.clientX - canvasPointerDownPos.x);
182+
const dy = Math.abs(e.clientY - canvasPointerDownPos.y);
183+
canvasPointerDownPos = null;
184+
// walk up the DOM from the release target — if we hit a node, this was a node click
185+
const clickedOnNode = !!(e.target as HTMLElement).closest(".workflow-node");
186+
// only deactivate on a clean click (no pan) on the canvas background
187+
if (dx + dy <= mouseMovementThreshold && !clickedOnNode) {
188+
onDeactivate();
189+
}
190+
},
191+
{ capture: true },
192+
);
193+
151194
watch(
152195
() => transform.value.k,
153196
() => (stateStore.scale = transform.value.k),
@@ -211,7 +254,8 @@ defineExpose({
211254
<WorkflowEdges
212255
:transform="transform"
213256
:dragging-terminal="draggingTerminal"
214-
:dragging-connection="draggingPosition" />
257+
:dragging-connection="draggingPosition"
258+
:focused-node-ids="focusedNodeIds" />
215259
<WorkflowNode
216260
v-for="(step, key) in steps"
217261
:id="step.id"
@@ -227,6 +271,7 @@ defineExpose({
227271
:readonly="readonly"
228272
:is-invocation="props.isInvocation && (!props.detailedView || activeNodeId !== step.id)"
229273
:populated-inputs="props.populatedInputs"
274+
:is-out-of-focus="focusedNodeIds !== null && !focusedNodeIds.has(step.id)"
230275
@pan-by="panBy"
231276
@stopDragging="onStopDragging"
232277
@onDragConnector="onDragConnector"
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { createPinia, setActivePinia } from "pinia";
2+
import { beforeEach, describe, expect, it } from "vitest";
3+
import { ref } from "vue";
4+
5+
import { useConnectionStore } from "@/stores/workflowConnectionStore";
6+
import { type NewStep, useWorkflowStepStore } from "@/stores/workflowStepStore";
7+
import type { Connection } from "@/stores/workflowStoreTypes";
8+
9+
import { useFocusedNodes } from "./useFocusedNodes";
10+
11+
const WORKFLOW_ID = "test-workflow";
12+
13+
const baseStep: NewStep = {
14+
input_connections: {},
15+
inputs: [],
16+
name: "step",
17+
outputs: [],
18+
post_job_actions: {},
19+
tool_state: {},
20+
type: "tool",
21+
workflow_outputs: [],
22+
};
23+
24+
/** Build a connection from outputStepId → inputStepId */
25+
function conn(outputStepId: number, inputStepId: number): Connection {
26+
return {
27+
output: { stepId: outputStepId, name: "out", connectorType: "output" },
28+
input: { stepId: inputStepId, name: "in", connectorType: "input" },
29+
};
30+
}
31+
32+
describe("useFocusedNodes", () => {
33+
let connectionStore: ReturnType<typeof useConnectionStore>;
34+
let stepStore: ReturnType<typeof useWorkflowStepStore>;
35+
36+
beforeEach(() => {
37+
setActivePinia(createPinia());
38+
stepStore = useWorkflowStepStore(WORKFLOW_ID);
39+
connectionStore = useConnectionStore(WORKFLOW_ID);
40+
});
41+
42+
/** Add n steps to the store (IDs are assigned 0..n-1) */
43+
function addSteps(n: number) {
44+
for (let i = 0; i < n; i++) {
45+
stepStore.addStep({ ...baseStep, name: `Step ${i}` });
46+
}
47+
}
48+
49+
/** Convenience: call the composable and return the computed value */
50+
function focused(activeNodeId: number | null): Set<number> | null {
51+
const { focusedNodeIds } = useFocusedNodes(ref(activeNodeId), connectionStore);
52+
return focusedNodeIds.value;
53+
}
54+
55+
it("returns null when no node is active", () => {
56+
addSteps(2);
57+
connectionStore.addConnection(conn(0, 1));
58+
expect(focused(null)).toBeNull();
59+
});
60+
61+
it("includes only the active node when it has no connections", () => {
62+
addSteps(1);
63+
expect(focused(0)).toEqual(new Set([0]));
64+
});
65+
66+
it("includes full linear chain when focusing the middle node (A→B→C, focus B)", () => {
67+
addSteps(3);
68+
connectionStore.addConnection(conn(0, 1)); // A→B
69+
connectionStore.addConnection(conn(1, 2)); // B→C
70+
expect(focused(1)).toEqual(new Set([0, 1, 2]));
71+
});
72+
73+
it("includes full linear chain when focusing the start node (A→B→C, focus A)", () => {
74+
addSteps(3);
75+
connectionStore.addConnection(conn(0, 1)); // A→B
76+
connectionStore.addConnection(conn(1, 2)); // B→C
77+
expect(focused(0)).toEqual(new Set([0, 1, 2]));
78+
});
79+
80+
it("excludes sibling inputs — A→C and B→C: focusing A should not include B", () => {
81+
// A(0) → C(2) ← B(1)
82+
addSteps(3);
83+
connectionStore.addConnection(conn(0, 2)); // A→C
84+
connectionStore.addConnection(conn(1, 2)); // B→C
85+
expect(focused(0)).toEqual(new Set([0, 2]));
86+
});
87+
88+
it("excludes sibling outputs — A→B and A→C: focusing C should not include B", () => {
89+
// B(1) ← A(0) → C(2)
90+
addSteps(3);
91+
connectionStore.addConnection(conn(0, 1)); // A→B
92+
connectionStore.addConnection(conn(0, 2)); // A→C
93+
expect(focused(2)).toEqual(new Set([0, 2]));
94+
});
95+
96+
it("handles diamond — A→B→D and A→C→D: focusing B excludes C", () => {
97+
// A(0) → B(1) → D(3)
98+
// ↘ C(2) ↗
99+
addSteps(4);
100+
connectionStore.addConnection(conn(0, 1)); // A→B
101+
connectionStore.addConnection(conn(0, 2)); // A→C
102+
connectionStore.addConnection(conn(1, 3)); // B→D
103+
connectionStore.addConnection(conn(2, 3)); // C→D
104+
expect(focused(1)).toEqual(new Set([0, 1, 3])); // A, B, D — not C
105+
});
106+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { computed, type Ref } from "vue";
2+
3+
import type { WorkflowConnectionStore } from "@/stores/workflowConnectionStore";
4+
5+
/**
6+
* Given an active node and the connection store, returns the set of step IDs
7+
* that are in the upstream/downstream pipeline of the active node.
8+
* Returns `null` when no node is active (no filtering needed).
9+
*/
10+
export function useFocusedNodes(activeNodeId: Ref<number | null>, connectionStore: WorkflowConnectionStore) {
11+
const focusedNodeIds = computed((): Set<number> | null => {
12+
if (activeNodeId.value === null) {
13+
return null;
14+
}
15+
16+
const visited = new Set<number>();
17+
visited.add(activeNodeId.value);
18+
19+
// Two separate directional traversals are used to avoid including "sibling" steps:
20+
// e.g. if A→C and B→C, focusing on A should NOT include B (even though B feeds the same step C).
21+
22+
// Upstream: walk backwards through output→input connections (find all ancestors)
23+
const upstreamQueue = [activeNodeId.value];
24+
while (upstreamQueue.length) {
25+
const stepId = upstreamQueue.shift()!;
26+
for (const conn of connectionStore.getConnectionsForStep(stepId)) {
27+
if (conn.input.stepId === stepId) {
28+
const upstream = conn.output.stepId;
29+
if (!visited.has(upstream)) {
30+
visited.add(upstream);
31+
upstreamQueue.push(upstream);
32+
}
33+
}
34+
}
35+
}
36+
37+
// Downstream: walk forwards through output→input connections (find all descendants)
38+
const downstreamQueue = [activeNodeId.value];
39+
while (downstreamQueue.length) {
40+
const stepId = downstreamQueue.shift()!;
41+
for (const conn of connectionStore.getConnectionsForStep(stepId)) {
42+
if (conn.output.stepId === stepId) {
43+
const downstream = conn.input.stepId;
44+
if (!visited.has(downstream)) {
45+
visited.add(downstream);
46+
downstreamQueue.push(downstream);
47+
}
48+
}
49+
}
50+
}
51+
52+
return visited;
53+
});
54+
55+
return { focusedNodeIds };
56+
}

0 commit comments

Comments
 (0)