Compare commits

..

8 Commits

Author SHA1 Message Date
jaeone94
4dc67b375b fix: node replacement fails after execution and modal sync
- Detect missing nodes by unregistered type instead of has_errors flag,
  which gets cleared by clearAllNodeErrorFlags during execution
- Sync modal replace action with executionErrorStore so Errors Tab
  updates immediately when nodes are replaced from the dialog
2026-02-27 10:37:47 +09:00
jaeone94
1d8a01cdf8 fix: ensure node replacement data loads before workflow processing
Await nodeReplacementStore.load() before collectMissingNodesAndModels
to prevent race condition where replacement mappings are not yet
available when determining isReplaceable flag.
2026-02-27 00:14:43 +09:00
jaeone94
b585dfa4fc fix: address review feedback for handleReplaceAll
- Remove redundant parameter that shadowed composable ref
- Only remove actually replaced types from error list on partial success
2026-02-26 23:05:12 +09:00
jaeone94
1be6d27024 refactor: Destructure defineProps in SwapNodesCard.vue 2026-02-26 22:03:42 +09:00
jaeone94
5aa4baf116 fix: address review feedback for node replacement
- Use i18n key for 'Swap Nodes' group title
- Preserve partial replacement results on error instead of returning empty array
2026-02-26 21:53:00 +09:00
jaeone94
7d69a0db5b fix: remove unused export from scanMissingNodes 2026-02-26 20:28:10 +09:00
jaeone94
83bb4300e3 fix: address code review feedback on node replacement
- Add error toast in replaceNodesInPlace for user-visible failure
  feedback, returning empty array on error instead of throwing
- Guard removeMissingNodesByType behind replacement success check
  (replaced.length > 0) to prevent stale error list updates
- Sort buildMissingNodeGroups by priority for deterministic UI order
  (Swap Nodes 0 → Missing Node Packs 1 → Execution Errors)
- Add aux_id fallback and cnr_id precedence tests for getCnrIdFromNode
- Split replaceAllWarning from replaceWarning to fix i18n key mismatch
  between TabErrors tooltip and MissingNodesContent dialog
2026-02-26 20:24:56 +09:00
jaeone94
0d58a92e34 feat: add node replacement UI to Errors Tab
Integrate the existing node replacement functionality into the Errors
Tab, allowing users to replace missing nodes directly from the side
panel without opening the modal dialog.
New components:
- SwapNodesCard: container with guidance label and grouped rows
- SwapNodeGroupRow: per-type replacement row with expand/collapse,
  node instance list, locate button, and replace action
Bug fixes discovered during implementation:
- Fix stale canvas rendering after replacement by calling onNodeAdded
  to refresh VueNodeData (bypassed by replaceWithMapping)
- Guard initializeVueNodeLayout against duplicate layout creation
- Fix missing node list being overwritten by incomplete server 400
  response — replaced with full graph rescan via useMissingNodeScan
- Add removeMissingNodesByType to prune replaced types from error list
Cleanup:
- Remove dead code: buildMissingNodeHint, createMissingNodeTypeFromError
2026-02-26 20:24:44 +09:00
340 changed files with 2705 additions and 18834 deletions

View File

@@ -5,10 +5,3 @@ reviews:
high_level_summary: false
auto_review:
drafts: true
ignore_title_keywords:
- '[release]'
- '[backport'
ignore_usernames:
- comfy-pr-bot
- github-actions
- github-actions[bot]

View File

@@ -45,7 +45,7 @@ jobs:
- name: Run performance tests
id: perf
continue-on-error: true
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
run: pnpm exec playwright test --project=performance --workers=1
- name: Upload perf metrics
if: always()
@@ -61,7 +61,6 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: write
@@ -91,31 +90,6 @@ jobs:
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Download historical perf baselines
continue-on-error: true
run: |
RUNS=$(gh api \
"/repos/${{ github.repository }}/actions/workflows/ci-perf-report.yaml/runs?branch=${{ github.event.pull_request.base.ref }}&event=push&status=success&per_page=5" \
--jq '.workflow_runs[].id' || true)
if [ -z "$RUNS" ]; then
echo "No historical runs available"
exit 0
fi
mkdir -p temp/perf-history
INDEX=0
for RUN_ID in $RUNS; do
DIR="temp/perf-history/$INDEX"
mkdir -p "$DIR"
gh run download "$RUN_ID" -n perf-metrics -D "$DIR/" 2>/dev/null || true
INDEX=$((INDEX + 1))
done
echo "Downloaded $(ls temp/perf-history/*/perf-metrics.json 2>/dev/null | wc -l) historical baselines"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate perf report
run: npx --yes tsx scripts/perf-report.ts > perf-report.md

View File

@@ -1,45 +0,0 @@
---
# Dispatches a frontend-asset-build event to the cloud repo on push to
# cloud/* branches and main. The cloud repo handles the actual build,
# GCS upload, and secret management (Sentry, Algolia, GCS creds).
#
# This is fire-and-forget — it does NOT wait for the cloud workflow to
# complete. Status is visible in the cloud repo's Actions tab.
name: Cloud Frontend Build Dispatch
on:
push:
branches:
- 'cloud/*'
- 'main'
workflow_dispatch:
permissions: {}
concurrency:
group: cloud-dispatch-${{ github.ref }}
cancel-in-progress: true
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
- name: Build client payload
id: payload
run: |
payload="$(jq -nc \
--arg ref "${GITHUB_SHA}" \
--arg branch "${GITHUB_REF_NAME}" \
'{ref: $ref, branch: $branch}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
- name: Dispatch to cloud repo
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
repository: Comfy-Org/cloud
event-type: frontend-asset-build
client-payload: ${{ steps.payload.outputs.json }}

View File

@@ -35,7 +35,7 @@
}
],
"no-control-regex": "off",
"no-eval": "error",
"no-eval": "off",
"no-redeclare": "error",
"no-restricted-imports": [
"error",

View File

@@ -1,760 +0,0 @@
{
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
"revision": 0,
"last_node_id": 11,
"last_link_id": 18,
"nodes": [
{
"id": 2,
"type": "PreviewAny",
"pos": [1031, 434],
"size": [250, 178],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 5
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, null]
},
{
"id": 5,
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
"pos": [788, 433.5],
"size": [225, 380],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [5]
}
],
"properties": {
"proxyWidgets": [
["3", "string_a"],
["4", "value"],
["6", "value"],
["6", "value_1"]
]
},
"widgets_values": []
},
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [548, 451],
"size": [225, 142],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [4]
}
],
"title": "Outer",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Outer\n"]
}
],
"links": [
[4, 1, 0, 5, 0, "STRING"],
[5, 5, 0, 2, 0, "STRING"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 0",
"inputNode": {
"id": -10,
"bounding": [351, 432.5, 120, 120]
},
"outputNode": {
"id": -20,
"bounding": [1352, 294.5, 120, 60]
},
"inputs": [
{
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
"name": "string_a",
"type": "STRING",
"linkIds": [1],
"localized_name": "string_a",
"pos": [451, 452.5]
},
{
"id": "5fb3dcf7-9bfd-4b3c-a1b9-750b4f3edf19",
"name": "value",
"type": "STRING",
"linkIds": [13],
"pos": [451, 472.5]
},
{
"id": "55d24b8a-7c82-4b02-8e3d-ff31ffb8aa13",
"name": "value_1",
"type": "STRING",
"linkIds": [16],
"pos": [451, 492.5]
},
{
"id": "c1fe7cc3-547e-4fb0-b763-61888558d4bd",
"name": "value_1_1",
"type": "STRING",
"linkIds": [18],
"pos": [451, 512.5]
}
],
"outputs": [
{
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
"name": "STRING",
"type": "STRING",
"linkIds": [9],
"localized_name": "STRING",
"pos": [1372, 314.5]
}
],
"widgets": [],
"nodes": [
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [504, 437],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 13
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [2]
}
],
"title": "Inner 1",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 1\n"]
},
{
"id": 3,
"type": "StringConcatenate",
"pos": [743, 325],
"size": [347, 231],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 1
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
"pos": [1115, 301],
"size": [210, 196],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 7
},
{
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 16
},
{
"name": "value_1",
"type": "STRING",
"widget": {
"name": "value_1"
},
"link": 18
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [9]
}
],
"properties": {
"proxyWidgets": [
["5", "string_a"],
["11", "value"],
["9", "value"],
["10", "string_a"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "STRING"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "STRING"
},
{
"id": 6,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 1,
"target_id": 4,
"target_slot": 0,
"type": "STRING"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 2,
"target_id": 6,
"target_slot": 1,
"type": "STRING"
},
{
"id": 18,
"origin_id": -10,
"origin_slot": 3,
"target_id": 6,
"target_slot": 2,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 1",
"inputNode": {
"id": -10,
"bounding": [180, 739, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [1246, 612, 120, 60]
},
"inputs": [
{
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
"name": "string_a",
"type": "STRING",
"linkIds": [4],
"localized_name": "string_a",
"pos": [280, 759]
},
{
"id": "d50f6a62-0185-43d4-a174-a8a94bd8f6e7",
"name": "value",
"type": "STRING",
"linkIds": [14],
"pos": [280, 779]
},
{
"id": "6b78450e-5986-49cd-b743-c933e5a34a69",
"name": "value_1",
"type": "STRING",
"linkIds": [17],
"pos": [280, 799]
}
],
"outputs": [
{
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
"name": "STRING",
"type": "STRING",
"linkIds": [12],
"localized_name": "STRING",
"pos": [1266, 632]
}
],
"widgets": [],
"nodes": [
{
"id": 11,
"type": "PrimitiveStringMultiline",
"pos": [334, 742],
"size": [210, 88],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 14
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"title": "Inner 2",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 2\n"]
},
{
"id": 10,
"type": "StringConcatenate",
"pos": [581, 637],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [11]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 9,
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"pos": [1004, 613],
"size": [210, 142],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 11
},
{
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 17
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [12]
}
],
"properties": {
"proxyWidgets": [
["7", "string_a"],
["8", "value"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 11,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": 10,
"origin_slot": 0,
"target_id": 9,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 12,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 1,
"target_id": 11,
"target_slot": 0,
"type": "STRING"
},
{
"id": 17,
"origin_id": -10,
"origin_slot": 2,
"target_id": 9,
"target_slot": 1,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 2",
"inputNode": {
"id": -10,
"bounding": [262, 1222, 120, 80]
},
"outputNode": {
"id": -20,
"bounding": [1123.089999999999, 1125.1999999999998, 120, 60]
},
"inputs": [
{
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
"name": "string_a",
"type": "STRING",
"linkIds": [9],
"localized_name": "string_a",
"pos": [362, 1242]
},
{
"id": "3a545207-7202-42a9-a82f-3b62e1b0f459",
"name": "value",
"type": "STRING",
"linkIds": [15],
"pos": [362, 1262]
}
],
"outputs": [
{
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
"name": "STRING",
"type": "STRING",
"linkIds": [10],
"localized_name": "STRING",
"pos": [1143.089999999999, 1145.1999999999998]
}
],
"widgets": [],
"nodes": [
{
"id": 8,
"type": "PrimitiveStringMultiline",
"pos": [412.96000000000004, 1228.2399999999996],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [8]
}
],
"title": "Inner 3",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 3\n"]
},
{
"id": 7,
"type": "StringConcatenate",
"pos": [686.08, 1132.38],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 9
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 8
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [10]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
}
],
"groups": [],
"links": [
{
"id": 8,
"origin_id": 8,
"origin_slot": 0,
"target_id": 7,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 8,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [-412, 11]
},
"frontendVersion": "1.41.7"
},
"version": 0.4
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -171,7 +171,6 @@ test.describe('Node Interaction', () => {
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -67,66 +67,4 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
recordMeasurement(m)
console.log(`Clipping: ${m.layouts} forced layouts`)
})
test('subgraph idle style recalculations', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.perf.startMeasuring()
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('subgraph-idle')
recordMeasurement(m)
console.log(
`Subgraph idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('subgraph mouse interaction style recalculations', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 100; i++) {
await comfyPage.page.mouse.move(
box.x + (box.width * i) / 100,
box.y + (box.height * (i % 3)) / 3
)
}
const m = await comfyPage.perf.stopMeasuring('subgraph-mouse-sweep')
recordMeasurement(m)
console.log(
`Subgraph mouse sweep: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 20; i++) {
await comfyPage.page.mouse.click(
box.x + box.width / 3 + (i % 5) * 30,
box.y + box.height / 3 + (i % 4) * 30
)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('subgraph-dom-widget-clipping')
recordMeasurement(m)
console.log(`Subgraph clipping: ${m.layouts} forced layouts`)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -555,74 +555,6 @@ test.describe(
})
})
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
// slot connected externally from the Outer node, so it should be
// disabled. The remaining promoted textarea widgets (value, value_1)
// are unlinked and should be enabled.
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('string_a')
expect(promotedNames).toContain('value')
const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
expect(linkedWidget?.disabled).toBe(true)
const unlinkedWidgets = disabledState.filter(
(w) => w.name !== 'string_a'
)
for (const w of unlinkedWidgets) {
expect(w.disabled).toBe(false)
}
})
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// The promoted textareas that are NOT externally linked should be
// fully opaque and interactive.
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
const wrapper = textarea.locator('..')
const opacity = await wrapper.evaluate(
(el) => getComputedStyle(el).opacity
)
if (opacity === '1' && (await textarea.isEditable())) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
}
}
})
})
test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
comfyPage

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -1,7 +1,6 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import pluginJs from '@eslint/js'
import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint'
@@ -112,28 +111,6 @@ export default defineConfig([
tseslintConfigs.recommended,
// Difference in typecheck on CI vs Local
pluginVue.configs['flat/recommended'],
// Tailwind CSS v4 linting (class ordering, duplicates, conflicts, etc.)
betterTailwindcss.configs.recommended,
{
settings: {
'better-tailwindcss': {
entryPoint: 'packages/design-system/src/css/style.css'
}
},
rules: {
// Off: requires whitelisting non-Tailwind classes (PrimeIcons, custom CSS)
'better-tailwindcss/no-unknown-classes': 'off',
// Off: may conflict with oxfmt formatting
'better-tailwindcss/enforce-consistent-line-wrapping': 'off',
// Off: large batch change, enable and apply with `eslint --fix`
'better-tailwindcss/enforce-consistent-class-order': 'off',
// Off: large batch change (v3→v4 renames like rounded→rounded-sm),
// enable and apply with `eslint --fix` in a follow-up PR
'better-tailwindcss/enforce-canonical-classes': 'off',
// Off: large batch change, enable and apply with `eslint --fix`
'better-tailwindcss/no-deprecated-classes': 'off'
}
},
// Disables ESLint rules that conflict with formatters
eslintConfigPrettier,
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types

View File

@@ -41,9 +41,7 @@ const config: KnipConfig = {
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue'
'.github/workflows/ci-oss-assets-validation.yaml'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.11",
"version": "1.41.6",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -146,7 +146,6 @@
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-import-resolver-typescript": "catalog:",
"eslint-plugin-better-tailwindcss": "catalog:",
"eslint-plugin-import-x": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"eslint-plugin-storybook": "catalog:",

View File

@@ -16,7 +16,7 @@
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,clip-text-encode,get-video-components,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
@custom-variant touch (@media (hover: none));
@@ -199,7 +199,7 @@
#3e1ffc 65.17%,
#009dff 103.86%
),
linear-gradient(var(--color-button-surface, #2d2e32));
var(--color-button-surface, #2d2e32);
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
@@ -358,6 +358,26 @@
--button-active-surface: var(--color-charcoal-600);
--button-icon: var(--color-smoke-800);
--subscription-button-gradient:
linear-gradient(
315deg,
rgb(105 230 255 / 0.15) 0%,
rgb(99 73 233 / 0.5) 100%
),
radial-gradient(
70.71% 70.71% at 50% 50%,
rgb(62 99 222 / 0.15) 0.01%,
rgb(66 0 123 / 0.5) 100%
),
linear-gradient(
92deg,
#d000ff 0.38%,
#b009fe 37.07%,
#3e1ffc 65.17%,
#009dff 103.86%
),
var(--color-button-surface, #2d2e32);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
@@ -470,6 +490,7 @@
--color-button-icon: var(--button-icon);
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-subscription-button-gradient: var(--subscription-button-gradient);
--color-modal-card-background: var(--modal-card-background);
--color-modal-card-background-hovered: var(--modal-card-background-hovered);
@@ -613,14 +634,6 @@
}
}
@utility highlight {
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {

View File

@@ -1,3 +0,0 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 9C12.7761 9 13 9.22386 13 9.5V20C13 20.2761 13.2239 20.5 13.5 20.5H28C28.2761 20.5 28.5 20.7239 28.5 21C28.5 21.2761 28.2761 21.5 28 21.5H13.5C12.6716 21.5 12 20.8284 12 20V9.5C12 9.22386 12.2239 9 12.5 9ZM14.5 7C14.7761 7 15 7.22386 15 7.5V18C15 18.2761 15.2239 18.5 15.5 18.5H30C30.2761 18.5 30.5 18.7239 30.5 19C30.5 19.2761 30.2761 19.5 30 19.5H15.5C14.6716 19.5 14 18.8284 14 18V7.5C14 7.22386 14.2239 7 14.5 7ZM16.5 5C16.7761 5 17 5.22386 17 5.5V16C17 16.2761 17.2239 16.5 17.5 16.5H32C32.2761 16.5 32.5 16.7239 32.5 17C32.5 17.2761 32.2761 17.5 32 17.5H17.5C16.6716 17.5 16 16.8284 16 16V5.5C16 5.22386 16.2239 5 16.5 5ZM33.7061 2.5C34.4126 2.5 34.9999 3.08968 35 3.7998V14.2002C34.9999 14.9103 34.4126 15.5 33.7061 15.5H19.2939C18.5874 15.5 18.0001 14.9103 18 14.2002V3.7998C18.0001 3.08968 18.5874 2.5 19.2939 2.5H33.7061ZM19.1084 12.2676V14.2002C19.1085 14.3124 19.1814 14.3856 19.293 14.3857H33.7061C33.8179 14.3857 33.8915 14.3125 33.8916 14.2002V12.6094L30.7207 10.0615L28.1055 11.873C27.9107 12.005 27.6299 11.9923 27.4473 11.8438L23.8896 8.95312L19.1084 12.2676ZM19.2939 3.61426C19.1821 3.61426 19.1085 3.68744 19.1084 3.7998V10.9092L23.5957 7.79883C23.6707 7.74519 23.7587 7.71107 23.8496 7.7002C23.9954 7.68428 24.1465 7.72944 24.2598 7.82227L27.8164 10.7178L30.4385 8.90723C30.6334 8.7753 30.9141 8.78784 31.0967 8.93652L33.8916 11.1826V3.7998C33.8915 3.68747 33.8179 3.61426 33.7061 3.61426H19.2939ZM27.7939 5.09961C28.7054 5.09987 29.4561 5.8554 29.4561 6.77148C29.456 7.68754 28.7054 8.44213 27.7939 8.44238C26.8823 8.44238 26.1309 7.6877 26.1309 6.77148C26.1309 5.85524 26.8823 5.09961 27.7939 5.09961ZM27.7939 6.21387C27.4814 6.21387 27.2393 6.45737 27.2393 6.77148C27.2393 7.08557 27.4814 7.32812 27.7939 7.32812C28.1062 7.32788 28.3476 7.08542 28.3477 6.77148C28.3477 6.45752 28.1063 6.21411 27.7939 6.21387Z" fill="#8A8A8A"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -1,5 +0,0 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.12 4.65979C26.0073 3.75304 27.4985 3.73975 28.3787 4.65979L31.3397 7.62073H31.3387C32.2523 8.49397 32.253 10.0028 31.3182 10.8785L31.3192 10.8795L23.62 18.5797L22.5096 19.6891V7.27112L22.7 7.08069L25.12 4.65979Z" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M32.3396 13.8499C33.6177 13.8499 34.65 14.8804 34.6501 16.1594V20.3401C34.6501 21.6199 33.618 22.6506 32.3396 22.6506H20.3503L21.4597 21.5403L29.1501 13.8499H32.3396Z" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M17.7604 17.2496C17.1991 17.2496 16.7498 17.6986 16.7497 18.2594C16.7497 18.8208 17.1995 19.2701 17.7604 19.2701C18.3065 19.2699 18.7702 18.8157 18.7702 18.2594C18.7701 17.6982 18.3211 17.2499 17.7604 17.2496ZM22.1706 18.2399C22.1706 20.6987 20.2192 22.6498 17.7604 22.65C15.2992 22.65 13.3493 20.677 13.3493 18.2399V3.65979C13.3494 2.38005 14.3815 1.34933 15.6598 1.34924H19.8405C21.1222 1.34934 22.1421 2.38132 22.1706 3.64514V18.2399Z" stroke="#8A8A8A" stroke-width="1.3"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5693 3L9.67677 14.099C9.59169 14.292 9.33927 14.3393 9.19012 14.1901L5.5 10.5M14.5693 3L1.65457 8.23468C1.40927 8.33411 1.40355 8.67936 1.64543 8.78686L5.5 10.5M14.5693 3L5.5 10.5M5.5 10.5C5.66712 10.5669 5.37259 10.3728 5.5 10.5ZM5.5 10.5C5.62741 10.6272 5.43279 10.333 5.5 10.5ZM5.5 10.5V13.5L7 12" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 500 B

View File

@@ -1,3 +0,0 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.8593 13C35.4827 13 36.0007 13.4988 36.0009 14.0996V22.9004C36.0007 23.5012 35.4827 24 34.8593 24H22.1425C21.5191 24 21.0011 23.5012 21.0009 22.9004V14.0996C21.0011 13.4988 21.5191 13 22.1425 13H34.8593ZM21.9794 21.2646V22.9004C21.9796 22.9953 22.0439 23.0566 22.1425 23.0566H34.8593C34.9579 23.0566 35.0222 22.9953 35.0224 22.9004V21.5547L32.2255 19.3984L29.9179 20.9307C29.746 21.0424 29.498 21.032 29.3369 20.9062L26.1982 18.4609L21.9794 21.2646ZM16.5009 10.5C16.777 10.5001 17.0009 10.7239 17.0009 11V17.5C17.001 18.3283 17.6727 18.9998 18.5009 19H18.7089L18.0615 18.3535C17.8665 18.1583 17.8665 17.8417 18.0615 17.6465C18.2567 17.4512 18.5742 17.4512 18.7695 17.6465L20.1835 19.0605C20.3785 19.2557 20.3784 19.5723 20.1835 19.7676L18.7695 21.1816C18.5742 21.3769 18.2567 21.3768 18.0615 21.1816C17.8666 20.9864 17.8664 20.6697 18.0615 20.4746L18.5361 20H18.5009C17.1204 19.9998 16.001 18.8806 16.0009 17.5V11C16.001 10.724 16.2249 10.5002 16.5009 10.5ZM22.1425 13.9424C22.0439 13.9424 21.9796 14.0047 21.9794 14.0996V20.1152L25.9384 17.4834C26.0045 17.4381 26.082 17.4096 26.162 17.4004C26.2907 17.3869 26.4244 17.4244 26.5244 17.5029L29.663 19.9531L31.9755 18.4219C32.1475 18.3102 32.3954 18.3204 32.5566 18.4463L35.0224 20.3467V14.0996C35.0222 14.0047 34.9579 13.9424 34.8593 13.9424H22.1425ZM29.6425 15.2002C30.4468 15.2003 31.1093 15.839 31.1093 16.6143C31.1093 17.3895 30.4469 18.0283 29.6425 18.0283C28.8381 18.0283 28.1747 17.3895 28.1747 16.6143C28.1748 15.839 28.8381 15.2002 29.6425 15.2002ZM29.6425 16.1426C29.3668 16.1426 29.1533 16.3485 29.1533 16.6143C29.1533 16.8801 29.3667 17.0859 29.6425 17.0859C29.9182 17.0859 30.1318 16.88 30.1318 16.6143C30.1318 16.3485 29.9182 16.1426 29.6425 16.1426ZM22.0917 0C23.6924 0.000102997 25.0009 1.29808 25.0009 2.91016V7.08984C25.0009 8.70192 23.6924 9.9999 22.0917 10H14.9111C13.3103 10 12.0009 8.70198 12.0009 7.08984V2.91016C12.0009 1.29802 13.3103 0 14.9111 0H22.0917ZM14.9111 1.04199C13.8598 1.04199 13.0331 1.87561 13.0331 2.91016V7.08984C13.0331 8.12439 13.8598 8.95801 14.9111 8.95801H22.0917C23.1429 8.95791 23.9697 8.12432 23.9697 7.08984V2.91016C23.9697 1.87568 23.1429 1.04209 22.0917 1.04199H14.9111ZM17.0146 2.36523C17.1026 2.36806 17.189 2.39596 17.2636 2.44531L20.5556 4.53613C20.7284 4.64278 20.7919 4.83988 20.7919 5C20.7919 5.16007 20.7283 5.35719 20.5556 5.46387L17.2646 7.55469C17.1075 7.65858 16.9024 7.66034 16.7441 7.56055L16.7431 7.55957C16.5867 7.45933 16.4941 7.27149 16.499 7.08398V2.91016C16.4953 2.64423 16.6989 2.38047 16.9755 2.36621L17.0146 2.36523ZM17.5068 6.1416L19.3095 5L17.5068 3.85449V6.1416ZM20.4999 5.22559L20.5234 5.19434C20.5303 5.1833 20.5364 5.17121 20.5419 5.15918C20.5308 5.1833 20.5167 5.20593 20.4999 5.22559Z" fill="#8A8A8A"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,11 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isPreviewableMediaType,
truncateFilename
@@ -202,147 +198,6 @@ describe('formatUtil', () => {
})
})
describe('getFilenameDetails', () => {
it('splits simple filenames into name and suffix', () => {
expect(getFilenameDetails('file.txt')).toEqual({
filename: 'file',
suffix: 'txt'
})
})
it('handles filenames with multiple dots', () => {
expect(getFilenameDetails('my.file.name.png')).toEqual({
filename: 'my.file.name',
suffix: 'png'
})
})
it('handles filenames without extension', () => {
expect(getFilenameDetails('README')).toEqual({
filename: 'README',
suffix: null
})
})
it('recognises .app.json as a compound extension', () => {
expect(getFilenameDetails('workflow.app.json')).toEqual({
filename: 'workflow',
suffix: 'app.json'
})
})
it('recognises .app.json case-insensitively', () => {
expect(getFilenameDetails('Workflow.APP.JSON')).toEqual({
filename: 'Workflow',
suffix: 'app.json'
})
})
it('handles regular .json files normally', () => {
expect(getFilenameDetails('workflow.json')).toEqual({
filename: 'workflow',
suffix: 'json'
})
})
it('treats bare .app.json as a dotfile without basename', () => {
expect(getFilenameDetails('.app.json')).toEqual({
filename: '.app',
suffix: 'json'
})
})
})
describe('getPathDetails', () => {
it('splits a path with .app.json extension', () => {
const result = getPathDetails('workflows/test.app.json')
expect(result).toEqual({
directory: 'workflows',
fullFilename: 'test.app.json',
filename: 'test',
suffix: 'app.json'
})
})
it('splits a path with .json extension', () => {
const result = getPathDetails('workflows/test.json')
expect(result).toEqual({
directory: 'workflows',
fullFilename: 'test.json',
filename: 'test',
suffix: 'json'
})
})
})
describe('appendWorkflowJsonExt', () => {
it('appends .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
})
it('appends .json when isApp is false', () => {
expect(appendWorkflowJsonExt('test', false)).toBe('test.json')
})
it('replaces .json with .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test.json', true)).toBe('test.app.json')
})
it('replaces .app.json with .json when isApp is false', () => {
expect(appendWorkflowJsonExt('test.app.json', false)).toBe('test.json')
})
it('leaves .app.json unchanged when isApp is true', () => {
expect(appendWorkflowJsonExt('test.app.json', true)).toBe('test.app.json')
})
it('leaves .json unchanged when isApp is false', () => {
expect(appendWorkflowJsonExt('test.json', false)).toBe('test.json')
})
it('handles case-insensitive extensions', () => {
expect(appendWorkflowJsonExt('test.JSON', true)).toBe('test.app.json')
expect(appendWorkflowJsonExt('test.APP.JSON', false)).toBe('test.json')
})
})
describe('ensureWorkflowSuffix', () => {
it('appends suffix when missing', () => {
expect(ensureWorkflowSuffix('file', 'json')).toBe('file.json')
})
it('does not double-append when suffix already present', () => {
expect(ensureWorkflowSuffix('file.json', 'json')).toBe('file.json')
})
it('appends compound suffix when missing', () => {
expect(ensureWorkflowSuffix('file', 'app.json')).toBe('file.app.json')
})
it('does not double-append compound suffix', () => {
expect(ensureWorkflowSuffix('file.app.json', 'app.json')).toBe(
'file.app.json'
)
})
it('replaces .json with .app.json when suffix is app.json', () => {
expect(ensureWorkflowSuffix('file.json', 'app.json')).toBe(
'file.app.json'
)
})
it('replaces .app.json with .json when suffix is json', () => {
expect(ensureWorkflowSuffix('file.app.json', 'json')).toBe('file.json')
})
it('handles case-insensitive extension detection', () => {
expect(ensureWorkflowSuffix('file.JSON', 'json')).toBe('file.json')
expect(ensureWorkflowSuffix('file.APP.JSON', 'app.json')).toBe(
'file.app.json'
)
})
})
describe('isPreviewableMediaType', () => {
it('returns true for image/video/audio/3D', () => {
expect(isPreviewableMediaType('image')).toBe(true)

View File

@@ -26,44 +26,13 @@ export function formatCamelCase(str: string): string {
return processedWords.join(' ')
}
// Metadata cannot be associated with workflows, so extension encodes the mode.
const JSON_SUFFIX = 'json'
const APP_JSON_SUFFIX = `app.${JSON_SUFFIX}`
const JSON_EXT = `.${JSON_SUFFIX}`
const APP_JSON_EXT = `.${APP_JSON_SUFFIX}`
export function appendJsonExt(path: string) {
if (!path.toLowerCase().endsWith(JSON_EXT)) {
path += JSON_EXT
if (!path.toLowerCase().endsWith('.json')) {
path += '.json'
}
return path
}
export type WorkflowSuffix = typeof JSON_SUFFIX | typeof APP_JSON_SUFFIX
export function getWorkflowSuffix(
suffix: string | null | undefined
): WorkflowSuffix {
return suffix === APP_JSON_SUFFIX ? APP_JSON_SUFFIX : JSON_SUFFIX
}
export function appendWorkflowJsonExt(path: string, isApp: boolean): string {
return ensureWorkflowSuffix(path, isApp ? APP_JSON_SUFFIX : JSON_SUFFIX)
}
export function ensureWorkflowSuffix(
name: string,
suffix: WorkflowSuffix
): string {
const lower = name.toLowerCase()
if (lower.endsWith(APP_JSON_EXT)) {
name = name.slice(0, -APP_JSON_EXT.length)
} else if (lower.endsWith(JSON_EXT)) {
name = name.slice(0, -JSON_EXT.length)
}
return name + '.' + suffix
}
export function highlightQuery(
text: string,
query: string,
@@ -127,27 +96,19 @@ export function formatCommitHash(value: string): string {
/**
* Returns various filename components.
* Recognises compound extensions like `.app.json`.
* Example:
* - fullFilename: 'file.txt' → { filename: 'file', suffix: 'txt' }
* - fullFilename: 'file.app.json' → { filename: 'file', suffix: 'app.json' }
* - fullFilename: 'file.txt'
* - filename: 'file'
* - suffix: 'txt'
*/
export function getFilenameDetails(fullFilename: string) {
const lower = fullFilename.toLowerCase()
if (
lower.endsWith(APP_JSON_EXT) &&
fullFilename.length > APP_JSON_EXT.length
) {
if (fullFilename.includes('.')) {
return {
filename: fullFilename.slice(0, -APP_JSON_EXT.length),
suffix: APP_JSON_SUFFIX
filename: fullFilename.split('.').slice(0, -1).join('.'),
suffix: fullFilename.split('.').pop() ?? null
}
}
const dotIndex = fullFilename.lastIndexOf('.')
if (dotIndex <= 0) return { filename: fullFilename, suffix: null }
return {
filename: fullFilename.slice(0, dotIndex),
suffix: fullFilename.slice(dotIndex + 1)
} else {
return { filename: fullFilename, suffix: null }
}
}

75
pnpm-lock.yaml generated
View File

@@ -180,9 +180,6 @@ catalogs:
eslint-import-resolver-typescript:
specifier: ^4.4.4
version: 4.4.4
eslint-plugin-better-tailwindcss:
specifier: ^4.3.1
version: 4.3.1
eslint-plugin-import-x:
specifier: ^4.16.1
version: 4.16.1
@@ -639,9 +636,6 @@ importers:
eslint-import-resolver-typescript:
specifier: 'catalog:'
version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-better-tailwindcss:
specifier: 'catalog:'
version: 4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3)
eslint-plugin-import-x:
specifier: 'catalog:'
version: 4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))
@@ -1922,10 +1916,6 @@ packages:
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/css-tree@3.6.9':
resolution: {integrity: sha512-3D5/OHibNEGk+wKwNwMbz63NMf367EoR4mVNNpxddCHKEb2Nez7z62J2U6YjtErSsZDoY0CsccmoUpdEbkogNA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
'@eslint/eslintrc@3.3.3':
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -5324,19 +5314,6 @@ packages:
eslint-import-resolver-webpack:
optional: true
eslint-plugin-better-tailwindcss@4.3.1:
resolution: {integrity: sha512-b6xM31GukKz0WlgMD0tQdY/rLjf/9mWIk8EcA45ngOKJPPQf1C482xZtBlT357jyunQE2mOk4NlPcL4i9Pr85A==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23.0.0}
peerDependencies:
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
oxlint: ^1.35.0
tailwindcss: ^3.3.0 || ^4.1.17
peerDependenciesMeta:
eslint:
optional: true
oxlint:
optional: true
eslint-plugin-import-x@4.16.1:
resolution: {integrity: sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -6581,9 +6558,6 @@ packages:
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
mdn-data@2.23.0:
resolution: {integrity: sha512-786vq1+4079JSeu2XdcDjrhi/Ry7BWtjDl9WtGPWLiIHb2T66GvIVflZTBoSNZ5JqTtJGYEVMuFA/lbQlMOyDQ==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@@ -7806,10 +7780,6 @@ packages:
resolution: {integrity: sha512-2SG1TnJGjMkD4+gblONMGYSrwAzYi+ymOitD+Jb/iMYm57nH20PlkVeMQRah3yDMKEa0QQYUF/QPWpdW7C6zNg==}
engines: {node: ^14.18.0 || >=16.0.0}
synckit@0.11.12:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0}
table@6.9.0:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'}
@@ -7818,10 +7788,6 @@ packages:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwind-csstree@0.1.4:
resolution: {integrity: sha512-FzD187HuFIZEyeR7Xy6sJbJll2d4SybS90satC8SKIuaNRC05CxMvdzN7BUsfDQffcnabckRM5OIcfArjsZ0mg==}
engines: {node: '>=18.18'}
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
@@ -7952,10 +7918,6 @@ packages:
ts-map@1.0.3:
resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==}
tsconfig-paths-webpack-plugin@4.2.0:
resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==}
engines: {node: '>=10.13.0'}
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
@@ -9746,11 +9708,6 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/css-tree@3.6.9':
dependencies:
mdn-data: 2.23.0
source-map-js: 1.2.1
'@eslint/eslintrc@3.3.3':
dependencies:
ajv: 6.14.0
@@ -13500,23 +13457,6 @@ snapshots:
- supports-color
optional: true
eslint-plugin-better-tailwindcss@4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3):
dependencies:
'@eslint/css-tree': 3.6.9
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
enhanced-resolve: 5.19.0
jiti: 2.6.1
synckit: 0.11.12
tailwind-csstree: 0.1.4
tailwindcss: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
valibot: 1.2.0(typescript@5.9.3)
optionalDependencies:
eslint: 9.39.1(jiti@2.6.1)
oxlint: 1.49.0(oxlint-tsgolint@0.14.2)
transitivePeerDependencies:
- typescript
eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)):
dependencies:
'@typescript-eslint/types': 8.56.0
@@ -14953,8 +14893,6 @@ snapshots:
mdn-data@2.12.2: {}
mdn-data@2.23.0: {}
mdurl@2.0.0: {}
media-encoder-host-broker@8.0.19:
@@ -16633,10 +16571,6 @@ snapshots:
'@pkgr/core': 0.2.9
tslib: 2.8.1
synckit@0.11.12:
dependencies:
'@pkgr/core': 0.2.9
table@6.9.0:
dependencies:
ajv: 8.18.0
@@ -16647,8 +16581,6 @@ snapshots:
tagged-tag@1.0.0: {}
tailwind-csstree@0.1.4: {}
tailwind-merge@2.6.0: {}
tailwindcss-primeui@0.6.1(tailwindcss@4.2.0):
@@ -16759,13 +16691,6 @@ snapshots:
ts-map@1.0.3: {}
tsconfig-paths-webpack-plugin@4.2.0:
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.19.0
tapable: 2.3.0
tsconfig-paths: 4.2.0
tsconfig-paths@3.15.0:
dependencies:
'@types/json5': 0.0.29

View File

@@ -61,7 +61,6 @@ catalog:
eslint: ^9.39.1
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-storybook: ^10.2.10

View File

@@ -1,14 +1,4 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import type { MetricStats } from './perf-stats'
import {
classifyChange,
computeStats,
formatSignificance,
isNoteworthy,
zScore
} from './perf-stats'
import { existsSync, readFileSync } from 'node:fs'
interface PerfMeasurement {
name: string
@@ -30,76 +20,12 @@ interface PerfReport {
const CURRENT_PATH = 'test-results/perf-metrics.json'
const BASELINE_PATH = 'temp/perf-baseline/perf-metrics.json'
const HISTORY_DIR = 'temp/perf-history'
type MetricKey = 'styleRecalcs' | 'layouts' | 'taskDurationMs'
const REPORTED_METRICS: { key: MetricKey; label: string; unit: string }[] = [
{ key: 'styleRecalcs', label: 'style recalcs', unit: '' },
{ key: 'layouts', label: 'layouts', unit: '' },
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' }
]
function groupByName(
measurements: PerfMeasurement[]
): Map<string, PerfMeasurement[]> {
const map = new Map<string, PerfMeasurement[]>()
for (const m of measurements) {
const list = map.get(m.name) ?? []
list.push(m)
map.set(m.name, list)
}
return map
}
function loadHistoricalReports(): PerfReport[] {
if (!existsSync(HISTORY_DIR)) return []
const reports: PerfReport[] = []
for (const dir of readdirSync(HISTORY_DIR)) {
const filePath = join(HISTORY_DIR, dir, 'perf-metrics.json')
if (!existsSync(filePath)) continue
try {
reports.push(JSON.parse(readFileSync(filePath, 'utf-8')) as PerfReport)
} catch {
console.warn(`Skipping malformed perf history: ${filePath}`)
}
}
return reports
}
function getHistoricalStats(
reports: PerfReport[],
testName: string,
metric: MetricKey
): MetricStats {
const values: number[] = []
for (const r of reports) {
const group = groupByName(r.measurements)
const samples = group.get(testName)
if (samples) {
const mean =
samples.reduce((sum, s) => sum + s[metric], 0) / samples.length
values.push(mean)
}
}
return computeStats(values)
}
function computeCV(stats: MetricStats): number {
return stats.mean > 0 ? (stats.stddev / stats.mean) * 100 : 0
}
function formatValue(value: number, unit: string): string {
return unit === 'ms' ? `${value.toFixed(0)}ms` : `${value.toFixed(0)}`
}
function formatDelta(pct: number | null): string {
if (pct === null) return '—'
const sign = pct >= 0 ? '+' : ''
return `${sign}${pct.toFixed(0)}%`
}
function meanMetric(samples: PerfMeasurement[], key: MetricKey): number {
return samples.reduce((sum, s) => sum + s[key], 0) / samples.length
function formatDelta(pct: number): string {
if (pct >= 20) return `+${pct.toFixed(0)}% 🔴`
if (pct >= 10) return `+${pct.toFixed(0)}% 🟠`
if (pct > -10) return `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}% ⚪`
return `${pct.toFixed(0)}% 🟢`
}
function formatBytes(bytes: number): string {
@@ -108,167 +34,18 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function renderFullReport(
prGroups: Map<string, PerfMeasurement[]>,
baseline: PerfReport,
historical: PerfReport[]
): string[] {
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
const tableHeader = [
'| Metric | Baseline | PR (n=3) | Δ | Sig |',
'|--------|----------|----------|---|-----|'
]
const flaggedRows: string[] = []
const allRows: string[] = []
for (const [testName, prSamples] of prGroups) {
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prValues = prSamples.map((s) => s[key])
const prMean = prValues.reduce((a, b) => a + b, 0) / prValues.length
const histStats = getHistoricalStats(historical, testName, key)
const cv = computeCV(histStats)
if (!baseSamples?.length) {
allRows.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
)
continue
}
const baseVal = meanMetric(baseSamples, key)
const deltaPct =
baseVal === 0
? prMean === 0
? 0
: null
: ((prMean - baseVal) / baseVal) * 100
const z = zScore(prMean, histStats)
const sig = classifyChange(z, cv)
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
allRows.push(row)
if (isNoteworthy(sig)) {
flaggedRows.push(row)
}
}
function calcDelta(
baseline: number,
current: number
): { pct: number; isNew: boolean } {
if (baseline > 0) {
return { pct: ((current - baseline) / baseline) * 100, isNew: false }
}
if (flaggedRows.length > 0) {
lines.push(
`⚠️ **${flaggedRows.length} regression${flaggedRows.length > 1 ? 's' : ''} detected**`,
'',
...tableHeader,
...flaggedRows,
''
)
} else {
lines.push('No regressions detected.', '')
}
lines.push(
`<details><summary>All metrics</summary>`,
'',
...tableHeader,
...allRows,
'',
'</details>',
''
)
lines.push(
`<details><summary>Historical variance (last ${historical.length} runs)</summary>`,
'',
'| Metric | μ | σ | CV |',
'|--------|---|---|-----|'
)
for (const [testName] of prGroups) {
for (const { key, label, unit } of REPORTED_METRICS) {
const stats = getHistoricalStats(historical, testName, key)
if (stats.n < 2) continue
const cv = computeCV(stats)
lines.push(
`| ${testName}: ${label} | ${formatValue(stats.mean, unit)} | ${formatValue(stats.stddev, unit)} | ${cv.toFixed(1)}% |`
)
}
}
lines.push('', '</details>')
return lines
return current > 0 ? { pct: Infinity, isNew: true } : { pct: 0, isNew: false }
}
function renderColdStartReport(
prGroups: Map<string, PerfMeasurement[]>,
baseline: PerfReport,
historicalCount: number
): string[] {
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
lines.push(
`> Collecting baseline variance data (${historicalCount}/5 runs). Significance will appear after 2 main branch runs.`,
'',
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
)
for (const [testName, prSamples] of prGroups) {
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prValues = prSamples.map((s) => s[key])
const prMean = prValues.reduce((a, b) => a + b, 0) / prValues.length
if (!baseSamples?.length) {
lines.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
)
continue
}
const baseVal = meanMetric(baseSamples, key)
const deltaPct =
baseVal === 0
? prMean === 0
? 0
: null
: ((prMean - baseVal) / baseVal) * 100
lines.push(
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} |`
)
}
}
return lines
}
function renderNoBaselineReport(
prGroups: Map<string, PerfMeasurement[]>
): string[] {
const lines: string[] = []
lines.push(
'No baseline found — showing absolute values.\n',
'| Metric | Value |',
'|--------|-------|'
)
for (const [testName, prSamples] of prGroups) {
const prMean = (key: MetricKey) =>
prSamples.reduce((sum, s) => sum + s[key], 0) / prSamples.length
lines.push(
`| ${testName}: style recalcs | ${prMean('styleRecalcs').toFixed(0)} |`
)
lines.push(`| ${testName}: layouts | ${prMean('layouts').toFixed(0)} |`)
lines.push(
`| ${testName}: task duration | ${prMean('taskDurationMs').toFixed(0)}ms |`
)
const heapMean =
prSamples.reduce((sum, s) => sum + s.heapDeltaBytes, 0) / prSamples.length
lines.push(`| ${testName}: heap delta | ${formatBytes(heapMean)} |`)
}
return lines
function formatDeltaCell(delta: { pct: number; isNew: boolean }): string {
return delta.isNew ? 'new 🔴' : formatDelta(delta.pct)
}
function main() {
@@ -285,18 +62,55 @@ function main() {
? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'))
: null
const historical = loadHistoricalReports()
const prGroups = groupByName(current.measurements)
const lines: string[] = []
lines.push('## ⚡ Performance Report\n')
if (baseline && historical.length >= 2) {
lines.push(...renderFullReport(prGroups, baseline, historical))
} else if (baseline) {
lines.push(...renderColdStartReport(prGroups, baseline, historical.length))
if (baseline) {
lines.push(
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
)
for (const m of current.measurements) {
const base = baseline.measurements.find((b) => b.name === m.name)
if (!base) {
lines.push(`| ${m.name}: style recalcs | — | ${m.styleRecalcs} | new |`)
lines.push(`| ${m.name}: layouts | — | ${m.layouts} | new |`)
lines.push(
`| ${m.name}: task duration | — | ${m.taskDurationMs.toFixed(0)}ms | new |`
)
continue
}
const recalcDelta = calcDelta(base.styleRecalcs, m.styleRecalcs)
lines.push(
`| ${m.name}: style recalcs | ${base.styleRecalcs} | ${m.styleRecalcs} | ${formatDeltaCell(recalcDelta)} |`
)
const layoutDelta = calcDelta(base.layouts, m.layouts)
lines.push(
`| ${m.name}: layouts | ${base.layouts} | ${m.layouts} | ${formatDeltaCell(layoutDelta)} |`
)
const taskDelta = calcDelta(base.taskDurationMs, m.taskDurationMs)
lines.push(
`| ${m.name}: task duration | ${base.taskDurationMs.toFixed(0)}ms | ${m.taskDurationMs.toFixed(0)}ms | ${formatDeltaCell(taskDelta)} |`
)
}
} else {
lines.push(...renderNoBaselineReport(prGroups))
lines.push(
'No baseline found — showing absolute values.\n',
'| Metric | Value |',
'|--------|-------|'
)
for (const m of current.measurements) {
lines.push(`| ${m.name}: style recalcs | ${m.styleRecalcs} |`)
lines.push(`| ${m.name}: layouts | ${m.layouts} |`)
lines.push(
`| ${m.name}: task duration | ${m.taskDurationMs.toFixed(0)}ms |`
)
lines.push(`| ${m.name}: heap delta | ${formatBytes(m.heapDeltaBytes)} |`)
}
}
lines.push('\n<details><summary>Raw data</summary>\n')

View File

@@ -1,133 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
classifyChange,
computeStats,
formatSignificance,
isNoteworthy,
zScore
} from './perf-stats'
describe('computeStats', () => {
it('returns zeros for empty array', () => {
const stats = computeStats([])
expect(stats).toEqual({ mean: 0, stddev: 0, min: 0, max: 0, n: 0 })
})
it('returns value with zero stddev for single element', () => {
const stats = computeStats([42])
expect(stats).toEqual({ mean: 42, stddev: 0, min: 42, max: 42, n: 1 })
})
it('computes correct stats for known values', () => {
// Values: [2, 4, 4, 4, 5, 5, 7, 9]
// Mean = 5, sample variance ≈ 4.57, sample stddev ≈ 2.14
const stats = computeStats([2, 4, 4, 4, 5, 5, 7, 9])
expect(stats.mean).toBe(5)
expect(stats.stddev).toBeCloseTo(2.138, 2)
expect(stats.min).toBe(2)
expect(stats.max).toBe(9)
expect(stats.n).toBe(8)
})
it('uses sample stddev (n-1 denominator)', () => {
// [10, 20] → mean=15, variance=(25+25)/1=50, stddev≈7.07
const stats = computeStats([10, 20])
expect(stats.mean).toBe(15)
expect(stats.stddev).toBeCloseTo(7.071, 2)
expect(stats.n).toBe(2)
})
it('handles identical values', () => {
const stats = computeStats([5, 5, 5, 5])
expect(stats.mean).toBe(5)
expect(stats.stddev).toBe(0)
})
})
describe('zScore', () => {
it('returns null when stddev is 0', () => {
const stats = computeStats([5, 5, 5])
expect(zScore(10, stats)).toBeNull()
})
it('returns null when n < 2', () => {
const stats = computeStats([5])
expect(zScore(10, stats)).toBeNull()
})
it('computes correct z-score', () => {
const stats = { mean: 100, stddev: 10, min: 80, max: 120, n: 5 }
expect(zScore(120, stats)).toBe(2)
expect(zScore(80, stats)).toBe(-2)
expect(zScore(100, stats)).toBe(0)
})
})
describe('classifyChange', () => {
it('returns noisy when CV > 50%', () => {
expect(classifyChange(3, 60)).toBe('noisy')
expect(classifyChange(-3, 51)).toBe('noisy')
})
it('does not classify as noisy when CV is exactly 50%', () => {
expect(classifyChange(3, 50)).toBe('regression')
expect(classifyChange(-3, 50)).toBe('improvement')
})
it('returns neutral when z is null', () => {
expect(classifyChange(null, 10)).toBe('neutral')
})
it('returns regression when z > 2', () => {
expect(classifyChange(2.1, 10)).toBe('regression')
expect(classifyChange(5, 10)).toBe('regression')
})
it('returns improvement when z < -2', () => {
expect(classifyChange(-2.1, 10)).toBe('improvement')
expect(classifyChange(-5, 10)).toBe('improvement')
})
it('returns neutral when z is within [-2, 2]', () => {
expect(classifyChange(0, 10)).toBe('neutral')
expect(classifyChange(1.9, 10)).toBe('neutral')
expect(classifyChange(-1.9, 10)).toBe('neutral')
expect(classifyChange(2, 10)).toBe('neutral')
expect(classifyChange(-2, 10)).toBe('neutral')
})
})
describe('formatSignificance', () => {
it('formats regression with z-score and emoji', () => {
expect(formatSignificance('regression', 3.2)).toBe('⚠️ z=3.2')
})
it('formats improvement with z-score without emoji', () => {
expect(formatSignificance('improvement', -2.5)).toBe('z=-2.5')
})
it('formats noisy as descriptive text', () => {
expect(formatSignificance('noisy', null)).toBe('variance too high')
})
it('formats neutral with z-score without emoji', () => {
expect(formatSignificance('neutral', 0.5)).toBe('z=0.5')
})
it('formats neutral without z-score as dash', () => {
expect(formatSignificance('neutral', null)).toBe('—')
})
})
describe('isNoteworthy', () => {
it('returns true for regressions', () => {
expect(isNoteworthy('regression')).toBe(true)
})
it('returns false for non-regressions', () => {
expect(isNoteworthy('improvement')).toBe(false)
expect(isNoteworthy('neutral')).toBe(false)
expect(isNoteworthy('noisy')).toBe(false)
})
})

View File

@@ -1,63 +0,0 @@
export interface MetricStats {
mean: number
stddev: number
min: number
max: number
n: number
}
export function computeStats(values: number[]): MetricStats {
const n = values.length
if (n === 0) return { mean: 0, stddev: 0, min: 0, max: 0, n: 0 }
if (n === 1)
return { mean: values[0], stddev: 0, min: values[0], max: values[0], n: 1 }
const mean = values.reduce((a, b) => a + b, 0) / n
const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (n - 1)
return {
mean,
stddev: Math.sqrt(variance),
min: Math.min(...values),
max: Math.max(...values),
n
}
}
export function zScore(value: number, stats: MetricStats): number | null {
if (stats.stddev === 0 || stats.n < 2) return null
return (value - stats.mean) / stats.stddev
}
export type Significance = 'regression' | 'improvement' | 'neutral' | 'noisy'
export function classifyChange(
z: number | null,
historicalCV: number
): Significance {
if (historicalCV > 50) return 'noisy'
if (z === null) return 'neutral'
if (z > 2) return 'regression'
if (z < -2) return 'improvement'
return 'neutral'
}
export function formatSignificance(
sig: Significance,
z: number | null
): string {
switch (sig) {
case 'regression':
return `⚠️ z=${z!.toFixed(1)}`
case 'improvement':
return `z=${z!.toFixed(1)}`
case 'noisy':
return 'variance too high'
case 'neutral':
return z !== null ? `z=${z.toFixed(1)}` : '—'
}
}
export function isNoteworthy(sig: Significance): boolean {
return sig === 'regression'
}

View File

@@ -18,7 +18,7 @@
<Splitter
:key="splitterRefreshKey"
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
:state-key="isSelectMode ? 'builder-splitter' : sidebarStateKey"
:state-key="sidebarStateKey"
state-storage="local"
@resizestart="onResizestart"
>
@@ -35,10 +35,8 @@
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:min-size="sidebarLocation === 'left' ? 10 : 15"
:size="20"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
@@ -56,7 +54,7 @@
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="CENTER_PANEL_SIZE" class="flex flex-col">
<SplitterPanel :size="80" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
@@ -64,7 +62,7 @@
layout="vertical"
:pt:gutter="
cn(
'rounded-tl-lg rounded-tr-lg',
'rounded-tl-lg rounded-tr-lg ',
!(bottomPanelVisible && !focusMode) && 'hidden'
)
"
@@ -97,10 +95,8 @@
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:min-size="sidebarLocation === 'right' ? 10 : 15"
:size="20"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
@@ -127,14 +123,8 @@ import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
SIDEBAR_MIN_SIZE,
SIDE_PANEL_SIZE
} from '@/constants/splitterConstants'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -155,17 +145,15 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const { isSelectMode, isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || isSelectMode.value
() => rightSidePanelVisible.value || appModeStore.mode === 'builder:select'
)
const sidebarPanelVisible = computed(
() => activeSidebarTab.value !== null && !isBuilderMode.value
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
const sidebarStateKey = computed(() => {
return unifiedWidth.value
@@ -186,7 +174,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
* to recalculate the width and panel order
*/
const splitterRefreshKey = computed(() => {
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}${isSelectMode.value ? '-builder' : ''}-${sidebarLocation.value}`
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() => {

View File

@@ -296,13 +296,11 @@ describe('TopMenuSection', () => {
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean,
showRunProgressBar = true
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
@@ -334,19 +332,6 @@ describe('TopMenuSection', () => {
).toBe(false)
})
it('does not render inline progress summary when run progress bar is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true, false)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')

View File

@@ -56,6 +56,43 @@
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'job-history'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu
ref="queueContextMenu"
:model="queueContextMenuItems"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
@@ -111,11 +148,14 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
@@ -125,14 +165,16 @@ import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isDesktop } from '@/platform/distribution/types'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
@@ -145,11 +187,17 @@ const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
const { t, n } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const queueUIStore = useQueueUIStore()
const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
@@ -162,19 +210,25 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const shouldShowInlineProgressSummary = computed(
() =>
isQueuePanelV2Enabled.value &&
isActionbarEnabled.value &&
isRunProgressBarEnabled.value
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
)
const shouldShowQueueNotificationBanners = computed(
() => isActionbarEnabled.value
@@ -192,9 +246,24 @@ const inlineProgressSummaryTarget = computed(() => {
const shouldHideInlineProgressSummary = computed(
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.manageExtensions'))
)
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
@@ -217,6 +286,27 @@ onMounted(() => {
}
})
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('job-history')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
}
const openCustomNodeManager = async () => {
try {
await managerState.openManager({

View File

@@ -1,101 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import { i18n } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
showRunProgressBar: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.UseNewMenu') return 'Top'
if (key === 'Comfy.Queue.QPOV2') return true
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
return undefined
})
}
const mountActionbar = (showRunProgressBar: boolean) => {
const topMenuContainer = document.createElement('div')
document.body.appendChild(topMenuContainer)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, showRunProgressBar)
const wrapper = mount(ComfyActionbar, {
attachTo: document.body,
props: {
topMenuContainer,
queueOverlayExpanded: false
},
global: {
plugins: [pinia, i18n],
stubs: {
ContextMenu: {
name: 'ContextMenu',
template: '<div />'
},
Panel: {
name: 'Panel',
template: '<div><slot /></div>'
},
StatusBadge: true,
ComfyRunButton: {
name: 'ComfyRunButton',
template: '<button type="button">Run</button>'
},
QueueInlineProgress: true
},
directives: {
tooltip: () => {}
}
}
})
return {
wrapper,
topMenuContainer
}
}
describe('ComfyActionbar', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
localStorage.clear()
})
it('teleports inline progress when run progress bar is enabled', async () => {
const { wrapper, topMenuContainer } = mountActionbar(true)
try {
await nextTick()
expect(
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
).not.toBeNull()
} finally {
wrapper.unmount()
topMenuContainer.remove()
}
})
it('does not teleport inline progress when run progress bar is disabled', async () => {
const { wrapper, topMenuContainer } = mountActionbar(false)
try {
await nextTick()
expect(
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
).toBeNull()
} finally {
wrapper.unmount()
topMenuContainer.remove()
}
})
})

View File

@@ -42,38 +42,6 @@
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
variant="secondary"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'job-history'
: queueOverlayExpanded
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
</Panel>
@@ -97,24 +65,18 @@ import {
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
@@ -128,20 +90,16 @@ const emit = defineEmits<{
(event: 'update:progressTarget', target: HTMLElement | null): void
}>()
const settingStore = useSettingStore()
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const sidebarTabStore = useSidebarTabStore()
const { t, n } = useI18n()
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const { activeJobsCount } = storeToRefs(queueStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const { t } = useI18n()
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingStore.get('Comfy.UseNewMenu'))
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const isQueuePanelV2Enabled = computed(() =>
settingsStore.get('Comfy.Queue.QPOV2')
)
const panelRef = ref<ComponentPublicInstance | null>(null)
const panelElement = computed<HTMLElement | null>(() => {
@@ -325,13 +283,7 @@ const onMouseLeaveDropZone = () => {
}
const inlineProgressTarget = computed(() => {
if (
!visible.value ||
!isQueuePanelV2Enabled.value ||
!isRunProgressBarEnabled.value
) {
return null
}
if (!visible.value || !isQueuePanelV2Enabled.value) return null
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
@@ -366,58 +318,11 @@ watch(isDragging, (dragging) => {
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(
t(
isQueuePanelV2Enabled.value
? 'sideToolbar.queueProgressOverlay.viewJobHistory'
: 'sideToolbar.queueProgressOverlay.expandCollapsedQueue'
)
)
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('job-history')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
}
const actionbarClass = computed(() =>
cn(

View File

@@ -8,29 +8,31 @@ import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemp
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const { enableAppBuilder } = useAppMode()
const { enterBuilder } = useAppModeStore()
const appModeStore = useAppModeStore()
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
)
const isAppsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
const isWorkflowsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'workflows'
)
function enterBuilderMode() {
appModeStore.setMode('builder:select')
}
function openAssets() {
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
}
function showApps() {
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
void commandStore.execute('Workspace.ToggleSidebarTab.workflows')
}
function openTemplates() {
@@ -41,7 +43,7 @@ function openTemplates() {
<template>
<div class="flex flex-col gap-2 pointer-events-auto">
<WorkflowActionsDropdown source="app_mode_toolbar">
<template #button="{ hasUnseenItems }">
<template #button>
<Button
v-tooltip.right="{
value: t('sideToolbar.labels.menu'),
@@ -50,21 +52,16 @@ function openTemplates() {
variant="secondary"
size="unset"
:aria-label="t('sideToolbar.labels.menu')"
class="relative h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
class="h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</template>
</WorkflowActionsDropdown>
<Button
v-if="enableAppBuilder"
v-if="appModeStore.enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
@@ -73,7 +70,7 @@ function openTemplates() {
size="unset"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilder"
@click="enterBuilderMode"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
@@ -104,7 +101,9 @@ function openTemplates() {
variant="textonly"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="cn('size-10', isAppsActive && 'bg-secondary-background-hover')"
:class="
cn('size-10', isWorkflowsActive && 'bg-secondary-background-hover')
"
@click="showApps"
>
<i class="icon-[lucide--panels-top-left] size-4" />

View File

@@ -1,7 +1,7 @@
<template>
<div
data-testid="subgraph-breadcrumb"
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center -mt-4 pt-4"
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs

View File

@@ -60,7 +60,6 @@ import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
@@ -71,6 +70,7 @@ import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { appendJsonExt } from '@/utils/formatUtil'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
interface Props {
@@ -107,10 +107,9 @@ const rename = async (
workflowStore.activeSubgraph.name = newName
} else if (workflowStore.activeWorkflow) {
try {
const suffix = getWorkflowSuffix(workflowStore.activeWorkflow.suffix)
await workflowService.renameWorkflow(
workflowStore.activeWorkflow,
ComfyWorkflow.basePath + ensureWorkflowSuffix(newName, suffix)
ComfyWorkflow.basePath + appendJsonExt(newName)
)
} catch (error) {
console.error(error)

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, provide, ref, toValue } from 'vue'
import { computed, ref, toValue } from 'vue'
import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
@@ -23,11 +23,8 @@ import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -39,36 +36,15 @@ const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
if (!node) return null
const widget = node.widgets?.find((w) => w.name === widgetName)
return { nodeId, widgetName, node, widget }
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
const node = app.rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) {
return {
nodeId,
widgetName,
subLabel: t('linearMode.builder.unknownWidget')
}
}
if (!node || !widget) return { nodeId, widgetName }
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
@@ -162,18 +138,16 @@ function handleClick(e: MouseEvent) {
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!isSelectOutputsMode.value) return
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id)
if (index === -1) appModeStore.selectedOutputs.push(node.id)
else appModeStore.selectedOutputs.splice(index, 1)
return
}
if (!isSelectInputsMode.value) return
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
([nodeId, widgetName]) => node.id === nodeId && widget.name === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
else appModeStore.selectedInputs.splice(index, 1)
@@ -205,45 +179,17 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</script>
<template>
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
{{ t('linearMode.builder.title') }}
<Button class="ml-auto" @click="appModeStore.exitBuilder">
{{ t('linearMode.builder.exit') }}
</Button>
</div>
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'p-2 my-2 pointer-events-auto')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div v-else class="text-muted-foreground text-sm p-1 pointer-events-none">
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-border-subtle border-b"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
@@ -279,19 +225,17 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
([id, name]) => nodeId === id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
@@ -325,15 +269,12 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k === key)"
/>
</DraggableList>
</PropertiesAccordionItem>
<Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
to="body"
>
<Teleport to="body">
<div
:class="
cn(
@@ -347,46 +288,36 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
@wheel="canvasInteractions.forwardEventToCanvas"
>
<TransformPane :canvas="canvasStore.getCanvas()">
<template v-if="isSelectInputsMode">
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
</template>
<template v-else>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
"
@pointerdown.stop
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg"
/>
</div>
</template>
</div>
</TransformPane>
</div>
</Teleport>

View File

@@ -1,62 +0,0 @@
<template>
<BuilderDialog @close="$emit('close')">
<template #title>
<span class="inline-flex items-center gap-2">
{{ $t('builderToolbar.defaultModeAppliedTitle') }}
<i
aria-hidden="true"
class="icon-[lucide--circle-check-big] size-4 text-green-500"
/>
</span>
</template>
<p class="m-0 text-sm text-muted-foreground">
{{
appliedAsApp
? $t('builderToolbar.defaultModeAppliedAppBody')
: $t('builderToolbar.defaultModeAppliedGraphBody')
}}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{
appliedAsApp
? $t('builderToolbar.defaultModeAppliedAppPrompt')
: $t('builderToolbar.defaultModeAppliedGraphPrompt')
}}
</p>
<template #footer>
<template v-if="appliedAsApp">
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
{{ $t('g.close') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('viewApp')">
{{ $t('builderToolbar.viewApp') }}
</Button>
</template>
<template v-else>
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
{{ $t('builderToolbar.viewApp') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('close')">
{{ $t('g.close') }}
</Button>
</template>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineProps<{
appliedAsApp: boolean
}>()
defineEmits<{
viewApp: []
close: []
}>()
</script>

View File

@@ -1,7 +1,5 @@
<template>
<div
class="flex min-h-80 w-full min-w-116 flex-col rounded-2xl bg-base-background"
>
<div class="flex w-full min-w-96 flex-col rounded-2xl bg-base-background">
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
@@ -13,7 +11,6 @@
</h2>
</div>
<Button
v-if="showClose"
variant="muted-textonly"
class="-mr-1"
:aria-label="$t('g.close')"
@@ -24,7 +21,7 @@
</div>
<!-- Body -->
<div class="flex flex-1 flex-col gap-4 px-4 py-4">
<div class="flex flex-col gap-4 px-4 py-4">
<slot />
</div>
@@ -38,10 +35,6 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { showClose = true } = defineProps<{
showClose?: boolean
}>()
defineEmits<{
close: []
}>()

View File

@@ -1,147 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { AppMode } from '@/composables/useAppMode'
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
const mockSetMode = vi.hoisted(() => vi.fn())
const mockExitBuilder = vi.hoisted(() => vi.fn())
const mockShowDialog = vi.hoisted(() => vi.fn())
const mockState = {
mode: 'builder:select' as AppMode,
settingView: false
}
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: computed(() => mockState.mode),
isBuilderMode: ref(true),
setMode: mockSetMode
})
}))
const mockHasOutputs = ref(true)
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({
exitBuilder: mockExitBuilder,
hasOutputs: mockHasOutputs,
$id: 'appMode'
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
dialogStack: []
})
}))
vi.mock('@/components/builder/useAppSetDefaultView', () => ({
useAppSetDefaultView: () => ({
settingView: computed(() => mockState.settingView),
showDialog: mockShowDialog
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
builderMenu: { exitAppBuilder: 'Exit app builder' },
g: { back: 'Back', next: 'Next' }
}
}
})
describe('BuilderFooterToolbar', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockState.mode = 'builder:inputs'
mockHasOutputs.value = true
mockState.settingView = false
})
function mountComponent() {
return mount(BuilderFooterToolbar, {
global: {
plugins: [i18n],
stubs: { Button: false }
}
})
}
function getButtons(wrapper: ReturnType<typeof mountComponent>) {
const buttons = wrapper.findAll('button')
return {
exit: buttons[0],
back: buttons[1],
next: buttons[2]
}
}
it('disables back on the first step', () => {
mockState.mode = 'builder:inputs'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeDefined()
})
it('enables back on the second step', () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeUndefined()
})
it('disables next on the setDefaultView step', () => {
mockState.settingView = true
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeDefined()
})
it('disables next on arrange step when no outputs', () => {
mockState.mode = 'builder:arrange'
mockHasOutputs.value = false
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeDefined()
})
it('enables next on inputs step', () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeUndefined()
})
it('calls setMode on back click', async () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
await back.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('calls setMode on next click from inputs step', async () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
await next.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('opens default view dialog on next click from arrange step', async () => {
mockState.mode = 'builder:arrange'
const { next } = getButtons(mountComponent())
await next.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
expect(mockShowDialog).toHaveBeenCalledOnce()
})
it('calls exitBuilder on exit button click', async () => {
const { exit } = getButtons(mountComponent())
await exit.trigger('click')
expect(mockExitBuilder).toHaveBeenCalledOnce()
})
})

View File

@@ -1,63 +0,0 @@
<template>
<nav
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<Button variant="textonly" size="lg" @click="onExitBuilder">
{{ t('builderMenu.exitAppBuilder') }}
</Button>
<Button
variant="textonly"
size="lg"
:disabled="isFirstStep"
@click="goBack"
>
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
{{ t('g.back') }}
</Button>
<Button size="lg" :disabled="isLastStep" @click="goNext">
{{ t('g.next') }}
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
</Button>
</nav>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
const { isBuilderMode } = useAppMode()
const { hasOutputs } = storeToRefs(appModeStore)
const { isFirstStep, isLastStep, goBack, goNext } = useBuilderSteps({
hasOutputs
})
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if (
e.key === 'Escape' &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
dialogStore.dialogStack.length === 0 &&
isBuilderMode.value
) {
e.preventDefault()
e.stopPropagation()
onExitBuilder()
}
})
function onExitBuilder() {
void appModeStore.exitBuilder()
}
</script>

View File

@@ -1,82 +0,0 @@
<template>
<Popover :show-arrow="false" class="min-w-56 p-3">
<template #button>
<button
:class="
cn(
'absolute left-4 top-[calc(var(--workflow-tabs-height)+16px)] z-1000 inline-flex h-10 cursor-pointer items-center gap-2.5 rounded-lg py-2 pr-2 pl-3 shadow-interface transition-colors border-none',
'bg-secondary-background hover:bg-secondary-background-hover',
'data-[state=open]:bg-secondary-background-hover'
)
"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
>
<i class="icon-[lucide--hammer] size-4" />
<span class="text-sm font-medium">
{{ t('linearMode.appModeToolbar.appBuilder') }}
</span>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
</button>
</template>
<template #default="{ close }">
<button
:class="
cn(
'flex w-full items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none',
hasOutputs
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'opacity-50 pointer-events-none'
)
"
:disabled="!hasOutputs"
@click="onSave(close)"
>
<i class="icon-[lucide--save] size-4" />
{{ t('g.save') }}
</button>
<div class="my-1 border-t border-border-default" />
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none hover:bg-secondary-background-hover"
@click="onExitBuilder(close)"
>
<i class="icon-[lucide--square-pen] size-4" />
{{ t('builderMenu.exitAppBuilder') }}
</button>
</template>
</Popover>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const { toastErrorHandler } = useErrorHandling()
async function onSave(close: () => void) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
try {
await workflowService.saveWorkflow(workflow)
close()
} catch (error) {
toastErrorHandler(error)
}
}
function onExitBuilder(close: () => void) {
void appModeStore.exitBuilder()
close()
}
</script>

View File

@@ -1,16 +1,32 @@
<template>
<BuilderDialog @close="$emit('close')">
<BuilderDialog @close="onClose">
<template #title>
{{ $t('builderToolbar.defaultViewTitle') }}
{{ $t('builderToolbar.saveAs') }}
</template>
<!-- Filename -->
<div class="flex flex-col gap-2">
<label :for="inputId" class="text-sm text-muted-foreground">
{{ $t('builderToolbar.filename') }}
</label>
<input
:id="inputId"
v-model="filename"
autofocus
type="text"
class="flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground focus:outline-none"
@keydown.enter="filename.trim() && onSave(filename.trim(), openAsApp)"
/>
</div>
<!-- Save as type -->
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground">
{{ $t('builderToolbar.defaultViewLabel') }}
{{ $t('builderToolbar.saveAsLabel') }}
</label>
<div role="radiogroup" class="flex flex-col gap-2">
<Button
v-for="option in viewTypeOptions"
v-for="option in saveTypeOptions"
:key="option.value.toString()"
role="radio"
:aria-checked="openAsApp === option.value"
@@ -45,18 +61,23 @@
</div>
<template #footer>
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
<Button variant="muted-textonly" size="lg" @click="onClose">
{{ $t('g.cancel') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('apply', openAsApp)">
{{ $t('g.apply') }}
<Button
variant="secondary"
size="lg"
:disabled="!filename.trim()"
@click="onSave(filename.trim(), openAsApp)"
>
{{ $t('g.save') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -66,18 +87,17 @@ import BuilderDialog from './BuilderDialog.vue'
const { t } = useI18n()
const { initialOpenAsApp = true } = defineProps<{
initialOpenAsApp?: boolean
const { defaultFilename, onSave, onClose } = defineProps<{
defaultFilename: string
onSave: (filename: string, openAsApp: boolean) => void
onClose: () => void
}>()
defineEmits<{
apply: [openAsApp: boolean]
close: []
}>()
const inputId = useId()
const filename = ref(defaultFilename)
const openAsApp = ref(true)
const openAsApp = ref(initialOpenAsApp)
const viewTypeOptions = [
const saveTypeOptions = [
{
value: true,
icon: 'icon-[lucide--app-window]',

View File

@@ -0,0 +1,51 @@
<template>
<BuilderDialog @close="onClose">
<template #header-icon>
<i class="icon-[lucide--circle-check-big] size-4 text-green-500" />
</template>
<template #title>
{{ $t('builderToolbar.saveSuccess') }}
</template>
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessAppMessage', { name: workflowName }) }}
</p>
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessAppPrompt') }}
</p>
<p v-else class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessGraphMessage', { name: workflowName }) }}
</p>
<template #footer>
<Button
:variant="savedAsApp ? 'muted-textonly' : 'secondary'"
size="lg"
@click="onClose"
>
{{ $t('g.close') }}
</Button>
<Button
v-if="savedAsApp && onViewApp"
variant="primary"
size="lg"
@click="onViewApp"
>
{{ $t('builderToolbar.viewApp') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineProps<{
workflowName: string
savedAsApp: boolean
onViewApp?: () => void
onClose: () => void
}>()
</script>

View File

@@ -1,23 +1,26 @@
<template>
<nav
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-1000 -translate-x-1/2"
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-[1000] -translate-x-1/2"
:aria-label="t('builderToolbar.label')"
>
<div
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<template v-for="(step, index) in steps" :key="step.id">
<template
v-for="(step, index) in [selectStep, arrangeStep]"
:key="step.id"
>
<button
:class="
cn(
stepClasses,
activeStep === step.id
? 'bg-interface-builder-mode-background'
: 'hover:bg-secondary-background bg-transparent'
activeStep === step.id && 'bg-interface-builder-mode-background',
activeStep !== step.id &&
'hover:bg-secondary-background bg-transparent'
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@click="navigateToStep(step.id)"
@click="appModeStore.setMode(step.id)"
>
<StepBadge :step :index :model-value="activeStep" />
<StepLabel :step />
@@ -26,19 +29,15 @@
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
</template>
<!-- Default view -->
<!-- Save -->
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
v-if="!appModeStore.hasOutputs"
:is-select-active="activeStep === 'builder:select'"
@switch="appModeStore.setMode('builder:select')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
</button>
</ConnectOutputPopover>
<button
@@ -46,72 +45,70 @@
:class="
cn(
stepClasses,
activeStep === 'setDefaultView'
activeStep === 'save'
? 'bg-interface-builder-mode-background'
: 'hover:bg-secondary-background bg-transparent'
)
"
@click="navigateToStep('setDefaultView')"
@click="appModeStore.setBuilderSaving(true)"
>
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
</button>
</div>
</nav>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useEventListener } from '@vueuse/core'
import { useAppModeStore } from '@/stores/appModeStore'
import type { AppMode } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types'
import type { BuilderStepId } from './useBuilderSteps'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
void appModeStore.exitBuilder()
})
const activeStep = computed(() =>
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
)
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
const selectInputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:inputs',
title: t('builderToolbar.inputs'),
subtitle: t('builderToolbar.inputsDescription'),
const selectStep: BuilderToolbarStep<AppMode> = {
id: 'builder:select',
title: t('builderToolbar.select'),
subtitle: t('builderToolbar.selectDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
const selectOutputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:outputs',
title: t('builderToolbar.outputs'),
subtitle: t('builderToolbar.outputsDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
const arrangeStep: BuilderToolbarStep<AppMode> = {
id: 'builder:arrange',
title: t('builderToolbar.arrange'),
subtitle: t('builderToolbar.arrangeDescription'),
icon: 'icon-[lucide--layout-panel-left]'
}
const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
id: 'setDefaultView',
title: t('builderToolbar.defaultView'),
subtitle: t('builderToolbar.defaultViewDescription'),
icon: 'icon-[lucide--eye]'
const saveStep: BuilderToolbarStep<'save'> = {
id: 'save',
title: t('builderToolbar.save'),
subtitle: t('builderToolbar.saveDescription'),
icon: 'icon-[lucide--cloud-upload]'
}
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
</script>

View File

@@ -46,7 +46,7 @@
</PopoverClose>
<PopoverClose as-child>
<Button variant="secondary" size="md" @click="emit('switch')">
{{ t('builderToolbar.switchToOutputs') }}
{{ t('builderToolbar.switchToSelect') }}
</Button>
</PopoverClose>
</template>

View File

@@ -1,40 +0,0 @@
<template>
<BuilderDialog :show-close="false">
<template #title>
{{ $t('builderToolbar.emptyWorkflowTitle') }}
</template>
<div class="flex flex-col gap-2">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.emptyWorkflowExplanation') }}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.emptyWorkflowPrompt') }}
</p>
</div>
<template #footer>
<Button
variant="muted-textonly"
size="lg"
@click="$emit('backToWorkflow')"
>
{{ $t('builderToolbar.backToWorkflow') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('loadTemplate')">
{{ $t('builderToolbar.loadTemplate') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineEmits<{
backToWorkflow: []
loadTemplate: []
}>()
</script>

View File

@@ -32,15 +32,9 @@ const entries = computed(() => {
})
</script>
<template>
<div class="p-2 my-2 rounded-lg flex items-center-safe gap-2">
<div
class="mr-auto flex-[4_1_0%] max-w-max min-w-0 truncate drag-handle inline"
v-text="title"
/>
<div
class="flex-[2_1_0%] max-w-max min-w-0 truncate text-muted-foreground text-end drag-handle inline"
v-text="subTitle"
/>
<div class="p-2 my-2 rounded-lg flex items-center-safe">
<span class="mr-auto" v-text="title" />
<span class="text-muted-foreground mr-2 text-end" v-text="subTitle" />
<Popover :entries>
<template #button>
<Button variant="muted-textonly">

View File

@@ -1,222 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockDialogService = vi.hoisted(() => ({
showLayoutDialog: vi.fn()
}))
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn(),
isDialogOpen: vi.fn<(key: string) => boolean>().mockReturnValue(false)
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: null as {
initialMode?: string | null
changeTracker?: { checkState: () => void }
} | null
}))
const mockApp = vi.hoisted(() => ({
rootGraph: { extra: {} as Record<string, unknown> }
}))
const mockSetMode = vi.hoisted(() => vi.fn())
vi.mock('@/services/dialogService', () => ({
useDialogService: () => mockDialogService
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: mockSetMode })
}))
vi.mock('./DefaultViewDialogContent.vue', () => ({
default: { name: 'MockDefaultViewDialogContent' }
}))
vi.mock('./BuilderDefaultModeAppliedDialogContent.vue', () => ({
default: { name: 'MockBuilderDefaultModeAppliedDialogContent' }
}))
import { useAppSetDefaultView } from './useAppSetDefaultView'
describe('useAppSetDefaultView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStore.activeWorkflow = null
mockApp.rootGraph.extra = {}
})
describe('settingView', () => {
it('reflects dialogStore.isDialogOpen', () => {
mockDialogStore.isDialogOpen.mockReturnValue(true)
const { settingView } = useAppSetDefaultView()
expect(settingView.value).toBe(true)
})
})
describe('showDialog', () => {
it('opens dialog via dialogService', () => {
const { showDialog } = useAppSetDefaultView()
showDialog()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledOnce()
})
it('passes initialOpenAsApp true when initialMode is not graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: 'app' }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(true)
})
it('passes initialOpenAsApp false when initialMode is graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: 'graph' }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(false)
})
it('passes initialOpenAsApp true when no active workflow', () => {
mockWorkflowStore.activeWorkflow = null
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(true)
})
})
describe('handleApply', () => {
it('sets initialMode to app when openAsApp is true', () => {
const workflow = { initialMode: null as string | null }
mockWorkflowStore.activeWorkflow = workflow
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(workflow.initialMode).toBe('app')
})
it('sets initialMode to graph when openAsApp is false', () => {
const workflow = { initialMode: null as string | null }
mockWorkflowStore.activeWorkflow = workflow
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(false)
expect(workflow.initialMode).toBe('graph')
})
it('sets linearMode on rootGraph.extra', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockApp.rootGraph.extra.linearMode).toBe(true)
})
it('closes dialog after applying', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view'
})
})
it('shows confirmation dialog after applying', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledTimes(2)
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
expect(confirmCall.key).toBe('builder-default-view-applied')
expect(confirmCall.props.appliedAsApp).toBe(true)
})
it('passes appliedAsApp false to confirmation dialog when graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(false)
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
expect(confirmCall.props.appliedAsApp).toBe(false)
})
})
describe('applied dialog', () => {
function applyAndGetConfirmDialog(openAsApp: boolean) {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const applyCall = mockDialogService.showLayoutDialog.mock.calls[0][0]
applyCall.props.onApply(openAsApp)
return mockDialogService.showLayoutDialog.mock.calls[1][0]
}
it('onViewApp sets mode to app and closes dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
confirmCall.props.onViewApp()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
expect(mockSetMode).toHaveBeenCalledWith('app')
})
it('onClose closes confirmation dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
mockDialogStore.closeDialog.mockClear()
confirmCall.props.onClose()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
})
})
})

View File

@@ -1,71 +0,0 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
const DIALOG_KEY = 'builder-default-view'
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
export function useAppSetDefaultView() {
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const { setMode } = useAppMode()
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
function showDialog() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: DefaultViewDialogContent,
props: {
initialOpenAsApp: workflowStore.activeWorkflow?.initialMode !== 'graph',
onApply: handleApply,
onClose: closeDialog
}
})
}
function handleApply(openAsApp: boolean) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
workflow.initialMode = openAsApp ? 'app' : 'graph'
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
closeDialog()
showAppliedDialog(openAsApp)
}
function showAppliedDialog(appliedAsApp: boolean) {
dialogService.showLayoutDialog({
key: APPLIED_DIALOG_KEY,
component: BuilderDefaultModeAppliedDialogContent,
props: {
appliedAsApp,
onViewApp: () => {
closeAppliedDialog()
setMode('app')
},
onClose: closeAppliedDialog
}
})
}
function closeDialog() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function closeAppliedDialog() {
dialogStore.closeDialog({ key: APPLIED_DIALOG_KEY })
}
return { settingView, showDialog }
}

View File

@@ -0,0 +1,123 @@
import { watch } from 'vue'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
export function useBuilderSave() {
const appModeStore = useAppModeStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
watch(
() => appModeStore.isBuilderSaving,
(saving) => {
if (saving) void onBuilderSave()
}
)
async function onBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) {
resetSaving()
return
}
if (!workflow.isTemporary && workflow.activeState.extra?.linearMode) {
try {
workflow.changeTracker?.checkState()
appModeStore.saveSelectedToWorkflow()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
} catch {
resetSaving()
}
return
}
showSaveDialog(workflow.filename)
}
function showSaveDialog(defaultFilename: string) {
dialogService.showLayoutDialog({
key: SAVE_DIALOG_KEY,
component: BuilderSaveDialogContent,
props: {
defaultFilename,
onSave: handleSave,
onClose: handleCancelSave
},
dialogComponentProps: {
onClose: resetSaving
}
})
}
function handleCancelSave() {
closeSaveDialog()
resetSaving()
}
async function handleSave(filename: string, openAsApp: boolean) {
try {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
appModeStore.saveSelectedToWorkflow()
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
openAsApp
})
if (!saved) return
closeSaveDialog()
showSuccessDialog(filename, openAsApp)
} catch {
closeSaveDialog()
resetSaving()
}
}
function showSuccessDialog(workflowName: string, savedAsApp: boolean) {
dialogService.showLayoutDialog({
key: SUCCESS_DIALOG_KEY,
component: BuilderSaveSuccessDialogContent,
props: {
workflowName,
savedAsApp,
onViewApp: () => {
appModeStore.setMode('app')
closeSuccessDialog()
},
onClose: closeSuccessDialog
},
dialogComponentProps: {
onClose: resetSaving
}
})
}
function closeSaveDialog() {
dialogStore.closeDialog({ key: SAVE_DIALOG_KEY })
}
function closeSuccessDialog() {
dialogStore.closeDialog({ key: SUCCESS_DIALOG_KEY })
resetSaving()
}
function resetSaving() {
appModeStore.setBuilderSaving(false)
}
}

View File

@@ -1,79 +0,0 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useAppSetDefaultView } from './useAppSetDefaultView'
const BUILDER_STEPS = [
'builder:inputs',
'builder:outputs',
'builder:arrange',
'setDefaultView'
] as const
export type BuilderStepId = (typeof BUILDER_STEPS)[number]
const ARRANGE_INDEX = BUILDER_STEPS.indexOf('builder:arrange')
export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
const { mode, isBuilderMode, setMode } = useAppMode()
const { settingView, showDialog } = useAppSetDefaultView()
const activeStep = computed<BuilderStepId>(() => {
if (settingView.value) return 'setDefaultView'
if (isBuilderMode.value) {
return mode.value as BuilderStepId
}
return 'builder:inputs'
})
const activeStepIndex = computed(() =>
BUILDER_STEPS.indexOf(activeStep.value)
)
const isFirstStep = computed(() => activeStepIndex.value === 0)
const isLastStep = computed(() => {
if (!options?.hasOutputs?.value)
return activeStepIndex.value >= ARRANGE_INDEX
return activeStepIndex.value >= BUILDER_STEPS.length - 1
})
const isSelectStep = computed(
() =>
activeStep.value === 'builder:inputs' ||
activeStep.value === 'builder:outputs'
)
function navigateToStep(stepId: BuilderStepId) {
if (stepId === 'setDefaultView') {
setMode('builder:arrange')
showDialog()
} else {
setMode(stepId)
}
}
function goBack() {
if (isFirstStep.value) return
navigateToStep(BUILDER_STEPS[activeStepIndex.value - 1])
}
function goNext() {
if (isLastStep.value) return
navigateToStep(BUILDER_STEPS[activeStepIndex.value + 1])
}
return {
activeStep,
activeStepIndex,
isFirstStep,
isLastStep,
isSelectStep,
navigateToStep,
goBack,
goNext
}
}

View File

@@ -1,44 +0,0 @@
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import EmptyWorkflowDialogContent from './EmptyWorkflowDialogContent.vue'
const DIALOG_KEY = 'builder-empty-workflow'
export function useEmptyWorkflowDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
function show(options: {
onEnterBuilder: () => void
onDismiss: () => void
}) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: EmptyWorkflowDialogContent,
props: {
onBackToWorkflow: () => {
closeDialog()
options.onDismiss()
},
onLoadTemplate: () => {
closeDialog()
templateSelectorDialog.show('appbuilder', {
afterClose: () => {
if (app.rootGraph?.nodes?.length) options.onEnterBuilder()
}
})
}
}
})
}
function closeDialog() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
return { show }
}

View File

@@ -40,7 +40,6 @@ import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
@@ -89,7 +88,6 @@ const handleFileUpload = async (event: Event) => {
type: 'input',
subfolder: 'backgrounds'
})
appendCloudResParam(params, file.name)
modelValue.value = `/api/view?${params.toString()}`
}
} catch (error) {

View File

@@ -1,11 +1,7 @@
<template>
<span
:class="
cn(
'flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs',
textColorClass
)
"
class="flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs"
:class="textColorClass"
:style="customStyle"
>
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />

View File

@@ -1,65 +0,0 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import { toValue } from 'vue'
const { t } = useI18n()
defineOptions({
inheritAttrs: false
})
defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
</script>
<template>
<DropdownMenuSeparator
v-if="item.separator"
class="h-[1px] bg-border-subtle m-1"
/>
<DropdownMenuSub v-else-if="item.items">
<DropdownMenuSubTrigger
:class="itemClass"
:disabled="toValue(item.disabled) ?? !item.items?.length"
>
{{ item.label }}
<i class="ml-auto icon-[lucide--chevron-right]" />
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
:class="contentClass"
:side-offset="2"
:align-offset="-5"
>
<DropdownItem
v-for="(subitem, index) in item.items"
:key="toValue(subitem.label) ?? index"
:item="subitem"
:item-class
:content-class
/>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem
v-else
:class="itemClass"
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5" :class="item.icon" />
{{ item.label }}
<div
v-if="item.new"
class="ml-auto bg-primary-background rounded-full text-xxs font-bold px-1 flex leading-none items-center"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>
</template>

View File

@@ -1,74 +0,0 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
import DropdownItem from '@/components/common/DropdownItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
itemClass?: string
contentClass?: string
}>()
const itemClass = computed(() =>
cn(
'data-[highlighted]:bg-secondary-background-hover data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground flex p-2 leading-none rounded-lg gap-1 cursor-pointer m-1',
itemProp
)
)
const contentClass = computed(() =>
cn(
'z-1700 rounded-lg p-2 bg-base-background border border-border-subtle min-w-[220px] shadow-sm will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
contentProp
)
)
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<slot name="button">
<Button size="icon">
<i :class="icon ?? 'icon-[lucide--menu]'" />
</Button>
</slot>
</DropdownMenuTrigger>
<DropdownMenuPortal :to>
<DropdownMenuContent
side="bottom"
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
:class="contentClass"
>
<slot :item-class>
<DropdownItem
v-for="(item, index) in entries ?? []"
:key="toValue(item.label) ?? index"
:item-class
:content-class
:item
/>
</slot>
<DropdownMenuArrow class="fill-base-background stroke-border-subtle" />
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>

View File

@@ -3,18 +3,14 @@
<Card>
<template #content>
<div class="flex flex-col items-center">
<i
v-if="icon"
:class="icon"
style="font-size: 3rem; margin-bottom: 1rem"
/>
<h3 v-if="title">{{ title }}</h3>
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem" />
<h3>{{ title }}</h3>
<p :class="textClass" class="text-center whitespace-pre-line">
{{ message }}
</p>
<Button
v-if="buttonLabel"
:variant="buttonVariant ?? 'textonly'"
variant="textonly"
@click="$emit('action')"
>
{{ buttonLabel }}
@@ -29,16 +25,14 @@
import Card from 'primevue/card'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '../ui/button/button.variants'
const props = defineProps<{
class?: string
icon?: string
title?: string
title: string
message: string
textClass?: string
buttonLabel?: string
buttonVariant?: ButtonVariants['variant']
}>()
defineEmits(['action'])
@@ -57,6 +51,7 @@ defineEmits(['action'])
}
.no-results-placeholder p {
color: var(--text-color-secondary);
margin-bottom: 1rem;
}
</style>

View File

@@ -8,7 +8,7 @@
v-if="!hideButtons"
:aria-label="t('g.decrement')"
data-testid="decrement"
class="h-full aspect-8/7 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@@ -53,7 +53,7 @@
v-if="!hideButtons"
:aria-label="t('g.increment')"
data-testid="increment"
class="h-full aspect-8/7 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"

View File

@@ -1,6 +1,4 @@
<script setup lang="ts">
import { computed } from 'vue'
import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
@@ -13,17 +11,17 @@ const {
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
}>()
const badgeClass = computed(() =>
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
)
</script>
<template>
<span :class="badgeClass">
<span
:class="
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
"
>
{{ label }}
</span>
</template>

View File

@@ -2,7 +2,6 @@ import { mount } from '@vue/test-utils'
import type { FlattenedItem } from 'reka-ui'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -10,25 +9,12 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue('left')
})
}))
vi.mock('@/stores/nodeBookmarkStore', () => ({
useNodeBookmarkStore: () => ({
isBookmarked: vi.fn().mockReturnValue(false),
toggleBookmark: vi.fn()
})
}))
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
default: { template: '<div />' }
}))
@@ -92,7 +78,6 @@ describe('TreeExplorerV2Node', () => {
return {
wrapper: mount(TreeExplorerV2Node, {
global: {
plugins: [i18n],
stubs: {
TreeItem: treeItemStub.stub,
Teleport: { template: '<div />' }

View File

@@ -24,25 +24,6 @@
{{ item.value.label }}
</slot>
</span>
<button
:class="
cn(
'flex size-6 shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted-foreground hover:text-foreground',
'opacity-0 group-hover/tree-node:opacity-100'
)
"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
</button>
</div>
<!-- Folder -->
@@ -52,15 +33,6 @@
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
>
<i
v-if="item.hasChildren"
:class="
cn(
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
<i
:class="cn(item.value.icon, 'size-4 shrink-0 text-muted-foreground')"
/>
@@ -69,6 +41,15 @@
{{ item.value.label }}
</slot>
</span>
<i
v-if="item.hasChildren"
:class="
cn(
'icon-[lucide--chevron-down] mr-4 size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
</div>
</TreeItem>
@@ -92,7 +73,6 @@ import { computed, inject } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -113,21 +93,9 @@ const emit = defineEmits<{
}>()
const contextMenuNode = inject(InjectKeyContextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeDef = computed(() => item.value.data)
const isBookmarked = computed(() => {
if (!nodeDef.value) return false
return nodeBookmarkStore.isBookmarked(nodeDef.value)
})
function toggleBookmark() {
if (nodeDef.value) {
nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
}
const {
previewRef,
showPreview,

View File

@@ -9,7 +9,6 @@ import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -28,13 +27,8 @@ const { menuItems } = useWorkflowActionsMenu(
{ isRoot: true }
)
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
() => menuItems.value
)
function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source
})
@@ -45,7 +39,7 @@ function handleOpen(open: boolean) {
<template>
<DropdownMenuRoot @update:open="handleOpen">
<DropdownMenuTrigger as-child>
<slot name="button" :has-unseen-items="hasUnseenItems">
<slot name="button">
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
@@ -55,7 +49,7 @@ function handleOpen(open: boolean) {
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="relative h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
class="h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i
class="size-4"
@@ -66,11 +60,6 @@ function handleOpen(open: boolean) {
"
/>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</slot>
</DropdownMenuTrigger>

View File

@@ -17,7 +17,7 @@ function createWrapper(items: WorkflowMenuItem[]) {
describe('WorkflowActionsList', () => {
it('renders action items with label and icon', () => {
const items: WorkflowMenuItem[] = [
{ id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() }
{ label: 'Save', icon: 'pi pi-save', command: vi.fn() }
]
const wrapper = createWrapper(items)
@@ -28,9 +28,9 @@ describe('WorkflowActionsList', () => {
it('renders separator items', () => {
const items: WorkflowMenuItem[] = [
{ id: 'before', label: 'Before', icon: 'pi pi-a', command: vi.fn() },
{ label: 'Before', icon: 'pi pi-a', command: vi.fn() },
{ separator: true },
{ id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() }
{ label: 'After', icon: 'pi pi-b', command: vi.fn() }
]
const wrapper = createWrapper(items)
@@ -44,7 +44,7 @@ describe('WorkflowActionsList', () => {
it('dispatches command on select', async () => {
const command = vi.fn()
const items: WorkflowMenuItem[] = [
{ id: 'action', label: 'Action', icon: 'pi pi-play', command }
{ label: 'Action', icon: 'pi pi-play', command }
]
const wrapper = createWrapper(items)
@@ -57,7 +57,6 @@ describe('WorkflowActionsList', () => {
it('renders badge when present', () => {
const items: WorkflowMenuItem[] = [
{
id: 'new-feature',
label: 'New Feature',
icon: 'pi pi-star',
command: vi.fn(),
@@ -70,27 +69,9 @@ describe('WorkflowActionsList', () => {
expect(wrapper.text()).toContain('NEW')
})
it('does not render items with visible set to false', () => {
const items: WorkflowMenuItem[] = [
{
id: 'hidden',
label: 'Hidden Item',
icon: 'pi pi-eye-slash',
command: vi.fn(),
visible: false
},
{ id: 'shown', label: 'Shown Item', icon: 'pi pi-eye', command: vi.fn() }
]
const wrapper = createWrapper(items)
expect(wrapper.text()).not.toContain('Hidden Item')
expect(wrapper.text()).toContain('Shown Item')
})
it('does not render badge when absent', () => {
const items: WorkflowMenuAction[] = [
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
{ label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
]
const wrapper = createWrapper(items)

View File

@@ -26,7 +26,7 @@ const {
/>
<component
:is="itemComponent"
v-else-if="item.visible !== false"
v-else
:disabled="item.disabled"
:class="
cn(

View File

@@ -341,10 +341,7 @@ function handleReplaceSelected() {
replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected
// replaceNodesInPlace() handles canvas rendering via onNodeAdded(),
// but the modal only updates its own local UI state above.
// Without this call the Errors Tab would still list the replaced nodes
// as missing because executionErrorStore is not aware of the replacement.
// Sync with execution error store so the Errors Tab updates immediately
if (result.length > 0) {
executionErrorStore.removeMissingNodesByType(result)
}

View File

@@ -18,11 +18,10 @@
>
<WorkflowTabs />
<TopbarBadges />
<TopbarSubscribeButton />
</div>
</div>
</template>
<template v-if="showUI && !isBuilderMode" #side-toolbar>
<template v-if="showUI && !appModeStore.isBuilderMode" #side-toolbar>
<SideToolbar />
</template>
<template v-if="showUI" #side-bar-panel>
@@ -32,24 +31,27 @@
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
</div>
</template>
<template v-if="showUI && !isBuilderMode" #topmenu>
<template v-if="showUI && !appModeStore.isBuilderMode" #topmenu>
<TopMenuSection />
</template>
<template v-if="showUI" #bottom-panel>
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<AppBuilder v-if="isBuilderMode" />
<NodePropertiesPanel v-else />
<AppBuilder v-if="appModeStore.mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!appModeStore.isBuilderMode" />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
v-if="canvasMenuEnabled && !isBuilderMode"
v-if="canvasMenuEnabled && !appModeStore.isBuilderMode"
class="pointer-events-auto"
/>
<MiniMap
v-if="
comfyAppReady && minimapEnabled && betaMenuEnabled && !isBuilderMode
comfyAppReady &&
minimapEnabled &&
betaMenuEnabled &&
!appModeStore.isBuilderMode
"
class="pointer-events-auto"
/>
@@ -125,10 +127,10 @@ import {
import { useI18n } from 'vue-i18n'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
@@ -141,7 +143,6 @@ import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
@@ -183,7 +184,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -204,7 +205,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()

View File

@@ -1,116 +0,0 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import type { DomWidgetState } from '@/stores/domWidgetStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import DomWidget from './DomWidget.vue'
const mockUpdatePosition = vi.fn()
const mockUpdateClipPath = vi.fn()
const mockCanvasElement = document.createElement('canvas')
const mockCanvasStore = {
canvas: {
graph: {
getNodeById: vi.fn(() => true)
},
ds: {
offset: [0, 0],
scale: 1
},
canvas: mockCanvasElement,
selected_nodes: {}
},
getCanvas: () => ({ canvas: mockCanvasElement }),
linearMode: false
}
vi.mock('@/composables/element/useAbsolutePosition', () => ({
useAbsolutePosition: () => ({
style: reactive<Record<string, string>>({}),
updatePosition: mockUpdatePosition
})
}))
vi.mock('@/composables/element/useDomClipping', () => ({
useDomClipping: () => ({
style: reactive<Record<string, string>>({}),
updateClipPath: mockUpdateClipPath
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => false)
})
}))
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
const domWidgetStore = useDomWidgetStore()
const node = createMockLGraphNode({
id: 1,
constructor: {
nodeData: {}
}
})
const widget = {
id: 'dom-widget-id',
name: 'test_widget',
type: 'custom',
value: '',
options: {},
node,
computedDisabled: false
} as unknown as BaseDOMWidget<object | string>
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: createMockLGraphNode({ id: 2 }),
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
})
const state = domWidgetStore.widgetStates.get(widget.id)
if (!state) throw new Error('Expected registered DomWidgetState')
state.zIndex = 2
state.size = [100, 40]
return reactive(state)
}
describe('DomWidget disabled style', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
useDomWidgetStore().clear()
vi.clearAllMocks()
})
it('uses disabled style when promoted override widget is computedDisabled', async () => {
const widgetState = createWidgetState(true)
const wrapper = mount(DomWidget, {
props: {
widgetState
}
})
widgetState.zIndex = 3
await wrapper.vm.$nextTick()
const root = wrapper.get('.dom-widget').element as HTMLElement
expect(root.style.pointerEvents).toBe('none')
expect(root.style.opacity).toBe('0.5')
})
})

View File

@@ -110,17 +110,13 @@ watch(
updateDomClipping()
}
const override = widgetState.positionOverride
const isDisabled = override
? (override.widget.computedDisabled ?? widget.computedDisabled)
: widget.computedDisabled
style.value = {
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents: widgetState.readonly || isDisabled ? 'none' : 'auto',
opacity: isDisabled ? 0.5 : 1
pointerEvents:
widgetState.readonly || widget.computedDisabled ? 'none' : 'auto',
opacity: widget.computedDisabled ? 0.5 : 1
}
},
{ deep: true }

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-base-background border border-border-default"
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-(--base-background) border border-(--border-default)"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div ref="previewWrapperRef" class="origin-top-left scale-50">
@@ -100,7 +100,7 @@ import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphN
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const SCALE_FACTOR = 0.5
const PREVIEW_CONTAINER_PADDING_PX = 24
const PREVIEW_CONTAINER_PADDING_PX = 24 // p-3 top + bottom (12px × 2)
const {
nodeDef,

View File

@@ -28,9 +28,8 @@
/>
<div
v-show="cursorVisible"
ref="cursorEl"
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)] will-change-transform"
:style="cursorSizeStyle"
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)]"
:style="cursorStyle"
/>
</div>
</div>
@@ -142,7 +141,7 @@
max="100"
step="1"
class="w-7 appearance-none border-0 bg-transparent text-right text-xs text-node-text-muted outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
@click.stop
@click.prevent
@change="
(e) => {
const val = Math.min(
@@ -282,7 +281,6 @@ const { nodeId } = defineProps<{
const modelValue = defineModel<string>({ default: '' })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const cursorEl = useTemplateRef<HTMLElement>('cursorEl')
const controlsEl = useTemplateRef<HTMLDivElement>('controlsEl')
const { width: controlsWidth } = useElementSize(controlsEl)
const compact = computed(
@@ -298,6 +296,8 @@ const {
backgroundColor,
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,
inputImageUrl,
@@ -309,7 +309,7 @@ const {
handlePointerLeave,
handleInputImageLoad,
handleClear
} = usePainter(nodeId, { canvasEl, cursorEl, modelValue })
} = usePainter(nodeId, { canvasEl, modelValue })
const canvasContainerStyle = computed(() => ({
aspectRatio: `${canvasWidth.value} / ${canvasHeight.value}`,
@@ -318,10 +318,16 @@ const canvasContainerStyle = computed(() => ({
: backgroundColor.value
}))
const cursorSizeStyle = computed(() => ({
width: `${displayBrushSize.value}px`,
height: `${displayBrushSize.value}px`
}))
const cursorStyle = computed(() => {
const size = displayBrushSize.value
const x = cursorX.value - size / 2
const y = cursorY.value - size / 2
return {
width: `${size}px`,
height: `${size}px`,
transform: `translate(${x}px, ${y}px)`
}
})
const brushOpacityPercent = computed({
get: () => Math.round(brushOpacity.value * 100),

View File

@@ -1,123 +0,0 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { i18n } from '@/i18n'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
const popoverCloseSpy = vi.fn()
vi.mock('@/components/ui/Popover.vue', () => {
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots }) {
return () =>
h('div', [
slots.button?.(),
slots.default?.({
close: () => {
popoverCloseSpy()
}
})
])
}
})
return { default: PopoverStub }
})
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
? true
: undefined
)
const mockSetSetting = vi.fn()
const mockSetMany = vi.fn()
const mockSidebarTabStore = {
activeSidebarTabId: null as string | null
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting,
set: mockSetSetting,
setMany: mockSetMany
})
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
const mountMenu = () =>
mount(JobHistoryActionsMenu, {
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
describe('JobHistoryActionsMenu', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
popoverCloseSpy.mockClear()
mockSetSetting.mockClear()
mockSetMany.mockClear()
mockSidebarTabStore.activeSidebarTabId = null
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
? true
: undefined
)
})
it('toggles show run progress bar setting from the menu', async () => {
const wrapper = mountMenu()
const showRunProgressBarButton = wrapper.get(
'[data-testid="show-run-progress-bar-action"]'
)
await showRunProgressBarButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith(
'Comfy.Queue.ShowRunProgressBar',
false
)
})
it('opens docked job history sidebar when enabling from the menu', async () => {
mockGetSetting.mockImplementation((key: string) => {
if (key === 'Comfy.Queue.QPOV2') return false
if (key === 'Comfy.Queue.ShowRunProgressBar') return true
return undefined
})
const wrapper = mountMenu()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
expect(mockSetMany).not.toHaveBeenCalled()
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('emits clear history from the menu', async () => {
const wrapper = mountMenu()
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'
)
await clearHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
})

View File

@@ -19,8 +19,8 @@
data-testid="docked-job-history-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
@click="onToggleDockedJobHistory(close)"
size="sm"
@click="onToggleDockedJobHistory"
>
<span class="flex items-center gap-2">
<i
@@ -35,32 +35,14 @@
class="icon-[lucide--check] size-4"
/>
</Button>
<Button
data-testid="show-run-progress-bar-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
@click="onToggleRunProgressBar"
>
<span class="flex items-center gap-2">
<i class="icon-[lucide--hourglass] size-4 text-text-secondary" />
<span>{{
t('sideToolbar.queueProgressOverlay.showRunProgressBar')
}}</span>
</span>
<i
v-if="isRunProgressBarEnabled"
class="icon-[lucide--check] size-4"
/>
</Button>
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
<template v-if="showClearHistoryAction">
<div class="my-1 border-t border-interface-stroke" />
<Button
data-testid="clear-history-action"
class="h-auto min-h-8 w-full items-start justify-start whitespace-normal"
class="h-auto min-h-0 w-full items-start justify-start whitespace-normal"
variant="textonly"
size="md"
size="sm"
@click="onClearHistoryFromMenu(close)"
>
<i
@@ -94,11 +76,9 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const emit = defineEmits<{
(e: 'clearHistory'): void
@@ -106,11 +86,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const showClearHistoryAction = computed(() => !isCloud)
const onClearHistoryFromMenu = (close: () => void) => {
@@ -118,29 +98,7 @@ const onClearHistoryFromMenu = (close: () => void) => {
emit('clearHistory')
}
const onToggleDockedJobHistory = async (close: () => void) => {
close()
try {
if (isQueuePanelV2Enabled.value) {
await settingStore.setMany({
'Comfy.Queue.QPOV2': false,
'Comfy.Queue.History.Expanded': true
})
return
}
sidebarTabStore.activeSidebarTabId = 'job-history'
await settingStore.set('Comfy.Queue.QPOV2', true)
} catch {
return
}
}
const onToggleRunProgressBar = async () => {
await settingStore.set(
'Comfy.Queue.ShowRunProgressBar',
!isRunProgressBarEnabled.value
)
const onToggleDockedJobHistory = async () => {
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled.value)
}
</script>

View File

@@ -1,9 +1,8 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent, h } from 'vue'
import { i18n } from '@/i18n'
const popoverCloseSpy = vi.fn()
vi.mock('@/components/ui/Popover.vue', () => {
@@ -24,29 +23,18 @@ vi.mock('@/components/ui/Popover.vue', () => {
return { default: PopoverStub }
})
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
? true
: undefined
const mockGetSetting = vi.fn((key: string) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const mockSetSetting = vi.fn()
const mockSetMany = vi.fn()
const mockSidebarTabStore = {
activeSidebarTabId: null as string | null
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting,
set: mockSetSetting,
setMany: mockSetMany
set: mockSetSetting
})
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
@@ -55,6 +43,27 @@ const tooltipDirectiveStub = {
updated: vi.fn()
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { more: 'More' },
sideToolbar: {
queueProgressOverlay: {
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
clearQueueTooltip: 'Clear queue',
clearAllJobsTooltip: 'Cancel all running jobs',
moreOptions: 'More options',
clearHistory: 'Clear history',
dockedJobHistory: 'Docked Job History'
}
}
}
}
})
const mountHeader = (props = {}) =>
mount(QueueOverlayHeader, {
props: {
@@ -70,14 +79,8 @@ const mountHeader = (props = {}) =>
describe('QueueOverlayHeader', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
popoverCloseSpy.mockClear()
mockSetSetting.mockClear()
mockSetMany.mockClear()
mockSidebarTabStore.activeSidebarTabId = null
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
})
it('renders header title', () => {
@@ -122,7 +125,7 @@ describe('QueueOverlayHeader', () => {
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
it('opens floating queue progress overlay when disabling from the menu', async () => {
it('toggles docked job history setting from the menu', async () => {
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
@@ -130,79 +133,7 @@ describe('QueueOverlayHeader', () => {
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledWith({
'Comfy.Queue.QPOV2': false,
'Comfy.Queue.History.Expanded': true
})
expect(mockSetSetting).not.toHaveBeenCalled()
expect(mockSidebarTabStore.activeSidebarTabId).toBe(null)
})
it('opens docked job history sidebar when enabling from the menu', async () => {
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
expect(mockSetMany).not.toHaveBeenCalled()
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('keeps docked target open even when enabling persistence fails', async () => {
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
mockSetSetting.mockRejectedValueOnce(new Error('persistence failed'))
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('closes the menu when disabling persistence fails', async () => {
mockSetMany.mockRejectedValueOnce(new Error('persistence failed'))
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledWith({
'Comfy.Queue.QPOV2': false,
'Comfy.Queue.History.Expanded': true
})
})
it('toggles show run progress bar setting from the menu', async () => {
const wrapper = mountHeader()
const showRunProgressBarButton = wrapper.get(
'[data-testid="show-run-progress-bar-action"]'
)
await showRunProgressBarButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith(
'Comfy.Queue.ShowRunProgressBar',
false
)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', false)
})
})

View File

@@ -166,13 +166,13 @@ const queuedAtValue = computed(() =>
: ''
)
const currentJobPriority = computed<number | null>(() => {
const currentQueueIndex = computed<number | null>(() => {
const task = taskForJob.value
return task ? Number(task.job.priority) : null
})
const jobsAhead = computed<number | null>(() => {
const idx = currentJobPriority.value
const idx = currentQueueIndex.value
if (idx == null) return null
const ahead = queueStore.pendingTasks.filter(
(t: TaskItemImpl) => Number(t.job.priority) < idx

View File

@@ -5,7 +5,7 @@
v-for="tab in visibleJobTabs"
:key="tab"
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
size="md"
size="sm"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}

Some files were not shown because too many files have changed in this diff Show More