mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-11 17:52:19 +00:00
Compare commits
7 Commits
copilot/su
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
751da03084 | ||
|
|
e6cfb868c0 | ||
|
|
37c6ddfcd9 | ||
|
|
55c42ee484 | ||
|
|
b04db536a1 | ||
|
|
975d6a360d | ||
|
|
7e137d880b |
150
.claude/skills/backport-management/SKILL.md
Normal file
150
.claude/skills/backport-management/SKILL.md
Normal 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)
|
||||
```
|
||||
68
.claude/skills/backport-management/reference/analysis.md
Normal file
68
.claude/skills/backport-management/reference/analysis.md
Normal 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
|
||||
42
.claude/skills/backport-management/reference/discovery.md
Normal file
42
.claude/skills/backport-management/reference/discovery.md
Normal 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 |
|
||||
150
.claude/skills/backport-management/reference/execution.md
Normal file
150
.claude/skills/backport-management/reference/execution.md
Normal 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.
|
||||
96
.claude/skills/backport-management/reference/logging.md
Normal file
96
.claude/skills/backport-management/reference/logging.md
Normal 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
1
.gitignore
vendored
@@ -26,6 +26,7 @@ dist-ssr
|
||||
.claude/*.local.json
|
||||
.claude/*.local.md
|
||||
.claude/*.local.txt
|
||||
.claude/worktrees
|
||||
CLAUDE.local.md
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
91
docs/architecture/change-tracker.md
Normal file
91
docs/architecture/change-tracker.md
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -39,11 +39,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementBounding, useEventListener, useRafFn } from '@vueuse/core'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useCanvasMenuPositionSync } from '@/composables/graph/useCanvasMenuPositionSync'
|
||||
import {
|
||||
registerNodeOptionsInstance,
|
||||
useMoreOptionsMenu
|
||||
@@ -52,7 +53,6 @@ import type {
|
||||
MenuOption,
|
||||
SubMenuOption
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
|
||||
|
||||
@@ -67,67 +67,11 @@ const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const { menuOptions, bump } = useMoreOptionsMenu()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// World position (canvas coordinates) where menu was opened
|
||||
const worldPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
// Get canvas bounding rect reactively
|
||||
const lgCanvas = canvasStore.getCanvas()
|
||||
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)
|
||||
|
||||
// Track last canvas transform to detect actual changes
|
||||
let lastScale = 0
|
||||
let lastOffsetX = 0
|
||||
let lastOffsetY = 0
|
||||
|
||||
// Update menu position based on canvas transform
|
||||
const updateMenuPosition = () => {
|
||||
if (!isOpen.value) return
|
||||
|
||||
const menuInstance = contextMenu.value as unknown as {
|
||||
container?: HTMLElement
|
||||
}
|
||||
const menuEl = menuInstance?.container
|
||||
if (!menuEl) return
|
||||
|
||||
const { scale, offset } = lgCanvas.ds
|
||||
|
||||
// Only update if canvas transform actually changed
|
||||
if (
|
||||
scale === lastScale &&
|
||||
offset[0] === lastOffsetX &&
|
||||
offset[1] === lastOffsetY
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
|
||||
// Convert world position to screen position
|
||||
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
|
||||
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value
|
||||
|
||||
// Update menu position
|
||||
menuEl.style.left = `${screenX}px`
|
||||
menuEl.style.top = `${screenY}px`
|
||||
}
|
||||
|
||||
// Sync with canvas transform using requestAnimationFrame
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
|
||||
immediate: false
|
||||
})
|
||||
|
||||
// Start/stop syncing based on menu visibility
|
||||
watchEffect(() => {
|
||||
if (isOpen.value) {
|
||||
startSync()
|
||||
} else {
|
||||
stopSync()
|
||||
}
|
||||
})
|
||||
const { setWorldPositionFromEvent } = useCanvasMenuPositionSync(
|
||||
contextMenu,
|
||||
isOpen
|
||||
)
|
||||
|
||||
// Close on touch outside to handle mobile devices where click might be swallowed
|
||||
useEventListener(
|
||||
@@ -207,25 +151,7 @@ const menuItems = computed<ExtendedMenuItem[]>(() =>
|
||||
// Show context menu
|
||||
function show(event: MouseEvent) {
|
||||
bump()
|
||||
|
||||
// Convert screen position to world coordinates
|
||||
// Screen position relative to canvas = event position - canvas offset
|
||||
const screenX = event.clientX - canvasLeft.value
|
||||
const screenY = event.clientY - canvasTop.value
|
||||
|
||||
// Convert to world coordinates using canvas transform
|
||||
const { scale, offset } = lgCanvas.ds
|
||||
worldPosition.value = {
|
||||
x: screenX / scale - offset[0],
|
||||
y: screenY / scale - offset[1]
|
||||
}
|
||||
|
||||
// Initialize last* values to current transform to prevent updateMenuPosition
|
||||
// from overwriting PrimeVue's flip-adjusted position on the first RAF tick
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
|
||||
setWorldPositionFromEvent(event)
|
||||
isOpen.value = true
|
||||
contextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
90
src/composables/graph/useCanvasMenuPositionSync.ts
Normal file
90
src/composables/graph/useCanvasMenuPositionSync.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useElementBounding, useRafFn } from '@vueuse/core'
|
||||
import type ContextMenu from 'primevue/contextmenu'
|
||||
import type { Ref } from 'vue'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
/**
|
||||
* Keeps a PrimeVue ContextMenu anchored to a world-space position while the
|
||||
* canvas is panned/zoomed. Performs dirty-checking each RAF tick so the DOM
|
||||
* is only touched when the transform actually changes.
|
||||
*/
|
||||
export function useCanvasMenuPositionSync(
|
||||
contextMenu: Ref<InstanceType<typeof ContextMenu> | undefined>,
|
||||
isOpen: Ref<boolean>
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const lgCanvas = canvasStore.getCanvas()
|
||||
const { left: canvasLeft, top: canvasTop } = useElementBounding(
|
||||
lgCanvas.canvas
|
||||
)
|
||||
|
||||
const worldPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
let lastScale = 0
|
||||
let lastOffsetX = 0
|
||||
let lastOffsetY = 0
|
||||
|
||||
function updateMenuPosition() {
|
||||
if (!isOpen.value) return
|
||||
|
||||
const menuInstance = contextMenu.value as unknown as {
|
||||
container?: HTMLElement
|
||||
}
|
||||
const menuEl = menuInstance?.container
|
||||
if (!menuEl) return
|
||||
|
||||
const { scale, offset } = lgCanvas.ds
|
||||
|
||||
if (
|
||||
scale === lastScale &&
|
||||
offset[0] === lastOffsetX &&
|
||||
offset[1] === lastOffsetY
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
|
||||
const screenX =
|
||||
(worldPosition.value.x + offset[0]) * scale + canvasLeft.value
|
||||
const screenY =
|
||||
(worldPosition.value.y + offset[1]) * scale + canvasTop.value
|
||||
|
||||
menuEl.style.left = `${screenX}px`
|
||||
menuEl.style.top = `${screenY}px`
|
||||
}
|
||||
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
|
||||
immediate: false
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (isOpen.value) {
|
||||
startSync()
|
||||
} else {
|
||||
stopSync()
|
||||
}
|
||||
})
|
||||
|
||||
/** Convert a mouse event's screen position to world coordinates and store it. */
|
||||
function setWorldPositionFromEvent(event: MouseEvent) {
|
||||
const screenX = event.clientX - canvasLeft.value
|
||||
const screenY = event.clientY - canvasTop.value
|
||||
const { scale, offset } = lgCanvas.ds
|
||||
|
||||
worldPosition.value = {
|
||||
x: screenX / scale - offset[0],
|
||||
y: screenY / scale - offset[1]
|
||||
}
|
||||
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
}
|
||||
|
||||
return { setWorldPositionFromEvent }
|
||||
}
|
||||
@@ -59,7 +59,10 @@ function mkFileUrl(props: { ref: ImageRef; preview?: boolean }): string {
|
||||
}
|
||||
|
||||
const pathPlusQueryParams = api.apiURL(
|
||||
'/view?' + params.toString() + app.getPreviewFormatParam()
|
||||
'/view?' +
|
||||
params.toString() +
|
||||
app.getPreviewFormatParam() +
|
||||
app.getRandParam()
|
||||
)
|
||||
const imageElement = new Image()
|
||||
imageElement.crossOrigin = 'anonymous'
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
type MockTask = {
|
||||
displayStatus: 'Completed' | 'Failed' | 'Cancelled' | 'Running' | 'Pending'
|
||||
executionEndTimestamp?: number
|
||||
previewOutput?: {
|
||||
isImage: boolean
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/stores/queueStore', () => {
|
||||
const state = reactive({
|
||||
runningTasks: [] as MockTask[],
|
||||
historyTasks: [] as MockTask[]
|
||||
})
|
||||
|
||||
return {
|
||||
useQueueStore: () => state
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/executionStore', () => {
|
||||
const state = reactive({
|
||||
isIdle: true
|
||||
})
|
||||
|
||||
return {
|
||||
useExecutionStore: () => state
|
||||
}
|
||||
})
|
||||
|
||||
describe('useCompletionSummary', () => {
|
||||
const queueStore = () =>
|
||||
useQueueStore() as {
|
||||
runningTasks: MockTask[]
|
||||
historyTasks: MockTask[]
|
||||
}
|
||||
const executionStore = () => useExecutionStore() as { isIdle: boolean }
|
||||
|
||||
const resetState = () => {
|
||||
queueStore().runningTasks = []
|
||||
queueStore().historyTasks = []
|
||||
executionStore().isIdle = true
|
||||
}
|
||||
|
||||
const createTask = (
|
||||
options: {
|
||||
state?: MockTask['displayStatus']
|
||||
ts?: number
|
||||
previewUrl?: string
|
||||
isImage?: boolean
|
||||
} = {}
|
||||
): MockTask => {
|
||||
const {
|
||||
state = 'Completed',
|
||||
ts = Date.now(),
|
||||
previewUrl,
|
||||
isImage = true
|
||||
} = options
|
||||
|
||||
const task: MockTask = {
|
||||
displayStatus: state,
|
||||
executionEndTimestamp: ts
|
||||
}
|
||||
|
||||
if (previewUrl) {
|
||||
task.previewOutput = {
|
||||
isImage,
|
||||
url: previewUrl
|
||||
}
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
const runBatch = async (options: {
|
||||
start: number
|
||||
finish: number
|
||||
tasks: MockTask[]
|
||||
}) => {
|
||||
const { start, finish, tasks } = options
|
||||
|
||||
vi.setSystemTime(start)
|
||||
executionStore().isIdle = false
|
||||
await nextTick()
|
||||
|
||||
vi.setSystemTime(finish)
|
||||
queueStore().historyTasks = tasks
|
||||
executionStore().isIdle = true
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
resetState()
|
||||
})
|
||||
|
||||
it('summarizes the most recent batch and auto clears after the dismiss delay', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 1_000
|
||||
const finish = 2_000
|
||||
|
||||
const tasks = [
|
||||
createTask({ ts: start - 100, previewUrl: 'ignored-old' }),
|
||||
createTask({ ts: start + 10, previewUrl: 'img-1' }),
|
||||
createTask({ ts: start + 20, previewUrl: 'img-2' }),
|
||||
createTask({ ts: start + 30, previewUrl: 'img-3' }),
|
||||
createTask({ ts: start + 40, previewUrl: 'img-4' }),
|
||||
createTask({ state: 'Failed', ts: start + 50 })
|
||||
]
|
||||
|
||||
await runBatch({ start, finish, tasks })
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'mixed',
|
||||
completedCount: 4,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: ['img-1', 'img-2', 'img-3']
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(6000)
|
||||
await nextTick()
|
||||
expect(summary.value).toBeNull()
|
||||
})
|
||||
|
||||
it('reports allFailed when every task in the batch failed', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 10_000
|
||||
const finish = 10_200
|
||||
|
||||
await runBatch({
|
||||
start,
|
||||
finish,
|
||||
tasks: [
|
||||
createTask({ state: 'Failed', ts: start + 25 }),
|
||||
createTask({ state: 'Failed', ts: start + 50 })
|
||||
]
|
||||
})
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'allFailed',
|
||||
completedCount: 0,
|
||||
failedCount: 2,
|
||||
thumbnailUrls: []
|
||||
})
|
||||
})
|
||||
|
||||
it('treats cancelled tasks as failures and skips non-image previews', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 15_000
|
||||
const finish = 15_200
|
||||
|
||||
await runBatch({
|
||||
start,
|
||||
finish,
|
||||
tasks: [
|
||||
createTask({ ts: start + 25, previewUrl: 'img-1' }),
|
||||
createTask({
|
||||
state: 'Cancelled',
|
||||
ts: start + 50,
|
||||
previewUrl: 'thumb-ignore',
|
||||
isImage: false
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'mixed',
|
||||
completedCount: 1,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: ['img-1']
|
||||
})
|
||||
})
|
||||
|
||||
it('clearSummary dismisses the banner immediately and still tracks future batches', async () => {
|
||||
const { summary, clearSummary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
await runBatch({
|
||||
start: 5_000,
|
||||
finish: 5_100,
|
||||
tasks: [createTask({ ts: 5_050, previewUrl: 'img-1' })]
|
||||
})
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'allSuccess',
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: ['img-1']
|
||||
})
|
||||
|
||||
clearSummary()
|
||||
expect(summary.value).toBeNull()
|
||||
|
||||
await runBatch({
|
||||
start: 6_000,
|
||||
finish: 6_150,
|
||||
tasks: [createTask({ ts: 6_075, previewUrl: 'img-2' })]
|
||||
})
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'allSuccess',
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: ['img-2']
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores batches that have no finished tasks after the active period started', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 20_000
|
||||
const finish = 20_500
|
||||
|
||||
await runBatch({
|
||||
start,
|
||||
finish,
|
||||
tasks: [createTask({ ts: start - 1, previewUrl: 'too-early' })]
|
||||
})
|
||||
|
||||
expect(summary.value).toBeNull()
|
||||
})
|
||||
|
||||
it('derives the active period from running tasks when execution is already idle', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 25_000
|
||||
vi.setSystemTime(start)
|
||||
queueStore().runningTasks = [
|
||||
createTask({ state: 'Running', ts: start + 1 })
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
const finish = start + 150
|
||||
vi.setSystemTime(finish)
|
||||
queueStore().historyTasks = [
|
||||
createTask({ ts: finish - 10, previewUrl: 'img-running-trigger' })
|
||||
]
|
||||
queueStore().runningTasks = []
|
||||
await nextTick()
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'allSuccess',
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: ['img-running-trigger']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not emit a summary when every finished task is still running or pending', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 30_000
|
||||
const finish = 30_300
|
||||
|
||||
await runBatch({
|
||||
start,
|
||||
finish,
|
||||
tasks: [
|
||||
createTask({ state: 'Running', ts: start + 20 }),
|
||||
createTask({ state: 'Pending', ts: start + 40 })
|
||||
]
|
||||
})
|
||||
|
||||
expect(summary.value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
|
||||
type CompletionSummaryMode = 'allSuccess' | 'mixed' | 'allFailed'
|
||||
|
||||
type CompletionSummary = {
|
||||
mode: CompletionSummaryMode
|
||||
completedCount: number
|
||||
failedCount: number
|
||||
thumbnailUrls: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks queue activity transitions and exposes a short-lived summary of the
|
||||
* most recent generation batch.
|
||||
*/
|
||||
export const useCompletionSummary = () => {
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const isActive = computed(
|
||||
() => queueStore.runningTasks.length > 0 || !executionStore.isIdle
|
||||
)
|
||||
|
||||
const lastActiveStartTs = ref<number | null>(null)
|
||||
const _summary = ref<CompletionSummary | null>(null)
|
||||
const dismissTimer = ref<number | null>(null)
|
||||
|
||||
const clearDismissTimer = () => {
|
||||
if (dismissTimer.value !== null) {
|
||||
clearTimeout(dismissTimer.value)
|
||||
dismissTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const startDismissTimer = () => {
|
||||
clearDismissTimer()
|
||||
dismissTimer.value = window.setTimeout(() => {
|
||||
_summary.value = null
|
||||
dismissTimer.value = null
|
||||
}, 6000)
|
||||
}
|
||||
|
||||
const clearSummary = () => {
|
||||
_summary.value = null
|
||||
clearDismissTimer()
|
||||
}
|
||||
|
||||
watch(
|
||||
isActive,
|
||||
(active, prev) => {
|
||||
if (!prev && active) {
|
||||
lastActiveStartTs.value = Date.now()
|
||||
}
|
||||
if (prev && !active) {
|
||||
const start = lastActiveStartTs.value ?? 0
|
||||
const finished = queueStore.historyTasks.filter((t) => {
|
||||
const ts = t.executionEndTimestamp
|
||||
return typeof ts === 'number' && ts >= start
|
||||
})
|
||||
|
||||
if (!finished.length) {
|
||||
_summary.value = null
|
||||
clearDismissTimer()
|
||||
return
|
||||
}
|
||||
|
||||
let completedCount = 0
|
||||
let failedCount = 0
|
||||
const imagePreviews: string[] = []
|
||||
|
||||
for (const task of finished) {
|
||||
const state = jobStateFromTask(task, false)
|
||||
if (state === 'completed') {
|
||||
completedCount++
|
||||
const preview = task.previewOutput
|
||||
if (preview?.isImage) {
|
||||
imagePreviews.push(preview.url)
|
||||
}
|
||||
} else if (state === 'failed') {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (completedCount === 0 && failedCount === 0) {
|
||||
_summary.value = null
|
||||
clearDismissTimer()
|
||||
return
|
||||
}
|
||||
|
||||
let mode: CompletionSummaryMode = 'mixed'
|
||||
if (failedCount === 0) mode = 'allSuccess'
|
||||
else if (completedCount === 0) mode = 'allFailed'
|
||||
|
||||
_summary.value = {
|
||||
mode,
|
||||
completedCount,
|
||||
failedCount,
|
||||
thumbnailUrls: imagePreviews.slice(0, 3)
|
||||
}
|
||||
startDismissTimer()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const summary = computed(() => _summary.value)
|
||||
|
||||
return {
|
||||
summary,
|
||||
clearSummary
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ export const useQueueNotificationBanners = () => {
|
||||
completedCount++
|
||||
const preview = task.previewOutput
|
||||
if (preview?.isImage) {
|
||||
imagePreviews.push(preview.url)
|
||||
imagePreviews.push(preview.urlWithTimestamp)
|
||||
}
|
||||
} else if (state === 'failed') {
|
||||
failedCount++
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
type ImageCompareOutput = NodeOutputWith<{
|
||||
@@ -10,7 +12,7 @@ type ImageCompareOutput = NodeOutputWith<{
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.ImageCompare',
|
||||
|
||||
async nodeCreated(node) {
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'ImageCompare') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
@@ -22,23 +24,22 @@ useExtensionService().registerExtension({
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const { a_images: aImages, b_images: bImages } = output
|
||||
const rand = app.getRandParam()
|
||||
|
||||
const beforeUrl =
|
||||
aImages && aImages.length > 0
|
||||
? api.apiURL(`/view?${new URLSearchParams(aImages[0])}`)
|
||||
: ''
|
||||
const afterUrl =
|
||||
bImages && bImages.length > 0
|
||||
? api.apiURL(`/view?${new URLSearchParams(bImages[0])}`)
|
||||
: ''
|
||||
const toUrl = (record: Record<string, string>) => {
|
||||
const params = new URLSearchParams(record)
|
||||
return api.apiURL(`/view?${params}${rand}`)
|
||||
}
|
||||
|
||||
const beforeImages =
|
||||
aImages && aImages.length > 0 ? aImages.map(toUrl) : []
|
||||
const afterImages =
|
||||
bImages && bImages.length > 0 ? bImages.map(toUrl) : []
|
||||
|
||||
const widget = node.widgets?.find((w) => w.type === 'imagecompare')
|
||||
|
||||
if (widget) {
|
||||
widget.value = {
|
||||
before: beforeUrl,
|
||||
after: afterUrl
|
||||
}
|
||||
widget.value = { beforeImages, afterImages }
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
class Load3dUtils {
|
||||
static async generateThumbnailIfNeeded(
|
||||
@@ -132,7 +133,8 @@ class Load3dUtils {
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
'type=' + type,
|
||||
'subfolder=' + subfolder
|
||||
'subfolder=' + subfolder,
|
||||
app.getRandParam().substring(1)
|
||||
].join('&')
|
||||
|
||||
return `/view?${params}`
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
/**
|
||||
* Format time in MM:SS format
|
||||
@@ -19,7 +20,8 @@ export function getResourceURL(
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
'type=' + type,
|
||||
'subfolder=' + subfolder
|
||||
'subfolder=' + subfolder,
|
||||
app.getRandParam().substring(1)
|
||||
].join('&')
|
||||
|
||||
return `/view?${params}`
|
||||
|
||||
@@ -382,6 +382,11 @@ export class ComfyApp {
|
||||
else return ''
|
||||
}
|
||||
|
||||
getRandParam() {
|
||||
if (isCloud) return ''
|
||||
return '&rand=' + Math.random()
|
||||
}
|
||||
|
||||
static onClipspaceEditorSave() {
|
||||
if (ComfyApp.clipspace_return_node) {
|
||||
ComfyApp.pasteFromClipspace(ComfyApp.clipspace_return_node)
|
||||
|
||||
@@ -117,11 +117,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
const outputs = getNodeOutputs(node)
|
||||
if (!outputs?.images?.length) return
|
||||
|
||||
const rand = app.getRandParam()
|
||||
const previewParam = getPreviewParam(node, outputs)
|
||||
|
||||
return outputs.images.map((image) => {
|
||||
const params = new URLSearchParams(image)
|
||||
return api.apiURL(`/view?${params}${previewParam}`)
|
||||
return api.apiURL(`/view?${params}${previewParam}${rand}`)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,10 @@ export class ResultItemImpl {
|
||||
return api.apiURL('/view?' + params)
|
||||
}
|
||||
|
||||
get urlWithTimestamp(): string {
|
||||
return `${this.url}&t=${+new Date()}`
|
||||
}
|
||||
|
||||
get isVhsFormat(): boolean {
|
||||
return !!this.format && !!this.frame_rate
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user