Compare commits
82 Commits
codex/cove
...
pysssss/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
217d6998e6 | ||
|
|
de205ed92d | ||
|
|
8423ff0215 | ||
|
|
d4bdd2f0f0 | ||
|
|
7392e1804e | ||
|
|
99a8265ec7 | ||
|
|
deca1d8b4a | ||
|
|
4b75012a89 | ||
|
|
ec404b6888 | ||
|
|
3a8d3f358b | ||
|
|
453289d710 | ||
|
|
a2adfe5124 | ||
|
|
ac713c2699 | ||
|
|
57de75748e | ||
|
|
49a90d4e2e | ||
|
|
ec269512e7 | ||
|
|
417f2d7b5c | ||
|
|
c9d02d1661 | ||
|
|
d6c582c399 | ||
|
|
6c2ab519ac | ||
|
|
9dde0c193d | ||
|
|
5da6a0389e | ||
|
|
6dc61c6ddb | ||
|
|
44d066d134 | ||
|
|
2c638d67c3 | ||
|
|
bb39a51d46 | ||
|
|
76abe8eb3f | ||
|
|
9bcfda88f6 | ||
|
|
e7cdfc8c35 | ||
|
|
e97746fd16 | ||
|
|
549200a76c | ||
|
|
1993bf4290 | ||
|
|
927f3f8541 | ||
|
|
979a832845 | ||
|
|
1c1c257f92 | ||
|
|
96c2ae1182 | ||
|
|
2312b213ce | ||
|
|
ce8b107322 | ||
|
|
831813a9db | ||
|
|
6455a49f58 | ||
|
|
64d10da9d7 | ||
|
|
3f84d4f5f2 | ||
|
|
b846cf4171 | ||
|
|
5383e23d24 | ||
|
|
9b8dd27f3d | ||
|
|
a818b7eee8 | ||
|
|
87d0a110cd | ||
|
|
b65da23915 | ||
|
|
07356e3253 | ||
|
|
51182127f3 | ||
|
|
fdfa9882b1 | ||
|
|
e970f5457b | ||
|
|
06d5443de1 | ||
|
|
86219d117d | ||
|
|
88cd848245 | ||
|
|
5dbb560ef0 | ||
|
|
f973626ebc | ||
|
|
e9729ca272 | ||
|
|
c51f963ef2 | ||
|
|
5e23f76642 | ||
|
|
f642384674 | ||
|
|
f80deb9655 | ||
|
|
75af0430fc | ||
|
|
cc74e1dc65 | ||
|
|
989773995a | ||
|
|
8ee6fc6f5f | ||
|
|
d9fd2e8c2f | ||
|
|
414469ed3c | ||
|
|
8e0622e423 | ||
|
|
be251d540a | ||
|
|
6bb1dc972f | ||
|
|
9065b845fc | ||
|
|
61ebcb514d | ||
|
|
b5fd5fd54c | ||
|
|
70c2e5e70e | ||
|
|
8bd12134b2 | ||
|
|
160d7c7a63 | ||
|
|
51efcf0424 | ||
|
|
0975a7ffbc | ||
|
|
8bebdb3021 | ||
|
|
b8207f2647 | ||
|
|
787815eb09 |
2
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped,range \
|
||||
--ignore-errors source,unmapped \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
|
||||
@@ -70,4 +70,39 @@ test.describe('Customer story detail @smoke', () => {
|
||||
'/customers/series-entertainment'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders a Creative Campus story with its education blocks', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/xindi-zhang')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /The tool that expands my art/i
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const nav = page.getByRole('navigation', { name: 'Category filter' })
|
||||
await expect(nav.getByRole('button', { name: 'INTRO' })).toBeVisible()
|
||||
await expect(nav.getByRole('button', { name: 'AT A GLANCE' })).toBeVisible()
|
||||
|
||||
// At a glance block (AtAGlance component) with its spec rows.
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'At a glance' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Program', { exact: true })).toBeVisible()
|
||||
|
||||
// Workflow download button (Download component).
|
||||
await expect(
|
||||
page.getByRole('link', {
|
||||
name: /Download Xindi's style transfer workflow/i
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
// Shared education call to action (EducationCta component).
|
||||
await expect(
|
||||
page.getByRole('link', { name: /Explore the Education Program/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,16 +4,24 @@ import { render } from 'astro:content'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { CustomerStoryEntry } from '../../utils/customers'
|
||||
import ArticleNav from './ArticleNav.vue'
|
||||
import AtAGlance from './content/AtAGlance.astro'
|
||||
import AuthorBio from './content/AuthorBio.astro'
|
||||
import BulletList from './content/BulletList.astro'
|
||||
import Contributors from './content/Contributors.astro'
|
||||
import Download from './content/Download.astro'
|
||||
import EducationCta from './content/EducationCta.astro'
|
||||
import Embed from './content/Embed.astro'
|
||||
import Figure from './content/Figure.astro'
|
||||
import Heading from './content/Heading.astro'
|
||||
import Heading4 from './content/Heading4.astro'
|
||||
import Link from './content/Link.astro'
|
||||
import ListItem from './content/ListItem.astro'
|
||||
import Paragraph from './content/Paragraph.astro'
|
||||
import Quote from './content/Quote.astro'
|
||||
import ReadMore from './content/ReadMore.vue'
|
||||
import Section from './content/Section.astro'
|
||||
import Steps from './content/Steps.astro'
|
||||
import Video from './content/Video.astro'
|
||||
|
||||
interface Props {
|
||||
entry: CustomerStoryEntry
|
||||
@@ -34,18 +42,26 @@ const categories = entry.data.sections.map((section) => ({
|
||||
// components (Section, Figure, ...) are used directly inside the MDX body.
|
||||
const contentComponents = {
|
||||
p: Paragraph,
|
||||
a: Link,
|
||||
h3: Heading,
|
||||
h4: Heading4,
|
||||
ul: BulletList,
|
||||
li: ListItem,
|
||||
Section,
|
||||
Figure,
|
||||
Quote,
|
||||
Contributors,
|
||||
Steps
|
||||
Steps,
|
||||
AtAGlance,
|
||||
AuthorBio,
|
||||
Download,
|
||||
EducationCta,
|
||||
Embed,
|
||||
Video
|
||||
}
|
||||
---
|
||||
|
||||
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<section class="max-w-9xl mx-auto px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<div class="lg:flex lg:gap-16">
|
||||
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
|
||||
<div class="sticky top-32">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
interface Row {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: Row[]
|
||||
}
|
||||
|
||||
const { rows } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class="my-8 overflow-hidden rounded-2xl border border-white/10 bg-site-bg-soft"
|
||||
>
|
||||
<dl class="divide-y divide-white/10">
|
||||
{
|
||||
rows.map((row) => (
|
||||
<div class="flex flex-col gap-1 p-5 sm:flex-row sm:gap-6">
|
||||
<dt class="text-primary-comfy-yellow shrink-0 text-xs font-bold tracking-widest uppercase sm:w-44">
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd class="text-sm/relaxed text-primary-comfy-canvas">{row.value}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
interface Author {
|
||||
name?: string
|
||||
role?: string
|
||||
photo?: string
|
||||
bio?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label?: string
|
||||
people: Author[]
|
||||
}
|
||||
|
||||
const { label, people } = Astro.props
|
||||
const hasBioSlot = Astro.slots.has('default')
|
||||
---
|
||||
|
||||
<div class="mt-12 border-t border-white/10 pt-8">
|
||||
{
|
||||
label && (
|
||||
<span class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<div class="mt-4 space-y-8">
|
||||
{
|
||||
people.map((person) => (
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6">
|
||||
{person.photo && (
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name ?? ''}
|
||||
class="size-20 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{person.name && (
|
||||
<p class="text-sm font-semibold text-primary-comfy-canvas">
|
||||
{person.name}
|
||||
{person.role && (
|
||||
<span class="text-primary-warm-gray"> · {person.role}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{person.bio ? (
|
||||
<p class="mt-2 text-sm/relaxed text-primary-comfy-canvas italic">
|
||||
{person.bio}
|
||||
</p>
|
||||
) : hasBioSlot ? (
|
||||
<p class="mt-2 text-sm/relaxed text-primary-comfy-canvas italic">
|
||||
<slot />
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,7 +14,7 @@ interface Props {
|
||||
const { label, people } = Astro.props
|
||||
---
|
||||
|
||||
<div class="mt-8 rounded-2xl bg-(--site-bg-soft) p-6">
|
||||
<div class="mt-8 rounded-2xl bg-site-bg-soft p-6">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
|
||||
19
apps/website/src/components/customers/content/Download.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
interface Props {
|
||||
href: string
|
||||
label: string
|
||||
newTab?: boolean
|
||||
}
|
||||
|
||||
const { href, label, newTab = false } = Astro.props
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
download={newTab ? undefined : true}
|
||||
target={newTab ? '_blank' : undefined}
|
||||
rel={newTab ? 'noopener noreferrer' : undefined}
|
||||
class="text-primary-comfy-yellow my-4 inline-block text-sm font-semibold underline underline-offset-2 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import Link from './Link.astro'
|
||||
---
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow mt-12 rounded-2xl border-l-4 bg-site-bg-soft p-8"
|
||||
>
|
||||
<p class="text-base/relaxed text-primary-comfy-canvas">
|
||||
<strong class="font-semibold">Teaching with ComfyUI?</strong> The Comfy Education
|
||||
Program is live: educational pricing, classroom cloud accounts on one invoice,
|
||||
<Link href="https://comfy.org/education">Explore the Education Program</Link> or
|
||||
<Link href="https://tally.so/r/Xx97lL">apply to be a part of the Creative
|
||||
Campus program</Link> if you're interested in exploring a deeper partnership with Comfy.
|
||||
</p>
|
||||
</div>
|
||||
22
apps/website/src/components/customers/content/Embed.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
interface Props {
|
||||
src: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const { src, title } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class="my-8 aspect-video overflow-hidden rounded-2xl border border-white/10 bg-black"
|
||||
>
|
||||
<iframe
|
||||
src={src}
|
||||
title={title}
|
||||
class="size-full"
|
||||
loading="lazy"
|
||||
allow="autoplay; fullscreen; picture-in-picture; clipboard-write"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"
|
||||
></iframe>
|
||||
</div>
|
||||
@@ -6,14 +6,15 @@ interface Props {
|
||||
}
|
||||
|
||||
const { src, alt, caption } = Astro.props
|
||||
const hasCaptionSlot = Astro.slots.has('default')
|
||||
---
|
||||
|
||||
<figure class="my-8">
|
||||
<img src={src} alt={alt} class="w-full rounded-2xl object-cover" />
|
||||
{
|
||||
caption && (
|
||||
(hasCaptionSlot || caption) && (
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{caption}
|
||||
{hasCaptionSlot ? <slot /> : caption}
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
---
|
||||
|
||||
<h4 class="mt-6 mb-2 text-base font-semibold text-primary-comfy-canvas">
|
||||
<slot />
|
||||
</h4>
|
||||
15
apps/website/src/components/customers/content/Link.astro
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
interface Props {
|
||||
href: string
|
||||
}
|
||||
|
||||
const { href } = Astro.props
|
||||
const isExternal = /^https?:\/\//.test(href)
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
class="text-primary-comfy-yellow underline underline-offset-2 transition-opacity hover:opacity-80"
|
||||
><slot /></a>
|
||||
@@ -1,16 +1,20 @@
|
||||
---
|
||||
interface Props {
|
||||
name: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
const { name } = Astro.props
|
||||
---
|
||||
|
||||
<blockquote
|
||||
class="border-primary-comfy-yellow my-8 rounded-2xl border-l-4 bg-(--site-bg-soft) p-8"
|
||||
class="border-primary-comfy-yellow my-8 rounded-2xl border-l-4 bg-site-bg-soft p-8"
|
||||
>
|
||||
<p class="text-lg/relaxed font-light text-primary-comfy-canvas italic">
|
||||
"<slot />"
|
||||
</p>
|
||||
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
|
||||
{
|
||||
name && (
|
||||
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
|
||||
)
|
||||
}
|
||||
</blockquote>
|
||||
|
||||
22
apps/website/src/components/customers/content/Video.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import VideoPlayer from '../../common/VideoPlayer.vue'
|
||||
|
||||
interface Props {
|
||||
src: string
|
||||
poster?: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
const { src, poster, caption } = Astro.props
|
||||
---
|
||||
|
||||
<figure class="my-8">
|
||||
<VideoPlayer src={src} poster={poster} client:visible />
|
||||
{
|
||||
caption && (
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
</figure>
|
||||
@@ -63,8 +63,12 @@ function bodySectionIds(body: string): string[] {
|
||||
|
||||
const stories = loadStories()
|
||||
|
||||
it('finds all ten customer stories', () => {
|
||||
expect(stories).toHaveLength(10)
|
||||
it('finds customer stories in every locale', () => {
|
||||
for (const locale of locales) {
|
||||
const prefix = `${locale}/`
|
||||
const inLocale = stories.filter((story) => story.file.startsWith(prefix))
|
||||
expect(inLocale.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
describe.for(stories)('$file', ({ frontmatter, body }) => {
|
||||
|
||||
148
apps/website/src/content/customers/en/golan-levin.mdx
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: "Seeing the world in new ways: how Prof. Golan Levin teaches with ComfyUI at Carnegie Mellon University"
|
||||
category: "CREATIVE CAMPUS SHOWCASE"
|
||||
description: "\"For me, ComfyUI is not just about generative AI. It's an image-processing workstation for completely new kinds of work.\""
|
||||
cover: "https://media.comfy.org/website/customers/golan-levin/cover.png"
|
||||
order: 7
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "WHERE COMFYUI FITS"
|
||||
- id: topic-3
|
||||
label: "IMAGE SYNTHESIS"
|
||||
- id: topic-4
|
||||
label: "IMAGE ANALYSIS"
|
||||
- id: topic-5
|
||||
label: "THE CV LAB"
|
||||
- id: topic-6
|
||||
label: "AT A GLANCE"
|
||||
- id: topic-7
|
||||
label: "STUDENT WORK"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/augmented-hand.jpg" alt="Golan Levin, Augmented Hand Series" caption="Golan Levin, Augmented Hand Series (2014), with Chris Sugrue and Kyle McDonald. Photo: Gerlinde de Geus, courtesy Cinekid." />
|
||||
|
||||
For many people, AI in the arts means image generation. But Levin has spent much of the past two decades teaching artists how computers can interpret, analyze, and measure the visual world. His own artworks have long explored machine perception through real-time computer vision systems, and since 2024 he has increasingly used ComfyUI to teach these principles.
|
||||
|
||||
For Levin, ComfyUI is less an image generator than an image-processing workbench. Students use it to assemble custom workflows for segmentation, tracking, depth estimation, and other forms of computational perception. The result is an environment where artists can experiment directly with research-grade machine learning tools and combine them into systems of their own design.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
### Where does ComfyUI fit in what you're trying to do?
|
||||
|
||||
I'm training creative technologists and technologically literate artists. The typical student in my Creative Coding class is a true hybrid: an art or design undergraduate who is also studying computer science, human-computer interaction, or information science. They have strong visual abilities, strong cultural literacy, and strong algorithmic thinking skills, but my course may be the first time they've had the opportunity to bring those together.
|
||||
|
||||
To me, that means giving students tools they can understand, modify, and remix to make systems of their own design, rather than treating creative software as a fixed given. That's why I'm such a proponent of community-driven, open-source software development toolkits for the arts.
|
||||
|
||||
<Quote>ComfyUI is the first AI tool I've found with both a low floor and a high ceiling. It's incredibly powerful and flexible, in terms of allowing artists to design their own AI workflows with the latest cutting-edge algorithms. But it also leapfrogs the headaches of coping with quirky GitHub repos and obsolete Colab notebooks.</Quote>
|
||||
|
||||
### What were students stuck on before?
|
||||
|
||||
Students often found themselves caught between two worlds. On one side were commercial AI tools that produced impressive results but offered limited opportunities for customization. On the other side were research projects published by universities and laboratories, where the software was often difficult to install, poorly documented, or already out of date.
|
||||
|
||||
ComfyUI bridges that gap. It gives students access to state-of-the-art algorithms through an environment they can understand, modify, and extend. Instead of adapting their ideas to fit a tool's built-in workflow, they can build workflows that reflect their own interests and questions.
|
||||
|
||||
<Quote>My students are explorers. They're artists who can write code and want to build systems that haven't existed before.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
### The first exercise: a p5.js sketch driving image synthesis, inside ComfyUI
|
||||
|
||||
In one of Levin's introductory exercises — students' first exposure to the ComfyUI environment — they write a simple p5.js sketch directly inside ComfyUI, then use the shapes they draw, plus a text prompt, to guide a Stable Diffusion image synthesis. They document the pairs of images it produces: their JavaScript canvas drawing on the left, and the AI synthesis on the right. Having already spent a few weeks fighting to get nuance out of p5.js, they're tickled to get these results from simple shapes, and they learn a lot about how Stable Diffusion works.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/p5-landscape.png" alt="p5.js ellipses guiding a Stable Diffusion synthesis" caption={`Some wide ellipses drawn in p5.js (left) guiding a Stable Diffusion synthesis with the prompt "rolling hills, foggy day" (right).`} />
|
||||
|
||||
It runs on a node-based canvas that art students pick up quickly, because it works like tools they already know.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/p5-workflow.png" alt="Template ComfyUI workflow using the ComfyUI-p5js-node" caption="The template ComfyUI workflow students receive. It uses the custom ComfyUI-p5js-node by Ben Fox. From Levin's 60-212 course repo." />
|
||||
|
||||
*Try it yourself: [json file](https://media.comfy.org/website/customers/golan-levin/p5-in-comfy.json) (Comfy Local only)*
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4">
|
||||
|
||||
### Many artists start off by using ComfyUI for generative AI. You use it differently.
|
||||
|
||||
Maybe so. I'm interested in AI as a framework for expanded perception, so a lot of how I've used machine learning and computer vision over the past 25 years has been for image analysis, rather than image synthesis. Essentially, I use computer vision to understand video and images, and then use the information I extract to create new kinds of interactive experiences. In the classroom, I use ComfyUI to help teach students how to "see like a machine." So I have students use ComfyUI as a framework for analyzing images, not just generating them. For example, I ask them to take an input image and then use AI to compute new ones from it, such as a semantic segmentation ("which pixels belong to the elephant?") and a monocular depth estimate ("how far away is each pixel?"). Then the students build an interactive piece that interprets the original image, but using five channels of information instead of three: the usual red, green, and blue, plus depth, plus segmentation. In my demo project, the segmentation colors the elephant pink, and the background pixels change size based on how far away the AI thinks they are.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/depth-segmentation.png" alt="Semantic segmentation and monocular depth analysis in ComfyUI" caption={`An input image analyzed inside ComfyUI: semantic segmentation and monocular depth, feeding a five-channel "Custom Pixel" exercise. From Levin's 60-212 course repo.`} />
|
||||
|
||||
*Try it yourself: [demo project](https://editor.p5js.org/golan/sketches/-_cFmLtoP) · [lesson plan & workflow](https://github.com/golanlevin/60-212/tree/main/lectures/comfy/image_analysis#3-segment-the-image-with-ai)*
|
||||
|
||||
*Workflow files: download the [.json](https://media.comfy.org/website/customers/golan-levin/image-analysis-workflow.json), or the [.png with the workflow embedded in its metadata](https://media.comfy.org/website/customers/golan-levin/image-analysis-workflow.png) (drag it into ComfyUI to load the graph).*
|
||||
|
||||
<Quote>I want students to understand that AI is not only a tool for generating images. It's also a tool for perception, measurement, and analysis.</Quote>
|
||||
|
||||
The computer vision tools built for this are usually aimed at developers and enterprises. They assume an engineering workflow. I wanted my art students to get to segmentation, depth, and tracking inside an environment they already think in, without standing up a production pipeline first.
|
||||
|
||||
### What changed once ComfyUI was in the workflow?
|
||||
|
||||
Two things. First, it runs on a node-based canvas that many art students already understand from environments like TouchDesigner, Max/MSP, and Grasshopper — except it runs in a browser and it's for AI. As a result, students can focus on the ideas behind machine learning workflows instead of first learning an entirely new interaction paradigm. Second, it collapses the distance between a research lab and a classroom.
|
||||
|
||||
<Quote>There's a fast pipeline from the lab to your classroom. It's become commonplace for enthusiasts to convert AI research code into Comfy nodes, often within days of their release.</Quote>
|
||||
|
||||
One of the most remarkable things about the ComfyUI ecosystem is how quickly new research becomes accessible. A computer-vision paper might appear at CVPR or ICCV, and within days someone in the community has wrapped it as a reusable ComfyUI node. For educators, that dramatically shortens the distance between a research laboratory and a classroom. Instead of spending weeks reconstructing an experimental software environment, students can begin exploring the underlying ideas almost immediately.
|
||||
|
||||
The cloud matters for accessibility and equity, too. Most of my students don't have big GPU workstations, and I don't want their access to advanced tools to depend on the caliber of their personal hardware. Cloud platforms make it possible for everyone in a class to work in the same environment, with the same models, regardless of what laptop they happen to own.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5">
|
||||
|
||||
### In your advanced Experimental Capture studio, you've turned ComfyUI into a computer-vision lab.
|
||||
|
||||
The goal of this course is to use technologies to help us see the world in new ways: the very fast, the very slow, the very small, the very large, and in spectra beyond human perception, like IR and UV. It's about cultivating the students' curiosity. But the limitation in this studio is hardware. We have one camera that can shoot 100,000 frames per second, one high-resolution thermal camera, and access to one electron microscope — but we've got 20 students. We can't always queue them all up for one exotic camera; it's a bottleneck.
|
||||
|
||||
<Quote>I need to give them tools they can use to see the world in new ways, that they can all run on their own hardware.</Quote>
|
||||
|
||||
ComfyUI allows students to use their own phones to ask questions they couldn't before. So they duct-tape their phone camera to a window, record the world going by, and then track things with the LocateAnything and SAM3 ComfyUI nodes, producing data files that distill what the camera saw. ComfyUI becomes a laboratory for computational observation, allowing students to ask questions of images and videos that would otherwise be difficult to formulate.
|
||||
|
||||
### You also wrap niche research libraries into ComfyUI nodes yourself.
|
||||
|
||||
One of the remarkable things about the ComfyUI ecosystem is the community that forms around it. There's a hero of mine on GitHub, Kijai, who keeps taking libraries from computer vision labs and turning them into ComfyUI nodes. He's made hundreds, probably doing more than anyone to turn lab-grade models into tools anyone can use. My students and I are starting to do this too. Niche is the right word. Right now I have my eye on a zoology lab that released a good library for tracking insect legs. The people who made it probably don't even know what ComfyUI is. But I want that algorithm for my students, and there's gotta be someone else out there who would love it too.
|
||||
|
||||
### What's the bigger pattern you see in your students?
|
||||
|
||||
My students are explorers. They see a new tool and immediately start wondering what else it could be connected to. They explore: I should be able to combine this thing with that other thing. That's the whole reason to give them a system they can build on, instead of a tool that tells them what they're allowed to do.
|
||||
|
||||
<Quote>We're educating students who want to invent new forms and experiences, not just reproduce existing ones.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="At a glance">
|
||||
|
||||
<AtAGlance rows={[
|
||||
{ label: "Courses", value: "Intermediate Studio: Creative Coding (60-212); Experimental Capture (co-taught with Nica Ross)" },
|
||||
{ label: "Level", value: "Undergraduate (sophomore studio + advanced studio, ~20 students)" },
|
||||
{ label: "Setup", value: "Cloud-hosted ComfyUI; runs on students' own laptops" },
|
||||
{ label: "Core techniques", value: "p5.js-driven synthesis; semantic segmentation; monocular depth; LocateAnything + SAM3 tracking" },
|
||||
{ label: "Distinctive angle", value: "ComfyUI as computer-vision lab, not just a generator" }
|
||||
]} />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-7" title="Student work">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-tippi.png" alt="Student work by Tippi Li" caption={`"nuclear explosion" by Tippi Li`} />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-xiao.png" alt="Student work by Xiao Yuan" caption={`"Chinese painting, plants, ink, transparent" by Xiao Yuan`} />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-aarnav.png" alt="Student work by Aarnav Patel" caption={`"NASA space image of a new cosmos detected" by Aarnav Patel`} />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-jeffrey.png" alt="Student work by Jeffrey Wang" caption={`"Dream Scene Painting" by Jeffrey Wang`} />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-kai.gif" alt="Student work by Kai Okorodudu" caption={`"Electric hand" by Kai Okorodudu`} />
|
||||
|
||||
</Section>
|
||||
|
||||
<AuthorBio people={[{ name: "Golan Levin", photo: "https://media.comfy.org/website/customers/golan-levin/author-golan.png" }]}>Golan Levin is a Professor of Computational Art at Carnegie Mellon University and co-author, with Tega Brain, of "Code as Creative Medium." This fall he is teaching two CMU courses with ComfyUI: "Intermediate Studio: Creative Coding" (60-212), built around p5.js, and "Experimental Capture," a studio in computational and expanded photography he co-teaches with Nica Ross. Levin is also widely known for interactive art installations driven by real-time machine vision, such as his [Augmented Hand Series](https://flong.com/archive/projects/augmented-hand-series/index.html) (2014), created with Kyle McDonald and Christine Sugrue.</AuthorBio>
|
||||
|
||||
<EducationCta />
|
||||
149
apps/website/src/content/customers/en/ina-conradi.mdx
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: "From Node Graph to Building Façade: how Ina Conradi's NTU students compose architectural-scale public art with ComfyUI"
|
||||
category: "CREATIVE CAMPUS SHOWCASE"
|
||||
description: "At NTU in Singapore, Ina Conradi's students compose 90-second films for building-sized LED walls that prompt boxes cannot render but ComfyUI can, work that travels from campus to Hangzhou's West Lake Media Façade and a million viewers a day."
|
||||
cover: "https://media.comfy.org/website/customers/ina-conradi/cover.png"
|
||||
order: 6
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "THE CANVAS"
|
||||
- id: topic-3
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-4
|
||||
label: "THE 2026 BRIEF"
|
||||
- id: topic-5
|
||||
label: "STUDENT WORK"
|
||||
- id: topic-6
|
||||
label: "PUBLIC SCREENS"
|
||||
- id: topic-7
|
||||
label: "AT A GLANCE"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig1-quantum-logos.jpg" alt="Quantum Logos (Vision Serpent) on the Media Art Nexus LED screen" caption="Quantum Logos (Vision Serpent), Mark Chavez and Ina Conradi. Experimental animation, Media Art Nexus LED screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
### Building an AI art pipeline from studio to screen
|
||||
|
||||
Ina Conradi has written and taught NTU's two AI courses since 2022: DM2012, Explorations in AI-Generated Art (undergraduate), and AP7055, Art in the Age of the Creative Machine (postgraduate). Each runs about 30 students a semester. Working alongside her on the production pipeline is Mark Chavez, an animation veteran (DreamWorks, Rhythm & Hues) and early ComfyUI adopter. Together they co-curate the platform those courses build for: a 15-metre by 2-metre LED wall installed at NTU's North Spine in 2016 as Media Art Nexus, now run by NTU Museum as NTU Index and still taking new work each semester.
|
||||
|
||||
Work from the wall has travelled to giant public screens in Singapore (Ten Square), Hangzhou, and Chongqing, and into collaborations with Bauhaus University, the University of the Arts Berlin, and the Elbphilharmonie in Hamburg.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig2-nature-sanctuary.jpg" alt="Nature Sanctuary 3000 on the West Lake Media Façade" caption="Nature Sanctuary 3000, Sowmya Sreeshna. Experimental animation, West Lake Media Façade (170 m × 18 m), Hangzhou, China. Photo: Limpid Art." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
### Ina, your students don't make films for laptops. Why screens the size of buildings?
|
||||
|
||||
Because the format teaches. A 90-second film at 6K across, in an 8:1 panorama, cannot be a lucky prompt. It has to be composed. And the screens are real: the strongest student work plays on NTU Index, our 15-metre by 2-metre wall on campus, and travels to urban façades in China and Europe through the City Digital Skin Art Festival (CDSA). When a student knows a million people a day might walk past their film in Hangzhou, the conversation about craft changes.
|
||||
|
||||
### Mark, describe the canvas.
|
||||
|
||||
Basically, we do compositions for really large media LED screens in Singapore and China. We have a screen that's eight by one in Singapore. It's 5,888 by 768 pixels.Students create images in the class, usually about 6K resolution across, a long landscape panorama. The output is 90-second short films. Two minutes, 90 seconds. I'm not going to change. I love that format because it's manageable within the class.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
### That format breaks most AI tools. What happened?
|
||||
|
||||
Runway is one of the tools we use, on an educational plan that has worked well for the school. The constraint we hit is format: Runway works in 16:9, and our 6K panoramas fall outside that. Last semester Midjourney gave us trouble at our resolution, and the upscale was difficult. So we're expanding the palette and bringing in ComfyUI alongside what we already run.
|
||||
|
||||
<Quote>ComfyUI gave the cleanest results. Upscaling to 8K at a 1-by-8 panorama after composition is genuinely hard, and ComfyUI is the only pipeline that lets students compose image, motion, and upscale models together.</Quote>
|
||||
|
||||
### What about the budget side?
|
||||
|
||||
Budget will keep being an issue. The school supports us well, but new tools arrive every semester and students want to try them and build their own pipelines. Monthly per-seat licenses don't fit how a semester runs. Running ComfyUI locally is hard for students: most laptops don't have a GPU with enough VRAM, and getting it working takes real trial and error. Many would rather work from home, but the hardware blocks them, so they come into the lab. Others used Comfy Cloud. It charges a subscription, but it still cost significantly less than the prepaid tools, and the results were better. Either way they're chasing the same thing: a pipeline they can keep working on, wherever they are.
|
||||
|
||||
### Ina, you insist these courses are not about tools. What are they about?
|
||||
|
||||
My class isn’t about teaching a single tool. It is the responsive system students interact with across platforms, directing, critiquing, and shaping outputs through ongoing dialogue. ComfyUI fits this: a node graph is an argument you can read, question, and rebuild. A prompt box is not. Singaporean students become technically fluent very fast. What they need from arts education is the language to question what they're making, not just the skill to make it.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4">
|
||||
|
||||
### Ina, the 2026 brief sends students to the ocean. What's the assignment?
|
||||
|
||||
The project is The Liquid Commons: Bringing Ocean Science into Global Media Architecture, developed in dialogue with OceanX, the organization behind the OceanXplorer research vessel, and the CDSA 2026 festival theme. The brief is strict: do not illustrate the science, translate it. The 2026 cohort is the first to build these films in ComfyUI with Topaz upscaling, working towards two real deadlines at once. Their pieces are in consideration for the OceanX Summit in Singapore this October, and jury-selected works will screen during the City Digital Skin Art Festival on Hangzhou's West Lake Media Façade: 170 metres by 18 metres, around a million viewers a day.
|
||||
|
||||
<Quote>The delivery spec tells you why the tooling matters: final exports at 5,888 × 768 px, 8K where required. That's the brief no prompt box can fill.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5">
|
||||
|
||||
### Mark, what does the student work look like?
|
||||
|
||||
About eight students have built their films through Comfy so far, and they're all pretty cool. They're surprising and insightful, because they're not limited by game-engine graphics. One student was the standout: he tried every model in Comfy and pushed the furthest.
|
||||
|
||||
Three projects from the 2026 cohort show the range.
|
||||
|
||||
**The Tao of Water** (Wang Zilin, AP7055) reads the ocean through the Tao Te Ching, a three-part arc from water to marine plant to void and back to origin. The pipeline moves from Pinterest research boards through Midjourney into ComfyUI, where Nano Banana extends single frames into seamless panoramas and Kling 3.0 animates first-frame-to-last-frame motion at full 5,888-pixel width, before a Premiere edit.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig3-tao-of-water.jpg" alt="The Tao of Water on the NTU Index screen" caption="The Tao of Water, Wang Zilin. Experimental animation, NTU Index screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
**microscophony** (Jiin Ko, AP7055) fuses *microscopic* and *micropolyphony*, Ligeti's term for dense webs of voices that blur into a single cloud of sound. The source is based on OceanX microscope footage of deep-sea microbes, translated into the visual logic of graphic notation (Ligeti, Xenakis, Cardew) so the panorama becomes a listening score. Images ran through Midjourney and Nano Banana, video through ComfyUI with Vidu Q2, sound design in Ableton Live, with distinct sonic textures mapped to distinct visual forms.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig4-microscophony.jpg" alt="microscophony on the NTU Index screen" caption="microscophony, Jiin Ko. Experimental animation, NTU Index screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
**GO! PLASTIC** (Jianwei Hoe, DM2012) is an ocean-plastics piece whose production log reads like studio paperwork, not prompt history. It opens with a one-line art direction (every project states its idea in a single line, with embedded irony, before a frame is generated), then walks through model selection, a platform-versus-local cost comparison (cost per clip and per scene on an RTX 5090 against a cloud B200, render times included), and a shot-by-shot sheet pairing every source image with its full prompt and settings.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig5-go-plastic.jpg" alt="GO! PLASTIC on the NTU Index screen" caption="GO! PLASTIC, Hoe Jianwei. Experimental animation, NTU Index screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6">
|
||||
|
||||
### Ina, where does the work go after the classroom?
|
||||
|
||||
Onto public screens, and into juried international competition. The City Digital Skin Art Festival was established in 2023, initiated by the China Academy of Art's School of Sculpture and Public Art and co-curated with Public Art Lab Berlin, MEET Digital Culture Center Milan, and NTU ADM, with a network of more than 29 art academies across China and Europe.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig6-cdsa-awards.jpg" alt="CDSA Festival award winners on the West Lake Media Façade" caption="CDSA Festival award winners, curators, and organizers. West Lake Media Façade (170 m × 18 m), Hangzhou, China. Photo: Limpid Art. Asia's largest high-definition outdoor screen" />
|
||||
|
||||
The 2024 edition ran across 11 LED screens in 9 cities in 5 countries and reached over 100 million views. The 2025–2026 edition, themed Memory Coexistence, drew over 200 international submissions, with the top 40 selected by a 16-member jury. I curate the Singapore programme across NTU Index and the Ten Square landmark façade. A student composing at 6K in our classroom is composing for that circuit.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig7-crispr.jpg" alt="Crispr on the Ten Square Landmark Façade" caption="Crispr, Lee Chaewon. Experimental animation, Ten Square Landmark Façade (21.2 m × 14.4 m), Singapore. Photo: Quek Jia Liang." />
|
||||
|
||||
NTU ADM students have already won at this level. At CDSA 2025, the majority of the top awards went to students from these two courses: Gold (Sun Yutong, *Echoes of Her*), Silver (Tan Yu Yan Cheerie, *Eternal Flux*), Bronze (Shah Pranjal Kirti, *Mumbai Miniatures*), Business (Ong Sze Ching, *Nuwa*), and Creative (Leah Chakola, *Caravan of Memory*). The courses have also taken NTU to Ars Electronica in Linz as the only Singapore campus partner since 2023, first with *Butterfly's Dreams* (2023, "Who Owns the Truth?") and then in 2025 with *Beyond the Screen*, a joint exhibition with the China Academy of Art and Bauhaus-Universität Weimar.
|
||||
|
||||
### Mark, you spent a decade at DreamWorks. Why does this tool fit art students?
|
||||
|
||||
I come from visual effects. I was at DreamWorks about ten years, then Rhythm & Hues, then the game industry and big interactive installations. I'm not a programmer, so I love ComfyUI.
|
||||
|
||||
<Quote>Everybody I know who does graphics now is using this, because it's so adaptable. Sometimes we use Comfy as just a back end. That's what everybody's doing.</Quote>
|
||||
|
||||
We got this large 15-metre by 2-metre screen in an art installation at the university, and it let us explore media and different techniques. We found students weren't technical enough to handle TouchDesigner, so they just started making movies. Then I started playing with AI, and now everything's AI. What I'd love next is templates custom-made for these screens.
|
||||
|
||||
Take *Echoes, Whispers and Memories*, the piece Ina and I made. We don't use Comfy to spit out finished illustrations. We build workflows that keep recomposing the image, breaking it apart and putting it back together so it evolves on screen, which is the whole point: entropy, memory, things falling apart and reforming. Then we push those outputs into real-time and projection systems for big rooms, places like Ars Electronica's Deep Space 8K and MEET in Milan.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig8-echoes.jpg" alt="Echoes, Whispers and Memories at Ars Electronica Deep Space 8K" caption="Echoes, Whispers and Memories, Mark Chavez and Ina Conradi. AI-generated immersive installation using ComfyUI, Deep Space 8K, Ars Electronica, Linz, Austria. Photo: Wolfgang Simlinger." />
|
||||
|
||||
### The signal from the industry
|
||||
|
||||
<Quote>I hear from my students looking for internships or jobs that the first question over there is, "Do you know Comfy?" Because they want to hire kids who know the pipeline.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-7" title="At a glance">
|
||||
|
||||
<AtAGlance rows={[
|
||||
{ label: "Institution", value: "Nanyang Technological University, School of Art, Design and Media (Singapore)" },
|
||||
{ label: "Courses", value: "DM2012: Explorations in AI-Generated Art (UG) and AP7055: Art in the Age of the Creative Machine (PG), written and taught by Ina Conradi since 2022; ~30 students/semester" },
|
||||
{ label: "The canvas", value: "6K-wide, 8:1 LED walls in Singapore and China; NTU Index wall on campus (15 m × 2 m, 5,888 × 768 px)" },
|
||||
{ label: "Core technique", value: "ComfyUI compositions with Topaz upscaling for ultra-wide panoramic output; production logs with per-clip cost and prompt sheets" },
|
||||
{ label: "Why Comfy won", value: "Hosted tools locked to 16:9; upscaling to 8K at a 1-by-8 panorama after composition needed a multi-model pipeline; per-seat monthly renewals didn't fit the semester" }
|
||||
]} />
|
||||
|
||||
</Section>
|
||||
|
||||
<AuthorBio label="About the authors" people={[
|
||||
{ name: "Ina Conradi", photo: "https://media.comfy.org/website/customers/ina-conradi/author-ina.jpg", bio: `Ina Conradi is an artist and curator based between Singapore and Los Angeles. She is founding faculty at NTU's School of Art, Design and Media (est. 2005), where she has written and taught the school's AI courses since 2022. Her film Moirai: Thread of Life won Best in Show at the SIGGRAPH Asia Computer Animation Festival 2023, a first for Singapore.` },
|
||||
{ name: "Mark Chavez", photo: "https://media.comfy.org/website/customers/ina-conradi/author-mark.jpg", bio: `Mark Chavez is an animator, director, and founding faculty at NTU's School of Art, Design and Media in Singapore. After a decade at DreamWorks Animation and visual effects work at the original Rhythm & Hues Studios, he established NTU's Digital Animation area (2005) and an animation research think-tank funded by Singapore's National Research Foundation and the Media Development Authority.` }
|
||||
]} />
|
||||
|
||||
<EducationCta />
|
||||
138
apps/website/src/content/customers/en/kathy-smith.mdx
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: "Built for AI: Prof. Kathy Smith on USC's Expanded Animation program and ComfyUI"
|
||||
category: "CREATIVE CAMPUS SHOWCASE"
|
||||
description: "Inside the experimental USC MFA that put AI into animation pedagogy from day one, and the student pipelines it produced."
|
||||
cover: "https://media.comfy.org/website/customers/kathy-smith/cover.png"
|
||||
order: 8
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "THE PROGRAM"
|
||||
- id: topic-2
|
||||
label: "TEACHING WITH AI"
|
||||
- id: topic-3
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-4
|
||||
label: "STUDENT WORK"
|
||||
- id: topic-5
|
||||
label: "AT A GLANCE"
|
||||
- id: topic-6
|
||||
label: "WHAT'S NEXT"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
### You built the Expanded Animation program in 2022 specifically to put AI into the curriculum from day one. What did you see that other programs missed?
|
||||
|
||||
We created Expanded Animation: Research and Practice specifically to focus on creative process and AI as part of how animators learn to make work. The thesis at the start was that AI was going to reshape animation as a medium, and the question was not whether to teach it but how to embed it in the curriculum so students learn it as part of their creative process rather than as a separate technical specialty.
|
||||
|
||||
USC's School of Cinematic Arts already had decades of cinematic storytelling tradition. What we did with XA was put AI inside that tradition. The conceptual thinking, the storytelling, the cinematic history come first. AI is one of the many tools available to them, sitting alongside hand-drawing, paint, 3D, and live-action footage. Students do not learn AI in one course and animation in another. They learn both side by side.
|
||||
|
||||
The students who arrive at the program are usually self-selected for it. They show up technically fluent, with their own GPU-equipped laptops. What we offer them is the storytelling, the cinematic history, and the conceptual frame. They bring the technical nimbleness.
|
||||
|
||||
<Quote>They are way ahead of the curve. They are ahead of the faculty in the way they work, technically, but not so much artistically. That is what we are there to deliver.</Quote>
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/kathy-smith/usc-campus.png" alt="USC School of Cinematic Arts" caption="USC School of Cinematic Arts. Source: USC Today" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
### How do you actually structure an AI assignment? Walk us through one.
|
||||
|
||||
In my Animation, Dreams, and Consciousness class, I have the students document their dreams and then use the dream as the source. Some of them draw, some of them write. The dream becomes the prompt, and they generate the image and emotion of the dream. I love when you get six fingers and weird stuff happening in the algorithms. Our human perception in dreams is often doing the same thing. Therefore, AI is evolving and dreaming with us.
|
||||
|
||||
That structure is deliberate. The students are not asking the model to produce work for them. They are using it as a layer of their process, alongside hand-drawing and painting and 3D rendering and live-action footage. The work that comes out is theirs because the creative decisions are theirs. The tool just gives them new ways to reach what they were trying to make.
|
||||
|
||||
There is a fear factor around AI, and I understand it. There has been a lot of scraping of artists' work, and that conversation is real and is going to take time to resolve. But I have been working with AI conceptually since 1998, and the way I describe the data sets to my students is that they are a repository of all of our creation. It is like the collective unconscious of the human mind. Artists have always drawn from everything around them.
|
||||
|
||||
<Quote>What really matters is what the artist does with it, *intentionality*.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
### Why does ComfyUI specifically fit the way your students work?
|
||||
|
||||
It is the node-based system. Those who have done Houdini feel very at home in Comfy. You can work with the prompts, but it is very visual. That is what they are used to. They are not asking a black box for an output. They are building a workflow.
|
||||
|
||||
And it stays in its lane. The students are not using Comfy to make AI art. They are using Comfy as one node graph alongside Blender, hand-drawn frames, paint, and live-action footage. The reason it fits is that it does not try to be the whole pipeline. It is one stage of a creative practice that still has cinema at its core.
|
||||
|
||||
What also matters is that Comfy is open and inspectable. The students can see what the model is doing at each step, fork a workflow, swap a sampler, drop in a custom node, and share what they built with the next cohort. That is closer to how an animation studio tradition has always behaved, with techniques passed along and improved rather than hidden behind a paywall.
|
||||
|
||||
They also work across whatever hardware they have: Comfy Cloud at home and when they are mobile, the portable version on their personal laptops, and the research computer in my office for the high-end runs. Animation students do not sit in one cubicle for a thesis project. They work everywhere.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4">
|
||||
|
||||
### Tell us about the work coming out of the program.
|
||||
|
||||
The pattern shows up across the cohort: the AI is in service of the cinematic story, not in place of it. Three students walked us through how Comfy actually sits inside their pipelines.
|
||||
|
||||
#### Sijia Zheng — Ori & Kiddo
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/kathy-smith/ori-kiddo.png" alt="Sijia Zheng, Ori & Kiddo" />
|
||||
|
||||
**What Comfy enabled:** an oil-paint, brush-stroke dream look that "other AI tools cannot possibly make," held consistent across shots with IP-Adapter style transfer and a custom LoRA.
|
||||
|
||||
*Ori & Kiddo* follows two ghosts who, after the universe dies, search for old human memories, rediscover love, and reverse the universe back into being. Most of the film is hand-drawn 2D. Comfy enters in the dream sequences, where the ghost Kiddo dreams of past lives and the look had to be unlike anything else in the film. Sijia drew stylized reference images first, then used them as the style reference over video clips through an IP-Adapter workflow to produce long, oil-painted, brush-stroke sequences. The same control shows up in shots where Sijia appears on screen: real footage, masked in Comfy to change the haircut and swap the background. For a look that has to stay locked, Sijia trains a LoRA and runs it through Comfy.
|
||||
|
||||
Sijia found Comfy in early 2025 while hunting for a style-transfer tool that Midjourney and DALL-E could not deliver, testing it on a stylized animated-film-look conversion.
|
||||
|
||||
<Quote>It totally broke my mind. Most of the time, I think I'll just stand on other people's shoulders. The workflows are already pretty amazing, and I'll base on the workflows and add something that I want.</Quote>
|
||||
|
||||
Since *Ori & Kiddo*, Sijia has taken the same Comfy-anchored workflow into professional commercial video work, on deadlines as tight as four days.
|
||||
|
||||
#### Ion Yunyang Li — L1LY
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/kathy-smith/l1ly.gif" alt="Ion Yunyang Li, L1LY" />
|
||||
|
||||
**What Comfy enabled:** a repeatable multi-step pipeline that drops the filmmaker into a photorealistic world, because "a sequence of a prompt is not the only thing you need."
|
||||
|
||||
Ion taught himself ComfyUI in early 2025, from tutorials in the generative-AI community, and built his most distinctive Comfy work in a body-and-environment project: start from 3D-model stills, convert them to a pencil-sketch style so the model would not over-study the original 3D aesthetic, generate photorealistic frames from the sketches, build character T-poses, composite himself into the scene, and animate the stills with a video model.
|
||||
|
||||
<Quote>A sequence of a prompt is not the only thing you need. You need many different settings, and it is very hard to redo those settings every time.</Quote>
|
||||
|
||||
What he values as much as the pipeline is where it can run: the same Comfy setup moves across a workstation in his school cubicle, a remote session from his apartment laptop, and fully cloud-based instances, depending on where he is.
|
||||
|
||||
#### Sihan Wu — Scary Coaster
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/kathy-smith/scary-coaster.gif" alt="Sihan Wu, Scary Coaster" />
|
||||
|
||||
**What Comfy enabled:** roughly 100 hand-drawn keyframes carried through a single workflow so a two-to-three-minute film stays visually consistent, on his first-ever AI project.
|
||||
|
||||
*Scary Coaster* (December 2024) was Sihan's first project ever made with AI. Coming from a digital-media and game-development undergrad, Sihan joined Professor Smith's Expanded Animation class and wanted something more controllable than the prompt-only tools on offer. The workflow he built: draw roughly 100 rough keyframes by hand, run them through Comfy to find a stylized Chinese-horror look, pick the favorite, then generate the in-betweens to produce the full sequence.
|
||||
|
||||
<Quote>I want to have a more controllable flow. I don't want to just use prompts and generate random images. I just use one workflow to create the whole two or three minutes, and I can make everything look very consistent.</Quote>
|
||||
|
||||
Sihan is honest that the on-ramp was steep: learning from the official ComfyUI GitHub workflows, combining them, and debugging Python environments along the way. His ask was specific: an official, beginner-to-advanced tutorial series. And his view on where AI should head next was equally specific: aim it at "the very time-consuming but not that creative process, like creating in-betweens," and leave the creative decisions to the artist.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="At a glance">
|
||||
|
||||
<AtAGlance rows={[
|
||||
{ label: "Program", value: "Expanded Animation: Research + Practice (XA), USC School of Cinematic Arts" },
|
||||
{ label: "Founded", value: "2022, AI embedded in the MFA curriculum from day one" },
|
||||
{ label: "Setup", value: "Students' own GPU laptops + Comfy Cloud + lab research machine" },
|
||||
{ label: "Core techniques", value: "IP-Adapter style transfer, custom LoRAs, masked compositing, keyframe-to-in-between pipelines" },
|
||||
{ label: "Outcomes", value: "Amazing student works from Sihan, and Ion, Sijia" }
|
||||
]} />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6">
|
||||
|
||||
### What excites you about where this is going?
|
||||
|
||||
I have a philosophy that everyone is an artist. They just forget that they are an artist. Creativity drives everything, and the tools we are getting now make it possible for more people to find that capacity in themselves. ComfyUI, because it is node-based and visual and open, gives non-programmers a way forward that is honest about how the model works. It does not pretend the AI is doing something magical. It shows the artist what is happening at each step.
|
||||
|
||||
The two basic rights of human life are health and education. The work Comfy is doing on the education side is touching something integral. The students who came through XA are already extending the work in directions the program did not anticipate, and the next generation of educators and students will keep doing the same.
|
||||
|
||||
<Quote>Everyone is an artist. They just forget that they are an artist.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<AuthorBio people={[{ name: "Kathy Smith", photo: "https://media.comfy.org/website/customers/kathy-smith/kathy-smith.jpg", bio: `Kathy Smith is Professor of Cinematic Arts at USC's School of Cinematic Arts and inaugural director (2022-2023) of Expanded Animation: Research + Practice (XA), the experimental MFA program she helped found in 2022 to integrate AI into animation pedagogy from the first day of the degree. To date she is the longest-serving chair of combined USC animation programs and has been exploring concepts of AI in her creative practice since 1998.` }]} />
|
||||
|
||||
<EducationCta />
|
||||
56
apps/website/src/content/customers/en/ual-cci.mdx
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: "Comfy and UAL's Creative Computing Institute Announce Creative Campus Partnership"
|
||||
category: "CREATIVE CAMPUS PARTNERSHIP"
|
||||
description: "Comfy announces Creative Campus Partnership to support teaching and research across UAL CCI's masters, PhD, and industry programmes"
|
||||
cover: "https://media.comfy.org/website/customers/ual-cci/cover.png"
|
||||
order: 9
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "WHAT CCI DOES"
|
||||
- id: topic-3
|
||||
label: "THE PARTNERSHIP"
|
||||
- id: topic-4
|
||||
label: "ABOUT CCI"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Comfy Org, the team behind ComfyUI, the open-source node-based interface for generative AI, and the Creative Computing Institute (CCI) at University of the Arts London today announced a Creative Campus partnership, making CCI a founding partner of the [Comfy Education Initiative](https://comfy.org/education).
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
CCI already runs ComfyUI at every level of the institute. On the Applied Machine Learning for Creatives masters course, students build image, video, audio, and text workflows, train their own models, and construct interactive pipelines. PhD researchers use Comfy for fine-tuning, custom datasets, and custom node development. The institute also uses ComfyUI in industry training, where its node-based interface gives non-technical collaborators a way into generative AI that code alone does not.
|
||||
|
||||
<Quote name="Prof Mick Grierson, Research Leader, UAL Creative Computing Institute">ComfyUI has become part of how we teach, research, and work with industry. It is one of the few generative AI environments where the workflows our students build are portable, inspectable, and forkable, and that open-source foundation is exactly what a university should be teaching on.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
Through the partnership, CCI educators and students gain access to classroom licenses with central billing & administration, educational discounts, early access to upcoming team features, a dedicated educator community with direct support from the Comfy team, and a voice in shaping the future of the education program.
|
||||
|
||||
Creative Campus partnerships are the deepest tier of the program: a direct, ongoing collaboration in which an institution works hand in hand with the Comfy team to roll out ComfyUI across teaching, research, and industry training.
|
||||
|
||||
<Quote name="The Comfy Team">CCI is the model we hope every creative campus follows: ComfyUI in the masters classroom, in PhD research, and in industry collaboration, all at once. As our first Creative Campus Partner, they are helping us design an education program that works the way universities actually work.</Quote>
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ual-cci/cci-camberwell.jpg" alt="Creative Computing Institute campus at UAL" caption="Creative Computing Institute Campus. Photo: Ana Escobar, courtesy UAL." />
|
||||
|
||||
The institute is leading a major £1.5 million publicly funded research programme developing copyright-compliant audiovisual foundation models for the UK's creative industries. Bringing together expertise in sound, image, and artificial intelligence, the project is building open tools and responsible AI national infrastructure designed to support UK creative production, research, and experimentation across the sector.
|
||||
|
||||
The outputs of the research will explore wider dissemination and adoption through open, node-based tools such as ComfyUI to support experimentation, workflows, and collaboration around emerging multimodal AI systems.
|
||||
|
||||
UAL CCI joins a founding cohort of educators and institutions featured at the launch of the Comfy Education Initiative, alongside researchers such as CCI co-founder Dr. Phoenix Perry, whose Antigravity Machine project Comfy supports as an industry partner.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="About the Creative Computing Institute at UAL">
|
||||
|
||||
The Creative Computing Institute at University of the Arts London applies computing to creativity and social impact, operating at the intersection of computational technologies and creative practice, teaching undergraduate, postgraduate, and PhD students alongside research and industry collaboration.
|
||||
|
||||
</Section>
|
||||
|
||||
<EducationCta />
|
||||
103
apps/website/src/content/customers/en/xindi-zhang.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: "The tool that expands my art: Xindi Zhang's Oscar-shortlisted thesis, built in ComfyUI"
|
||||
category: "CREATIVE CAMPUS SHOWCASE"
|
||||
description: "How a USC Expanded Animation thesis became a Student Academy Award winner, an Oscar shortlist entry, and helped land a job at Amazon — with the artist's own illustrations as the style guide."
|
||||
cover: "https://media.comfy.org/website/customers/xindi-zhang/cover.webp"
|
||||
order: 5
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-3
|
||||
label: "THE PIPELINE"
|
||||
- id: topic-4
|
||||
label: "AT A GLANCE"
|
||||
- id: topic-5
|
||||
label: "WHAT'S NEXT"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
<Embed src="https://player.vimeo.com/video/1131160045" title="The Song of Drifters by Xindi Zhang" />
|
||||
|
||||
*From The Song of Drifters. Film images: Xindi Zhang.*
|
||||
|
||||
### Tell us about The Song of Drifters. What is it about, and where did it start?
|
||||
|
||||
The Song of Drifters is a documentary animation about people caught between leaving and returning, wanderers who drift through unfamiliar cities, holding onto memories of a homeland out of reach and searching for a sense of belonging. The title is a direct translation from an ancient Chinese poem about a mother's love for a child who leaves her hometown. My version takes the opposite point of view, from the child's perspective.
|
||||
|
||||
I built the film in ComfyUI. When I started, I was not trying to show what AI could do. I was trying to prove something almost opposite.
|
||||
|
||||
<Quote>It started as a challenge to the stereotype that AI-generated work is generic and cheap. I wanted to prove that AI could be an amplifier for personal vision, not a replacement for it.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2">
|
||||
|
||||
### You came to this from illustration, not engineering. How did you end up in ComfyUI?
|
||||
|
||||
I started as an illustrator. I earned my BFA in illustration at the Rhode Island School of Design, then worked as a game concept artist, where I picked up shaders, Unity, and Unreal. That technical side made me a fast learner with new tools. Later I went to USC's School of Cinematic Arts for an MFA in Expanded Animation, where I studied with Professor Kathy Smith.
|
||||
|
||||
By my thesis year I had moved from Stable Diffusion's standard interfaces to ComfyUI, because I think in node-based structures and I wanted to control every step. Most AI tools are one click: you prompt, you click, you get a result. That is not what I wanted.
|
||||
|
||||
<Quote>I want to control the process, and the process is even more important than the result itself. For artists like me, I don't want to automate anything. I want to participate in every single stage of designing the workflow. That's the fun part of it.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3">
|
||||
|
||||
### Walk us through the pipeline. What were you actually feeding the model?
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/balloon-workflow.png" alt="Xindi's ComfyUI workflow for the balloon sequence">Xindi's ComfyUI workflow for the balloon sequence. Source: [xindizhangart.com](https://xindizhangart.com).</Figure>
|
||||
|
||||
My core technique was style transfer in Stable Diffusion 1.5, driven by IP-Adapter and ControlNet. What mattered most was what I fed it: my own work. The base materials were live-action footage I shot on an iPhone 15 Pro and 3D animation I built in Blender. The AI restyled imagery I had already made. It did not invent it.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/film-still.jpg" alt="Style-guide still from The Song of Drifters">Style-guide still from The Song of Drifters. Source: [xindizhangart.com](https://xindizhangart.com).</Figure>
|
||||
|
||||
<Quote>Unlike most AI-generated videos, which use other artists' works from the model, I use my own illustrations as the style guide.</Quote>
|
||||
|
||||
<Download href="https://media.comfy.org/website/customers/xindi-zhang/workflows/style-transfer-workflow.json" label="Download Xindi's style transfer workflow (json) on ComfyUI" />
|
||||
|
||||
I also trained custom LoRAs on my own video, footage of the cities I had lived in. Capturing that footage became a vital part of the documentary process. Wandering through the streets where I once lived let me reconnect with those cities. Most of it never appears in the final cut, but it lives in the visuals as training data. The hybrid pipeline made rendering the final look more efficient and saved more time for ideation.
|
||||
|
||||
For the dream sequences I combined animated 3D with AI morphing, moving from abstract to concrete to mimic the feeling of being half awake.
|
||||
|
||||
<Video src="https://media.comfy.org/website/customers/xindi-zhang/bts-clip.mp4" poster="https://media.comfy.org/website/customers/xindi-zhang/bts-poster.jpg" caption="BTS clip, AI morphing. Source: Xindi Zhang." />
|
||||
|
||||
<Download href="https://media.comfy.org/website/customers/xindi-zhang/workflows/morphing-workflow.json" label="Download Xindi's AI morphing workflow (json) on ComfyUI" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="At a glance">
|
||||
|
||||
<AtAGlance rows={[
|
||||
{ label: "Program", value: "USC School of Cinematic Arts — MFA Expanded Animation (thesis)" },
|
||||
{ label: "Base materials", value: "iPhone 15 Pro live-action; her own Blender 3D animation" },
|
||||
{ label: "Core technique", value: "Style transfer in SD 1.5 via IP-Adapter + ControlNet, in ComfyUI" },
|
||||
{ label: "Style source", value: "Her own illustrations + custom LoRAs trained on her own city footage" },
|
||||
{ label: "Finishing", value: "Depth, mask, and fade passes in After Effects; heavy compositing" },
|
||||
{ label: "Outcome", value: "Student Academy Awards Golden Award (2025); 98th Academy Awards shortlist; AI Creative role at Amazon AI Studio" }
|
||||
]} />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5">
|
||||
|
||||
### The film won gold at the Student Academy Awards and was shortlisted for the Oscars. What's next?
|
||||
|
||||
I made the film for creative reasons, not career ones. I honestly did not expect it to connect to a job at all. Then it won the Golden Award at the 2025 Student Academy Awards and was shortlisted for the Oscars, and the calls started.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/awards.png" alt="Xindi Zhang at the 2025 Student Academy Awards" caption="Xindi Zhang at the 2025 Student Academy Awards. Source: Oscars Press Office." />
|
||||
|
||||
What people wanted was the combination: someone who understands both traditional craft and AI tools. I now work as an AI Creative at Amazon AI Studio building custom production pipelines. I see that same demand across the industry, with ComfyUI experience starting to show up as a requirement in job postings at major studios and design agencies.
|
||||
|
||||
<Quote>It's not the tool that steals my art. It's the tool that expands my art.</Quote>
|
||||
|
||||
My advice to other students is not really about software. AI is just another tool to convey ideas, but nothing is more important than the story itself. If you use AI, use it on purpose. The more you understand it, the more freedom you have to make work that is genuinely yours.
|
||||
|
||||
</Section>
|
||||
|
||||
<AuthorBio people={[{ name: "Xindi Zhang", photo: "https://media.comfy.org/website/customers/xindi-zhang/profile.jpg", bio: `Xindi Zhang is a Chinese animation director and visual artist (RISD BFA in illustration, 2020; USC MFA in Expanded Animation, 2025). The Song of Drifters won the Golden Award at the 2025 Student Academy Awards and was shortlisted for the 98th Academy Awards. She works as an AI Creative at Amazon AI Studio, has collaborated with Sony Music's immersive studio, and is now on the faculty at the University of South Florida.` }]} />
|
||||
|
||||
<EducationCta />
|
||||
@@ -61,6 +61,11 @@
|
||||
|
||||
@theme {
|
||||
--color-site-dropdown: #332b38;
|
||||
--color-site-bg-soft: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary-comfy-ink) 88%,
|
||||
black 12%
|
||||
);
|
||||
--color-primary-comfy-yellow: #f2ff59;
|
||||
--color-primary-comfy-ink: #211927;
|
||||
--color-primary-comfy-ink-light: #2a2330;
|
||||
@@ -261,6 +266,6 @@ video::-webkit-media-controls-panel {
|
||||
|
||||
:root {
|
||||
--site-bg: var(--color-primary-comfy-ink);
|
||||
--site-bg-soft: color-mix(in srgb, var(--site-bg) 88%, black 12%);
|
||||
--site-bg-soft: var(--color-site-bg-soft);
|
||||
--site-border-subtle: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { config as dotenvConfig } from 'dotenv'
|
||||
import MCR from 'monocart-coverage-reports'
|
||||
|
||||
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
|
||||
import { TOURS } from '@/platform/onboarding/onboardingTours'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
|
||||
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
ModelLibrarySidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
NodeLibrarySidebarTabV2,
|
||||
SidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
@@ -70,6 +72,7 @@ class ComfyPropertiesPanel {
|
||||
}
|
||||
|
||||
class ComfyMenu {
|
||||
private _appsTab: SidebarTab | null = null
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
@@ -104,6 +107,11 @@ class ComfyMenu {
|
||||
return this._nodeLibraryTabV2
|
||||
}
|
||||
|
||||
get appsTab() {
|
||||
this._appsTab ??= new SidebarTab(this.page, 'apps')
|
||||
return this._appsTab
|
||||
}
|
||||
|
||||
get assetsTab() {
|
||||
this._assetsTab ??= new AssetsSidebarTab(this.page)
|
||||
return this._assetsTab
|
||||
@@ -535,6 +543,8 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.userId': userId,
|
||||
// Set tutorial completed to true to avoid loading the tutorial workflow.
|
||||
'Comfy.TutorialCompleted': true,
|
||||
// An auto-opened tour's blocker would break unrelated tests.
|
||||
'Comfy.OnboardingCoachmarks.Seen': Object.keys(TOURS),
|
||||
'Comfy.Queue.MaxHistoryItems': 64,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class SidebarTab {
|
||||
export class SidebarTab {
|
||||
public readonly tabButton: Locator
|
||||
public readonly selectedTabButton: Locator
|
||||
|
||||
|
||||
74
browser_tests/fixtures/components/Tour.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export type CoachTour = 'appMode'
|
||||
|
||||
const SEEN_SETTING = 'Comfy.OnboardingCoachmarks.Seen'
|
||||
|
||||
/** Accessible name of each tour's in-app replay (help) button. */
|
||||
const TOUR_REPLAY_BUTTONS: Record<CoachTour, string> = {
|
||||
appMode: 'Take a tour of App Mode'
|
||||
}
|
||||
|
||||
/** Coach-mark overlay (src/platform/onboarding/TourOverlay.vue). */
|
||||
export class OnboardingCoachmarks {
|
||||
public readonly landing: Locator
|
||||
public readonly landingStartButton: Locator
|
||||
public readonly landingSkipButton: Locator
|
||||
/** The current spotlight step card (the dialog carrying a "Step N of M" label). */
|
||||
public readonly card: Locator
|
||||
public readonly cardNextButton: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.landing = page.getByTestId('coach-landing')
|
||||
this.landingStartButton = this.landing.getByRole('button', {
|
||||
name: 'Start tutorial'
|
||||
})
|
||||
this.landingSkipButton = this.landing.getByRole('button', {
|
||||
name: 'Skip for now'
|
||||
})
|
||||
this.card = page.getByRole('dialog').filter({ hasText: /Step \d+ of \d+/ })
|
||||
this.cardNextButton = this.card.getByRole('button', { name: 'Next' })
|
||||
}
|
||||
|
||||
/** The tour's in-app help button, which replays it past the seen-flag. */
|
||||
replayButton(tour: CoachTour): Locator {
|
||||
return this.page.getByRole('button', { name: TOUR_REPLAY_BUTTONS[tour] })
|
||||
}
|
||||
|
||||
/** The spotlight card while it is showing the given step number. */
|
||||
cardForStep(step: number): Locator {
|
||||
return this.card.filter({ hasText: new RegExp(`Step ${step} of `) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the pre-seeded seen-flag (so dismissal assertions observe it being
|
||||
* set again) and clicks the tour's replay button, which must be mounted.
|
||||
*/
|
||||
async startTour(tour: CoachTour) {
|
||||
await this.clearSeen()
|
||||
await this.replayButton(tour).click()
|
||||
}
|
||||
|
||||
private async clearSeen() {
|
||||
await this.page.evaluate(
|
||||
async (key) => window.app!.extensionManager.setting.set(key, []),
|
||||
SEEN_SETTING
|
||||
)
|
||||
}
|
||||
|
||||
/** An element a tour points at, by its `data-coach-id` anchor. */
|
||||
coachAnchor(id: string): Locator {
|
||||
return this.page.locator(`[data-coach-id="${id}"]`)
|
||||
}
|
||||
|
||||
async seen(tour: CoachTour): Promise<boolean> {
|
||||
const seen = await this.page.evaluate(
|
||||
async (key) =>
|
||||
(await window.app!.extensionManager.setting.get(key)) as
|
||||
| string[]
|
||||
| undefined,
|
||||
SEEN_SETTING
|
||||
)
|
||||
return !!seen?.includes(tour)
|
||||
}
|
||||
}
|
||||
@@ -42,16 +42,16 @@ export class AppModeHelper {
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
public readonly runButton: Locator
|
||||
/** The welcome screen shown when app mode has no outputs or no nodes. */
|
||||
/** The welcome card shown when the graph has nodes or outputs (build prompt / ready to run). */
|
||||
public readonly welcome: Locator
|
||||
/** The empty workflow message shown when no nodes exist. */
|
||||
public readonly emptyWorkflowText: Locator
|
||||
/** The "Build app" button shown when nodes exist but no outputs. */
|
||||
public readonly buildAppButton: Locator
|
||||
/** The "Back to workflow" button on the welcome screen. */
|
||||
public readonly backToWorkflowButton: Locator
|
||||
/** The "Load template" button shown when no nodes exist. */
|
||||
public readonly loadTemplateButton: Locator
|
||||
/** The get started page shown when the graph is empty. */
|
||||
public readonly getStarted: Locator
|
||||
/** The "Discover all templates" button on the get started page. */
|
||||
public readonly getStartedDiscoverButton: Locator
|
||||
/** Featured template cards on the get started page. */
|
||||
public readonly getStartedTemplateCards: Locator
|
||||
/** The cancel button for an in-progress run in the output history. */
|
||||
public readonly cancelRunButton: Locator
|
||||
/** Arrange-step placeholder shown when outputs are configured but no run has happened. */
|
||||
@@ -111,15 +111,13 @@ export class AppModeHelper {
|
||||
.getByTestId(TestIds.linear.runButton)
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
TestIds.appMode.emptyWorkflow
|
||||
)
|
||||
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
|
||||
this.backToWorkflowButton = this.page.getByTestId(
|
||||
TestIds.appMode.backToWorkflow
|
||||
this.getStarted = this.page.getByTestId(TestIds.appMode.getStarted)
|
||||
this.getStartedDiscoverButton = this.page.getByTestId(
|
||||
TestIds.appMode.getStartedDiscover
|
||||
)
|
||||
this.loadTemplateButton = this.page.getByTestId(
|
||||
TestIds.appMode.loadTemplate
|
||||
this.getStartedTemplateCards = this.page.getByTestId(
|
||||
TestIds.appMode.getStartedTemplate
|
||||
)
|
||||
this.cancelRunButton = this.page.getByTestId(
|
||||
TestIds.outputHistory.cancelRun
|
||||
|
||||
@@ -218,10 +218,10 @@ export const TestIds = {
|
||||
appMode: {
|
||||
widgetItem: 'app-mode-widget-item',
|
||||
welcome: 'linear-welcome',
|
||||
emptyWorkflow: 'linear-welcome-empty-workflow',
|
||||
buildApp: 'linear-welcome-build-app',
|
||||
backToWorkflow: 'linear-welcome-back-to-workflow',
|
||||
loadTemplate: 'linear-welcome-load-template',
|
||||
getStarted: 'linear-get-started',
|
||||
getStartedDiscover: 'linear-get-started-discover',
|
||||
getStartedTemplate: 'linear-get-started-template',
|
||||
arrangePreview: 'linear-arrange-preview',
|
||||
arrangeNoOutputs: 'linear-arrange-no-outputs',
|
||||
arrangeSwitchToOutputs: 'linear-arrange-switch-to-outputs',
|
||||
@@ -238,6 +238,9 @@ export const TestIds = {
|
||||
renameInput: 'subgraph-breadcrumb-rename-input',
|
||||
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
|
||||
},
|
||||
workflowActions: {
|
||||
viewModeToggle: 'view-mode-toggle'
|
||||
},
|
||||
templates: {
|
||||
content: 'template-workflows-content',
|
||||
workflowCard: (id: string) => `template-workflow-${id}`
|
||||
|
||||
11
browser_tests/fixtures/tourFixture.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { OnboardingCoachmarks } from '@e2e/fixtures/components/Tour'
|
||||
|
||||
export const onboardingFixture = base.extend<{
|
||||
onboarding: OnboardingCoachmarks
|
||||
}>({
|
||||
onboarding: async ({ page }, use) => {
|
||||
await use(new OnboardingCoachmarks(page))
|
||||
}
|
||||
})
|
||||
@@ -137,6 +137,125 @@ test.describe('App mode usage', () => {
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
|
||||
})
|
||||
|
||||
test('Shares the graph side toolbar, filtered to assets + apps', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { sideToolbar, nodeLibraryTab, assetsTab, appsTab } = comfyPage.menu
|
||||
|
||||
await test.step('Graph mode shows the full toolbar', async () => {
|
||||
await expect(sideToolbar).toBeVisible()
|
||||
await expect(nodeLibraryTab.tabButton).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('App mode reuses it with only assets + apps', async () => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
await expect(sideToolbar).toBeVisible()
|
||||
await expect(assetsTab.tabButton).toBeVisible()
|
||||
await expect(appsTab.tabButton).toBeVisible()
|
||||
await expect(nodeLibraryTab.tabButton).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('Workflow actions menu keeps the same position across graph/app mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Toggling graph<->app mode happens from this control, so it must not move
|
||||
// out from under the cursor as the mode flips.
|
||||
const graphActions = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
await expect(graphActions).toBeVisible()
|
||||
const graphBox = await graphActions.boundingBox()
|
||||
|
||||
expect(graphBox).not.toBeNull()
|
||||
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
const appActions = comfyPage.page
|
||||
.getByTestId(TestIds.linear.centerPanel)
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
await expect(appActions).toBeVisible()
|
||||
|
||||
// The toggle segments reorder (morph) as the mode flips, so poll until the
|
||||
// active control settles at the same x it occupied in graph mode.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await appActions.boundingBox()
|
||||
return box ? Math.abs(box.x - graphBox!.x) : Infinity
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Toggle segment flips mode without opening the menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await comfyPage.page.getByRole('button', { name: 'Enter app mode' }).click()
|
||||
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
// The inactive segment switches mode; it must not also open the actions menu.
|
||||
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Toggle segment flips mode via keyboard without opening the menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const appSegment = comfyPage.page.getByRole('button', {
|
||||
name: 'Enter app mode'
|
||||
})
|
||||
await appSegment.focus()
|
||||
await appSegment.press('Enter')
|
||||
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
// Keyboard activation of the inactive segment must switch mode without the
|
||||
// keydown bubbling to the trigger and opening the actions menu.
|
||||
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Mode toggle re-appears after exiting the builder to graph mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await expect(toggle).toBeHidden()
|
||||
await expect(comfyPage.appMode.centerPanel).toBeHidden()
|
||||
|
||||
await comfyPage.appMode.footer.exitButton.click()
|
||||
// Exiting the builder lands in graph mode: the app-mode-only center panel
|
||||
// stays hidden while the toggle's teleport host re-mounts and the toggle
|
||||
// re-appears.
|
||||
await expect(toggle).toBeVisible()
|
||||
await expect(comfyPage.appMode.centerPanel).toBeHidden()
|
||||
})
|
||||
|
||||
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
|
||||
await comfyPage.menu.assetsTab.tabButton.click()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
@@ -184,3 +303,45 @@ test.describe('App mode usage', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('App mode credits', () => {
|
||||
const API_PRICED_NODE = 'FluxProUltraImageNode'
|
||||
|
||||
test('shows the credit breakdown popover for priced nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeBadge.ShowApiPricing', true)
|
||||
await comfyPage.page.evaluate((type) => {
|
||||
const registered = window.LiteGraph!.registered_node_types[type] as {
|
||||
nodeData?: { price_badge?: unknown }
|
||||
}
|
||||
if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
|
||||
registered.nodeData.price_badge = {
|
||||
engine: 'jsonata',
|
||||
expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
|
||||
depends_on: { widgets: [], inputs: [], input_groups: [] }
|
||||
}
|
||||
}, API_PRICED_NODE)
|
||||
|
||||
await comfyPage.nodeOps.addNode(API_PRICED_NODE)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
// The run/subscribe button flags that the workflow needs credits, even when
|
||||
// the pill collapses to its icon (kept in the accessible name).
|
||||
const runButton = comfyPage.appMode.runButton
|
||||
await expect(runButton).toBeVisible()
|
||||
await expect(runButton).toHaveAccessibleName(/Uses credits/)
|
||||
|
||||
// Hovering the button reveals the per-node credit breakdown.
|
||||
await runButton.hover()
|
||||
const breakdown = comfyPage.page.getByRole('list', {
|
||||
name: 'Credit breakdown by model'
|
||||
})
|
||||
await expect(breakdown).toBeVisible()
|
||||
await expect(breakdown).toContainText('99.9 credits/Run')
|
||||
await expect(
|
||||
comfyPage.page.getByText('Requires additional credits')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,14 +9,12 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
await comfyPage.appMode.suppressVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('Empty workflow text is visible when no nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Get started page is visible when no nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
|
||||
await expect(comfyPage.appMode.getStarted).toBeVisible()
|
||||
await expect(comfyPage.appMode.welcome).toBeHidden()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -27,35 +25,41 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
|
||||
await expect(comfyPage.appMode.getStarted).toBeHidden()
|
||||
})
|
||||
|
||||
test('Empty workflow and build app are hidden when app has outputs', async ({
|
||||
test('Get started and build app are hidden when app has outputs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
|
||||
await expect(comfyPage.appMode.getStarted).toBeHidden()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await comfyPage.appMode.backToWorkflowButton.click()
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
await expect(comfyPage.appMode.welcome).toBeHidden()
|
||||
})
|
||||
|
||||
test('Load template opens template selector', async ({ comfyPage }) => {
|
||||
test('Clicking a featured template loads it into the graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await comfyPage.appMode.loadTemplateButton.click()
|
||||
await comfyPage.appMode.getStartedTemplateCards.first().click()
|
||||
|
||||
await expect(comfyPage.appMode.getStarted).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Discover all templates opens template selector', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.getStarted).toBeVisible()
|
||||
await comfyPage.appMode.getStartedDiscoverButton.click()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
@@ -28,7 +28,12 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
98
browser_tests/tests/tour.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { onboardingFixture } from '@e2e/fixtures/tourFixture'
|
||||
|
||||
import { COACH_IDS } from '@/platform/onboarding/onboardingTours'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, onboardingFixture)
|
||||
|
||||
// Relies on the default workflow the test server loads (locally: pnpm dev:test)
|
||||
// — an empty graph would show the welcome screen, not the tour's controls.
|
||||
test.describe('Onboarding coachmarks', { tag: '@ui' }, () => {
|
||||
test.describe('app-mode tour', () => {
|
||||
test('opens on the welcome landing, focuses Start, and Skip dismisses it', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await onboarding.startTour('appMode')
|
||||
const coach = onboarding
|
||||
|
||||
await expect(coach.landing).toBeVisible()
|
||||
await expect(coach.landing.getByRole('heading')).toHaveText(
|
||||
'Welcome to Apps'
|
||||
)
|
||||
await expect(coach.landingStartButton).toBeFocused()
|
||||
|
||||
await coach.landingSkipButton.click()
|
||||
await expect(coach.landing).toBeHidden()
|
||||
expect(await coach.seen('appMode')).toBe(true)
|
||||
})
|
||||
|
||||
test('Escape dismisses the welcome landing and marks it seen', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await onboarding.startTour('appMode')
|
||||
const coach = onboarding
|
||||
await expect(coach.landing).toBeVisible()
|
||||
await expect(coach.landingStartButton).toBeFocused()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(coach.landing).toBeHidden()
|
||||
expect(await coach.seen('appMode')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('coach anchors', () => {
|
||||
test('every registry id resolves to an element (drift guard)', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
const coach = onboarding
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
// The assets panel only mounts once its button is clicked; every other
|
||||
// anchor should already be present in a running app.
|
||||
for (const id of Object.values(COACH_IDS).filter(
|
||||
(id) => id !== COACH_IDS.assetsPanel
|
||||
)) {
|
||||
await expect(coach.coachAnchor(id)).toBeVisible()
|
||||
}
|
||||
await coach.coachAnchor(COACH_IDS.assetsButton).click()
|
||||
await expect(coach.coachAnchor(COACH_IDS.assetsPanel)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('spotlight placement', () => {
|
||||
test('every spotlight card stays fully within the viewport', async ({
|
||||
comfyPage,
|
||||
onboarding
|
||||
}) => {
|
||||
const coach = onboarding
|
||||
// Read settled placements, not a transient mid-animation frame.
|
||||
await comfyPage.page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await coach.startTour('appMode')
|
||||
await expect(coach.landing).toBeVisible()
|
||||
await coach.landingStartButton.click()
|
||||
|
||||
// Step 3 (outputs) is the vertically-centred `leftCenter` placement that
|
||||
// must not slide off the top/bottom edge.
|
||||
for (const step of [1, 2, 3]) {
|
||||
const card = coach.cardForStep(step)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toBeInViewport({ ratio: 1 })
|
||||
await coach.cardNextButton.click()
|
||||
}
|
||||
|
||||
// Step 4 (assets button) advances by clicking its target, not Next.
|
||||
await expect(coach.cardForStep(4)).toBeInViewport({ ratio: 1 })
|
||||
await coach.coachAnchor('assets-button').click()
|
||||
|
||||
await expect(coach.cardForStep(5)).toBeInViewport({ ratio: 1 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -73,6 +73,7 @@
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@customerio/cdp-analytics-browser": "catalog:",
|
||||
"@floating-ui/vue": "catalog:",
|
||||
"@formkit/auto-animate": "catalog:",
|
||||
"@iconify/json": "catalog:",
|
||||
"@primeuix/forms": "catalog:",
|
||||
|
||||
6
pnpm-lock.yaml
generated
@@ -30,6 +30,9 @@ catalogs:
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1
|
||||
'@floating-ui/vue':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11
|
||||
'@formkit/auto-animate':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
@@ -465,6 +468,9 @@ importers:
|
||||
'@customerio/cdp-analytics-browser':
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.3
|
||||
'@floating-ui/vue':
|
||||
specifier: 'catalog:'
|
||||
version: 1.1.11(vue@3.5.34(typescript@5.9.3))
|
||||
'@formkit/auto-animate':
|
||||
specifier: 'catalog:'
|
||||
version: 0.9.0
|
||||
|
||||
@@ -18,6 +18,7 @@ catalog:
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@customerio/cdp-analytics-browser': ^0.5.3
|
||||
'@eslint/js': ^10.0.1
|
||||
'@floating-ui/vue': ^1.1.11
|
||||
'@formkit/auto-animate': ^0.9.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
|
||||
BIN
public/assets/images/app-mode-landing.png
Normal file
|
After Width: | Height: | Size: 355 KiB |
@@ -15,6 +15,7 @@ const IGNORE_PATTERNS = [
|
||||
/^dataTypes\./, // Data types might be referenced dynamically
|
||||
/^contextMenu\./, // Context menu items might be dynamic
|
||||
/^color\./, // Color names might be used dynamically
|
||||
/^onboardingCoachmarks\.[^.]+\.[^.]+\./, // Step keys derived as onboardingCoachmarks.<tour>.<step>.*
|
||||
// Auto-generated categories from collect-i18n-general.ts
|
||||
/^menuLabels\./, // Menu labels generated from command labels
|
||||
/^settingsCategories\./, // Settings categories generated from setting definitions
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
/* Generating screen ambient glow — a slowly rotating, blurred conic gradient.
|
||||
--gen-angle must be a registered <angle> so the conic gradient interpolates
|
||||
instead of jumping between keyframes. */
|
||||
@property --gen-angle {
|
||||
syntax: '<angle>';
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
@keyframes gen-angle-spin {
|
||||
to {
|
||||
--gen-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
.gen-glow {
|
||||
position: absolute;
|
||||
inset: -28%;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from var(--gen-angle),
|
||||
#3b82f63b,
|
||||
#8b5cf633,
|
||||
#d946ef2b,
|
||||
#ec489933,
|
||||
#f9731629,
|
||||
#14b8a62e,
|
||||
#3b82f63b
|
||||
);
|
||||
filter: blur(60px);
|
||||
opacity: 0.34;
|
||||
animation: gen-angle-spin 12s linear infinite;
|
||||
mask-image: radial-gradient(circle, #000 0%, #000 22%, rgb(0 0 0 / 0) 70%);
|
||||
}
|
||||
|
||||
.gen-glow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 8%;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from calc(var(--gen-angle) + 120deg),
|
||||
#3b82f629,
|
||||
#8b5cf621,
|
||||
#d946ef1c,
|
||||
#ec489924,
|
||||
#f973161a,
|
||||
#14b8a621,
|
||||
#3b82f629
|
||||
);
|
||||
filter: blur(34px);
|
||||
opacity: 0.39;
|
||||
mask-image: radial-gradient(
|
||||
circle,
|
||||
#000 0%,
|
||||
rgb(0 0 0 / 0.62) 36%,
|
||||
rgb(0 0 0 / 0.22) 50%,
|
||||
rgb(0 0 0 / 0) 64%
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gen-glow {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.genfan-enter-active,
|
||||
.genfan-leave-active,
|
||||
.gen-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Generating fan cards fade in/out so adding and evicting cards stays smooth. */
|
||||
.genfan-enter-active,
|
||||
.genfan-leave-active {
|
||||
transition: opacity 0.42s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.genfan-enter-from,
|
||||
.genfan-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
|
||||
and JS listeners aren't broken. */
|
||||
.disable-animations *,
|
||||
@@ -30,3 +114,22 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#linearCenterPanel {
|
||||
background:
|
||||
radial-gradient(
|
||||
60% 45% at 50% 38%,
|
||||
rgb(70 78 104 / 0.35) 0%,
|
||||
rgb(70 78 104 / 0) 100%
|
||||
),
|
||||
radial-gradient(
|
||||
45% 40% at 28% 68%,
|
||||
rgb(78 62 98 / 0.28) 0%,
|
||||
rgb(78 62 98 / 0) 100%
|
||||
),
|
||||
radial-gradient(
|
||||
45% 40% at 74% 62%,
|
||||
rgb(46 70 94 / 0.28) 0%,
|
||||
rgb(46 70 94 / 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ const formatNumber = ({
|
||||
return new Intl.NumberFormat(locale, merged).format(value)
|
||||
}
|
||||
|
||||
export const CREDITS_ICON = 'icon-[lucide--coins]'
|
||||
|
||||
export const CREDITS_PER_USD = 211
|
||||
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent
|
||||
|
||||
|
||||
87
src/components/appMode/AppModeToolbar.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeToolbar from './AppModeToolbar.vue'
|
||||
|
||||
const appModeState = vi.hoisted(() => ({
|
||||
enableAppBuilder: true,
|
||||
hasNodes: true
|
||||
}))
|
||||
const enterBuilder = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ enableAppBuilder: appModeState.enableAppBuilder })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', async () => {
|
||||
const { computed, reactive } = await import('vue')
|
||||
return {
|
||||
useAppModeStore: () =>
|
||||
reactive({
|
||||
enterBuilder,
|
||||
hasNodes: computed(() => appModeState.hasNodes)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const BUILD_AN_APP = 'Build an app'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: { appModeToolbar: { buildAnApp: BUILD_AN_APP } }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderToolbar() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(AppModeToolbar, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
WorkflowActionsDropdown: true
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('AppModeToolbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appModeState.enableAppBuilder = true
|
||||
appModeState.hasNodes = true
|
||||
})
|
||||
|
||||
it('shows an enabled build button and enters the builder on click', async () => {
|
||||
const { user } = renderToolbar()
|
||||
|
||||
const button = screen.getByRole('button', { name: BUILD_AN_APP })
|
||||
expect(button).toBeEnabled()
|
||||
|
||||
await user.click(button)
|
||||
|
||||
expect(enterBuilder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the build button when there are no nodes', () => {
|
||||
appModeState.hasNodes = false
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByRole('button', { name: BUILD_AN_APP })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('hides the build button when app building is disabled', () => {
|
||||
appModeState.enableAppBuilder = false
|
||||
renderToolbar()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: BUILD_AN_APP })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,119 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
openShareDialog,
|
||||
prefetchShareDialog
|
||||
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { enableAppBuilder } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { enterBuilder } = appModeStore
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { hasNodes } = storeToRefs(appModeStore)
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
const isAssetsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
|
||||
)
|
||||
const isAppsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
|
||||
)
|
||||
|
||||
function openAssets() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
|
||||
}
|
||||
|
||||
function showApps() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-auto flex flex-row items-start gap-2">
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="
|
||||
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
variant="base"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.buildAnApp')"
|
||||
class="h-10 gap-1.5 rounded-lg px-3 font-normal"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
<span>{{ t('linearMode.appModeToolbar.buildAnApp') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
71
src/components/breadcrumb/SubgraphBreadcrumb.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SubgraphBreadcrumb from './SubgraphBreadcrumb.vue'
|
||||
|
||||
const canvasState = vi.hoisted(() => ({ linearMode: false }))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({ activeWorkflow: { filename: 'workflow.json' } })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphNavigationStore', () => ({
|
||||
useSubgraphNavigationStore: () => ({ navigationStack: [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: () => ({ isSubgraphBlueprint: () => false })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ linearMode: canvasState.linearMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useOverflowObserver', () => ({
|
||||
useOverflowObserver: () => ({
|
||||
dispose: vi.fn(),
|
||||
checkOverflow: vi.fn(),
|
||||
disposed: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { g: { graphNavigation: 'Graph navigation' } }
|
||||
}
|
||||
})
|
||||
|
||||
function renderBreadcrumb() {
|
||||
return render(SubgraphBreadcrumb, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
WorkflowActionsDropdown: { template: '<div data-testid="wad" />' },
|
||||
Breadcrumb: true,
|
||||
Button: true,
|
||||
SubgraphBreadcrumbItem: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubgraphBreadcrumb', () => {
|
||||
beforeEach(() => {
|
||||
canvasState.linearMode = false
|
||||
})
|
||||
|
||||
it('renders the workflow actions dropdown when not in linear mode', () => {
|
||||
renderBreadcrumb()
|
||||
expect(screen.getByTestId('wad')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the workflow actions dropdown in linear mode', () => {
|
||||
canvasState.linearMode = true
|
||||
renderBreadcrumb()
|
||||
expect(screen.queryByTestId('wad')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,10 @@
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
|
||||
<WorkflowActionsDropdown
|
||||
v-if="!canvasStore.linearMode"
|
||||
source="breadcrumb_subgraph_menu_selected"
|
||||
/>
|
||||
<Button
|
||||
v-if="isInSubgraph"
|
||||
class="back-button pointer-events-auto ml-1.5 size-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
@@ -71,6 +74,7 @@ const ICON_WIDTH = 20
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
class="p-1 text-amber-400"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--component]" />
|
||||
<i :class="CREDITS_ICON" />
|
||||
</template>
|
||||
</Tag>
|
||||
<div :class="textClass">
|
||||
@@ -29,7 +29,10 @@ import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
CREDITS_ICON,
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const { textClass, showCreditsOnly } = defineProps<{
|
||||
|
||||
222
src/components/common/WorkflowActionsDropdown.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ViewMode } from '@/utils/appMode'
|
||||
|
||||
import WorkflowActionsDropdown from './WorkflowActionsDropdown.vue'
|
||||
|
||||
const spies = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
trackUiButtonClicked: vi.fn(),
|
||||
markAsSeen: vi.fn()
|
||||
}))
|
||||
|
||||
const viewState = vi.hoisted(() => ({
|
||||
viewMode: 'graph' as ViewMode,
|
||||
displayViewMode: 'graph' as ViewMode
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', async () => {
|
||||
const { computed, reactive } = await import('vue')
|
||||
return {
|
||||
useAppModeStore: () =>
|
||||
reactive({
|
||||
viewMode: computed(() => viewState.viewMode),
|
||||
displayViewMode: computed(() => viewState.displayViewMode)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: spies.execute, commands: [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybindingByCommandId: () => ({ combo: { toString: () => 'Ctrl+L' } })
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowActionsMenu', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return { useWorkflowActionsMenu: () => ({ menuItems: ref([]) }) }
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useNewMenuItemIndicator', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useNewMenuItemIndicator: () => ({
|
||||
hasUnseenItems: ref(true),
|
||||
markAsSeen: spies.markAsSeen
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { shortcutSuffix: ' ({shortcut})' },
|
||||
breadcrumbsMenu: {
|
||||
graph: 'Graph',
|
||||
app: 'App',
|
||||
enterNodeGraph: 'Enter node graph',
|
||||
enterAppMode: 'Enter app mode',
|
||||
workflowActions: 'Workflow actions',
|
||||
activeModeWorkflowActions: '{mode} mode, workflow actions'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderDropdown() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(WorkflowActionsDropdown, {
|
||||
props: { source: 'test' },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
DropdownMenuRoot: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
WorkflowActionsList: true
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('WorkflowActionsDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
viewState.viewMode = 'graph'
|
||||
viewState.displayViewMode = 'graph'
|
||||
})
|
||||
|
||||
it('keeps the active segment label in its accessible name alongside the actions label', () => {
|
||||
renderDropdown()
|
||||
|
||||
// Graph is the active segment, so its name must contain the visible "Graph"
|
||||
// label (label-in-name) while still matching the "Workflow actions" trigger.
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
expect(active).toHaveAttribute('aria-label', 'Graph mode, workflow actions')
|
||||
})
|
||||
|
||||
it('labels the inactive segment with its switch action only', () => {
|
||||
renderDropdown()
|
||||
|
||||
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
|
||||
expect(inactive).toHaveAttribute('aria-label', 'Enter app mode')
|
||||
})
|
||||
|
||||
it('flips the segment roles when app mode is active', () => {
|
||||
viewState.viewMode = 'app'
|
||||
viewState.displayViewMode = 'app'
|
||||
renderDropdown()
|
||||
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
expect(active).toHaveAttribute('aria-label', 'App mode, workflow actions')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Enter node graph' })
|
||||
).toHaveAttribute('aria-label', 'Enter node graph')
|
||||
})
|
||||
|
||||
it('derives the active segment from the real mode, not the lagged display mode', () => {
|
||||
// Mid-animation: the mode has flipped to app but the display still lags.
|
||||
viewState.viewMode = 'app'
|
||||
viewState.displayViewMode = 'graph'
|
||||
renderDropdown()
|
||||
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
expect(active).toHaveAttribute('aria-label', 'App mode, workflow actions')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Enter node graph' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('carries the popup semantics only on the active segment', () => {
|
||||
renderDropdown()
|
||||
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
expect(active).toHaveAttribute('aria-haspopup', 'menu')
|
||||
expect(active).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Enter app mode' })
|
||||
).not.toHaveAttribute('aria-haspopup')
|
||||
})
|
||||
|
||||
it('toggles the view mode when the inactive segment is clicked', async () => {
|
||||
const { user } = renderDropdown()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Enter app mode' }))
|
||||
|
||||
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'test' }
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the menu instead of toggling the mode when the active segment is clicked', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
|
||||
await user.click(active)
|
||||
|
||||
expect(spies.execute).not.toHaveBeenCalled()
|
||||
expect(active).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(spies.markAsSeen).toHaveBeenCalled()
|
||||
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'test',
|
||||
element_group: 'workflow_actions'
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the menu when the open trigger is clicked again', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
|
||||
await user.click(active)
|
||||
await user.click(active)
|
||||
|
||||
expect(active).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('switches mode when the inactive segment is activated by keyboard', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
|
||||
|
||||
inactive.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'test' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not switch mode when the active segment is activated by keyboard', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
|
||||
active.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(spies.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the menu on ArrowDown on the active segment', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
|
||||
active.focus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
|
||||
expect(active).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenuRoot
|
||||
} from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import type { FocusOutsideEvent, PointerDownOutsideEvent } from 'reka-ui'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
@@ -14,8 +15,21 @@ import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { ViewMode } from '@/utils/appMode'
|
||||
|
||||
interface ViewModeSegment {
|
||||
mode: ViewMode
|
||||
icon: string
|
||||
label: string
|
||||
switchLabel: string
|
||||
switchTooltip: string
|
||||
/** Truth: drives behavior and aria. Flips as soon as the mode changes. */
|
||||
active: boolean
|
||||
/** Frame-lagged mirror of {@link active}: drives the morph styling/order. */
|
||||
displayActive: boolean
|
||||
}
|
||||
|
||||
const { source, align = 'start' } = defineProps<{
|
||||
source: string
|
||||
@@ -23,46 +37,120 @@ const { source, align = 'start' } = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const dropdownOpen = ref(false)
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(
|
||||
() => useCommandStore().execute('Comfy.RenameWorkflow'),
|
||||
{ isRoot: true }
|
||||
)
|
||||
|
||||
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
|
||||
() => menuItems.value
|
||||
)
|
||||
|
||||
function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source,
|
||||
element_group: 'workflow_actions'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleModeTooltip() {
|
||||
const label = canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
const toggleShortcut = computed(() => {
|
||||
const shortcut = keybindingStore
|
||||
.getKeybindingByCommandId('Comfy.ToggleLinear')
|
||||
?.combo.toString()
|
||||
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
|
||||
return shortcut ? t('g.shortcutSuffix', { shortcut }) : ''
|
||||
})
|
||||
|
||||
const segments = computed<ViewModeSegment[]>(() =>
|
||||
(
|
||||
[
|
||||
{
|
||||
mode: 'graph',
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
label: t('breadcrumbsMenu.graph'),
|
||||
switchLabel: t('breadcrumbsMenu.enterNodeGraph'),
|
||||
switchTooltip:
|
||||
t('breadcrumbsMenu.enterNodeGraph') + toggleShortcut.value
|
||||
},
|
||||
{
|
||||
mode: 'app',
|
||||
icon: 'icon-[lucide--panels-top-left]',
|
||||
label: t('breadcrumbsMenu.app'),
|
||||
switchLabel: t('breadcrumbsMenu.enterAppMode'),
|
||||
switchTooltip: t('breadcrumbsMenu.enterAppMode') + toggleShortcut.value
|
||||
}
|
||||
] as const
|
||||
).map((seg) => ({
|
||||
...seg,
|
||||
active: appModeStore.viewMode === seg.mode,
|
||||
displayActive: appModeStore.displayViewMode === seg.mode
|
||||
}))
|
||||
)
|
||||
|
||||
// Display-inactive segment first (left), display-active last (right). On mode
|
||||
// switch the array reorders and TransitionGroup FLIP-animates the keyed nodes
|
||||
// to their new spots.
|
||||
const orderedSegments = computed(() => {
|
||||
const [graph, app] = segments.value
|
||||
return graph.displayActive ? [app, graph] : [graph, app]
|
||||
})
|
||||
|
||||
const toggleContainer = useTemplateRef<HTMLDivElement>('toggleContainer')
|
||||
|
||||
// The active segment is the only element carrying popup semantics, which makes
|
||||
// this a stable, markup-derived way to find it.
|
||||
function activeSegmentElement() {
|
||||
return (
|
||||
toggleContainer.value?.querySelector<HTMLElement>(
|
||||
'[aria-haspopup="menu"]'
|
||||
) ?? undefined
|
||||
)
|
||||
}
|
||||
|
||||
function toggleLinearMode() {
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
if (!dropdownOpen.value) return
|
||||
markAsSeen()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source,
|
||||
element_group: 'workflow_actions'
|
||||
})
|
||||
}
|
||||
|
||||
function switchMode() {
|
||||
dropdownOpen.value = false
|
||||
void useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
metadata: { source }
|
||||
})
|
||||
}
|
||||
|
||||
function onSegmentClick(seg: ViewModeSegment) {
|
||||
if (seg.active) toggleDropdown()
|
||||
else switchMode()
|
||||
}
|
||||
|
||||
// Match the stock dropdown trigger: ArrowDown on the trigger opens the menu.
|
||||
function onSegmentKeydown(seg: ViewModeSegment, e: KeyboardEvent) {
|
||||
if (!seg.active || e.key !== 'ArrowDown') return
|
||||
e.preventDefault()
|
||||
if (!dropdownOpen.value) toggleDropdown()
|
||||
}
|
||||
|
||||
// Reimplements the two trigger-element behaviors of a stock DropdownMenuTrigger
|
||||
// (which this component cannot use without breaking the FLIP morph): a click on
|
||||
// the open menu's trigger toggles it closed instead of dismiss-then-reopen, and
|
||||
// focus returns to the trigger on close unless the user interacted elsewhere.
|
||||
let interactedOutside = false
|
||||
function onInteractOutside(event: PointerDownOutsideEvent | FocusOutsideEvent) {
|
||||
const target = event.target
|
||||
if (target instanceof Node && activeSegmentElement()?.contains(target)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
interactedOutside = true
|
||||
}
|
||||
|
||||
function onCloseAutoFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
if (!interactedOutside) activeSegmentElement()?.focus()
|
||||
interactedOutside = false
|
||||
}
|
||||
|
||||
const tooltipPt = {
|
||||
root: {
|
||||
style: {
|
||||
@@ -75,82 +163,97 @@ const tooltipPt = {
|
||||
style: { whiteSpace: 'nowrap' }
|
||||
},
|
||||
arrow: {
|
||||
class: '!left-[16px]'
|
||||
style: { left: '16px' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot
|
||||
v-model:open="dropdownOpen"
|
||||
:modal="false"
|
||||
@update:open="handleOpen"
|
||||
>
|
||||
<slot name="button" :has-unseen-items="hasUnseenItems">
|
||||
<div
|
||||
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
|
||||
<DropdownMenuRoot v-model:open="dropdownOpen" :modal="false">
|
||||
<div
|
||||
ref="toggleContainer"
|
||||
data-testid="view-mode-toggle"
|
||||
class="group pointer-events-auto relative inline-block rounded-lg bg-base-background p-1"
|
||||
:data-state="dropdownOpen ? 'open' : 'closed'"
|
||||
>
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
move-class="transition-[background-color,color,transform] duration-200"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Button
|
||||
v-for="seg in orderedSegments"
|
||||
:key="seg.mode"
|
||||
v-tooltip.bottom="{
|
||||
value: toggleModeTooltip(),
|
||||
value: seg.active
|
||||
? t('breadcrumbsMenu.workflowActions')
|
||||
: seg.switchTooltip,
|
||||
showDelay: 300,
|
||||
hideDelay: 300,
|
||||
pt: tooltipPt
|
||||
pt: seg.active ? undefined : tooltipPt
|
||||
}"
|
||||
type="button"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="
|
||||
canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
seg.active
|
||||
? t('breadcrumbsMenu.activeModeWorkflowActions', {
|
||||
mode: seg.label
|
||||
})
|
||||
: seg.switchLabel
|
||||
"
|
||||
variant="base"
|
||||
class="m-1"
|
||||
@pointerdown.stop
|
||||
@click="toggleLinearMode"
|
||||
:aria-haspopup="seg.active ? 'menu' : undefined"
|
||||
:aria-expanded="seg.active ? dropdownOpen : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex h-8 items-center gap-0 rounded-md font-normal transition-[background-color,color,transform] duration-200',
|
||||
seg.displayActive
|
||||
? 'bg-secondary-background pr-2 pl-2.5 text-base-foreground group-data-[state=open]:bg-secondary-background-hover group-data-[state=open]:shadow-interface hover:bg-secondary-background'
|
||||
: 'w-8 justify-center bg-transparent text-muted-foreground hover:bg-secondary-background hover:text-base-foreground'
|
||||
)
|
||||
"
|
||||
@click="onSegmentClick(seg)"
|
||||
@keydown="onSegmentKeydown(seg, $event)"
|
||||
>
|
||||
<i
|
||||
class="size-4"
|
||||
<i :class="cn('size-4 shrink-0', seg.icon)" aria-hidden="true" />
|
||||
<span
|
||||
:class="
|
||||
canvasStore.linearMode
|
||||
? 'icon-[lucide--panels-top-left]'
|
||||
: 'icon-[comfy--workflow]'
|
||||
cn(
|
||||
'grid transition-[grid-template-columns,opacity] duration-200',
|
||||
seg.displayActive
|
||||
? 'ml-1.5 grid-cols-[1fr] opacity-100'
|
||||
: 'grid-cols-[0fr] opacity-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center overflow-hidden text-sm leading-none whitespace-nowrap"
|
||||
>
|
||||
{{ seg.label }}
|
||||
<i
|
||||
class="ml-1 icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="seg.active && hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<span>{{
|
||||
canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.app')
|
||||
: t('breadcrumbsMenu.graph')
|
||||
}}</span>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
|
||||
/>
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
</slot>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:align
|
||||
:side-offset="5"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
:reference="toggleContainer ?? undefined"
|
||||
:side-offset="8"
|
||||
:collision-padding="10"
|
||||
class="z-1000 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
@interact-outside="onInteractOutside"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
>
|
||||
<WorkflowActionsList :items="menuItems" />
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -430,6 +430,14 @@ import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
getBaseThumbnailSrc,
|
||||
getEffectiveSourceModule,
|
||||
getOverlayThumbnailSrc,
|
||||
getTemplateDescription,
|
||||
getTemplateTitle,
|
||||
isAppTemplate
|
||||
} from '@/platform/workflow/templates/utils/templateUtil'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
@@ -468,28 +476,7 @@ provide(OnCloseKey, onClose)
|
||||
|
||||
// Workflow templates store and composable
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const {
|
||||
loadTemplates,
|
||||
loadWorkflowTemplate,
|
||||
getTemplateThumbnailUrl,
|
||||
getTemplateTitle,
|
||||
getTemplateDescription
|
||||
} = useTemplateWorkflows()
|
||||
|
||||
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
||||
template.sourceModule || 'default'
|
||||
|
||||
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
|
||||
|
||||
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
||||
}
|
||||
|
||||
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
|
||||
}
|
||||
const { loadTemplates, loadWorkflowTemplate } = useTemplateWorkflows()
|
||||
|
||||
// Open tutorial in new tab
|
||||
const openTutorial = (template: TemplateInfo) => {
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 text-gold-500')" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4')" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4')" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
@@ -154,12 +154,16 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
CREDITS_ICON,
|
||||
creditsToUsd,
|
||||
usdToCredits
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
@@ -178,7 +182,7 @@ const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
// Constants
|
||||
@@ -260,9 +264,9 @@ async function handleBuy() {
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
|
||||
// In workspace mode (personal workspace), show workspace settings panel
|
||||
// Otherwise, show legacy subscription/credits panel
|
||||
const settingsPanel = flags.teamWorkspacesEnabled
|
||||
// On the consolidated (workspace) billing flow, show the workspace settings
|
||||
// panel; otherwise show the legacy subscription/credits panel.
|
||||
const settingsPanel = shouldUseWorkspaceBilling.value
|
||||
? 'workspace'
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
|
||||
@@ -2,12 +2,11 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { defineComponent, nextTick, onMounted, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
|
||||
import type * as DistributionTypes from '@/platform/distribution/types'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
@@ -35,19 +34,29 @@ vi.mock('@/services/customerEventsService', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockTelemetry = vi.hoisted(() => ({
|
||||
checkForCompletedTopup: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
useTelemetry: () => mockTelemetry
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof DistributionTypes>()),
|
||||
isCloud: true
|
||||
const mockBillingRouting = vi.hoisted(() => ({
|
||||
shouldUseWorkspaceBilling: false
|
||||
}))
|
||||
vi.mock('@/composables/billing/useBillingRouting', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const shouldUseWorkspaceBilling = ref(false)
|
||||
Object.defineProperty(mockBillingRouting, 'shouldUseWorkspaceBilling', {
|
||||
get: () => shouldUseWorkspaceBilling.value,
|
||||
set: (value: boolean) => {
|
||||
shouldUseWorkspaceBilling.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useBillingRouting: () => ({ shouldUseWorkspaceBilling })
|
||||
}
|
||||
})
|
||||
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
getBillingEvents: vi.fn()
|
||||
@@ -68,7 +77,10 @@ const i18n = createI18n({
|
||||
additionalInfo: 'Additional Info',
|
||||
added: 'Added',
|
||||
accountInitialized: 'Account initialized',
|
||||
model: 'Model'
|
||||
model: 'Model',
|
||||
loadEventsError: 'Failed to load activity. Please try again.',
|
||||
loadEventsUnknownError:
|
||||
'Something went wrong while loading activity. Please refresh and try again.'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +107,11 @@ const AutoRefreshWrapper = defineComponent({
|
||||
template: '<UsageLogsTable ref="tableRef" />'
|
||||
})
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function makeEventsResponse(
|
||||
events: Partial<AuditLog>[],
|
||||
overrides: Record<string, unknown> = {}
|
||||
@@ -137,7 +154,7 @@ describe('UsageLogsTable', () => {
|
||||
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = false
|
||||
mockCustomerEventsService.formatEventType.mockImplementation(
|
||||
(type: string) => {
|
||||
switch (type) {
|
||||
@@ -228,7 +245,7 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error message when service throws', async () => {
|
||||
it('shows a localized fallback instead of a raw Error message', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockRejectedValue(
|
||||
new Error('Network error')
|
||||
)
|
||||
@@ -236,7 +253,25 @@ describe('UsageLogsTable', () => {
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Something went wrong while loading activity. Please refresh and try again.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Network error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a localized fallback when the service reports no message', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
|
||||
mockCustomerEventsService.error.value = null
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Failed to load activity. Please try again.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -341,8 +376,8 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
|
||||
describe('billing events source', () => {
|
||||
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
it('uses workspaceApi.getBillingEvents on the workspace billing flow', async () => {
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
|
||||
await renderLoaded()
|
||||
|
||||
@@ -352,6 +387,90 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards a stale legacy response when routing flips mid-fetch', async () => {
|
||||
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
||||
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLegacy = resolve
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'workspace-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
||||
createdAt: '2024-02-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
resolveLegacy(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'legacy-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'LegacyAPI', model: 'legacy-model' },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
expect(screen.queryByText('LegacyAPI')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('runs top-up completion telemetry for a superseded response', async () => {
|
||||
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
||||
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLegacy = resolve
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'workspace-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
||||
createdAt: '2024-02-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const legacyResponse = makeEventsResponse([
|
||||
{
|
||||
event_id: 'legacy-1',
|
||||
event_type: EventType.CREDIT_ADDED,
|
||||
params: { amount: 1000 },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
resolveLegacy(legacyResponse)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTelemetry.checkForCompletedTopup).toHaveBeenCalledWith(
|
||||
legacyResponse.events
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
|
||||
@@ -96,11 +96,11 @@ import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
@@ -109,14 +109,15 @@ import {
|
||||
useCustomerEventsService
|
||||
} from '@/services/customerEventsService'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const events = ref<AuditLog[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
@@ -139,7 +140,12 @@ const tooltipContentMap = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
// A billing-route flip can overlap two loads against different backends; only
|
||||
// the latest may mutate state, so a superseded response is discarded.
|
||||
let latestLoadToken = 0
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadToken = ++latestLoadToken
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -148,10 +154,17 @@ const loadEvents = async () => {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit
|
||||
}
|
||||
const response = useBillingApi.value
|
||||
const response = shouldUseWorkspaceBilling.value
|
||||
? await workspaceApi.getBillingEvents(params)
|
||||
: await customerEventService.getMyEvents(params)
|
||||
|
||||
// Completion telemetry must run even when a mid-checkout route flip
|
||||
// supersedes this load, since legacy and workspace backends emit different
|
||||
// top-up events and the winning fetch may not carry the completion yet.
|
||||
useTelemetry()?.checkForCompletedTopup(response?.events)
|
||||
|
||||
if (loadToken !== latestLoadToken) return
|
||||
|
||||
if (response) {
|
||||
if (response.events) {
|
||||
events.value = response.events
|
||||
@@ -165,24 +178,25 @@ const loadEvents = async () => {
|
||||
pagination.value.limit = response.limit
|
||||
}
|
||||
|
||||
if (response.total) {
|
||||
if (response.total != null) {
|
||||
pagination.value.total = response.total
|
||||
}
|
||||
|
||||
if (response.totalPages) {
|
||||
if (response.totalPages != null) {
|
||||
pagination.value.totalPages = response.totalPages
|
||||
}
|
||||
|
||||
// Check if a pending top-up has completed
|
||||
useTelemetry()?.checkForCompletedTopup(response.events)
|
||||
} else {
|
||||
error.value = customerEventService.error.value || 'Failed to load events'
|
||||
const legacyError = shouldUseWorkspaceBilling.value
|
||||
? null
|
||||
: customerEventService.error.value
|
||||
error.value = legacyError || t('credits.loadEventsError')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
if (loadToken !== latestLoadToken) return
|
||||
error.value = t('credits.loadEventsUnknownError')
|
||||
console.error('Error loading events:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (loadToken === latestLoadToken) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +212,12 @@ const refresh = async () => {
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
watch(shouldUseWorkspaceBilling, () => {
|
||||
refresh().catch((error) => {
|
||||
console.error('Error loading events:', error)
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
refresh
|
||||
})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
/** Shared PrimeVue/Reka modal stacking sequence; later registrations cover earlier ones. */
|
||||
export const MODAL_Z_KEY = 'modal'
|
||||
export const MODAL_Z_BASE = 1700
|
||||
|
||||
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
|
||||
// any order. PrimeVue auto-increments a per-key z-index counter so later
|
||||
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
|
||||
@@ -9,7 +13,7 @@ import type { Directive } from 'vue'
|
||||
// renderers share one stacking sequence: whichever dialog opens last wins.
|
||||
export const vRekaZIndex: Directive<HTMLElement> = {
|
||||
mounted(el) {
|
||||
ZIndex.set('modal', el, 1700)
|
||||
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
|
||||
},
|
||||
beforeUnmount(el) {
|
||||
ZIndex.clear(el)
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI && !isBuilderMode" #side-toolbar>
|
||||
<SideToolbar />
|
||||
<template #side-toolbar>
|
||||
<SideToolbar v-if="showUI && !isBuilderMode && !linearMode" />
|
||||
</template>
|
||||
<template v-if="showUI" #side-bar-panel>
|
||||
<div
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--component] h-full bg-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'h-full bg-amber-400')" />
|
||||
<span class="truncate" v-text="text" />
|
||||
</span>
|
||||
<span
|
||||
@@ -21,6 +21,8 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
|
||||
defineProps<{
|
||||
text: string
|
||||
rest?: string
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--component] size-3 text-amber-400"
|
||||
:class="cn(CREDITS_ICON, 'size-3 text-amber-400')"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
@@ -134,6 +134,8 @@ import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
|
||||
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
|
||||
const {
|
||||
nodeDef,
|
||||
currentQuery,
|
||||
|
||||
195
src/components/sidebar/SideToolbar.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SideToolbar from './SideToolbar.vue'
|
||||
|
||||
interface TestTab {
|
||||
id: string
|
||||
icon: string
|
||||
tooltip: string
|
||||
label: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const spies = vi.hoisted(() => ({
|
||||
trackUiButtonClicked: vi.fn(),
|
||||
toggleAssets: vi.fn()
|
||||
}))
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
isMultiUserServer: false,
|
||||
sidebarTabs: [] as TestTab[],
|
||||
activeSidebarTab: null as { id: string } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({
|
||||
getSidebarTabs: () => state.sidebarTabs,
|
||||
sidebarTab: { activeSidebarTab: state.activeSidebarTab }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => {
|
||||
if (key === 'Comfy.Sidebar.Size') return 'large'
|
||||
if (key === 'Comfy.Sidebar.Location') return 'left'
|
||||
return 'floating'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/userStore', () => ({
|
||||
useUserStore: () => ({ isMultiUserServer: state.isMultiUserServer })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
commands: [
|
||||
{ id: 'Workspace.ToggleSidebarTab.assets', function: spies.toggleAssets }
|
||||
]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: null })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({ getKeybindingByCommandId: () => undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
type SideToolbarProps = ComponentProps<typeof SideToolbar>
|
||||
|
||||
function renderToolbar(props: SideToolbarProps = {}) {
|
||||
return render(SideToolbar, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
directives: { tooltip: Tooltip },
|
||||
stubs: {
|
||||
ComfyMenuButton: { template: '<div />' },
|
||||
SidebarTemplatesButton: { template: '<div />' },
|
||||
SidebarLogoutIcon: { template: '<div data-testid="logout" />' },
|
||||
SidebarHelpCenterIcon: { template: '<div />' },
|
||||
SidebarSettingsButton: { template: '<div />' },
|
||||
HelpCenterPopups: { template: '<div />' },
|
||||
SidebarBottomPanelToggleButton: {
|
||||
template: '<div data-testid="bottom-panel-toggle" />'
|
||||
},
|
||||
SidebarShortcutsToggleButton: {
|
||||
template: '<div data-testid="shortcuts-toggle" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const assetsTab: TestTab = {
|
||||
id: 'assets',
|
||||
icon: 'pi pi-image',
|
||||
tooltip: 'Assets',
|
||||
label: 'Assets',
|
||||
title: 'Assets'
|
||||
}
|
||||
|
||||
const workflowsTab: TestTab = {
|
||||
id: 'workflows',
|
||||
icon: 'pi pi-folder',
|
||||
tooltip: 'Workflows',
|
||||
label: 'Workflows',
|
||||
title: 'Workflows'
|
||||
}
|
||||
|
||||
describe('SideToolbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.isMultiUserServer = false
|
||||
state.sidebarTabs = [assetsTab, workflowsTab]
|
||||
state.activeSidebarTab = null
|
||||
})
|
||||
|
||||
it('renders only the tabs listed in visibleTabIds', () => {
|
||||
renderToolbar({ visibleTabIds: ['assets'] })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Assets' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Workflows' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all sidebar tabs when visibleTabIds is omitted', () => {
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Assets' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Workflows' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the toolbar as connected when forceConnected is true', () => {
|
||||
renderToolbar({ forceConnected: true })
|
||||
|
||||
// connected-sidebar is a behavioral hook: it drives the global
|
||||
// :root:has() sidebar width variables.
|
||||
expect(screen.getByTestId('side-toolbar')).toHaveClass('connected-sidebar')
|
||||
})
|
||||
|
||||
it('shows the shortcuts and bottom panel toggles by default', () => {
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByTestId('shortcuts-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bottom-panel-toggle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the shortcuts and bottom panel toggles when hideWorkspaceToggles is set', () => {
|
||||
renderToolbar({ hideWorkspaceToggles: true })
|
||||
|
||||
expect(screen.queryByTestId('shortcuts-toggle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bottom-panel-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports telemetry and runs the toggle command when a tab is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderToolbar({ visibleTabIds: ['assets'] })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Assets' }))
|
||||
|
||||
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'sidebar_tab_assets_media_selected',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
expect(spies.toggleAssets).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the logout icon only on a multi-user server', () => {
|
||||
const { unmount } = renderToolbar()
|
||||
expect(screen.queryByTestId('logout')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
state.isMultiUserServer = true
|
||||
renderToolbar()
|
||||
expect(screen.getByTestId('logout')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@
|
||||
<SidebarIcon
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
v-coachmark="tab.id === 'assets' ? 'assets-button' : undefined"
|
||||
:icon="tab.icon"
|
||||
:icon-badge="tab.iconBadge"
|
||||
:tooltip="tab.tooltip"
|
||||
@@ -42,8 +43,14 @@
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton
|
||||
v-if="!isCloud && !hideWorkspaceToggles"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarShortcutsToggleButton
|
||||
v-if="!hideWorkspaceToggles"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,6 +80,7 @@ import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { vCoachmark } from '@/platform/onboarding/vCoachmark'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -89,6 +97,16 @@ import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
|
||||
|
||||
const {
|
||||
visibleTabIds,
|
||||
forceConnected = false,
|
||||
hideWorkspaceToggles = false
|
||||
} = defineProps<{
|
||||
visibleTabIds?: string[]
|
||||
forceConnected?: boolean
|
||||
hideWorkspaceToggles?: boolean
|
||||
}>()
|
||||
|
||||
const NightlySurveyController =
|
||||
isNightly && !isCloud && !isDesktop
|
||||
? defineAsyncComponent(
|
||||
@@ -115,12 +133,18 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
forceConnected ||
|
||||
selectedTab.value ||
|
||||
isOverflowing.value ||
|
||||
sidebarStyle.value === 'connected'
|
||||
)
|
||||
|
||||
const tabs = computed(() => workspaceStore.getSidebarTabs())
|
||||
const tabs = computed(() => {
|
||||
const all = workspaceStore.getSidebarTabs()
|
||||
return visibleTabIds
|
||||
? all.filter((tab) => visibleTabIds.includes(tab.id))
|
||||
: all
|
||||
})
|
||||
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
|
||||
/**
|
||||
|
||||
150
src/components/sidebar/SidebarHelpCenterIcon.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
|
||||
|
||||
const typeformState = vi.hoisted(() => ({
|
||||
typeformError: false,
|
||||
isValidTypeformId: true,
|
||||
typeformId: 'jmmzmlKw'
|
||||
}))
|
||||
|
||||
const canvasState = vi.hoisted(() => ({ linearMode: true }))
|
||||
|
||||
const helpCenterSpies = vi.hoisted(() => ({ toggleHelpCenter: vi.fn() }))
|
||||
|
||||
vi.mock('@/platform/surveys/useTypeformEmbed', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useTypeformEmbed: () => ({
|
||||
typeformError: computed(() => typeformState.typeformError),
|
||||
isValidTypeformId: computed(() => typeformState.isValidTypeformId),
|
||||
typeformId: computed(() => typeformState.typeformId)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useHelpCenter', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useHelpCenter: () => ({
|
||||
shouldShowRedDot: ref(false),
|
||||
toggleHelpCenter: helpCenterSpies.toggleHelpCenter
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: () => 'left' })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', async () => {
|
||||
const { computed, reactive } = await import('vue')
|
||||
return {
|
||||
useCanvasStore: () =>
|
||||
reactive({ linearMode: computed(() => canvasState.linearMode) })
|
||||
}
|
||||
})
|
||||
|
||||
const FEEDBACK_LOAD_ERROR =
|
||||
'Failed to load feedback form. Please try again later.'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
menu: { help: 'Help' },
|
||||
sideToolbar: { helpCenter: 'Help Center' },
|
||||
linearMode: {
|
||||
giveFeedback: 'Give feedback',
|
||||
feedbackLoadError: FEEDBACK_LOAD_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderIcon() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(SidebarHelpCenterIcon, {
|
||||
props: { isSmall: false },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
Popover: {
|
||||
template: '<div><slot name="button" /><slot /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('SidebarHelpCenterIcon', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
typeformState.typeformError = false
|
||||
typeformState.isValidTypeformId = true
|
||||
canvasState.linearMode = true
|
||||
})
|
||||
|
||||
it('mounts the Typeform embed container when the id is valid and loads', () => {
|
||||
const { container } = renderIcon()
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).not.toBeNull()
|
||||
expect(screen.queryByText(FEEDBACK_LOAD_ERROR)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the localized fallback instead of the embed when loading fails', () => {
|
||||
typeformState.typeformError = true
|
||||
const { container } = renderIcon()
|
||||
|
||||
expect(screen.getByText(FEEDBACK_LOAD_ERROR)).toBeInTheDocument()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the localized fallback when the form id is invalid', () => {
|
||||
typeformState.isValidTypeformId = false
|
||||
const { container } = renderIcon()
|
||||
|
||||
expect(screen.getByText(FEEDBACK_LOAD_ERROR)).toBeInTheDocument()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not open the help center from the feedback button in app mode', async () => {
|
||||
const { user } = renderIcon()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Give feedback' }))
|
||||
|
||||
expect(helpCenterSpies.toggleHelpCenter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the help center button instead of the feedback popover in graph mode', () => {
|
||||
canvasState.linearMode = false
|
||||
const { container } = renderIcon()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Help Center' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Give feedback' })
|
||||
).not.toBeInTheDocument()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).toBeNull()
|
||||
})
|
||||
|
||||
it('toggles the help center on click in graph mode', async () => {
|
||||
canvasState.linearMode = false
|
||||
const { user } = renderIcon()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Help Center' }))
|
||||
|
||||
expect(helpCenterSpies.toggleHelpCenter).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,34 @@
|
||||
<template>
|
||||
<Popover
|
||||
v-if="linearMode"
|
||||
:side="sidebarOnLeft ? 'right' : 'left'"
|
||||
:side-offset="8"
|
||||
>
|
||||
<template #button>
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
data-testid="help-center-button"
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('linearMode.giveFeedback')"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-if="typeformError || !isValidTypeformId"
|
||||
class="text-danger p-4 text-sm"
|
||||
>
|
||||
{{ $t('linearMode.feedbackLoadError') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="feedbackRef"
|
||||
data-tf-auto-resize
|
||||
:data-tf-widget="typeformId"
|
||||
/>
|
||||
</Popover>
|
||||
<SidebarIcon
|
||||
v-else
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
data-testid="help-center-button"
|
||||
@@ -13,13 +42,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTypeformEmbed } from '@/platform/surveys/useTypeformEmbed'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const APP_MODE_FEEDBACK_TYPEFORM_ID = 'jmmzmlKw'
|
||||
|
||||
defineProps<{
|
||||
isSmall: boolean
|
||||
}>()
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
|
||||
const { linearMode } = storeToRefs(useCanvasStore())
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarOnLeft = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
)
|
||||
|
||||
const feedbackRef = useTemplateRef<HTMLDivElement>('feedbackRef')
|
||||
const { typeformError, isValidTypeformId, typeformId } = useTypeformEmbed(
|
||||
feedbackRef,
|
||||
APP_MODE_FEEDBACK_TYPEFORM_ID
|
||||
)
|
||||
</script>
|
||||
|
||||
207
src/components/sidebar/tabs/AppsSidebarTab.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
import AppsSidebarTab from './AppsSidebarTab.vue'
|
||||
|
||||
const execute = vi.hoisted(() => vi.fn())
|
||||
|
||||
const workflowStoreState = vi.hoisted(() => ({
|
||||
persistedWorkflows: [] as ComfyWorkflow[]
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
const { ComfyWorkflow } =
|
||||
await import('@/platform/workflow/management/stores/comfyWorkflow')
|
||||
return {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore: () => ({
|
||||
get workflows() {
|
||||
return workflowStoreState.persistedWorkflows
|
||||
},
|
||||
get persistedWorkflows() {
|
||||
return workflowStoreState.persistedWorkflows
|
||||
},
|
||||
bookmarkedWorkflows: [],
|
||||
openWorkflows: [],
|
||||
activeWorkflow: undefined,
|
||||
isSyncLoading: false,
|
||||
syncWorkflows: vi.fn()
|
||||
}),
|
||||
useWorkflowBookmarkStore: () => ({ loadBookmarks: vi.fn() })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
|
||||
useSearchQueryTracking: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({ shiftDown: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return { useAppMode: () => ({ isAppMode: computed(() => true) }) }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: () => undefined })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
beta: 'Beta',
|
||||
refresh: 'Refresh',
|
||||
searchPlaceholder: 'Search {subject}'
|
||||
},
|
||||
sideToolbar: {
|
||||
workflowTab: {
|
||||
workflowTreeType: {
|
||||
open: 'Open',
|
||||
bookmarks: 'Bookmarks',
|
||||
browse: 'Browse'
|
||||
}
|
||||
}
|
||||
},
|
||||
linearMode: {
|
||||
appModeToolbar: {
|
||||
apps: 'Apps',
|
||||
create: 'Create',
|
||||
createApp: 'Create app',
|
||||
appsEmptyMessage: 'No apps yet',
|
||||
appsEmptyMessageAction: 'Create one to get started'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const noResultsPlaceholderStub = {
|
||||
props: ['buttonLabel'],
|
||||
emits: ['action'],
|
||||
template: '<button @click="$emit(\'action\')">{{ buttonLabel }}</button>'
|
||||
}
|
||||
|
||||
function renderTab({ hasResults = true }: { hasResults?: boolean } = {}) {
|
||||
const user = userEvent.setup()
|
||||
const result = render(AppsSidebarTab, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
BaseWorkflowsSidebarTab: {
|
||||
template: `<div><slot name="header-actions" :has-results="${hasResults}" /><slot name="empty-state" /></div>`
|
||||
},
|
||||
NoResultsPlaceholder: noResultsPlaceholderStub
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
async function makeWorkflow(path: string): Promise<ComfyWorkflow> {
|
||||
const { ComfyWorkflow } =
|
||||
await import('@/platform/workflow/management/stores/comfyWorkflow')
|
||||
return new ComfyWorkflow({ path, modified: 0, size: 1 })
|
||||
}
|
||||
|
||||
function renderTabWithRealBase() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(AppsSidebarTab, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
SidebarTabTemplate: {
|
||||
template:
|
||||
'<div><slot name="alt-title" /><slot name="tool-buttons" /><slot name="header" /><slot name="body" /></div>'
|
||||
},
|
||||
SidebarTopArea: { template: '<div><slot /></div>' },
|
||||
SearchInput: { template: '<input />', methods: { focus() {} } },
|
||||
TreeExplorer: { template: '<div data-testid="tree-explorer" />' },
|
||||
NoResultsPlaceholder: noResultsPlaceholderStub
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('AppsSidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
workflowStoreState.persistedWorkflows = []
|
||||
})
|
||||
|
||||
it('shows the create action only when there are results', () => {
|
||||
const { unmount } = renderTab({ hasResults: false })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Create' })
|
||||
).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
renderTab({ hasResults: true })
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('runs the new-workflow command when the create action is clicked', async () => {
|
||||
const { user } = renderTab({ hasResults: true })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
|
||||
})
|
||||
|
||||
it('runs the new-workflow command from the empty-state action', async () => {
|
||||
const { user } = renderTab({ hasResults: false })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create app' }))
|
||||
|
||||
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
|
||||
})
|
||||
|
||||
describe('with the real workflows tab', () => {
|
||||
it('counts only app workflows as results', async () => {
|
||||
workflowStoreState.persistedWorkflows = [
|
||||
await makeWorkflow('workflows/my-app.app.json'),
|
||||
await makeWorkflow('workflows/regular.json')
|
||||
]
|
||||
|
||||
renderTabWithRealBase()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Create app' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the empty state when no app workflows exist', async () => {
|
||||
workflowStoreState.persistedWorkflows = [
|
||||
await makeWorkflow('workflows/regular.json')
|
||||
]
|
||||
|
||||
renderTabWithRealBase()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Create' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Create app' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,18 +13,25 @@
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #header-actions="{ hasResults }">
|
||||
<Button
|
||||
v-if="hasResults"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
@click="createApp"
|
||||
>
|
||||
<i class="icon-[lucide--plus] size-4" aria-hidden="true" />
|
||||
{{ $t('linearMode.appModeToolbar.create') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<NoResultsPlaceholder
|
||||
button-variant="secondary"
|
||||
text-class="text-muted-foreground text-sm"
|
||||
:message="
|
||||
isAppMode
|
||||
? $t('linearMode.appModeToolbar.appsEmptyMessage')
|
||||
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
|
||||
"
|
||||
button-icon="icon-[lucide--hammer]"
|
||||
:button-label="isAppMode ? undefined : $t('linearMode.buildAnApp')"
|
||||
@action="enterAppMode"
|
||||
:message="`${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`"
|
||||
button-icon="icon-[lucide--plus]"
|
||||
:button-label="$t('linearMode.appModeToolbar.createApp')"
|
||||
@action="createApp"
|
||||
/>
|
||||
</template>
|
||||
</BaseWorkflowsSidebarTab>
|
||||
@@ -33,16 +40,17 @@
|
||||
<script setup lang="ts">
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { isAppMode, setMode } = useAppMode()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
|
||||
return workflow.suffix === 'app.json'
|
||||
}
|
||||
|
||||
function enterAppMode() {
|
||||
setMode('app')
|
||||
function createApp() {
|
||||
void commandStore.execute('Comfy.NewBlankWorkflow')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<slot
|
||||
name="header-actions"
|
||||
:has-results="filteredPersistedWorkflows.length > 0"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
<SidebarTopArea>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'text-sm text-amber-400')" />
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
@@ -153,7 +153,12 @@ import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import {
|
||||
CREDITS_ICON,
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { active = true } = defineProps<{
|
||||
dataTfWidget: string
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const feedbackRef = useTemplateRef('feedbackRef')
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
whenever(feedbackRef, () => {
|
||||
const scriptEl = document.createElement('script')
|
||||
scriptEl.src = '//embed.typeform.com/next/embed.js'
|
||||
feedbackRef.value?.appendChild(scriptEl)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Button
|
||||
v-if="isMobile"
|
||||
as="a"
|
||||
:href="`https://form.typeform.com/to/${dataTfWidget}`"
|
||||
target="_blank"
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
<Popover v-else>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<div v-if="active" ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -25,7 +25,8 @@ export const buttonVariants = cva({
|
||||
tertiary:
|
||||
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
|
||||
gradient:
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
|
||||
'brand-yellow': 'bg-brand-yellow text-black hover:bg-brand-yellow/85'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
@@ -58,7 +59,8 @@ const variants = [
|
||||
'base',
|
||||
'tertiary',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
'gradient',
|
||||
'brand-yellow'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = [
|
||||
'sm',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
@@ -223,7 +224,8 @@ const { t } = useI18n()
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[comfy--credits] size-3 shrink-0',
|
||||
CREDITS_ICON,
|
||||
'size-3 shrink-0',
|
||||
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
|
||||
)
|
||||
"
|
||||
|
||||
35
src/components/ui/tooltip/Tooltip.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipRoot,
|
||||
TooltipTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { text, side = 'top' } = defineProps<{
|
||||
text: string
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="300">
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child v-bind="$attrs">
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
:side
|
||||
:side-offset="5"
|
||||
class="z-1700 rounded-md border border-border-subtle bg-base-background px-2 py-1 text-xs shadow-sm"
|
||||
>
|
||||
{{ text }}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
@@ -19,6 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockConsolidatedBillingEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
@@ -26,6 +27,7 @@ const {
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockConsolidatedBillingEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
@@ -57,11 +59,23 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
const consolidatedBillingEnabledRef = ref(
|
||||
mockConsolidatedBillingEnabled.value
|
||||
)
|
||||
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
|
||||
get: () => consolidatedBillingEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
consolidatedBillingEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
},
|
||||
get consolidatedBillingEnabled() {
|
||||
return mockConsolidatedBillingEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -151,6 +165,7 @@ describe('useBillingContext', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
@@ -162,16 +177,27 @@ describe('useBillingContext', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when team workspaces are enabled', () => {
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when consolidated billing is enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team when team workspaces are enabled', () => {
|
||||
it('selects workspace type for team regardless of consolidated billing', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
@@ -272,6 +298,7 @@ describe('useBillingContext', () => {
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -280,9 +307,27 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
await nextTick()
|
||||
expect(type.value).toBe('legacy')
|
||||
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
|
||||
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
@@ -294,6 +339,20 @@ describe('useBillingContext', () => {
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
|
||||
it('never clobbers the list-derived store when a subscription is absent', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalledWith({
|
||||
isSubscribed: false,
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaxSeats', () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
KEY_TO_TIER,
|
||||
getTierFeatures
|
||||
@@ -18,10 +17,10 @@ import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingContext,
|
||||
BillingType,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
import { useBillingRouting } from './useBillingRouting'
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
|
||||
|
||||
@@ -35,8 +34,9 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
* Unified billing context that selects the billing implementation by build/flag.
|
||||
*
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for team
|
||||
* workspaces, and for personal workspaces once consolidated billing is
|
||||
* enabled; personal workspaces otherwise stay on legacy billing
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -69,7 +69,7 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
*/
|
||||
function useBillingContextInternal(): BillingContext {
|
||||
const store = useTeamWorkspaceStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
|
||||
null
|
||||
@@ -96,16 +96,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use, keyed only on the build/flag:
|
||||
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
|
||||
* - Team workspaces feature enabled: workspace (/api/billing), for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*/
|
||||
const type = computed<BillingType>(() =>
|
||||
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
|
||||
)
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
)
|
||||
@@ -170,9 +160,12 @@ function useBillingContextInternal(): BillingContext {
|
||||
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
|
||||
}
|
||||
|
||||
// Sync subscription info to workspace store for display in workspace switcher
|
||||
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
||||
// This ensures the delete button is enabled after cancellation, even before the period ends
|
||||
// Sync subscription info to workspace store for display in workspace switcher.
|
||||
// Subscribed means active AND not cancelled, so the delete button enables
|
||||
// after cancellation, even before the period ends. A null subscription means
|
||||
// "not loaded yet" (adapters are discarded on every workspace/type switch);
|
||||
// skip it so the transient reinit gap can't clobber the list-derived baseline
|
||||
// (personal workspaces and subscribed teams already read subscribed there).
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
@@ -186,24 +179,27 @@ function useBillingContextInternal(): BillingContext {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Discarding the adapter instances forces a fresh fetch and lets an in-flight
|
||||
// init detect that it was superseded (its captured adapter is no longer the
|
||||
// active one), so a stale response can't resolve into a ready state for the
|
||||
// wrong workspace.
|
||||
function resetBillingState() {
|
||||
legacyBillingRef.value = null
|
||||
workspaceBillingRef.value = null
|
||||
isInitialized.value = false
|
||||
isLoading.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type can flip after setup when the team-workspaces flag resolves from
|
||||
// authenticated config, swapping the active backend; a fresh init is needed.
|
||||
// The watch fires only when id or type actually changes, so any fire with a
|
||||
// workspace selected warrants a reinit.
|
||||
// type flips when the team-workspaces or consolidated-billing flag resolves
|
||||
// from authenticated config, swapping the active backend. Reset then reinit
|
||||
// on every workspace-id or type change.
|
||||
watch(
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
if (!newWorkspaceId) {
|
||||
resetBillingState()
|
||||
return
|
||||
}
|
||||
resetBillingState()
|
||||
if (!newWorkspaceId) return
|
||||
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
@@ -216,17 +212,20 @@ function useBillingContextInternal(): BillingContext {
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
const adapter = activeContext.value
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await activeContext.value.initialize()
|
||||
await adapter.initialize()
|
||||
if (activeContext.value !== adapter) return
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
if (activeContext.value !== adapter) return
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
if (activeContext.value === adapter) isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
99
src/composables/billing/useBillingRouting.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useBillingRouting } from './useBillingRouting'
|
||||
|
||||
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
|
||||
mockFlags: {
|
||||
teamWorkspacesEnabled: false,
|
||||
consolidatedBillingEnabled: false
|
||||
},
|
||||
mockActiveWorkspace: {
|
||||
value: null as { id: string; type: 'personal' | 'team' } | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get activeWorkspace() {
|
||||
return mockActiveWorkspace.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const personal = { id: 'w-personal', type: 'personal' as const }
|
||||
const team = { id: 'w-team', type: 'team' as const }
|
||||
|
||||
describe('useBillingRouting', () => {
|
||||
beforeEach(() => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
})
|
||||
|
||||
it('uses legacy billing when team workspaces are disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('moves personal to workspace billing when consolidated billing is enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to legacy while the workspace has not loaded', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = null
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
})
|
||||
36
src/composables/billing/useBillingRouting.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type { BillingType } from './types'
|
||||
|
||||
/**
|
||||
* Selects the billing backend for the active workspace: legacy user-scoped
|
||||
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
|
||||
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
|
||||
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
|
||||
*/
|
||||
export function useBillingRouting() {
|
||||
const { flags } = useFeatureFlags()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
|
||||
// An unloaded workspace has no type yet; stay legacy so bootstrap never
|
||||
// eagerly routes to workspace billing.
|
||||
const workspaceType = workspaceStore.activeWorkspace?.type
|
||||
if (!workspaceType) return 'legacy'
|
||||
|
||||
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
|
||||
return 'legacy'
|
||||
}
|
||||
|
||||
return 'workspace'
|
||||
})
|
||||
|
||||
const shouldUseWorkspaceBilling = computed(() => type.value === 'workspace')
|
||||
|
||||
return { type, shouldUseWorkspaceBilling }
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
useFeatureFlags
|
||||
} from '@/composables/useFeatureFlags'
|
||||
import * as distributionTypes from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// Mock the API module
|
||||
@@ -219,6 +225,86 @@ describe('useFeatureFlags', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth-gated flags on cloud', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(distributionTypes).isCloud = true
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns the cached session value during the auth window', () => {
|
||||
cachedTeamWorkspacesEnabled.value = false
|
||||
cachedConsolidatedBillingEnabled.value = true
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false during the auth window when nothing is cached', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers authenticated remoteConfig over the server feature fallback', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
}
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {}
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
|
||||
return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('signupTurnstileMode', () => {
|
||||
|
||||