Skip to content

Improve visualizer usability #1382

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 43 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e3b81f3
Complete reset when changing trace files
wenli-cai Jul 21, 2025
cbcea20
Fix naming for node current and past state
wenli-cai Jul 22, 2025
9c1fc6a
Enumerate the specific changes for each node and color them accordingly
wenli-cai Jul 22, 2025
25d04a4
Move away from using reflection for accessing node fields
wenli-cai Jul 22, 2025
1ce9943
Change node data being visualized
wenli-cai Jul 22, 2025
b5d3b16
Remove unnecessary frame tracking in main composable
wenli-cai Jul 22, 2025
6dddf94
Add legend for the colors used in the frame
wenli-cai Jul 22, 2025
6d2d31b
Group unaffected and affected children separately
wenli-cai Jul 24, 2025
e71a870
Separate composable components and change workflow UI pattern
wenli-cai Jul 24, 2025
18c3b6f
Don't apply simple/nested pattern for unaffected children group
wenli-cai Jul 24, 2025
5646d10
Add file dump functionality from live tracing mode
wenli-cai Jul 25, 2025
18f82cd
Fix NPE when enabling live view without a connected device
japplin Jul 24, 2025
07ab5fd
Enable compose hot reload
japplin Jul 25, 2025
58c58a9
Change sample/tutorial module's agp version
wenli-cai Jul 25, 2025
2aaa120
WIP text diff
wenli-cai Jul 25, 2025
5ec8a18
Add text-diff functionality
wenli-cai Jul 26, 2025
82eae8b
Fix compose violations
wenli-cai Jul 26, 2025
1633f4c
Merge branch 'main' into wenli/improve-visualizer
wenli-cai Jul 26, 2025
1e1da90
Apply changes from dependencyGuardBaseline --refresh-dependencies
wenli-cai Jul 26, 2025
8fbeab4
Apply changes from artifactsDump
workflow-pr-fixer[bot] Jul 26, 2025
a50f9a5
Fix merge bugs
wenli-cai Jul 26, 2025
c4fe05e
Allow device selection
wenli-cai Jul 26, 2025
6687f04
Manual api dump
wenli-cai Jul 26, 2025
cdfbc82
Remove compose hot reload changes
wenli-cai Jul 28, 2025
5d3e3f4
Apply changes from dependencyGuardBaseline --refresh-dependencies
wenli-cai Jul 28, 2025
fdf2253
Apply changes from apiDump
workflow-pr-fixer[bot] Jul 28, 2025
5280f84
Apply changes from artifactsDump
workflow-pr-fixer[bot] Jul 28, 2025
a2c98ae
Clean up
wenli-cai Jul 28, 2025
325b239
Add search box for available nodes in the specified frame
wenli-cai Jul 29, 2025
688688e
Refactor directories
wenli-cai Jul 29, 2025
24fae61
Improve error handling
wenli-cai Jul 29, 2025
cc40120
Make app window snap to the specific node when it is being searched.
wenli-cai Jul 30, 2025
a9a44e9
Fix zooming for sandbox
wenli-cai Jul 30, 2025
4f9f92b
Replace color legend with tooltip display on node hover
wenli-cai Aug 1, 2025
418abf1
New frame selector format
wenli-cai Aug 4, 2025
f2b5169
Fill background of info panel arrow
wenli-cai Aug 5, 2025
7b7b41a
Usage guide gifs
wenli-cai Aug 5, 2025
a489f00
Improve readme
wenli-cai Aug 5, 2025
03c505d
Fix reset state
wenli-cai Aug 12, 2025
08f412c
Cleanup code for review
wenli-cai Aug 12, 2025
543258b
Revert dependencies classpath
wenli-cai Aug 14, 2025
4a8d29d
Add remaining internal modifiers
wenli-cai Aug 14, 2025
6a056ca
Fix PR comments
wenli-cai Aug 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ turbine = "1.0.0"
vanniktech-publish = "0.32.0"

[plugins]

kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Expand Down
73 changes: 65 additions & 8 deletions workflow-trace-viewer/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Workflow Trace Viewer

A Compose for Desktop app that can be used to view Workflow traces.
A Compose for Desktop application that visualizes and debugs Workflow execution traces. This tool
helps developers understand the hierarchical structure and execution flow of their Workflow
applications by providing both file-based and live streaming trace visualization.

## Running

Expand All @@ -10,20 +12,75 @@ It can be run via Gradle using:
./gradlew :workflow-trace-viewer:run
```

By Default, the app will be in file parsing mode, where you are able to select a previously recorded workflow trace file for it to visualize the data.
## Usage Guide

By hitting the bottom switch, you are able to toggle to live stream mode, where data is directly pulled from the emulator into the visualizer. A connection can only happen once. If there needs to be rerecording of the trace, the emulator must first be restarted, and then the app must be restarted as well. This is due to the fact that any open socket will consume all render pass data, meaning there is nothing to read from the emulator.
By default, the app will be in file parsing mode, where you are able to select a previously recorded
workflow trace file for it to visualize the data. Once the workflow tree is rendered
in [File](#file-mode) or [Live](#live-mode) mode, you can switch frames (
see [Terminology](#Terminology)) to see different events that fired. All nodes are color coded based
on what had happened during this frame, and a text diff will show the specific changes. You can open
the right node panel and left click a box get a more detailed view of the specific node, or right
click to expand/collapse a specific node's children.

It is ***important*** to run the emulator first before toggling to live mode.
<img src="https://github.com/square/workflow-kotlin/raw/wenli/improve-visualizer/workflow-trace-viewer/docs/demo.gif" width="320" alt="Demo" />

#### File Mode

Once a file of the live data is saved, it can easily be uploaded to retrace the steps taken during
the live session. Currently, text/json files that are saved from recordings only contain raw data,
meaning it is simply a list of lists of node renderings.

<img src="https://github.com/square/workflow-kotlin/raw/wenli/improve-visualizer/workflow-trace-viewer/docs/file_mode.gif" width="320" alt="File Mode" />

#### Live Mode

By hitting the bottom switch, you are able to toggle to live stream mode, where data is directly
pulled from the emulator into the visualizer. To do so:

1. Start the app (on any device)
2. Start the app, and toggle the switch to enter Live mode
3. Select the desired device

Once in Live mode, frames will appear as you interact with the app. You may also save the current
data into a file saved in `~/Downloads` to be used later (this action will take some time, so it may
not appear immediately)

Render pass data is passively stored in a buffer before being sent to the visualizer, so you do not
need to immediately open/run the app to "catch" everything. However, since the the buffer has
limited size, it's strongly recommended to avoid interacting with the app — beyond starting it —
before Live mode has been triggered; this helps to avoid losing data.

<img src="https://github.com/square/workflow-kotlin/raw/wenli/improve-visualizer/workflow-trace-viewer/docs/live_mode.gif" width="320" alt="Live Mode" />

### Note

A connection can only happen once. There is currently no support for a recording of the trace data
due to the fact that an open socket will consume all render pass data when a connection begins. To
restart the recording:

1. (optional) Save the current trace
2. Switch out of Live mode
3. Restart the app
4. Switch back to Live mode, and the

### Terminology

**Trace**: A trace is a file — made up of frames — that contains the execution history of a Workflow. It includes information about render passes, how states have changed within workflows, and the specific props being passed through. The data collected to generate these should be in chronological order, and allows developers to step through the process easily.
`Trace`: A trace is a file — made up of frames — that contains the execution history of a Workflow.
It includes information about render passes, how states have changed within workflows, and the
specific props being passed through.

**Frame**: Essentially a "snapshot" of the current "state" of the whole Workflow tree. It contains relevant information about the changes in workflow states and how props are passed throughout.
`Frame`: Essentially a "snapshot" of the current "state" of the whole Workflow tree. It contains
relevant information about the changes in workflow states and how props are passed throughout.

- Note that "snapshot" and "state" are different from `snapshotState` and `State`, which are idiomatic to the Workflow library.
- Note that "snapshot" and "state" are different from `snapshotState` and `State`, which are
idiomatic to the Workflow library.

### External Libraries

[FileKit](https://github.com/vinceglb/FileKit) is an external library made to apply file operations on Kotlin and KMP projects. It's purpose in this app is to allow developers to upload their own json trace files. The motivation for its use is to quickly implement a file picker.
[FileKit](https://github.com/vinceglb/FileKit) is an external library made to apply file operations
on Kotlin and KMP projects. This simplified the development process of allowing file selection

## Future

This app can be integrated into the process of anyone working with Workflow, so it's highly
encouraged for anyone to make improvements that makes their life a little easier using this app.
23 changes: 11 additions & 12 deletions workflow-trace-viewer/api/workflow-trace-viewer.api
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,23 @@ public final class com/squareup/workflow1/traceviewer/MainKt {
public final class com/squareup/workflow1/traceviewer/ui/ComposableSingletons$WorkflowInfoPanelKt {
public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ui/ComposableSingletons$WorkflowInfoPanelKt;
public static field lambda-1 Lkotlin/jvm/functions/Function3;
public static field lambda-2 Lkotlin/jvm/functions/Function3;
public fun <init> ()V
public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
public final fun getLambda-2$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
}

public final class com/squareup/workflow1/traceviewer/util/ComposableSingletons$UploadFileKt {
public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/util/ComposableSingletons$UploadFileKt;
public static field lambda-1 Lkotlin/jvm/functions/Function3;
public final class com/squareup/workflow1/traceviewer/ui/control/ComposableSingletons$SearchBoxKt {
public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ui/control/ComposableSingletons$SearchBoxKt;
public static field lambda-1 Lkotlin/jvm/functions/Function2;
public static field lambda-2 Lkotlin/jvm/functions/Function2;
public fun <init> ()V
public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
}

public final class com/squareup/workflow1/traceviewer/util/JsonParserKt {
public static final field ROOT_ID Ljava/lang/String;
public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function2;
public final fun getLambda-2$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function2;
}

public final class com/squareup/workflow1/traceviewer/util/UploadFileKt {
public static final fun UploadFile (Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V
public final class com/squareup/workflow1/traceviewer/ui/control/ComposableSingletons$UploadFileKt {
public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ui/control/ComposableSingletons$UploadFileKt;
public static field lambda-1 Lkotlin/jvm/functions/Function3;
public fun <init> ()V
public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
}

2 changes: 2 additions & 0 deletions workflow-trace-viewer/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ kotlin {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
Expand All @@ -25,6 +26,7 @@ kotlin {
implementation(compose.materialIconsExtended)
implementation(libs.squareup.moshi.kotlin)
implementation(libs.filekit.dialogs.compose)
implementation(libs.java.diff.utils)
}
}
jvmTest {
Expand Down
Binary file added workflow-trace-viewer/docs/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added workflow-trace-viewer/docs/file_mode.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added workflow-trace-viewer/docs/live_mode.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,27 +1,39 @@
package com.squareup.workflow1.traceviewer

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowPosition.PlatformDefault.x
import com.squareup.workflow1.traceviewer.model.Node
import com.squareup.workflow1.traceviewer.model.NodeUpdate
import com.squareup.workflow1.traceviewer.ui.FrameSelectTab
import com.squareup.workflow1.traceviewer.ui.RightInfoPanel
import com.squareup.workflow1.traceviewer.ui.TraceModeToggleSwitch
import com.squareup.workflow1.traceviewer.util.RenderTrace
import com.squareup.workflow1.traceviewer.ui.control.DisplayDevices
import com.squareup.workflow1.traceviewer.ui.control.FileDump
import com.squareup.workflow1.traceviewer.ui.control.FrameNavigator
import com.squareup.workflow1.traceviewer.ui.control.SearchBox
import com.squareup.workflow1.traceviewer.ui.control.TraceModeToggleSwitch
import com.squareup.workflow1.traceviewer.ui.control.UploadFile
import com.squareup.workflow1.traceviewer.util.SandboxBackground
import com.squareup.workflow1.traceviewer.util.UploadFile
import com.squareup.workflow1.traceviewer.util.parser.RenderTrace
import io.github.vinceglb.filekit.PlatformFile

/**
Expand All @@ -31,14 +43,20 @@ import io.github.vinceglb.filekit.PlatformFile
internal fun App(
modifier: Modifier = Modifier
) {
var appWindowSize by remember { mutableStateOf(IntSize(0, 0)) }
var selectedNode by remember { mutableStateOf<NodeUpdate?>(null) }
val workflowFrames = remember { mutableStateListOf<Node>() }
var frameSize by remember { mutableIntStateOf(0) }
var rawRenderPass by remember { mutableStateOf("") }
var frameIndex by remember { mutableIntStateOf(0) }
val sandboxState = remember { SandboxState() }
val nodeLocations = remember { mutableListOf<SnapshotStateMap<Node, Offset>>() }
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's usually a bad practice to use mutableListOf in composition, should this be mutableStateListOf?

Copy link
Collaborator

Choose a reason for hiding this comment

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

nodeLocations is read in composition, so it needs to be snapshot state backed.

Suggested change
val nodeLocations = remember { mutableListOf<SnapshotStateMap<Node, Offset>>() }
val nodeLocations = remember { mutableStateListOf<SnapshotStateMap<Node, Offset>>() }

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hm, the original goal was that I would only want compositions to track what's happening to each SnapshotStateMap since that's where all the locations are stored, and I would just index into the list. Would my current code not work for that?


// Default to File mode, and can be toggled to be in Live mode.
var active by remember { mutableStateOf(false) }
var traceMode by remember { mutableStateOf<TraceMode>(TraceMode.File(null)) }
var selectedTraceFile by remember { mutableStateOf<PlatformFile?>(null) }
// frameIndex is set to -1 when app is in Live Mode, so we increment it by one to avoid off-by-one errors
val frameInd = if (traceMode is TraceMode.Live) frameIndex + 1 else frameIndex

LaunchedEffect(sandboxState) {
snapshotFlow { frameIndex }.collect {
Expand All @@ -47,47 +65,81 @@ internal fun App(
}

Box(
modifier = modifier
modifier = modifier.onSizeChanged {
appWindowSize = it
}
) {
fun resetStates() {
selectedTraceFile = null
selectedNode = null
frameIndex = 0
workflowFrames.clear()
frameSize = 0
rawRenderPass = ""
active = false
nodeLocations.clear()
}

// Main content
SandboxBackground(
appWindowSize = appWindowSize,
sandboxState = sandboxState,
) {
// if there is not a file selected and trace mode is live, then don't render anything.
val readyForFileTrace = traceMode is TraceMode.File && selectedTraceFile != null
val readyForLiveTrace = traceMode is TraceMode.Live
val readyForFileTrace = TraceMode.validateFileMode(traceMode)
val readyForLiveTrace = TraceMode.validateLiveMode(traceMode)

if (readyForFileTrace || readyForLiveTrace) {
active = true
RenderTrace(
traceSource = traceMode,
frameInd = frameIndex,
onFileParse = { workflowFrames.addAll(it) },
onNodeSelect = { node, prevNode ->
selectedNode = NodeUpdate(node, prevNode)
},
onNewFrame = { frameIndex += 1 }
onFileParse = { frameSize += it },
onNodeSelect = { selectedNode = it },
onNewFrame = { frameIndex += 1 },
onNewData = { rawRenderPass += "$it," },
storeNodeLocation = { node, loc -> nodeLocations[frameInd] += (node to loc) }
)
}
}

FrameSelectTab(
frames = workflowFrames,
currentIndex = frameIndex,
onIndexChange = { frameIndex = it },
modifier = Modifier.align(Alignment.TopCenter)
)

RightInfoPanel(
selectedNode = selectedNode,
Column(
modifier = Modifier
.align(Alignment.TopEnd)
)
.align(Alignment.TopCenter)
.padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (active) {
// Since we can jump from frame to frame, we fill in the map during each recomposition
if (nodeLocations.getOrNull(frameInd) == null) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
if (nodeLocations.getOrNull(frameInd) == null) {
if (nodeLocations[frameInd] == null) {

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think using square brackets to index could throw an IndexOutOfBoundsException right? Having .getOrNull was for the purpose when we go from frame 0 immediately to frame 10, so we would need to fill in the gap of frames with empty maps.

// frameSize has not been updated yet, so on the first frame, frameSize = nodeLocations.size = 0,
// and it will append a new map
while (nodeLocations.size <= frameSize) {
nodeLocations += mutableStateMapOf()
}
}

val frameNodeLocations = nodeLocations[frameInd]
SearchBox(
nodes = frameNodeLocations.keys.toList(),
onSearch = { name ->
sandboxState.scale = 1f
val node = frameNodeLocations.keys.first { it.name == name }
val newX = (sandboxState.offset.x - frameNodeLocations.getValue(node).x
+ appWindowSize.width / 2)
val newY = (sandboxState.offset.y - frameNodeLocations.getValue(node).y
+ appWindowSize.height / 2)
sandboxState.offset = Offset(x = newX, y = newY)
},
)

FrameNavigator(
totalFrames = frameSize,
currentIndex = frameIndex,
onIndexChange = { frameIndex = it },
)
}
}

TraceModeToggleSwitch(
onToggle = {
Expand All @@ -96,13 +148,12 @@ internal fun App(
frameIndex = 0
TraceMode.File(null)
} else {
// TODO: TraceRecorder needs to be able to take in multiple clients if this is the case
/*
We set the frame to -1 here since we always increment it during Live mode as the list of
frames get populated, so we avoid off by one when indexing into the frames.
*/
frameIndex = -1
TraceMode.Live
TraceMode.Live()
}
},
traceMode = traceMode,
Expand All @@ -120,6 +171,26 @@ internal fun App(
modifier = Modifier.align(Alignment.BottomStart)
)
}

if (traceMode is TraceMode.Live && (traceMode as TraceMode.Live).device == null) {
DisplayDevices(
onDeviceSelect = { selectedDevice ->
traceMode = TraceMode.Live(selectedDevice)
},
devices = listDevices(),
modifier = Modifier.align(Alignment.Center)
)

FileDump(
trace = rawRenderPass,
modifier = Modifier.align(Alignment.BottomStart)
)
}

RightInfoPanel(
selectedNode = selectedNode,
modifier = Modifier.align(Alignment.TopEnd)
)
}
}

Expand All @@ -134,5 +205,25 @@ internal class SandboxState {

internal sealed interface TraceMode {
data class File(val file: PlatformFile?) : TraceMode
data object Live : TraceMode
data class Live(val device: String? = null) : TraceMode

companion object {
fun validateLiveMode(traceMode: TraceMode): Boolean {
return traceMode is Live && traceMode.device != null
}

fun validateFileMode(traceMode: TraceMode): Boolean {
return traceMode is File && traceMode.file != null
}
}
}

/**
* Allows users to select from multiple devices that are currently running.
*/
private fun listDevices(): List<String> {
val process = ProcessBuilder("adb", "devices", "-l").start()
process.waitFor()
// We drop the header "List of devices attached"
return process.inputStream.bufferedReader().readLines().drop(1).dropLast(1)
}
Loading