Compare commits

..

1 Commits

Author SHA1 Message Date
bymyself
e04225dd49 feat: remove client-side cache-busting query parameters
Remove getRandParam() and urlWithTimestamp that added cache-busting
query params (&rand=... and &t=...) to output URLs.

This is now handled by the backend generating unique filenames
with timestamps (ComfyUI PR pending).

Updated files:
- app.ts: removed getRandParam() method
- queueStore.ts: removed urlWithTimestamp getter
- imagePreviewStore.ts: removed unused imports
- useCompletionSummary.ts: use .url instead of .urlWithTimestamp
- imageCompare.ts, useMaskEditorLoader.ts, audioUtils.ts,
  Load3dUtils.ts: removed getRandParam() usage

Amp-Thread-ID: https://ampcode.com/threads/T-019c17e5-1c0a-736f-970d-e411aae222fc
2026-03-12 13:11:47 -07:00
245 changed files with 1814 additions and 12853 deletions

View File

@@ -1,9 +0,0 @@
{
"permissions": {
"allow": [
"Bash(pnpx vitest run --testPathPattern=\"draftCacheV2.property\")",
"Bash(pnpx vitest run \"draftCacheV2.property\")",
"Bash(node -e \"const fc = require\\(''fast-check''\\); console.log\\(Object.keys\\(fc\\).filter\\(k => k.includes\\(''string''\\)\\).join\\('', ''\\)\\)\")"
]
}
}

View File

@@ -1,179 +0,0 @@
---
name: perf-fix-with-proof
description: 'Ships performance fixes with CI-proven improvement using stacked PRs. PR1 adds a @perf test (establishes baseline on main), PR2 adds the fix (CI shows delta). Use when implementing a perf optimization and wanting to prove it in CI.'
---
# Performance Fix with Proof
Ships perf fixes as two stacked PRs so CI automatically proves the improvement.
## Why Two PRs
The `ci-perf-report.yaml` workflow compares PR metrics against the **base branch baseline**. If you add a new `@perf` test in the same PR as the fix, that test doesn't exist on main yet — no baseline, no delta, no proof. Stacking solves this:
1. **PR1 (test-only)** — adds the `@perf` test that exercises the bottleneck. Merges to main. CI runs it on main → baseline established.
2. **PR2 (fix)** — adds the optimization. CI runs the same test → compares against PR1's baseline → delta shows improvement.
## Workflow
### Step 1: Create the test branch
```bash
git worktree add <worktree-path> -b perf/test-<name> origin/main
```
### Step 2: Write the `@perf` test
Add a test to `browser_tests/tests/performance.spec.ts` (or a new file with `@perf` tag). The test should stress the specific bottleneck.
**Test structure:**
```typescript
test('<descriptive name>', async ({ comfyPage }) => {
// 1. Load a workflow that exercises the bottleneck
await comfyPage.workflow.loadWorkflow('<workflow>')
// 2. Start measuring
await comfyPage.perf.startMeasuring()
// 3. Perform the action that triggers the bottleneck (at scale)
for (let i = 0; i < N; i++) {
// ... stress the hot path ...
await comfyPage.nextFrame()
}
// 4. Stop measuring and record
const m = await comfyPage.perf.stopMeasuring('<metric-name>')
recordMeasurement(m)
console.log(`<name>: ${m.styleRecalcs} recalcs, ${m.layouts} layouts`)
})
```
**Available metrics** (from `PerformanceHelper`):
- `m.styleRecalcs` / `m.styleRecalcDurationMs` — style recalculation count and time
- `m.layouts` / `m.layoutDurationMs` — forced layout count and time
- `m.taskDurationMs` — total main-thread JS execution time
- `m.heapDeltaBytes` — memory pressure delta
**Key helpers** (from `ComfyPage`):
- `comfyPage.perf.startMeasuring()` / `.stopMeasuring(name)` — CDP metrics capture
- `comfyPage.nextFrame()` — wait one animation frame
- `comfyPage.workflow.loadWorkflow(name)` — load a test workflow from `browser_tests/assets/`
- `comfyPage.canvas` — the canvas locator
- `comfyPage.page.mouse.move(x, y)` — mouse interaction
### Step 3: Add test workflow asset (if needed)
If the bottleneck needs a specific workflow (e.g., 50+ nodes, many DOM widgets), add it to `browser_tests/assets/`. Keep it minimal — only the structure needed to trigger the bottleneck.
### Step 4: Verify locally
```bash
pnpm exec playwright test --project=performance --grep "<test name>"
```
Confirm the test runs and produces reasonable metric values.
### Step 5: Create PR1 (test-only)
```bash
pnpm typecheck:browser
pnpm lint
git add browser_tests/
git commit -m "test: add perf test for <bottleneck description>"
git push -u origin perf/test-<name>
gh pr create --title "test: add perf test for <bottleneck>" \
--body "Adds a @perf test to establish a baseline for <bottleneck>.
This is PR 1 of 2. The fix will follow in a separate PR once this baseline is established on main.
## What
Adds \`<test-name>\` to the performance test suite measuring <metric> during <action>.
## Why
Needed to prove the improvement from the upcoming fix for backlog item #<N>." \
--base main
```
### Step 6: Get PR1 merged
Once PR1 merges, CI runs the test on main → baseline artifact saved.
### Step 7: Create PR2 (fix) on top of main
```bash
git worktree add <worktree-path> -b perf/fix-<name> origin/main
```
Implement the fix. The `@perf` test from PR1 is now on main and will run automatically. CI will:
1. Run the test on the PR branch
2. Download the baseline from main (which includes PR1's test results)
3. Post a PR comment showing the delta
### Step 8: Verify the improvement shows in CI
The `ci-perf-report.yaml` posts a comment like:
```markdown
## ⚡ Performance Report
| Metric | Baseline | PR (n=3) | Δ | Sig |
| --------------------- | -------- | -------- | ---- | --- |
| <name>: style recalcs | 450 | 12 | -97% | 🟢 |
```
If Δ is negative for the target metric, the fix is proven.
## Test Design Guidelines
1. **Stress the specific bottleneck** — don't measure everything, isolate the hot path
2. **Use enough iterations** — the test should run long enough that the metric difference is clear (100+ frames for idle tests, 50+ interactions for event tests)
3. **Keep it deterministic** — avoid timing-dependent assertions; measure counts not durations when possible
4. **Match the backlog entry** — reference the backlog item number in the test name or PR description
## Examples
**Testing DOM widget reactive mutations (backlog #8):**
```typescript
test('DOM widget positioning recalculations', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Idle for 120 frames — DOM widgets update position every frame
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('dom-widget-idle')
recordMeasurement(m)
})
```
**Testing measureText caching (backlog #4):**
```typescript
test('canvas text rendering with many nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('large-workflow-50-nodes')
await comfyPage.perf.startMeasuring()
for (let i = 0; i < 60; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('text-rendering-50-nodes')
recordMeasurement(m)
})
```
## Reference
| Resource | Path |
| ----------------- | ----------------------------------------------------- |
| Perf test file | `browser_tests/tests/performance.spec.ts` |
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
| Report generator | `scripts/perf-report.ts` |
| Stats utilities | `scripts/perf-stats.ts` |
| Backlog | `docs/perf/BACKLOG.md` (local only, not committed) |
| Playbook | `docs/perf/PLAYBOOK.md` (local only, not committed) |

View File

@@ -1,361 +0,0 @@
---
name: ticket-intake
description: 'Parse ticket URL (Notion or GitHub), extract all data, initialize pipeline run. Use when starting work on a new ticket or when asked to pick up a ticket.'
---
# Ticket Intake
Parses a ticket URL from supported sources (Notion or GitHub), extracts all relevant information, and creates a ticket in the pipeline API.
> **🚨 CRITICAL REQUIREMENT**: This skill MUST register the ticket in the Pipeline API and update the source (Notion/GitHub). If these steps are skipped, the entire pipeline breaks. See [Mandatory API Calls](#mandatory-api-calls-execute-all-three) below.
## Supported Sources
| Source | URL Pattern | Provider File |
| ------ | --------------------------------------------------- | --------------------- |
| Notion | `https://notion.so/...` `https://www.notion.so/...` | `providers/notion.md` |
| GitHub | `https://github.com/{owner}/{repo}/issues/{n}` | `providers/github.md` |
## Quick Start
When given a ticket URL:
1. **Detect source type** from URL pattern
2. **Load provider-specific logic** from `providers/` directory
3. Fetch ticket content via appropriate API
4. Extract and normalize properties to common schema
5. **Register ticket in pipeline API** ← MANDATORY
6. **Update source** (Notion status / GitHub comment) ← MANDATORY
7. **Run verification script** to confirm API registration
8. Output summary and handoff to `research-orchestrator`
## Configuration
Uses the **production API** by default. No configuration needed for read operations.
**Defaults (no setup required):**
- API URL: `https://api-gateway-856475788601.us-central1.run.app`
- Read-only endpoints at `/public/*` require no authentication
**For write operations** (transitions, creating tickets), set:
```bash
export PIPELINE_API_KEY="..." # Get from GCP Secret Manager or ask admin
```
**Optional (for local working artifacts):**
```bash
PIPELINE_DIR="${PIPELINE_DIR:-$HOME/repos/ticket-to-pr-pipeline}"
```
## Mandatory API Calls (Execute ALL Three)
**⚠️ These three API calls are the ENTIRE POINT of this skill. Without them, the ticket is invisible to the pipeline, downstream skills will fail, and Notion status won't update.**
**You MUST make these HTTP requests.** Use `curl` from bash — do not just read this as documentation.
### Call 1: Create Ticket
```bash
API_URL="${PIPELINE_API_URL:-https://api-gateway-856475788601.us-central1.run.app}"
API_KEY="${PIPELINE_API_KEY}"
curl -s -X POST "${API_URL}/v1/tickets" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"notion_page_id": "NOTION_PAGE_UUID_HERE",
"title": "TICKET_TITLE_HERE",
"source": "notion",
"metadata": {
"description": "DESCRIPTION_HERE",
"priority": "High",
"labels": [],
"acceptanceCriteria": []
}
}'
```
Save the returned `id` — you need it for the next two calls.
### Call 2: Transition to RESEARCH
```bash
TICKET_ID="id-from-step-1"
curl -s -X POST "${API_URL}/v1/tickets/${TICKET_ID}/transition" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"to_state": "RESEARCH",
"reason": "Intake complete, starting research"
}'
```
### Call 3: Queue Source Update
```bash
curl -s -X POST "${API_URL}/v1/sync/queue" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"ticket_id": "TICKET_ID_HERE",
"action": "update_status",
"payload": { "status": "In Progress" },
"priority": "normal"
}'
```
> **Note:** The action MUST be `"update_status"` (not `"UPDATE_NOTION_STATUS"`). Valid actions: `update_status`, `update_pr_url`, `mark_done`.
### TypeScript Equivalent (if using pipeline client)
```typescript
import { PipelineClient } from '@pipeline/client'
const client = new PipelineClient({
apiUrl:
process.env.PIPELINE_API_URL ||
'https://api-gateway-856475788601.us-central1.run.app',
agentId: process.env.AGENT_ID!
})
const ticket = await client.createTicket({
notion_page_id: pageId,
title: ticketTitle,
source: 'notion',
metadata: { description, priority, labels, acceptanceCriteria }
})
await client.transitionState(
ticket.id,
'RESEARCH',
'Intake complete, starting research'
)
await client.queueSync(ticket.id, 'update_status', { status: 'In Progress' })
```
## Workflow
### Step 1: Detect Source Type
Parse the URL to determine source:
```javascript
if (url.includes('notion.so')) {
source = 'notion'
// Load providers/notion.md
} else if (url.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/)) {
source = 'github'
// Load providers/github.md
} else {
// Error: Unsupported URL format
}
```
### Step 2: Load Provider and Fetch Data
Read the appropriate provider file for source-specific instructions:
- **Notion**: `providers/notion.md` - Uses Notion MCP, handles Slack links
- **GitHub**: `providers/github.md` - Uses `gh` CLI, handles Dosu comments
Follow the provider's instructions for:
- Fetching content
- Extracting properties
- **Updating the source** (Notion status → "In Progress", Assignee → pipeline owner)
### Step 3: Normalize to Common Schema
All providers must extract normalized ticket data following `schema.md`:
```json
{
"id": "abc12345",
"url": "https://...",
"source": "notion | github",
"title": "Ticket title",
"description": "Full description",
"status": "Not Started",
"assignee": "username",
"priority": "High",
"area": "UI",
"labels": ["bug", "frontend"],
"acceptanceCriteria": ["Criterion 1", "Criterion 2"],
"fetchedAt": "2024-01-15T10:30:00Z"
}
```
### Step 4: Register Ticket in Pipeline API (MANDATORY — DO NOT SKIP)
**Execute all three API calls from [Mandatory API Calls](#mandatory-api-calls-execute-all-three) above.**
This is not optional. This is not documentation. You MUST make these HTTP requests right now.
1. `createTicket()` → save the returned ticket ID
2. `transitionState(id, 'RESEARCH')` → confirm state changed
3. `queueSync(id, 'update_status', { status: 'In Progress' })` → confirm queued
**If any call fails**, retry once. If it still fails, report the error prominently — do NOT silently continue.
### Step 5: Run Verification Script
After making the API calls, run the verification script to confirm everything worked:
```bash
bash scripts/verify-intake.sh TICKET_ID_OR_NOTION_PAGE_ID
```
**If the script is not available locally**, verify manually via the public API:
```bash
curl -s "${API_URL}/public/tickets/${TICKET_ID}" | jq '{id, state, title, notion_page_id}'
```
Expected output:
```json
{
"id": "...",
"state": "RESEARCH",
"title": "...",
"notion_page_id": "..."
}
```
**If `state` is not `RESEARCH`, go back to Step 4 and complete the missing calls.**
### Step 6: Output Summary and Handoff
Print a clear summary:
```markdown
## Ticket Intake Complete
**Source:** Notion | GitHub
**Title:** [Ticket title]
**ID:** abc12345
**Status:** In Progress (queued)
**Priority:** High
**Area:** UI
### Description
[Brief description or first 200 chars]
### Acceptance Criteria
- [ ] Criterion 1
- [ ] Criterion 2
### Links
- **Ticket:** [Original URL]
- **Slack:** [Slack thread content fetched via slackdump] (Notion only)
### Pipeline
- **API Ticket ID:** abc12345
- **State:** RESEARCH
- **Verified:** ✅ (via verify-intake.sh or public API)
```
**After printing the summary, immediately handoff** to continue the pipeline. Use the `handoff` tool with all necessary context (ticket ID, source, title, description, slack context if any):
> **Handoff goal:** "Continue pipeline for ticket {ID} ({title}). Ticket is in RESEARCH state. Load skill: `research-orchestrator` to begin research phase. Ticket data: source={source}, notion_page_id={pageId}, priority={priority}. {slack context summary if available}"
**Do NOT wait for human approval to proceed.** The intake phase is complete — handoff immediately.
## Error Handling
### Unsupported URL
```
❌ Unsupported ticket URL format.
Supported formats:
- Notion: https://notion.so/... or https://www.notion.so/...
- GitHub: https://github.com/{owner}/{repo}/issues/{number}
Received: [provided URL]
```
### Provider-Specific Errors
See individual provider files for source-specific error handling:
- `providers/notion.md` - Authentication, page not found
- `providers/github.md` - Auth, rate limits, issue not found
### Missing Properties
Continue with available data and note what's missing:
```
⚠️ Some properties unavailable:
- Priority: not found (using default: Medium)
- Area: not found
Proceeding with available data...
```
### API Call Failures
```
❌ Pipeline API call failed: {method} {endpoint}
Status: {status}
Error: {message}
Retrying once...
❌ Retry also failed. INTAKE IS INCOMPLETE.
The ticket was NOT registered in the pipeline.
Downstream skills will not work until this is fixed.
```
## Notes
- This skill focuses ONLY on intake — it does not do research
- Slack thread content is fetched automatically via the `slackdump` skill — no manual copy-paste needed
- ALL API calls (createTicket, transitionState, queueSync) are MANDATORY — never skip them
- The `queueSync` action must be `"update_status"`, NOT `"UPDATE_NOTION_STATUS"`
- Pipeline state is tracked via the API, not local files
- Working artifacts (research-report.md, plan.md) can be saved locally to `$PIPELINE_DIR/runs/{ticket-id}/`
- The `source` field in the ticket determines which research strategies to use
## API Client Reference
### Available Methods
| Method | Description |
| ----------------------------------------------------------- | ------------------------------------------------------------------- |
| `createTicket({ notion_page_id, title, source, metadata })` | Create a new ticket in the API |
| `getTicket(id)` | Retrieve a ticket by ID |
| `findByNotionId(notionPageId)` | Look up a ticket by its Notion page ID |
| `listTickets({ state, agent_id, limit, offset })` | List tickets with optional filters |
| `transitionState(id, state, reason)` | Move ticket to a new state (e.g., `'RESEARCH'`) |
| `setPRCreated(id, prUrl)` | Mark ticket as having a PR created |
| `queueSync(id, action, payload)` | Queue a sync action (`update_status`, `update_pr_url`, `mark_done`) |
| `registerBranch(id, branch, repo)` | Register working branch for automatic PR detection |
### Error Handling
```typescript
import { PipelineClient, PipelineAPIError } from '@pipeline/client';
try {
await client.createTicket({ ... });
} catch (error) {
if (error instanceof PipelineAPIError) {
console.error(`API Error ${error.status}: ${error.message}`);
}
throw error;
}
```

View File

@@ -1,194 +0,0 @@
# GitHub Provider - Ticket Intake
Provider-specific logic for ingesting tickets from GitHub Issues.
## URL Pattern
```
https://github.com/{owner}/{repo}/issues/{number}
https://www.github.com/{owner}/{repo}/issues/{number}
```
Extract: `owner`, `repo`, `issue_number` from URL.
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
- Access to the repository
## Fetch Issue Content
Use `gh` CLI to fetch issue details:
```bash
# Get issue details in JSON
gh issue view {number} --repo {owner}/{repo} --json title,body,state,labels,assignees,milestone,author,createdAt,comments,linkedPRs
# Get comments separately if needed
gh issue view {number} --repo {owner}/{repo} --comments
```
## Extract Ticket Data
Map GitHub issue fields to normalized ticket data (stored via API):
| GitHub Field | ticket.json Field | Notes |
| ------------ | ----------------- | -------------------------- |
| title | title | Direct mapping |
| body | description | Issue body/description |
| state | status | Map: open → "Not Started" |
| labels | labels | Array of label names |
| assignees | assignee | First assignee login |
| author | author | Issue author login |
| milestone | milestone | Milestone title if present |
| comments | comments | Array of comment objects |
| linkedPRs | linkedPRs | PRs linked to this issue |
### Priority Mapping
Infer priority from labels:
- `priority:critical`, `P0` → "Critical"
- `priority:high`, `P1` → "High"
- `priority:medium`, `P2` → "Medium"
- `priority:low`, `P3` → "Low"
- No priority label → "Medium" (default)
### Area Mapping
Infer area from labels:
- `area:ui`, `frontend`, `component:*` → "UI"
- `area:api`, `backend` → "API"
- `area:docs`, `documentation` → "Docs"
- `bug`, `fix` → "Bug"
- `enhancement`, `feature` → "Feature"
## Update Source
**For GitHub issues, update is optional but recommended.**
Add a comment to indicate work has started:
```bash
gh issue comment {number} --repo {owner}/{repo} --body "🤖 Pipeline started processing this issue."
```
Optionally assign to self:
```bash
gh issue edit {number} --repo {owner}/{repo} --add-assignee @me
```
Log any updates via the Pipeline API:
```typescript
await client.updateTicket(ticketId, {
metadata: {
...ticket.metadata,
githubWrites: [
...(ticket.metadata?.githubWrites || []),
{
action: 'comment',
issueNumber: 123,
at: new Date().toISOString(),
skill: 'ticket-intake',
success: true
}
]
}
})
```
## GitHub-Specific Ticket Fields
Store via API using `client.createTicket()`:
```json
{
"source": "github",
"githubOwner": "Comfy-Org",
"githubRepo": "ComfyUI_frontend",
"githubIssueNumber": 123,
"githubIssueUrl": "https://github.com/Comfy-Org/ComfyUI_frontend/issues/123",
"labels": ["bug", "area:ui", "priority:high"],
"linkedPRs": [456, 789],
"dosuComment": "..." // Extracted Dosu bot analysis if present
}
```
## Dosu Bot Detection
Many repositories use Dosu bot for automated issue analysis. Check comments for Dosu:
```bash
gh issue view {number} --repo {owner}/{repo} --comments | grep -A 100 "dosu"
```
Look for comments from:
- `dosu[bot]`
- `dosu-bot`
Extract Dosu analysis which typically includes:
- Root cause analysis
- Suggested files to modify
- Related issues/PRs
- Potential solutions
Store in ticket data via API:
```json
{
"dosuComment": {
"found": true,
"analysis": "...",
"suggestedFiles": ["src/file1.ts", "src/file2.ts"],
"relatedIssues": [100, 101]
}
}
```
## Extract Linked Issues/PRs
Parse issue body and comments for references:
- `#123` → Issue or PR reference
- `fixes #123`, `closes #123` → Linked issue
- `https://github.com/.../issues/123` → Full URL reference
Store in ticket data via API for research phase:
```json
{
"referencedIssues": [100, 101, 102],
"referencedPRs": [200, 201]
}
```
## Error Handling
### Authentication Error
```
⚠️ GitHub CLI not authenticated.
Run: gh auth login
```
### Issue Not Found
```
❌ GitHub issue not found or inaccessible.
- Check the URL is correct
- Ensure you have access to this repository
- Run: gh auth status
```
### Rate Limiting
```
⚠️ GitHub API rate limited.
Wait a few minutes and try again.
Check status: gh api rate_limit
```

View File

@@ -1,202 +0,0 @@
# Notion Provider - Ticket Intake
Provider-specific logic for ingesting tickets from Notion.
## URL Pattern
```
https://www.notion.so/workspace/Page-Title-abc123def456...
https://notion.so/Page-Title-abc123def456...
https://www.notion.so/abc123def456...
```
Page ID is the 32-character hex string (with or without hyphens).
## Prerequisites
- Notion MCP connected and authenticated
- If not setup: `claude mcp add --transport http notion https://mcp.notion.com/mcp`
- Authenticate via `/mcp` command if prompted
## Fetch Ticket Content
Use `Notion:notion-fetch` with the page URL or ID:
```
Fetch the full page content including all properties
```
## Extract Ticket Data
Extract these properties (names may vary):
| Property | Expected Name | Type |
| ------------- | ------------------------- | ------------ |
| Title | Name / Title | Title |
| Status | Status | Select |
| Assignee | Assignee / Assigned To | Person |
| Description | - | Page content |
| Slack Link | Slack Link / Slack Thread | URL |
| GitHub PR | GitHub PR / PR Link | URL |
| Priority | Priority | Select |
| Area | Area / Category | Select |
| Related Tasks | Related Tasks | Relation |
**If properties are missing**: Note what's unavailable and continue with available data.
## Update Source (REQUIRED)
**⚠️ DO NOT SKIP THIS STEP. This is a required action, not optional.**
**⚠️ Notion Write Safety rules apply (see `$PIPELINE_DIR/docs/notion-write-safety.md` for full reference):**
- **Whitelist**: Only `Status`, `GitHub PR`, and `Assignee` fields may be written
- **Valid transitions**: Not Started → In Progress, In Progress → In Review, In Review → Done
- **Logging**: Every write attempt MUST be logged with timestamp, field, value, previous value, skill name, and success status
Use `Notion:notion-update-page` to update the ticket:
1. **Status**: Set to "In Progress" (only valid from "Not Started")
2. **Assignee**: Assign to pipeline owner (Notion ID: `175d872b-594c-81d4-ba5a-0002911c5966`)
```json
{
"page_id": "{page_id_from_ticket}",
"command": "update_properties",
"properties": {
"Status": "In Progress",
"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"
}
}
```
**After the update succeeds**, log the write via the Pipeline API:
```typescript
await client.updateTicket(ticketId, {
metadata: {
...ticket.metadata,
notionWrites: [
...(ticket.metadata?.notionWrites || []),
{
field: 'Status',
value: 'In Progress',
previousValue: 'Not Started',
at: new Date().toISOString(),
skill: 'ticket-intake',
success: true
}
]
}
})
```
If update fails, log with `success: false` and continue.
## Notion-Specific Ticket Fields
Store via API using `client.createTicket()`:
```json
{
"source": "notion",
"notionPageId": "abc123def456...",
"slackLink": "https://slack.com/...",
"relatedTasks": ["page-id-1", "page-id-2"]
}
```
## Slack Thread Handling
If a Slack link exists, use the `slackdump` skill to fetch the thread content programmatically.
### Slack URL Conversion
Notion stores Slack links in `slackMessage://` format:
```
slackMessage://comfy-organization.slack.com/CHANNEL_ID/THREAD_TS/MESSAGE_TS
```
Convert to browser-clickable format:
```
https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS_NO_DOT
```
**Example:**
- Input: `slackMessage://comfy-organization.slack.com/C075ANWQ8KS/1766022478.450909/1764772881.854829`
- Output: `https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1764772881854829`
(Remove the dot from the last timestamp and prefix with `p`)
### Fetching Thread Content
Load the `slackdump` skill and use the **export-thread** workflow:
```bash
# Export thread by URL
slackdump dump "https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS"
# Or by colon notation (channel_id:thread_ts)
slackdump dump CHANNEL_ID:THREAD_TS
```
Save the thread content to `$RUN_DIR/slack-context.md` and include it in the ticket metadata.
> **No manual action required.** The slackdump CLI handles authentication via stored credentials at `~/.cache/slackdump/comfy-organization.bin`.
## Database Reference: Comfy Tasks
The "Comfy Tasks" database has these properties (verify via `notion-search`):
- **Status values**: Not Started, In Progress, In Review, Done
- **Team assignment**: "Frontend Team" for unassigned tickets
- **Filtering note**: Team filtering in Notion may have quirks - handle gracefully
### Pipeline Owner Details
When assigning tickets, use these identifiers:
| Platform | Identifier |
| --------------- | -------------------------------------- |
| Notion User ID | `175d872b-594c-81d4-ba5a-0002911c5966` |
| Notion Name | Christian Byrne |
| Notion Email | cbyrne@comfy.org |
| Slack User ID | U087MJCDHHC |
| GitHub Username | christian-byrne |
**To update Assignee**, use the Notion User ID (not name):
```
properties: {"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"}
```
### Finding Active Tickets
To list your active tickets:
```
Use Notion:notion-search for "Comfy Tasks"
Filter by Assignee = current user OR Team = "Frontend Team"
```
## Error Handling
### Authentication Error
```
⚠️ Notion authentication required.
Run: claude mcp add --transport http notion https://mcp.notion.com/mcp
Then authenticate via /mcp command.
```
### Page Not Found
```
❌ Notion page not found or inaccessible.
- Check the URL is correct
- Ensure you have access to this page
- Try re-authenticating via /mcp
```

View File

@@ -1,81 +0,0 @@
# Ticket Schema
Common schema for normalized ticket data across all sources. This data is stored and retrieved via the Pipeline API, not local files.
## Ticket Data Schema
```json
{
// Required fields (all sources)
"id": "string", // Unique identifier (short form)
"url": "string", // Original URL
"source": "notion | github", // Source type
"title": "string", // Ticket title
"description": "string", // Full description/body
"fetchedAt": "ISO8601", // When ticket was fetched
// Common optional fields
"status": "string", // Current status
"assignee": "string", // Assigned user
"priority": "string", // Priority level
"area": "string", // Category/area
"labels": ["string"], // Tags/labels
"acceptanceCriteria": ["string"] // List of AC items
// Source-specific fields (see providers)
// Notion: notionPageId, slackLink, relatedTasks, notionWrites
// GitHub: githubOwner, githubRepo, githubIssueNumber, linkedPRs, dosuComment, referencedIssues
}
```
## Ticket State Schema (via API)
State is managed via the Pipeline API using `client.transitionState()`:
```json
{
"ticketId": "string",
"state": "intake | research | planning | implementation | pr_created | done | failed",
"stateChangedAt": "ISO8601",
// Timestamps tracked by API
"createdAt": "ISO8601",
"updatedAt": "ISO8601"
}
```
## Priority Normalization
All sources should normalize to these values:
| Normalized | Description |
| ---------- | ------------------------- |
| Critical | Production down, security |
| High | Blocking work, urgent |
| Medium | Normal priority (default) |
| Low | Nice to have, backlog |
## Status Normalization
Pipeline tracks these statuses internally:
| Status | Description |
| -------------- | ---------------------------- |
| research | Gathering context |
| planning | Creating implementation plan |
| implementation | Writing code |
| review | Code review in progress |
| qa | Quality assurance |
| done | PR merged or completed |
## ID Generation
IDs are generated by the API when creating tickets. For reference:
- **Notion**: First 8 characters of page ID
- **GitHub**: `gh-{owner}-{repo}-{issue_number}` (sanitized)
Examples:
- Notion: `abc12345`
- GitHub: `gh-comfy-org-frontend-123`

View File

@@ -1,143 +0,0 @@
---
name: writing-storybook-stories
description: 'Write or update Storybook stories for Vue components in ComfyUI_frontend. Use when adding, modifying, reviewing, or debugging `.stories.ts` files, Storybook docs, component demos, or visual catalog entries in `src/` or `apps/desktop-ui/`.'
---
# Write Storybook Stories for ComfyUI_frontend
## Workflow
1. !!!!IMPORTANT Confirm the worktree is on a `feat/*` or `fix/*` branch. Base PRs on the local `main`, not a fork branch.
2. Read the component source first. Understand props, emits, slots, exposed methods, and any supporting types or composables.
3. Read nearby stories before writing anything.
- Search stories: `rg --files src apps | rg '\.stories\.ts$'`
- Inspect title patterns: `rg -n "title:\\s*'" src apps --glob '*.stories.ts'`
4. If a Figma link is provided, list the states you need to cover before writing stories.
5. Co-locate the story file with the component: `ComponentName.stories.ts`.
6. Add each variation on separate stories, except hover state. this should be automatically applied by the implementation and not require a separate story.
7. Run Storybook and validation checks before handing off.
## Match Local Conventions
- Copy the closest neighboring story instead of forcing one universal template.
- Most repo stories use `@storybook/vue3-vite`. Some stories under `apps/desktop-ui` still use `@storybook/vue3`; keep the local convention for that area.
- Add `tags: ['autodocs']` unless the surrounding stories in that area intentionally omit it.
- Use `ComponentPropsAndSlots<typeof Component>` when it helps with prop and slot typing.
- Keep `render` functions stateful when needed. Use `ref()`, `computed()`, and `toRefs(args)` instead of mutating Storybook args directly.
- Use `args.default` or other slot-shaped args when the component content is provided through slots.
- Use `ComponentExposed` only when a component's exposed API breaks the normal Storybook typing.
- Add decorators for realistic width or background context when the component needs it.
## Title Patterns
Do not invent titles from scratch when a close sibling story already exists. Match the nearest domain pattern.
| Component area | Typical title pattern |
| ------------------------------------------------------- | ------------------------------------ |
| `src/components/ui/button/Button.vue` | `Components/Button/Button` |
| `src/components/ui/input/Input.vue` | `Components/Input` |
| `src/components/ui/search-input/SearchInput.vue` | `Components/Input/SearchInput` |
| `src/components/common/SearchBox.vue` | `Components/Input/SearchBox` |
| `src/renderer/extensions/vueNodes/widgets/components/*` | `Widgets/<WidgetName>` |
| `src/platform/assets/components/*` | `Platform/Assets/<ComponentName>` |
| `apps/desktop-ui/src/components/*` | `Desktop/Components/<ComponentName>` |
| `apps/desktop-ui/src/views/*` | `Desktop/Views/<ViewName>` |
If multiple patterns seem plausible, follow the closest sibling story in the same folder tree.
## Common Story Shapes
### Stateful input or `v-model`
```typescript
export const Default: Story = {
render: (args) => ({
components: { MyComponent },
setup() {
const { disabled, size } = toRefs(args)
const value = ref('Hello world')
return { value, disabled, size }
},
template:
'<MyComponent v-model="value" :disabled="disabled" :size="size" />'
})
}
```
### Slot-driven content
```typescript
const meta: Meta<ComponentPropsAndSlots<typeof Button>> = {
argTypes: {
default: { control: 'text' }
},
args: {
default: 'Button'
}
}
export const SingleButton: Story = {
render: (args) => ({
components: { Button },
setup() {
return { args }
},
template: '<Button v-bind="args">{{ args.default }}</Button>'
})
}
```
### Variants or edge cases grid
```typescript
export const AllVariants: Story = {
render: () => ({
components: { MyComponent },
template: `
<div class="grid gap-4 sm:grid-cols-2">
<MyComponent />
<MyComponent disabled />
<MyComponent loading />
<MyComponent invalid />
</div>
`
})
}
```
## Figma Mapping
- Extract the named states from the design first.
- Prefer explicit prop-driven stories such as `Disabled`, `Loading`, `Invalid`, `WithPlaceholder`, `AllSizes`, or `EdgeCases`.
- Add an aggregate story such as `AllVariants`, `AllSizes`, or `EdgeCases` when side-by-side comparison is useful.
- Use pseudo-state parameters only if the addon is already configured in this repo.
- If a Figma state cannot be represented exactly, capture the closest prop-driven version and explain the gap in the story docs.
## Component-Specific Notes
- Widget components often need a minimal `SimplifiedWidget` object. Build it in `setup()` and use `computed()` when `args` change `widget.options`.
- Input and search components often need a width-constrained wrapper so they render at realistic sizes.
- Asset and platform cards often need background decorators such as `bg-base-background` and fixed-width containers.
- Desktop installer stories may need custom `backgrounds` parameters and may intentionally keep the older Storybook import style used by neighboring files.
- Use semantic tokens such as `bg-base-background` and `bg-node-component-surface` instead of `dark:` variants or hardcoded theme assumptions.
## Checklist
- [ ] Read the component source and any supporting types or composables
- [ ] Match the nearest local title pattern and story style
- [ ] Include a baseline story; name it `Default` only when that matches nearby conventions
- [ ] Add focused stories for meaningful states
- [ ] Add `tags: ['autodocs']`
- [ ] Keep the story co-located with the component
- [ ] Run `pnpm storybook`
- [ ] Run `pnpm typecheck`
- [ ] Run `pnpm lint`
## Avoid
- Do not guess props, emits, slots, or exposed methods.
- Do not force one generic title convention across the repo.
- Do not mutate Storybook args directly for `v-model` components.
- Do not introduce `dark:` Tailwind variants in story wrappers.
- Do not create barrel files.
- Do not assume every story needs `layout: 'centered'` or a `Default` export; follow the nearest existing pattern.

View File

@@ -1,4 +0,0 @@
interface:
display_name: 'ComfyUI Storybook Stories'
short_description: 'Write Vue Storybook stories for ComfyUI'
default_prompt: 'Use $writing-storybook-stories to add or update a Storybook story for this ComfyUI_frontend component.'

View File

@@ -12,14 +12,3 @@ reviews:
- comfy-pr-bot
- github-actions
- github-actions[bot]
pre_merge_checks:
custom_checks:
- name: End-to-end regression coverage for fixes
mode: warning
instructions: |
Pass if at least one of the following is true:
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes at least one file under `browser_tests/`.
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.

View File

@@ -19,7 +19,7 @@ runs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
node-version: 'lts/*'
cache: 'pnpm'
cache-dependency-path: './pnpm-lock.yaml'

View File

@@ -61,22 +61,6 @@ jobs:
git commit -m "[automated] Apply ESLint and Oxfmt fixes"
git push
- name: Fail for fork PRs with unfixed lint/format issues
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::error::Linting/formatting issues found. Since this PR is from a fork, auto-fix cannot be applied automatically."
echo ""
echo "Please run these commands locally and push the changes:"
echo " pnpm lint:fix"
echo " pnpm stylelint:fix"
echo " pnpm format"
echo ""
echo "Or set up pre-commit hooks to automatically format on every commit:"
echo " pnpm prepare"
echo ""
echo "See CONTRIBUTING.md for more details."
exit 1
- name: Final validation
run: |
pnpm lint
@@ -100,3 +84,16 @@ jobs:
repo: context.repo.repo,
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting'
})
- name: Comment on PR about manual fix needed
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
continue-on-error: true
uses: actions/github-script@v8
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## ⚠️ Linting/Formatting Issues Found\n\nThis PR has linting or formatting issues that need to be fixed.\n\n**Since this PR is from a fork, auto-fix cannot be applied automatically.**\n\n### Option 1: Set up pre-commit hooks (recommended)\nRun this once to automatically format code on every commit:\n```bash\npnpm prepare\n```\n\n### Option 2: Fix manually\nRun these commands and push the changes:\n```bash\npnpm lint:fix\npnpm format\n```\n\nSee [CONTRIBUTING.md](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/CONTRIBUTING.md#git-pre-commit-hooks) for more details.'
})

View File

@@ -1 +0,0 @@
.claude/worktrees

View File

@@ -17,7 +17,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
### Prerequisites & Technology Stack
- **Required Software**:
- Node.js (see `.nvmrc` for the required version) and pnpm
- Node.js (see `.nvmrc`, currently v24) and pnpm
- Git for version control
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)
@@ -87,10 +87,6 @@ navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to
> ⚠️ IMPORTANT:
> The dev server will NOT load JavaScript extensions from custom nodes. Only core extensions (built into the frontend) will be loaded. This is because the shim system that allows custom node JavaScript to import frontend modules only works in production builds. Python custom nodes still function normally. See [Extension Development Guide](docs/extensions/development.md) for details and workarounds. And See [Extension Overview](docs/extensions/README.md) for extensions overview.
## Troubleshooting
If you run into issues during development (e.g. `pnpm dev` hanging, TypeScript errors after pulling, lock file conflicts), see [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common fixes.
## Development Workflow
### Architecture Decision Records

View File

@@ -1,368 +0,0 @@
# Troubleshooting Guide
This guide helps you resolve common issues when developing ComfyUI Frontend.
## Quick Diagnostic Flowchart
```mermaid
flowchart TD
A[Having Issues?] --> B{What's the problem?}
B -->|Dev server stuck| C[nx serve hangs]
B -->|Build errors| D[Check build issues]
B -->|Lint errors| Q[Check linting issues]
B -->|Dependency issues| E[Package problems]
B -->|Other| F[See FAQ below]
Q --> R{oxlint or ESLint?}
R -->|oxlint| S[Check .oxlintrc.json<br/>and run pnpm lint:fix]
R -->|ESLint| T[Check eslint.config.ts<br/>and run pnpm lint:fix]
S --> L
T --> L
C --> G{Tried quick fixes?}
G -->|No| H[Run: pnpm i]
G -->|Still stuck| I[Run: pnpm clean]
I --> J{Still stuck?}
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
J -->|No| L[Fixed!]
H --> L
D --> M[Run: pnpm build]
M --> N{Build succeeds?}
N -->|No| O[Check error messages<br/>in FAQ]
N -->|Yes| L
E --> H
F --> P[Search FAQ or<br/>ask in Discord]
```
## Frequently Asked Questions
### Development Server Issues
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
**Symptoms:**
- Command hangs on "nx serve"
- Dev server doesn't respond
- Terminal appears frozen
**Solutions (try in order):**
1. **First attempt - Reinstall dependencies:**
```bash
pnpm i
```
2. **Second attempt - Clean build cache:**
```bash
pnpm clean
```
3. **Last resort - Full node_modules reset:**
```bash
pnpm dlx rimraf node_modules && pnpm i
```
**Why this happens:**
- Corrupted dependency cache
- Outdated lock files after branch switching
- Incomplete previous installations
- NX cache corruption
---
#### Q: Port conflicts - "Address already in use"
**Symptoms:**
- Error: `EADDRINUSE` or "port already in use"
- Dev server fails to start
**Solutions:**
1. **Find and kill the process using the port:**
```bash
# On Linux/Mac
lsof -ti:5173 | xargs kill -9
# On Windows
netstat -ano | findstr :5173
taskkill /PID <PID> /F
```
2. **Use a different port** by adding a `port` option to the `server` block in `vite.config.mts`:
```ts
server: {
port: 3000,
// ...existing config
}
```
---
### Build and Type Issues
#### Q: TypeScript errors after pulling latest changes
**Symptoms:**
- Type errors in files you didn't modify
- "Cannot find module" errors
**Solutions:**
1. **Rebuild TypeScript references:**
```bash
pnpm build
```
2. **Clean and reinstall:**
```bash
pnpm clean && pnpm i
```
3. **Restart your IDE's TypeScript server**
- VS Code: `Cmd/Ctrl + Shift + P` → "TypeScript: Restart TS Server"
---
#### Q: "Workspace not found" or monorepo errors
**Symptoms:**
- pnpm can't find workspace packages
- Import errors between packages
**Solutions:**
1. **Verify you're in the project root:**
```bash
pwd # Should be in ComfyUI_frontend/
```
2. **Rebuild workspace:**
```bash
pnpm install
pnpm build
```
---
### Linting Issues (oxlint)
#### Q: `eslint-disable` comment isn't suppressing an oxlint rule
**Symptoms:**
- `// eslint-disable-next-line rule-name` has no effect
- Lint error persists despite the disable comment
**Solution:**
oxlint has its own disable syntax. Use `oxlint-disable` instead:
```ts
// oxlint-disable-next-line no-console
console.log('debug')
```
Check whether the rule is enforced by oxlint (in `.oxlintrc.json`) or ESLint (in `eslint.config.ts`) to pick the right disable comment.
---
#### Q: New lint errors after pulling/upgrading oxlint
**Symptoms:**
- Lint errors in files you didn't change
- Rules you haven't seen before (e.g. `no-immediate-mutation`, `prefer-optional-chain`)
**Solutions:**
1. **Run the auto-fixer first:**
```bash
pnpm lint:fix
```
2. **Review changes carefully** — some oxlint auto-fixes can produce incorrect code. Check the diff before committing.
3. **If a rule seems wrong**, check `.oxlintrc.json` to see if it should be disabled or configured differently.
**Why this happens:** oxlint version bumps often enable new rules by default.
---
#### Q: oxlint fails with TypeScript errors
**Symptoms:**
- `pnpm oxlint` or `pnpm lint` fails with type-related errors
- Errors mention type resolution or missing type information
**Solution:**
oxlint runs with `--type-aware` in this project, which requires valid TypeScript compilation. Fix the TS errors first:
```bash
pnpm typecheck # Identify TS errors
pnpm build # Or do a full build
pnpm lint # Then re-run lint
```
---
#### Q: Duplicate lint errors from both oxlint and ESLint
**Symptoms:**
- Same violation reported twice
- Conflicting auto-fix suggestions
**Solution:**
The project uses `eslint-plugin-oxlint` to automatically disable ESLint rules that oxlint already covers (see `eslint.config.ts`). If you see duplicates:
1. Ensure `.oxlintrc.json` is up to date after adding new oxlint rules
2. Run `pnpm lint` (which runs oxlint then ESLint in sequence) rather than running them individually
---
### Dependency and Package Issues
#### Q: "Package not found" after adding a dependency
**Symptoms:**
- Module not found after `pnpm add`
- Import errors for newly installed packages
**Solutions:**
1. **Ensure you installed in the correct workspace** (see `pnpm-workspace.yaml` for available workspaces):
```bash
# Example: install in a specific workspace
pnpm --filter <workspace-name> add <package>
```
2. **Clear pnpm cache:**
```bash
pnpm store prune
pnpm install
```
---
#### Q: Lock file conflicts after merge/rebase
**Symptoms:**
- Git conflicts in `pnpm-lock.yaml`
- Dependency resolution errors
**Solutions:**
1. **Regenerate lock file:**
```bash
rm pnpm-lock.yaml
pnpm install
```
2. **Or accept upstream lock file:**
```bash
git checkout --theirs pnpm-lock.yaml
pnpm install
```
---
### Testing Issues
#### Q: Tests fail locally but pass in CI
**Symptoms:**
- Flaky tests
- Different results between local and CI
**Solutions:**
1. **Run tests in CI mode:**
```bash
CI=true pnpm test:unit
```
2. **Clear test cache:**
```bash
pnpm test:unit --no-cache
```
3. **Check Node version matches CI** (see `.nvmrc` for the required version):
```bash
node --version
nvm use # If using nvm — reads .nvmrc automatically
```
---
### Git and Branch Issues
#### Q: Changes from another branch appearing in my branch
**Symptoms:**
- Uncommitted changes not related to your work
- Dirty working directory
**Solutions:**
1. **Stash and reinstall:**
```bash
git stash
pnpm install
```
2. **Check for untracked files:**
```bash
git status
git clean -fd # Careful: removes untracked files!
```
---
## Still Having Issues?
1. **Search existing issues:** [GitHub Issues](https://github.com/Comfy-Org/ComfyUI_frontend/issues)
2. **Ask the community:** [Discord](https://discord.com/invite/comfyorg) (navigate to the `#dev-frontend` channel)
3. **Create a new issue:** Include:
- Your OS and Node version (`node --version`)
- Steps to reproduce
- Full error message
- What you've already tried
## Contributing to This Guide
Found a solution to a common problem? Please:
1. Open a PR to add it to this guide
2. Follow the FAQ format above
3. Include the symptoms, solutions, and why it happens
---
**Last Updated:** 2026-03-10

View File

@@ -27,7 +27,7 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
### Node.js & Playwright Prerequisites
Ensure you have the Node.js version specified in `.nvmrc` installed.
Ensure you have the Node.js version from `.nvmrc` installed (currently v24).
Then, set up the Chromium test driver:
```bash

View File

@@ -328,11 +328,9 @@ test.describe('Settings', () => {
})
await newBlankWorkflowRow.click()
// Click add keybinding button (New Blank Workflow has no default keybinding)
const addKeybindingButton = newBlankWorkflowRow.locator(
'.icon-\\[lucide--plus\\]'
)
await addKeybindingButton.click()
// Click edit button
const editKeybindingButton = newBlankWorkflowRow.locator('.pi-pencil')
await editKeybindingButton.click()
// Set new keybinding
const input = comfyPage.page.getByPlaceholder('Enter your keybind')

View File

@@ -1,123 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
/**
* Returns the client-space position of a group's title bar (for clicking).
*/
async function getGroupTitlePosition(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> {
const pos = await comfyPage.page.evaluate((title) => {
const app = window.app!
const group = app.graph.groups.find(
(g: { title: string }) => g.title === title
)
if (!group) return null
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
/**
* Returns {selectedNodeCount, selectedGroupCount, selectedItemCount}
* from the canvas in the browser.
*/
async function getSelectionCounts(comfyPage: ComfyPage) {
return comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
let selectedNodeCount = 0
let selectedGroupCount = 0
for (const item of canvas.selectedItems) {
if ('inputs' in item || 'outputs' in item) selectedNodeCount++
else selectedGroupCount++
}
return {
selectedNodeCount,
selectedGroupCount,
selectedItemCount: canvas.selectedItems.size
}
})
}
test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('Setting enabled: clicking outer group selects nested group and inner node', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.Group.SelectChildrenOnClick',
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
await comfyPage.nextFrame()
const counts = await getSelectionCounts(comfyPage)
// Outer Group + Inner Group + 1 node = 3 items
expect(counts.selectedItemCount).toBe(3)
expect(counts.selectedGroupCount).toBe(2)
expect(counts.selectedNodeCount).toBe(1)
})
test('Setting disabled: clicking outer group selects only the group', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.Group.SelectChildrenOnClick',
false
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
await comfyPage.nextFrame()
const counts = await getSelectionCounts(comfyPage)
expect(counts.selectedItemCount).toBe(1)
expect(counts.selectedGroupCount).toBe(1)
expect(counts.selectedNodeCount).toBe(0)
})
test('Deselecting outer group deselects all children', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.Group.SelectChildrenOnClick',
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
// Select the outer group (cascades to children)
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
await comfyPage.nextFrame()
let counts = await getSelectionCounts(comfyPage)
expect(counts.selectedItemCount).toBe(3)
// Deselect all via page.evaluate to avoid UI overlay interception
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
counts = await getSelectionCounts(comfyPage)
expect(counts.selectedItemCount).toBe(0)
})
})

View File

@@ -1,104 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { Position } from '../fixtures/types'
type NodeSnapshot = { id: number } & Position
async function getAllNodePositions(
comfyPage: ComfyPage
): Promise<NodeSnapshot[]> {
return comfyPage.page.evaluate(() =>
window.app!.graph.nodes.map((n) => ({
id: n.id as number,
x: n.pos[0],
y: n.pos[1]
}))
)
}
async function getNodePosition(
comfyPage: ComfyPage,
nodeId: number
): Promise<Position | undefined> {
return comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)
if (!node) return
return {
x: node.pos[0],
y: node.pos[1]
}
}, nodeId)
}
async function expectNodePositionStable(
comfyPage: ComfyPage,
initial: NodeSnapshot,
mode: string
) {
await expect
.poll(
async () => {
const current = await getNodePosition(comfyPage, initial.id)
return current?.x ?? Number.NaN
},
{ message: `node ${initial.id} x drifted in ${mode} mode` }
)
.toBeCloseTo(initial.x, 1)
await expect
.poll(
async () => {
const current = await getNodePosition(comfyPage, initial.id)
return current?.y ?? Number.NaN
},
{ message: `node ${initial.id} y drifted in ${mode} mode` }
)
.toBeCloseTo(initial.y, 1)
}
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
if (enabled) {
await comfyPage.vueNodes.waitForNodes()
}
await comfyPage.nextFrame()
}
test.describe(
'Renderer toggle stability',
{ tag: ['@node', '@canvas'] },
() => {
test('node positions do not drift when toggling between Vue and LiteGraph renderers', async ({
comfyPage
}) => {
const TOGGLE_COUNT = 5
const initialPositions = await getAllNodePositions(comfyPage)
expect(initialPositions.length).toBeGreaterThan(0)
for (let i = 0; i < TOGGLE_COUNT; i++) {
await setVueMode(comfyPage, true)
for (const initial of initialPositions) {
await expectNodePositionStable(
comfyPage,
initial,
`Vue toggle ${i + 1}`
)
}
await setVueMode(comfyPage, false)
for (const initial of initialPositions) {
await expectNodePositionStable(
comfyPage,
initial,
`LiteGraph toggle ${i + 1}`
)
}
}
})
}
)

View File

@@ -1,77 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Subgraph progress clear on navigation',
{ tag: ['@subgraph'] },
() => {
test('Stale progress is cleared on subgraph node after navigating back', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Find the subgraph node
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return subgraphNode ? String(subgraphNode.id) : null
})
expect(subgraphNodeId).not.toBeNull()
// Simulate a stale progress value on the subgraph node.
// This happens when:
// 1. User views root graph during execution
// 2. Progress watcher sets node.progress = 0.5
// 3. User enters subgraph
// 4. Execution completes (nodeProgressStates becomes {})
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
// SubgraphNode isn't visible so it keeps stale progress
// 6. User navigates back — watcher should fire and clear it
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.5
}, subgraphNodeId!)
// Verify progress is set
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressBefore).toBe(0.5)
// Navigate into the subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
subgraphNodeId!
)
await subgraphNode.navigateIntoSubgraph()
// Verify we're inside the subgraph
const inSubgraph = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
expect(inSubgraph).toBe(true)
// Navigate back to the root graph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// The progress watcher should fire when graph changes (because
// nodeLocationProgressStates is empty {} and the watcher should
// iterate canvas.graph.nodes to clear stale node.progress values).
//
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
// fire on subgraph->root navigation when progress is already empty,
// leaving stale node.progress = 0.5 on the SubgraphNode.
await expect(async () => {
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressAfter).toBeUndefined()
}).toPass({ timeout: 2_000 })
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -1,102 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Advanced Widget Visibility', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
// Add a ModelSamplingFlux node which has both advanced (max_shift,
// base_shift) and non-advanced (width, height) widgets.
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('ModelSamplingFlux')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
await comfyPage.vueNodes.waitForNodes()
})
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return getNode(comfyPage).locator('.lg-node-widget')
}
test('should hide advanced widgets by default', async ({ comfyPage }) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
// Non-advanced widgets (width, height) should be visible
await expect(widgets).toHaveCount(2)
await expect(node.getByLabel('width', { exact: true })).toBeVisible()
await expect(node.getByLabel('height', { exact: true })).toBeVisible()
// Advanced widgets should not be rendered
await expect(
node.getByLabel('max_shift', { exact: true })
).not.toBeVisible()
await expect(
node.getByLabel('base_shift', { exact: true })
).not.toBeVisible()
// "Show advanced inputs" button should be present
await expect(node.getByText('Show advanced inputs')).toBeVisible()
})
test('should show advanced widgets when per-node toggle is clicked', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Click the toggle button to show advanced widgets
await node.getByText('Show advanced inputs').click()
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// Button text should change to "Hide advanced inputs"
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
// Click again to hide
await node.getByText('Hide advanced inputs').click()
await expect(widgets).toHaveCount(2)
})
test('should show advanced widgets when global setting is enabled', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Enable the global setting
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
true
)
// All 4 widgets should now be visible
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// The toggle button should not be shown when global setting is active
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
})
})

View File

@@ -24,7 +24,6 @@ const extraFileExtensions = ['.vue']
const commonGlobals = {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
} as const

1
global.d.ts vendored
View File

@@ -1,5 +1,4 @@
declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __COMFYUI_FRONTEND_COMMIT__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string

View File

@@ -47,60 +47,7 @@
border: 0;
}
</style>
<style>
/* Pre-Vue splash loader — inlined to avoid SPA fallback serving
index.html instead of CSS on cloud/ephemeral environments */
#splash-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
contain: strict;
}
#splash-loader svg {
width: min(200px, 50vw);
height: auto;
transform: translateZ(0);
}
#splash-loader .wave-group {
animation: splash-rise 4s ease-in-out infinite alternate;
will-change: transform;
transform: translateZ(0);
}
#splash-loader .wave-path {
animation: splash-wave 1.2s linear infinite;
will-change: transform;
transform: translateZ(0);
}
@keyframes splash-rise {
from {
transform: translateY(280px);
}
to {
transform: translateY(-80px);
}
}
@keyframes splash-wave {
from {
transform: translateX(0);
}
to {
transform: translateX(-880px);
}
}
@media (prefers-reduced-motion: reduce) {
#splash-loader .wave-group,
#splash-loader .wave-path {
animation: none;
}
#splash-loader .wave-group {
transform: translateY(-80px);
}
}
</style>
<link rel="stylesheet" href="splash.css" />
<link rel="manifest" href="manifest.json" />
</head>

View File

@@ -11,7 +11,7 @@ const config: KnipConfig = {
'src/types/index.ts',
'src/storybook/mocks/**/*.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
},
'apps/desktop-ui': {
entry: ['src/main.ts', 'src/i18n.ts'],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.42.4",
"version": "1.42.3",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -154,7 +154,6 @@
"eslint-plugin-storybook": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"eslint-plugin-vue": "catalog:",
"fast-check": "catalog:",
"fs-extra": "^11.2.0",
"globals": "catalog:",
"happy-dom": "catalog:",

View File

@@ -1420,6 +1420,15 @@ audio.comfy-audio.empty-audio-widget {
font-size 0.1s ease;
}
/* Performance optimization during canvas interaction */
.transform-pane--interacting .lg-node * {
transition: none !important;
}
.transform-pane--interacting .lg-node {
will-change: transform;
}
/* ===================== Mask Editor Styles ===================== */
/* To be migrated to Tailwind later */
#maskEditor_brush {

550
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -69,7 +69,6 @@ catalog:
eslint-plugin-storybook: ^10.2.10
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
fast-check: ^4.5.3
firebase: ^11.6.0
glob: ^13.0.6
globals: ^16.5.0
@@ -108,7 +107,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: ^8.0.0
vite: 8.0.0-beta.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0

View File

@@ -9,19 +9,15 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
import { isStaleChunkError, parsePreloadError } from '@/utils/preloadErrorUtil'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const { t } = useI18n()
import { electronAPI } from '@/utils/envUtil'
import { isDesktop } from '@/platform/distribution/types'
import { app } from '@/scripts/app'
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
@@ -49,19 +45,6 @@ const showContextMenu = (event: MouseEvent) => {
}
}
function handleResourceError(url: string, tagName: string) {
console.error('[resource:loadError]', { url, tagName })
if (__DISTRIBUTION__ === 'cloud') {
captureException(new Error(`Resource load failed: ${url}`), {
tags: {
error_type: 'resource_load_error',
tag_name: tagName
}
})
}
}
onMounted(() => {
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
@@ -73,58 +56,15 @@ onMounted(() => {
// See: https://vite.dev/guide/build#load-error-handling
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
const info = parsePreloadError(event.payload)
console.error('[vite:preloadError]', {
url: info.url,
fileType: info.fileType,
chunkName: info.chunkName,
message: info.message
})
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: {
error_type: 'vite_preload_error',
file_type: info.fileType,
chunk_name: info.chunkName ?? undefined
},
contexts: {
preload: {
url: info.url,
fileType: info.fileType,
chunkName: info.chunkName
}
}
})
}
if (isStaleChunkError(info)) {
useToastStore().add({
severity: 'error',
summary: t('g.preloadErrorTitle'),
detail: t('g.preloadError'),
life: 10000
tags: { error_type: 'vite_preload_error' }
})
} else {
console.error('[vite:preloadError]', event.payload)
}
})
// Capture resource load failures (CSS, scripts) in non-localhost distributions
if (__DISTRIBUTION__ !== 'localhost') {
window.addEventListener(
'error',
(event) => {
const target = event.target
if (target instanceof HTMLScriptElement) {
handleResourceError(target.src, 'script')
} else if (
target instanceof HTMLLinkElement &&
target.rel === 'stylesheet'
) {
handleResourceError(target.href, 'link')
}
},
true
)
}
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()

View File

@@ -78,14 +78,6 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
vi.mock('@/scripts/app', () => ({
app: {
menu: {
element: document.createElement('div')
}
}
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
@@ -139,18 +131,6 @@ function createWrapper({
})
}
function getLegacyCommandsContainer(
wrapper: ReturnType<typeof createWrapper>
): HTMLElement {
const legacyContainer = wrapper.find(
'[data-testid="legacy-topbar-container"]'
).element
if (!(legacyContainer instanceof HTMLElement)) {
throw new Error('Expected legacy commands container to be present')
}
return legacyContainer
}
function createJob(id: string, status: JobStatus): JobListItem {
return {
id,
@@ -535,69 +515,4 @@ describe('TopMenuSection', () => {
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
})
it('coalesces legacy topbar mutation scans to one check per frame', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const rafCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallbacks.push(cb)
return rafCallbacks.length
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.UseNewMenu') return 'Top'
if (key === 'Comfy.UI.TabBarLayout') return 'Integrated'
if (key === 'Comfy.RightSidePanel.IsOpen') return true
return undefined
})
const wrapper = createWrapper({ pinia, attachTo: document.body })
try {
await nextTick()
const actionbarContainer = wrapper.find('.actionbar-container')
expect(actionbarContainer.classes()).toContain('w-0')
const legacyContainer = getLegacyCommandsContainer(wrapper)
const querySpy = vi.spyOn(legacyContainer, 'querySelector')
if (rafCallbacks.length > 0) {
const initialCallbacks = [...rafCallbacks]
rafCallbacks.length = 0
initialCallbacks.forEach((callback) => callback(0))
await nextTick()
}
querySpy.mockClear()
querySpy.mockReturnValue(document.createElement('div'))
for (let index = 0; index < 3; index++) {
const outer = document.createElement('div')
const inner = document.createElement('div')
inner.textContent = `legacy-${index}`
outer.appendChild(inner)
legacyContainer.appendChild(outer)
}
await vi.waitFor(() => {
expect(rafCallbacks.length).toBeGreaterThan(0)
})
expect(querySpy).not.toHaveBeenCalled()
const callbacks = [...rafCallbacks]
rafCallbacks.length = 0
callbacks.forEach((callback) => callback(0))
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer.classes()).toContain('px-2')
} finally {
wrapper.unmount()
vi.unstubAllGlobals()
}
})
})

View File

@@ -39,7 +39,6 @@
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
@@ -117,7 +116,7 @@
<script setup lang="ts">
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
@@ -265,7 +264,6 @@ const rightSidePanelTooltipConfig = computed(() =>
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)
let legacyContentCheckRafId: number | null = null
function checkLegacyContent() {
const el = legacyCommandsContainerRef.value
@@ -278,35 +276,19 @@ function checkLegacyContent() {
el.querySelector(':scope > * > *:not(:empty)') !== null
}
function scheduleLegacyContentCheck() {
if (legacyContentCheckRafId !== null) return
legacyContentCheckRafId = requestAnimationFrame(() => {
legacyContentCheckRafId = null
checkLegacyContent()
})
}
useMutationObserver(legacyCommandsContainerRef, scheduleLegacyContentCheck, {
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
childList: true,
subtree: true
subtree: true,
characterData: true
})
onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'
legacyCommandsContainerRef.value.appendChild(app.menu.element)
checkLegacyContent()
}
})
onBeforeUnmount(() => {
if (legacyContentCheckRafId === null) return
cancelAnimationFrame(legacyContentCheckRafId)
legacyContentCheckRafId = null
})
const openCustomNodeManager = async () => {
try {
await managerState.openManager({

View File

@@ -3,19 +3,19 @@
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<ScrubableNumberInput v-model="x" :min="0" :step="1" :disabled />
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<ScrubableNumberInput v-model="y" :min="0" :step="1" :disabled />
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<ScrubableNumberInput v-model="width" :min="1" :step="1" :disabled />
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<ScrubableNumberInput v-model="height" :min="1" :step="1" :disabled />
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
</div>
</template>
@@ -25,10 +25,6 @@ import { computed } from 'vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import type { Bounds } from '@/renderer/core/layout/types'
const { disabled = false } = defineProps<{
disabled?: boolean
}>()
const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, ref, toValue } from 'vue'
import { computed, provide, ref, toValue } from 'vue'
import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
@@ -30,6 +30,7 @@ import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -45,8 +46,19 @@ const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return node ? { nodeId, widgetName, node, widget } : null
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
@@ -189,9 +201,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</script>
<template>
<div class="flex h-full flex-col">
<div
class="flex h-12 items-center border-b border-border-subtle px-4 font-bold"
>
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
@@ -199,10 +209,34 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
class="overflow-x-clip"
>
<AppModeWidgetList builder-mode />
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div
v-else
class="pointer-events-none p-1 text-sm text-muted-foreground"
>
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"

View File

@@ -1,212 +0,0 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
}
action: { widget: IBaseWidget; node: LGraphNode }
}
const { mobile = false, builderMode = false } = defineProps<{
mobile?: boolean
builderMode?: boolean
}>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
provide(HideLayoutFieldKey, true)
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
app.rootGraph.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
const mappedSelections = computed((): WidgetEntry[] => {
void graphNodes.value
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
}
const fullNodeData = nodeDataByNode.get(node)!
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return (
isPromotedWidgetView(widget) &&
widget.sourceNodeId == storeNodeId &&
widget.sourceWidgetName === vueWidget.storeName
)
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = String(node.id)
return [
{
key: `${nodeId}:${widgetName}`,
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
},
action: { widget, node }
}
]
})
})
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const filename = node.widgets?.[0]?.value
const resultItem = { type: 'input', filename: `${filename}` }
const buildImageUrl = () => {
if (!filename) return undefined
const params = new URLSearchParams(resultItem)
appendCloudResParam(params, resultItem.filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
return {
iconClass: 'icon-[lucide--image]',
imageUrl: buildImageUrl(),
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
}
}
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
return {
...nodeData,
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
dropIndicator,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver
}
}
</script>
<template>
<div
v-for="{ key, nodeData, action } in mappedSelections"
:key
:class="
cn(
builderMode &&
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing'
)
"
:aria-label="
builderMode
? `${action.widget.label ?? action.widget.name} ${action.node.title}`
: undefined
"
>
<div
:class="
cn(
'mt-1.5 flex min-h-8 items-center gap-1 px-3',
builderMode && 'drag-handle'
)
"
>
<span
:class="cn('truncate text-sm/8', builderMode && 'pointer-events-none')"
>
{{ action.widget.label || action.widget.name }}
</span>
<span
v-if="builderMode"
class="pointer-events-none mx-1 min-w-10 flex-1 truncate text-right text-xs text-muted-foreground"
>
{{ action.node.title }}
</span>
<div v-else class="flex-1" />
<Popover
:class="cn('shrink-0', builderMode && 'pointer-events-auto')"
:entries="[
{
label: t('g.rename'),
icon: 'icon-[lucide--pencil]',
command: () => promptRenameWidget(action.widget, action.node, t)
},
{
label: t('g.remove'),
icon: 'icon-[lucide--x]',
command: () =>
appModeStore.removeSelectedInput(action.widget, action.node)
}
]"
>
<template #button>
<Button variant="textonly" size="icon">
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
</Popover>
</div>
<div
:class="builderMode && 'pointer-events-none'"
:inert="builderMode || undefined"
>
<DropZone
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
:drop-indicator="nodeData.dropIndicator"
class="text-muted-foreground"
>
<NodeWidgets
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-1 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
)
"
/>
</DropZone>
</div>
</div>
</template>

View File

@@ -59,8 +59,6 @@ import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
const props = defineProps<{
stats: SystemStats
}>()
@@ -79,7 +77,6 @@ type SystemInfoKey = keyof SystemStats['system']
type ColumnDef = {
field: SystemInfoKey
header: string
getValue?: () => string
format?: (value: string) => string
formatNumber?: (value: number) => string
}
@@ -107,7 +104,6 @@ const cloudColumns: ColumnDef[] = [
{
field: 'comfyui_frontend_version',
header: 'Frontend Version',
getValue: () => frontendCommit,
format: formatCommitHash
},
{ field: 'workflow_templates_version', header: 'Templates Version' }
@@ -123,9 +119,7 @@ function isOutdated(column: ColumnDef): boolean {
}
function getDisplayValue(column: ColumnDef) {
const value = column.getValue
? column.getValue()
: systemInfo.value[column.field]
const value = systemInfo.value[column.field]
if (column.formatNumber && typeof value === 'number') {
return column.formatNumber(value)
}

View File

@@ -3,13 +3,8 @@
ref="svgRef"
viewBox="-0.04 -0.04 1.08 1.08"
preserveAspectRatio="xMidYMid meet"
:class="
cn(
'aspect-square w-full rounded-[5px] bg-node-component-surface',
disabled ? 'cursor-default' : 'cursor-crosshair'
)
"
@pointerdown.stop="onSvgPointerDown"
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
@pointerdown.stop="handleSvgPointerDown"
@contextmenu.prevent.stop
>
<line
@@ -61,23 +56,20 @@
:stroke="curveColor"
stroke-width="0.008"
stroke-linecap="round"
:opacity="disabled ? 0.5 : 1"
/>
<template v-if="!disabled">
<circle
v-for="(point, i) in modelValue"
:key="i"
:cx="point[0]"
:cy="1 - point[1]"
r="0.02"
:fill="curveColor"
stroke="white"
stroke-width="0.004"
class="cursor-grab"
@pointerdown.stop="startDrag(i, $event)"
/>
</template>
<circle
v-for="(point, i) in modelValue"
:key="i"
:cx="point[0]"
:cy="1 - point[1]"
r="0.02"
:fill="curveColor"
stroke="white"
stroke-width="0.004"
class="cursor-grab"
@pointerdown.stop="startDrag(i, $event)"
/>
</svg>
</template>
@@ -85,20 +77,14 @@
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import { cn } from '@/utils/tailwindUtil'
import type { CurvePoint } from './types'
import { histogramToPath } from './curveUtils'
const {
curveColor = 'white',
histogram,
disabled = false
} = defineProps<{
const { curveColor = 'white', histogram } = defineProps<{
curveColor?: string
histogram?: Uint32Array | null
disabled?: boolean
}>()
const modelValue = defineModel<CurvePoint[]>({
@@ -112,10 +98,6 @@ const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
modelValue
})
function onSvgPointerDown(e: PointerEvent) {
if (!disabled) handleSvgPointerDown(e)
}
const histogramPath = computed(() =>
histogram ? histogramToPath(histogram) : ''
)

View File

@@ -1,27 +1,11 @@
<template>
<CurveEditor
:model-value="effectivePoints"
:disabled="isDisabled"
@update:model-value="modelValue = $event"
/>
<CurveEditor v-model="modelValue" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
singleValueExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import CurveEditor from './CurveEditor.vue'
import { isCurvePointArray } from './curveUtils'
import type { CurvePoint } from './types'
const { widget } = defineProps<{
widget: SimplifiedWidget
}>()
import CurveEditor from './CurveEditor.vue'
const modelValue = defineModel<CurvePoint[]>({
default: () => [
@@ -29,17 +13,4 @@ const modelValue = defineModel<CurvePoint[]>({
[1, 1]
]
})
const isDisabled = computed(() => !!widget.options?.disabled)
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
singleValueExtractor(isCurvePointArray)
)
const effectivePoints = computed(() =>
isDisabled.value && upstreamValue.value
? upstreamValue.value
: modelValue.value
)
</script>

View File

@@ -1,19 +1,5 @@
import type { CurvePoint } from './types'
export function isCurvePointArray(value: unknown): value is CurvePoint[] {
return (
Array.isArray(value) &&
value.length >= 2 &&
value.every(
(p) =>
Array.isArray(p) &&
p.length === 2 &&
typeof p[0] === 'number' &&
typeof p[1] === 'number'
)
)
}
/**
* Monotone cubic Hermite interpolation.
* Produces a smooth curve that passes through all control points

View File

@@ -30,33 +30,31 @@
</div>
<div class="flex shrink-0 items-center gap-2">
<Skeleton v-if="showSkeleton(model)" class="ml-1.5 h-4 w-12" />
<template v-else-if="model.isDownloadable">
<span
v-if="fileSizes.get(model.url)"
class="pl-1.5 text-xs text-muted-foreground"
>
{{ formatSize(fileSizes.get(model.url)) }}
</span>
<a
v-if="gatedModelUrls.has(model.url)"
:href="gatedModelUrls.get(model.url)"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline"
>
{{ $t('missingModelsDialog.acceptTerms') }}
</a>
<Button
v-else
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.download')"
@click="downloadModel(model, paths)"
>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
<span
v-else-if="model.isDownloadable && fileSizes.get(model.url)"
class="pl-1.5 text-xs text-muted-foreground"
>
{{ formatSize(fileSizes.get(model.url)) }}
</span>
<a
v-else-if="gatedModelUrls.has(model.url)"
:href="gatedModelUrls.get(model.url)"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline"
>
{{ $t('missingModelsDialog.acceptTerms') }}
</a>
<Button
v-else-if="model.isDownloadable"
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.download')"
@click="downloadModel(model, paths)"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<Button
v-else
variant="textonly"

View File

@@ -5,257 +5,101 @@
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
/>
<ContextMenuRoot>
<ContextMenuTrigger as-child>
<div @contextmenu.capture="clearContextMenuTarget">
<DataTable
v-model:selection="selectedCommandData"
v-model:expanded-rows="expandedRows"
:value="commandsData"
data-key="id"
:global-filter-fields="['id', 'label']"
:filters="filters"
selection-mode="single"
context-menu
striped-rows
:pt="{
header: 'px-0'
}"
@row-click="handleRowClick($event)"
@row-dblclick="handleRowDblClick($event.data)"
@row-contextmenu="handleRowContextMenu($event)"
>
<Column
field="id"
:header="$t('g.command')"
sortable
class="max-w-64 2xl:max-w-full"
:pt="{ bodyCell: 'p-1 min-h-8' }"
<DataTable
v-model:selection="selectedCommandData"
:value="commandsData"
:global-filter-fields="['id', 'label']"
:filters="filters"
selection-mode="single"
striped-rows
:pt="{
header: 'px-0'
}"
@row-dblclick="editKeybinding($event.data)"
>
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
<template #body="slotProps">
<div class="actions flex flex-row">
<Button
variant="textonly"
size="icon"
:aria-label="$t('g.edit')"
@click="editKeybinding(slotProps.data)"
>
<template #body="slotProps">
<div
class="flex items-center gap-1 truncate"
:class="slotProps.data.keybindings.length < 2 && 'pl-5'"
:title="slotProps.data.id"
>
<i
v-if="slotProps.data.keybindings.length >= 2"
class="icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground transition-transform"
:class="
expandedCommandIds.has(slotProps.data.id) && 'rotate-90'
"
/>
<i
v-if="
slotProps.data.keybindings.some(
(b: KeybindingImpl) => b.combo.isBrowserReserved
)
"
v-tooltip="$t('g.browserReservedKeybindingTooltip')"
class="icon-[lucide--triangle-alert] shrink-0 text-warning-background"
/>
{{ slotProps.data.label }}
</div>
</template>
</Column>
<Column
field="keybindings"
:header="$t('g.keybinding')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
<i class="pi pi-pencil" />
</Button>
<Button
variant="textonly"
size="icon"
:aria-label="$t('g.reset')"
:disabled="
!keybindingStore.isCommandKeybindingModified(slotProps.data.id)
"
@click="resetKeybinding(slotProps.data)"
>
<template #body="slotProps">
<div
v-if="slotProps.data.keybindings.length > 0"
class="flex items-center gap-1"
>
<template
v-for="(binding, idx) in (
slotProps.data as ICommandData
).keybindings.slice(0, 2)"
:key="binding.combo.serialize()"
>
<span v-if="idx > 0" class="text-muted-foreground">,</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
/>
</template>
<span
v-if="slotProps.data.keybindings.length > 2"
class="rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{
$t('g.nMoreKeybindings', {
count: slotProps.data.keybindings.length - 2
})
}}
</span>
</div>
<span v-else>-</span>
</template>
</Column>
<Column
field="source"
:header="$t('g.source')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
<i class="pi pi-replay" />
</Button>
<Button
variant="textonly"
size="icon"
:aria-label="$t('g.delete')"
:disabled="!slotProps.data.keybinding"
@click="removeKeybinding(slotProps.data)"
>
<template #body="slotProps">
<span class="overflow-hidden text-ellipsis">{{
slotProps.data.source || '-'
}}</span>
</template>
</Column>
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
<template #body="slotProps">
<div class="actions flex flex-row justify-end">
<Button
v-if="slotProps.data.keybindings.length === 1"
v-tooltip="$t('g.edit')"
variant="textonly"
size="icon"
:aria-label="$t('g.edit')"
@click="
editKeybinding(
slotProps.data,
slotProps.data.keybindings[0]
)
"
>
<i class="icon-[lucide--pencil]" />
</Button>
<Button
v-tooltip="$t('g.addNewKeybinding')"
variant="textonly"
size="icon"
:aria-label="$t('g.addNewKeybinding')"
@click="addKeybinding(slotProps.data)"
>
<i class="icon-[lucide--plus]" />
</Button>
<Button
v-tooltip="$t('g.reset')"
variant="textonly"
size="icon"
:aria-label="$t('g.reset')"
:disabled="
!keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
@click="resetKeybinding(slotProps.data)"
>
<i class="icon-[lucide--rotate-ccw]" />
</Button>
<Button
v-tooltip="$t('g.delete')"
variant="textonly"
size="icon"
:aria-label="$t('g.delete')"
:disabled="slotProps.data.keybindings.length === 0"
@click="handleRemoveKeybindingFromMenu(slotProps.data)"
>
<i class="icon-[lucide--trash-2]" />
</Button>
</div>
</template>
</Column>
<template #expansion="slotProps">
<div class="pl-4">
<div
v-for="(binding, idx) in (slotProps.data as ICommandData)
.keybindings"
:key="binding.combo.serialize()"
class="flex items-center justify-between border-b border-border-subtle py-1.5 last:border-b-0"
>
<div class="flex items-center gap-4">
<span class="text-muted-foreground">{{
slotProps.data.label
}}</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
/>
</div>
<div class="flex flex-row">
<Button
v-tooltip="$t('g.edit')"
variant="textonly"
size="icon"
:aria-label="$t('g.edit')"
@click="editKeybinding(slotProps.data, binding)"
>
<i class="icon-[lucide--pencil]" />
</Button>
<Button
v-tooltip="$t('g.removeKeybinding')"
variant="textonly"
size="icon"
:aria-label="$t('g.removeKeybinding')"
@click="removeSingleKeybinding(slotProps.data, idx)"
>
<i class="icon-[lucide--trash-2]" />
</Button>
</div>
</div>
</div>
</template>
</DataTable>
</div>
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
:disabled="
!contextMenuTarget || contextMenuTarget.keybindings.length === 0
<i class="pi pi-trash" />
</Button>
</div>
</template>
</Column>
<Column
field="id"
:header="$t('g.command')"
sortable
class="max-w-64 2xl:max-w-full"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
class="flex items-center gap-1.5 truncate"
:title="slotProps.data.id"
>
<i
v-if="slotProps.data.keybinding?.combo.isBrowserReserved"
v-tooltip="$t('g.browserReservedKeybindingTooltip')"
class="icon-[lucide--triangle-alert] shrink-0 text-warning-background"
/>
{{ slotProps.data.label }}
</div>
</template>
</Column>
<Column
field="keybinding"
:header="$t('g.keybinding')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<KeyComboDisplay
v-if="slotProps.data.keybinding"
:key-combo="slotProps.data.keybinding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(slotProps.data.id)
"
@select="ctxChangeKeybinding"
>
<i class="icon-[lucide--pencil] size-4" />
{{ $t('g.changeKeybinding') }}
</ContextMenuItem>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered"
@select="ctxAddKeybinding"
>
<i class="icon-[lucide--plus] size-4" />
{{ $t('g.addNewKeybinding') }}
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border-subtle" />
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
:disabled="
!contextMenuTarget ||
!keybindingStore.isCommandKeybindingModified(contextMenuTarget.id)
"
@select="ctxResetToDefault"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
{{ $t('g.resetToDefault') }}
</ContextMenuItem>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
:disabled="
!contextMenuTarget || contextMenuTarget.keybindings.length === 0
"
@select="ctxRemoveKeybinding"
>
<i class="icon-[lucide--trash-2] size-4" />
{{ $t('g.removeKeybinding') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
/>
<span v-else>-</span>
</template>
</Column>
<Column
field="source"
:header="$t('g.source')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<span class="overflow-hidden text-ellipsis">{{
slotProps.data.source || '-'
}}</span>
</template>
</Column>
</DataTable>
<Button
v-tooltip="$t('g.resetAllKeybindingsTooltip')"
@@ -263,7 +107,7 @@
variant="destructive-textonly"
@click="resetAllKeybindings"
>
<i class="icon-[lucide--rotate-ccw]" />
<i class="pi pi-replay" />
{{ $t('g.resetAll') }}
</Button>
</div>
@@ -274,26 +118,16 @@ import { FilterMatchMode } from '@primevue/core/api'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import { useToast } from 'primevue/usetoast'
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuSeparator,
ContextMenuTrigger
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
@@ -305,12 +139,11 @@ const filters = ref({
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
interface ICommandData {
id: string
keybindings: KeybindingImpl[]
keybinding: KeybindingImpl | null
label: string
source?: string
}
@@ -322,166 +155,31 @@ const commandsData = computed<ICommandData[]>(() => {
`commands.${normalizeI18nKey(command.id)}.label`,
command.label ?? ''
),
keybindings: keybindingStore.getKeybindingsByCommandId(command.id),
keybinding: keybindingStore.getKeybindingByCommandId(command.id),
source: command.source
}))
})
const expandedCommandIds = ref<Set<string>>(new Set())
const expandedRows = computed({
get() {
const result: Record<string, boolean> = {}
for (const id of expandedCommandIds.value) {
result[id] = true
}
return result
},
set(value: Record<string, boolean>) {
expandedCommandIds.value = new Set(Object.keys(value))
}
})
function toggleExpanded(commandId: string) {
if (expandedCommandIds.value.has(commandId)) {
expandedCommandIds.value.delete(commandId)
} else {
expandedCommandIds.value.add(commandId)
}
}
watch(filters, () => expandedCommandIds.value.clear(), { deep: true })
const selectedCommandData = ref<ICommandData | null>(null)
const editKeybindingDialog = useEditKeybindingDialog()
const contextMenuTarget = ref<ICommandData | null>(null)
function editKeybinding(commandData: ICommandData, binding: KeybindingImpl) {
function editKeybinding(commandData: ICommandData) {
editKeybindingDialog.show({
commandId: commandData.id,
commandLabel: commandData.label,
currentCombo: binding.combo,
mode: 'edit',
existingBinding: binding
currentCombo: commandData.keybinding?.combo ?? null
})
}
function addKeybinding(commandData: ICommandData) {
editKeybindingDialog.show({
commandId: commandData.id,
commandLabel: commandData.label,
currentCombo: null,
mode: 'add'
})
}
function handleRowClick(event: { originalEvent: Event; data: ICommandData }) {
const target = event.originalEvent.target as HTMLElement
if (target.closest('.actions')) return
const commandData = event.data
if (
commandData.keybindings.length >= 2 ||
expandedCommandIds.value.has(commandData.id)
) {
toggleExpanded(commandData.id)
}
}
function handleRowDblClick(commandData: ICommandData) {
if (commandData.keybindings.length === 0) {
addKeybinding(commandData)
} else if (commandData.keybindings.length === 1) {
editKeybinding(commandData, commandData.keybindings[0])
}
}
function handleRowContextMenu(event: {
originalEvent: Event
data: ICommandData
}) {
contextMenuTarget.value = event.data
}
function clearContextMenuTarget() {
contextMenuTarget.value = null
}
async function removeSingleKeybinding(
commandData: ICommandData,
index: number
) {
const binding = commandData.keybindings[index]
if (binding) {
keybindingStore.unsetKeybinding(binding)
if (commandData.keybindings.length <= 2) {
expandedCommandIds.value.delete(commandData.id)
}
async function removeKeybinding(commandData: ICommandData) {
if (commandData.keybinding) {
keybindingStore.unsetKeybinding(commandData.keybinding)
await keybindingService.persistUserKeybindings()
}
}
function handleRemoveAllKeybindings(commandData: ICommandData) {
const dialog = showConfirmDialog({
headerProps: { title: t('g.removeAllKeybindingsTitle') },
props: { promptText: t('g.removeAllKeybindingsMessage') },
footerProps: {
confirmText: t('g.removeAll'),
confirmVariant: 'destructive',
onCancel: () => dialogStore.closeDialog(dialog),
onConfirm: async () => {
keybindingStore.removeAllKeybindingsForCommand(commandData.id)
await keybindingService.persistUserKeybindings()
dialogStore.closeDialog(dialog)
}
}
})
}
function handleRemoveKeybindingFromMenu(commandData: ICommandData) {
if (commandData.keybindings.length >= 2) {
handleRemoveAllKeybindings(commandData)
} else {
removeSingleKeybinding(commandData, 0)
}
}
function ctxChangeKeybinding() {
if (!contextMenuTarget.value) return
const target = contextMenuTarget.value
if (target.keybindings.length === 1) {
editKeybinding(target, target.keybindings[0])
} else if (target.keybindings.length >= 2) {
if (!expandedCommandIds.value.has(target.id)) {
toggleExpanded(target.id)
}
}
}
function ctxAddKeybinding() {
if (contextMenuTarget.value) {
addKeybinding(contextMenuTarget.value)
}
}
function ctxResetToDefault() {
if (contextMenuTarget.value) {
resetKeybinding(contextMenuTarget.value)
}
}
function ctxRemoveKeybinding() {
if (
contextMenuTarget.value &&
contextMenuTarget.value.keybindings.length > 0
) {
handleRemoveKeybindingFromMenu(contextMenuTarget.value)
}
}
async function resetKeybinding(commandData: ICommandData) {
if (keybindingStore.resetKeybindingForCommand(commandData.id)) {
expandedCommandIds.value.delete(commandData.id)
await keybindingService.persistUserKeybindings()
} else {
console.warn(
@@ -491,33 +189,14 @@ async function resetKeybinding(commandData: ICommandData) {
}
const toast = useToast()
function resetAllKeybindings() {
const dialog = showConfirmDialog({
headerProps: {
title: t('g.resetAllKeybindingsTitle')
},
props: {
promptText: t('g.resetAllKeybindingsMessage')
},
footerProps: {
confirmText: t('g.resetAll'),
confirmVariant: 'destructive',
onCancel: () => {
dialogStore.closeDialog(dialog)
},
onConfirm: async () => {
keybindingStore.resetAllKeybindings()
await keybindingService.persistUserKeybindings()
dialogStore.closeDialog(dialog)
toast.add({
severity: 'info',
summary: t('g.info'),
detail: t('g.allKeybindingsReset'),
life: 3000
})
}
}
async function resetAllKeybindings() {
keybindingStore.resetAllKeybindings()
await keybindingService.persistUserKeybindings()
toast.add({
severity: 'info',
summary: 'Info',
detail: 'All keybindings reset',
life: 3000
})
}
</script>

View File

@@ -64,18 +64,9 @@ async function handleSave() {
dialogStore.closeDialog({ key: DIALOG_KEY })
if (dialogState.mode === 'add') {
keybindingStore.addUserKeybinding(new KeybindingImpl({ commandId, combo }))
} else if (dialogState.existingBinding) {
keybindingStore.updateSpecificKeybinding(
dialogState.existingBinding,
new KeybindingImpl({ commandId, combo })
)
} else {
keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
}
await keybindingService.persistUserKeybindings()
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
if (updated) await keybindingService.persistUserKeybindings()
}
</script>

View File

@@ -1,10 +1,7 @@
<template>
<span>
<template v-for="(sequence, index) in keySequences" :key="index">
<Tag
class="bg-interface-menu-keybind-surface-default text-base-foreground"
:severity="isModified ? 'info' : 'secondary'"
>
<Tag :severity="isModified ? 'info' : 'secondary'">
{{ sequence }}
</Tag>
<span v-if="index < keySequences.length - 1" class="px-2">+</span>

View File

@@ -81,6 +81,7 @@
? 'Execution error'
: null
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
/>
</TransformPane>
@@ -143,7 +144,6 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -246,16 +246,6 @@ const { shouldRenderVueNodes } = useVueFeatureFlags()
// Vue node system
const vueNodeLifecycle = useVueNodeLifecycle()
// Error-clearing hooks run regardless of rendering mode (Vue or legacy canvas).
let cleanupErrorHooks: (() => void) | null = null
watch(
() => canvasStore.currentGraph,
(graph) => {
cleanupErrorHooks?.()
cleanupErrorHooks = graph ? installErrorClearingHooks(graph) : null
}
)
const handleVueNodeLifecycleReset = async () => {
if (shouldRenderVueNodes.value) {
vueNodeLifecycle.disposeNodeManagerAndSyncs()
@@ -374,24 +364,10 @@ watch(
}
)
/**
* Propagates execution progress from the store to LiteGraph node objects
* and triggers a canvas redraw.
*
* No `deep: true` needed — `nodeLocationProgressStates` is a computed that
* returns a new `Record` object on every progress event (the underlying
* `nodeProgressStates` ref is replaced wholesale by the WebSocket handler).
*
* `currentGraph` triggers this watcher on subgraph navigation so stale
* progress bars are cleared when returning to the root graph.
*/
// Update the progress of executing nodes
watch(
() =>
[
executionStore.nodeLocationProgressStates,
canvasStore.canvas,
canvasStore.currentGraph
] as const,
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
([nodeLocationProgressStates, canvas]) => {
if (!canvas?.graph) return
for (const node of canvas.graph.nodes) {
@@ -406,15 +382,43 @@ watch(
// Force canvas redraw to ensure progress updates are visible
canvas.setDirty(true, false)
}
},
{ deep: true }
)
// Repaint canvas when node errors change.
// Slot error flags are reconciled by reconcileNodeErrorFlags in executionErrorStore.
// Update node slot errors for LiteGraph nodes
// (Vue nodes read from store directly)
watch(
() => executionErrorStore.lastNodeErrors,
() => {
comfyApp.canvas?.setDirty(true, true)
(lastNodeErrors) => {
if (!comfyApp.graph) return
forEachNode(comfyApp.rootGraph, (node) => {
// Clear existing errors
for (const slot of node.inputs) {
delete slot.hasErrors
}
for (const slot of node.outputs) {
delete slot.hasErrors
}
const nodeErrors = lastNodeErrors?.[node.id]
if (!nodeErrors) return
const validErrors = nodeErrors.errors.filter(
(error) => error.extra_info?.input_name !== undefined
)
validErrors.forEach((error) => {
const inputName = error.extra_info!.input_name!
const inputIndex = node.findInputSlot(inputName)
if (inputIndex !== -1) {
node.inputs[inputIndex].hasErrors = true
}
})
})
comfyApp.canvas.setDirty(true, true)
}
)
@@ -517,11 +521,6 @@ onMounted(async () => {
comfyAppReady.value = true
// Install error-clearing hooks on the initial graph
if (comfyApp.canvas?.graph) {
cleanupErrorHooks = installErrorClearingHooks(comfyApp.canvas.graph)
}
vueNodeLifecycle.setupEmptyGraphListener()
} finally {
workspaceStore.spinner = false
@@ -565,13 +564,13 @@ onMounted(async () => {
})
onUnmounted(() => {
cleanupErrorHooks?.()
cleanupErrorHooks = null
vueNodeLifecycle.cleanup()
})
function forwardPanEvent(e: PointerEvent) {
if (!isMiddlePointerInput(e)) return
if (shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target)
if (
(shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target) ||
!isMiddlePointerInput(e)
)
return
canvasInteractions.forwardEventToCanvas(e)

View File

@@ -36,37 +36,26 @@
<div
v-if="imageUrl && !isLoading"
:class="
cn(
'absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]',
isDisabled && 'pointer-events-none opacity-60'
)
"
class="absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
:style="cropBoxStyle"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"
/>
<template v-for="handle in resizeHandles" :key="handle.direction">
<div
v-show="imageUrl && !isLoading"
:class="
cn(
'absolute',
handle.class,
isDisabled && 'pointer-events-none opacity-60'
)
"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
/>
</template>
<div
v-for="handle in resizeHandles"
v-show="imageUrl && !isLoading"
:key="handle.direction"
:class="['absolute', handle.class]"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
/>
</div>
<div v-if="!isDisabled" class="flex shrink-0 items-center gap-2">
<div class="flex shrink-0 items-center gap-2">
<label class="text-xs text-muted-foreground">
{{ $t('imageCrop.ratio') }}
</label>
@@ -101,16 +90,12 @@
</Button>
</div>
<WidgetBoundingBox
v-model="effectiveBounds"
:disabled="isDisabled"
class="shrink-0"
/>
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
</div>
</template>
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'
import { useTemplateRef } from 'vue'
import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue'
import Button from '@/components/ui/button/Button.vue'
@@ -120,17 +105,10 @@ import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { ASPECT_RATIOS, useImageCrop } from '@/composables/useImageCrop'
import {
boundsExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Bounds } from '@/renderer/core/layout/types'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
const { widget, nodeId } = defineProps<{
widget: SimplifiedWidget
const props = defineProps<{
nodeId: NodeId
}>()
@@ -138,23 +116,6 @@ const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})
const isDisabled = computed(() => !!widget.options?.disabled)
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
boundsExtractor()
)
const effectiveBounds = computed({
get: () =>
isDisabled.value && upstreamValue.value
? upstreamValue.value
: modelValue.value,
set: (v) => {
if (!isDisabled.value) modelValue.value = v
}
})
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
@@ -178,5 +139,5 @@ const {
handleResizeStart,
handleResizeMove,
handleResizeEnd
} = useImageCrop(nodeId, { imageEl, containerEl, modelValue: effectiveBounds })
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
</script>

View File

@@ -1,5 +1,20 @@
<template>
<div class="flex h-full flex-col">
<!-- Assets Header -->
<div v-if="assets.length" class="px-2 2xl:px-4">
<div
class="flex items-center py-2 font-inter text-sm/normal font-normal text-muted-foreground"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div>
</div>
<!-- Assets Grid -->
<VirtualGrid
class="flex-1"
@@ -25,14 +40,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
const {
assets,
isSelected,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
}>()
@@ -45,6 +68,8 @@ const emit = defineEmits<{
(e: 'output-count-click', asset: AssetItem): void
}>()
const { t } = useI18n()
type AssetGridItem = { key: string; asset: AssetItem }
const assetItems = computed<AssetGridItem[]>(() =>

View File

@@ -50,7 +50,8 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {}
toggleStack: async () => {},
assetType: 'output'
},
global: {
stubs: {
@@ -60,6 +61,18 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
})
describe('AssetsSidebarListView', () => {
it('shows generated assets header when there are assets', () => {
const wrapper = mountListView([buildOutputItem(buildAsset('a1', 'x.png'))])
expect(wrapper.text()).toContain('sideToolbar.generatedAssetsHeader')
})
it('does not show assets header when there are no assets', () => {
const wrapper = mountListView([])
expect(wrapper.text()).not.toContain('sideToolbar.generatedAssetsHeader')
})
it('marks mp4 assets as video previews', () => {
const videoAsset = {
...buildAsset('video-asset', 'clip.mp4'),

View File

@@ -1,5 +1,19 @@
<template>
<div class="flex h-full flex-col">
<div v-if="assetItems.length" class="px-2">
<div
class="flex items-center p-2 font-inter text-sm/normal font-normal text-muted-foreground"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div>
</div>
<VirtualGrid
class="flex-1"
:items="assetItems"
@@ -92,13 +106,15 @@ const {
selectableAssets,
isSelected,
isStackExpanded,
toggleStack
toggleStack,
assetType = 'output'
} = defineProps<{
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
isSelected: (assetId: string) => boolean
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => Promise<void>
assetType?: 'input' | 'output'
}>()
const assetsStore = useAssetsStore()

View File

@@ -24,6 +24,17 @@
</div>
</div>
</template>
<template #tool-buttons>
<!-- Normal Tab View -->
<TabList v-if="!isInFolderView" v-model="activeTab">
<Tab class="font-inter" value="output">{{
$t('sideToolbar.labels.generated')
}}</Tab>
<Tab class="font-inter" value="input">{{
$t('sideToolbar.labels.imported')
}}</Tab>
</TabList>
</template>
<template #header>
<!-- Job Detail View Header -->
<div v-if="isInFolderView" class="px-2 2xl:px-4">
@@ -39,24 +50,15 @@
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
v-model:media-type-filters="mediaTypeFilters"
bottom-divider
class="px-2 pb-1 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'"
/>
<!-- Tab list -->
<div
v-if="!isInFolderView"
class="border-b border-comfy-input p-2 2xl:px-4"
>
<TabList v-model="activeTab">
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
</TabList>
</div>
<Divider type="dashed" class="my-2" />
</template>
<template #body>
<div
v-if="showLoadingState"
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 p-2"
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 px-2"
>
<div
v-for="n in skeletonCount"
@@ -83,11 +85,7 @@
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
<div
v-else
class="relative size-full py-2"
@click="handleEmptySpaceClick"
>
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<AssetsSidebarListView
v-if="isListView"
:asset-items="listViewAssetItems"
@@ -95,6 +93,7 @@
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@@ -104,6 +103,7 @@
v-else
:assets="displayAssets"
:is-selected="isSelected"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@@ -203,6 +203,7 @@ import {
useStorage,
useTimeoutFn
} from '@vueuse/core'
import Divider from 'primevue/divider'
import { useToast } from 'primevue/usetoast'
import {
computed,

View File

@@ -20,14 +20,15 @@
</Button>
</template>
<template #header>
<SidebarTopArea>
<div class="px-2 2xl:px-4">
<SearchInput
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="workflows-search-box"
:placeholder="$t('g.searchPlaceholder', { subject: searchSubject })"
@search="handleSearch"
/>
</SidebarTopArea>
</div>
</template>
<template #body>
<div v-if="!isSearching" class="comfyui-workflows-panel">
@@ -146,7 +147,6 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import TextDivider from '@/components/common/TextDivider.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'

View File

@@ -21,7 +21,7 @@
</Button>
</template>
<template #header>
<SidebarTopArea>
<div class="px-2 2xl:px-4">
<SearchInput
ref="searchBoxRef"
v-model:model-value="searchQuery"
@@ -32,7 +32,7 @@
"
@search="handleSearch"
/>
</SidebarTopArea>
</div>
</template>
<template #body>
<ElectronDownloadItems v-if="isDesktop" />
@@ -57,7 +57,6 @@ import { Divider } from 'primevue'
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -95,6 +96,12 @@ describe('NodeLibrarySidebarTabV2', () => {
return mount(NodeLibrarySidebarTabV2, {
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n],
components: {
TabsRoot,
TabsList,
TabsTrigger,
TabsContent
},
stubs: {
teleport: true
}
@@ -105,7 +112,7 @@ describe('NodeLibrarySidebarTabV2', () => {
it('should render with tabs', () => {
const wrapper = mountComponent()
const triggers = wrapper.findAll('[role="tab"]')
const triggers = wrapper.findAllComponents(TabsTrigger)
expect(triggers).toHaveLength(3)
})

View File

@@ -1,23 +1,22 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.nodes')">
<template #header>
<SidebarTopArea bottom-divider>
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@search="handleSearch"
/>
<template #actions>
<TabsRoot v-model="selectedTab" class="flex flex-col">
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@search="handleSearch"
/>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
variant="secondary"
size="icon"
<button
:aria-label="$t('g.sort')"
class="hover:bg-comfy-input-hover flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-comfy-input"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
@@ -43,13 +42,12 @@
</DropdownMenuRoot>
<DropdownMenuRoot v-if="selectedTab === 'all'">
<DropdownMenuTrigger as-child>
<Button
variant="secondary"
size="icon"
<button
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
class="hover:bg-comfy-input-hover flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-comfy-input"
>
<i class="icon-[lucide--list-filter] size-4" />
</Button>
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
@@ -104,55 +102,65 @@
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
</SidebarTopArea>
<div class="border-b border-comfy-input p-2 2xl:px-4">
<TabList v-model="selectedTab">
<Tab v-for="tab in tabs" :key="tab.value" :value="tab.value">
</div>
<Separator decorative class="border border-dashed border-comfy-input" />
<!-- Tab list in header (fixed) -->
<TabsList
class="bg-background flex gap-4 border-b border-comfy-input p-4"
>
<TabsTrigger
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
:class="
cn(
'cursor-pointer rounded-lg border-none px-3 py-2 outline-none select-none',
'text-foreground text-sm transition-colors',
selectedTab === tab.value
? 'bg-comfy-input font-bold'
: 'bg-transparent font-normal'
)
"
>
{{ tab.label }}
</Tab>
</TabList>
</div>
</TabsTrigger>
</TabsList>
</TabsRoot>
</template>
<template #body>
<NodeDragPreview />
<div class="flex h-full flex-col">
<div class="min-h-0 flex-1 overflow-y-auto py-2">
<TabPanel
v-if="flags.nodeLibraryEssentialsEnabled"
:model-value="selectedTab"
value="essentials"
>
<EssentialNodesPanel
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
</TabPanel>
<TabPanel :model-value="selectedTab" value="all">
<AllNodesPanel
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
</TabPanel>
<TabPanel :model-value="selectedTab" value="blueprints">
<BlueprintsPanel
v-model:expanded-keys="expandedKeys"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabPanel>
</div>
</div>
<!-- Tab content (scrollable) -->
<TabsRoot v-model="selectedTab" class="h-full">
<EssentialNodesPanel
v-if="
flags.nodeLibraryEssentialsEnabled && selectedTab === 'essentials'
"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
<AllNodesPanel
v-if="selectedTab === 'all'"
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
<BlueprintsPanel
v-if="selectedTab === 'blueprints'"
v-model:expanded-keys="expandedKeys"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabsRoot>
</template>
</SidebarTabTemplate>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import { useLocalStorage } from '@vueuse/core'
import {
DropdownMenuCheckboxItem,
@@ -162,18 +170,17 @@ import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRoot,
DropdownMenuTrigger
DropdownMenuTrigger,
Separator,
TabsList,
TabsRoot,
TabsTrigger
} from 'reka-ui'
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import TabPanel from '@/components/tab/TabPanel.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'

View File

@@ -7,9 +7,9 @@
)
"
>
<div class="comfy-vue-side-bar-header flex flex-col">
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<Toolbar
class="min-h-16 rounded-none border-x-0 border-t-0 bg-transparent px-3 2xl:px-4"
class="min-h-16 rounded-none border-x-0 border-t-0 bg-transparent px-2 2xl:px-4"
:pt="sidebarPt"
>
<template #start>

View File

@@ -1,17 +0,0 @@
<template>
<div class="flex items-center gap-2 p-2 2xl:px-4">
<div class="min-w-0 flex-1">
<slot />
</div>
<div v-if="$slots.actions" class="flex shrink-0 items-center gap-2">
<slot name="actions" />
</div>
</div>
<div v-if="bottomDivider" class="border-t border-dashed border-comfy-input" />
</template>
<script setup lang="ts">
defineProps<{
bottomDivider?: boolean
}>()
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="h-full flex-1 overflow-y-auto">
<TabsContent value="all" class="h-full flex-1 overflow-y-auto">
<!-- Favorites section -->
<h3
class="mb-0 px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground uppercase"
@@ -34,10 +34,11 @@
@add-to-favorites="handleAddToFavorites"
/>
</div>
</div>
</TabsContent>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import { computed } from 'vue'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'

View File

@@ -1,5 +1,5 @@
<template>
<div class="h-full flex-1 overflow-y-auto">
<TabsContent value="blueprints" class="h-full flex-1 overflow-y-auto">
<div v-for="(section, index) in sections" :key="section.title ?? index">
<h3
v-if="section.title"
@@ -14,10 +14,12 @@
@node-click="(node) => emit('nodeClick', node)"
/>
</div>
</div>
</TabsContent>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type {

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex h-full flex-1 flex-col">
<TabsContent value="custom" class="flex h-full flex-1 flex-col">
<div
v-for="(section, index) in sections"
:key="section.title ?? index"
@@ -30,10 +30,12 @@
{{ $t('g.manageExtensions') }}
</Button>
</div>
</div>
</TabsContent>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

View File

@@ -1,5 +1,9 @@
<template>
<div ref="panelEl" class="h-full flex-1 overflow-y-auto px-3">
<TabsContent
ref="panelEl"
value="essentials"
class="h-full flex-1 overflow-y-auto px-3"
>
<div class="flex flex-col gap-2 pb-6">
<!-- Flat sorted grid when alphabetical -->
<div
@@ -53,25 +57,28 @@
</CollapsibleRoot>
</template>
</div>
</div>
</TabsContent>
</template>
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger
CollapsibleTrigger,
TabsContent
} from 'reka-ui'
import type { ComponentPublicInstance } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
import EssentialNodeCard from './EssentialNodeCard.vue'
const panelEl = ref<ComponentPublicInstance | null>(null)
const panelRef = computed(() => panelEl.value?.$el as HTMLElement | null)
provide('essentialsPanelRef', panelRef)
const panelEl = ref<HTMLDivElement | null>(null)
provide('essentialsPanelRef', panelEl)
import EssentialNodeCard from './EssentialNodeCard.vue'
const { root, flatNodes = [] } = defineProps<{
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>

View File

@@ -1,74 +1,48 @@
<template>
<button
:id="`tab-${props.value}`"
:id="tabId"
:class="tabClasses"
role="tab"
type="button"
:aria-selected="isActive"
:aria-controls="`tabpanel-${props.value}`"
:data-state="isActive ? 'active' : 'inactive'"
:tabindex="isActive ? 0 : -1"
:class="
cn(
'flex shrink-0 items-center justify-center',
'cursor-pointer rounded-lg border-none px-2.5 py-2 text-sm transition-all duration-200',
'focus-visible:ring-ring/20 outline-hidden focus-visible:ring-1',
isActive
? 'bg-interface-menu-component-surface-hovered text-text-primary'
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface',
props.class
)
"
:aria-controls="panelId"
:tabindex="0"
@click="handleClick"
@keydown="handleKeydown"
>
<slot />
</button>
</template>
<script setup lang="ts" generic="T extends string = string">
import type { HTMLAttributes } from 'vue'
import type { Ref } from 'vue'
import { computed, inject } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const props = defineProps<{
const { value, panelId } = defineProps<{
value: T
class?: HTMLAttributes['class']
panelId?: string
}>()
const context = inject(TAB_LIST_INJECTION_KEY)
const currentValue = inject<Ref<T>>('tabs-value')
const updateValue = inject<(value: T) => void>('tabs-update')
const isActive = computed(() => context?.modelValue.value === props.value)
const tabId = computed(() => `tab-${value}`)
const isActive = computed(() => currentValue?.value === value)
function handleClick() {
context?.select(props.value)
}
const tabClasses = computed(() => {
return cn(
// Base styles from TextButton
'flex shrink-0 items-center justify-center',
'cursor-pointer rounded-lg px-2.5 py-2 text-sm transition-all duration-200',
'border-none outline-hidden',
// State styles with semantic tokens
isActive.value
? 'text-bold bg-interface-menu-component-surface-hovered text-text-primary'
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
)
})
function handleKeydown(event: KeyboardEvent) {
const tablist = (event.currentTarget as HTMLElement).parentElement
if (!tablist) return
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]'))
const currentIndex = tabs.indexOf(event.currentTarget as HTMLElement)
let targetIndex = -1
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
targetIndex = (currentIndex + 1) % tabs.length
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
targetIndex = (currentIndex - 1 + tabs.length) % tabs.length
} else if (event.key === 'Home') {
targetIndex = 0
} else if (event.key === 'End') {
targetIndex = tabs.length - 1
}
if (targetIndex !== -1) {
event.preventDefault()
tabs[targetIndex].focus()
}
const handleClick = () => {
updateValue?.(value)
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div role="tablist" class="flex w-full items-center gap-2">
<div role="tablist" class="flex w-full items-center gap-2 pb-1">
<slot />
</div>
</template>
@@ -7,16 +7,11 @@
<script setup lang="ts" generic="T extends string = string">
import { provide } from 'vue'
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const modelValue = defineModel<T>({ required: true })
function select(value: string) {
modelValue.value = value as T
}
provide(TAB_LIST_INJECTION_KEY, {
modelValue,
select
// Provide for child Tab components
provide('tabs-value', modelValue)
provide('tabs-update', (value: T) => {
modelValue.value = value
})
</script>

View File

@@ -1,29 +0,0 @@
<template>
<div
v-if="isActive"
:id="`tabpanel-${value}`"
role="tabpanel"
tabindex="0"
:aria-labelledby="`tab-${value}`"
>
<slot />
</div>
</template>
<script setup lang="ts" generic="T extends string = string">
import { computed, inject } from 'vue'
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const { value, modelValue } = defineProps<{
value: T
modelValue?: T
}>()
const context = inject(TAB_LIST_INJECTION_KEY, undefined)
const isActive = computed(() =>
modelValue !== undefined
? modelValue === value
: context?.modelValue.value === value
)
</script>

View File

@@ -1,9 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
interface TabListContext {
modelValue: Ref<string>
select: (value: string) => void
}
export const TAB_LIST_INJECTION_KEY: InjectionKey<TabListContext> =
Symbol('TabListContext')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,68 +0,0 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref } from 'vue'
import ColorPicker from './ColorPicker.vue'
const meta: Meta<ComponentPropsAndSlots<typeof ColorPicker>> = {
title: 'Components/ColorPicker',
component: ColorPicker,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
(story) => ({
components: { story },
template: '<div class="w-60"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#e06cbd')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}
export const Red: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#ff0000')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}
export const Black: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#000000')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}
export const White: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#ffffff')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}

View File

@@ -1,125 +0,0 @@
<script setup lang="ts">
import {
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import type { HSVA } from '@/utils/colorUtil'
import { hexToHsva, hsbToRgb, hsvaToHex, rgbToHex } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
import ColorPickerPanel from './ColorPickerPanel.vue'
defineProps<{
class?: string
}>()
const modelValue = defineModel<string>({ default: '#000000' })
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
const displayMode = ref<'hex' | 'rgba'>('hex')
watch(modelValue, (newVal) => {
const current = hsvaToHex(hsva.value)
if (newVal !== current) {
hsva.value = hexToHsva(newVal || '#000000')
}
})
watch(
hsva,
(newHsva) => {
const hex = hsvaToHex(newHsva)
if (hex !== modelValue.value) {
modelValue.value = hex
}
},
{ deep: true }
)
const baseRgb = computed(() =>
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
)
const previewColor = computed(() => {
const hex = rgbToHex(baseRgb.value)
const a = hsva.value.a / 100
if (a < 1) {
const alphaHex = Math.round(a * 255)
.toString(16)
.padStart(2, '0')
return `${hex}${alphaHex}`
}
return hex
})
const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
const isOpen = ref(false)
</script>
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<button
type="button"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-node-component-surface pr-2 outline-none hover:bg-component-node-widget-background-hovered',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-node-component-slot-text"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="bottom"
align="start"
:side-offset="7"
:collision-padding="10"
class="z-1700"
>
<ColorPickerPanel
v-model:hsva="hsva"
v-model:display-mode="displayMode"
/>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type { HSVA } from '@/utils/colorUtil'
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
import ColorPickerSlider from './ColorPickerSlider.vue'
const hsva = defineModel<HSVA>('hsva', { required: true })
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
required: true
})
const rgb = computed(() =>
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
)
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
const { t } = useI18n()
</script>
<template>
<div
class="flex w-[211px] flex-col gap-2 rounded-lg border border-border-subtle bg-base-background p-2 shadow-md"
>
<ColorPickerSaturationValue
v-model:saturation="hsva.s"
v-model:value="hsva.v"
:hue="hsva.h"
/>
<ColorPickerSlider v-model="hsva.h" type="hue" />
<ColorPickerSlider
v-model="hsva.a"
type="alpha"
:hue="hsva.h"
:saturation="hsva.s"
:brightness="hsva.v"
/>
<div class="flex items-center gap-1">
<Select v-model="displayMode">
<SelectTrigger
class="h-6 w-[58px] shrink-0 gap-0.5 overflow-clip rounded-sm border-0 px-1.5 py-0 text-xs [&>span]:overflow-visible"
>
<SelectValue />
</SelectTrigger>
<SelectContent class="min-w-16 p-1">
<SelectItem value="hex" class="px-2 py-1 text-xs">
{{ t('color.hex') }}
</SelectItem>
<SelectItem value="rgba" class="px-2 py-1 text-xs">
{{ t('color.rgba') }}
</SelectItem>
</SelectContent>
</Select>
<div
class="flex h-6 min-w-0 flex-1 items-center gap-1 rounded-sm bg-secondary-background px-1 text-xs text-node-component-slot-text"
>
<template v-if="displayMode === 'hex'">
<span class="min-w-0 flex-1 truncate text-center">{{
hexString
}}</span>
</template>
<template v-else>
<span class="w-6 shrink-0 text-center">{{ rgb.r }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
</template>
<span class="shrink-0 border-l border-border-subtle pl-1"
>{{ hsva.a }}%</span
>
</div>
</div>
</div>
</template>

View File

@@ -1,66 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { hue } = defineProps<{
hue: number
}>()
const saturation = defineModel<number>('saturation', { required: true })
const value = defineModel<number>('value', { required: true })
const containerRef = ref<HTMLElement | null>(null)
const hueBackground = computed(() => `hsl(${hue}, 100%, 50%)`)
const handleStyle = computed(() => ({
left: `${saturation.value}%`,
top: `${100 - value.value}%`
}))
function updateFromPointer(e: PointerEvent) {
const el = containerRef.value
if (!el) return
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
saturation.value = Math.round(x * 100)
value.value = Math.round((1 - y) * 100)
}
function handlePointerDown(e: PointerEvent) {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
updateFromPointer(e)
}
function handlePointerMove(e: PointerEvent) {
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
updateFromPointer(e)
}
</script>
<template>
<div
ref="containerRef"
role="slider"
:aria-label="t('color.saturationBrightness')"
:aria-valuetext="`${saturation}%, ${value}%`"
class="relative aspect-square w-full cursor-crosshair rounded-sm"
:style="{ backgroundColor: hueBackground, touchAction: 'none' }"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
>
<div
class="absolute inset-0 rounded-sm bg-linear-to-r from-white to-transparent"
/>
<div
class="absolute inset-0 rounded-sm bg-linear-to-b from-transparent to-black"
/>
<div
class="pointer-events-none absolute size-3.5 -translate-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
:style="handleStyle"
/>
</div>
</template>

View File

@@ -1,91 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
const { t } = useI18n()
const {
type,
hue = 0,
saturation = 100,
brightness = 100
} = defineProps<{
type: 'hue' | 'alpha'
hue?: number
saturation?: number
brightness?: number
}>()
const modelValue = defineModel<number>({ required: true })
const max = computed(() => (type === 'hue' ? 360 : 100))
const fraction = computed(() => modelValue.value / max.value)
const trackBackground = computed(() => {
if (type === 'hue') {
return 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)'
}
const rgb = hsbToRgb({ h: hue, s: saturation, b: brightness })
const hex = rgbToHex(rgb)
return `linear-gradient(to right, transparent, ${hex})`
})
const containerStyle = computed(() => {
if (type === 'alpha') {
return {
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '8px 8px',
touchAction: 'none'
}
}
return {
background: trackBackground.value,
touchAction: 'none'
}
})
function updateFromPointer(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
modelValue.value = Math.round(x * max.value)
}
function handlePointerDown(e: PointerEvent) {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
updateFromPointer(e)
}
function handlePointerMove(e: PointerEvent) {
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
updateFromPointer(e)
}
</script>
<template>
<div
role="slider"
:aria-label="type === 'hue' ? t('color.hue') : t('color.alpha')"
:aria-valuemin="0"
:aria-valuemax="max"
:aria-valuenow="modelValue"
class="relative flex h-4 cursor-pointer items-center rounded-full p-px"
:style="containerStyle"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
>
<div
v-if="type === 'alpha'"
class="absolute inset-0 rounded-full"
:style="{ background: trackBackground }"
/>
<div
class="pointer-events-none absolute aspect-square h-full -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
:style="{ left: `${fraction * 100}%` }"
/>
</div>
</template>

View File

@@ -1,352 +0,0 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
function seedSimpleError(
store: ReturnType<typeof useExecutionErrorStore>,
executionId: string,
inputName: string
) {
store.lastNodeErrors = {
[executionId]: {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: inputName }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
}
describe('Connection error clearing via onConnectionsChange', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
function createGraphWithInput() {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
node.addInput('clip', 'CLIP')
graph.add(node)
return { graph, node }
}
it('clears simple node error when INPUT is connected', () => {
const { graph, node } = createGraphWithInput()
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedSimpleError(store, String(node.id), 'clip')
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
expect(store.lastNodeErrors).toBeNull()
})
it('does not clear errors on disconnection', () => {
const { graph, node } = createGraphWithInput()
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedSimpleError(store, String(node.id), 'clip')
node.onConnectionsChange!(
NodeSlotType.INPUT,
0,
false,
null,
node.inputs[0]
)
expect(store.lastNodeErrors).not.toBeNull()
})
it('does not clear errors on OUTPUT connection', () => {
const { graph, node } = createGraphWithInput()
node.addOutput('out', 'CLIP')
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedSimpleError(store, String(node.id), 'clip')
node.onConnectionsChange!(
NodeSlotType.OUTPUT,
0,
true,
null,
node.outputs[0]
)
expect(store.lastNodeErrors).not.toBeNull()
})
it('clears errors for pure input slots without widget property', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('model', 'MODEL')
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedSimpleError(store, String(node.id), 'model')
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
expect(store.lastNodeErrors).toBeNull()
})
})
describe('Widget change error clearing via onWidgetChanged', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('clears simple error when widget value changes to valid range', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {
min: 1,
max: 100
})
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
store.lastNodeErrors = {
[String(node.id)]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
expect(store.lastNodeErrors).toBeNull()
})
it('retains error when widget value is still out of range', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {
min: 1,
max: 100
})
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
store.lastNodeErrors = {
[String(node.id)]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
node.onWidgetChanged!.call(node, 'steps', 150, 20, node.widgets![0])
expect(store.lastNodeErrors).not.toBeNull()
})
it('does not clear errors when rootGraph is unavailable', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(
undefined as unknown as LGraph
)
store.lastNodeErrors = {
[String(node.id)]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
expect(store.lastNodeErrors).not.toBeNull()
})
it('uses interior node execution ID for promoted widget error clearing', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'model.safetensors',
() => undefined,
{ values: ['model.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
// with that name so the slot-name filter matches.
seedSimpleError(store, interiorExecId, promotedWidget!.name)
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'other_model.safetensors',
'model.safetensors',
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
})
describe('installErrorClearingHooks lifecycle', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('propagates hooks to nodes added after installation', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('value', 'INT')
graph.add(node)
installErrorClearingHooks(graph)
// Add a new node after hooks are installed
const lateNode = new LGraphNode('late')
lateNode.addInput('value', 'INT')
graph.add(lateNode)
// The late-added node should have error-clearing hooks
expect(lateNode.onConnectionsChange).toBeDefined()
expect(lateNode.onWidgetChanged).toBeDefined()
// Verify the hooks actually work
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedSimpleError(store, String(lateNode.id), 'value')
lateNode.onConnectionsChange!(
NodeSlotType.INPUT,
0,
true,
null,
lateNode.inputs[0]
)
expect(store.lastNodeErrors).toBeNull()
})
it('restores original onNodeAdded when cleanup is called', () => {
const graph = new LGraph()
const originalHook = vi.fn()
graph.onNodeAdded = originalHook
const cleanup = installErrorClearingHooks(graph)
expect(graph.onNodeAdded).not.toBe(originalHook)
cleanup()
expect(graph.onNodeAdded).toBe(originalHook)
})
})
describe('clearWidgetRelatedErrors parameter routing', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('passes widgetName (not errorInputName) for model lookup', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const widget = node.addWidget('number', 'steps', 42, () => undefined, {
min: 0,
max: 100
})
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const clearSpy = vi.spyOn(store, 'clearWidgetRelatedErrors')
node.onWidgetChanged!.call(node, 'steps', 42, 0, widget)
expect(clearSpy).toHaveBeenCalledWith(
String(node.id),
'steps',
'steps',
42,
{ min: 0, max: 100 }
)
clearSpy.mockRestore()
})
})

View File

@@ -1,108 +0,0 @@
/**
* Installs per-node error-clearing callbacks (onConnectionsChange,
* onWidgetChanged) on all current and future nodes in a graph.
*
* Decoupled from the Vue rendering lifecycle so that error auto-clearing
* works in legacy canvas mode as well.
*/
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
function resolvePromotedExecId(
rootGraph: LGraph,
node: LGraphNode,
widget: IBaseWidget,
hostExecId: string
): string {
if (!isPromotedWidgetView(widget)) return hostExecId
const result = resolveConcretePromotedWidget(
node,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved' && result.resolved.node) {
return getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId
}
return hostExecId
}
const hookedNodes = new WeakSet<LGraphNode>()
function installNodeHooks(node: LGraphNode): void {
if (hookedNodes.has(node)) return
hookedNodes.add(node)
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
function (type, slotIndex, isConnected) {
if (type !== NodeSlotType.INPUT || !isConnected) return
if (!app.rootGraph) return
const slotName = node.inputs?.[slotIndex]?.name
if (!slotName) return
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return
useExecutionErrorStore().clearSimpleNodeErrors(execId, slotName)
}
)
node.onWidgetChanged = useChainCallback(
node.onWidgetChanged,
// _name is the LiteGraph callback arg; re-derive from the widget
// object to handle promoted widgets where sourceWidgetName differs.
function (_name, newValue, _oldValue, widget) {
if (!app.rootGraph) return
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
if (!hostExecId) return
const execId = resolvePromotedExecId(
app.rootGraph,
node,
widget,
hostExecId
)
const widgetName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
useExecutionErrorStore().clearWidgetRelatedErrors(
execId,
widget.name,
widgetName,
newValue,
{ min: widget.options?.min, max: widget.options?.max }
)
}
)
}
function installNodeHooksRecursive(node: LGraphNode): void {
installNodeHooks(node)
if (node.isSubgraphNode?.()) {
for (const innerNode of node.subgraph._nodes ?? []) {
installNodeHooksRecursive(innerNode)
}
}
}
export function installErrorClearingHooks(graph: LGraph): () => void {
for (const node of graph._nodes ?? []) {
installNodeHooksRecursive(node)
}
const originalOnNodeAdded = graph.onNodeAdded
graph.onNodeAdded = function (node: LGraphNode) {
installNodeHooksRecursive(node)
originalOnNodeAdded?.call(this, node)
}
return () => {
graph.onNodeAdded = originalOnNodeAdded || undefined
}
}

View File

@@ -11,9 +11,6 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -243,78 +240,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
})
})
describe('Subgraph output slot label reactivity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('updates output slot labels when node:slot-label:changed is triggered', async () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addOutput('original_name', 'STRING')
node.addOutput('other_name', 'STRING')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeId = String(node.id)
const nodeData = vueNodeData.get(nodeId)
if (!nodeData?.outputs) throw new Error('Expected output data to exist')
expect(nodeData.outputs[0].label).toBeUndefined()
expect(nodeData.outputs[1].label).toBeUndefined()
// Simulate what SubgraphNode does: set the label, then fire the trigger
node.outputs[0].label = 'custom_label'
graph.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: NodeSlotType.OUTPUT
})
await nextTick()
const updatedData = vueNodeData.get(nodeId)
expect(updatedData?.outputs?.[0]?.label).toBe('custom_label')
expect(updatedData?.outputs?.[1]?.label).toBeUndefined()
})
it('updates input slot labels when node:slot-label:changed is triggered', async () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('original_name', 'STRING')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeId = String(node.id)
const nodeData = vueNodeData.get(nodeId)
if (!nodeData?.inputs) throw new Error('Expected input data to exist')
expect(nodeData.inputs[0].label).toBeUndefined()
node.inputs[0].label = 'custom_label'
graph.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT
})
await nextTick()
const updatedData = vueNodeData.get(nodeId)
expect(updatedData?.inputs?.[0]?.label).toBe('custom_label')
})
it('ignores node:slot-label:changed for unknown node ids', () => {
const graph = new LGraph()
useGraphNodeManager(graph)
expect(() =>
graph.trigger('node:slot-label:changed', {
nodeId: 'missing-node',
slotType: NodeSlotType.OUTPUT
})
).not.toThrow()
})
})
describe('Subgraph Promoted Pseudo Widgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -391,280 +316,3 @@ describe('Nested promoted widget mapping', () => {
)
})
})
describe('Promoted widget sourceExecutionId', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('sets sourceExecutionId to the interior node execution ID for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'model.safetensors',
() => undefined,
{
values: ['model.safetensors']
}
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(
`${subgraphNode.id}:${interiorNode.id}`
)
})
it('does not set sourceExecutionId for non-promoted widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
expect(widget).toBeDefined()
expect(widget?.sourceExecutionId).toBeUndefined()
})
})
describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function setupGraphWithStore() {
const graph = new LGraph()
const nodeA = new LGraphNode('KSampler')
nodeA.addInput('model', 'MODEL')
nodeA.addInput('steps', 'INT')
graph.add(nodeA)
const nodeB = new LGraphNode('LoadCheckpoint')
nodeB.addInput('ckpt_name', 'STRING')
graph.add(nodeB)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
// Initialize store (triggers watcher registration)
useGraphNodeManager(graph)
const store = useExecutionErrorStore()
return { graph, nodeA, nodeB, store }
}
it('sets has_errors on nodes referenced in lastNodeErrors', async () => {
const { nodeA, nodeB, store } = setupGraphWithStore()
store.lastNodeErrors = {
[String(nodeA.id)]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
await nextTick()
expect(nodeA.has_errors).toBe(true)
expect(nodeB.has_errors).toBeFalsy()
})
it('sets slot hasErrors for inputs matching error input_name', async () => {
const { nodeA, store } = setupGraphWithStore()
store.lastNodeErrors = {
[String(nodeA.id)]: {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'model' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
await nextTick()
expect(nodeA.inputs[0].hasErrors).toBe(true)
expect(nodeA.inputs[1].hasErrors).toBe(false)
})
it('clears has_errors and slot hasErrors when errors are removed', async () => {
const { nodeA, store } = setupGraphWithStore()
store.lastNodeErrors = {
[String(nodeA.id)]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
await nextTick()
expect(nodeA.has_errors).toBe(true)
expect(nodeA.inputs[1].hasErrors).toBe(true)
store.lastNodeErrors = null
await nextTick()
expect(nodeA.has_errors).toBeFalsy()
expect(nodeA.inputs[1].hasErrors).toBe(false)
})
it('propagates has_errors to parent subgraph node', async () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('InnerNode')
interiorNode.addInput('value', 'INT')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
useGraphNodeManager(graph)
const store = useExecutionErrorStore()
// Error on interior node: execution ID = "50:<interiorNodeId>"
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
store.lastNodeErrors = {
[interiorExecId]: {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'value' }
}
],
dependent_outputs: [],
class_type: 'InnerNode'
}
}
await nextTick()
// Interior node should have the error
expect(interiorNode.has_errors).toBe(true)
expect(interiorNode.inputs[0].hasErrors).toBe(true)
// Parent subgraph node should also be flagged
expect(subgraphNode.has_errors).toBe(true)
})
it('sets has_errors on nodes with missing models', async () => {
const { nodeA, nodeB } = setupGraphWithStore()
const missingModelStore = useMissingModelStore()
missingModelStore.setMissingModels([
{
nodeId: String(nodeA.id),
nodeType: 'CheckpointLoader',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
}
])
await nextTick()
expect(nodeA.has_errors).toBe(true)
expect(nodeB.has_errors).toBeFalsy()
})
it('clears has_errors when missing models are removed', async () => {
const { nodeA } = setupGraphWithStore()
const missingModelStore = useMissingModelStore()
missingModelStore.setMissingModels([
{
nodeId: String(nodeA.id),
nodeType: 'CheckpointLoader',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
}
])
await nextTick()
expect(nodeA.has_errors).toBe(true)
missingModelStore.clearMissingModels()
await nextTick()
expect(nodeA.has_errors).toBeFalsy()
})
it('flags parent subgraph node when interior node has missing model', async () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('CheckpointLoader')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
useGraphNodeManager(graph)
useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
missingModelStore.setMissingModels([
{
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
nodeType: 'CheckpointLoader',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
}
])
await nextTick()
expect(interiorNode.has_errors).toBe(true)
expect(subgraphNode.has_errors).toBe(true)
})
})

View File

@@ -36,13 +36,10 @@ import type {
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: string
originOutputName?: string
}
/**
@@ -83,13 +80,6 @@ export interface SafeWidgetData {
* which differs from the subgraph node's input slot widget name.
*/
slotName?: string
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the
* host subgraph node. Used for missing-model lookups that key by
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
}
export interface VueNodeData {
@@ -214,7 +204,7 @@ function safeWidgetMapper(
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
advanced: widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
@@ -334,21 +324,10 @@ function safeWidgetMapper(
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
// For promoted widgets, name is sourceWidgetName while widget.name
// is the subgraph input slot name — store the slot name for lookups.
slotName: name !== widget.name ? widget.name : undefined,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
'[safeWidgetMapper] Failed to map widget:',
widget.name,
error
)
return {
name: widget.name || 'unknown',
type: widget.type || 'text'
@@ -357,36 +336,6 @@ function safeWidgetMapper(
}
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: string | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
if (link) {
originNodeId = String(link.origin_id)
const originNode = graphRef.getNodeById(link.origin_id)
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
originNodeId,
originOutputName
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
@@ -428,9 +377,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
},
set(v) {
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
},
configurable: true,
enumerable: true
}
})
}
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
@@ -440,30 +387,21 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
},
set(v) {
reactiveInputs.splice(0, reactiveInputs.length, ...v)
},
configurable: true,
enumerable: true
})
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
Object.defineProperty(node, 'outputs', {
get() {
return reactiveOutputs
},
set(v) {
reactiveOutputs.splice(0, reactiveOutputs.length, ...v)
},
configurable: true,
enumerable: true
}
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
node.inputs?.forEach((input, index) => {
const slotInfo = {
index,
linked: input.link != null
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
@@ -491,7 +429,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: reactiveOutputs,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
@@ -516,7 +454,17 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (!nodeRef || !currentData) return
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
// Only extract slot-related data instead of full node re-extraction
const slotMetadata = new Map<string, WidgetSlotMetadata>()
nodeRef.inputs?.forEach((input, index) => {
const slotInfo = {
index,
linked: input.link != null
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
@@ -694,12 +642,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
title: String(propertyEvent.newValue)
})
break
case 'has_errors':
vueNodeData.set(nodeId, {
...currentData,
hasErrors: Boolean(propertyEvent.newValue)
})
break
case 'flags.collapsed':
vueNodeData.set(nodeId, {
...currentData,
@@ -779,20 +721,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(String(slotLinksEvent.nodeId))
}
},
'node:slot-label:changed': (slotLabelEvent) => {
const nodeId = String(slotLabelEvent.nodeId)
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return
// Force shallowReactive to detect the deep property change
// by re-assigning the affected array through the defineProperty setter.
if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
nodeRef.inputs = [...nodeRef.inputs]
}
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
}
}
@@ -807,9 +735,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
case 'node:slot-links:changed':
triggerHandlers['node:slot-links:changed'](event)
break
case 'node:slot-label:changed':
triggerHandlers['node:slot-label:changed'](event)
break
}
// Chain to original handler

View File

@@ -80,12 +80,8 @@ export function showNodeOptions(
}
/**
* Check if the node options menu is currently open
* Hide the node options popover
*/
export function isNodeOptionsOpen(): boolean {
return nodeOptionsInstance?.isOpen.value ?? false
}
interface NodeOptionsInstance {
toggle: (event: Event) => void
show: (event: MouseEvent) => void

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