Skip to content

Conversation

@wenli-cai
Copy link
Contributor

@wenli-cai wenli-cai commented Jul 10, 2025

UI changes

  1. The specific nodes that were affected in the render pass now highlighted in context to the whole tree, rather than just showing the specific frame. This makes it easier to see the exact process and layers of workflow that were passed through

  2. UI for displaying node data is improved. It also shows the transition between the node's previous state and current state to allow for better understanding on what actually changed.

  3. The display window is also recentered each time a new frame is selected.

Code changes

  1. More fields were added to Node to support visualization capabilities, and is closely related with how ActionLogger records data from workflow runtime.

  2. NodeDiff was created to store a node's previous and current state.

  3. All UI functions are now internal, since there's no need to support any consumers using this specific API (as it is meant to be run as a downloadable application).

Hoists the UI state up to App.kt to allow for manual reset each time a different frame is selected
1. Force it to consume all pointerInput, meaning there cannot be interactino with any objects shadowed by it.
2. Hoist alignment into App.kt instead of forcing it to the right with a full Spacer
Currently this builds one tree which is an amalgamation of all the frames. It uses a recursive algorithm to fill in missing children nodes after each renderpass has been parsed
These can be viewed as pairs: for each frame, there is the full workflow tree that is slowly built up with the new nodes.

Add a copy method onto Node class to store a current copy of the workflow tree.
@wenli-cai wenli-cai force-pushed the wenli/visualizer-ui-improvements branch from 2c50fde to 370d1c7 Compare July 10, 2025 14:48
@wenli-cai wenli-cai force-pushed the wenli/visualizer-ui-improvements branch from cf2c3ed to 13fa318 Compare July 10, 2025 15:04
@wenli-cai wenli-cai force-pushed the wenli/visualizer-ui-improvements branch from a5ea5dd to b989a1a Compare July 11, 2025 17:53
@wenli-cai wenli-cai force-pushed the wenli/visualizer-ui-improvements branch 2 times, most recently from 5ebf042 to 32c5b9f Compare July 11, 2025 20:27
This commit introduces the idea of a NodeDiff, which stores the node's current and previous state - from the previous frame - in order to better represent what is actually changing through the UI.

If there is no previous frame (only on the first frame of the trace), there is no need to show a diff, so we plainly depict the node's states.
@wenli-cai wenli-cai marked this pull request as ready for review July 11, 2025 20:35
@wenli-cai wenli-cai requested review from a team and zach-klippenstein as code owners July 11, 2025 20:35
@wenli-cai wenli-cai force-pushed the wenli/visualizer-ui-improvements branch from 471f276 to abe0486 Compare July 11, 2025 20:46
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
}

internal fun Node.replaceChild(child: Node): Node {
return copy(children = this.children.map { if (it.id == child.id) child else it })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

immutable maps can use the plus operator (.plus or + as infix) to do this without having to map each node. e.g.,

Suggested change
return copy(children = this.children.map { if (it.id == child.id) child else it })
return copy(children = this.children.plus(child.id to child))

That will replace only the value with child.id as its key.

Kotlin Playground link: https://pl.kotl.in/8IX2XBth3

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.plus would work on a map, but children is a list of Nodes. Would you think a map is a better use case here? I was thinking I could at least keep the insertion order using a List, unless I used something like a LinkedHashMap?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like LinkedHashMap would be better for this case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, doing this would also mean that addChild and replaceChild are the exact same in implementation. Is it good still keep those as different functions, though, to make the recursive algorithm easier to understand?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can leave them separate as long as one uses the other (so you avoid having to update multiple places with the duplicate code).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WEre you going to do this? Or on a later PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in another comment here

package com.squareup.workflow1.traceviewer.model

/**
* Represents the difference between the current and previous state of a node in the workflow trace.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not really represent the difference in any way. It just holds both nodes.

It might better be called NodeUpdate or something like that?

You might start adding some diffing methods on this class to help with that (if it's useful).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true, NodeUpdate is good.

modifier = Modifier.padding(top = 8.dp, bottom = 8.dp)
)
}
val fields = listOf("Name", "Id", "Props", "State", "Rendering")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you stick with this method (which I think is fine btw!) it would be good to just have this list as a constant on Node so that it keeps it close the code its representing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed with reflection in this PR #1367

Comment on lines +110 to +114
if (mainTreeChild != null) {
mergedTree.replaceChild(mergeFrameIntoMainTree(frameChild, mainTreeChild))
} else {
mergedTree.addChild(frameChild)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option rather than keeping a separate list of affectedNodes would be to mark up these nodes as they are parsed within the actual node class itself. I.e. you could have an enum of change types and a field on Node for status., e.g., status is SAME, NEW, PROPS_CHANGED, STATE_CHANGED, CHILD_STATE_CHANGED or something like that? That way we can continue to add more colouring information to the UI for different aspects.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this would be great to have. I'll note this as a TODO in the future.

dependencies {
implementation(kotlin("test"))
implementation(kotlin("test-junit5"))
implementation(libs.junit.jupiter)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is jupiter for? I could not see something related to this on your unit test that you wrote?

Copy link
Contributor Author

@wenli-cai wenli-cai Jul 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wasn't being used for anything, thanks for catching that. I had goose write the tests and it had just included it. Is it cleaning if it's removed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great to remove this if it's not needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines 37 to 39
LaunchedEffect(frameIndex) {
sandboxState.reset()
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an anti-pattern (written about multiple times in Compose Chronicles).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be

LaunchedEffect(sandboxState) {
  snapshotFlow { frameIndex }.collect {
    sandboxState.reset()
  }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}

internal fun Node.replaceChild(child: Node): Node {
return copy(children = this.children.map { if (it.id == child.id) child else it })
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like LinkedHashMap would be better for this case.

Comment on lines 42 to 44
var frames by remember { mutableStateOf<List<Node>>(emptyList()) }
var fullTree by remember { mutableStateOf<List<Node>>(emptyList()) }
var affectedNodes by remember { mutableStateOf<List<Set<Node>>>(emptyList()) }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can any of these be SnapshotStateLists?

Or if you want them to be immutable, then consider using ImmutableList and friends.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know about this, this is great to know! The next step in the project would require these being mutable, so thanks for pointing this out

Comment on lines 102 to 104
if (frame.id != main.id) {
throw IllegalArgumentException("Frame root ID does not match main tree root ID.")
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Could be require

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines 45 to 46
val factor = 1f + (-scrollDelta * 0.1f)
sandboxState.scale = (sandboxState.scale * factor).coerceIn(0.1f, 10f)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment explaining what this math is for and what the constants mean?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +150 to +152
if (!open) {
return@Card
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX nit: Would AnimatedVisibility look more polished?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely, there's a lot of ui/ux changes I have written down to look more polished, and it's something I'm looking to complete later in the project.

val state: String,
val rendering: String = "",
val children: List<Node>,
) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class looks like it's intended to basically be a database table, where the properties are referenced dynamically by the UI. Can these properties just be stored in a map, with keys defined as constants? No need to use reflection.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These properties are all here for the purpose of moshi being able to parse a file into these objects. Unless there is a way to use moshi in the way you're describing it.

Even then, I'm not sure what the difference is. Could you help me explain the use case of having a data class like this vs using a map and treating it like a database table?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The map woudl be just key : value where both are strings. Since everything but children here is a string, the idea would be that essentially we are using this data class to store a bunch of String properties. Since they are all strings, we could just make it a nodeProperties = mapOf<String, String>() for everything but the children. once that is a map then you are able to easily iterate both the 'properties' keys (with nodeProperties.keys.) and the values (with nodeProperties.values) without needing to use reflection etc.

As for Moshi I imagine they have a default adapter that just maps JSON properties to a map.

Where it gets slightly more complicated is with the children property here, since that is not a String and is why we have this as a full class.

You could do something like:

data class Node ( val properties: ImmutableMap<String, String>, val children: LinkedHashMap<String, Node>) {} 

and then you'd have to change the parsing. ultimately probably that would be the 'best' fit for the data (and you'd be able to avoid using reflection as the properties list change since you'd always iterate over all the keys).

As for whether you do that now, I'm not too worried either way 🤷🏻

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

understood, this makes sense. However, I still don't understand the distinction that @zach-klippenstein made originally about classes and "database table classes". Are data classes not meant to be used in a way that just holds data?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ya they are meant for that. I think the distinction comes down to the type of data. if you have properties of different strongly types vs if you just have a set of String properties.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah gotcha, I understand now, thanks for clarifying that.

Comment on lines 41 to 43
Box(
modifier = modifier
) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in PR scope, but you probably want propagateMinConstraints here.

Copy link
Contributor Author

@wenli-cai wenli-cai Jul 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain this more? As far as I understand, this would be telling that all children containers need to be a specific size within the Box?

}
}

tasks.named<Test>("jvmTest") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also wonder if this is not needed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 - do we know why this is needed? if not, lets remove.

Copy link
Contributor Author

@wenli-cai wenli-cai Jul 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems like it's needed for registering and running junit tests in a multiplatform app?
https://kotlinlang.org/docs/jvm-test-using-junit.html#add-dependencies

But I havent removed the logging parts afterwards, those aren't necessary.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. sounds good.

}
}

tasks.named<Test>("jvmTest") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 - do we know why this is needed? if not, lets remove.

val state: String,
val rendering: String = "",
val children: List<Node>,
) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The map woudl be just key : value where both are strings. Since everything but children here is a string, the idea would be that essentially we are using this data class to store a bunch of String properties. Since they are all strings, we could just make it a nodeProperties = mapOf<String, String>() for everything but the children. once that is a map then you are able to easily iterate both the 'properties' keys (with nodeProperties.keys.) and the values (with nodeProperties.values) without needing to use reflection etc.

As for Moshi I imagine they have a default adapter that just maps JSON properties to a map.

Where it gets slightly more complicated is with the children property here, since that is not a String and is why we have this as a full class.

You could do something like:

data class Node ( val properties: ImmutableMap<String, String>, val children: LinkedHashMap<String, Node>) {} 

and then you'd have to change the parsing. ultimately probably that would be the 'best' fit for the data (and you'd be able to avoid using reflection as the properties list change since you'd always iterate over all the keys).

As for whether you do that now, I'm not too worried either way 🤷🏻

}

internal fun Node.replaceChild(child: Node): Node {
return copy(children = this.children.map { if (it.id == child.id) child else it })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can leave them separate as long as one uses the other (so you avoid having to update multiple places with the duplicate code).

}

internal fun Node.replaceChild(child: Node): Node {
return copy(children = this.children.map { if (it.id == child.id) child else it })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WEre you going to do this? Or on a later PR?

}

internal fun Node.replaceChild(child: Node): Node {
return copy(children = LinkedHashMap(this.children.plus(child.id to child)))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make replaceChild call addChild so you only have 1 place to update/maintain

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@wenli-cai wenli-cai merged commit 314c0b3 into main Jul 18, 2025
64 checks passed
@wenli-cai wenli-cai deleted the wenli/visualizer-ui-improvements branch July 18, 2025 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants