Compare commits
44 Commits
codex/code
...
nathaniel/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0cf34dd1 | ||
|
|
5022ee8979 | ||
|
|
a02279ec92 | ||
|
|
517d6d3df8 | ||
|
|
128e8f7bd7 | ||
|
|
63690cca50 | ||
|
|
887e3a587a | ||
|
|
37a5554962 | ||
|
|
a0983fd2a4 | ||
|
|
375e808973 | ||
|
|
5ee3576062 | ||
|
|
7aee27ebfb | ||
|
|
010389903d | ||
|
|
30ef8b3b8c | ||
|
|
c931e75550 | ||
|
|
684b0b08b0 | ||
|
|
749e473192 | ||
|
|
95b121bed9 | ||
|
|
d5aae1a24c | ||
|
|
baeb6df662 | ||
|
|
e58b231664 | ||
|
|
545b48ee5b | ||
|
|
b83df7704e | ||
|
|
944ff2c009 | ||
|
|
22ea53fb56 | ||
|
|
17bf3643e9 | ||
|
|
37632ad3b8 | ||
|
|
ad150c88b3 | ||
|
|
9fe5dd51b8 | ||
|
|
88cdef79f5 | ||
|
|
500a6d9c00 | ||
|
|
028b15c3c9 | ||
|
|
442b49b3e5 | ||
|
|
50d510f344 | ||
|
|
2675779e20 | ||
|
|
084fe439c0 | ||
|
|
42a7681a3a | ||
|
|
fd83e792b4 | ||
|
|
4a6a877823 | ||
|
|
f414df4d14 | ||
|
|
b82d7d5c3a | ||
|
|
083cb95f1f | ||
|
|
c11f28f363 | ||
|
|
3d1c489b34 |
4
.github/workflows/pr-cursor-review.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
|
||||
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
|
||||
# from the same commit as the workflow definition.
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
|
||||
with:
|
||||
# Overriding diff_excludes replaces the reusable default wholesale, so
|
||||
# this restates the generated/vendored defaults and adds this repo's heavy
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
:!**/*-snapshots/**
|
||||
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
|
||||
# Load the prompts/scripts from the same ref as `uses:`.
|
||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
||||
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
|
||||
secrets:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
# Optional — enables start/complete Slack DMs to the triggerer.
|
||||
|
||||
@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('AIModelsSection heading and 5 model cards are visible', async ({
|
||||
test('AIModelsSection heading and 6 model cards are visible', async ({
|
||||
page
|
||||
}) => {
|
||||
const heading = page.getByRole('heading', { name: /leading AI models/i })
|
||||
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
const section = heading.locator('xpath=ancestor::section')
|
||||
const grid = section.locator('.grid')
|
||||
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
|
||||
await expect(modelCards).toHaveCount(5)
|
||||
await expect(modelCards).toHaveCount(6)
|
||||
})
|
||||
|
||||
test('AIModelsSection CTA links to workflows', async ({ page }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
@@ -33,7 +33,7 @@ const ctaButtons = [
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
|
||||
@@ -30,7 +30,12 @@ const { title, description, cta, href, bg } = defineProps<{
|
||||
<p class="text-sm text-white/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
<Button as="span" variant="default" size="sm" class="mt-4">
|
||||
<Button
|
||||
as="span"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="mt-4 h-auto whitespace-normal"
|
||||
>
|
||||
{{ cta }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
38
apps/website/src/components/ui/icon-button/IconButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { IconButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { iconButtonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: IconButtonVariants['variant']
|
||||
size?: IconButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
disabled
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="icon-button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(iconButtonVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
28
apps/website/src/components/ui/icon-button/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const iconButtonVariants = cva(
|
||||
[
|
||||
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
ghost:
|
||||
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
default: 'size-10',
|
||||
lg: 'size-14'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'ghost',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>
|
||||
75
apps/website/src/composables/useBannerDismissal.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
|
||||
|
||||
type ClosedBanners = Record<string, boolean>
|
||||
|
||||
function readClosedBanners(): ClosedBanners {
|
||||
try {
|
||||
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeClosedBanners(value: ClosedBanners): void {
|
||||
try {
|
||||
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
// Storage unavailable (private mode / quota) — dismissal just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
/** The stable part of a version key (everything before `_v<hash>`). */
|
||||
function versionPrefix(version: string): string {
|
||||
const idx = version.lastIndexOf('_v')
|
||||
return idx === -1 ? version : version.slice(0, idx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side dismissal persisted in localStorage, keyed by a content-aware
|
||||
* `version`. The banner renders visible in the static HTML (so non-dismissers
|
||||
* see no pop-in); an inline pre-hydration script hides an already-dismissed
|
||||
* banner before paint, and this composable then removes it from the DOM on mount.
|
||||
*/
|
||||
export function useBannerDismissal(version: string) {
|
||||
const isVisible = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
const stored = readClosedBanners()
|
||||
const prefix = versionPrefix(version)
|
||||
|
||||
// Prune stale versions of THIS banner+locale; keep other banners/locales
|
||||
// and the current version.
|
||||
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
|
||||
let pruned = false
|
||||
for (const key of Object.keys(stored)) {
|
||||
if (versionPrefix(key) !== prefix || key === version) {
|
||||
cleaned[key] = stored[key]
|
||||
} else {
|
||||
pruned = true
|
||||
}
|
||||
}
|
||||
if (pruned) writeClosedBanners(cleaned)
|
||||
|
||||
isVisible.value = !cleaned[version]
|
||||
})
|
||||
|
||||
function close(): void {
|
||||
isVisible.value = false
|
||||
const stored = readClosedBanners()
|
||||
stored[version] = true
|
||||
writeClosedBanners(stored)
|
||||
}
|
||||
|
||||
// Call once the close transition has finished. Sets the pre-paint hide signal
|
||||
// so the banner doesn't flash back in on a ClientRouter (view-transition)
|
||||
// navigation — where the inline <head> script does not re-run but <html>
|
||||
// persists. Deferred to after the animation so the leave transition can play.
|
||||
function persistHidden(): void {
|
||||
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
|
||||
}
|
||||
|
||||
return { isVisible, close, persistHidden }
|
||||
}
|
||||
84
apps/website/src/config/banner.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { ButtonVariants } from '../components/ui/button'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { t } from '../i18n/translations'
|
||||
import { resolveRel } from '../utils/cta'
|
||||
|
||||
// The banner "CMS": a single typed config resolved through i18n at build time.
|
||||
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
|
||||
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
|
||||
// window gates on the last deploy, not the visitor's exact clock.
|
||||
|
||||
interface BannerLinkConfig {
|
||||
readonly href: string
|
||||
readonly titleKey: TranslationKey
|
||||
readonly target?: boolean
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerConfig {
|
||||
readonly id: string
|
||||
readonly isActive: boolean
|
||||
readonly startsAt?: string
|
||||
readonly endsAt?: string
|
||||
/** Empty/undefined = all locales. */
|
||||
readonly targetLocales?: readonly Locale[]
|
||||
/** v1 only supports 'sitewide'. */
|
||||
readonly targetSections?: readonly string[]
|
||||
readonly titleKey: TranslationKey
|
||||
readonly descriptionKey?: TranslationKey
|
||||
readonly link?: BannerLinkConfig
|
||||
}
|
||||
|
||||
interface BannerLinkData {
|
||||
readonly href: string
|
||||
readonly title: string
|
||||
readonly target?: '_blank'
|
||||
readonly rel?: string
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerData {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly description?: string
|
||||
readonly link?: BannerLinkData
|
||||
}
|
||||
|
||||
export const bannerConfig: BannerConfig = {
|
||||
id: 'announcement',
|
||||
isActive: true,
|
||||
targetSections: ['sitewide'],
|
||||
titleKey: 'launches.banner.text',
|
||||
link: {
|
||||
href: '/mcp',
|
||||
titleKey: 'launches.banner.cta',
|
||||
buttonVariant: 'underlineLink'
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a config's i18n keys into display strings for the given locale. */
|
||||
export function getBannerData(
|
||||
config: BannerConfig,
|
||||
locale: Locale
|
||||
): BannerData {
|
||||
const { link } = config
|
||||
const target = link?.target ? '_blank' : undefined
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
title: t(config.titleKey, locale),
|
||||
description: config.descriptionKey
|
||||
? t(config.descriptionKey, locale)
|
||||
: undefined,
|
||||
link: link
|
||||
? {
|
||||
href: link.href,
|
||||
title: t(link.titleKey, locale),
|
||||
target,
|
||||
rel: resolveRel({ target: target ?? '_self' }),
|
||||
buttonVariant: link.buttonVariant
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -3983,12 +3983,12 @@ const translations = {
|
||||
// Launches page (/launches) — subscribe banner
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.banner.text': {
|
||||
en: 'Join the live stream. Get answers in real time.',
|
||||
'zh-CN': '加入直播,实时获得解答。'
|
||||
en: 'Now turn your agent into a creative technologist.',
|
||||
'zh-CN': '现在,让你的智能体成为创意技术专家。'
|
||||
},
|
||||
'launches.banner.cta': {
|
||||
en: 'Join livestream',
|
||||
'zh-CN': '加入直播'
|
||||
en: 'Start Comfy MCP',
|
||||
'zh-CN': '启动 Comfy MCP'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — closing CTA
|
||||
|
||||
@@ -5,6 +5,14 @@ import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
|
||||
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
|
||||
import { bannerConfig, getBannerData } from '../config/banner'
|
||||
import {
|
||||
BANNER_DISMISS_ATTR,
|
||||
BANNER_STORAGE_KEY,
|
||||
createBannerVersion,
|
||||
evaluateBannerVisibility
|
||||
} from '../utils/banner'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
|
||||
@@ -34,6 +42,15 @@ const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
// Announcement banner — build-time visibility gate + content-hash version key.
|
||||
const bannerData = getBannerData(bannerConfig, locale)
|
||||
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
})
|
||||
const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
@@ -124,6 +141,25 @@ const websiteJsonLd = {
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
|
||||
{bannerVisible && (
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
bannerVersion,
|
||||
storageKey: BANNER_STORAGE_KEY,
|
||||
dismissAttr: BANNER_DISMISS_ATTR
|
||||
}}
|
||||
>
|
||||
try {
|
||||
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
|
||||
if (dismissed[bannerVersion]) {
|
||||
document.documentElement.setAttribute(dismissAttr, '')
|
||||
}
|
||||
} catch (e) {}
|
||||
</script>
|
||||
)}
|
||||
</head>
|
||||
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
|
||||
{gtmEnabled && (
|
||||
@@ -137,8 +173,16 @@ const websiteJsonLd = {
|
||||
</noscript>
|
||||
)}
|
||||
|
||||
{bannerVisible && (
|
||||
<AnnouncementBanner
|
||||
data={bannerData}
|
||||
version={bannerVersion}
|
||||
locale={locale}
|
||||
client:load
|
||||
/>
|
||||
)}
|
||||
<HeaderMain locale={locale} github-stars={githubStars} client:load />
|
||||
<main class="mt-20 lg:mt-32">
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter locale={locale} client:load />
|
||||
|
||||
@@ -3,7 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const locale = 'en' as const
|
||||
@@ -13,7 +12,6 @@ const locale = 'en' as const
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
|
||||
@@ -3,7 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const locale = 'zh-CN' as const
|
||||
@@ -13,7 +12,6 @@ const locale = 'zh-CN' as const
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
--color-secondary-mauve: #4d3762;
|
||||
--color-destructive: #f44336;
|
||||
--color-primary-comfy-plum: #49378b;
|
||||
--color-secondary-deep-plum: #2b2040;
|
||||
--color-secondary-cool-gray: #3c3c3c;
|
||||
--color-illustration-forest: #20464c;
|
||||
--color-transparency-white-t4: rgb(255 255 255 / 0.04);
|
||||
@@ -93,6 +94,14 @@
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
/* Pre-hydration hide for a dismissed announcement banner (set by an inline
|
||||
script in BaseLayout head) — prevents any flash before Vue hydrates.
|
||||
The [data-banner-dismissed] literal is BANNER_DISMISS_ATTR in utils/banner.ts;
|
||||
keep them in sync. */
|
||||
[data-banner-dismissed] [data-slot='announcement-banner'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes border-angle-spin {
|
||||
to {
|
||||
--border-angle: 360deg;
|
||||
|
||||
107
apps/website/src/templates/drops/AnnouncementBanner.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight, X } from '@lucide/vue'
|
||||
|
||||
import type { BannerData } from '../../config/banner'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import IconButton from '@/components/ui/icon-button/IconButton.vue'
|
||||
import { useBannerDismissal } from '../../composables/useBannerDismissal'
|
||||
|
||||
const {
|
||||
data,
|
||||
version,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
data: BannerData
|
||||
version: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const { isVisible, close, persistHidden } = useBannerDismissal(version)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="banner-collapse" @after-leave="persistHidden">
|
||||
<div v-if="isVisible" class="banner-collapse grid">
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<div
|
||||
data-slot="announcement-banner"
|
||||
class="after:bg-transparency-white-t4 relative flex items-center gap-x-6 px-6 py-4 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px sm:px-3.5 sm:before:flex-1"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-primary-comfy-plum) 0%,
|
||||
var(--color-secondary-deep-plum) 53.85%,
|
||||
var(--color-secondary-mauve) 100%
|
||||
);
|
||||
"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-x-8 gap-y-2">
|
||||
<p
|
||||
class="text-primary-warm-white ppformula-text-center text-sm md:text-base/6"
|
||||
>
|
||||
{{ data.title }}
|
||||
<span v-if="data.description" class="text-primary-warm-white/80">
|
||||
{{ data.description }}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
v-if="data.link"
|
||||
as="a"
|
||||
:href="data.link.href"
|
||||
:target="data.link.target"
|
||||
:rel="data.link.rel"
|
||||
:variant="data.link.buttonVariant ?? 'underlineLink'"
|
||||
size="sm"
|
||||
>
|
||||
{{ data.link.title }}
|
||||
<template #append>
|
||||
<ArrowRight class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-1 justify-end">
|
||||
<IconButton
|
||||
type="button"
|
||||
:aria-label="t('nav.close', locale)"
|
||||
@click="close"
|
||||
>
|
||||
<X class="size-5" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Collapse the banner's height (grid 1fr → 0fr) so page content below slides
|
||||
up smoothly, with a fade. Enter is defined for symmetry; in practice only the
|
||||
leave (dismiss) runs, since the banner renders present in the static HTML. */
|
||||
.banner-collapse {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.banner-collapse-enter-active,
|
||||
.banner-collapse-leave-active {
|
||||
transition:
|
||||
grid-template-rows 300ms ease,
|
||||
opacity 250ms ease;
|
||||
}
|
||||
|
||||
.banner-collapse-enter-from,
|
||||
.banner-collapse-leave-to {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.banner-collapse-enter-active,
|
||||
.banner-collapse-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
import { livestream } from './livestream'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
|
||||
const signUpRel = resolveRel({ target: '_blank' })
|
||||
|
||||
// Hide once the livestream window closes — both for visitors arriving after
|
||||
// the event and for visitors whose tab is open when it ends.
|
||||
const endMs = new Date(livestream.endDateTime).getTime()
|
||||
const visible = ref(true)
|
||||
|
||||
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
|
||||
// schedules a long-lived server timer.
|
||||
const { start } = useTimeoutFn(
|
||||
() => {
|
||||
visible.value = false
|
||||
},
|
||||
() => Math.max(0, endMs - Date.now()),
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (endMs - Date.now() <= 0) {
|
||||
visible.value = false
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="px-4">
|
||||
<div
|
||||
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
|
||||
>
|
||||
<p class="ppformula-text-center">
|
||||
{{ t('launches.banner.text', locale) }}
|
||||
</p>
|
||||
<Button
|
||||
:href="signUpHref"
|
||||
as="a"
|
||||
variant="underlineLink"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
:rel="signUpRel"
|
||||
>
|
||||
{{ t('launches.banner.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
109
apps/website/src/utils/banner.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { EvaluableBanner } from './banner'
|
||||
|
||||
import { createBannerVersion, evaluateBannerVisibility } from './banner'
|
||||
|
||||
const base: EvaluableBanner = {
|
||||
isActive: true,
|
||||
targetSections: ['sitewide']
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
currentLocale: 'en',
|
||||
currentSection: 'sitewide',
|
||||
now: new Date('2026-07-06T00:00:00Z')
|
||||
}
|
||||
|
||||
describe('evaluateBannerVisibility', () => {
|
||||
it('shows an active, untargeted, sitewide banner', () => {
|
||||
expect(evaluateBannerVisibility(base, ctx)).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when inactive', () => {
|
||||
expect(evaluateBannerVisibility({ ...base, isActive: false }, ctx)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides before startsAt and shows within the window', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility(
|
||||
{ ...base, startsAt: '2026-07-10T00:00:00Z' },
|
||||
ctx
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility(
|
||||
{ ...base, startsAt: '2026-07-01T00:00:00Z' },
|
||||
ctx
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides after endsAt', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, endsAt: '2026-07-01T00:00:00Z' }, ctx)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, endsAt: '2026-07-10T00:00:00Z' }, ctx)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an empty targetLocales as "all locales"', () => {
|
||||
expect(evaluateBannerVisibility({ ...base, targetLocales: [] }, ctx)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides when targetLocales excludes the current locale', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetLocales: ['zh-CN'] }, ctx)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetLocales: ['en', 'zh-CN'] }, ctx)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when targetSections does not include the current section', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetSections: ['checkout'] }, ctx)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides when targetSections is absent (nothing to match)', () => {
|
||||
expect(evaluateBannerVisibility({ isActive: true }, ctx)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBannerVersion', () => {
|
||||
const content = {
|
||||
id: 'announcement',
|
||||
title: 'Join the live stream',
|
||||
link: { href: 'https://x', title: 'Join' }
|
||||
}
|
||||
|
||||
it('is deterministic for identical content', () => {
|
||||
expect(createBannerVersion(content, 'en')).toBe(
|
||||
createBannerVersion(content, 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('encodes the banner id and locale in the key', () => {
|
||||
expect(createBannerVersion(content, 'en')).toMatch(
|
||||
/^announcement_en_v-?\d+$/
|
||||
)
|
||||
})
|
||||
|
||||
it('changes when the copy changes', () => {
|
||||
expect(createBannerVersion(content, 'en')).not.toBe(
|
||||
createBannerVersion({ ...content, title: 'New copy' }, 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('differs per locale so one locale edit does not re-show another', () => {
|
||||
expect(createBannerVersion(content, 'en')).not.toBe(
|
||||
createBannerVersion(content, 'zh-CN')
|
||||
)
|
||||
})
|
||||
})
|
||||
87
apps/website/src/utils/banner.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Pure, framework-agnostic banner logic — no Vue/Astro/config imports so it stays
|
||||
// trivially unit-testable. Locale/section are plain strings on purpose.
|
||||
|
||||
// Shared dismissal storage contract. The pre-hydration script in BaseLayout.astro,
|
||||
// the useBannerDismissal composable, and the CSS selector in global.css must all
|
||||
// agree on these literals — keep them here as the single source of truth.
|
||||
export const BANNER_STORAGE_KEY = 'closedBanners'
|
||||
export const BANNER_DISMISS_ATTR = 'data-banner-dismissed'
|
||||
|
||||
export interface BannerVisibilityContext {
|
||||
currentLocale: string
|
||||
currentSection: string
|
||||
now: Date
|
||||
}
|
||||
|
||||
export interface EvaluableBanner {
|
||||
isActive: boolean
|
||||
startsAt?: string
|
||||
endsAt?: string
|
||||
targetLocales?: readonly string[]
|
||||
targetSections?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Server/build-time visibility gate. Returns false on the FIRST failing check,
|
||||
* in order: active flag → start window → end window → locale targeting →
|
||||
* section targeting. An empty/absent `targetLocales` means "all locales".
|
||||
*/
|
||||
export function evaluateBannerVisibility(
|
||||
banner: EvaluableBanner,
|
||||
ctx: BannerVisibilityContext
|
||||
): boolean {
|
||||
if (!banner.isActive) return false
|
||||
if (
|
||||
banner.startsAt &&
|
||||
ctx.now.getTime() < new Date(banner.startsAt).getTime()
|
||||
)
|
||||
return false
|
||||
if (banner.endsAt && ctx.now.getTime() > new Date(banner.endsAt).getTime())
|
||||
return false
|
||||
|
||||
const targetLocales = banner.targetLocales ?? []
|
||||
if (targetLocales.length > 0 && !targetLocales.includes(ctx.currentLocale))
|
||||
return false
|
||||
|
||||
const targetSections = banner.targetSections ?? []
|
||||
if (!targetSections.includes(ctx.currentSection)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
interface BannerLinkContent {
|
||||
href: string
|
||||
title: string
|
||||
target?: string
|
||||
rel?: string
|
||||
buttonVariant?: string
|
||||
}
|
||||
|
||||
export interface BannerVersionContent {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
link?: BannerLinkContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-aware version key. Editing the copy changes the hash, so a previously
|
||||
* dismissed banner re-appears. Keyed per-locale so a zh-CN edit doesn't re-show
|
||||
* the banner for en visitors. Format: `${content.id}_${locale}_v${hash}`.
|
||||
*/
|
||||
export function createBannerVersion(
|
||||
content: BannerVersionContent,
|
||||
locale: string
|
||||
): string {
|
||||
const contentString = JSON.stringify({
|
||||
locale,
|
||||
title: content.title,
|
||||
description: content.description,
|
||||
link: content.link
|
||||
})
|
||||
let hash = 0
|
||||
for (const char of contentString) {
|
||||
hash = Math.imul(hash, 31) + char.charCodeAt(0)
|
||||
}
|
||||
return `${content.id}_${locale}_v${hash}`
|
||||
}
|
||||
200
browser_tests/tests/agent/agentPanel.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type { AgentWsEvent } from '@/workbench/extensions/agent/schemas/agentApiSchema'
|
||||
|
||||
import {
|
||||
DRAFT_PATCH,
|
||||
MESSAGE_DELTA_EVENT,
|
||||
MESSAGE_DONE_EVENT,
|
||||
THINKING_EVENT,
|
||||
TOOL_CALL_EVENT,
|
||||
mockAgentBoot
|
||||
} from '@e2e/tests/agent/agentPanelMocks'
|
||||
|
||||
/**
|
||||
* In-App Agent panel e2e coverage (FE-1187).
|
||||
*
|
||||
* The panel is a CLOUD-ONLY workbench extension: `src/extensions/core/index.ts`
|
||||
* imports `agentPanel.ts` only under `if (isCloud)`, and `isCloud` is the
|
||||
* build-time `__DISTRIBUTION__ === 'cloud'` define. It therefore exists only in
|
||||
* the cloud build, which the `cloud` Playwright project runs against
|
||||
* (`frontend-dist-cloud` in CI; `playwright.config.ts` gives that project
|
||||
* `grep: /@cloud/` while every other project greps it out). These specs are
|
||||
* tagged `@cloud` so they run there and nowhere else. The `comfyPageFixture`
|
||||
* itself already installs the cloud Firebase-auth mock for any `@cloud` test and
|
||||
* boots the app, so tests only arrange agent mocks and act.
|
||||
*
|
||||
* The panel docks on the right and opens from the top-bar "Ask Comfy Agent"
|
||||
* button (not a sidebar tab). It is gated FAIL-CLOSED by the PostHog flag
|
||||
* `agent-in-app-experience`: `agentPanel.ts` reads `posthog.isFeatureEnabled`
|
||||
* and only exposes that button once it is `true`. PostHog only initializes
|
||||
* when `/api/features` supplies a `posthog_project_token`, so the mocks seed both
|
||||
* the token and the flag (through PostHog `bootstrap.featureFlags`, which resolves
|
||||
* the read synchronously with no `/decide` network call — see `agentPanelMocks.ts`).
|
||||
* `agentFlagEnabled` (default true) is a fixture option a test flips off to model
|
||||
* the fail-closed default.
|
||||
*
|
||||
* REST is mocked via `page.route` (POST messages -> 202, GET draft snapshot) and
|
||||
* server->client WS frames are injected with the repo's `webSocketFixture`
|
||||
* (`context.routeWebSocket(/\/ws/)`): `ws.send(JSON.stringify({type, data}))`
|
||||
* pushes a frame onto the app's shared `/ws`, which is exactly where the panel's
|
||||
* reconnecting event source listens.
|
||||
*/
|
||||
|
||||
type AgentFixtures = {
|
||||
agentFlagEnabled: boolean
|
||||
postedMessages: string[]
|
||||
}
|
||||
|
||||
// Install agent boot + REST mocks in the `page` fixture (before navigation) and
|
||||
// expose the recorded POST bodies. `comfyPageFixture` consumes this same `page`
|
||||
// and then boots the app, so the mocks are in place before mount.
|
||||
const agentCloudFixture = comfyPageFixture.extend<AgentFixtures>({
|
||||
agentFlagEnabled: [true, { option: true }],
|
||||
postedMessages: async ({ page, agentFlagEnabled }, use) => {
|
||||
const { postedMessages } = await mockAgentBoot(page, {
|
||||
agentFlag: agentFlagEnabled
|
||||
})
|
||||
await use(postedMessages)
|
||||
}
|
||||
})
|
||||
|
||||
const test = mergeTests(agentCloudFixture, webSocketFixture)
|
||||
|
||||
// The panel opens from the top-bar action button, exposed with aria-label = tooltip.
|
||||
const OPEN_AGENT_LABEL = enMessages.agent.askComfyAgent
|
||||
|
||||
// Push one agent WS event onto the app's /ws in the ComfyUI envelope shape.
|
||||
function pushEvent(ws: WebSocketRoute, event: AgentWsEvent): void {
|
||||
ws.send(JSON.stringify(event))
|
||||
}
|
||||
|
||||
test.describe('In-App Agent panel', { tag: '@cloud' }, () => {
|
||||
test.describe('flag off', () => {
|
||||
test.use({ agentFlagEnabled: false })
|
||||
|
||||
test('does not expose the Ask Comfy Agent button', async ({
|
||||
comfyPage,
|
||||
postedMessages
|
||||
}) => {
|
||||
// Touch the fixture so its mocks (flag-off /api/features) are installed.
|
||||
expect(postedMessages).toHaveLength(0)
|
||||
|
||||
// Fail-closed: with the PostHog flag false the top-bar button is never
|
||||
// exposed, so the panel cannot be opened.
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: OPEN_AGENT_LABEL })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('shows the greeting, inserts a suggested prompt, and completes a chat turn', async ({
|
||||
comfyPage,
|
||||
postedMessages,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
// Flag on: the top-bar button is exposed. Open the panel.
|
||||
const openButton = page.getByRole('button', { name: OPEN_AGENT_LABEL })
|
||||
await expect(openButton).toBeVisible()
|
||||
await openButton.click()
|
||||
|
||||
const panel = page.locator('#agent-panel-root')
|
||||
await expect(panel).toBeVisible()
|
||||
|
||||
// Empty state: greeting + question and the five suggested-prompt chips. The greeting
|
||||
// personalizes to the account's first name, so match the stable prefix, not a fixed name.
|
||||
await expect(panel.getByText(/^Hello/)).toBeVisible()
|
||||
await expect(panel.getByText('What do you want to make?')).toBeVisible()
|
||||
// Sourced from the bundled locale so the spec cannot drift from the rendered prompts.
|
||||
const firstPrompt = enMessages.agent.suggestedPrompts[0]
|
||||
const promptChip = panel.getByRole('button', { name: firstPrompt })
|
||||
await expect(promptChip).toBeVisible()
|
||||
|
||||
const composer = panel.getByPlaceholder(enMessages.agent.placeholder)
|
||||
const sendButton = panel.getByRole('button', { name: 'Send' })
|
||||
|
||||
// Clicking a suggested prompt INSERTS it into the composer, it does not send.
|
||||
await expect(composer).toHaveValue('')
|
||||
await promptChip.click()
|
||||
await expect(composer).toHaveValue(firstPrompt)
|
||||
expect(
|
||||
postedMessages,
|
||||
'inserting a prompt must not POST a message'
|
||||
).toHaveLength(0)
|
||||
|
||||
// Submitting sends the composed message: POST /api/agent/threads/new/messages.
|
||||
const ws = await getWebSocket()
|
||||
await sendButton.click()
|
||||
await expect.poll(() => postedMessages.length).toBeGreaterThanOrEqual(1)
|
||||
expect(postedMessages[0]).toContain(firstPrompt)
|
||||
// The composer clears once the send is accepted.
|
||||
await expect(composer).toHaveValue('')
|
||||
|
||||
// agent_thinking -> the "Thinking..." status is visible.
|
||||
pushEvent(ws, THINKING_EVENT)
|
||||
await expect(panel.getByText('Thinking...')).toBeVisible()
|
||||
|
||||
// agent_tool_call (ok) -> a tool-call group card appears ("Ran 1 tool call").
|
||||
pushEvent(ws, TOOL_CALL_EVENT)
|
||||
await expect(panel.getByText('Ran 1 tool call')).toBeVisible()
|
||||
|
||||
// agent_message_delta with markdown -> rendered agent message; the **bold**
|
||||
// run renders as <strong>.
|
||||
pushEvent(ws, MESSAGE_DELTA_EVENT)
|
||||
await expect(
|
||||
panel.locator('strong', { hasText: 'fully ready' })
|
||||
).toBeVisible()
|
||||
// Text arriving clears the thinking chip.
|
||||
await expect(panel.getByText('Thinking...')).toBeHidden()
|
||||
|
||||
// agent_message_done -> the turn settles: the primary button returns to Send
|
||||
// (it is Stop while streaming).
|
||||
pushEvent(ws, MESSAGE_DONE_EVENT)
|
||||
await expect(panel.getByRole('button', { name: 'Send' })).toBeVisible()
|
||||
await expect(panel.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('applies a draft_patch graph to the canvas', async ({
|
||||
comfyPage,
|
||||
postedMessages,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
const panel = page.locator('#agent-panel-root')
|
||||
|
||||
const openButton = page.getByRole('button', { name: OPEN_AGENT_LABEL })
|
||||
await expect(openButton).toBeVisible()
|
||||
await openButton.click()
|
||||
await expect(panel).toBeVisible()
|
||||
|
||||
// The draft store only adopts a draft_patch whose workflow_id matches the
|
||||
// server's workflow. The server returns that id in the message ack, and the
|
||||
// panel binds the draft store to it. So send once to establish the bind before
|
||||
// pushing the patch. A draft_patch is otherwise NEVER turn-filtered.
|
||||
await panel.getByPlaceholder(enMessages.agent.placeholder).fill('Build it')
|
||||
await panel.getByRole('button', { name: 'Send' }).click()
|
||||
await expect.poll(() => postedMessages.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const ws = await getWebSocket()
|
||||
pushEvent(ws, { type: 'draft_patch', data: DRAFT_PATCH })
|
||||
|
||||
// The two-node draft graph is validated and handed to app.loadGraphData, so
|
||||
// the canvas graph must reflect it.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.app!.graph!.nodes.length))
|
||||
.toBe(2)
|
||||
const nodeTypes = await page.evaluate(() =>
|
||||
window.app!.graph!.nodes.map((n) => n.type)
|
||||
)
|
||||
expect(nodeTypes).toEqual(
|
||||
expect.arrayContaining(['CheckpointLoaderSimple', 'SaveImage'])
|
||||
)
|
||||
})
|
||||
})
|
||||
272
browser_tests/tests/agent/agentPanelMocks.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
AgentDraftSnapshot,
|
||||
AgentTurnAccepted,
|
||||
AgentWsEvent,
|
||||
DraftPatchData
|
||||
} from '@/workbench/extensions/agent/schemas/agentApiSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
|
||||
/**
|
||||
* Typed mocks for the In-App Agent panel e2e spec. Every REST body and WS frame
|
||||
* is annotated with the shared agent contract types
|
||||
* (`@/workbench/extensions/agent/schemas/agentApiSchema`) and the workflow schema,
|
||||
* so a wire-shape drift is a compile error here, not a flaky runtime failure. The
|
||||
* payload values are copied from the captured fixtures under
|
||||
* `src/workbench/extensions/agent/schemas/__fixtures__/agent/`.
|
||||
*/
|
||||
|
||||
// The turn everything in scenario 2 & 3 is addressed by. The panel adopts this
|
||||
// server-minted message_id (from the POST 202 ack) as the TurnId that keys the
|
||||
// assistant message the WS frames stream into.
|
||||
const THREAD_ID = 'd4c016c4-3b8c-44cf-97de-1ae27e43e718'
|
||||
const TURN_ID = '3818ba00-d772-4a3f-98c1-9312725b577d'
|
||||
const WORKFLOW_ID = 'a81718a4-02ae-41e6-ae85-c33b7bb880f6'
|
||||
|
||||
// The 202 body the panel's postMessage('new') expects: {thread_id, message_id}
|
||||
// plus the captured additive workflow_id (the schema is passthrough).
|
||||
const TURN_ACCEPTED: AgentTurnAccepted = {
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID,
|
||||
workflow_id: WORKFLOW_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal schema-valid v0.4 workflow. `validateComfyWorkflow` first parses a
|
||||
* top-level `version: number` (the captured draft graphs omit it, so they would
|
||||
* be rejected before ever reaching the canvas); this shape carries `version`,
|
||||
* `last_node_id`, `last_link_id`, `nodes` and `links` so it survives validation
|
||||
* and `app.loadGraphData` actually applies it. Two nodes so the assertion on the
|
||||
* canvas node count is unambiguous.
|
||||
*/
|
||||
const DRAFT_GRAPH: ComfyWorkflowJSON = {
|
||||
version: 0.4,
|
||||
last_node_id: 2,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [100, 300],
|
||||
size: [210, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'MODEL', type: 'MODEL', links: [] },
|
||||
{ name: 'CLIP', type: 'CLIP', links: [] },
|
||||
{ name: 'VAE', type: 'VAE', links: [] }
|
||||
],
|
||||
properties: {},
|
||||
widgets_values: ['sd_xl_base_1.0.safetensors']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'SaveImage',
|
||||
pos: [1400, 300],
|
||||
size: [210, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: null }],
|
||||
outputs: [],
|
||||
properties: {},
|
||||
widgets_values: ['ComfyUI']
|
||||
}
|
||||
],
|
||||
links: []
|
||||
}
|
||||
|
||||
// The GET /api/agent/draft snapshot body: {content, version}. content rides the
|
||||
// wire as an opaque object; the panel re-validates it before loading.
|
||||
const DRAFT_SNAPSHOT: AgentDraftSnapshot = {
|
||||
content: DRAFT_GRAPH as unknown as Record<string, unknown>,
|
||||
version: 24
|
||||
}
|
||||
|
||||
// A draft_patch WS event whose content is the minimal valid graph above. The panel
|
||||
// adopts monotonically, so the version must exceed any snapshot already adopted.
|
||||
export const DRAFT_PATCH: DraftPatchData = {
|
||||
base_version: 24,
|
||||
version: 25,
|
||||
content: DRAFT_GRAPH as unknown as Record<string, unknown>,
|
||||
workflow_id: WORKFLOW_ID,
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID
|
||||
}
|
||||
|
||||
// The chat-turn WS frames for scenario 2, in arrival order. Each rides the ComfyUI
|
||||
// envelope {type, data}; values copied from ws-turn-ask-run.jsonl but retargeted to
|
||||
// TURN_ID so they land on the turn the POST ack opened.
|
||||
export const THINKING_EVENT: AgentWsEvent = {
|
||||
type: 'agent_thinking',
|
||||
data: {
|
||||
delta: "I'll set the positive prompt to your red fox scene.",
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID
|
||||
}
|
||||
}
|
||||
|
||||
export const TOOL_CALL_EVENT: AgentWsEvent = {
|
||||
type: 'agent_tool_call',
|
||||
data: {
|
||||
tool_name: 'set_widget',
|
||||
status: 'ok',
|
||||
args: ['workflow', 'set-widget', 'workflow.json'],
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID
|
||||
}
|
||||
}
|
||||
|
||||
// Markdown delta: the **bold** run renders as <strong> through the sanitizing
|
||||
// markdown pipeline, which the spec asserts on.
|
||||
const MESSAGE_DELTA_TEXT =
|
||||
'The graph is **fully ready** to go — prompt set to the red fox in the snow.'
|
||||
|
||||
export const MESSAGE_DELTA_EVENT: AgentWsEvent = {
|
||||
type: 'agent_message_delta',
|
||||
data: {
|
||||
delta: MESSAGE_DELTA_TEXT,
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID
|
||||
}
|
||||
}
|
||||
|
||||
export const MESSAGE_DONE_EVENT: AgentWsEvent = {
|
||||
type: 'agent_message_done',
|
||||
data: {
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID,
|
||||
usage: {
|
||||
input_tokens: 4493,
|
||||
output_tokens: 425,
|
||||
total_tokens: 12393,
|
||||
cache_read_input_tokens: 35596,
|
||||
cache_creation_input_tokens: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/features` payload that boots the cloud telemetry provider with PostHog AND
|
||||
* turns the agent gate on. PostHog only initializes when a `posthog_project_token`
|
||||
* is present, and the panel's fail-closed gate reads `posthog.isFeatureEnabled`;
|
||||
* seeding the flag through PostHog's `bootstrap.featureFlags` makes that read
|
||||
* resolve `true` synchronously at init with no `/decide` round-trip, so the gate is
|
||||
* deterministic. Omit `agentFlag` (or pass false) to model the fail-closed default.
|
||||
*/
|
||||
function agentFeatures(agentFlag: boolean): RemoteConfig {
|
||||
return {
|
||||
team_workspaces_enabled: true,
|
||||
posthog_project_token: 'phc_e2e_agent_panel',
|
||||
posthog_config: {
|
||||
// Never let posthog-js reach its flags endpoint in the test env; bootstrap
|
||||
// alone supplies the flag value the gate reads.
|
||||
advanced_disable_flags: true,
|
||||
bootstrap: {
|
||||
featureFlags: { 'agent-in-app-experience': agentFlag }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
/**
|
||||
* Boots the cloud app against fully mocked boot + agent REST endpoints. Mirrors
|
||||
* `CloudWorkspaceMockHelper.mockBoot`, adding the agent's own REST surface
|
||||
* (`postMessage`, `getDraft`). Returns the recorded POST bodies so the spec can
|
||||
* assert the composed message reached the wire. Auth (Firebase) is mocked by the
|
||||
* caller via `CloudAuthHelper` before navigation.
|
||||
*/
|
||||
export async function mockAgentBoot(
|
||||
page: Page,
|
||||
{ agentFlag }: { agentFlag: boolean }
|
||||
): Promise<{ postedMessages: string[] }> {
|
||||
const postedMessages: string[] = []
|
||||
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(jsonRoute(agentFeatures(agentFlag)))
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// TutorialCompleted marks the user as returning so the new-user Templates dialog
|
||||
// never auto-opens over the sidebar rail; errors tab off suppresses a 401 toast.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// POST /api/agent/threads/new/messages -> 202 {thread_id, message_id, workflow_id}.
|
||||
// The server owns the workflow and returns its id in this ack; the panel binds the
|
||||
// draft store to it and then only adopts draft_patch events whose workflow_id
|
||||
// matches. Record the body so the spec can assert the composed text was sent, not
|
||||
// just that a request fired. GET on the same path (history hydration) is empty.
|
||||
await page.route('**/api/agent/threads/*/messages', (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === 'POST') {
|
||||
postedMessages.push(request.postData() ?? '')
|
||||
return route.fulfill({
|
||||
status: 202,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(TURN_ACCEPTED)
|
||||
})
|
||||
}
|
||||
return route.fulfill(jsonRoute([]))
|
||||
})
|
||||
|
||||
await page.route('**/api/agent/draft**', (r) =>
|
||||
r.fulfill(jsonRoute(DRAFT_SNAPSHOT))
|
||||
)
|
||||
|
||||
return { postedMessages }
|
||||
}
|
||||
@@ -120,6 +120,7 @@
|
||||
"primevue": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"semver": "^7.7.2",
|
||||
"shiki": "catalog:",
|
||||
"three": "catalog:",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
|
||||
@@ -12,6 +12,11 @@ export type {
|
||||
AddAssetTagsErrors,
|
||||
AddAssetTagsResponse,
|
||||
AddAssetTagsResponses,
|
||||
AdminDeleteHubWorkflowData,
|
||||
AdminDeleteHubWorkflowError,
|
||||
AdminDeleteHubWorkflowErrors,
|
||||
AdminDeleteHubWorkflowResponse,
|
||||
AdminDeleteHubWorkflowResponses,
|
||||
Asset,
|
||||
AssetCreated,
|
||||
AssetCreatedWritable,
|
||||
@@ -42,6 +47,11 @@ export type {
|
||||
CancelJobErrors,
|
||||
CancelJobResponse,
|
||||
CancelJobResponses,
|
||||
CancelJobsData,
|
||||
CancelJobsError,
|
||||
CancelJobsErrors,
|
||||
CancelJobsResponse,
|
||||
CancelJobsResponses,
|
||||
CancelSubscriptionData,
|
||||
CancelSubscriptionError,
|
||||
CancelSubscriptionErrors,
|
||||
@@ -84,6 +94,11 @@ export type {
|
||||
CreateDeletionRequestErrors,
|
||||
CreateDeletionRequestResponse,
|
||||
CreateDeletionRequestResponses,
|
||||
CreateDesktopLoginCodeData,
|
||||
CreateDesktopLoginCodeError,
|
||||
CreateDesktopLoginCodeErrors,
|
||||
CreateDesktopLoginCodeResponse,
|
||||
CreateDesktopLoginCodeResponses,
|
||||
CreateHubAssetUploadUrlData,
|
||||
CreateHubAssetUploadUrlError,
|
||||
CreateHubAssetUploadUrlErrors,
|
||||
@@ -186,12 +201,31 @@ export type {
|
||||
DeleteWorkspaceResponses,
|
||||
DeletionRequest,
|
||||
DeletionStatus,
|
||||
DesktopLoginCodeCreateRequest,
|
||||
DesktopLoginCodeCreateResponse,
|
||||
DesktopLoginCodeExchangeRequest,
|
||||
DesktopLoginCodeExchangeResponse,
|
||||
DesktopLoginCodeRedeemRequest,
|
||||
DesktopLoginCodeRedeemResponse,
|
||||
DownloadExportData,
|
||||
DownloadExportError,
|
||||
DownloadExportErrors,
|
||||
DownloadExportResponse,
|
||||
DownloadExportResponses,
|
||||
EnsureWorkspaceBillingLegacySnapshot,
|
||||
EnsureWorkspaceBillingProvisionedData,
|
||||
EnsureWorkspaceBillingProvisionedError,
|
||||
EnsureWorkspaceBillingProvisionedErrors,
|
||||
EnsureWorkspaceBillingProvisionedRequest,
|
||||
EnsureWorkspaceBillingProvisionedResponse,
|
||||
EnsureWorkspaceBillingProvisionedResponse2,
|
||||
EnsureWorkspaceBillingProvisionedResponses,
|
||||
ErrorResponse,
|
||||
ExchangeDesktopLoginCodeData,
|
||||
ExchangeDesktopLoginCodeError,
|
||||
ExchangeDesktopLoginCodeErrors,
|
||||
ExchangeDesktopLoginCodeResponse,
|
||||
ExchangeDesktopLoginCodeResponses,
|
||||
ExchangeTokenData,
|
||||
ExchangeTokenError,
|
||||
ExchangeTokenErrors,
|
||||
@@ -230,6 +264,11 @@ export type {
|
||||
GetAssetByIdErrors,
|
||||
GetAssetByIdResponse,
|
||||
GetAssetByIdResponses,
|
||||
GetAssetContentData,
|
||||
GetAssetContentError,
|
||||
GetAssetContentErrors,
|
||||
GetAssetContentResponse,
|
||||
GetAssetContentResponses,
|
||||
GetAssetSeedStatusData,
|
||||
GetAssetSeedStatusResponse,
|
||||
GetAssetSeedStatusResponses,
|
||||
@@ -303,6 +342,11 @@ export type {
|
||||
GetHistoryData,
|
||||
GetHistoryError,
|
||||
GetHistoryErrors,
|
||||
GetHistoryEventsData,
|
||||
GetHistoryEventsError,
|
||||
GetHistoryEventsErrors,
|
||||
GetHistoryEventsResponse,
|
||||
GetHistoryEventsResponses,
|
||||
GetHistoryForPromptData,
|
||||
GetHistoryForPromptError,
|
||||
GetHistoryForPromptErrors,
|
||||
@@ -345,8 +389,6 @@ export type {
|
||||
GetJwksData,
|
||||
GetJwksResponse,
|
||||
GetJwksResponses,
|
||||
GetLegacyAssetContentData,
|
||||
GetLegacyAssetContentErrors,
|
||||
GetLegacyHistoryByIdData,
|
||||
GetLegacyHistoryByIdErrors,
|
||||
GetLegacyHistoryData,
|
||||
@@ -556,6 +598,7 @@ export type {
|
||||
HistoryDetailEntry,
|
||||
HistoryDetailResponse,
|
||||
HistoryEntry,
|
||||
HistoryEventRequest,
|
||||
HistoryManageRequest,
|
||||
HistoryResponse,
|
||||
HubAssetUploadUrlRequest,
|
||||
@@ -589,6 +632,8 @@ export type {
|
||||
JobCancelResponse,
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsCancelRequest,
|
||||
JobsCancelResponse,
|
||||
JobsListResponse,
|
||||
JobStatusResponse,
|
||||
JwkKey,
|
||||
@@ -627,7 +672,19 @@ export type {
|
||||
ListJobsErrors,
|
||||
ListJobsResponse,
|
||||
ListJobsResponses,
|
||||
ListLinkedFirebaseUidsData,
|
||||
ListLinkedFirebaseUidsError,
|
||||
ListLinkedFirebaseUidsErrors,
|
||||
ListLinkedFirebaseUidsRequest,
|
||||
ListLinkedFirebaseUidsResponse,
|
||||
ListLinkedFirebaseUidsResponse2,
|
||||
ListLinkedFirebaseUidsResponses,
|
||||
ListMembersResponse,
|
||||
ListSecretProvidersData,
|
||||
ListSecretProvidersError,
|
||||
ListSecretProvidersErrors,
|
||||
ListSecretProvidersResponse,
|
||||
ListSecretProvidersResponses,
|
||||
ListSecretsData,
|
||||
ListSecretsError,
|
||||
ListSecretsErrors,
|
||||
@@ -775,6 +832,17 @@ export type {
|
||||
QueueInfo,
|
||||
QueueManageRequest,
|
||||
QueueManageResponse,
|
||||
RedeemDesktopLoginCodeData,
|
||||
RedeemDesktopLoginCodeError,
|
||||
RedeemDesktopLoginCodeErrors,
|
||||
RedeemDesktopLoginCodeResponse,
|
||||
RedeemDesktopLoginCodeResponses,
|
||||
ReleaseDeletionHoldData,
|
||||
ReleaseDeletionHoldError,
|
||||
ReleaseDeletionHoldErrors,
|
||||
ReleaseDeletionHoldResponse,
|
||||
ReleaseDeletionHoldResponses,
|
||||
ReleaseHoldResponse,
|
||||
RemoveAssetTagsData,
|
||||
RemoveAssetTagsError,
|
||||
RemoveAssetTagsErrors,
|
||||
@@ -785,6 +853,11 @@ export type {
|
||||
RemoveWorkspaceMemberErrors,
|
||||
RemoveWorkspaceMemberResponse,
|
||||
RemoveWorkspaceMemberResponses,
|
||||
ReportHistoryEventData,
|
||||
ReportHistoryEventError,
|
||||
ReportHistoryEventErrors,
|
||||
ReportHistoryEventResponse,
|
||||
ReportHistoryEventResponses,
|
||||
ReportPartnerUsageData,
|
||||
ReportPartnerUsageError,
|
||||
ReportPartnerUsageErrors,
|
||||
@@ -808,6 +881,8 @@ export type {
|
||||
RevokeWorkspaceInviteResponse,
|
||||
RevokeWorkspaceInviteResponses,
|
||||
SecretListResponse,
|
||||
SecretProvider,
|
||||
SecretProvidersResponse,
|
||||
SecretResponse,
|
||||
SeedAssetsData,
|
||||
SeedAssetsResponse,
|
||||
@@ -819,6 +894,8 @@ export type {
|
||||
SetReviewStatusResponse,
|
||||
SetReviewStatusResponse2,
|
||||
SetReviewStatusResponses,
|
||||
ShortLinkRedirectData,
|
||||
ShortLinkRedirectErrors,
|
||||
SubmitFeedbackData,
|
||||
SubmitFeedbackError,
|
||||
SubmitFeedbackErrors,
|
||||
@@ -848,6 +925,10 @@ export type {
|
||||
TaskEntry,
|
||||
TaskResponse,
|
||||
TasksListResponse,
|
||||
TeamCreditStop,
|
||||
TeamCreditStopPrice,
|
||||
TeamCreditStops,
|
||||
TeamCreditStopSummary,
|
||||
UpdateAssetData,
|
||||
UpdateAssetError,
|
||||
UpdateAssetErrors,
|
||||
@@ -865,6 +946,7 @@ export type {
|
||||
UpdateHubWorkflowRequest,
|
||||
UpdateHubWorkflowResponse,
|
||||
UpdateHubWorkflowResponses,
|
||||
UpdateMemberRoleRequest,
|
||||
UpdateMultipleSettingsData,
|
||||
UpdateMultipleSettingsError,
|
||||
UpdateMultipleSettingsErrors,
|
||||
@@ -895,6 +977,11 @@ export type {
|
||||
UpdateWorkspaceData,
|
||||
UpdateWorkspaceError,
|
||||
UpdateWorkspaceErrors,
|
||||
UpdateWorkspaceMemberRoleData,
|
||||
UpdateWorkspaceMemberRoleError,
|
||||
UpdateWorkspaceMemberRoleErrors,
|
||||
UpdateWorkspaceMemberRoleResponse,
|
||||
UpdateWorkspaceMemberRoleResponses,
|
||||
UpdateWorkspaceRequest,
|
||||
UpdateWorkspaceResponse,
|
||||
UpdateWorkspaceResponses,
|
||||
|
||||
1031
packages/ingest-types/src/types.gen.ts
generated
452
packages/ingest-types/src/zod.gen.ts
generated
@@ -465,6 +465,20 @@ export const zCreateWorkflowRequest = z.object({
|
||||
forked_from_workflow_version_id: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for forwarding a comfy-api audit/history event. Identify the target workspace by either user_id (cloud resolves the user's personal workspace via the converged identity, BE-1047) or an explicit workspace_id. At least one must be provided; workspace_id wins when both are set.
|
||||
*/
|
||||
export const zHistoryEventRequest = z.object({
|
||||
user_id: z.string().optional(),
|
||||
workspace_id: z.string().optional(),
|
||||
event_type: z.string().min(1),
|
||||
event_id: z.string().min(1),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
auth_method: z.enum(['api_key', 'bearer_token']).optional(),
|
||||
customer_ref: z.string().optional(),
|
||||
timestamp: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Response after recording partner usage data.
|
||||
*/
|
||||
@@ -540,11 +554,11 @@ export const zPaymentPortalRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Response after successfully resubscribing to a billing plan.
|
||||
* Response after accepting a resubscribe request.
|
||||
*/
|
||||
export const zResubscribeResponse = z.object({
|
||||
billing_op_id: z.string(),
|
||||
status: z.enum(['active']),
|
||||
status: z.enum(['active', 'pending']),
|
||||
message: z.string().optional()
|
||||
})
|
||||
|
||||
@@ -585,6 +599,8 @@ export const zSubscribeResponse = z.object({
|
||||
*/
|
||||
export const zSubscribeRequest = z.object({
|
||||
plan_slug: z.string(),
|
||||
team_credit_stop_id: z.string().optional(),
|
||||
billing_cycle: z.enum(['monthly', 'yearly']).optional(),
|
||||
idempotency_key: z.string().optional(),
|
||||
return_url: z.string().optional(),
|
||||
cancel_url: z.string().optional()
|
||||
@@ -626,7 +642,8 @@ export const zSubscriptionTier = z.enum([
|
||||
'STANDARD',
|
||||
'CREATOR',
|
||||
'PRO',
|
||||
'FOUNDERS_EDITION'
|
||||
'FOUNDERS_EDITION',
|
||||
'TEAM'
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -714,6 +731,57 @@ export const zPreviewSubscribeRequest = z.object({
|
||||
plan_slug: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Pre/post-discount price for a team credit stop, in cents.
|
||||
*/
|
||||
export const zTeamCreditStopPrice = z.object({
|
||||
list_price_cents: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
price_cents: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A selectable preset on the team pricing slider. Echoed on subscribe via
|
||||
* team_credit_stop_id; the backend owns the resolved amounts. credits is a
|
||||
* RAW monthly credit count (not cents). Save% is derived by the FE as
|
||||
* (list_price_cents - price_cents) / list_price_cents.
|
||||
*
|
||||
*/
|
||||
export const zTeamCreditStop = z.object({
|
||||
id: z.string(),
|
||||
credits: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
monthly: zTeamCreditStopPrice,
|
||||
yearly: zTeamCreditStopPrice
|
||||
})
|
||||
|
||||
/**
|
||||
* Credit-stop ladder for the pricing slider (BE-1254). Returned by GET /api/billing/plans for every workspace regardless of the caller's token or workspace type (the personal/team distinction was removed); omitted only when the catalog defines no stops.
|
||||
*/
|
||||
export const zTeamCreditStops = z.object({
|
||||
default_stop_index: z.number().int(),
|
||||
stops: z.array(zTeamCreditStop)
|
||||
})
|
||||
|
||||
/**
|
||||
* Reason why a plan is unavailable
|
||||
*/
|
||||
@@ -773,7 +841,50 @@ export const zPlan = z.object({
|
||||
*/
|
||||
export const zBillingPlansResponse = z.object({
|
||||
current_plan_slug: z.string().optional(),
|
||||
plans: z.array(zPlan)
|
||||
plans: z.array(zPlan),
|
||||
team_credit_stops: zTeamCreditStops.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* The team credit stop a workspace is currently subscribed to: the
|
||||
* per-workspace slider choice recorded at subscribe time
|
||||
* (workspace_subscriptions.team_credit_stop_id). Amounts are owned by the
|
||||
* catalog, not the subscription row. Returned on GET /api/billing/status
|
||||
* for per-credit Team plans (BE-1254).
|
||||
*
|
||||
*/
|
||||
export const zTeamCreditStopSummary = z.object({
|
||||
id: z.string(),
|
||||
credits_monthly: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
stop_usd: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A provider the user may configure a secret for. The shape is deliberately minimal (identifier only) and reserved for future per-provider fields such as sub-keys.
|
||||
*/
|
||||
export const zSecretProvider = z.object({
|
||||
id: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* The providers available to the authenticated user in the current workspace.
|
||||
*/
|
||||
export const zSecretProvidersResponse = z.object({
|
||||
data: z.array(zSecretProvider)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -813,7 +924,7 @@ export const zCreateSecretRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* A single billing event such as a charge, credit, or adjustment.
|
||||
* A single history event. The cloud history-events store is the single source of truth for both billing events (charges, credits, adjustments) and user-facing usage events.
|
||||
*/
|
||||
export const zBillingEvent = z.object({
|
||||
event_type: z.string(),
|
||||
@@ -868,7 +979,8 @@ export const zBillingStatusResponse = z.object({
|
||||
billing_status: zBillingStatus.optional(),
|
||||
has_funds: z.boolean(),
|
||||
cancel_at: z.string().datetime().optional(),
|
||||
renewal_date: z.string().datetime().optional()
|
||||
renewal_date: z.string().datetime().optional(),
|
||||
team_credit_stop: zTeamCreditStopSummary.nullable()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -930,6 +1042,7 @@ export const zOAuthConsentChallenge = z.object({
|
||||
csrf_token: z.string(),
|
||||
client_display_name: z.string(),
|
||||
resource_display_name: z.string(),
|
||||
redirect_uri: z.string().url(),
|
||||
scopes: z.array(z.string()),
|
||||
workspaces: z.array(zOAuthConsentChallengeWorkspace)
|
||||
})
|
||||
@@ -1056,6 +1169,66 @@ export const zSyncApiKeyRequest = z.object({
|
||||
customer_id: z.string().min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* The personal workspace's provisioned billing identity.
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingProvisionedResponse = z.object({
|
||||
workspace_id: z.string(),
|
||||
stripe_customer_id: z.string(),
|
||||
metronome_customer_id: z.string(),
|
||||
metronome_contract_id: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* The caller's already-resolved legacy (comfy-api) customer identity. When
|
||||
* present and carrying provider IDs, provisioning ATTACHES this identity to
|
||||
* the personal workspace (sharing the existing balance and subscription)
|
||||
* instead of minting a net-new empty customer. Omit (or send with no
|
||||
* provider IDs) for a free user with nothing to attach — provisioning then
|
||||
* creates net-new. This closes the create-new-before-attach gap: a caller
|
||||
* that already knows the legacy identity hands it over so the very first
|
||||
* provisioning is an attach.
|
||||
*
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingLegacySnapshot = z.object({
|
||||
stripe_customer_id: z.string().optional(),
|
||||
metronome_customer_id: z.string().optional(),
|
||||
metronome_contract_id: z.string().optional(),
|
||||
has_funds: z.boolean().optional(),
|
||||
subscription_tier: z.string().optional(),
|
||||
legacy_stripe_subscription_id: z.string().optional(),
|
||||
legacy_comfy_user_id: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for ensuring a user's personal workspace carries a fully
|
||||
* provisioned billing identity. Sent by comfy-api's CreateCustomer (BE-1047)
|
||||
* with the already canonical-resolved user identity.
|
||||
*
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingProvisionedRequest = z.object({
|
||||
user_id: z.string().min(1),
|
||||
email: z.string().email().min(1),
|
||||
snapshot: zEnsureWorkspaceBillingLegacySnapshot.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Firebase UIDs linked to the canonical comfy_user_id. Empty list when
|
||||
* no mappings exist (not an error — callers can treat empty as "unknown
|
||||
* canonical").
|
||||
*
|
||||
*/
|
||||
export const zListLinkedFirebaseUidsResponse = z.object({
|
||||
firebase_uids: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for reverse-looking-up Firebase UIDs linked to a canonical comfy_user_id.
|
||||
*/
|
||||
export const zListLinkedFirebaseUidsRequest = z.object({
|
||||
comfy_user_id: z.string().min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* Response confirming the validity and scope of a workspace API key.
|
||||
*/
|
||||
@@ -1172,7 +1345,8 @@ export const zMember = z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['owner', 'member']),
|
||||
joined_at: z.string().datetime()
|
||||
joined_at: z.string().datetime(),
|
||||
is_original_owner: z.boolean()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1183,6 +1357,13 @@ export const zListMembersResponse = z.object({
|
||||
pagination: zPaginationInfo
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for changing a workspace member's role.
|
||||
*/
|
||||
export const zUpdateMemberRoleRequest = z.object({
|
||||
role: z.enum(['owner', 'member'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for updating an existing workspace's settings.
|
||||
*/
|
||||
@@ -1227,6 +1408,60 @@ export const zWorkspace = z.object({
|
||||
created_at: z.string().datetime()
|
||||
})
|
||||
|
||||
/**
|
||||
* Exchange poll result. Pending until the code is redeemed in the browser.
|
||||
*/
|
||||
export const zDesktopLoginCodeExchangeResponse = z.object({
|
||||
status: z.enum(['pending', 'complete']),
|
||||
custom_token: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to exchange a redeemed login code for a custom token.
|
||||
*/
|
||||
export const zDesktopLoginCodeExchangeRequest = z.object({
|
||||
code: z.string(),
|
||||
code_verifier: z.string().min(43).max(128)
|
||||
})
|
||||
|
||||
/**
|
||||
* Result of redeeming a desktop login code.
|
||||
*/
|
||||
export const zDesktopLoginCodeRedeemResponse = z.object({
|
||||
status: z.enum(['redeemed'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to claim a desktop login code for the authenticated user.
|
||||
*/
|
||||
export const zDesktopLoginCodeRedeemRequest = z.object({
|
||||
code: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* A freshly minted desktop login code and its polling parameters.
|
||||
*/
|
||||
export const zDesktopLoginCodeCreateResponse = z.object({
|
||||
code: z.string(),
|
||||
expires_in: z.number().int(),
|
||||
poll_interval: z.number().int()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to mint a desktop login code.
|
||||
*/
|
||||
export const zDesktopLoginCodeCreateRequest = z.object({
|
||||
installation_id: z
|
||||
.string()
|
||||
.min(8)
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z0-9._-]+$/)
|
||||
.optional(),
|
||||
platform: z.string().min(1).max(32),
|
||||
app_version: z.string().min(1).max(64),
|
||||
code_challenge: z.string().min(43).max(128)
|
||||
})
|
||||
|
||||
/**
|
||||
* Abbreviated workspace metadata used in list responses.
|
||||
*/
|
||||
@@ -1294,6 +1529,15 @@ export const zTasksListResponse = z.object({
|
||||
pagination: zPaginationInfo
|
||||
})
|
||||
|
||||
/**
|
||||
* Result of authorizing a legal-hold release on a user's deletion.
|
||||
*/
|
||||
export const zReleaseHoldResponse = z.object({
|
||||
firebase_id: z.string(),
|
||||
released: z.boolean(),
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Current status of a user data deletion request.
|
||||
*/
|
||||
@@ -1363,6 +1607,20 @@ export const zJobDetailResponse = z.object({
|
||||
execution_meta: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Response for POST /api/jobs/cancel.
|
||||
*/
|
||||
export const zJobsCancelResponse = z.object({
|
||||
cancelled: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to cancel multiple jobs by ID.
|
||||
*/
|
||||
export const zJobsCancelRequest = z.object({
|
||||
job_ids: z.array(z.string().uuid()).min(1).max(100)
|
||||
})
|
||||
|
||||
/**
|
||||
* Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.
|
||||
*/
|
||||
@@ -1529,6 +1787,7 @@ export const zAsset = z.object({
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).readonly().optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
short_url: z.string().nullish(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
@@ -1624,6 +1883,7 @@ export const zSystemStatsResponse = z.object({
|
||||
python_version: z.string(),
|
||||
embedded_python: z.boolean(),
|
||||
comfyui_version: z.string(),
|
||||
deploy_environment: z.string().optional(),
|
||||
comfyui_frontend_version: z.string().optional(),
|
||||
workflow_templates_version: z.string().optional(),
|
||||
cloud_version: z.string().optional(),
|
||||
@@ -1962,6 +2222,7 @@ export const zAssetWritable = z.object({
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
short_url: z.string().nullish(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
@@ -2180,7 +2441,11 @@ export const zGetJobDetailData = z.object({
|
||||
path: z.object({
|
||||
job_id: z.string().uuid()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
query: z
|
||||
.object({
|
||||
short_link: z.enum(['ephemeral_tool_chain', 'default']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -2201,6 +2466,17 @@ export const zCancelJobData = z.object({
|
||||
*/
|
||||
export const zCancelJobResponse = zJobCancelResponse
|
||||
|
||||
export const zCancelJobsData = z.object({
|
||||
body: zJobsCancelRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - cancel requests dispatched (or jobs were already terminal)
|
||||
*/
|
||||
export const zCancelJobsResponse = zJobsCancelResponse
|
||||
|
||||
export const zViewFileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2580,6 +2856,17 @@ export const zCreateSecretData = z.object({
|
||||
*/
|
||||
export const zCreateSecretResponse = zSecretResponse
|
||||
|
||||
export const zListSecretProvidersData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zListSecretProvidersResponse = zSecretProvidersResponse
|
||||
|
||||
export const zDeleteSecretData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -2881,6 +3168,40 @@ export const zExchangeTokenData = z.object({
|
||||
*/
|
||||
export const zExchangeTokenResponse2 = zExchangeTokenResponse
|
||||
|
||||
export const zCreateDesktopLoginCodeData = z.object({
|
||||
body: zDesktopLoginCodeCreateRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Login code created
|
||||
*/
|
||||
export const zCreateDesktopLoginCodeResponse = zDesktopLoginCodeCreateResponse
|
||||
|
||||
export const zRedeemDesktopLoginCodeData = z.object({
|
||||
body: zDesktopLoginCodeRedeemRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Code redeemed (or already redeemed by the same user)
|
||||
*/
|
||||
export const zRedeemDesktopLoginCodeResponse = zDesktopLoginCodeRedeemResponse
|
||||
|
||||
export const zExchangeDesktopLoginCodeData = z.object({
|
||||
body: zDesktopLoginCodeExchangeRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Pending (not yet redeemed) or complete with a custom token
|
||||
*/
|
||||
export const zExchangeDesktopLoginCodeResponse =
|
||||
zDesktopLoginCodeExchangeResponse
|
||||
|
||||
export const zGetJwksData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3150,6 +3471,19 @@ export const zRemoveWorkspaceMemberData = z.object({
|
||||
*/
|
||||
export const zRemoveWorkspaceMemberResponse = z.void()
|
||||
|
||||
export const zUpdateWorkspaceMemberRoleData = z.object({
|
||||
body: zUpdateMemberRoleRequest,
|
||||
path: z.object({
|
||||
userId: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Member role updated
|
||||
*/
|
||||
export const zUpdateWorkspaceMemberRoleResponse = zMember
|
||||
|
||||
export const zListWorkspaceApiKeysData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3236,6 +3570,19 @@ export const zSetReviewStatusData = z.object({
|
||||
*/
|
||||
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
|
||||
|
||||
export const zAdminDeleteHubWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Successfully deleted
|
||||
*/
|
||||
export const zAdminDeleteHubWorkflowResponse = z.void()
|
||||
|
||||
export const zUpdateHubWorkflowData = z.object({
|
||||
body: zUpdateHubWorkflowRequest,
|
||||
path: z.object({
|
||||
@@ -3277,6 +3624,19 @@ export const zCreateDeletionRequestResponse = z.object({
|
||||
user_found_in_cloud: z.boolean()
|
||||
})
|
||||
|
||||
export const zReleaseDeletionHoldData = z.object({
|
||||
body: z.object({
|
||||
firebase_id: z.string()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Release authorized; the deletion workflow will proceed
|
||||
*/
|
||||
export const zReleaseDeletionHoldResponse = zReleaseHoldResponse
|
||||
|
||||
export const zReportPartnerUsageData = z.object({
|
||||
body: zPartnerUsageRequest,
|
||||
path: z.never().optional(),
|
||||
@@ -3288,6 +3648,38 @@ export const zReportPartnerUsageData = z.object({
|
||||
*/
|
||||
export const zReportPartnerUsageResponse = zPartnerUsageResponse
|
||||
|
||||
export const zGetHistoryEventsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
workspace_id: z.string().optional(),
|
||||
user_id: z.string().optional(),
|
||||
event_type: z.string().optional(),
|
||||
start_date: z.string().datetime().optional(),
|
||||
end_date: z.string().datetime().optional(),
|
||||
page: z.number().int().optional(),
|
||||
limit: z.number().int().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Paginated cloud history events for the workspace
|
||||
*/
|
||||
export const zGetHistoryEventsResponse = zBillingEventsResponse
|
||||
|
||||
export const zReportHistoryEventData = z.object({
|
||||
body: zHistoryEventRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* History event recorded successfully
|
||||
*/
|
||||
export const zReportHistoryEventResponse = zPartnerUsageResponse
|
||||
|
||||
export const zUpdateSubscriptionCacheData = z.object({
|
||||
body: z.object({
|
||||
user_id: z.string(),
|
||||
@@ -3305,6 +3697,29 @@ export const zUpdateSubscriptionCacheResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
export const zListLinkedFirebaseUidsData = z.object({
|
||||
body: zListLinkedFirebaseUidsRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Linked Firebase UIDs (possibly empty list)
|
||||
*/
|
||||
export const zListLinkedFirebaseUidsResponse2 = zListLinkedFirebaseUidsResponse
|
||||
|
||||
export const zEnsureWorkspaceBillingProvisionedData = z.object({
|
||||
body: zEnsureWorkspaceBillingProvisionedRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* The workspace's provisioned billing identity
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingProvisionedResponse2 =
|
||||
zEnsureWorkspaceBillingProvisionedResponse
|
||||
|
||||
export const zInsertDynamicConfigData = z.object({
|
||||
body: z.record(z.unknown()),
|
||||
path: z.never().optional(),
|
||||
@@ -4010,6 +4425,14 @@ export const zGetModelPreviewData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zShortLinkRedirectData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetLegacyPromptByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -4070,14 +4493,23 @@ export const zGetLegacyUserdataV2Data = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetLegacyAssetContentData = z.object({
|
||||
export const zGetAssetContentData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
query: z
|
||||
.object({
|
||||
disposition: z.enum(['inline', 'attachment']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset content stream (local runtime streams the bytes directly)
|
||||
*/
|
||||
export const zGetAssetContentResponse = z.string()
|
||||
|
||||
export const zGetLegacyViewMetadataData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "*.config.ts"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "vitest.config.ts"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
94
pnpm-lock.yaml
generated
@@ -324,6 +324,9 @@ catalogs:
|
||||
rollup-plugin-visualizer:
|
||||
specifier: ^6.0.4
|
||||
version: 6.0.4
|
||||
shiki:
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.1
|
||||
storybook:
|
||||
specifier: ^10.2.10
|
||||
version: 10.2.10
|
||||
@@ -609,6 +612,9 @@ importers:
|
||||
semver:
|
||||
specifier: ^7.7.2
|
||||
version: 7.7.4
|
||||
shiki:
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.1
|
||||
three:
|
||||
specifier: 'catalog:'
|
||||
version: 0.184.0
|
||||
@@ -3461,32 +3467,32 @@ packages:
|
||||
pinia:
|
||||
optional: true
|
||||
|
||||
'@shikijs/core@4.1.0':
|
||||
resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
|
||||
'@shikijs/core@4.3.1':
|
||||
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-javascript@4.1.0':
|
||||
resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
|
||||
'@shikijs/engine-javascript@4.3.1':
|
||||
resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-oniguruma@4.1.0':
|
||||
resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
|
||||
'@shikijs/engine-oniguruma@4.3.1':
|
||||
resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/langs@4.1.0':
|
||||
resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
|
||||
'@shikijs/langs@4.3.1':
|
||||
resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/primitive@4.1.0':
|
||||
resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
|
||||
'@shikijs/primitive@4.3.1':
|
||||
resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/themes@4.1.0':
|
||||
resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
|
||||
'@shikijs/themes@4.3.1':
|
||||
resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/types@4.1.0':
|
||||
resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
|
||||
'@shikijs/types@4.3.1':
|
||||
resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
@@ -7918,8 +7924,8 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shiki@4.1.0:
|
||||
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
|
||||
shiki@4.3.1:
|
||||
resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
@@ -8884,8 +8890,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.5:
|
||||
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
|
||||
vue-component-type-helpers@3.3.6:
|
||||
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -9331,7 +9337,7 @@ snapshots:
|
||||
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
lru-cache: 11.2.6
|
||||
lru-cache: 11.5.1
|
||||
|
||||
'@asamuzakjp/dom-selector@6.7.6':
|
||||
dependencies:
|
||||
@@ -9365,7 +9371,7 @@ snapshots:
|
||||
js-yaml: 4.1.1
|
||||
picomatch: 4.0.4
|
||||
retext-smartypants: 6.2.0
|
||||
shiki: 4.1.0
|
||||
shiki: 4.3.1
|
||||
smol-toml: 1.6.1
|
||||
unified: 11.0.5
|
||||
|
||||
@@ -11528,40 +11534,40 @@ snapshots:
|
||||
optionalDependencies:
|
||||
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
|
||||
|
||||
'@shikijs/core@4.1.0':
|
||||
'@shikijs/core@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/primitive': 4.1.0
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/primitive': 4.3.1
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@4.1.0':
|
||||
'@shikijs/engine-javascript@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.6
|
||||
|
||||
'@shikijs/engine-oniguruma@4.1.0':
|
||||
'@shikijs/engine-oniguruma@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@4.1.0':
|
||||
'@shikijs/langs@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/types': 4.3.1
|
||||
|
||||
'@shikijs/primitive@4.1.0':
|
||||
'@shikijs/primitive@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/themes@4.1.0':
|
||||
'@shikijs/themes@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/types': 4.3.1
|
||||
|
||||
'@shikijs/types@4.1.0':
|
||||
'@shikijs/types@4.3.1':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
@@ -11668,7 +11674,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.5
|
||||
vue-component-type-helpers: 3.3.6
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -12979,7 +12985,7 @@ snapshots:
|
||||
picomatch: 4.0.4
|
||||
rehype: 13.0.2
|
||||
semver: 7.7.4
|
||||
shiki: 4.1.0
|
||||
shiki: 4.3.1
|
||||
smol-toml: 1.6.1
|
||||
svgo: 4.0.1
|
||||
tinyclip: 0.1.13
|
||||
@@ -16996,14 +17002,14 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shiki@4.1.0:
|
||||
shiki@4.3.1:
|
||||
dependencies:
|
||||
'@shikijs/core': 4.1.0
|
||||
'@shikijs/engine-javascript': 4.1.0
|
||||
'@shikijs/engine-oniguruma': 4.1.0
|
||||
'@shikijs/langs': 4.1.0
|
||||
'@shikijs/themes': 4.1.0
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/core': 4.3.1
|
||||
'@shikijs/engine-javascript': 4.3.1
|
||||
'@shikijs/engine-oniguruma': 4.3.1
|
||||
'@shikijs/langs': 4.3.1
|
||||
'@shikijs/themes': 4.3.1
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
@@ -18138,7 +18144,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.5: {}
|
||||
vue-component-type-helpers@3.3.6: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -117,6 +117,7 @@ catalog:
|
||||
primevue: ^4.2.5
|
||||
reka-ui: 2.5.0
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
shiki: ^4.3.0
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.3.0
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
/* In-App Agent panel (FE-1187) theme: aliases agent-* tokens onto host semantic
|
||||
variables. Imported after the design-system so those host variables exist. */
|
||||
@import '../../workbench/extensions/agent/agentTheme.css';
|
||||
|
||||
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
|
||||
and JS listeners aren't broken. */
|
||||
|
||||
@@ -1,133 +1,159 @@
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-col"
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-row"
|
||||
>
|
||||
<slot name="workflow-tabs" />
|
||||
|
||||
<!-- Left column: workflow tabs + canvas/panels. The agent dock is a sibling so it
|
||||
spans the full viewport height beside the tab bar. -->
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
class="pointer-events-none flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
<slot name="workflow-tabs" />
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel
|
||||
class="graph-canvas-panel relative overflow-visible"
|
||||
>
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: the agent dock, full viewport height, pixel width with a drag
|
||||
handle on its left edge (420-960px; the panel header icon jumps between the
|
||||
extremes). -->
|
||||
<div
|
||||
v-if="agentPanelDocked"
|
||||
class="pointer-events-auto relative h-full shrink-0 overflow-hidden"
|
||||
:style="{ width: `${agentPanelWidth}px` }"
|
||||
>
|
||||
<div
|
||||
class="agent-resize-handle absolute top-0 left-0 z-10 h-full w-[5px] cursor-col-resize"
|
||||
:data-resizing="isAgentResizing"
|
||||
@pointerdown="onAgentResizeStart"
|
||||
@lostpointercapture="isAgentResizing = false"
|
||||
/>
|
||||
<slot name="agent-panel" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import type { SplitterResizeStartEvent } from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -142,11 +168,13 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
const { t } = useI18n()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
@@ -162,6 +190,37 @@ const { isSelectMode, isBuilderMode } = useAppMode()
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||
const {
|
||||
isOpen: agentPanelOpen,
|
||||
enabled: agentPanelEnabled,
|
||||
width: agentPanelWidth
|
||||
} = storeToRefs(agentPanelStore)
|
||||
// The agent dock renders only while its flag gate is on and the user opened it.
|
||||
const agentPanelDocked = computed(
|
||||
() => agentPanelEnabled.value && agentPanelOpen.value
|
||||
)
|
||||
|
||||
// Drag-resize for the agent dock: capture the pointer on the handle and track the delta;
|
||||
// the store clamps into its [min, max] range.
|
||||
const isAgentResizing = ref(false)
|
||||
let agentResizeStartX = 0
|
||||
let agentResizeStartWidth = 0
|
||||
|
||||
function onAgentResizeStart(e: PointerEvent): void {
|
||||
isAgentResizing.value = true
|
||||
agentResizeStartX = e.clientX
|
||||
agentResizeStartWidth = agentPanelStore.width
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
useEventListener(document, 'pointermove', (e: PointerEvent) => {
|
||||
if (!isAgentResizing.value) return
|
||||
agentPanelStore.setWidth(
|
||||
agentResizeStartWidth + (agentResizeStartX - e.clientX)
|
||||
)
|
||||
})
|
||||
|
||||
const showOffsideSplitter = computed(
|
||||
() => rightSidePanelVisible.value || isSelectMode.value
|
||||
)
|
||||
@@ -304,4 +363,10 @@ const lastPanelStyle = computed(() => {
|
||||
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
.agent-resize-handle:hover,
|
||||
.agent-resize-handle[data-resizing='true'] {
|
||||
transition: background-color 0.2s ease 300ms;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@ import Password from 'primevue/password'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { computed, defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
@@ -38,29 +38,45 @@ vi.mock('@/stores/authStore', () => ({
|
||||
}))
|
||||
|
||||
const mockTurnstileEnabled = ref(false)
|
||||
const mockTurnstileEnforced = ref(false)
|
||||
const mockTurnstileToken = ref('')
|
||||
const mockTurnstileUnavailable = ref(false)
|
||||
const mockReset = vi.fn()
|
||||
let emitTurnstileToken: ((token: string) => void) | undefined
|
||||
let emitTurnstileUnavailable: ((unavailable: boolean) => void) | undefined
|
||||
|
||||
// The reset-on-toggle behavior lives in useTurnstileGate itself (see
|
||||
// useTurnstile.test.ts); this fake just wires token/unavailable through to
|
||||
// `waiting` the same way so SignUpForm's submit gating can be exercised.
|
||||
vi.mock('@/composables/auth/useTurnstile', () => ({
|
||||
useTurnstile: () => ({
|
||||
enabled: mockTurnstileEnabled,
|
||||
enforced: mockTurnstileEnforced
|
||||
enabled: mockTurnstileEnabled
|
||||
}),
|
||||
useTurnstileGate: () => ({
|
||||
token: mockTurnstileToken,
|
||||
unavailable: mockTurnstileUnavailable,
|
||||
waiting: computed(
|
||||
() =>
|
||||
mockTurnstileEnabled.value &&
|
||||
!mockTurnstileToken.value &&
|
||||
!mockTurnstileUnavailable.value
|
||||
)
|
||||
})
|
||||
}))
|
||||
|
||||
// Stub the real widget (which loads the external Turnstile script) with one that
|
||||
// exposes a spyable reset() and lets a test drive the v-model token the way a
|
||||
// solved challenge would.
|
||||
// exposes a spyable reset() and lets a test drive the v-model token/unavailable
|
||||
// the way a solved challenge (or a broken/slow widget) would.
|
||||
vi.mock('./TurnstileWidget.vue', async () => {
|
||||
const { defineComponent: defineMock } = await import('vue')
|
||||
return {
|
||||
default: defineMock({
|
||||
name: 'TurnstileWidget',
|
||||
emits: ['update:token'],
|
||||
emits: ['update:token', 'update:unavailable'],
|
||||
setup(_, { expose, emit }) {
|
||||
expose({ reset: mockReset })
|
||||
emitTurnstileToken = (token: string) => emit('update:token', token)
|
||||
emitTurnstileUnavailable = (unavailable: boolean) =>
|
||||
emit('update:unavailable', unavailable)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
@@ -92,9 +108,11 @@ describe('SignUpForm', () => {
|
||||
beforeEach(() => {
|
||||
mockLoadingRef.value = false
|
||||
mockTurnstileEnabled.value = false
|
||||
mockTurnstileEnforced.value = false
|
||||
mockTurnstileToken.value = ''
|
||||
mockTurnstileUnavailable.value = false
|
||||
mockReset.mockClear()
|
||||
emitTurnstileToken = undefined
|
||||
emitTurnstileUnavailable = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -211,43 +229,22 @@ describe('SignUpForm', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile token hygiene', () => {
|
||||
it('clears the stale token when Turnstile becomes disabled', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const { user } = renderComponent()
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileToken!('stale-token')
|
||||
await nextTick()
|
||||
expect(
|
||||
screen.getByRole('button', { name: signUpButton })
|
||||
).not.toBeDisabled()
|
||||
|
||||
mockTurnstileEnabled.value = false
|
||||
await nextTick()
|
||||
|
||||
// re-enable: the stale token must have been cleared so submit is blocked again
|
||||
mockTurnstileEnabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Regression coverage for the shadow-mode race: previously submit was only
|
||||
// gated in 'enforce' mode, so most real signups in 'shadow' mode raced
|
||||
// ahead of the async Cloudflare challenge and reached the backend with an
|
||||
// empty token. Gating now depends only on whether the widget is enabled
|
||||
// (shadow or enforce both render it), so both modes behave identically here.
|
||||
describe('Turnstile submit gating', () => {
|
||||
it('disables the submit button in enforce mode until a token is present', async () => {
|
||||
it('disables the submit button until a token is present', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not emit submit in enforce mode while the token is empty', async () => {
|
||||
it('does not emit submit while the token is empty', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
@@ -257,9 +254,8 @@ describe('SignUpForm', () => {
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
|
||||
it('emits submit with the token once the challenge is solved', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
@@ -271,13 +267,14 @@ describe('SignUpForm', () => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
|
||||
})
|
||||
|
||||
it('emits submit without a token in shadow mode (never blocks)', async () => {
|
||||
it('emits submit without a token once the widget reports itself unavailable (broken/slow load fallback)', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = false
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileUnavailable!(true)
|
||||
await nextTick()
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)
|
||||
|
||||
@@ -33,10 +33,11 @@
|
||||
v-if="turnstileEnabled"
|
||||
ref="turnstileWidget"
|
||||
v-model:token="turnstileToken"
|
||||
v-model:unavailable="turnstileUnavailable"
|
||||
/>
|
||||
|
||||
<small
|
||||
v-show="submitBlockedByTurnstile"
|
||||
v-show="waitingForTurnstile"
|
||||
id="comfy-org-sign-up-turnstile-hint"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@@ -51,11 +52,9 @@
|
||||
v-else
|
||||
type="submit"
|
||||
class="mt-4 h-10 font-medium"
|
||||
:disabled="!$form.valid || submitBlockedByTurnstile"
|
||||
:disabled="!$form.valid || waitingForTurnstile"
|
||||
:aria-describedby="
|
||||
submitBlockedByTurnstile
|
||||
? 'comfy-org-sign-up-turnstile-hint'
|
||||
: undefined
|
||||
waitingForTurnstile ? 'comfy-org-sign-up-turnstile-hint' : undefined
|
||||
"
|
||||
>
|
||||
{{ t('auth.signup.signUpButton') }}
|
||||
@@ -70,11 +69,11 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTurnstile } from '@/composables/auth/useTurnstile'
|
||||
import { useTurnstile, useTurnstileGate } from '@/composables/auth/useTurnstile'
|
||||
import { signUpSchema } from '@/schemas/signInSchema'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -86,25 +85,21 @@ const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
|
||||
useTurnstile()
|
||||
const turnstileToken = ref('')
|
||||
const { enabled: turnstileEnabled } = useTurnstile()
|
||||
const {
|
||||
token: turnstileToken,
|
||||
unavailable: turnstileUnavailable,
|
||||
waiting: waitingForTurnstile
|
||||
} = useTurnstileGate(turnstileEnabled)
|
||||
const turnstileWidget =
|
||||
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
|
||||
const submitBlockedByTurnstile = computed(
|
||||
() => turnstileEnforced.value && !turnstileToken.value
|
||||
)
|
||||
|
||||
watch(turnstileEnabled, (on) => {
|
||||
if (!on) turnstileToken.value = ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: SignUpData, turnstileToken?: string]
|
||||
}>()
|
||||
|
||||
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
|
||||
if (event.valid && !submitBlockedByTurnstile.value) {
|
||||
if (event.valid && !waitingForTurnstile.value) {
|
||||
emit(
|
||||
'submit',
|
||||
event.values as SignUpData,
|
||||
|
||||
@@ -261,4 +261,138 @@ describe('TurnstileWidget', () => {
|
||||
|
||||
expect(api.remove).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
// A widget that never resolves (broken script, ad-blocker, CDN outage, or a
|
||||
// hung challenge) must eventually tell the parent it cannot be relied on,
|
||||
// so submission can fall back instead of blocking a legitimate signup
|
||||
// forever.
|
||||
describe('unavailable fallback', () => {
|
||||
it('reports unavailable when the Turnstile script fails to load', async () => {
|
||||
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('reports unavailable on a challenge error', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('clears the unavailable fallback once a token is solved', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('falls back once the widget fails to resolve within the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
// Let the onMounted hook's `await loadTurnstile()` microtask settle
|
||||
// and render() run, without yet advancing to the timeout itself.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(options()).toBeDefined()
|
||||
expect(emitted()['update:unavailable']).toBeUndefined()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fall back once a token arrives before the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']).toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('resets the widget to fetch a fresh challenge on token expiry', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
options()!['expired-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(api.reset).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
it('falls back if a post-solve expiry is not followed by a fresh token within the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// Establish a solved, available widget: an initial error marks it
|
||||
// unavailable, then solving a challenge clears that (the same
|
||||
// transition the existing "clears the unavailable fallback" test
|
||||
// verifies), so the expiry below is the only thing driving fallback.
|
||||
options()!['error-callback']!()
|
||||
options()!.callback!('token-abc')
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
|
||||
// The token later expires (e.g. tab backgrounded past its ~300s
|
||||
// lifetime) without the widget itself erroring.
|
||||
options()!['expired-callback']!()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// A fresh challenge was requested, but nothing solves it before the
|
||||
// re-armed load timeout elapses, so submission must eventually be
|
||||
// unblocked rather than staying stuck forever.
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -20,6 +21,14 @@ import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
const token = defineModel<string>('token', { default: '' })
|
||||
/**
|
||||
* Set true whenever the widget cannot be relied on to ever produce a token:
|
||||
* the Cloudflare script failed to load, the rendered challenge errored out,
|
||||
* or it simply hasn't resolved within `TURNSTILE_LOAD_TIMEOUT_MS`. The parent
|
||||
* uses this to stop waiting on a token so a broken/slow widget (network
|
||||
* issue, ad-blocker, CDN outage) can never permanently block signup.
|
||||
*/
|
||||
const unavailable = defineModel<boolean>('unavailable', { default: false })
|
||||
|
||||
const { t } = useI18n()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
@@ -28,6 +37,16 @@ const containerRef = ref<HTMLDivElement>()
|
||||
const errorMessage = ref('')
|
||||
let widgetId: string | undefined
|
||||
|
||||
/** How long to wait for the widget to resolve before falling back. */
|
||||
const TURNSTILE_LOAD_TIMEOUT_MS = 9_000
|
||||
const { start: armTimeout, stop: clearLoadTimeout } = useTimeoutFn(
|
||||
() => {
|
||||
unavailable.value = true
|
||||
},
|
||||
TURNSTILE_LOAD_TIMEOUT_MS,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const clearToken = () => {
|
||||
token.value = ''
|
||||
}
|
||||
@@ -46,12 +65,18 @@ const reset = () => {
|
||||
errorMessage.value = ''
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
// A widget that renders can request a fresh challenge, so give it
|
||||
// another chance before falling back again.
|
||||
unavailable.value = false
|
||||
armTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
|
||||
onMounted(async () => {
|
||||
armTimeout()
|
||||
|
||||
try {
|
||||
const turnstile = await loadTurnstile()
|
||||
if (!containerRef.value) return
|
||||
@@ -64,23 +89,37 @@ onMounted(async () => {
|
||||
sitekey: getTurnstileSiteKey(),
|
||||
theme,
|
||||
callback: (newToken: string) => {
|
||||
clearLoadTimeout()
|
||||
errorMessage.value = ''
|
||||
unavailable.value = false
|
||||
token.value = newToken
|
||||
},
|
||||
'expired-callback': () => {
|
||||
clearToken()
|
||||
errorMessage.value = t('auth.turnstile.expired')
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
// A solved token can expire on its own (e.g. the tab was
|
||||
// backgrounded past the token's ~300s lifetime) without the widget
|
||||
// ever erroring, so proactively request a fresh challenge and
|
||||
// re-arm the load timeout in case it doesn't resolve in time.
|
||||
armTimeout()
|
||||
}
|
||||
},
|
||||
'error-callback': () => {
|
||||
clearToken()
|
||||
clearLoadTimeout()
|
||||
console.warn('Turnstile challenge failed')
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
unavailable.value = true
|
||||
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
clearLoadTimeout()
|
||||
console.warn('Turnstile failed to load', error)
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
unavailable.value = true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
<AppBuilder v-if="isBuilderMode" />
|
||||
<NodePropertiesPanel v-else />
|
||||
</template>
|
||||
<template #agent-panel>
|
||||
<div class="size-full p-2">
|
||||
<div
|
||||
class="size-full overflow-hidden rounded-lg border border-(--interface-stroke)"
|
||||
>
|
||||
<AgentPanelRoot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu
|
||||
v-if="canvasMenuEnabled && !isBuilderMode"
|
||||
@@ -181,6 +190,7 @@ import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useNewUserService } from '@/services/useNewUserService'
|
||||
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
|
||||
import AgentPanelRoot from '@/workbench/extensions/agent/AgentPanelRoot.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
|
||||
@@ -147,7 +147,9 @@ describe('TabErrors.vue', () => {
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
|
||||
// Raw prompt-error details pass through to the card so "View details"
|
||||
// shows the underlying failure.
|
||||
expect(screen.getByText('Error details')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders node validation errors grouped by catalog copy', async () => {
|
||||
|
||||
@@ -62,6 +62,10 @@ vi.mock('@/i18n', () => {
|
||||
'Prompt has no outputs',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.desc':
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.',
|
||||
'errorCatalog.promptErrors.agent_draft_apply_failed.title':
|
||||
'An error was found',
|
||||
'errorCatalog.promptErrors.agent_draft_apply_failed.desc':
|
||||
"Couldn't apply the agent's draft to the canvas.",
|
||||
'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed',
|
||||
'errorCatalog.runtimeErrors.execution_failed.message':
|
||||
'Node threw an error during execution.',
|
||||
@@ -621,6 +625,25 @@ describe('useErrorGroups', () => {
|
||||
expect(promptGroup).toBeDefined()
|
||||
})
|
||||
|
||||
it('carries prompt error details onto the card item', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastPromptError = {
|
||||
type: 'agent_draft_apply_failed',
|
||||
message: "Couldn't apply the agent's draft to the canvas",
|
||||
details: 'Validation error: Required at "version"'
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution' && g.displayTitle === 'An error was found'
|
||||
)
|
||||
const details =
|
||||
promptGroup && 'cards' in promptGroup
|
||||
? promptGroup.cards[0]?.errors[0]?.details
|
||||
: undefined
|
||||
expect(details).toBe('Validation error: Required at "version"')
|
||||
})
|
||||
|
||||
it('includes prompt error when a node is selected', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -358,6 +358,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
errors: [
|
||||
{
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
...resolvedDisplay
|
||||
}
|
||||
]
|
||||
|
||||
@@ -79,6 +79,11 @@ vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({ shiftDown: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/agent/stores/agent/agentPanelStore', () => ({
|
||||
useAgentPanelStore: () =>
|
||||
reactive({ isOpen: false, enabled: false, toggle: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/mouseDownUtil', () => ({
|
||||
whileMouseDown: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -84,6 +84,23 @@
|
||||
data-testid="integrated-tab-bar-actions"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<button
|
||||
v-if="agentPanelEnabled"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'no-drag flex h-6 shrink-0 cursor-pointer items-center gap-2 rounded-sm border px-2 text-xs text-base-foreground transition-colors',
|
||||
isAgentPanelOpen
|
||||
? 'border-plum-500 bg-plum-600/20'
|
||||
: 'border-plum-600 bg-ink-700 hover:border-plum-500'
|
||||
)
|
||||
"
|
||||
:aria-label="$t('agent.askComfyAgent')"
|
||||
@click="agentPanelStore.toggle()"
|
||||
>
|
||||
<i class="icon-[comfy--comfy-c] size-3 text-brand-yellow" />
|
||||
<span>{{ $t('agent.askComfyAgent') }}</span>
|
||||
</button>
|
||||
<Button
|
||||
v-if="isCloud || isNightly"
|
||||
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
|
||||
@@ -106,7 +123,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
@@ -126,6 +145,7 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
@@ -145,6 +165,9 @@ const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const commandStore = useCommandStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
const { isOpen: isAgentPanelOpen, enabled: agentPanelEnabled } =
|
||||
storeToRefs(agentPanelStore)
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
// Dismiss a tab's terminal status badge once it has been viewed
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import {
|
||||
isTurnstileEnabled,
|
||||
normalizeTurnstileMode,
|
||||
useTurnstile
|
||||
useTurnstile,
|
||||
useTurnstileGate
|
||||
} from '@/composables/auth/useTurnstile'
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -137,3 +139,63 @@ describe('useTurnstile', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTurnstileGate', () => {
|
||||
it('waits while enabled with no token yet', () => {
|
||||
const { waiting } = useTurnstileGate(ref(true))
|
||||
expect(waiting.value).toBe(true)
|
||||
})
|
||||
|
||||
it('never waits while disabled', () => {
|
||||
const { waiting } = useTurnstileGate(ref(false))
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stops waiting once a token arrives', () => {
|
||||
const { token, waiting } = useTurnstileGate(ref(true))
|
||||
|
||||
token.value = 'token-abc'
|
||||
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stops waiting once the widget reports itself unavailable', () => {
|
||||
const { unavailable, waiting } = useTurnstileGate(ref(true))
|
||||
|
||||
unavailable.value = true
|
||||
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('clears stale token/unavailable state when enabled turns off', async () => {
|
||||
const enabled = ref(true)
|
||||
const { token, unavailable } = useTurnstileGate(enabled)
|
||||
token.value = 'stale-token'
|
||||
unavailable.value = true
|
||||
|
||||
enabled.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(token.value).toBe('')
|
||||
expect(unavailable.value).toBe(false)
|
||||
})
|
||||
|
||||
// Regression coverage: the reset used to only run on the enabled->disabled
|
||||
// transition, so state written while the widget was briefly disabled could
|
||||
// survive into the next enabled widget instance.
|
||||
it('clears stale token/unavailable state when enabled turns back on', async () => {
|
||||
const enabled = ref(true)
|
||||
const { token, unavailable } = useTurnstileGate(enabled)
|
||||
|
||||
enabled.value = false
|
||||
await nextTick()
|
||||
token.value = 'stale-token'
|
||||
unavailable.value = true
|
||||
|
||||
enabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(token.value).toBe('')
|
||||
expect(unavailable.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
@@ -42,3 +43,31 @@ export function useTurnstile() {
|
||||
|
||||
return { mode, siteKey, enabled, enforced }
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit-gating state for the signup form's Turnstile widget: a token/
|
||||
* unavailable pair, plus `waiting`, which is true while a real token is still
|
||||
* needed. Waits in both shadow and enforce mode (`enabled`), not just
|
||||
* `enforced`, so shadow mode's token can't race the async Cloudflare
|
||||
* challenge; falls back open once the widget reports `unavailable` so a
|
||||
* broken/slow load can never permanently block signup.
|
||||
*
|
||||
* `token`/`unavailable` reset on every `enabled` transition, in either
|
||||
* direction, so state from a previous widget instance can never leak into a
|
||||
* freshly (re-)rendered one.
|
||||
*/
|
||||
export function useTurnstileGate(enabled: Ref<boolean>) {
|
||||
const token = ref('')
|
||||
const unavailable = ref(false)
|
||||
|
||||
const waiting = computed(
|
||||
() => enabled.value && !token.value && !unavailable.value
|
||||
)
|
||||
|
||||
watch(enabled, () => {
|
||||
token.value = ''
|
||||
unavailable.value = false
|
||||
})
|
||||
|
||||
return { token, unavailable, waiting }
|
||||
}
|
||||
|
||||
89
src/extensions/core/agentPanel.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
// Per-test mock state, hoisted so the vi.mock factories below may close over it.
|
||||
// The extension's only job is the fail-closed flag gate: it flips the panel store's
|
||||
// `enabled` and closes an open panel when the flag turns off (the tab-bar button and the
|
||||
// dock both key off the store). The posthog mock exposes isFeatureEnabled (whose return
|
||||
// the gate reads) and onFeatureFlags (which captures the reload listener so the test can
|
||||
// drive flag transitions).
|
||||
const mocks = vi.hoisted(() => ({
|
||||
capturedExtensions: [] as ComfyExtension[],
|
||||
agentStore: { enabled: false, close: vi.fn() },
|
||||
flagEnabled: undefined as boolean | undefined,
|
||||
flagListener: null as (() => void) | null
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
registerExtension: (ext: ComfyExtension) => {
|
||||
mocks.capturedExtensions.push(ext)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/agent/stores/agent/agentPanelStore', () => ({
|
||||
useAgentPanelStore: () => mocks.agentStore
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
isFeatureEnabled: () => mocks.flagEnabled,
|
||||
onFeatureFlags: (listener: () => void) => {
|
||||
mocks.flagListener = listener
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const flush = (): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
async function loadEntryAndSetup(): Promise<void> {
|
||||
await import('./agentPanel')
|
||||
const ext = mocks.capturedExtensions.find(
|
||||
(e) => e.name === 'Comfy.AgentPanel'
|
||||
)
|
||||
expect(ext).toBeDefined()
|
||||
ext!.setup!({} as Parameters<NonNullable<ComfyExtension['setup']>>[0])
|
||||
// setup fires setupFlagGate, which awaits the (mocked) posthog-js dynamic import before
|
||||
// it captures the onFeatureFlags listener and runs the initial sync(). Flush macrotasks
|
||||
// until that listener is registered so tests can drive flag transitions through it.
|
||||
for (let i = 0; i < 20 && mocks.flagListener === null; i++) await flush()
|
||||
expect(mocks.flagListener).toBeTypeOf('function')
|
||||
}
|
||||
|
||||
describe('AgentPanel extension flag gate', () => {
|
||||
beforeEach(() => {
|
||||
mocks.capturedExtensions.length = 0
|
||||
mocks.agentStore.close.mockClear()
|
||||
mocks.agentStore.enabled = false
|
||||
mocks.flagEnabled = undefined
|
||||
mocks.flagListener = null
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('leaves the panel disabled while the flag is undefined', async () => {
|
||||
await loadEntryAndSetup()
|
||||
expect(mocks.agentStore.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('enables the panel when the flag turns true', async () => {
|
||||
await loadEntryAndSetup()
|
||||
mocks.flagEnabled = true
|
||||
mocks.flagListener!()
|
||||
expect(mocks.agentStore.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('disables and closes the panel when the flag flips back to false', async () => {
|
||||
await loadEntryAndSetup()
|
||||
mocks.flagEnabled = true
|
||||
mocks.flagListener!()
|
||||
mocks.flagEnabled = false
|
||||
mocks.flagListener!()
|
||||
|
||||
expect(mocks.agentStore.enabled).toBe(false)
|
||||
expect(mocks.agentStore.close).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
39
src/extensions/core/agentPanel.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createPostHogFlagSource } from '@/workbench/extensions/agent/composables/agent/useAgentFeatureGate'
|
||||
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
/**
|
||||
* In-App Agent panel (FE-1187): the panel docks as a full-viewport-height right column
|
||||
* (the splitter overlay's agent-panel slot) and opens from the workflow-tab-bar "Ask
|
||||
* Comfy Agent" button. Gated by the PostHog flag `agent-in-app-experience`, fail-closed:
|
||||
* the button is absent and the panel unreachable until the flag evaluates true, and both
|
||||
* hide again (closing an open panel) if the flag turns off.
|
||||
*/
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.AgentPanel',
|
||||
setup() {
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
|
||||
async function setupFlagGate(): Promise<void> {
|
||||
// posthog-js is initialized (or not) by the telemetry provider; an
|
||||
// uninitialized client answers isFeatureEnabled with undefined, which the
|
||||
// flag source maps to false, so the gate stays closed by default.
|
||||
const posthog = (await import('posthog-js')).default
|
||||
const source = createPostHogFlagSource(posthog)
|
||||
const sync = (): void => {
|
||||
// The dev server shows the panel without the PostHog flag (which is scoped to a cloud
|
||||
// project the dev build may not read); test and production builds still gate on it.
|
||||
const forceInDev = import.meta.env.MODE === 'development'
|
||||
const enabled = forceInDev || source.isEnabled()
|
||||
agentPanelStore.enabled = enabled
|
||||
// Fail-closed: a flag flip-off also closes an open panel.
|
||||
if (!enabled) agentPanelStore.close()
|
||||
}
|
||||
source.onChange?.(sync)
|
||||
sync()
|
||||
}
|
||||
|
||||
void setupFlagGate()
|
||||
}
|
||||
})
|
||||
@@ -37,6 +37,7 @@ if (isCloud) {
|
||||
await import('./cloudRemoteConfig')
|
||||
await import('./cloudBadges')
|
||||
await import('./cloudSessionCookie')
|
||||
await import('./agentPanel')
|
||||
}
|
||||
|
||||
// Feedback button for cloud and nightly builds
|
||||
|
||||
@@ -886,6 +886,7 @@
|
||||
"nodes": "Nodes",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"agentPanel": "Comfy Agent",
|
||||
"assets": "Assets",
|
||||
"mediaAssets": {
|
||||
"title": "Media Assets",
|
||||
@@ -910,6 +911,7 @@
|
||||
"queue": "Queue",
|
||||
"nodes": "Nodes",
|
||||
"models": "Models",
|
||||
"agentPanel": "Comfy Agent",
|
||||
"assets": "Assets",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
@@ -4316,6 +4318,10 @@
|
||||
"prompt_outputs_failed_validation": {
|
||||
"title": "Prompt validation failed",
|
||||
"desc": "The workflow has invalid node inputs. Fix the highlighted nodes before running it again."
|
||||
},
|
||||
"agent_draft_apply_failed": {
|
||||
"title": "An error was found",
|
||||
"desc": "Couldn't apply the agent's draft to the canvas."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4496,5 +4502,81 @@
|
||||
"training": "Training…",
|
||||
"processingVideo": "Processing video…",
|
||||
"running": "Running…"
|
||||
},
|
||||
"agent": {
|
||||
"title": "Comfy Agent",
|
||||
"askComfyAgent": "Ask Comfy Agent",
|
||||
"caption": "The agent edits your workflow as a draft. Only you can run it.",
|
||||
"latest": "Latest",
|
||||
"copyUnavailable": "Only the current conversation can be copied in this version",
|
||||
"alpha": "ALPHA",
|
||||
"newChat": "New chat",
|
||||
"newChatTitle": "Untitled",
|
||||
"maximize": "Maximize panel",
|
||||
"minimize": "Minimize panel",
|
||||
"close": "Close",
|
||||
"history": "Chat history",
|
||||
"showChatHistory": "Show chat history",
|
||||
"backToChat": "Back to chat",
|
||||
"historyCurrent": "Current",
|
||||
"historyToday": "Today",
|
||||
"historyYesterday": "Yesterday",
|
||||
"historyEarlier": "Earlier",
|
||||
"historyEmpty": "No conversations yet",
|
||||
"copyMarkdown": "Copy as markdown",
|
||||
"delete": "Delete",
|
||||
"untitledChat": "Untitled",
|
||||
"next": "Next",
|
||||
"skip": "Skip",
|
||||
"gotIt": "Got it",
|
||||
"startingPointTitle": "How do you want to start?",
|
||||
"startFromScratch": "Start from scratch",
|
||||
"browseTemplates": "Browse templates",
|
||||
"greeting": "Hello {name},",
|
||||
"greetingQuestion": "What do you want to make?",
|
||||
"placeholder": "Ask Comfy Agent…",
|
||||
"suggestedPrompts": [
|
||||
"Generate a yellow duck with a hockey mask",
|
||||
"List my saved workflows",
|
||||
"Find the best workflow for skin upscaling",
|
||||
"Explain the selected node",
|
||||
"Build a workflow for image to video with 3 models"
|
||||
],
|
||||
"attach": "Attach a file",
|
||||
"mention": "Mention a node",
|
||||
"modelAuto": "Auto",
|
||||
"send": "Send",
|
||||
"stop": "Stop",
|
||||
"thinking": "Thinking...",
|
||||
"reasoning": "Reasoning",
|
||||
"helpful": "Helpful",
|
||||
"notHelpful": "Not helpful",
|
||||
"toggleRaw": "Toggle raw markdown",
|
||||
"ranToolCalls": "Ran {count} tool call | Ran {count} tool calls",
|
||||
"savedToAssets": "Saved to your Assets",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"remove": "Remove",
|
||||
"friend": "there",
|
||||
"you": "You",
|
||||
"sessionOptions": "Chat options",
|
||||
"takeControl": "Take control",
|
||||
"approve": "Approve",
|
||||
"deny": "Deny",
|
||||
"revert": "Revert",
|
||||
"approvalTitle": "Approval needed",
|
||||
"waiting": "Waiting for approval...",
|
||||
"agentEditing": "Comfy Agent is editing the canvas",
|
||||
"conflictTitle": "You edited while the agent was working",
|
||||
"conflictBody": "Choose whose changes to keep.",
|
||||
"keepMine": "Keep my changes",
|
||||
"letAgentContinue": "Let the agent continue",
|
||||
"openNewTab": "Open in a new tab",
|
||||
"sendFailed": "Message failed to send",
|
||||
"sendBusy": "A message is already being sent",
|
||||
"malformedEvent": "The agent sent an event this panel could not read",
|
||||
"draftApplyFailed": "Couldn't apply the agent's draft to the canvas",
|
||||
"coachTitle": "Meet the agent",
|
||||
"coachBody": "Ask it to build or edit graphs."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,6 +555,23 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves the agent draft-apply failure to overlay copy', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'agent_draft_apply_failed',
|
||||
message: "Couldn't apply the agent's draft to the canvas",
|
||||
details: 'Validation error: Required at "version"'
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayTitle: 'An error was found',
|
||||
displayMessage: "Couldn't apply the agent's draft to the canvas."
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves server_error prompt copy by environment', () => {
|
||||
const error = {
|
||||
type: 'server_error',
|
||||
|
||||
@@ -13,7 +13,8 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
||||
'no_prompt',
|
||||
'server_error',
|
||||
'missing_node_type',
|
||||
'prompt_outputs_failed_validation'
|
||||
'prompt_outputs_failed_validation',
|
||||
'agent_draft_apply_failed'
|
||||
])
|
||||
|
||||
function getPromptExceptionMessage(
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AgentMessageFeedbackMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
@@ -283,6 +284,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackUiButtonClicked?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentMessageFeedback(metadata: AgentMessageFeedbackMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentMessageFeedback?.(metadata))
|
||||
}
|
||||
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageView?.(pageName, properties))
|
||||
}
|
||||
|
||||
@@ -195,6 +195,46 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('platform axes (client / deployment)', () => {
|
||||
afterEach(() => {
|
||||
delete window.__comfyDesktop2
|
||||
})
|
||||
|
||||
it('registers client=web and deployment=cloud in a plain browser', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
client: 'web',
|
||||
deployment: 'cloud'
|
||||
})
|
||||
})
|
||||
|
||||
it('registers client=desktop when the desktop preload bridge is present', async () => {
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
Telemetry: { capture: vi.fn() }
|
||||
}
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
client: 'desktop',
|
||||
deployment: 'cloud'
|
||||
})
|
||||
})
|
||||
|
||||
it('registers platform axes before flushing pre-init queued events', async () => {
|
||||
const provider = createProvider()
|
||||
provider.trackSignupOpened()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const registerOrder = hoisted.mockRegister.mock.invocationCallOrder[0]
|
||||
const captureOrder = hoisted.mockCapture.mock.invocationCallOrder[0]
|
||||
expect(registerOrder).toBeLessThan(captureOrder)
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop entry capture', () => {
|
||||
function setLocation(search: string): void {
|
||||
Object.defineProperty(window.location, 'search', {
|
||||
@@ -208,12 +248,20 @@ describe('PostHogTelemetryProvider', () => {
|
||||
setLocation('')
|
||||
})
|
||||
|
||||
// The platform-axes register (client/deployment) always fires, so these
|
||||
// assert no register call carrying desktop-entry attribution props.
|
||||
function desktopEntryRegisterCalls(): unknown[][] {
|
||||
return hoisted.mockRegister.mock.calls.filter(
|
||||
([props]) => props && 'source_app' in (props as Record<string, unknown>)
|
||||
)
|
||||
}
|
||||
|
||||
it('does not register desktop props when utm_source is absent', async () => {
|
||||
setLocation('')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
expect(desktopEntryRegisterCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not register desktop props when utm_source is not comfy.desktop', async () => {
|
||||
@@ -221,7 +269,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
expect(desktopEntryRegisterCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('registers source_app and desktop_device_id when arriving from desktop', async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AgentMessageFeedbackMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
@@ -143,6 +144,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
before_send: createPostHogBeforeSend()
|
||||
})
|
||||
this.isInitialized = true
|
||||
// Before flushEventQueue so pre-init events also carry the
|
||||
// platform super properties.
|
||||
this.registerPlatformProps()
|
||||
this.flushEventQueue()
|
||||
this.registerDesktopEntryProps()
|
||||
|
||||
@@ -285,6 +289,18 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
)
|
||||
}
|
||||
|
||||
private registerPlatformProps(): void {
|
||||
if (!this.posthog) return
|
||||
try {
|
||||
this.posthog.register({
|
||||
client: window.__comfyDesktop2 ? 'desktop' : 'web',
|
||||
deployment: 'cloud'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to register platform props:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private registerDesktopEntryProps(): void {
|
||||
if (!this.posthog) return
|
||||
const props = readDesktopEntryProps()
|
||||
@@ -549,6 +565,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackAgentMessageFeedback(metadata: AgentMessageFeedbackMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_MESSAGE_FEEDBACK, metadata)
|
||||
}
|
||||
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.captureRaw(TelemetryEvents.PAGE_VIEW, {
|
||||
page_name: pageName,
|
||||
|
||||
@@ -375,6 +375,15 @@ export interface UiButtonClickMetadata {
|
||||
element_group: string
|
||||
}
|
||||
|
||||
/**
|
||||
* In-App Agent message rating metadata (PM-98). `vote` is null when the user retracts a
|
||||
* prior thumb, which the eval pipeline records as a retraction rather than dropping.
|
||||
*/
|
||||
export interface AgentMessageFeedbackMetadata extends Record<string, unknown> {
|
||||
message_id: string
|
||||
vote: 'up' | 'down' | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Help center opened metadata
|
||||
*/
|
||||
@@ -615,6 +624,9 @@ export interface TelemetryProvider {
|
||||
// Generic UI button click events
|
||||
trackUiButtonClicked?(metadata: UiButtonClickMetadata): void
|
||||
|
||||
// In-App Agent message rating (PM-98)
|
||||
trackAgentMessageFeedback?(metadata: AgentMessageFeedbackMetadata): void
|
||||
|
||||
// Page view tracking
|
||||
trackPageView?(pageName: string, properties?: PageViewMetadata): void
|
||||
}
|
||||
@@ -717,6 +729,9 @@ export const TelemetryEvents = {
|
||||
// Generic UI Button Click
|
||||
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
|
||||
|
||||
// In-App Agent
|
||||
AGENT_MESSAGE_FEEDBACK: 'app:agent_message_feedback',
|
||||
|
||||
// Page View
|
||||
PAGE_VIEW: 'app:page_view'
|
||||
} as const
|
||||
@@ -766,6 +781,7 @@ export type TelemetryEventProperties =
|
||||
| HelpCenterOpenedMetadata
|
||||
| HelpResourceClickedMetadata
|
||||
| HelpCenterClosedMetadata
|
||||
| AgentMessageFeedbackMetadata
|
||||
| WorkflowCreatedMetadata
|
||||
| EnterLinearMetadata
|
||||
| ShareFlowMetadata
|
||||
|
||||
561
src/workbench/extensions/agent/AgentPanelRoot.test.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
import { app } from '@/scripts/app'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
// A controllable stand-in for the host api: an EventTarget with a swappable socket, the
|
||||
// exact surface the reconnecting event source binds to. Emitting a malformed agent frame
|
||||
// through its socket is the one seam that drives a session notice at mount without a live
|
||||
// backend, which is what FIX 5 forwards to the toast. Hoisted so the api mock factory can
|
||||
// close over the same listener map the test emits through.
|
||||
const socket = vi.hoisted(() => {
|
||||
type Listener = (event?: { data: unknown }) => void
|
||||
const listeners = new Map<string, Set<Listener>>()
|
||||
const fake = {
|
||||
readyState: 1,
|
||||
addEventListener(type: string, listener: Listener) {
|
||||
const set = listeners.get(type) ?? new Set()
|
||||
set.add(listener)
|
||||
listeners.set(type, set)
|
||||
},
|
||||
removeEventListener(type: string, listener: Listener) {
|
||||
listeners.get(type)?.delete(listener)
|
||||
}
|
||||
}
|
||||
const emit = (type: string, event?: { data: unknown }): void => {
|
||||
for (const listener of listeners.get(type) ?? []) listener(event)
|
||||
}
|
||||
const clear = (): void => listeners.clear()
|
||||
return { fake, emit, clear }
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/api', () => {
|
||||
const target = new EventTarget()
|
||||
return {
|
||||
api: {
|
||||
socket: socket.fake,
|
||||
addEventListener: target.addEventListener.bind(target),
|
||||
removeEventListener: target.removeEventListener.bind(target)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { loadGraphData: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/validation/schemas/workflowSchema', () => ({
|
||||
validateComfyWorkflow: vi.fn(async (content: unknown) => content)
|
||||
}))
|
||||
|
||||
// Light stand-in for the host execution-error store the draft-apply failure writes to.
|
||||
const executionErrors = vi.hoisted(() => ({
|
||||
lastPromptError: null as {
|
||||
type: string
|
||||
message: string
|
||||
details: string
|
||||
} | null,
|
||||
showErrorOverlay: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => executionErrors
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => ({ workspaceToken: undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ userDisplayName: { value: 'Jo Rivera' } })
|
||||
}))
|
||||
|
||||
const trackAgentMessageFeedback = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackAgentMessageFeedback })
|
||||
}))
|
||||
|
||||
import type { TurnId } from './schemas/agentApiSchema'
|
||||
import { zAgentWsEvent } from './schemas/agentApiSchema'
|
||||
import type { AgentChatEvent } from './services/agent/agentEventTransport'
|
||||
import { useAgentChatHistoryStore } from './stores/agent/agentChatHistoryStore'
|
||||
import { useAgentConversationStore } from './stores/agent/agentConversationStore'
|
||||
|
||||
import AgentPanelRoot from './AgentPanelRoot.vue'
|
||||
|
||||
const zAgentWsEventForTest = (raw: unknown): AgentChatEvent =>
|
||||
zAgentWsEvent.parse(raw) as AgentChatEvent
|
||||
|
||||
describe('AgentPanelRoot session notices', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
socket.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('forwards a session notice to the host toast as an error', async () => {
|
||||
render(AgentPanelRoot, { global: { plugins: [i18n] } })
|
||||
const toast = useToastStore()
|
||||
|
||||
// A malformed agent_message_done with no readable message_id makes the session push an
|
||||
// error notice (i18n agent.malformedEvent). The root watch must surface it as a toast.
|
||||
socket.emit('message', {
|
||||
data: JSON.stringify({ type: 'agent_message_done', data: {} })
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(toast.messagesToAdd).toHaveLength(1)
|
||||
expect(toast.messagesToAdd[0]).toMatchObject({
|
||||
severity: 'error',
|
||||
summary: i18n.global.t('agent.malformedEvent')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentPanelRoot attach flow', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
socket.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uploads a picked file, stages its ref, and forwards it on the next send', async () => {
|
||||
const messageBodies: unknown[] = []
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url.endsWith('/api/upload/image')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
name: 'uploaded_cat.png',
|
||||
subfolder: '',
|
||||
type: 'input'
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
// The messages POST that carries the send payload.
|
||||
messageBodies.push(JSON.parse(String(init?.body)))
|
||||
return new Response(
|
||||
JSON.stringify({ thread_id: 'th-1', message_id: 'm-1' }),
|
||||
{ status: 202, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
render(AgentPanelRoot, { global: { plugins: [i18n] } })
|
||||
|
||||
const file = new File(['x'], 'cat.png', { type: 'image/png' })
|
||||
const input = screen.getByTestId<HTMLInputElement>('agent-file-input')
|
||||
await userEvent.upload(input, file)
|
||||
|
||||
// The uploaded file's server name renders as a staged chip.
|
||||
expect(await screen.findByText('cat.png')).toBeInTheDocument()
|
||||
|
||||
await userEvent.type(screen.getByRole('textbox'), 'make it pop')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
|
||||
expect(messageBodies).toHaveLength(1)
|
||||
expect(messageBodies[0]).toMatchObject({
|
||||
content: 'make it pop',
|
||||
attachments: ['uploaded_cat.png']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentPanelRoot draft binding', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
socket.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('binds the draft to the workflow id from the message ack and reloads the canvas on a patch', async () => {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
// The server owns the workflow and returns its id in the message ack.
|
||||
if (url.includes('/messages')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
thread_id: 'th-1',
|
||||
message_id: 'm-1',
|
||||
workflow_id: 'wf-42'
|
||||
}),
|
||||
{ status: 202, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
return new Response('{}', { status: 200 })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
render(AgentPanelRoot, { global: { plugins: [i18n] } })
|
||||
|
||||
await userEvent.type(screen.getByRole('textbox'), 'build a graph')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
await screen.findByRole('button', { name: 'Stop' })
|
||||
|
||||
// A draft_patch carrying the ack's workflow id now drives the canvas; a foreign one does not.
|
||||
const graph = { version: 0.4, nodes: [{ id: 1 }] }
|
||||
socket.emit('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'draft_patch',
|
||||
data: { workflow_id: 'other', base_version: 0, version: 1, content: {} }
|
||||
})
|
||||
})
|
||||
socket.emit('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'draft_patch',
|
||||
data: {
|
||||
workflow_id: 'wf-42',
|
||||
base_version: 0,
|
||||
version: 1,
|
||||
content: graph
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(app.loadGraphData).toHaveBeenCalledWith(graph)
|
||||
)
|
||||
expect(app.loadGraphData).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces one workflow error per rejection streak and recovers on a valid draft', async () => {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.includes('/messages')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
thread_id: 'th-1',
|
||||
message_id: 'm-1',
|
||||
workflow_id: 'wf-42'
|
||||
}),
|
||||
{ status: 202, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
return new Response('{}', { status: 200 })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// Module-level fns retain calls across tests (restoreAllMocks only restores spies).
|
||||
vi.mocked(app.loadGraphData).mockClear()
|
||||
vi.mocked(validateComfyWorkflow).mockClear()
|
||||
executionErrors.lastPromptError = null
|
||||
executionErrors.showErrorOverlay.mockClear()
|
||||
// The schema rejects the first two patches (e.g. content missing its required
|
||||
// version field, as observed live); the third passes through and must both load
|
||||
// the canvas and reset the failure streak so a later rejection surfaces again.
|
||||
vi.mocked(validateComfyWorkflow)
|
||||
.mockImplementationOnce(async (_content, onError) => {
|
||||
onError?.('rejected: version required')
|
||||
return null
|
||||
})
|
||||
.mockImplementationOnce(async (_content, onError) => {
|
||||
onError?.('rejected: version required')
|
||||
return null
|
||||
})
|
||||
|
||||
render(AgentPanelRoot, { global: { plugins: [i18n] } })
|
||||
|
||||
await userEvent.type(screen.getByRole('textbox'), 'build a graph')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
await screen.findByRole('button', { name: 'Stop' })
|
||||
|
||||
const patch = (version: number): void =>
|
||||
socket.emit('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'draft_patch',
|
||||
data: {
|
||||
workflow_id: 'wf-42',
|
||||
base_version: version - 1,
|
||||
version,
|
||||
content: { nodes: [] }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// One patch per tick: the version watcher batches same-tick bumps into one fire.
|
||||
patch(1)
|
||||
await vi.waitFor(() =>
|
||||
expect(vi.mocked(validateComfyWorkflow)).toHaveBeenCalledTimes(1)
|
||||
)
|
||||
patch(2)
|
||||
await vi.waitFor(() =>
|
||||
expect(vi.mocked(validateComfyWorkflow)).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(app.loadGraphData).not.toHaveBeenCalled()
|
||||
// The failure lands on the host workflow-error surface, once per streak.
|
||||
expect(executionErrors.showErrorOverlay).toHaveBeenCalledTimes(1)
|
||||
expect(executionErrors.lastPromptError).toMatchObject({
|
||||
type: 'agent_draft_apply_failed',
|
||||
details: 'rejected: version required'
|
||||
})
|
||||
|
||||
patch(3)
|
||||
await vi.waitFor(() => expect(app.loadGraphData).toHaveBeenCalledTimes(1))
|
||||
|
||||
// A rejection after a successful apply is a NEW streak and surfaces again.
|
||||
vi.mocked(validateComfyWorkflow).mockImplementationOnce(
|
||||
async (_content, onError) => {
|
||||
onError?.('rejected again')
|
||||
return null
|
||||
}
|
||||
)
|
||||
patch(4)
|
||||
await vi.waitFor(() =>
|
||||
expect(executionErrors.showErrorOverlay).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces a loadGraphData rejection as the same workflow error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) =>
|
||||
url.includes('/messages')
|
||||
? new Response(
|
||||
JSON.stringify({
|
||||
thread_id: 'th-1',
|
||||
message_id: 'm-1',
|
||||
workflow_id: 'wf-42'
|
||||
}),
|
||||
{ status: 202, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
: new Response('{}', { status: 200 })
|
||||
)
|
||||
)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.mocked(app.loadGraphData)
|
||||
.mockClear()
|
||||
.mockRejectedValueOnce(new Error('graph configure exploded'))
|
||||
vi.mocked(validateComfyWorkflow).mockClear()
|
||||
executionErrors.lastPromptError = null
|
||||
executionErrors.showErrorOverlay.mockClear()
|
||||
|
||||
render(AgentPanelRoot, { global: { plugins: [i18n] } })
|
||||
|
||||
await userEvent.type(screen.getByRole('textbox'), 'build a graph')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
await screen.findByRole('button', { name: 'Stop' })
|
||||
|
||||
socket.emit('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'draft_patch',
|
||||
data: {
|
||||
workflow_id: 'wf-42',
|
||||
base_version: 0,
|
||||
version: 1,
|
||||
content: { version: 0.4, nodes: [] }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(executionErrors.showErrorOverlay).toHaveBeenCalledTimes(1)
|
||||
)
|
||||
expect(executionErrors.lastPromptError).toMatchObject({
|
||||
type: 'agent_draft_apply_failed',
|
||||
details: 'graph configure exploded'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentPanelRoot history', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
socket.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('populates Chat History from the server thread list on mount', async () => {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/api/agent/threads')) {
|
||||
// Live envelope shape: {threads, pagination}; title is "" until the server
|
||||
// names the thread, with the first prompt in preview.
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
threads: [
|
||||
{
|
||||
id: 'th-9',
|
||||
title: 'build a text to image graph',
|
||||
last_message_at: '2026-07-07T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'th-10',
|
||||
title: '',
|
||||
preview: 'make a duck',
|
||||
last_message_at: '2026-07-07T09:00:00Z'
|
||||
}
|
||||
],
|
||||
pagination: { page: 1 }
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
return new Response('[]', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
render(AgentPanelRoot, { global: { plugins: [i18n] } })
|
||||
|
||||
const history = useAgentChatHistoryStore()
|
||||
await vi.waitFor(() => expect(history.sessions).toHaveLength(2))
|
||||
expect(history.sessions[0]).toMatchObject({
|
||||
id: 'th-9',
|
||||
title: 'build a text to image graph'
|
||||
})
|
||||
// An unnamed thread falls back to its preview for the row title.
|
||||
expect(history.sessions[1]).toMatchObject({
|
||||
id: 'th-10',
|
||||
title: 'make a duck'
|
||||
})
|
||||
})
|
||||
|
||||
it('marks the adopted thread as the current session', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) =>
|
||||
url.endsWith('/api/agent/threads')
|
||||
? new Response('{"threads":[]}', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
: new Response('[]', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
)
|
||||
render(AgentPanelRoot, { global: { plugins: [i18n] } })
|
||||
|
||||
const convo = useAgentConversationStore()
|
||||
convo.setThreadId('th-active')
|
||||
await nextTick()
|
||||
|
||||
const history = useAgentChatHistoryStore()
|
||||
expect(history.activeId).toBe('th-active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentPanelRoot feedback capture', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
socket.clear()
|
||||
trackAgentMessageFeedback.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('forwards a thumbs vote to telemetry with the message id and vote', async () => {
|
||||
render(AgentPanelRoot, { global: { plugins: [i18n] } })
|
||||
|
||||
// Seed a settled assistant turn so its feedback control renders.
|
||||
const store = useAgentConversationStore()
|
||||
const turnId = 'turn-9' as TurnId
|
||||
store.recordUser(turnId, 'make a cat')
|
||||
store.startTurn(turnId)
|
||||
store.ingest(
|
||||
zAgentWsEventForTest({
|
||||
type: 'agent_message_delta',
|
||||
data: { delta: 'Here is a cat', message_id: 'turn-9', thread_id: 'th' }
|
||||
})
|
||||
)
|
||||
store.ingest(
|
||||
zAgentWsEventForTest({
|
||||
type: 'agent_message_done',
|
||||
data: { message_id: 'turn-9', thread_id: 'th', usage: null }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Helpful' })
|
||||
)
|
||||
// A second click on the active thumb retracts the vote; null must forward, not be dropped.
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Helpful' })
|
||||
)
|
||||
|
||||
expect(trackAgentMessageFeedback.mock.calls).toEqual([
|
||||
[{ message_id: 'turn-9', vote: 'up' }],
|
||||
[{ message_id: 'turn-9', vote: null }]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentPanelRoot lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
socket.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('cancels the in-flight turn when the panel unmounts', async () => {
|
||||
const urls: string[] = []
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
urls.push(url)
|
||||
return new Response(
|
||||
JSON.stringify({ thread_id: 'th-1', message_id: 'm-1' }),
|
||||
{ status: 202, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const { unmount } = render(AgentPanelRoot, { global: { plugins: [i18n] } })
|
||||
|
||||
await userEvent.type(screen.getByRole('textbox'), 'hello')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
// The turn is in flight once the Stop control shows (activeTurnId + thread adopted).
|
||||
await screen.findByRole('button', { name: 'Stop' })
|
||||
|
||||
unmount()
|
||||
|
||||
// Closing the panel must POST the cancel so the turn does not keep billing unheard.
|
||||
await vi.waitFor(() =>
|
||||
expect(urls.some((url) => url.endsWith('/cancel'))).toBe(true)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentPanelRoot greeting', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
socket.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('personalizes the empty-state greeting with the account first name', async () => {
|
||||
// useCurrentUser is mocked to "Jo Rivera"; the greeting shows the first name only.
|
||||
render(AgentPanelRoot, { global: { plugins: [i18n] } })
|
||||
|
||||
expect(await screen.findByText('Hello Jo,')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
260
src/workbench/extensions/agent/AgentPanelRoot.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
|
||||
import AgentPanel from './components/agent/AgentPanel.vue'
|
||||
import OnboardingCoach from './components/agent/OnboardingCoach.vue'
|
||||
import { useAttachment } from './composables/agent/useAttachment'
|
||||
import type { CoachStep } from './composables/agent/useOnboarding'
|
||||
import type { ComposerAttachment } from './composables/agent/useComposer'
|
||||
import type { AgentThreadSummary } from './schemas/agentApiSchema'
|
||||
import type { ChatSession } from './stores/agent/agentChatHistoryStore'
|
||||
import { useAgentSession } from './composables/agent/useAgentSession'
|
||||
import { useDraftCanvasApply } from './composables/agent/useDraftCanvasApply'
|
||||
import { buildTranscriptMarkdown } from './services/agent/agentTranscript'
|
||||
import { createAgentRestClient } from './services/agent/agentRestClient'
|
||||
import { createReconnectingEventSource } from './services/agent/agentEventSource'
|
||||
import { useAgentChatHistoryStore } from './stores/agent/agentChatHistoryStore'
|
||||
import { useAgentPanelStore } from './stores/agent/agentPanelStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToastStore()
|
||||
const workspaceAuthStore = useWorkspaceAuthStore()
|
||||
|
||||
const { userDisplayName } = useCurrentUser()
|
||||
const userName = computed(
|
||||
() => userDisplayName.value?.trim().split(/\s+/)[0] || undefined
|
||||
)
|
||||
|
||||
const rest = createAgentRestClient({
|
||||
getAuthToken: () => workspaceAuthStore.workspaceToken ?? undefined
|
||||
})
|
||||
|
||||
// Follows api.socket across reconnects (the api nulls and replaces its socket on
|
||||
// close/reopen) so the panel is not left deaf after a reconnect.
|
||||
const events = createReconnectingEventSource(api)
|
||||
|
||||
const {
|
||||
sendMessage,
|
||||
stopTurn,
|
||||
newChat,
|
||||
start,
|
||||
stop,
|
||||
entries,
|
||||
isStreaming,
|
||||
status,
|
||||
notices,
|
||||
threadId,
|
||||
listThreads,
|
||||
loadThread
|
||||
} = useAgentSession({ rest, events })
|
||||
|
||||
const noticeSeverity = {
|
||||
error: 'error',
|
||||
warning: 'warn',
|
||||
info: 'info'
|
||||
} as const
|
||||
let noticesSeen = 0
|
||||
watch(
|
||||
() => notices.value.length,
|
||||
(length) => {
|
||||
for (const notice of notices.value.slice(noticesSeen)) {
|
||||
toast.add({
|
||||
severity: noticeSeverity[notice.level],
|
||||
summary: notice.text
|
||||
})
|
||||
}
|
||||
noticesSeen = length
|
||||
}
|
||||
)
|
||||
|
||||
// The draft rides the wire untyped; validate it through the host workflow schema
|
||||
// before loading. A failed canvas apply is a WORKFLOW error, so it surfaces through
|
||||
// the host error overlay ("View details" opens the Errors tab), once per failure
|
||||
// streak (a turn can carry many patches).
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
let draftRejectionNotified = false
|
||||
|
||||
function surfaceDraftApplyFailure(details: string): void {
|
||||
console.warn(details)
|
||||
if (draftRejectionNotified) return
|
||||
draftRejectionNotified = true
|
||||
executionErrorStore.lastPromptError = {
|
||||
type: 'agent_draft_apply_failed',
|
||||
message: t('agent.draftApplyFailed'),
|
||||
details
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
}
|
||||
|
||||
useDraftCanvasApply((content) => {
|
||||
void (async () => {
|
||||
const workflow = await validateComfyWorkflow(content, (error) => {
|
||||
surfaceDraftApplyFailure(error)
|
||||
})
|
||||
if (!workflow) return
|
||||
try {
|
||||
await app.loadGraphData(workflow)
|
||||
draftRejectionNotified = false
|
||||
} catch (error) {
|
||||
surfaceDraftApplyFailure(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
start()
|
||||
onBeforeUnmount(() => {
|
||||
// Cancel any in-flight turn so it does not keep generating and billing while the panel
|
||||
// is closed; unsubscribe only after the cancel settles so its ack is not torn down.
|
||||
void stopTurn().finally(stop)
|
||||
})
|
||||
|
||||
const history = useAgentChatHistoryStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
|
||||
const { copy } = useClipboard({ legacy: true })
|
||||
|
||||
// A null vote is a retraction of a prior thumb and is forwarded so the eval pipeline
|
||||
// records it rather than dropping it.
|
||||
function onFeedback(turnId: string, vote: 'up' | 'down' | null): void {
|
||||
useTelemetry()?.trackAgentMessageFeedback({ message_id: turnId, vote })
|
||||
}
|
||||
|
||||
// title is "" until the server names the thread, so the row falls back to the preview
|
||||
// (the first prompt).
|
||||
function toChatSession(thread: AgentThreadSummary): ChatSession {
|
||||
const stamp = thread.last_message_at ?? thread.updated_at ?? thread.created_at
|
||||
const updatedAt = stamp ? Date.parse(stamp) : Date.now()
|
||||
return {
|
||||
id: thread.id,
|
||||
title: thread.title || thread.preview || t('agent.untitledChat'),
|
||||
updatedAt: Number.isNaN(updatedAt) ? Date.now() : updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHistory(): Promise<void> {
|
||||
try {
|
||||
history.replaceAll((await listThreads()).map(toChatSession))
|
||||
} catch (error) {
|
||||
console.warn('[agent] listThreads failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
watch(threadId, (id) => history.setActive(id), { immediate: true })
|
||||
|
||||
void refreshHistory()
|
||||
|
||||
async function onSelectHistory(id: string): Promise<void> {
|
||||
await loadThread(id)
|
||||
void refreshHistory()
|
||||
}
|
||||
|
||||
// Only the active in-memory conversation has a transcript; a non-active id has nothing
|
||||
// to serialize and gets an info toast instead.
|
||||
function onCopyMarkdown(id: string): void {
|
||||
if (id === history.activeId) void copy(buildTranscriptMarkdown(entries.value))
|
||||
else toast.add({ severity: 'info', summary: t('agent.copyUnavailable') })
|
||||
}
|
||||
|
||||
const coachSteps: CoachStep[] = [
|
||||
{
|
||||
target: '#agent-panel-root',
|
||||
title: t('agent.coachTitle'),
|
||||
body: t('agent.coachBody')
|
||||
}
|
||||
]
|
||||
|
||||
function onSend(text: string, attachments: ComposerAttachment[]): void {
|
||||
void sendMessage(
|
||||
text,
|
||||
attachments.map((attachment) => attachment.ref)
|
||||
)
|
||||
}
|
||||
|
||||
function onStop(): void {
|
||||
void stopTurn()
|
||||
}
|
||||
|
||||
function onNewChat(): void {
|
||||
newChat()
|
||||
}
|
||||
|
||||
// uploadImage answers {name, subfolder, type}; the send path forwards `name` as the
|
||||
// LoadImage-style ref.
|
||||
const panelRef = ref<InstanceType<typeof AgentPanel>>()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
|
||||
const attachment = useAttachment({
|
||||
upload: async (file) => ({
|
||||
ref: (await rest.uploadImage(file, file.name)).name
|
||||
}),
|
||||
onError: (message) => toast.add({ severity: 'error', summary: message })
|
||||
})
|
||||
|
||||
function onAttach(): void {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onFilesPicked(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (files && files.length > 0) {
|
||||
for (const staged of await attachment.addFiles(Array.from(files))) {
|
||||
panelRef.value?.addAttachment(staged)
|
||||
}
|
||||
}
|
||||
// Clear so re-picking the same file fires change again.
|
||||
input.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="agent-panel-root" class="size-full">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
data-testid="agent-file-input"
|
||||
@change="onFilesPicked"
|
||||
/>
|
||||
<AgentPanel
|
||||
ref="panelRef"
|
||||
:entries
|
||||
:user-name="userName"
|
||||
:streaming="isStreaming"
|
||||
:submitting="status === 'thinking'"
|
||||
:can-attach="true"
|
||||
:is-maximized="agentPanelStore.isMaximized"
|
||||
:history-groups="history.grouped"
|
||||
@send="onSend"
|
||||
@stop="onStop"
|
||||
@attach="onAttach"
|
||||
@feedback="onFeedback"
|
||||
@new-chat="onNewChat"
|
||||
@toggle-size="agentPanelStore.toggleMaximize()"
|
||||
@close="agentPanelStore.close()"
|
||||
@open-history="refreshHistory()"
|
||||
@select-history="onSelectHistory"
|
||||
@delete-history="history.remove($event)"
|
||||
@copy-history="onCopyMarkdown"
|
||||
/>
|
||||
<OnboardingCoach
|
||||
:steps="coachSteps"
|
||||
storage-key="Comfy.AgentPanel.onboarded"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
22
src/workbench/extensions/agent/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# In-App Agent panel (FE-1187)
|
||||
|
||||
The In-App Agent panel is a manager-pattern workbench extension. The panel lives
|
||||
entirely in this subtree and renders in a flag-gated right dock registered by
|
||||
`src/extensions/core/agentPanel.ts`, so it shares the host pinia and vue-i18n
|
||||
instances and wires every host dependency itself (REST client, `/ws` event source,
|
||||
draft-to-canvas seam).
|
||||
|
||||
## Staged (built, tested, not yet wired)
|
||||
|
||||
These surfaces are complete and covered by tests but are not wired into the shipped panel
|
||||
yet; they are V0 tech-design surfaces awaiting host wiring:
|
||||
|
||||
- `components/agent/ActiveTabStrip.vue` - tab context
|
||||
- `components/agent/composer/AssetTray.vue` - cloud-asset @-tag tray
|
||||
- `components/agent/composer/SelectionActionChips.vue` - canvas selection
|
||||
- `composables/agent/useCanvasSelection.ts` - canvas selection
|
||||
|
||||
## UI primitives
|
||||
|
||||
The panel intentionally keeps its own UI primitives (`components/ui/*`) pending a separate
|
||||
design-system reconciliation PR.
|
||||
132
src/workbench/extensions/agent/agentTheme.css
Normal file
@@ -0,0 +1,132 @@
|
||||
/* In-App Agent panel (FE-1187) theme, owned by the feature (not the design-system
|
||||
master). The agent components render on agent-* utility classes (bg-agent-surface,
|
||||
text-agent-fg, border-agent-border, ...); this file routes those tokens onto host
|
||||
semantic variables so the panel tracks host theme flips (light mode included)
|
||||
automatically. @theme inline substitutes the var reference into the generated
|
||||
utilities, which is what makes the flip work without touching any component.
|
||||
|
||||
The panel's Figma frames are dark-only; light-mode rendering follows the host theme
|
||||
here, an intentional divergence deferred to the FE-1187 design-alignment pass. */
|
||||
@theme inline {
|
||||
/* agent token -> host semantic token
|
||||
Aliases tuned to the design prototype's token choices (visual reference). */
|
||||
/* surface -> base background (#171718 dark) */
|
||||
--color-agent-surface: var(--color-base-background);
|
||||
/* surface-raised -> secondary background (composer, chips, bubbles) */
|
||||
--color-agent-surface-raised: var(--color-secondary-background);
|
||||
/* surface-hover -> secondary background hover */
|
||||
--color-agent-surface-hover: var(--color-secondary-background-hover);
|
||||
/* fg -> base foreground */
|
||||
--color-agent-fg: var(--color-base-foreground);
|
||||
/* fg-muted -> muted foreground */
|
||||
--color-agent-fg-muted: var(--color-muted-foreground);
|
||||
/* fg-subtle: the reference uses muted-foreground for every secondary text run */
|
||||
--color-agent-fg-subtle: var(--color-muted-foreground);
|
||||
/* border -> node component border (header/session dividers) */
|
||||
--color-agent-border: var(--color-component-node-border);
|
||||
/* border-strong -> default border (composer resting border) */
|
||||
--color-agent-border-strong: var(--color-border-default);
|
||||
/* accent -> primary background */
|
||||
--color-agent-accent: var(--color-primary-background);
|
||||
/* accent-fg: text on the always-blue accent stays white in both themes; no host
|
||||
on-primary token flips correctly here. Design-review item for FE-1187. */
|
||||
--color-agent-accent-fg: #ffffff;
|
||||
/* danger -> destructive background */
|
||||
--color-agent-danger: var(--color-destructive-background);
|
||||
/* success -> success background */
|
||||
--color-agent-success: var(--color-success-background);
|
||||
/* pill: subtle chip surface with no defensible host pairing distinct from
|
||||
surface-raised; kept literal pending the FE-1187 design pass. */
|
||||
--color-agent-pill: #303036;
|
||||
|
||||
/* Radius is not theme-flipped and the host exposes no matching --radius-* step, so
|
||||
the agent radius stays a literal here. */
|
||||
--radius-agent: 0.75rem;
|
||||
}
|
||||
|
||||
/* Structural (theme-independent) collapsible animation tokens + their keyframes,
|
||||
driven by the --reka-collapsible-content-height var Reka sets on the content
|
||||
element. Used by the agent CollapsibleContent for reasoning/tool disclosures. */
|
||||
@theme {
|
||||
--animate-collapsible-down: collapsible-down 200ms ease-out;
|
||||
--animate-collapsible-up: collapsible-up 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes collapsible-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--reka-collapsible-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes collapsible-up {
|
||||
from {
|
||||
height: var(--reka-collapsible-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmering "Thinking…" text (design reference): a light band sweeps across the muted
|
||||
label while the agent reasons. Falls back to the muted color under reduced-motion. */
|
||||
@keyframes agent-shimmer {
|
||||
from {
|
||||
background-position: 150% center;
|
||||
}
|
||||
to {
|
||||
background-position: -50% center;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-shimmer-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-muted-foreground) 35%,
|
||||
var(--color-base-foreground) 50%,
|
||||
var(--color-muted-foreground) 65%
|
||||
);
|
||||
background-size: 200% auto;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: agent-shimmer 1.8s linear infinite;
|
||||
}
|
||||
|
||||
.disable-animations .agent-shimmer-text {
|
||||
animation: none;
|
||||
color: var(--color-muted-foreground);
|
||||
-webkit-text-fill-color: currentcolor;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-shimmer-text {
|
||||
animation: none;
|
||||
color: var(--color-muted-foreground);
|
||||
-webkit-text-fill-color: currentcolor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tailwind Preflight is disabled host-wide (PrimeVue + litegraph coexistence), so raw form
|
||||
elements in the panel inherit UA chrome: a <button> gets the buttonface fill + 2px outset
|
||||
border, a <textarea>/<input> gets its own border, font and padding. Re-apply Preflight's
|
||||
normalization, scoped to the panel root and its teleported reka surfaces (dialogs,
|
||||
dropdowns, drawer carry .agent-scope). :where() holds the selectors at zero specificity so
|
||||
each element's own bg-* / border utilities still win. */
|
||||
@layer base {
|
||||
:where(#agent-panel-root, .agent-scope)
|
||||
:where(button, [type='button'], [type='reset'], [type='submit']) {
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
border: 0 solid;
|
||||
}
|
||||
|
||||
:where(#agent-panel-root, .agent-scope) :where(textarea, input, select) {
|
||||
appearance: none;
|
||||
border: 0 solid;
|
||||
background-color: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
// Staged for FE-1187 host wiring (tab context is a V0 tech-design surface); built and tested, not yet wired into the shipped panel.
|
||||
import type { ActiveTab } from '../../composables/agent/useCanvasContext'
|
||||
|
||||
// Shows which workflow the agent is acting on. Hidden when there is no active tab.
|
||||
const { tab } = defineProps<{ tab: ActiveTab | null }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="tab"
|
||||
class="border-agent-border text-agent-fg-muted flex items-center gap-1.5 border-b px-4 py-1.5 text-xs"
|
||||
>
|
||||
<span class="icon-[lucide--panels-top-left] size-3.5" />
|
||||
<span class="truncate">{{ tab.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
185
src/workbench/extensions/agent/components/agent/AgentPanel.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComposerAttachment } from '../../composables/agent/useComposer'
|
||||
import type {
|
||||
ApprovalCard,
|
||||
ConflictChoice,
|
||||
LockState
|
||||
} from './safety/safetyTypes'
|
||||
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
|
||||
import type { HistoryGroups } from '../../stores/agent/agentChatHistoryStore'
|
||||
|
||||
import ChatHistoryScreen from './ChatHistoryScreen.vue'
|
||||
import Composer from './Composer.vue'
|
||||
import ConversationView from './ConversationView.vue'
|
||||
import EmptyState from './EmptyState.vue'
|
||||
import PanelHeader from './PanelHeader.vue'
|
||||
import SessionBar from './SessionBar.vue'
|
||||
import ApprovalCardView from './safety/ApprovalCard.vue'
|
||||
import ConflictDialog from './safety/ConflictDialog.vue'
|
||||
import LockBanner from './safety/LockBanner.vue'
|
||||
import RevertButton from './safety/RevertButton.vue'
|
||||
|
||||
const {
|
||||
entries,
|
||||
userName,
|
||||
streaming = false,
|
||||
submitting = false,
|
||||
approvalCards = [],
|
||||
lockState = 'UNLOCKED',
|
||||
conflictOpen = false,
|
||||
canRevert = false,
|
||||
canAttach = false,
|
||||
isMaximized = false,
|
||||
historyGroups
|
||||
} = defineProps<{
|
||||
entries: ConversationEntry[]
|
||||
userName?: string
|
||||
streaming?: boolean
|
||||
submitting?: boolean
|
||||
approvalCards?: ApprovalCard[]
|
||||
lockState?: LockState
|
||||
conflictOpen?: boolean
|
||||
canRevert?: boolean
|
||||
canAttach?: boolean
|
||||
isMaximized?: boolean
|
||||
historyGroups: HistoryGroups
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
send: [text: string, attachments: ComposerAttachment[]]
|
||||
stop: []
|
||||
attach: []
|
||||
feedback: [turnId: string, vote: 'up' | 'down' | null]
|
||||
answer: [approvalId: string, approved: boolean]
|
||||
takeControl: []
|
||||
resolveConflict: [choice: ConflictChoice]
|
||||
revert: []
|
||||
newChat: []
|
||||
toggleSize: []
|
||||
close: []
|
||||
openHistory: []
|
||||
selectHistory: [id: string]
|
||||
deleteHistory: [id: string]
|
||||
copyHistory: [id: string]
|
||||
}>()
|
||||
|
||||
// In-panel Chat History screen (B12): the session bar opens it, the back arrow / picking a
|
||||
// chat / starting a new chat closes it. Rendered inline instead of a teleported drawer.
|
||||
const showHistory = ref(false)
|
||||
|
||||
function onNewChat(): void {
|
||||
showHistory.value = false
|
||||
emit('newChat')
|
||||
}
|
||||
function onOpenHistory(): void {
|
||||
showHistory.value = true
|
||||
emit('openHistory')
|
||||
}
|
||||
function onSelectHistory(id: string): void {
|
||||
showHistory.value = false
|
||||
emit('selectHistory', id)
|
||||
}
|
||||
|
||||
const composerRef = ref<InstanceType<typeof Composer>>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Session bar title (B4): the first user prompt titles the session; a fresh session reads
|
||||
// "Untitled" (SessionBar's fallback), per the DES-455 chat-history flow.
|
||||
const sessionTitle = computed(() => {
|
||||
const firstUser = entries.find(
|
||||
(entry): entry is Extract<ConversationEntry, { role: 'user' }> =>
|
||||
entry.role === 'user'
|
||||
)
|
||||
return firstUser?.text.trim().slice(0, 60) || undefined
|
||||
})
|
||||
|
||||
// The root wires the file picker + upload and stages the result back through here, so the
|
||||
// panel forwards a staged attachment down to the composer without owning the upload path.
|
||||
function addAttachment(attachment: ComposerAttachment): void {
|
||||
composerRef.value?.addAttachment(attachment)
|
||||
}
|
||||
|
||||
defineExpose({ addAttachment })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-agent-surface text-agent-fg @container flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<PanelHeader
|
||||
:is-maximized="isMaximized"
|
||||
@new-chat="onNewChat"
|
||||
@toggle-size="emit('toggleSize')"
|
||||
@close="emit('close')"
|
||||
/>
|
||||
|
||||
<template v-if="showHistory">
|
||||
<ChatHistoryScreen
|
||||
:groups="historyGroups"
|
||||
class="min-h-0 flex-1"
|
||||
@back="showHistory = false"
|
||||
@select="onSelectHistory"
|
||||
@delete="emit('deleteHistory', $event)"
|
||||
@copy-markdown="emit('copyHistory', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<SessionBar :title="sessionTitle" @open-history="onOpenHistory" />
|
||||
|
||||
<LockBanner :state="lockState" @take-control="emit('takeControl')" />
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<EmptyState
|
||||
v-if="!entries.length"
|
||||
:user-name="userName"
|
||||
@insert="composerRef?.insert($event)"
|
||||
/>
|
||||
<ConversationView
|
||||
v-else
|
||||
:entries="entries"
|
||||
@feedback="(id, vote) => emit('feedback', id, vote)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="!showHistory">
|
||||
<div v-if="approvalCards.length || canRevert" class="space-y-2 px-3 pb-1">
|
||||
<ApprovalCardView
|
||||
v-for="card in approvalCards"
|
||||
:key="card.approvalId"
|
||||
:card="card"
|
||||
@answer="(id, approved) => emit('answer', id, approved)"
|
||||
/>
|
||||
<div v-if="canRevert" class="flex justify-end">
|
||||
<RevertButton :can-revert="canRevert" @revert="emit('revert')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="shrink-0 p-4">
|
||||
<div class="mx-auto flex w-full max-w-[640px] flex-col gap-2.5">
|
||||
<Composer
|
||||
ref="composerRef"
|
||||
:streaming="streaming"
|
||||
:submitting="submitting"
|
||||
:can-attach="canAttach"
|
||||
@send="(text, attachments) => emit('send', text, attachments)"
|
||||
@stop="emit('stop')"
|
||||
@attach="emit('attach')"
|
||||
/>
|
||||
<p class="text-agent-fg-muted my-0 text-center text-xs">
|
||||
{{ t('agent.caption') }}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<ConflictDialog
|
||||
:open="conflictOpen"
|
||||
@resolve="emit('resolveConflict', $event)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
ChatSession,
|
||||
HistoryGroups
|
||||
} from '../../stores/agent/agentChatHistoryStore'
|
||||
|
||||
// In-panel Chat History screen (Figma B12): replaces the conversation within the panel,
|
||||
// with a "<- Chat History" back affordance. Sessions are grouped by recency; a row loads
|
||||
// its chat, and a hover reveals copy / delete. Rendered inline (not a teleported drawer)
|
||||
// so it never fights the docked panel's stacking context.
|
||||
const { groups } = defineProps<{ groups: HistoryGroups }>()
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
select: [id: string]
|
||||
delete: [id: string]
|
||||
copyMarkdown: [id: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sections = computed(() =>
|
||||
(
|
||||
[
|
||||
['current', t('agent.historyCurrent'), groups.current],
|
||||
['today', t('agent.historyToday'), groups.today],
|
||||
['yesterday', t('agent.historyYesterday'), groups.yesterday],
|
||||
['earlier', t('agent.historyEarlier'), groups.earlier]
|
||||
] as const
|
||||
).filter(([, , items]) => items.length > 0)
|
||||
)
|
||||
|
||||
const isEmpty = computed(() => sections.value.length === 0)
|
||||
|
||||
function pick(session: ChatSession): void {
|
||||
emit('select', session.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div class="flex shrink-0 items-center px-2 py-1.5">
|
||||
<button
|
||||
v-tooltip.bottom="{ value: t('agent.backToChat'), showDelay: 500 }"
|
||||
type="button"
|
||||
class="text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex h-6 cursor-pointer items-center gap-1 rounded-sm px-2 text-xs transition-colors"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<span class="icon-[lucide--arrow-left] size-3.5 shrink-0" />
|
||||
<span>{{ t('agent.history') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<p
|
||||
v-if="isEmpty"
|
||||
class="text-agent-fg-muted px-2 py-8 text-center text-sm"
|
||||
>
|
||||
{{ t('agent.historyEmpty') }}
|
||||
</p>
|
||||
|
||||
<section v-for="[key, label, items] in sections" :key="key" class="mb-3">
|
||||
<p class="text-agent-fg-muted my-0 px-2 py-1 text-xs font-medium">
|
||||
{{ label }}
|
||||
</p>
|
||||
<div
|
||||
v-for="session in items"
|
||||
:key="session.id"
|
||||
class="group hover:bg-agent-surface-hover flex items-center gap-2 rounded-md px-2 py-1.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-agent-fg flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left text-sm"
|
||||
@click="pick(session)"
|
||||
>
|
||||
<span
|
||||
class="text-agent-fg-muted icon-[lucide--circle-check] size-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{
|
||||
session.title || t('agent.untitledChat')
|
||||
}}</span>
|
||||
</button>
|
||||
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<button
|
||||
type="button"
|
||||
class="text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex cursor-pointer items-center justify-center rounded-sm p-0.5 transition-colors"
|
||||
:aria-label="t('agent.copyMarkdown')"
|
||||
@click="emit('copyMarkdown', session.id)"
|
||||
>
|
||||
<span class="icon-[lucide--copy] size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-agent-fg-muted hover:text-agent-danger hover:bg-agent-surface-hover flex cursor-pointer items-center justify-center rounded-sm p-0.5 transition-colors"
|
||||
:aria-label="t('agent.delete')"
|
||||
@click="emit('delete', session.id)"
|
||||
>
|
||||
<span class="icon-[lucide--trash-2] size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
import Composer from './Composer.vue'
|
||||
|
||||
function mount(props: ComponentProps<typeof Composer> = {}) {
|
||||
return render(Composer, { props, global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
describe('Composer', () => {
|
||||
it('disables send when empty and enables once text is typed', async () => {
|
||||
mount()
|
||||
const send = screen.getByRole('button', { name: 'Send' })
|
||||
expect(send).toBeDisabled()
|
||||
await userEvent.type(screen.getByRole('textbox'), 'hello')
|
||||
expect(send).toBeEnabled()
|
||||
})
|
||||
|
||||
it('emits send with the trimmed text and clears the draft', async () => {
|
||||
const { emitted } = mount()
|
||||
const box = screen.getByRole('textbox')
|
||||
await userEvent.type(box, ' make art ')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
expect(emitted().send[0]).toEqual(['make art', []])
|
||||
expect((box as HTMLTextAreaElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('sends on Enter but not on Shift+Enter', async () => {
|
||||
const { emitted } = mount()
|
||||
const box = screen.getByRole('textbox')
|
||||
await userEvent.type(box, 'one{Shift>}{Enter}{/Shift}two')
|
||||
expect(emitted().send).toBeUndefined()
|
||||
await userEvent.type(box, '{Enter}')
|
||||
expect(emitted().send).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows Stop while streaming and emits stop instead of send', async () => {
|
||||
const { emitted } = mount({ streaming: true })
|
||||
const stop = screen.getByRole('button', { name: 'Stop' })
|
||||
await userEvent.click(stop)
|
||||
expect(emitted().stop).toHaveLength(1)
|
||||
expect(emitted().send).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hides the paperclip by default', () => {
|
||||
mount()
|
||||
expect(screen.queryByRole('button', { name: 'Attach a file' })).toBeNull()
|
||||
})
|
||||
|
||||
it('emits attach when the paperclip is clicked and canAttach is set', async () => {
|
||||
const { emitted } = mount({ canAttach: true })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Attach a file' }))
|
||||
expect(emitted().attach).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
134
src/workbench/extensions/agent/components/agent/Composer.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Textarea from '../ui/Textarea.vue'
|
||||
import type { ComposerAttachment } from '../../composables/agent/useComposer'
|
||||
import { useComposer } from '../../composables/agent/useComposer'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import AttachmentChip from './composer/AttachmentChip.vue'
|
||||
|
||||
const {
|
||||
streaming = false,
|
||||
submitting = false,
|
||||
canAttach = false
|
||||
} = defineProps<{
|
||||
streaming?: boolean
|
||||
submitting?: boolean
|
||||
canAttach?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
send: [text: string, attachments: ComposerAttachment[]]
|
||||
stop: []
|
||||
attach: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const composer = useComposer({
|
||||
onSend: (text, attachments) => emit('send', text, attachments),
|
||||
isStreaming: () => streaming,
|
||||
onStop: () => emit('stop')
|
||||
})
|
||||
|
||||
// Enter sends; Shift+Enter inserts a newline (default textarea behavior). An Enter that
|
||||
// commits an IME composition (CJK input) must NOT send the half-composed text.
|
||||
function onEnter(event: KeyboardEvent): void {
|
||||
if (event.isComposing || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
composer.submit()
|
||||
}
|
||||
|
||||
// Parent (asset tray / file picker) stages attachments through this.
|
||||
defineExpose({
|
||||
insert: composer.insert,
|
||||
addAttachment: composer.addAttachment
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border-agent-border-strong bg-agent-surface-raised focus-within:border-agent-fg-muted flex flex-col rounded-2xl border transition-colors"
|
||||
>
|
||||
<div
|
||||
v-if="composer.attachments.value.length"
|
||||
class="flex flex-wrap gap-1.5 px-4 pt-3"
|
||||
>
|
||||
<AttachmentChip
|
||||
v-for="item in composer.attachments.value"
|
||||
:key="item.id"
|
||||
:name="item.name"
|
||||
:preview-url="item.previewUrl"
|
||||
@remove="composer.removeAttachment(item.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
v-model="composer.draft.value"
|
||||
:placeholder="t('agent.placeholder')"
|
||||
rows="1"
|
||||
class="max-h-48 min-h-20 px-4 py-3"
|
||||
@keydown.enter="onEnter"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between px-3 py-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="canAttach"
|
||||
type="button"
|
||||
:aria-label="t('agent.attach')"
|
||||
class="rounded-agent text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex size-8 cursor-pointer items-center justify-center transition-colors"
|
||||
@click="emit('attach')"
|
||||
>
|
||||
<span class="icon-[lucide--paperclip] size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('agent.mention')"
|
||||
class="rounded-agent text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex size-8 cursor-pointer items-center justify-center transition-colors"
|
||||
@click="composer.insert('@')"
|
||||
>
|
||||
<span class="icon-[lucide--at-sign] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="text-agent-fg-muted hover:bg-agent-surface-hover flex h-8 cursor-pointer items-center gap-1 rounded-sm px-2 text-xs transition-colors"
|
||||
:aria-label="t('agent.modelAuto')"
|
||||
>
|
||||
<span>{{ t('agent.modelAuto') }}</span>
|
||||
<span class="icon-[lucide--chevron-down] size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="streaming ? t('agent.stop') : t('agent.send')"
|
||||
:disabled="!streaming && !composer.canSend.value"
|
||||
:class="
|
||||
cn(
|
||||
'flex size-8 items-center justify-center rounded-xl transition-colors',
|
||||
streaming
|
||||
? 'bg-agent-surface-hover text-agent-fg hover:bg-agent-border cursor-pointer'
|
||||
: 'bg-agent-fg text-agent-surface hover:bg-agent-fg/90 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)
|
||||
"
|
||||
@click="composer.submit"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'size-4',
|
||||
submitting
|
||||
? 'icon-[lucide--loader-circle] animate-spin'
|
||||
: streaming
|
||||
? 'icon-[lucide--square]'
|
||||
: 'icon-[lucide--arrow-up]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,170 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
|
||||
// Capture the IntersectionObserver callback useIntersectionObserver registers so the test
|
||||
// can drive the "at bottom" signal directly — happy-dom has no real observer, and the pill
|
||||
// only shows once the sentinel scrolls out of view.
|
||||
const intersectionCallbacks = vi.hoisted(
|
||||
() => [] as ((entries: { isIntersecting: boolean }[]) => void)[]
|
||||
)
|
||||
vi.mock('@vueuse/core', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof VueUse>()),
|
||||
useIntersectionObserver: (
|
||||
_target: unknown,
|
||||
callback: (entries: { isIntersecting: boolean }[]) => void
|
||||
) => {
|
||||
intersectionCallbacks.push(callback)
|
||||
return { stop: () => {} }
|
||||
}
|
||||
}))
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
import type { TurnId } from '../../schemas/agentApiSchema'
|
||||
import { zAgentWsEvent } from '../../schemas/agentApiSchema'
|
||||
import type { AgentChatEvent } from '../../services/agent/agentEventTransport'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
FilePart,
|
||||
ReasoningPart
|
||||
} from '../../services/agent/agentMessageParts'
|
||||
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
|
||||
import { useAgentConversationStore } from '../../stores/agent/agentConversationStore'
|
||||
|
||||
import ConversationView from './ConversationView.vue'
|
||||
|
||||
const T = 'msg-1' as TurnId
|
||||
const chat = (raw: unknown): AgentChatEvent =>
|
||||
zAgentWsEvent.parse(raw) as AgentChatEvent
|
||||
const thinking = (id: string, delta: string) =>
|
||||
chat({
|
||||
type: 'agent_thinking',
|
||||
data: { delta, message_id: id, thread_id: 'th' }
|
||||
})
|
||||
const delta = (id: string, text: string) =>
|
||||
chat({
|
||||
type: 'agent_message_delta',
|
||||
data: { delta: text, message_id: id, thread_id: 'th' }
|
||||
})
|
||||
const toolCall = (id: string, name: string, status: string) =>
|
||||
chat({
|
||||
type: 'agent_tool_call',
|
||||
data: { tool_name: name, status, args: [], message_id: id, thread_id: 'th' }
|
||||
})
|
||||
const done = (id: string) =>
|
||||
chat({
|
||||
type: 'agent_message_done',
|
||||
data: { message_id: id, thread_id: 'th', usage: null }
|
||||
})
|
||||
|
||||
const Harness = defineComponent({
|
||||
components: { ConversationView },
|
||||
setup() {
|
||||
const store = useAgentConversationStore()
|
||||
return { store }
|
||||
},
|
||||
template: `<ConversationView :entries="store.entries" user-name="Ada" />`
|
||||
})
|
||||
|
||||
function mountHarness() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const utils = render(Harness, { global: { plugins: [pinia, i18n] } })
|
||||
return { store: useAgentConversationStore(), ...utils }
|
||||
}
|
||||
|
||||
describe('ConversationView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
intersectionCallbacks.length = 0
|
||||
})
|
||||
|
||||
it('wire-driven v1 turn renders user pill, spinner, reasoning-free text, tool group', async () => {
|
||||
const { store } = mountHarness()
|
||||
store.recordUser(T, 'make a cat')
|
||||
store.startTurn(T)
|
||||
|
||||
store.ingest(thinking('msg-1', 'pondering'))
|
||||
// The thinking chip shows before any text streams.
|
||||
expect(await screen.findByText('Thinking...')).toBeInTheDocument()
|
||||
|
||||
store.ingest(delta('msg-1', 'Here is a **cat**'))
|
||||
store.ingest(toolCall('msg-1', 'add_node', 'ok'))
|
||||
store.ingest(done('msg-1'))
|
||||
|
||||
expect(await screen.findByText('make a cat')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ran 1 tool call')).toBeInTheDocument()
|
||||
// The markdown bold survives sanitization as a real <strong> element.
|
||||
expect(screen.getByText('cat', { selector: 'strong' })).toBeInTheDocument()
|
||||
expect(store.entries.at(-1)).toMatchObject({
|
||||
role: 'assistant',
|
||||
streaming: false
|
||||
})
|
||||
})
|
||||
|
||||
it('renders parts-driven surfaces without a wire source', async () => {
|
||||
// These surfaces have no v1 wire event yet: thinking is transient (agent_thinking
|
||||
// never persists a ReasoningPart) and assets arrive via the host run path, not the
|
||||
// chat socket. The components must still render them from a hand-built parts list.
|
||||
const reasoning: ReasoningPart = {
|
||||
type: 'reasoning',
|
||||
text: 'pondering',
|
||||
state: 'done'
|
||||
}
|
||||
const file: FilePart = {
|
||||
type: 'file',
|
||||
mediaType: 'image',
|
||||
url: 'https://x/cat.png',
|
||||
filename: 'cat.png'
|
||||
}
|
||||
const assistant: AssistantMessage = {
|
||||
id: 'msg-1' as TurnId,
|
||||
role: 'assistant',
|
||||
parts: [reasoning, file],
|
||||
tokens: 0,
|
||||
streaming: false,
|
||||
thinking: false
|
||||
}
|
||||
const entries: ConversationEntry[] = [assistant]
|
||||
|
||||
render(ConversationView, {
|
||||
props: { entries },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Reasoning')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', 'https://x/cat.png')
|
||||
})
|
||||
|
||||
it('shows a scroll-to-latest pill when scrolled up and returns to bottom on click', async () => {
|
||||
const assistant: AssistantMessage = {
|
||||
id: 'msg-1' as TurnId,
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: 'hello', state: 'done' }],
|
||||
tokens: 0,
|
||||
streaming: false,
|
||||
thinking: false
|
||||
}
|
||||
const scrollIntoView = vi.fn()
|
||||
Element.prototype.scrollIntoView = scrollIntoView
|
||||
|
||||
render(ConversationView, {
|
||||
props: { entries: [assistant] },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
// At bottom initially: no pill.
|
||||
expect(screen.queryByText('Latest')).not.toBeInTheDocument()
|
||||
|
||||
// Drive the sentinel out of view -> the pill appears.
|
||||
for (const cb of intersectionCallbacks) cb([{ isIntersecting: false }])
|
||||
const pill = await screen.findByRole('button', { name: 'Latest' })
|
||||
|
||||
await userEvent.click(pill)
|
||||
expect(scrollIntoView).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ScrollArea from '../ui/ScrollArea.vue'
|
||||
import type { ConversationEntry } from '../../stores/agent/agentConversationStore'
|
||||
|
||||
import AgentMessage from './message/AgentMessage.vue'
|
||||
import UserMessage from './message/UserMessage.vue'
|
||||
|
||||
const { entries } = defineProps<{
|
||||
entries: ConversationEntry[]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
feedback: [turnId: string, vote: 'up' | 'down' | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Keep the newest turn in view as content streams in, but ONLY when the user is already at
|
||||
// the bottom — if they scrolled up to re-read, a streaming delta must not yank them back.
|
||||
// The bottom sentinel's visibility is the "at bottom" signal.
|
||||
const bottom = ref<HTMLElement>()
|
||||
const atBottom = ref(true)
|
||||
|
||||
useIntersectionObserver(bottom, ([entry]) => {
|
||||
atBottom.value = entry?.isIntersecting ?? true
|
||||
})
|
||||
|
||||
function scrollToLatest(): void {
|
||||
bottom.value?.scrollIntoView({ block: 'end' })
|
||||
}
|
||||
|
||||
// Only the LAST entry can grow while a turn streams (earlier ones are settled), so this
|
||||
// signal replaces a deep watch that re-walked every entry per token.
|
||||
const latestContentSignal = computed(() => {
|
||||
const last = entries.at(-1)
|
||||
if (!last) return '0'
|
||||
const size = 'parts' in last ? JSON.stringify(last.parts).length : 0
|
||||
return `${entries.length}:${size}`
|
||||
})
|
||||
|
||||
watch(
|
||||
latestContentSignal,
|
||||
async () => {
|
||||
if (!atBottom.value) return
|
||||
await nextTick()
|
||||
scrollToLatest()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<ScrollArea class="h-full" viewport-class="mx-auto max-w-[640px] p-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<template v-for="entry in entries" :key="`${entry.role}-${entry.id}`">
|
||||
<UserMessage v-if="entry.role === 'user'" :text="entry.text" />
|
||||
<AgentMessage
|
||||
v-else
|
||||
:message="entry"
|
||||
@feedback="emit('feedback', entry.id, $event)"
|
||||
/>
|
||||
</template>
|
||||
<div ref="bottom" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<button
|
||||
v-if="!atBottom"
|
||||
type="button"
|
||||
class="rounded-agent border-agent-border bg-agent-surface-raised text-agent-fg-muted hover:text-agent-fg absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 border px-3 py-1.5 text-xs shadow-md transition-colors"
|
||||
@click="scrollToLatest"
|
||||
>
|
||||
<span class="icon-[lucide--arrow-down] size-3.5" />
|
||||
{{ t('agent.latest') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SuggestedPromptChip from './composer/SuggestedPromptChip.vue'
|
||||
|
||||
const { userName } = defineProps<{ userName?: string }>()
|
||||
const emit = defineEmits<{ insert: [text: string] }>()
|
||||
|
||||
const { t, tm } = useI18n()
|
||||
|
||||
const prompts = computed(() => tm('agent.suggestedPrompts') as string[])
|
||||
|
||||
// Leading icon per suggested prompt (Figma B5), in the same order as agent.suggestedPrompts.
|
||||
const promptIcons = [
|
||||
'icon-[lucide--lightbulb]',
|
||||
'icon-[lucide--list]',
|
||||
'icon-[lucide--search]',
|
||||
'icon-[lucide--message-circle-warning]',
|
||||
'icon-[lucide--workflow]'
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col p-4">
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center"
|
||||
>
|
||||
<div
|
||||
class="mb-2 flex size-12 items-center justify-center rounded-xl border border-plum-600 bg-ink-700"
|
||||
>
|
||||
<span
|
||||
class="icon-[comfy--comfy-c] size-6 text-brand-yellow drop-shadow-[0_0_12px_currentColor]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="text-agent-fg flex max-w-sm flex-col items-center gap-2 text-base/snug font-semibold tracking-tight @min-[570px]:text-2xl/snug"
|
||||
>
|
||||
<p class="my-0">
|
||||
{{ t('agent.greeting', { name: userName ?? t('agent.friend') }) }}
|
||||
</p>
|
||||
<p class="my-0">
|
||||
{{ t('agent.greetingQuestion') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2 @min-[460px]:justify-center">
|
||||
<SuggestedPromptChip
|
||||
v-for="(prompt, index) in prompts"
|
||||
:key="index"
|
||||
:text="prompt"
|
||||
:icon="promptIcons[index] ?? 'icon-[lucide--sparkles]'"
|
||||
@insert="emit('insert', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import type { CoachStep } from '../../composables/agent/useOnboarding'
|
||||
import { useOnboarding } from '../../composables/agent/useOnboarding'
|
||||
|
||||
// Host-DOM anchored: each step points at a host selector (e.g. '#graph-canvas'). Where the
|
||||
// anchor is absent (the standalone harness) the coach simply shows nothing — it must NOT
|
||||
// advance/finish, which would burn the persist-once flag before the user ever saw a step.
|
||||
const { steps, storageKey } = defineProps<{
|
||||
steps: CoachStep[]
|
||||
storageKey?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { active, currentStep, isLast, next, skip } = useOnboarding({
|
||||
steps,
|
||||
storageKey
|
||||
})
|
||||
|
||||
const { width, height } = useWindowSize()
|
||||
const CARD_W = 256
|
||||
const CARD_H = 160
|
||||
const MARGIN = 8
|
||||
|
||||
const cardStyle = ref<Record<string, string> | null>(null)
|
||||
|
||||
// flush:'post' so the effect runs AFTER render — a synchronous pre-mount pass would miss
|
||||
// every app-rendered anchor. A missing anchor leaves the card hidden without consuming the
|
||||
// step. NOTE(follow-up): re-check via MutationObserver to skip a step whose anchor never
|
||||
// appears rather than blocking on it.
|
||||
watchEffect(
|
||||
() => {
|
||||
cardStyle.value = null
|
||||
if (!active.value || !currentStep.value) return
|
||||
const target = document.querySelector(currentStep.value.target)
|
||||
if (!target) return
|
||||
const rect = target.getBoundingClientRect()
|
||||
const left = Math.min(
|
||||
Math.max(MARGIN, rect.left),
|
||||
Math.max(MARGIN, width.value - CARD_W - MARGIN)
|
||||
)
|
||||
const top = Math.min(
|
||||
Math.max(MARGIN, rect.bottom + MARGIN),
|
||||
Math.max(MARGIN, height.value - CARD_H - MARGIN)
|
||||
)
|
||||
cardStyle.value = { top: `${top}px`, left: `${left}px` }
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="active && currentStep && cardStyle" class="fixed inset-0 z-50">
|
||||
<div class="absolute inset-0 bg-black/40" />
|
||||
<div
|
||||
:style="cardStyle"
|
||||
class="rounded-agent border-agent-border bg-agent-surface-raised text-agent-fg absolute w-64 border p-3 shadow-xl"
|
||||
>
|
||||
<p class="text-sm font-semibold">{{ currentStep.title }}</p>
|
||||
<p class="text-agent-fg-muted mt-1 text-xs">{{ currentStep.body }}</p>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" @click="skip">{{
|
||||
t('agent.skip')
|
||||
}}</Button>
|
||||
<Button size="sm" @click="next">
|
||||
{{ isLast ? t('agent.gotIt') : t('agent.next') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
|
||||
// Header per the design reference: title + neutral ALPHA badge (no logo mark), then new
|
||||
// chat, width toggle (420 <-> 960 dock), close. History is reached from the session bar
|
||||
// (B4), not the header.
|
||||
const { isMaximized = false } = defineProps<{ isMaximized?: boolean }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
newChat: []
|
||||
toggleSize: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sizeToggleIcon = computed(() =>
|
||||
isMaximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
|
||||
)
|
||||
const sizeToggleLabel = computed(() =>
|
||||
isMaximized ? t('agent.minimize') : t('agent.maximize')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="border-agent-border flex h-12 shrink-0 items-center gap-2 border-b px-4"
|
||||
>
|
||||
<h1 class="text-agent-fg my-0 text-sm font-normal">
|
||||
{{ t('agent.title') }}
|
||||
</h1>
|
||||
<span
|
||||
class="border-agent-border-strong text-agent-fg-muted rounded-full border px-2 py-0.5 text-xs"
|
||||
>
|
||||
{{ t('agent.alpha') }}
|
||||
</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
v-tooltip.bottom="{ value: t('agent.newChat'), showDelay: 300 }"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="t('agent.newChat')"
|
||||
@click="emit('newChat')"
|
||||
>
|
||||
<span class="icon-[lucide--message-circle-plus] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.bottom="{ value: sizeToggleLabel, showDelay: 300 }"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="sizeToggleLabel"
|
||||
@click="emit('toggleSize')"
|
||||
>
|
||||
<span :class="cn(sizeToggleIcon, 'size-4')" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.bottom="{ value: t('agent.close'), showDelay: 300 }"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="t('agent.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<span class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// Session / title bar (Figma B4): the row under the header showing the current chat title
|
||||
// (or "Untitled" for a fresh session). The compact pill is a button that opens Chat
|
||||
// History; its tooltip reads "Show chat history" to name the action.
|
||||
const { title } = defineProps<{ title?: string }>()
|
||||
const emit = defineEmits<{ openHistory: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 items-center px-2 py-1.5">
|
||||
<button
|
||||
v-tooltip.bottom="{ value: t('agent.showChatHistory'), showDelay: 500 }"
|
||||
type="button"
|
||||
class="text-agent-fg-muted hover:bg-agent-surface-hover flex h-6 cursor-pointer items-center gap-1 rounded-sm px-2 text-xs transition-colors"
|
||||
@click="emit('openHistory')"
|
||||
>
|
||||
<span class="icon-[lucide--align-justify] size-3.5 shrink-0" />
|
||||
<span class="max-w-56 truncate">{{
|
||||
title || t('agent.newChatTitle')
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
// Staged for FE-1187 host wiring (attachments are a V0 tech-design surface); built and tested, not yet wired into the shipped panel.
|
||||
import type { CloudAsset } from '../../../composables/agent/useCloudAssets'
|
||||
|
||||
// The user's recent cloud assets; clicking one @-tags it into the message.
|
||||
const { assets } = defineProps<{ assets: CloudAsset[] }>()
|
||||
const emit = defineEmits<{ select: [asset: CloudAsset] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-4 gap-1.5">
|
||||
<button
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
type="button"
|
||||
:aria-label="asset.name"
|
||||
class="rounded-agent border-agent-border focus-visible:ring-agent-accent aspect-square overflow-hidden border focus-visible:ring-2 focus-visible:outline-none"
|
||||
@click="emit('select', asset)"
|
||||
>
|
||||
<img :src="asset.url" :alt="asset.name" class="size-full object-cover" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { name, previewUrl } = defineProps<{
|
||||
name: string
|
||||
previewUrl?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ remove: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="rounded-agent bg-agent-pill text-agent-fg inline-flex items-center gap-1.5 py-1 pr-2 pl-1 text-xs"
|
||||
>
|
||||
<img
|
||||
v-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
:alt="name"
|
||||
class="size-5 rounded-sm object-cover"
|
||||
/>
|
||||
<span v-else class="text-agent-fg-subtle icon-[lucide--paperclip] size-4" />
|
||||
<span class="max-w-32 truncate">{{ name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('agent.remove')"
|
||||
class="text-agent-fg-subtle hover:bg-agent-surface-hover hover:text-agent-fg flex size-4 items-center justify-center rounded-full transition-colors"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<span class="icon-[lucide--x] size-3" />
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
// Staged for FE-1187 host wiring (canvas selection is a V0 tech-design surface); built and tested, not yet wired into the shipped panel.
|
||||
// Quick actions for a single selected node (e.g. Explain / Improve). Each inserts its
|
||||
// prompt into the composer; live-only, so the parent hides these while disconnected.
|
||||
interface SelectionAction {
|
||||
label: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
const { actions } = defineProps<{ actions: SelectionAction[] }>()
|
||||
const emit = defineEmits<{ action: [prompt: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="action in actions"
|
||||
:key="action.label"
|
||||
type="button"
|
||||
class="rounded-agent border-agent-border text-agent-fg-muted hover:border-agent-border-strong hover:text-agent-fg border px-2 py-1 text-xs transition-colors"
|
||||
@click="emit('action', action.prompt)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
// Click INSERTS the prompt into the composer (never sends) per the locked Figma decision.
|
||||
// Per the design reference: a rounded-full pill (leading icon + label) that stacks
|
||||
// full-width in narrow panels and wraps centered from 460px container width up.
|
||||
const { text, icon } = defineProps<{ text: string; icon: string }>()
|
||||
const emit = defineEmits<{ insert: [text: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-agent-surface-raised text-agent-fg hover:bg-agent-surface-hover focus-visible:ring-agent-accent flex h-8 w-full cursor-pointer items-center justify-start gap-2 rounded-full px-3 text-sm whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none @min-[460px]:w-auto"
|
||||
@click="emit('insert', text)"
|
||||
>
|
||||
<span
|
||||
:class="cn('text-agent-fg-muted size-3 shrink-0', icon)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="truncate">{{ text }}</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasContext } from '../../composables/agent/useCanvasContext'
|
||||
import type { CloudAsset } from '../../composables/agent/useCloudAssets'
|
||||
import { useCloudAssets } from '../../composables/agent/useCloudAssets'
|
||||
|
||||
import ActiveTabStrip from './ActiveTabStrip.vue'
|
||||
import AssetTray from './composer/AssetTray.vue'
|
||||
import SelectionActionChips from './composer/SelectionActionChips.vue'
|
||||
|
||||
describe('SelectionActionChips', () => {
|
||||
it('emits the prompt of the clicked action', async () => {
|
||||
const { emitted } = render(SelectionActionChips, {
|
||||
props: { actions: [{ label: 'Explain', prompt: 'Explain this node' }] }
|
||||
})
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Explain' }))
|
||||
expect(emitted().action[0]).toEqual(['Explain this node'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AssetTray', () => {
|
||||
it('emits the clicked asset', async () => {
|
||||
const asset: CloudAsset = { id: 'a1', name: 'cat.png', url: 'blob:cat' }
|
||||
const { emitted } = render(AssetTray, { props: { assets: [asset] } })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'cat.png' }))
|
||||
expect(emitted().select[0]).toEqual([asset])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ActiveTabStrip', () => {
|
||||
it('shows the tab name, and nothing when there is no tab', async () => {
|
||||
const { rerender } = render(ActiveTabStrip, {
|
||||
props: { tab: { name: 'portrait.json' } }
|
||||
})
|
||||
expect(screen.getByText('portrait.json')).not.toBeNull()
|
||||
|
||||
await rerender({ tab: null })
|
||||
expect(screen.queryByText('portrait.json')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCloudAssets', () => {
|
||||
it('dedupes by id and caps to max', async () => {
|
||||
const raw: CloudAsset[] = [
|
||||
{ id: '1', name: 'a', url: 'u1' },
|
||||
{ id: '1', name: 'a-dup', url: 'u1' },
|
||||
{ id: '2', name: 'b', url: 'u2' },
|
||||
{ id: '3', name: 'c', url: 'u3' }
|
||||
]
|
||||
const { assets, load } = useCloudAssets({
|
||||
fetchAssets: () => Promise.resolve(raw),
|
||||
max: 2
|
||||
})
|
||||
await load()
|
||||
expect(assets.value.map((a) => a.id)).toEqual(['1', '2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCanvasContext', () => {
|
||||
it('pushes a tab switch when the active tab changes', async () => {
|
||||
const activeTab = ref<{ name: string; id?: string | null } | null>({
|
||||
name: 'first',
|
||||
id: 't1'
|
||||
})
|
||||
const onTabChange = vi.fn()
|
||||
useCanvasContext({ activeTab, onTabChange })
|
||||
|
||||
activeTab.value = { name: 'second', id: 't2' }
|
||||
await Promise.resolve()
|
||||
expect(onTabChange).toHaveBeenCalledWith({ name: 'second', id: 't2' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
AssistantMessage,
|
||||
FilePart,
|
||||
NoticePart,
|
||||
ReasoningPart,
|
||||
TextPart,
|
||||
ToolPart
|
||||
} from '../../../services/agent/agentMessageParts'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import AssetGrid from './AssetGrid.vue'
|
||||
import MarkdownStream from './MarkdownStream.vue'
|
||||
import MessageFeedback from './MessageFeedback.vue'
|
||||
import ReasoningDisclosure from './ReasoningDisclosure.vue'
|
||||
import ThinkingStatus from './ThinkingStatus.vue'
|
||||
import ToolCallGroup from './ToolCallGroup.vue'
|
||||
|
||||
const { message } = defineProps<{ message: AssistantMessage }>()
|
||||
const emit = defineEmits<{ feedback: [vote: 'up' | 'down' | null] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Adjacent tool / asset parts render as one grouped card; text, reasoning and notices
|
||||
// stay as their own rows, preserving the transport's interleaved order.
|
||||
type Group =
|
||||
| { kind: 'text'; part: TextPart }
|
||||
| { kind: 'reasoning'; part: ReasoningPart }
|
||||
| { kind: 'notice'; part: NoticePart }
|
||||
| { kind: 'tools'; parts: ToolPart[] }
|
||||
| { kind: 'assets'; parts: FilePart[] }
|
||||
|
||||
const groups = computed<Group[]>(() => {
|
||||
const out: Group[] = []
|
||||
for (const part of message.parts) {
|
||||
const prev = out.at(-1)
|
||||
if (part.type === 'tool') {
|
||||
if (prev?.kind === 'tools') prev.parts.push(part)
|
||||
else out.push({ kind: 'tools', parts: [part] })
|
||||
} else if (part.type === 'file') {
|
||||
if (prev?.kind === 'assets') prev.parts.push(part)
|
||||
else out.push({ kind: 'assets', parts: [part] })
|
||||
} else if (part.type === 'text') {
|
||||
out.push({ kind: 'text', part })
|
||||
} else if (part.type === 'reasoning') {
|
||||
out.push({ kind: 'reasoning', part })
|
||||
} else {
|
||||
out.push({ kind: 'notice', part })
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const plainText = computed(() =>
|
||||
message.parts
|
||||
.filter((part): part is TextPart => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n\n')
|
||||
)
|
||||
|
||||
const showActions = computed(
|
||||
() => !message.streaming && plainText.value.length > 0
|
||||
)
|
||||
|
||||
const raw = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group space-y-1.5">
|
||||
<ThinkingStatus
|
||||
v-if="message.thinking || (message.streaming && !message.parts.length)"
|
||||
:tokens="message.tokens"
|
||||
/>
|
||||
|
||||
<template v-for="(group, index) in groups" :key="index">
|
||||
<MarkdownStream
|
||||
v-if="group.kind === 'text'"
|
||||
:text="group.part.text"
|
||||
:raw="raw"
|
||||
/>
|
||||
<ReasoningDisclosure
|
||||
v-else-if="group.kind === 'reasoning'"
|
||||
:text="group.part.text"
|
||||
:streaming="group.part.state === 'streaming'"
|
||||
/>
|
||||
<ToolCallGroup v-else-if="group.kind === 'tools'" :tools="group.parts" />
|
||||
<AssetGrid v-else-if="group.kind === 'assets'" :assets="group.parts" />
|
||||
<div
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'rounded-agent flex items-start gap-2 border px-3 py-2 text-sm',
|
||||
group.part.level === 'error'
|
||||
? 'border-agent-danger/40 text-agent-danger'
|
||||
: 'border-agent-border text-agent-fg-muted'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="mt-0.5 icon-[lucide--triangle-alert] size-4 shrink-0" />
|
||||
<span>{{ group.part.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="showActions"
|
||||
class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<MessageFeedback :text="plainText" @feedback="emit('feedback', $event)" />
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('agent.toggleRaw')"
|
||||
:aria-pressed="raw"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-agent text-agent-fg-subtle hover:bg-agent-surface-hover hover:text-agent-fg flex size-7 items-center justify-center transition-colors',
|
||||
raw && 'text-agent-fg'
|
||||
)
|
||||
"
|
||||
@click="raw = !raw"
|
||||
>
|
||||
<span class="icon-[lucide--code] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const {
|
||||
url,
|
||||
filename,
|
||||
mediaType = 'image'
|
||||
} = defineProps<{
|
||||
url: string
|
||||
filename: string
|
||||
mediaType?: string
|
||||
}>()
|
||||
|
||||
const isVideo = computed(() => mediaType.startsWith('video'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-agent border-agent-border bg-agent-surface focus-visible:ring-agent-accent relative block aspect-square overflow-hidden border focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<video
|
||||
v-if="isVideo"
|
||||
:src="url"
|
||||
class="size-full object-cover"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<img v-else :src="url" :alt="filename" class="size-full object-cover" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { FilePart } from '../../../services/agent/agentMessageParts'
|
||||
|
||||
import AssetCard from './AssetCard.vue'
|
||||
import Lightbox from './Lightbox.vue'
|
||||
|
||||
const { assets } = defineProps<{ assets: FilePart[] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const open = ref(false)
|
||||
const active = ref<FilePart | null>(null)
|
||||
|
||||
function show(asset: FilePart): void {
|
||||
active.value = asset
|
||||
open.value = true
|
||||
}
|
||||
|
||||
const activeIsVideo = computed(
|
||||
() => active.value?.mediaType.startsWith('video') ?? false
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<AssetCard
|
||||
v-for="(asset, index) in assets"
|
||||
:key="index"
|
||||
:url="asset.url"
|
||||
:filename="asset.filename"
|
||||
:media-type="asset.mediaType"
|
||||
@click="show(asset)"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-agent-fg-subtle mt-1.5 text-xs">
|
||||
{{ t('agent.savedToAssets') }}
|
||||
</p>
|
||||
<Lightbox
|
||||
v-model:open="open"
|
||||
:url="active?.url"
|
||||
:filename="active?.filename"
|
||||
:is-video="activeIsVideo"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { default as DOMPurify } from 'dompurify'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { code, lang = 'text' } = defineProps<{
|
||||
code: string
|
||||
lang?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000, legacy: true })
|
||||
|
||||
// shiki highlights asynchronously and its bundle is lazy-loaded, so the block first
|
||||
// renders as plain escaped code and swaps to the highlighted markup once shiki resolves.
|
||||
// An unknown language (or a shiki failure) degrades to the plain fallback rather than
|
||||
// throwing. shiki emits its own trusted <span> markup, safe to inject.
|
||||
const highlighted = ref<string | null>(null)
|
||||
|
||||
// watch (not watchEffect) so the code/lang deps are tracked even though the highlight body
|
||||
// awaits the lazy shiki import — after the first await a watchEffect tracks nothing.
|
||||
watch(
|
||||
() => [code, lang] as const,
|
||||
async ([currentCode, currentLang], _prev, onCleanup) => {
|
||||
// Streaming re-runs this per token; a superseded run must not land its stale
|
||||
// highlight after a newer one (async resolutions are not ordered).
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
highlighted.value = null
|
||||
try {
|
||||
const { codeToHtml } = await import('shiki')
|
||||
const html = await codeToHtml(currentCode, {
|
||||
lang: currentLang,
|
||||
theme: 'github-dark',
|
||||
colorReplacements: { '#24292e': 'transparent' }
|
||||
})
|
||||
if (!cancelled) highlighted.value = DOMPurify.sanitize(html)
|
||||
} catch {
|
||||
if (!cancelled) highlighted.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group border-agent-border-strong relative my-2 overflow-hidden rounded-md border"
|
||||
>
|
||||
<div
|
||||
class="border-agent-border-strong bg-agent-surface-hover flex items-center justify-between border-b px-3 py-1.5"
|
||||
>
|
||||
<span
|
||||
class="text-agent-fg-subtle flex items-center gap-1.5 font-mono text-xs"
|
||||
>
|
||||
<span class="icon-[lucide--file-code] size-3.5" />
|
||||
<span class="text-agent-fg font-medium">{{ lang }}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-agent-fg-subtle hover:bg-agent-surface hover:text-agent-fg border-agent-border-strong flex items-center gap-1 rounded-sm border px-2 py-0.5 font-mono text-xs transition-colors"
|
||||
@click="copy(code)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'size-3.5',
|
||||
copied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{ copied ? t('agent.copied') : t('agent.copy') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="highlighted"
|
||||
class="overflow-x-auto p-4 font-mono text-sm [&_pre]:bg-transparent"
|
||||
v-html="highlighted"
|
||||
/>
|
||||
<pre
|
||||
v-else
|
||||
class="text-agent-fg overflow-x-auto p-4 font-mono text-sm"
|
||||
><code>{{ code }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogRoot, DialogTitle } from 'reka-ui'
|
||||
|
||||
import DialogContent from '../../ui/DialogContent.vue'
|
||||
|
||||
const {
|
||||
url,
|
||||
filename,
|
||||
isVideo = false
|
||||
} = defineProps<{
|
||||
url?: string
|
||||
filename?: string
|
||||
isVideo?: boolean
|
||||
}>()
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-model:open="open">
|
||||
<DialogContent class="max-w-3xl border-0 bg-transparent p-0 shadow-none">
|
||||
<DialogTitle class="sr-only">{{ filename }}</DialogTitle>
|
||||
<video
|
||||
v-if="isVideo && url"
|
||||
:src="url"
|
||||
class="rounded-agent max-h-[80vh] w-full object-contain"
|
||||
controls
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
loop
|
||||
/>
|
||||
<img
|
||||
v-else-if="url"
|
||||
:src="url"
|
||||
:alt="filename"
|
||||
class="rounded-agent max-h-[80vh] w-full object-contain"
|
||||
/>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { renderMarkdownToHtml } from '../../../utils/agent/renderMarkdownToHtml'
|
||||
|
||||
import CodeBlock from './CodeBlock.vue'
|
||||
|
||||
const { text, raw = false } = defineProps<{
|
||||
text: string
|
||||
raw?: boolean
|
||||
}>()
|
||||
|
||||
interface ProseSegment {
|
||||
type: 'prose'
|
||||
html: string
|
||||
}
|
||||
interface CodeSegment {
|
||||
type: 'code'
|
||||
code: string
|
||||
lang: string
|
||||
}
|
||||
type Segment = ProseSegment | CodeSegment
|
||||
|
||||
// Fenced code blocks are pulled out so they render as shiki-highlighted CodeBlocks with
|
||||
// copy chrome; the prose between them goes through the sanitizing markdown renderer. The
|
||||
// opening fence must start a line (lookbehind for start-or-newline) so an inline
|
||||
// triple-backtick span mid-sentence is left to the markdown renderer, not misparsed as a
|
||||
// block. NOTE(migration): 4+ backtick fences still need a marked.lexer-based split.
|
||||
const FENCE = /(?<=^|\n)```([\w-]*)\n([\s\S]*?)```/g
|
||||
|
||||
const segments = computed<Segment[]>(() => {
|
||||
const out: Segment[] = []
|
||||
let last = 0
|
||||
for (const match of text.matchAll(FENCE)) {
|
||||
const idx = match.index ?? 0
|
||||
if (idx > last) {
|
||||
out.push({
|
||||
type: 'prose',
|
||||
html: renderMarkdownToHtml(text.slice(last, idx))
|
||||
})
|
||||
}
|
||||
out.push({
|
||||
type: 'code',
|
||||
code: match[2].replace(/\n$/, ''),
|
||||
lang: match[1] || 'text'
|
||||
})
|
||||
last = idx + match[0].length
|
||||
}
|
||||
if (last < text.length) {
|
||||
out.push({ type: 'prose', html: renderMarkdownToHtml(text.slice(last)) })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// Prose styles read from the deployed prototype's .agent-markdown rules.
|
||||
const proseClass = cn(
|
||||
'text-agent-fg text-sm/relaxed',
|
||||
'[&_a]:text-agent-accent [&_a]:cursor-pointer [&_a]:underline',
|
||||
'[&_p]:my-0 [&_p]:pt-1 [&_strong]:font-semibold',
|
||||
'[&_h1]:mt-0 [&_h1]:pt-4 [&_h1]:pb-2 [&_h1]:text-2xl [&_h1]:font-semibold',
|
||||
'[&_h2]:pt-3.5 [&_h2]:pb-1.5 [&_h2]:text-base [&_h2]:font-semibold [&_h3]:pt-2 [&_h3]:font-semibold',
|
||||
'[&_ol]:my-0 [&_ol]:list-decimal [&_ol]:pt-1 [&_ol]:pb-2 [&_ol]:pl-5',
|
||||
'[&_ul]:my-0 [&_ul]:list-disc [&_ul]:pt-1 [&_ul]:pb-2 [&_ul]:pl-5',
|
||||
'[&_:not(pre)>code]:bg-agent-surface-hover [&_:not(pre)>code]:border-agent-border-strong [&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:border [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:text-[0.875em]',
|
||||
'[&_blockquote]:border-agent-border-strong [&_blockquote]:text-agent-fg-muted [&_blockquote]:my-2 [&_blockquote]:border-l-[3px] [&_blockquote]:py-1.5 [&_blockquote]:pl-3.5',
|
||||
'[&_table]:bg-agent-surface-raised [&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden [&_table]:rounded-lg',
|
||||
'[&_th]:border-agent-border-strong [&_th]:bg-agent-surface-hover [&_th]:border-b [&_th]:px-4 [&_th]:py-2.5 [&_th]:text-left [&_th]:font-semibold',
|
||||
'[&_td]:border-agent-border-strong [&_td]:border-b [&_td]:px-4 [&_td]:py-2.5'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<pre
|
||||
v-if="raw"
|
||||
class="rounded-agent bg-agent-surface text-agent-fg overflow-x-auto p-3 text-xs whitespace-pre-wrap"
|
||||
>{{ text }}</pre
|
||||
>
|
||||
<div v-else>
|
||||
<template v-for="(segment, index) in segments" :key="index">
|
||||
<CodeBlock
|
||||
v-if="segment.type === 'code'"
|
||||
:code="segment.code"
|
||||
:lang="segment.lang"
|
||||
/>
|
||||
<div v-else :class="proseClass" v-html="segment.html" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
// Thumbs are mutually exclusive; clicking the active one clears it. Feedback is emitted
|
||||
// so the container can forward it to the real submit_feedback tool.
|
||||
const { text } = defineProps<{ text: string }>()
|
||||
const emit = defineEmits<{ feedback: [vote: 'up' | 'down' | null] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000, legacy: true })
|
||||
|
||||
const vote = ref<'up' | 'down' | null>(null)
|
||||
|
||||
function setVote(next: 'up' | 'down'): void {
|
||||
vote.value = vote.value === next ? null : next
|
||||
emit('feedback', vote.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-agent-fg-subtle flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('agent.helpful')"
|
||||
:aria-pressed="vote === 'up'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-agent hover:bg-agent-surface-hover hover:text-agent-fg flex size-6 items-center justify-center transition-colors',
|
||||
vote === 'up' && 'text-agent-accent'
|
||||
)
|
||||
"
|
||||
@click="setVote('up')"
|
||||
>
|
||||
<span class="icon-[lucide--thumbs-up] size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('agent.notHelpful')"
|
||||
:aria-pressed="vote === 'down'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-agent hover:bg-agent-surface-hover hover:text-agent-fg flex size-6 items-center justify-center transition-colors',
|
||||
vote === 'down' && 'text-agent-danger'
|
||||
)
|
||||
"
|
||||
@click="setVote('down')"
|
||||
>
|
||||
<span class="icon-[lucide--thumbs-down] size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="copied ? t('agent.copied') : t('agent.copy')"
|
||||
class="rounded-agent hover:bg-agent-surface-hover hover:text-agent-fg flex size-6 items-center justify-center transition-colors"
|
||||
@click="copy(text)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'size-3.5',
|
||||
copied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CollapsibleContent from '../../ui/CollapsibleContent.vue'
|
||||
|
||||
// Open while the reasoning is still streaming so the user sees it live; collapses to a
|
||||
// one-line summary once the turn moves on. Controlled (not defaultOpen, which reka reads
|
||||
// only at mount) so it actually collapses when streaming ends; a manual re-open sticks.
|
||||
const { text, streaming = false } = defineProps<{
|
||||
text: string
|
||||
streaming?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const open = ref(streaming)
|
||||
watch(
|
||||
() => streaming,
|
||||
(isStreaming) => {
|
||||
if (!isStreaming) open.value = false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRoot
|
||||
v-model:open="open"
|
||||
class="rounded-agent border-agent-border bg-agent-surface border"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
class="group text-agent-fg-muted hover:text-agent-fg flex w-full items-center gap-1.5 px-3 py-1.5 text-xs transition-colors"
|
||||
>
|
||||
<span
|
||||
class="icon-[lucide--chevron-right] size-3.5 transition-transform group-data-[state=open]:rotate-90"
|
||||
/>
|
||||
{{ t('agent.reasoning') }}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
class="text-agent-fg-muted px-3 pb-2 text-xs/relaxed whitespace-pre-wrap"
|
||||
>
|
||||
{{ text }}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// Plain "Thinking..." per the locked Figma decision (the monolith's rotating status
|
||||
// words are dropped). The running think-token count shows only when the server has sent
|
||||
// one (tokens > 0).
|
||||
const { tokens = 0 } = defineProps<{ tokens?: number }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-agent-fg-muted flex items-center gap-1.5 py-1 text-sm">
|
||||
<span class="icon-[lucide--brain] size-3.5 shrink-0" />
|
||||
<span class="agent-shimmer-text">{{ t('agent.thinking') }}</span>
|
||||
<span v-if="tokens > 0" class="text-agent-fg-subtle text-xs">{{
|
||||
tokens
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { PartState } from '../../../services/agent/agentMessageParts'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
// One tool row (or a run of identical calls collapsed with a count). `ok` is undefined
|
||||
// while the call is still running.
|
||||
const {
|
||||
name,
|
||||
state,
|
||||
ok,
|
||||
count = 1
|
||||
} = defineProps<{
|
||||
name: string
|
||||
state: PartState
|
||||
ok?: boolean
|
||||
count?: number
|
||||
}>()
|
||||
|
||||
const glyph = computed(() => {
|
||||
if (state === 'streaming') return 'animate-spin icon-[lucide--loader-circle]'
|
||||
return ok === false
|
||||
? 'icon-[lucide--circle-x]'
|
||||
: 'icon-[lucide--circle-check]'
|
||||
})
|
||||
|
||||
const glyphColor = computed(() => {
|
||||
if (state === 'streaming') return 'text-agent-fg-subtle'
|
||||
return ok === false ? 'text-agent-danger' : 'text-agent-success'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-agent-fg flex items-center gap-2 px-3 py-1.5 text-sm">
|
||||
<span :class="cn('size-4 shrink-0', glyph, glyphColor)" />
|
||||
<span class="truncate font-mono text-xs">{{ name }}</span>
|
||||
<span v-if="count > 1" class="text-agent-fg-subtle text-xs"
|
||||
>×{{ count }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CollapsibleContent from '../../ui/CollapsibleContent.vue'
|
||||
import type { ToolPart } from '../../../services/agent/agentMessageParts'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import ToolCallCard from './ToolCallCard.vue'
|
||||
|
||||
const { tools } = defineProps<{ tools: ToolPart[] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Consecutive identical tool names collapse into one row with a count (same-tool xN).
|
||||
interface Row {
|
||||
name: string
|
||||
state: ToolPart['state']
|
||||
ok?: boolean
|
||||
count: number
|
||||
}
|
||||
const rows = computed<Row[]>(() => {
|
||||
const out: Row[] = []
|
||||
for (const tool of tools) {
|
||||
const prev = out.at(-1)
|
||||
if (prev && prev.name === tool.name) {
|
||||
prev.count += 1
|
||||
if (tool.state === 'streaming') prev.state = 'streaming'
|
||||
if (tool.ok === false) prev.ok = false
|
||||
} else {
|
||||
out.push({
|
||||
name: tool.name,
|
||||
state: tool.state,
|
||||
ok: tool.ok,
|
||||
count: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const running = computed(() => tools.some((tool) => tool.state === 'streaming'))
|
||||
const failed = computed(() =>
|
||||
tools.some((tool) => tool.state === 'done' && tool.ok === false)
|
||||
)
|
||||
|
||||
// Live while running or if anything failed; collapse once every call settled clean. Drive
|
||||
// open BOTH ways off the source: a later tool re-entering the group (running again) or a
|
||||
// tool failing must re-open it, not just the first clean settle. Manual re-open of a
|
||||
// settled clean group still sticks because the source does not change then.
|
||||
const open = ref(true)
|
||||
watch(
|
||||
() => running.value || failed.value,
|
||||
(stayOpen) => {
|
||||
open.value = stayOpen
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRoot v-model:open="open">
|
||||
<CollapsibleTrigger
|
||||
class="group text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-sm transition-colors"
|
||||
>
|
||||
<span
|
||||
v-if="running"
|
||||
class="text-agent-fg-subtle icon-[lucide--loader-circle] size-4 shrink-0 animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-else-if="failed"
|
||||
class="text-agent-danger icon-[lucide--circle-x] size-4 shrink-0"
|
||||
/>
|
||||
<span v-else class="icon-[lucide--wrench] size-4 shrink-0" />
|
||||
<span class="flex-1 text-left">{{
|
||||
t('agent.ranToolCalls', tools.length)
|
||||
}}</span>
|
||||
<span
|
||||
class="icon-[lucide--chevron-down] size-4 shrink-0 transition-transform group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div :class="cn('flex flex-col gap-0.5 pt-1 pl-2')">
|
||||
<ToolCallCard
|
||||
v-for="(row, index) in rows"
|
||||
:key="index"
|
||||
:name="row.name"
|
||||
:state="row.state"
|
||||
:ok="row.ok"
|
||||
:count="row.count"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
// User message per the design reference: a right-aligned pill, no avatar.
|
||||
const { text } = defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-end">
|
||||
<div
|
||||
class="bg-agent-surface-raised text-agent-fg w-fit max-w-full rounded-lg px-4 py-3 text-xs whitespace-pre-wrap"
|
||||
>
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
import type { ApprovalCard as ApprovalCardModel } from './safetyTypes'
|
||||
|
||||
import ApprovalCard from './ApprovalCard.vue'
|
||||
|
||||
const base = {
|
||||
approvalId: 'a1',
|
||||
turnId: 't1',
|
||||
tool: 'spend_credits',
|
||||
summary: 'Generate 4 images (8 credits)',
|
||||
outcome: null
|
||||
}
|
||||
|
||||
function mount(card: ApprovalCardModel) {
|
||||
return render(ApprovalCard, { props: { card }, global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
describe('ApprovalCard (non-optimistic)', () => {
|
||||
it('emits answer on Approve but does not itself flip to approved', async () => {
|
||||
const { emitted } = mount({ ...base, status: 'open' })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
expect(emitted().answer[0]).toEqual(['a1', true])
|
||||
// Still shows the live buttons — the card only resolves on the server's approval_closed.
|
||||
expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('approved')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a waiting state with no action buttons once answered', () => {
|
||||
mount({ ...base, status: 'waiting' })
|
||||
expect(screen.getByText('Waiting for approval...')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the terminal outcome once resolved', () => {
|
||||
mount({ ...base, status: 'resolved', outcome: 'approved' })
|
||||
expect(screen.getByText('approved')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '../../ui/Button.vue'
|
||||
import type { ApprovalCard } from './safetyTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
// NON-optimistic: clicking Approve/Deny emits the answer and moves the card to 'waiting'
|
||||
// (parent calls answerApproval); it flips to 'resolved' ONLY when the server's
|
||||
// approval_closed lands. The card never shows "approved" on click alone.
|
||||
const { card } = defineProps<{ card: ApprovalCard }>()
|
||||
const emit = defineEmits<{ answer: [approvalId: string, approved: boolean] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// A rapid double-click must not emit two answers before the parent flips the card off
|
||||
// 'open'; an approval is a spend decision.
|
||||
const answered = ref(false)
|
||||
|
||||
function answer(approved: boolean): void {
|
||||
if (answered.value) return
|
||||
answered.value = true
|
||||
emit('answer', card.approvalId, approved)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-agent border-agent-border bg-agent-surface-raised space-y-2 border p-3"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-agent-accent icon-[lucide--shield-alert] size-4" />
|
||||
<span class="text-agent-fg font-medium">{{
|
||||
t('agent.approvalTitle')
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="text-agent-fg-muted text-sm">{{ card.summary }}</p>
|
||||
<p class="text-agent-fg-subtle font-mono text-xs">{{ card.tool }}</p>
|
||||
|
||||
<div v-if="card.status === 'open'" class="flex gap-2">
|
||||
<Button size="sm" :disabled="answered" @click="answer(true)">
|
||||
{{ t('agent.approve') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="answered"
|
||||
@click="answer(false)"
|
||||
>
|
||||
{{ t('agent.deny') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
v-else-if="card.status === 'waiting'"
|
||||
class="text-agent-fg-muted flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<span class="icon-[lucide--loader-circle] size-3.5 animate-spin" />
|
||||
{{ t('agent.waiting') }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'text-xs',
|
||||
card.outcome === 'approved'
|
||||
? 'text-agent-success'
|
||||
: 'text-agent-fg-subtle'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ card.outcome }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogDescription, DialogRoot, DialogTitle } from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '../../ui/Button.vue'
|
||||
import DialogContent from '../../ui/DialogContent.vue'
|
||||
import type { ConflictChoice } from './safetyTypes'
|
||||
|
||||
// Controlled: visibility is driven by the lock's conflictOpen state, and resolving flips
|
||||
// that state upstream. Not self-dismissible (no update:open handler) — the user must pick.
|
||||
const { open } = defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ resolve: [choice: ConflictChoice] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function choose(choice: ConflictChoice): void {
|
||||
emit('resolve', choice)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="open">
|
||||
<DialogContent :show-close="false" class="space-y-3">
|
||||
<DialogTitle class="text-agent-fg text-base font-semibold">
|
||||
{{ t('agent.conflictTitle') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription class="text-agent-fg-muted text-sm">
|
||||
{{ t('agent.conflictBody') }}
|
||||
</DialogDescription>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button @click="choose('mine')">{{ t('agent.keepMine') }}</Button>
|
||||
<Button variant="outline" @click="choose('agent')">
|
||||
{{ t('agent.letAgentContinue') }}
|
||||
</Button>
|
||||
<Button variant="ghost" @click="choose('newtab')">
|
||||
{{ t('agent.openNewTab') }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '../../ui/Button.vue'
|
||||
import type { LockState } from './safetyTypes'
|
||||
|
||||
// Shows while the agent holds (or is claiming) the canvas lock. Take control is always
|
||||
// offered here — the always-visible reclaim button is one of the five lock failsafes.
|
||||
const { state } = defineProps<{ state: LockState }>()
|
||||
const emit = defineEmits<{ takeControl: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const show = computed(() => state === 'LOCKED' || state === 'LOCK_PENDING')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="border-agent-border bg-agent-surface-raised flex items-center gap-2 border-b px-4 py-2 text-sm"
|
||||
>
|
||||
<span class="text-agent-accent icon-[lucide--lock] size-4" />
|
||||
<span class="text-agent-fg-muted flex-1">{{
|
||||
t('agent.agentEditing')
|
||||
}}</span>
|
||||
<Button size="sm" variant="outline" @click="emit('takeControl')">
|
||||
{{ t('agent.takeControl') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '../../ui/Button.vue'
|
||||
|
||||
const { canRevert } = defineProps<{ canRevert: boolean }>()
|
||||
const emit = defineEmits<{ revert: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="!canRevert"
|
||||
@click="emit('revert')"
|
||||
>
|
||||
<span class="icon-[lucide--undo-2] size-4" />
|
||||
{{ t('agent.revert') }}
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
type ApprovalOutcome =
|
||||
| 'approved'
|
||||
| 'denied'
|
||||
| 'timeout'
|
||||
| 'cancelled'
|
||||
| 'disconnected'
|
||||
|
||||
// waiting = user answered but the server's terminal outcome has not landed yet.
|
||||
type ApprovalCardStatus = 'open' | 'waiting' | 'resolved'
|
||||
|
||||
export interface ApprovalCard {
|
||||
approvalId: string
|
||||
turnId: string
|
||||
tool: string
|
||||
summary: string
|
||||
status: ApprovalCardStatus
|
||||
outcome: ApprovalOutcome | null
|
||||
}
|
||||
|
||||
export type LockState =
|
||||
| 'UNLOCKED'
|
||||
| 'LOCK_PENDING'
|
||||
| 'LOCKED'
|
||||
| 'CONFLICT'
|
||||
| 'UNLOCKING'
|
||||
|
||||
export type ConflictChoice = 'agent' | 'mine' | 'newtab'
|
||||
57
src/workbench/extensions/agent/components/ui/Button.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { cva } from 'cva'
|
||||
import { Primitive } from 'reka-ui'
|
||||
|
||||
import type { VariantProps } from 'cva'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const buttonVariants = cva({
|
||||
base: 'rounded-agent focus-visible:ring-agent-accent inline-flex items-center justify-center gap-2 text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-agent-accent text-agent-accent-fg hover:bg-agent-accent/90',
|
||||
ghost:
|
||||
'text-agent-fg-muted hover:bg-agent-surface-hover hover:text-agent-fg',
|
||||
outline:
|
||||
'border-agent-border text-agent-fg hover:bg-agent-surface-hover border bg-transparent',
|
||||
danger: 'bg-agent-danger text-agent-accent-fg hover:bg-agent-danger/90'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 px-3',
|
||||
icon: 'size-8'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
})
|
||||
|
||||
type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
|
||||
const {
|
||||
variant,
|
||||
size,
|
||||
as = 'button',
|
||||
asChild = false,
|
||||
class: cls
|
||||
} = defineProps<{
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
as?: string
|
||||
asChild?: boolean
|
||||
class?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), cls)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { CollapsibleContent } from 'reka-ui'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: cls } = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleContent
|
||||
:class="
|
||||
cn(
|
||||
'overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down',
|
||||
cls
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</CollapsibleContent>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: cls, showClose = true } = defineProps<{
|
||||
class?: string
|
||||
showClose?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-50 bg-black/60" />
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'agent-scope rounded-agent border-agent-border bg-agent-surface-raised text-agent-fg fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-1/2 border p-5 shadow-xl focus:outline-none',
|
||||
cls
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<DialogClose
|
||||
v-if="showClose"
|
||||
class="rounded-agent text-agent-fg-subtle hover:bg-agent-surface-hover hover:text-agent-fg focus-visible:ring-agent-accent absolute top-3 right-3 flex size-7 items-center justify-center transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
:aria-label="t('agent.close')"
|
||||
>
|
||||
<span class="icon-[lucide--x] size-4" />
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
31
src/workbench/extensions/agent/components/ui/ScrollArea.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport
|
||||
} from 'reka-ui'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: cls, viewportClass } = defineProps<{
|
||||
class?: string
|
||||
viewportClass?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaRoot :class="cn('relative overflow-hidden', cls)" type="hover">
|
||||
<ScrollAreaViewport :class="cn('size-full', viewportClass)">
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar
|
||||
orientation="vertical"
|
||||
class="flex w-2 touch-none p-0.5 transition-colors select-none"
|
||||
>
|
||||
<ScrollAreaThumb
|
||||
class="bg-agent-border-strong relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
||||
18
src/workbench/extensions/agent/components/ui/Textarea.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: cls } = defineProps<{ class?: string }>()
|
||||
const model = defineModel<string>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
v-model="model"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-agent text-agent-fg placeholder:text-agent-fg-subtle flex w-full resize-none bg-transparent px-3 py-2 text-sm focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
cls
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
// Reka's Dialog teleports its content to document.body; jsdom serializes the portalled
|
||||
// tree reliably, so this integration smoke test (that the primitive wiring mounts and
|
||||
// opens) runs against jsdom rather than happy-dom.
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DialogRoot, DialogTrigger } from 'reka-ui'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
import Button from './Button.vue'
|
||||
import DialogContent from './DialogContent.vue'
|
||||
|
||||
describe('ui primitives wiring', () => {
|
||||
it('Button renders its slot and reacts to clicks', async () => {
|
||||
let clicks = 0
|
||||
render(
|
||||
defineComponent(
|
||||
() => () => h(Button, { onClick: () => (clicks += 1) }, () => 'Send')
|
||||
)
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
expect(clicks).toBe(1)
|
||||
})
|
||||
|
||||
it('Dialog opens from its trigger and shows portalled content + close', async () => {
|
||||
render(
|
||||
defineComponent(
|
||||
() => () =>
|
||||
h(DialogRoot, null, () => [
|
||||
h(DialogTrigger, null, () => 'Open'),
|
||||
h(DialogContent, null, () => 'Body copy')
|
||||
])
|
||||
),
|
||||
{ global: { plugins: [i18n] } }
|
||||
)
|
||||
expect(screen.queryByText('Body copy')).toBeNull()
|
||||
await userEvent.click(screen.getByText('Open'))
|
||||
expect(screen.getByText('Body copy')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Close')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
AGENT_PANEL_FLAG,
|
||||
createPostHogFlagSource,
|
||||
useAgentFeatureGate
|
||||
} from './useAgentFeatureGate'
|
||||
import type { AgentFlagSource, PostHogLike } from './useAgentFeatureGate'
|
||||
|
||||
function fakePostHog(initial: boolean | undefined): {
|
||||
posthog: PostHogLike
|
||||
setFlag: (value: boolean | undefined) => void
|
||||
} {
|
||||
let value = initial
|
||||
let listener: (() => void) | undefined
|
||||
return {
|
||||
posthog: {
|
||||
isFeatureEnabled: () => value,
|
||||
onFeatureFlags: (fn) => {
|
||||
listener = fn
|
||||
return () => {
|
||||
listener = undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
setFlag: (next) => {
|
||||
value = next
|
||||
listener?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAgentFeatureGate', () => {
|
||||
it('fails closed when the source reports disabled', () => {
|
||||
const source: AgentFlagSource = { isEnabled: () => false }
|
||||
const { enabled } = useAgentFeatureGate(source)
|
||||
expect(enabled.value).toBe(false)
|
||||
})
|
||||
|
||||
it('maps an unloaded posthog flag (undefined) to false', () => {
|
||||
const { posthog } = fakePostHog(undefined)
|
||||
const { enabled } = useAgentFeatureGate(createPostHogFlagSource(posthog))
|
||||
expect(enabled.value).toBe(false)
|
||||
})
|
||||
|
||||
it('flips on when flags load and the listener fires, then off on a later change', () => {
|
||||
const { posthog, setFlag } = fakePostHog(undefined)
|
||||
const { enabled } = useAgentFeatureGate(createPostHogFlagSource(posthog))
|
||||
expect(enabled.value).toBe(false)
|
||||
|
||||
setFlag(true)
|
||||
expect(enabled.value).toBe(true)
|
||||
|
||||
setFlag(false)
|
||||
expect(enabled.value).toBe(false)
|
||||
})
|
||||
|
||||
it('dispose stops further updates', () => {
|
||||
const { posthog, setFlag } = fakePostHog(undefined)
|
||||
const { enabled, dispose } = useAgentFeatureGate(
|
||||
createPostHogFlagSource(posthog)
|
||||
)
|
||||
dispose()
|
||||
setFlag(true)
|
||||
expect(enabled.value).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a void-returning onFeatureFlags', () => {
|
||||
const posthog: PostHogLike = {
|
||||
isFeatureEnabled: () => true,
|
||||
onFeatureFlags: vi.fn()
|
||||
}
|
||||
const { enabled, dispose } = useAgentFeatureGate(
|
||||
createPostHogFlagSource(posthog)
|
||||
)
|
||||
expect(enabled.value).toBe(true)
|
||||
expect(() => dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it('createPostHogFlagSource queries the agent flag by default', () => {
|
||||
const isFeatureEnabled = vi.fn(() => true)
|
||||
const source = createPostHogFlagSource({
|
||||
isFeatureEnabled,
|
||||
onFeatureFlags: () => {}
|
||||
})
|
||||
expect(source.isEnabled()).toBe(true)
|
||||
expect(isFeatureEnabled).toHaveBeenCalledWith(AGENT_PANEL_FLAG)
|
||||
})
|
||||
})
|
||||