Compare commits

...

7 Commits

Author SHA1 Message Date
CodeRabbit Fixer
7deb337683 fix: a11y: Make ariaLabel required (or add decorative path) in ChartBar.vue and ChartLine.vue (#9805)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:51:57 +01:00
Johnpaul Chiwetelu
ef477d0381 feat: Warn when binding browser-reserved shortcuts (#9406)
## Summary

Show a non-blocking warning in the keybinding edit dialog when users try
to bind shortcuts that browsers intercept (e.g. Ctrl+T, Ctrl+W, F12).

## Changes

- **What**: Add `RESERVED_BY_BROWSER` set of known browser-intercepted
shortcuts, `isBrowserReserved` getter on `KeyComboImpl`, and a warning
`<Message>` in the keybinding edit dialog. Users can still save the
binding.

## Review Focus

Whether the list of browser-reserved shortcuts is comprehensive enough,
and whether a non-blocking warning (vs blocking) is the right UX choice.

## Before


https://github.com/user-attachments/assets/5abfc062-5ed1-4fcd-b394-ff98221d82a8

## After



https://github.com/user-attachments/assets/12a49e24-051f-4579-894a-164dbf1cb7b7


Fixes #1087

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9406-feat-Warn-when-binding-browser-reserved-shortcuts-31a6d73d36508162a021e88ab76914f6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-12 16:31:09 +01:00
Christian Byrne
37c6ddfcd9 chore: add backport-management agent skill (#9619)
Adds a reusable agent skill for managing cherry-pick backports across
stable release branches.

## What
Agent skill at `.claude/skills/backport-management/` with routing-table
SKILL.md + 4 reference files (discovery, analysis, execution, logging).

## Why
Codifies lessons from backporting 57 PRs across cloud/1.41, core/1.41,
and core/1.40. Makes future backport sessions faster and less
error-prone.

## Key learnings baked in
- Cloud-only PRs must not be backported to `core/*` branches (wasted
effort)
- Wave verification (`pnpm typecheck`) between batches to catch breakage
early
- Human review required for non-trivial conflict resolutions before
admin-merge
- MUST vs SHOULD decision guide with clear criteria
- Continuous backporting preference over bulk sessions
- Mermaid diagram as final session deliverable
- Conflict triage table (never skip based on file count alone)
- `gh api` for labels instead of `gh pr edit` (Projects Classic
deprecation)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9619-chore-add-backport-management-agent-skill-31d6d73d3650815b9808c3916b8e3343)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-12 04:55:01 -07:00
jaeone94
55c42ee484 [bugfix] Asset widget search matches display label (#9774)
## Summary
Asset widget dropdown search only matched against `item.name`
(filename), but users see `item.label` (display name). Now searches both
fields so filtering matches what is visually displayed.

## Changes
- **What**: `defaultSearcher` in `FormDropdown` now matches against both
`name` and `label` fields
- Added 3 unit tests covering label-based search scenarios

## Review Focus
- The change only affects cloud asset mode where `name` (filename) and
`label` (display name) differ. In local mode, `label` is either
`undefined` or identical to `name`, so behavior is unchanged.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9774-bugfix-Asset-widget-search-matches-display-label-3216d73d365081ca8befdf7260c66a26)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-12 20:40:44 +09:00
Dante
b04db536a1 fix: restore correct workflow on page reload (#9318)
## Summary

- Fixes incorrect workflow loading after page refresh when the active
workflow is saved and unmodified
- Adds saved-workflow fallback in `loadPreviousWorkflowFromStorage()`
before falling back to latest draft
- Calls `openWorkflow()` in `restoreWorkflowTabsState()` to activate the
correct tab after restoration

- Fixes #9317

## Test plan

- [x] Unit tests for saved-workflow fallback (draft missing, saved
workflow exists)
- [x] Unit tests for correct tab activation after restoration
- [x] Unit tests for existing behavior preservation (draft preferred
over saved, no-session-path fallback)
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] All 117 persistence tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9318-fix-restore-correct-workflow-on-page-reload-3166d73d365081ba9139f7c23c917aa4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 20:25:10 +09:00
pythongosssss
975d6a360d fix: switching tabs in app mode clearing outputs (#9745)
## Summary

- remove reset on exiting app mode and instead cleanup at specific
stages instead of a reset all
- more job<->workflow specificity updates
- ensure pending data is cleared up and doesnt leak over time

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9745-fix-switching-tabs-in-app-mode-clearing-outputs-3206d73d365081038cb0c83f0d953e71)
by [Unito](https://www.unito.io)
2026-03-12 03:27:52 -07:00
Christian Byrne
7e137d880b docs: add change tracker architecture documentation (#9767)
Documents the ChangeTracker undo/redo system: how checkState() works,
all automatic triggers, when manual calls are needed, transaction
guards, and key invariants.

Companion to #9623 which fixed missing checkState() calls in Vue
widgets.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9767-docs-add-change-tracker-architecture-documentation-3216d73d365081268c27c54e0d5824e6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-12 03:03:07 -07:00
30 changed files with 2323 additions and 165 deletions

View File

@@ -0,0 +1,150 @@
---
name: backport-management
description: Manages cherry-pick backports across stable release branches. Discovers candidates from Slack/git, analyzes dependencies, resolves conflicts via worktree, and logs results. Use when asked to backport, cherry-pick to stable, manage release branches, do stable branch maintenance, or run a backport session.
---
# Backport Management
Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release branches.
## Quick Start
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
3. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
4. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
5. **Verify** — After each wave, verify branch integrity before proceeding
6. **Log & Report** — Generate session report with mermaid diagram (`reference/logging.md`)
## System Context
| Item | Value |
| -------------- | ------------------------------------------------- |
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
| Tracking dir | `~/temp/backport-session/` |
## Branch Scope Rules
**Critical: Match PRs to the correct target branches.**
| Branch prefix | Scope | Example |
| ------------- | ------------------------------ | ----------------------------------------- |
| `cloud/*` | Cloud-hosted ComfyUI only | App mode, cloud auth, cloud-specific UI |
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** Cloud-only changes (app mode, cloud auth, cloud billing UI, cloud-specific API calls) are irrelevant to local users and waste effort. Before backporting any PR to a `core/*` branch, check:
- Does the PR title/description mention "app mode", "cloud", or cloud-specific features?
- Does the PR only touch files like `appModeStore.ts`, cloud auth, or cloud-specific components?
- If yes → skip for `core/*` branches (may still apply to `cloud/*` branches)
## ⚠️ Gotchas (Learn from Past Sessions)
### Use `gh api` for Labels — NOT `gh pr edit`
`gh pr edit --add-label` triggers Projects Classic deprecation errors. Always use:
```bash
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
```
### Automation Over-Reports Conflicts
The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-pick -m 1` with git auto-merge handles many cases the automation can't. Always attempt manual cherry-pick before skipping.
### Never Skip Based on Conflict File Count
12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below.
## Conflict Triage
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
| Type | Symptom | Resolution |
| ---------------------------- | ------------------------------------ | --------------------------------------------------------------- |
| **Binary snapshots (PNGs)** | `.png` files in conflict list | `git checkout --theirs $FILE && git add $FILE` — always trivial |
| **Modify/delete (new file)** | PR introduces files not on target | `git add $FILE` — keep the new file |
| **Modify/delete (removed)** | Target removed files the PR modifies | `git rm $FILE` — file no longer relevant |
| **Content conflicts** | Marker-based (`<<<<<<<`) | Accept theirs via python regex (see below) |
| **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict |
| **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after |
```python
# Accept theirs for content conflicts
import re
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
```
### Escalation Triggers (Flag for Human)
- **Package.json/lockfile changes** → skip on stable (transitive dep regression risk)
- **Core type definition changes** → requires human judgment
- **Business logic conflicts** (not just imports/exports) → requires domain knowledge
- **Admin-merged conflict resolutions** → get human review of the resolution before continuing the wave
## Auto-Skip Categories
Skip these without discussion:
- **Dep refresh PRs** — Risk of transitive dep regressions on stable. Cherry-pick individual CVE fixes instead.
- **CI/tooling changes** — Not user-facing
- **Test-only / lint rule changes** — Not user-facing
- **Revert pairs** — If PR A reverted by PR B, skip both. If fixed version (PR C) exists, backport only C.
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
- **Cloud-only PRs on core/\* branches** — App mode, cloud auth, cloud billing. These only affect cloud-hosted ComfyUI.
## Wave Verification
After merging each wave of PRs to a target branch, verify branch integrity before proceeding:
```bash
# Fetch latest state of target branch
git fetch origin TARGET_BRANCH
# Quick smoke check: does the branch build?
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
cd /tmp/verify-TARGET
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
git worktree remove /tmp/verify-TARGET --force
```
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
## Continuous Backporting Recommendation
Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuous backporting:
- Backport bug fixes as they merge to main (same day or next day)
- Use the automation labels immediately after merge
- Reserve session-style bulk backporting for catching up after gaps
- When a release branch is created, immediately start the continuous process
## Quick Reference
### Label-Driven Automation (default path)
```bash
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
# Wait 3 min, check: gh pr list --base TARGET_BRANCH --state open
```
### Manual Worktree Cherry-Pick (conflict fallback)
```bash
git worktree add /tmp/backport-$BRANCH origin/$BRANCH
cd /tmp/backport-$BRANCH
git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH
git cherry-pick -m 1 $MERGE_SHA
# Resolve conflicts, push, create PR, merge
```
### PR Title Convention
```
[backport TARGET_BRANCH] Original Title (#ORIGINAL_PR)
```

View File

@@ -0,0 +1,68 @@
# Analysis & Decision Framework
## Categorization
| Category | Criteria | Action |
| -------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| **MUST** | User-facing bug, crash, data corruption, security. Clear breakage that users will hit. | Backport (with deps if needed) |
| **SHOULD** | UX improvement, minor bug, small dep chain. No user-visible breakage if skipped, but improves experience. | Backport if clean cherry-pick; defer if conflict resolution is non-trivial |
| **SKIP** | CI/tooling, test-only, lint rules, cosmetic, dep refresh | Skip with documented reason |
| **NEEDS DISCUSSION** | Large dep chain, unclear risk/benefit, touches core types | Flag for human |
### MUST vs SHOULD Decision Guide
When unsure, ask: "If a user on this stable branch reports this issue, would we consider it a bug?"
- **Yes** → MUST. The fix addresses broken behavior.
- **No, but it's noticeably better** → SHOULD. The fix is a quality-of-life improvement.
- **No, and it's cosmetic or internal** → SKIP.
For SHOULD items with conflicts: if conflict resolution requires more than trivial accept-theirs patterns (content conflicts in business logic, not just imports), downgrade to SKIP or escalate to NEEDS DISCUSSION.
## Branch Scope Filtering
**Before categorizing, filter by branch scope:**
| Target branch | Skip if PR is... |
| ------------- | ------------------------------------------------------------------- |
| `core/*` | Cloud-only (app mode, cloud auth, cloud billing, cloud-specific UI) |
| `cloud/*` | Local-only features not present on cloud branch |
Cloud-only PRs backported to `core/*` are wasted effort — `core/*` branches serve local/self-hosted users who never see cloud features. Check PR titles, descriptions, and files changed for cloud-specific indicators.
## Features Not on Stable Branches
Check before backporting — these don't exist on older branches:
- **Painter** (`src/extensions/core/painter.ts`) — not on core/1.40
- **GLSLShader** — not on core/1.40
- **App builder** — check per branch
- **appModeStore.ts** — not on core/1.40
## Dep Refresh PRs
Always SKIP on stable branches. Risk of transitive dependency regressions outweighs audit cleanup benefit. If a specific CVE fix is needed, cherry-pick that individual fix instead.
## Revert Pairs
If PR A is reverted by PR B:
- Skip BOTH A and B
- If a fixed version exists (PR C), backport only C
## Dependency Analysis
```bash
# Find other PRs that touched the same files
gh pr view $PR --json files --jq '.files[].path' | while read f; do
git log --oneline origin/TARGET..$MERGE_SHA -- "$f"
done
```
## Human Review Checkpoint
Present decisions.md before execution. Include:
1. All MUST/SHOULD/SKIP categorizations with rationale
2. Questions for human (feature existence, scope, deps)
3. Estimated effort per branch

View File

@@ -0,0 +1,42 @@
# Discovery — Candidate Collection
## Source 1: Slack Backport-Checker Bot
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
```bash
slackdump export -o ~/slack-exports/frontend-releases.zip C09K9TPU2G7
```
Parse bot messages for PRs flagged "Might need backport" per release version.
## Source 2: Git Log Gap Analysis
```bash
# Count gap
git log --oneline origin/TARGET..origin/main | wc -l
# List gap commits
git log --oneline origin/TARGET..origin/main
# Check if a PR is already on target
git log --oneline origin/TARGET --grep="#PR_NUMBER"
# Check for existing backport PRs
gh pr list --base TARGET --state all --search "backport PR_NUMBER"
```
## Source 3: GitHub PR Details
```bash
# Get merge commit SHA
gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.mergeCommit.oid)"'
# Get files changed
gh pr view $PR --json files --jq '.files[].path'
```
## Output: candidate_list.md
Table per target branch:
| PR# | Title | Category | Flagged by Bot? | Decision |

View File

@@ -0,0 +1,150 @@
# Execution Workflow
## Per-Branch Execution Order
1. Smallest gap first (validation run)
2. Medium gap next (quick win)
3. Largest gap last (main effort)
## Step 1: Label-Driven Automation (Batch)
```bash
# Add labels to all candidates for a target branch
for pr in $PR_LIST; do
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$pr/labels \
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH" --silent
sleep 2
done
# Wait 3 minutes for automation
sleep 180
# Check which got auto-PRs
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
```
## Step 2: Review & Merge Clean Auto-PRs
```bash
for pr in $AUTO_PRS; do
# Check size
gh pr view $pr --json title,additions,deletions,changedFiles \
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
# Admin merge
gh pr merge $pr --squash --admin
sleep 3
done
```
## Step 3: Manual Worktree for Conflicts
```bash
git fetch origin TARGET_BRANCH
git worktree add /tmp/backport-TARGET origin/TARGET_BRANCH
cd /tmp/backport-TARGET
for PR in ${CONFLICT_PRS[@]}; do
# Refresh target ref so each branch is based on current HEAD
git fetch origin TARGET_BRANCH
git checkout origin/TARGET_BRANCH
git checkout -b backport-$PR-to-TARGET origin/TARGET_BRANCH
git cherry-pick -m 1 $MERGE_SHA
# If conflict — NEVER skip based on file count alone!
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add
# See SKILL.md Conflict Triage table for resolution per type.
# Resolve all conflicts, then:
git add .
GIT_EDITOR=true git cherry-pick --continue
git push origin backport-$PR-to-TARGET
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
--title "[backport TARGET] TITLE (#$PR)" \
--body "Backport of #$PR..." | grep -oP '\d+$')
gh pr merge $NEW_PR --squash --admin
sleep 3
done
# Cleanup
cd -
git worktree remove /tmp/backport-TARGET --force
```
**⚠️ Human review for conflict resolutions:** When admin-merging a PR where you manually resolved conflicts (especially content conflicts beyond trivial accept-theirs), pause and present the resolution diff to the human for review before merging. Trivial resolutions (binary snapshots, modify/delete, locale key additions) can proceed without review.
## Step 4: Wave Verification
After completing all PRs in a wave for a target branch:
```bash
git fetch origin TARGET_BRANCH
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
cd /tmp/verify-TARGET
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
git worktree remove /tmp/verify-TARGET --force
```
If verification fails, stop and fix before proceeding to the next wave. Do not compound problems across waves.
## Conflict Resolution Patterns
### 1. Content Conflicts (accept theirs)
```python
import re
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
```
### 2. Modify/Delete (two cases!)
```bash
# Case A: PR introduces NEW files not on target → keep them
git add $FILE
# Case B: Target REMOVED files the PR modifies → drop them
git rm $FILE
```
### 3. Binary Files (snapshots)
```bash
git checkout --theirs $FILE && git add $FILE
```
### 4. Locale Files
Usually adding new i18n keys — accept theirs, validate JSON:
```bash
python3 -c "import json; json.load(open('src/locales/en/main.json'))" && echo "Valid"
```
## Merge Conflicts After Other Merges
When merging multiple PRs to the same branch, later PRs may conflict with earlier merges:
```bash
git fetch origin TARGET_BRANCH
git rebase origin/TARGET_BRANCH
# Resolve new conflicts
git push --force origin backport-$PR-to-TARGET
sleep 20 # Wait for GitHub to recompute merge state
gh pr merge $PR --squash --admin
```
## Lessons Learned
1. **Automation reports more conflicts than reality**`cherry-pick -m 1` with git auto-merge handles many "conflicts" the automation can't
2. **Never skip based on conflict file count** — 12 or 27 conflicts can be trivial (snapshots, new files). Categorize first: binary PNGs, modify/delete, content, add/add.
3. **Modify/delete goes BOTH ways** — if the PR introduces new files (not on target), `git add` them. If target deleted files the PR modifies, `git rm`.
4. **Binary snapshot PNGs** — always `git checkout --theirs && git add`. Never skip a PR just because it has many snapshot conflicts.
5. **Batch label additions need 2s delay** between API calls to avoid rate limits
6. **Merging 6+ PRs rapidly** can cause later PRs to become unmergeable — wait 20-30s for GitHub to recompute merge state
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
8. **Always validate JSON** after resolving locale file conflicts
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.

View File

@@ -0,0 +1,96 @@
# Logging & Session Reports
## During Execution
Maintain `execution-log.md` with per-branch tables:
```markdown
| PR# | Title | Status | Backport PR | Notes |
| ----- | ----- | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
```
## Wave Verification Log
Track verification results per wave:
```markdown
## Wave N Verification — TARGET_BRANCH
- PRs merged: #A, #B, #C
- Typecheck: ✅ Pass / ❌ Fail
- Issues found: (if any)
- Human review needed: (list any non-trivial conflict resolutions)
```
## Session Report Template
```markdown
# Backport Session Report
## Summary
| Branch | Candidates | Merged | Skipped | Deferred | Rate |
| ------ | ---------- | ------ | ------- | -------- | ---- |
## Deferred Items (Needs Human)
| PR# | Title | Branch | Issue |
## Conflict Resolutions Requiring Review
| PR# | Branch | Conflict Type | Resolution Summary |
## Automation Performance
| Metric | Value |
| --------------------------- | ----- |
| Auto success rate | X% |
| Manual resolution rate | X% |
| Overall clean rate | X% |
| Wave verification pass rate | X% |
## Process Recommendations
- Were there clusters of related PRs that should have been backported together?
- Any PRs that should have been backported sooner (continuous backporting candidates)?
- Feature branches that need tracking for future sessions?
```
## Final Deliverable: Visual Summary
At session end, generate a **mermaid diagram** showing all backported PRs organized by target branch and category (MUST/SHOULD), plus a summary table. Present this to the user as the final output.
```mermaid
graph TD
subgraph branch1["☁️ cloud/X.XX — N PRs"]
C1["#XXXX title"]
C2["#XXXX title"]
end
subgraph branch2must["🔴 core/X.XX MUST — N PRs"]
M1["#XXXX title"]
end
subgraph branch2should["🟡 core/X.XX SHOULD — N PRs"]
S1["#XXXX-#XXXX N auto-merged"]
S2["#XXXX-#XXXX N manual picks"]
end
classDef cloudStyle fill:#1a3a5c,stroke:#4da6ff,color:#e0f0ff
classDef coreStyle fill:#1a4a2e,stroke:#4dff88,color:#e0ffe8
classDef mustStyle fill:#5c1a1a,stroke:#ff4d4d,color:#ffe0e0
classDef shouldStyle fill:#4a3a1a,stroke:#ffcc4d,color:#fff5e0
```
Use the `mermaid` tool to render this diagram and present it alongside the summary table as the session's final deliverable.
## Files to Track
- `candidate_list.md` — all candidates per branch
- `decisions.md` — MUST/SHOULD/SKIP with rationale
- `wave-plan.md` — execution order
- `execution-log.md` — real-time status
- `backport-session-report.md` — final summary
All in `~/temp/backport-session/`.

1
.gitignore vendored
View File

@@ -26,6 +26,7 @@ dist-ssr
.claude/*.local.json
.claude/*.local.md
.claude/*.local.txt
.claude/worktrees
CLAUDE.local.md
# Editor directories and files

View File

@@ -333,7 +333,7 @@ test.describe('Settings', () => {
await editKeybindingButton.click()
// Set new keybinding
const input = comfyPage.page.getByPlaceholder('Press keys for new binding')
const input = comfyPage.page.getByPlaceholder('Enter your keybind')
await input.press('Alt+n')
const requestPromise = comfyPage.page.waitForRequest(
@@ -345,7 +345,7 @@ test.describe('Settings', () => {
// Save keybinding
const saveButton = comfyPage.page
.getByLabel('New Blank Workflow')
.getByLabel('Modify keybinding')
.getByText('Save')
await saveButton.click()

View File

@@ -0,0 +1,91 @@
# Change Tracker (Undo/Redo System)
The `ChangeTracker` class (`src/scripts/changeTracker.ts`) manages undo/redo
history by comparing serialized graph snapshots.
## How It Works
`checkState()` is the core method. It:
1. Serializes the current graph via `app.rootGraph.serialize()`
2. Deep-compares the result against the last known `activeState`
3. If different, pushes `activeState` onto `undoQueue` and replaces it
**It is not reactive.** Changes to the graph (widget values, node positions,
links, etc.) are only captured when `checkState()` is explicitly triggered.
## Automatic Triggers
These are set up once in `ChangeTracker.init()`:
| Trigger | Event / Hook | What It Catches |
| ----------------------------------- | -------------------------------------------------- | --------------------------------------------------- |
| Keyboard (non-modifier, non-repeat) | `window` `keydown` | Shortcuts, typing in canvas |
| Modifier key release | `window` `keyup` | Releasing Ctrl/Shift/Alt/Meta |
| Mouse click | `window` `mouseup` | General clicks on native DOM |
| Canvas mouse up | `LGraphCanvas.processMouseUp` override | LiteGraph canvas interactions |
| Number/string dialog | `LGraphCanvas.prompt` override | Dialog popups for editing widgets |
| Context menu close | `LiteGraph.ContextMenu.close` override | COMBO widget menus in LiteGraph |
| Active input element | `bindInput` (change/input/blur on focused element) | Native HTML input edits |
| Prompt queued | `api` `promptQueued` event | Dynamic widget changes on queue |
| Graph cleared | `api` `graphCleared` event | Full graph clear |
| Transaction end | `litegraph:canvas` `after-change` event | Batched operations via `beforeChange`/`afterChange` |
## When You Must Call `checkState()` Manually
The automatic triggers above are designed around LiteGraph's native DOM
rendering. They **do not cover**:
- **Vue-rendered widgets** — Vue handles events internally without triggering
native DOM events that the tracker listens to (e.g., `mouseup` on a Vue
dropdown doesn't bubble the same way as a native LiteGraph widget click)
- **Programmatic graph mutations** — Any code that modifies the graph outside
of user interaction (e.g., applying a template, pasting nodes, aligning)
- **Async operations** — File uploads, API calls that change widget values
after the initial user gesture
### Pattern for Manual Calls
```typescript
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
// After mutating the graph:
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
```
### Existing Manual Call Sites
These locations already call `checkState()` explicitly:
- `WidgetSelectDropdown.vue` — After dropdown selection and file upload
- `ColorPickerButton.vue` — After changing node colors
- `NodeSearchBoxPopover.vue` — After adding a node from search
- `useAppSetDefaultView.ts` — After setting default view
- `useSelectionOperations.ts` — After align, copy, paste, duplicate, group
- `useSelectedNodeActions.ts` — After pin, bypass, collapse
- `useGroupMenuOptions.ts` — After group operations
- `useSubgraphOperations.ts` — After subgraph enter/exit
- `useCanvasRefresh.ts` — After canvas refresh
- `useCoreCommands.ts` — After metadata/subgraph commands
- `workflowService.ts` — After workflow service operations
## Transaction Guards
For operations that make multiple changes that should be a single undo entry:
```typescript
changeTracker.beforeChange()
// ... multiple graph mutations ...
changeTracker.afterChange() // calls checkState() when nesting count hits 0
```
The `litegraph:canvas` custom event also supports this with `before-change` /
`after-change` sub-types.
## Key Invariants
- `checkState()` is a no-op during `loadGraphData` (guarded by
`isLoadingGraph`) to prevent cross-workflow corruption
- `checkState()` is a no-op when `changeCount > 0` (inside a transaction)
- `undoQueue` is capped at 50 entries (`MAX_HISTORY`)
- `graphEqual` ignores node order and `ds` (pan/zoom) when comparing

View File

@@ -25,15 +25,13 @@
class: {
'p-3 rounded-lg': true,
'pointer-events-none':
bottomPanelStore.bottomPanelTabs.length === 1
},
style: {
color: 'var(--fg-color)',
backgroundColor:
bottomPanelStore.bottomPanelTabs.length === 1,
'bg-secondary-background text-secondary-foreground':
x.context.active &&
bottomPanelStore.bottomPanelTabs.length > 1,
'text-muted-foreground':
!x.context.active ||
bottomPanelStore.bottomPanelTabs.length === 1
? ''
: 'var(--bg-color)'
bottomPanelStore.bottomPanelTabs.length <= 1
}
})
"
@@ -127,4 +125,8 @@ const closeBottomPanel = () => {
:deep(.p-tablist-active-bar) {
display: none;
}
:deep(.p-tab-active) {
color: inherit;
}
</style>

View File

@@ -59,7 +59,15 @@
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div class="truncate" :title="slotProps.data.id">
<div
class="flex items-center gap-1.5 truncate"
:title="slotProps.data.id"
>
<i
v-if="slotProps.data.keybinding?.combo.isBrowserReserved"
v-tooltip="$t('g.browserReservedKeybindingTooltip')"
class="icon-[lucide--triangle-alert] shrink-0 text-warning-background"
/>
{{ slotProps.data.label }}
</div>
</template>
@@ -93,44 +101,6 @@
</Column>
</DataTable>
<Dialog
v-model:visible="editDialogVisible"
class="min-w-96"
modal
:header="currentEditingCommand?.label"
@hide="cancelEdit"
>
<div>
<InputText
ref="keybindingInput"
class="mb-2 text-center"
:model-value="newBindingKeyCombo?.toString() ?? ''"
:placeholder="$t('g.pressKeysForNewBinding')"
autocomplete="off"
fluid
@keydown.stop.prevent="captureKeybinding"
/>
<Message v-if="existingKeybindingOnCombo" severity="warn">
{{ $t('g.keybindingAlreadyExists') }}
<Tag
severity="secondary"
:value="existingKeybindingOnCombo.commandId"
/>
</Message>
</div>
<template #footer>
<Button
:variant="existingKeybindingOnCombo ? 'destructive' : 'primary'"
autofocus
@click="saveKeybinding"
>
<i
:class="existingKeybindingOnCombo ? 'pi pi-pencil' : 'pi pi-check'"
/>
{{ existingKeybindingOnCombo ? $t('g.overwrite') : $t('g.save') }}
</Button>
</template>
</Dialog>
<Button
v-tooltip="$t('g.resetAllKeybindingsTooltip')"
class="mt-4 w-full"
@@ -147,18 +117,14 @@
import { FilterMatchMode } from '@primevue/core/api'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import { useToast } from 'primevue/usetoast'
import { computed, ref, watchEffect } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -195,50 +161,16 @@ const commandsData = computed<ICommandData[]>(() => {
})
const selectedCommandData = ref<ICommandData | null>(null)
const editDialogVisible = ref(false)
const newBindingKeyCombo = ref<KeyComboImpl | null>(null)
const currentEditingCommand = ref<ICommandData | null>(null)
const keybindingInput = ref<InstanceType<typeof InputText> | null>(null)
const existingKeybindingOnCombo = computed<KeybindingImpl | null>(() => {
if (!currentEditingCommand.value) {
return null
}
// If the new keybinding is the same as the current editing command, then don't show the error
if (
currentEditingCommand.value.keybinding?.combo?.equals(
newBindingKeyCombo.value
)
) {
return null
}
if (!newBindingKeyCombo.value) {
return null
}
return keybindingStore.getKeybinding(newBindingKeyCombo.value)
})
const editKeybindingDialog = useEditKeybindingDialog()
function editKeybinding(commandData: ICommandData) {
currentEditingCommand.value = commandData
newBindingKeyCombo.value = commandData.keybinding
? commandData.keybinding.combo
: null
editDialogVisible.value = true
editKeybindingDialog.show({
commandId: commandData.id,
commandLabel: commandData.label,
currentCombo: commandData.keybinding?.combo ?? null
})
}
watchEffect(() => {
if (editDialogVisible.value) {
// nextTick doesn't work here, so we use a timeout instead
setTimeout(() => {
// @ts-expect-error - $el is an internal property of the InputText component
keybindingInput.value?.$el?.focus()
}, 300)
}
})
async function removeKeybinding(commandData: ICommandData) {
if (commandData.keybinding) {
keybindingStore.unsetKeybinding(commandData.keybinding)
@@ -246,40 +178,6 @@ async function removeKeybinding(commandData: ICommandData) {
}
}
async function captureKeybinding(event: KeyboardEvent) {
// Allow the use of keyboard shortcuts when adding keyboard shortcuts
if (!event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
switch (event.key) {
case 'Escape':
cancelEdit()
return
case 'Enter':
await saveKeybinding()
return
}
}
const keyCombo = KeyComboImpl.fromEvent(event)
newBindingKeyCombo.value = keyCombo
}
function cancelEdit() {
editDialogVisible.value = false
currentEditingCommand.value = null
newBindingKeyCombo.value = null
}
async function saveKeybinding() {
const commandId = currentEditingCommand.value?.id
const combo = newBindingKeyCombo.value
cancelEdit()
if (!combo || commandId == undefined) return
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
if (updated) await keybindingService.persistUserKeybindings()
}
async function resetKeybinding(commandData: ICommandData) {
if (keybindingStore.resetKeybindingForCommand(commandData.id)) {
await keybindingService.persistUserKeybindings()

View File

@@ -0,0 +1,56 @@
<template>
<div class="flex w-96 flex-col border-t border-border-default px-4">
<p class="mb-4 text-sm text-muted-foreground">
{{ $t('g.setAKeybindingForTheFollowing') }}
</p>
<div class="mb-4 text-sm text-base-foreground">
{{ commandLabel }}
</div>
<input
class="text-foreground mb-4 w-full rounded-sm border border-border-default bg-secondary-background px-3 py-2 text-center shadow-none focus:outline-none"
:value="dialogState.newCombo?.toString() ?? ''"
:placeholder="$t('g.enterYourKeybind')"
:aria-label="$t('g.enterYourKeybind')"
autocomplete="off"
autofocus
@keydown.stop.prevent="captureKeybinding"
/>
<div class="min-h-12">
<p
v-if="dialogState.newCombo?.isBrowserReserved"
class="m-0 text-sm text-destructive-background"
>
{{ $t('g.browserReservedKeybinding') }}
</p>
<p
v-else-if="existingKeybindingOnCombo"
class="m-0 text-sm text-destructive-background"
>
{{ $t('g.keybindingAlreadyExists') }}
{{ existingKeybindingOnCombo.commandId }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import type { EditKeybindingDialogState } from '@/composables/useEditKeybindingDialog'
const { dialogState, onUpdateCombo, existingKeybindingOnCombo } = defineProps<{
dialogState: EditKeybindingDialogState
commandLabel: string
onUpdateCombo: (combo: KeyComboImpl) => void
existingKeybindingOnCombo: KeybindingImpl | null
}>()
function captureKeybinding(event: KeyboardEvent) {
if (!event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
if (event.key === 'Escape') return
}
onUpdateCombo(KeyComboImpl.fromEvent(event))
}
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div class="flex w-full justify-end gap-2 px-4 py-2">
<Button
variant="textonly"
size="md"
class="text-muted-foreground"
@click="handleCancel"
>
{{ $t('g.cancel') }}
</Button>
<Button
:variant="
existingKeybindingOnCombo
? 'destructive'
: dialogState.newCombo?.isBrowserReserved
? 'secondary'
: 'primary'
"
size="md"
:disabled="!dialogState.newCombo"
class="px-4 py-2"
@click="handleSave"
>
{{
existingKeybindingOnCombo
? $t('g.overwrite')
: dialogState.newCombo?.isBrowserReserved
? $t('g.saveAnyway')
: $t('g.save')
}}
</Button>
</div>
</template>
<script setup lang="ts">
import type { Reactive } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { EditKeybindingDialogState } from '@/composables/useEditKeybindingDialog'
import { DIALOG_KEY } from '@/composables/useEditKeybindingDialog'
const { dialogState, existingKeybindingOnCombo } = defineProps<{
dialogState: Reactive<EditKeybindingDialogState>
existingKeybindingOnCombo: KeybindingImpl | null
}>()
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const dialogStore = useDialogStore()
function handleCancel() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
async function handleSave() {
const combo = dialogState.newCombo
const commandId = dialogState.commandId
if (!combo || !commandId) return
dialogStore.closeDialog({ key: DIALOG_KEY })
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
if (updated) await keybindingService.persistUserKeybindings()
}
</script>

View File

@@ -0,0 +1,5 @@
<template>
<div class="flex w-full items-center gap-2 p-4">
<p class="m-0 font-semibold">{{ $t('g.modifyKeybinding') }}</p>
</div>
</template>

View File

@@ -538,7 +538,7 @@ onMounted(async () => {
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
workflowPersistence.restoreWorkflowTabsState()
await workflowPersistence.restoreWorkflowTabsState()
const sharedWorkflowLoadStatus =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()

View File

@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ChartBar from './ChartBar.vue'
const meta: Meta<typeof ChartBar> = {
title: 'Components/Chart/ChartBar',
component: ChartBar,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
ariaLabel: 'Bar chart example',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'BarName1',
data: [10, 50, 35, 75],
backgroundColor: '#ff8000'
}
]
}
}
}
export const MultipleDatasets: Story = {
args: {
ariaLabel: 'Bar chart with multiple datasets',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'Series 1',
data: [30, 60, 45, 80],
backgroundColor: '#ff8000'
},
{
label: 'Series 2',
data: [50, 40, 70, 20],
backgroundColor: '#4ade80'
}
]
}
}
}

View File

@@ -0,0 +1,34 @@
<template>
<div
:class="
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
"
>
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
</div>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed, ref, toRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useChart } from './useChart'
const props = defineProps<{
data: ChartData<'bar'>
options?: ChartOptions<'bar'>
ariaLabel: string
class?: string
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
useChart(
canvasRef,
ref('bar'),
toRef(() => props.data),
computed(() => props.options as ChartOptions | undefined)
)
</script>

View File

@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ChartLine from './ChartLine.vue'
const meta: Meta<typeof ChartLine> = {
title: 'Components/Chart/ChartLine',
component: ChartLine,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
ariaLabel: 'Line chart example',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'LineName1',
data: [10, 45, 25, 80],
borderColor: '#4ade80',
borderDash: [5, 5],
fill: true,
backgroundColor: '#4ade8033',
tension: 0.4
}
]
}
}
}
export const MultipleLines: Story = {
args: {
ariaLabel: 'Line chart with multiple lines',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'LineName1',
data: [10, 45, 25, 80],
borderColor: '#4ade80',
borderDash: [5, 5],
fill: true,
backgroundColor: '#4ade8033',
tension: 0.4
},
{
label: 'LineName2',
data: [80, 60, 40, 10],
borderColor: '#ff8000',
fill: true,
backgroundColor: '#ff800033',
tension: 0.4
},
{
label: 'LineName3',
data: [60, 70, 35, 40],
borderColor: '#ef4444',
fill: true,
backgroundColor: '#ef444433',
tension: 0.4
}
]
}
}
}

View File

@@ -0,0 +1,34 @@
<template>
<div
:class="
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
"
>
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
</div>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed, ref, toRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useChart } from './useChart'
const props = defineProps<{
data: ChartData<'line'>
options?: ChartOptions<'line'>
ariaLabel: string
class?: string
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
useChart(
canvasRef,
ref('line'),
toRef(() => props.data),
computed(() => props.options as ChartOptions | undefined)
)
</script>

View File

@@ -0,0 +1,196 @@
import type { ChartData, ChartOptions, ChartType } from 'chart.js'
import {
BarController,
BarElement,
CategoryScale,
Chart,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip
} from 'chart.js'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
Chart.register(
BarController,
BarElement,
CategoryScale,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip
)
function getCssVar(name: string): string {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim()
}
function getDefaultOptions(type: ChartType): ChartOptions {
const foreground = getCssVar('--color-base-foreground') || '#ffffff'
const muted = getCssVar('--color-muted-foreground') || '#8a8a8a'
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
align: 'start',
labels: {
color: foreground,
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 8,
boxHeight: 8,
padding: 16,
font: { family: 'Inter', size: 11 },
generateLabels(chart) {
const datasets = chart.data.datasets
return datasets.map((dataset, i) => {
const color =
(dataset as { borderColor?: string }).borderColor ??
(dataset as { backgroundColor?: string }).backgroundColor ??
'#888'
return {
text: dataset.label ?? '',
fillStyle: color as string,
strokeStyle: color as string,
lineWidth: 0,
pointStyle: 'circle' as const,
hidden: !chart.isDatasetVisible(i),
datasetIndex: i
}
})
}
}
},
tooltip: {
enabled: true
}
},
elements: {
point: {
radius: 0,
hoverRadius: 4
}
},
scales: {
x: {
ticks: {
color: muted,
font: { family: 'Inter', size: 11 },
padding: 8
},
grid: {
display: true,
color: muted + '33',
drawTicks: false
},
border: { display: true, color: muted }
},
y: {
ticks: {
color: muted,
font: { family: 'Inter', size: 11 },
padding: 4
},
grid: {
display: false,
drawTicks: false
},
border: { display: true, color: muted }
}
},
...(type === 'bar' && {
datasets: {
bar: {
borderRadius: { topLeft: 4, topRight: 4 },
borderSkipped: false,
barPercentage: 0.6,
categoryPercentage: 0.8
}
}
})
}
}
export function useChart(
canvasRef: Ref<HTMLCanvasElement | null>,
type: Ref<ChartType>,
data: Ref<ChartData>,
options?: Ref<ChartOptions | undefined>
) {
const chartInstance = ref<Chart | null>(null)
function createChart() {
if (!canvasRef.value) return
chartInstance.value?.destroy()
const defaults = getDefaultOptions(type.value)
const merged = options?.value
? deepMerge(defaults, options.value)
: defaults
chartInstance.value = new Chart(canvasRef.value, {
type: type.value,
data: data.value,
options: merged
})
}
onMounted(createChart)
watch([type, data, options ?? ref(undefined)], () => {
if (chartInstance.value) {
chartInstance.value.data = data.value
chartInstance.value.options = options?.value
? deepMerge(getDefaultOptions(type.value), options.value)
: getDefaultOptions(type.value)
chartInstance.value.update()
}
})
onBeforeUnmount(() => {
chartInstance.value?.destroy()
chartInstance.value = null
})
return { chartInstance }
}
function deepMerge<T extends Record<string, unknown>>(
target: T,
source: Record<string, unknown>
): T {
const result = { ...target } as Record<string, unknown>
for (const key of Object.keys(source)) {
const srcVal = source[key]
const tgtVal = result[key]
if (
srcVal &&
typeof srcVal === 'object' &&
!Array.isArray(srcVal) &&
tgtVal &&
typeof tgtVal === 'object' &&
!Array.isArray(tgtVal)
) {
result[key] = deepMerge(
tgtVal as Record<string, unknown>,
srcVal as Record<string, unknown>
)
} else {
result[key] = srcVal
}
}
return result as T
}

View File

@@ -0,0 +1,60 @@
import { computed, reactive } from 'vue'
import EditKeybindingContent from '@/components/dialog/content/setting/keybinding/EditKeybindingContent.vue'
import EditKeybindingFooter from '@/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue'
import EditKeybindingHeader from '@/components/dialog/content/setting/keybinding/EditKeybindingHeader.vue'
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useDialogService } from '@/services/dialogService'
export const DIALOG_KEY = 'edit-keybinding'
export interface EditKeybindingDialogState {
commandId: string
newCombo: KeyComboImpl | null
currentCombo: KeyComboImpl | null
}
export function useEditKeybindingDialog() {
const { showSmallLayoutDialog } = useDialogService()
const keybindingStore = useKeybindingStore()
function show(options: {
commandId: string
commandLabel: string
currentCombo: KeyComboImpl | null
}) {
const dialogState = reactive<EditKeybindingDialogState>({
commandId: options.commandId,
newCombo: options.currentCombo,
currentCombo: options.currentCombo
})
const existingKeybindingOnCombo = computed(() => {
if (!dialogState.newCombo) return null
if (dialogState.currentCombo?.equals(dialogState.newCombo)) return null
return keybindingStore.getKeybinding(dialogState.newCombo)
})
function onUpdateCombo(combo: KeyComboImpl) {
dialogState.newCombo = combo
}
showSmallLayoutDialog({
key: DIALOG_KEY,
headerComponent: EditKeybindingHeader,
footerComponent: EditKeybindingFooter,
component: EditKeybindingContent,
props: {
dialogState,
onUpdateCombo,
commandLabel: options.commandLabel,
existingKeybindingOnCombo
},
headerProps: {},
footerProps: { dialogState, existingKeybindingOnCombo }
})
}
return { show }
}

View File

@@ -110,6 +110,7 @@
"delete": "Delete",
"rename": "Rename",
"save": "Save",
"saveAnyway": "Save Anyway",
"saving": "Saving",
"no": "No",
"cancel": "Cancel",
@@ -120,7 +121,6 @@
"showRightPanel": "Show right panel",
"hideRightPanel": "Hide right panel",
"or": "or",
"pressKeysForNewBinding": "Press keys for new binding",
"defaultBanner": "default banner",
"enableOrDisablePack": "Enable or disable pack",
"openManager": "Open Manager",
@@ -265,6 +265,11 @@
"multiSelectDropdown": "Multi-select dropdown",
"singleSelectDropdown": "Single-select dropdown",
"progressCountOf": "of",
"modifyKeybinding": "Modify keybinding",
"setAKeybindingForTheFollowing": "Set a keybinding for the following:",
"enterYourKeybind": "Enter your keybind",
"browserReservedKeybinding": "This shortcut is reserved by some browsers and may have unexpected results.",
"browserReservedKeybindingTooltip": "This shortcut conflicts with browser-reserved shortcuts",
"keybindingAlreadyExists": "Keybinding already exists on",
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information.",
"startRecording": "Start Recording",

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { KeyComboImpl } from './keyCombo'
describe('KeyComboImpl', () => {
describe('isBrowserReserved', () => {
it.each([
{ key: 't', ctrl: true, label: 'Ctrl + t' },
{ key: 'w', ctrl: true, label: 'Ctrl + w' },
{ key: 'F12', label: 'F12' },
{ key: 'n', ctrl: true, shift: true, label: 'Ctrl + Shift + n' },
{ key: 'r', ctrl: true, label: 'Ctrl + r' },
{ key: 'F5', label: 'F5' }
])('returns true for $label', ({ key, ctrl, shift }) => {
const combo = new KeyComboImpl({
key,
ctrl: ctrl ?? false,
alt: false,
shift: shift ?? false
})
expect(combo.isBrowserReserved).toBe(true)
})
it.each([
{ key: 'k', ctrl: true, label: 'Ctrl + k' },
{ key: 's', alt: true, label: 'Alt + s' },
{ key: 'z', ctrl: true, label: 'Ctrl + z' },
{ key: 'F6', label: 'F6' }
])('returns false for $label', ({ key, ctrl, alt }) => {
const combo = new KeyComboImpl({
key,
ctrl: ctrl ?? false,
alt: alt ?? false,
shift: false
})
expect(combo.isBrowserReserved).toBe(false)
})
})
})

View File

@@ -1,6 +1,6 @@
import { toRaw } from 'vue'
import { RESERVED_BY_TEXT_INPUT } from './reserved'
import { RESERVED_BY_BROWSER, RESERVED_BY_TEXT_INPUT } from './reserved'
import type { KeyCombo } from './types'
export class KeyComboImpl implements KeyCombo {
@@ -61,11 +61,15 @@ export class KeyComboImpl implements KeyCombo {
return this.shift && this.modifierCount === 1
}
get isBrowserReserved(): boolean {
return RESERVED_BY_BROWSER.has(toNormalizedString(this))
}
get isReservedByTextInput(): boolean {
return (
!this.hasModifier ||
this.isShiftOnly ||
RESERVED_BY_TEXT_INPUT.has(this.toString())
RESERVED_BY_TEXT_INPUT.has(toNormalizedString(this))
)
}
@@ -84,3 +88,12 @@ export class KeyComboImpl implements KeyCombo {
return sequences
}
}
function toNormalizedString(combo: KeyComboImpl): string {
const sequences: string[] = []
if (combo.ctrl) sequences.push('Ctrl')
if (combo.alt) sequences.push('Alt')
if (combo.shift) sequences.push('Shift')
sequences.push(combo.key.length === 1 ? combo.key.toLowerCase() : combo.key)
return sequences.join(' + ')
}

View File

@@ -1,3 +1,28 @@
export const RESERVED_BY_BROWSER = new Set([
'Ctrl + t', // New tab (all browsers)
'Ctrl + w', // Close tab (all browsers)
'Ctrl + n', // New window (all browsers)
'Ctrl + Shift + n', // New incognito/private window (all browsers)
'Ctrl + Tab', // Next tab (all browsers)
'Ctrl + Shift + Tab', // Previous tab (all browsers)
'Ctrl + Shift + Delete', // Clear browsing data (Chrome, Edge, Firefox)
'Ctrl + h', // History (all browsers)
'Ctrl + j', // Downloads (Chrome, Edge)
'Ctrl + d', // Bookmark current page (all browsers)
'Ctrl + Shift + b', // Toggle bookmarks bar (Chrome, Edge)
'Ctrl + Shift + o', // Bookmarks manager (Chrome, Edge)
'Ctrl + Shift + i', // DevTools (all browsers)
'Ctrl + Shift + j', // DevTools console (Chrome, Edge)
'F5', // Reload page (all browsers)
'Ctrl + F5', // Hard reload (all browsers)
'Ctrl + r', // Reload page (all browsers)
'Ctrl + Shift + r', // Hard reload (all browsers)
'F7', // Caret browsing (Firefox, Edge)
'F11', // Toggle fullscreen (all browsers)
'F12', // DevTools (all browsers)
'Alt + F4' // Close window (Windows, all browsers)
])
export const RESERVED_BY_TEXT_INPUT = new Set([
'Ctrl + a',
'Ctrl + c',

View File

@@ -0,0 +1,360 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as I18n from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
const settingMocks = vi.hoisted(() => ({
persistRef: null as { value: boolean } | null
}))
vi.mock('@/platform/settings/settingStore', async () => {
const { ref } = await import('vue')
settingMocks.persistRef = ref(true)
return {
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Workflow.Persist')
return settingMocks.persistRef!.value
return undefined
}),
set: vi.fn()
}))
}
})
const mockToastAdd = vi.fn()
vi.mock('primevue', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock(
'@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader',
() => ({
useSharedWorkflowUrlLoader: () => ({
loadSharedWorkflowFromUrl: vi.fn().mockResolvedValue('not-present')
})
})
)
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof I18n>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
const openWorkflowMock = vi.fn()
const loadBlankWorkflowMock = vi.fn()
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
openWorkflow: openWorkflowMock,
loadBlankWorkflow: loadBlankWorkflowMock
})
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
() => ({
useTemplateUrlLoader: () => ({
loadTemplateFromUrl: vi.fn()
})
})
)
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn()
})
}))
vi.mock('vue-router', () => ({
useRoute: () => ({
query: {}
}),
useRouter: () => ({
replace: vi.fn()
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserLogout: vi.fn()
})
}))
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn(() => null)
}))
vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template' }
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('../migration/migrateV1toV2', () => ({
migrateV1toV2: vi.fn()
}))
type GraphChangedHandler = (() => void) | null
const mocks = vi.hoisted(() => {
const state = {
graphChangedHandler: null as GraphChangedHandler,
currentGraph: {} as Record<string, unknown>
}
const serializeMock = vi.fn(() => state.currentGraph)
const loadGraphDataMock = vi.fn()
const apiMock = {
clientId: 'test-client',
initialClientId: 'test-client',
addEventListener: vi.fn((event: string, handler: () => void) => {
if (event === 'graphChanged') {
state.graphChangedHandler = handler
}
}),
removeEventListener: vi.fn()
}
return { state, serializeMock, loadGraphDataMock, apiMock }
})
vi.mock('@/scripts/app', () => ({
app: {
graph: {
serialize: () => mocks.serializeMock()
},
rootGraph: {
serialize: () => mocks.serializeMock()
},
loadGraphData: (...args: unknown[]) => mocks.loadGraphDataMock(...args),
canvas: {}
}
}))
vi.mock('@/scripts/api', () => ({
api: mocks.apiMock
}))
describe('useWorkflowPersistenceV2', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
setActivePinia(createTestingPinia({ stubActions: false }))
localStorage.clear()
sessionStorage.clear()
vi.clearAllMocks()
settingMocks.persistRef!.value = true
mocks.state.graphChangedHandler = null
mocks.state.currentGraph = { initial: true }
mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
mocks.loadGraphDataMock.mockReset()
mocks.apiMock.clientId = 'test-client'
mocks.apiMock.initialClientId = 'test-client'
mocks.apiMock.addEventListener.mockImplementation(
(event: string, handler: () => void) => {
if (event === 'graphChanged') {
mocks.state.graphChangedHandler = handler
}
}
)
mocks.apiMock.removeEventListener.mockImplementation(() => {})
openWorkflowMock.mockReset()
loadBlankWorkflowMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
function writeTabState(paths: string[], activeIndex: number) {
const pointer = {
workspaceId: 'personal',
paths,
activeIndex
}
sessionStorage.setItem(
`Comfy.Workflow.OpenPaths:test-client`,
JSON.stringify(pointer)
)
}
function writeActivePath(path: string) {
const pointer = {
workspaceId: 'personal',
path
}
sessionStorage.setItem(
`Comfy.Workflow.ActivePath:test-client`,
JSON.stringify(pointer)
)
}
describe('loadPreviousWorkflowFromStorage', () => {
it('loads saved workflow when draft is missing for session path', async () => {
const workflowStore = useWorkflowStore()
const savedWorkflow = workflowStore.createTemporary('SavedWorkflow.json')
// Set session path to the saved workflow but do NOT create a draft
writeActivePath(savedWorkflow.path)
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
// Should call workflowService.openWorkflow with the saved workflow
expect(openWorkflowMock).toHaveBeenCalledWith(savedWorkflow)
// Should NOT fall through to loadGraphData (fallbackToLatestDraft)
expect(mocks.loadGraphDataMock).not.toHaveBeenCalled()
})
it('prefers draft over saved workflow when draft exists', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
const workflow = workflowStore.createTemporary('DraftWorkflow.json')
const draftData = JSON.stringify({ nodes: [], title: 'draft' })
draftStore.saveDraft(workflow.path, draftData, {
name: 'DraftWorkflow.json',
isTemporary: true
})
writeActivePath(workflow.path)
mocks.loadGraphDataMock.mockResolvedValue(undefined)
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
// Should load draft via loadGraphData, not via workflowService.openWorkflow
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
it('falls back to latest draft only when no session path exists', async () => {
const draftStore = useWorkflowDraftStoreV2()
// No session path set, but a draft exists
const draftData = JSON.stringify({ nodes: [], title: 'latest' })
draftStore.saveDraft('workflows/Other.json', draftData, {
name: 'Other.json',
isTemporary: true
})
mocks.loadGraphDataMock.mockResolvedValue(undefined)
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
// Should load via fallbackToLatestDraft
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
})
describe('restoreWorkflowTabsState', () => {
it('activates the correct workflow at storedActiveIndex', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
// Create two temporary workflows with drafts
const workflowA = workflowStore.createTemporary('WorkflowA.json')
const workflowB = workflowStore.createTemporary('WorkflowB.json')
draftStore.saveDraft(workflowA.path, JSON.stringify({ title: 'A' }), {
name: 'WorkflowA.json',
isTemporary: true
})
draftStore.saveDraft(workflowB.path, JSON.stringify({ title: 'B' }), {
name: 'WorkflowB.json',
isTemporary: true
})
// storedActiveIndex = 1 → WorkflowB should be activated
writeTabState([workflowA.path, workflowB.path], 1)
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
expect(openWorkflowMock).toHaveBeenCalledWith(workflowB)
})
it('activates first tab when storedActiveIndex is 0', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
const workflowA = workflowStore.createTemporary('WorkflowA.json')
const workflowB = workflowStore.createTemporary('WorkflowB.json')
draftStore.saveDraft(workflowA.path, JSON.stringify({ title: 'A' }), {
name: 'WorkflowA.json',
isTemporary: true
})
draftStore.saveDraft(workflowB.path, JSON.stringify({ title: 'B' }), {
name: 'WorkflowB.json',
isTemporary: true
})
writeTabState([workflowA.path, workflowB.path], 0)
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
expect(openWorkflowMock).toHaveBeenCalledWith(workflowA)
})
it('does not call openWorkflow when no restorable state', async () => {
// No tab state written to sessionStorage
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
it('restores temporary workflows and adds them to tabs', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
// Save a draft for a workflow that doesn't exist in the store yet
const path = 'workflows/Unsaved.json'
draftStore.saveDraft(path, JSON.stringify({ title: 'Unsaved' }), {
name: 'Unsaved.json',
isTemporary: true
})
writeTabState([path], 0)
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
const restored = workflowStore.getWorkflowByPath(path)
expect(restored).toBeTruthy()
expect(restored?.isTemporary).toBe(true)
expect(workflowStore.openWorkflows.map((w) => w?.path)).toContain(path)
})
it('skips activation when persistence is disabled', async () => {
settingMocks.persistRef!.value = false
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
})
})

View File

@@ -132,19 +132,28 @@ export function useWorkflowPersistenceV2() {
const debouncedPersist = debounce(persistCurrentWorkflow, PERSIST_DEBOUNCE_MS)
const loadPreviousWorkflowFromStorage = async () => {
// 1. Try session pointer (for tab restoration)
const sessionPath = tabState.getActivePath()
// 1. Try draft for session path
if (
sessionPath &&
(await draftStore.loadPersistedWorkflow({
workflowName: null,
preferredPath: sessionPath
}))
) {
)
return true
// 2. Try saved workflow by path (draft may not exist for saved+unmodified workflows)
if (sessionPath) {
const saved = workflowStore.getWorkflowByPath(sessionPath)
if (saved) {
await useWorkflowService().openWorkflow(saved)
return true
}
}
// 2. Fall back to most recent draft
// 3. Fall back to most recent draft
return await draftStore.loadPersistedWorkflow({
workflowName: null,
fallbackToLatestDraft: true
@@ -242,7 +251,7 @@ export function useWorkflowPersistenceV2() {
}
})
const restoreWorkflowTabsState = () => {
const restoreWorkflowTabsState = async () => {
if (!workflowPersistenceEnabled.value) {
tabStateRestored = true
return
@@ -254,10 +263,11 @@ export function useWorkflowPersistenceV2() {
const storedWorkflows = storedTabState?.paths ?? []
const storedActiveIndex = storedTabState?.activeIndex ?? -1
tabStateRestored = true
const isRestorable = storedWorkflows.length > 0 && storedActiveIndex >= 0
if (!isRestorable) return
if (!isRestorable) {
tabStateRestored = true
return
}
storedWorkflows.forEach((path: string) => {
if (workflowStore.getWorkflowByPath(path)) return
@@ -280,6 +290,17 @@ export function useWorkflowPersistenceV2() {
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
})
tabStateRestored = true
// Activate the correct workflow at storedActiveIndex
const activePath = storedWorkflows[storedActiveIndex]
const workflow = activePath
? workflowStore.getWorkflowByPath(activePath)
: null
if (workflow) {
await useWorkflowService().openWorkflow(workflow)
}
}
return {

View File

@@ -365,6 +365,7 @@ describe('linearOutputStore', () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
activeJobIdRef.value = 'job-1'
await nextTick()
@@ -387,12 +388,14 @@ describe('linearOutputStore', () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
activeJobIdRef.value = 'job-1'
await nextTick()
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
// Direct transition: job-1 → job-2 (no null in between)
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
activeJobIdRef.value = 'job-2'
await nextTick()
@@ -631,7 +634,7 @@ describe('linearOutputStore', () => {
)
})
it('resets state when leaving app mode', async () => {
it('preserves in-progress items when leaving app mode', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
@@ -643,9 +646,65 @@ describe('linearOutputStore', () => {
isAppModeRef.value = false
await nextTick()
expect(store.inProgressItems).toHaveLength(0)
expect(store.selectedId).toBeNull()
expect(store.pendingResolve.size).toBe(0)
expect(store.inProgressItems.length).toBeGreaterThan(0)
expect(store.selectedId).toBe('slot:some-id')
})
it('completes stale tracked job when re-entering app mode', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
activeJobIdRef.value = 'job-1'
await nextTick()
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
// Switch away — job finishes while we're gone
isAppModeRef.value = false
await nextTick()
activeJobIdRef.value = null
await nextTick()
// Switch back — store should reconcile the stale tracked job
isAppModeRef.value = true
await nextTick()
expect(store.pendingResolve.has('job-1')).toBe(true)
})
it('recovers latent preview when re-entering app mode', async () => {
vi.useFakeTimers()
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
activeJobIdRef.value = 'job-1'
await nextTick()
// First node executes, consuming the skeleton
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
expect(store.inProgressItems[0].state).toBe('image')
// Switch away — latent preview arrives for next node while gone
isAppModeRef.value = false
await nextTick()
previewsRef.value = {
'job-1': { url: 'blob:preview-while-away', nodeId: 'node-2' }
}
await nextTick()
// Switch back — should recover the latent preview
isAppModeRef.value = true
await nextTick()
vi.advanceTimersByTime(16)
const latentItems = store.inProgressItems.filter(
(i) => i.state === 'latent'
)
expect(latentItems).toHaveLength(1)
expect(latentItems[0].latentPreviewUrl).toBe('blob:preview-while-away')
vi.useRealTimers()
})
it('does not show in-progress items from another workflow', () => {
@@ -785,4 +844,472 @@ describe('linearOutputStore', () => {
expect(store.inProgressItems).toHaveLength(0)
})
describe('workflow switching during generation', () => {
async function setup() {
vi.useFakeTimers()
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
return { store, nextTick }
}
afterEach(() => {
vi.useRealTimers()
})
it('preserves images and latent previews across tab switch', async () => {
const { store, nextTick } = await setup()
// Workflow A: start job, produce 1 image + 1 latent
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
store.onLatentPreview('job-a', 'blob:node2-latent', '2')
vi.advanceTimersByTime(16)
const imagesBefore = store.inProgressItems.filter(
(i) => i.state === 'image'
)
const latentsBefore = store.inProgressItems.filter(
(i) => i.state === 'latent'
)
expect(imagesBefore).toHaveLength(1)
expect(latentsBefore).toHaveLength(1)
// Switch to workflow B (graph mode)
activeWorkflowPathRef.value = 'workflows/graph-b.json'
isAppModeRef.value = false
await nextTick()
// Items still in store (not reset)
expect(store.inProgressItems.filter((i) => i.state === 'image')).toEqual(
imagesBefore
)
expect(store.inProgressItems.filter((i) => i.state === 'latent')).toEqual(
latentsBefore
)
// Switch back to workflow A
activeWorkflowPathRef.value = 'workflows/app-a.json'
isAppModeRef.value = true
await nextTick()
vi.advanceTimersByTime(16)
// Items visible via activeWorkflowInProgressItems
expect(store.activeWorkflowInProgressItems).toHaveLength(2)
expect(
store.activeWorkflowInProgressItems.some((i) => i.state === 'image')
).toBe(true)
})
it('captures outputs produced while viewing another tab', async () => {
const { store, nextTick } = await setup()
// Workflow A: start job, produce 1 image
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
expect(
store.inProgressItems.filter((i) => i.state === 'image')
).toHaveLength(1)
// Switch away
activeWorkflowPathRef.value = 'workflows/graph-b.json'
isAppModeRef.value = false
await nextTick()
// While away: node 2 executes (event missed — listener removed)
// Node 3 starts sending latent previews (watcher guarded)
previewsRef.value = {
'job-a': { url: 'blob:node3-latent', nodeId: '3' }
}
await nextTick()
// Switch back
activeWorkflowPathRef.value = 'workflows/app-a.json'
isAppModeRef.value = true
await nextTick()
vi.advanceTimersByTime(16)
// Original image preserved + latent preview recovered
expect(
store.inProgressItems.filter((i) => i.state === 'image')
).toHaveLength(1)
expect(
store.inProgressItems.filter((i) => i.state === 'latent')
).toHaveLength(1)
expect(store.inProgressItems[0].latentPreviewUrl).toBe(
'blob:node3-latent'
)
})
it('scopes items to correct workflow after switching back', async () => {
const { store, nextTick } = await setup()
// Workflow A: start job
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
// Switch to workflow B
activeWorkflowPathRef.value = 'workflows/app-b.json'
// Workflow A items should NOT appear for workflow B
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
// Switch back to workflow A
activeWorkflowPathRef.value = 'workflows/app-a.json'
// Workflow A items should reappear
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-a')
expect(store.activeWorkflowInProgressItems[0].state).toBe('image')
})
it('completes job A while away and starts tracking job B on return', async () => {
const { store, nextTick } = await setup()
// Workflow A: start and partially generate
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
// Switch to workflow B (graph mode)
activeWorkflowPathRef.value = 'workflows/graph-b.json'
isAppModeRef.value = false
await nextTick()
// While away: job A finishes, job B starts
setJobWorkflowPath('job-b', 'workflows/app-a.json')
activeJobIdRef.value = 'job-b'
await nextTick()
// Switch back to workflow A
activeWorkflowPathRef.value = 'workflows/app-a.json'
isAppModeRef.value = true
await nextTick()
// Job A should have been completed (pending resolve)
expect(store.pendingResolve.has('job-a')).toBe(true)
// Job B should have been started
expect(store.inProgressItems.some((i) => i.jobId === 'job-b')).toBe(true)
})
it('handles job finishing while away with no new job', async () => {
const { store, nextTick } = await setup()
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
// Switch away
isAppModeRef.value = false
await nextTick()
// Job finishes, no new job
activeJobIdRef.value = null
await nextTick()
// Switch back
isAppModeRef.value = true
await nextTick()
// Job A completed, images pending resolve
expect(store.pendingResolve.has('job-a')).toBe(true)
// No new skeleton should have been created (no active job)
expect(
store.inProgressItems.filter((i) => i.state === 'skeleton')
).toHaveLength(0)
})
it('does not leak workflow A items into workflow B view', async () => {
const { store, nextTick } = await setup()
// Workflow A: two images
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
store.onNodeExecuted(
'job-a',
makeExecutedDetail(
'job-a',
[{ filename: 'b.png', subfolder: '', type: 'output' }],
'2'
)
)
// Items exist in the global list
expect(
store.inProgressItems.filter((i) => i.state === 'image')
).toHaveLength(2)
// Switch to workflow B (also app mode)
activeWorkflowPathRef.value = 'workflows/app-b.json'
// Workflow B should see nothing from job-a
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
// Global list still has them
expect(
store.inProgressItems.filter((i) => i.state === 'image')
).toHaveLength(2)
})
it('cleans up stale tracked job when leaving app mode after job finishes', async () => {
const { store, nextTick } = await setup()
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
// Job finishes while still in app mode
store.onJobComplete('job-a')
expect(store.pendingResolve.has('job-a')).toBe(true)
// Now switch away — pendingResolve items stay (still-running case
// doesn't apply, but items are kept for history absorption)
activeJobIdRef.value = null
isAppModeRef.value = false
await nextTick()
// Items preserved (pendingResolve is not cleared on exit)
expect(store.inProgressItems.some((i) => i.jobId === 'job-a')).toBe(true)
})
it('evicts prior pendingResolve entries when a new job completes', () => {
vi.useFakeTimers()
const store = useLinearOutputStore()
// Job 1: produce image, complete
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
store.onJobComplete('job-1')
expect(store.pendingResolve.has('job-1')).toBe(true)
expect(store.inProgressItems.some((i) => i.jobId === 'job-1')).toBe(true)
// Job 2: produce image, complete — should evict job-1
store.onJobStart('job-2')
store.onNodeExecuted('job-2', makeExecutedDetail('job-2'))
store.onJobComplete('job-2')
expect(store.pendingResolve.has('job-1')).toBe(false)
expect(store.inProgressItems.some((i) => i.jobId === 'job-1')).toBe(false)
// Job 2 is now pending resolve
expect(store.pendingResolve.has('job-2')).toBe(true)
vi.useRealTimers()
})
it('cleans up finished tracked job on exit when job ended while in app mode', async () => {
const { store, nextTick } = await setup()
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
// Job ends, new job starts — all while in app mode
setJobWorkflowPath('job-b', 'workflows/app-a.json')
activeJobIdRef.value = 'job-b'
await nextTick()
// job-a completed via activeJobId watcher, now pending resolve
expect(store.pendingResolve.has('job-a')).toBe(true)
// Switch away — tracked job is now job-b which is still active, so
// the else-branch does NOT complete it (it's still running)
isAppModeRef.value = false
await nextTick()
// job-b items preserved (still running)
expect(store.inProgressItems.some((i) => i.jobId === 'job-b')).toBe(true)
})
it('does not leak items across many job cycles', () => {
vi.useFakeTimers()
const store = useLinearOutputStore()
for (let i = 1; i <= 5; i++) {
const jobId = `job-${i}`
setJobWorkflowPath(jobId, 'workflows/test-workflow.json')
store.onJobStart(jobId)
store.onNodeExecuted(jobId, makeExecutedDetail(jobId))
store.onJobComplete(jobId)
}
// Only the last job should have items (pending resolve).
// All prior jobs were evicted by subsequent onJobComplete calls.
expect(store.pendingResolve.size).toBe(1)
expect(store.pendingResolve.has('job-5')).toBe(true)
expect(store.inProgressItems.every((i) => i.jobId === 'job-5')).toBe(true)
vi.useRealTimers()
})
it('does not adopt another workflow job when switching back', async () => {
const { store, nextTick } = await setup()
// Tab A: queue "cat"
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-cat', 'workflows/app-a.json')
activeJobIdRef.value = 'job-cat'
await nextTick()
store.onNodeExecuted(
'job-cat',
makeExecutedDetail('job-cat', undefined, '1')
)
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-cat')
// Switch to tab B (different workflow, also app mode): queue "dog"
activeWorkflowPathRef.value = 'workflows/app-b.json'
isAppModeRef.value = false
await nextTick()
isAppModeRef.value = true
await nextTick()
setJobWorkflowPath('job-dog', 'workflows/app-b.json')
activeJobIdRef.value = 'job-dog'
await nextTick()
// Tab B should see dog, not cat
expect(
store.activeWorkflowInProgressItems.every((i) => i.jobId === 'job-dog')
).toBe(true)
// Switch back to tab A
activeWorkflowPathRef.value = 'workflows/app-a.json'
isAppModeRef.value = false
await nextTick()
isAppModeRef.value = true
await nextTick()
// Dog's executed events arrive while viewing tab A (listener is
// active and activeJobId is still job-dog).
const event = new CustomEvent('executed', {
detail: makeExecutedDetail(
'job-dog',
[{ filename: 'dog.png', subfolder: '', type: 'output' }],
'1'
)
})
apiTarget.dispatchEvent(event)
// Dog's latent preview also arrives
previewsRef.value = {
'job-dog': { url: 'blob:dog-latent', nodeId: '2' }
}
await nextTick()
vi.advanceTimersByTime(16)
// Tab A must NOT show dog — only cat
const tabAItems = store.activeWorkflowInProgressItems
expect(tabAItems.every((i) => i.jobId === 'job-cat')).toBe(true)
expect(tabAItems.some((i) => i.jobId === 'job-dog')).toBe(false)
// Selection must not have been yanked to a dog item
expect(store.selectedId?.includes('job-dog') ?? false).toBe(false)
// Dog should still exist globally (scoped to tab B)
expect(store.inProgressItems.some((i) => i.jobId === 'job-dog')).toBe(
true
)
})
it('does not create skeleton for next job from another workflow', async () => {
const { store, nextTick } = await setup()
// Run dog on dog tab
activeWorkflowPathRef.value = 'workflows/dog.json'
setJobWorkflowPath('job-dog', 'workflows/dog.json')
activeJobIdRef.value = 'job-dog'
await nextTick()
// Swap to cat tab, queue cat (dog still running)
activeWorkflowPathRef.value = 'workflows/cat.json'
isAppModeRef.value = false
await nextTick()
isAppModeRef.value = true
await nextTick()
setJobWorkflowPath('job-cat', 'workflows/cat.json')
// Swap back to dog tab
activeWorkflowPathRef.value = 'workflows/dog.json'
isAppModeRef.value = false
await nextTick()
isAppModeRef.value = true
await nextTick()
// Dog finishes, cat starts (activeJobId transitions on dog tab)
activeJobIdRef.value = 'job-cat'
await nextTick()
// Dog tab must NOT show cat's skeleton
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
// No skeleton for cat should have been created at all
expect(
store.inProgressItems.some(
(i) => i.jobId === 'job-cat' && i.state === 'skeleton'
)
).toBe(false)
})
it('processes new executed events after switching back', async () => {
const { store, nextTick } = await setup()
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
// Switch away and back
isAppModeRef.value = false
await nextTick()
isAppModeRef.value = true
await nextTick()
// Fire an executed event via the API — listener should be re-attached
const event = new CustomEvent('executed', {
detail: makeExecutedDetail(
'job-a',
[{ filename: 'c.png', subfolder: '', type: 'output' }],
'3'
)
})
apiTarget.dispatchEvent(event)
expect(
store.inProgressItems.filter((i) => i.state === 'image')
).toHaveLength(2)
})
})
})

View File

@@ -167,6 +167,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
}
function onJobComplete(jobId: string) {
// On any job complete, remove all pending resolve items.
if (pendingResolve.value.size > 0) {
for (const oldJobId of pendingResolve.value) {
removeJobItems(oldJobId)
}
pendingResolve.value = new Set()
}
if (raf) {
cancelAnimationFrame(raf)
raf = null
@@ -226,11 +234,16 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
isFollowing.value = true
}
function isJobForActiveWorkflow(jobId: string): boolean {
return (
executionStore.jobIdToSessionWorkflowPath.get(jobId) ===
workflowStore.activeWorkflow?.path
)
}
function autoSelect(slotId: string, jobId: string) {
// Only auto-select if the job belongs to the active workflow
const path = workflowStore.activeWorkflow?.path
if (path && executionStore.jobIdToSessionWorkflowPath.get(jobId) !== path)
return
if (!isJobForActiveWorkflow(jobId)) return
const sel = selectedId.value
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
@@ -249,20 +262,6 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
onNodeExecuted(jobId, detail)
}
function reset() {
if (raf) {
cancelAnimationFrame(raf)
raf = null
}
executedNodeIds.clear()
inProgressItems.value = []
selectedId.value = null
isFollowing.value = true
trackedJobId.value = null
currentSkeletonId.value = null
pendingResolve.value = new Set()
}
watch(
() => executionStore.activeJobId,
(jobId, oldJobId) => {
@@ -270,7 +269,10 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
if (oldJobId && oldJobId !== jobId) {
onJobComplete(oldJobId)
}
if (jobId) {
// Start tracking only if the job belongs to this workflow.
// Jobs from other workflows are picked up by reconcileOnEnter
// when the user switches to that workflow's tab.
if (jobId && isJobForActiveWorkflow(jobId)) {
onJobStart(jobId)
}
}
@@ -288,14 +290,66 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
{ deep: true }
)
function reconcileOnEnter() {
// Complete any tracked job that finished while we were away.
// The activeJobId watcher couldn't fire onJobComplete because
// isAppMode was false at the time.
if (
trackedJobId.value &&
trackedJobId.value !== executionStore.activeJobId
) {
onJobComplete(trackedJobId.value)
}
// Start tracking the current job only if it belongs to this
// workflow — otherwise we'd adopt another tab's job.
if (
executionStore.activeJobId &&
trackedJobId.value !== executionStore.activeJobId &&
isJobForActiveWorkflow(executionStore.activeJobId)
) {
onJobStart(executionStore.activeJobId)
}
// Clear stale selection from another workflow's job.
if (
selectedId.value?.startsWith('slot:') &&
trackedJobId.value &&
!isJobForActiveWorkflow(trackedJobId.value)
) {
selectedId.value = null
isFollowing.value = true
}
// Re-apply the latest latent preview that may have arrived while
// away, but only for a job belonging to the active workflow.
const jobId = trackedJobId.value
if (jobId && isJobForActiveWorkflow(jobId)) {
const preview = jobPreviewStore.nodePreviewsByPromptId[jobId]
if (preview) onLatentPreview(jobId, preview.url, preview.nodeId)
}
}
function cleanupOnLeave() {
// If the tracked job already finished (no longer the active job),
// complete it now to clean up skeletons/latents. If it's still
// running, preserve all items for tab switching.
if (
trackedJobId.value &&
trackedJobId.value !== executionStore.activeJobId
) {
onJobComplete(trackedJobId.value)
}
}
watch(
isAppMode,
(active, wasActive) => {
if (active) {
api.addEventListener('executed', handleExecuted)
reconcileOnEnter()
} else if (wasActive) {
api.removeEventListener('executed', handleExecuted)
reset()
cleanupOnLeave()
}
},
{ immediate: true }

View File

@@ -51,6 +51,31 @@ describe('defaultSearcher', () => {
const result = await defaultSearcher('xyz', items)
expect(result).toHaveLength(0)
})
it('matches against label when provided', async () => {
const itemsWithLabels = [
createItem('model_v1.safetensors', 'My Cool Model'),
createItem('lora_v2.safetensors', 'Style Transfer LoRA'),
createItem('checkpoint.ckpt', 'Realistic Vision')
]
const result = await defaultSearcher('cool', itemsWithLabels)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('model_v1.safetensors')
})
it('matches by label case-insensitively', async () => {
const itemsWithLabels = [createItem('file.safetensors', 'My Cool Model')]
const result = await defaultSearcher('MY COOL', itemsWithLabels)
expect(result).toHaveLength(1)
})
it('matches when word is in name or label', async () => {
const itemsWithLabels = [
createItem('sd_v15.safetensors', 'Stable Diffusion 1.5')
]
const result = await defaultSearcher('stable', itemsWithLabels)
expect(result).toHaveLength(1)
})
})
describe('getDefaultSortOptions', () => {

View File

@@ -12,7 +12,8 @@ export async function defaultSearcher(
const words = query.trim().toLowerCase().split(' ')
return items.filter((item) => {
const name = item.name.toLowerCase()
return words.every((word) => name.includes(word))
const label = item.label?.toLowerCase() ?? ''
return words.every((word) => name.includes(word) || label.includes(word))
})
}