From 3bc4ac44e5b528d63eb0b09b7237fdbddf9544bf Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:56:25 +0000 Subject: [PATCH 01/52] fix(ci): correct difyctl release and e2e workflow gates (#41355) --- .github/workflows/cli-e2e.yml | 45 ++++-- .github/workflows/cli-release.yml | 42 ++---- .github/workflows/cli-tests.yml | 2 +- cli/AGENTS.md | 1 + cli/bin/dev.js | 4 +- cli/package.json | 7 +- cli/scripts/install-local.sh | 4 +- cli/scripts/release-build.sh | 11 +- cli/scripts/release-guards.test.ts | 120 ++++++++++++++++ cli/scripts/release-naming.mjs | 29 +++- cli/scripts/release-naming.test.ts | 151 +++++++++++++++++---- cli/scripts/release-validate-manifest.sh | 41 ------ cli/scripts/release-write-checksums.sh | 2 +- cli/src/version/render.test.ts | 12 -- cli/test/fixtures/pkg-manifest.ts | 7 +- cli/test/scripts/resolve-buildinfo.test.ts | 71 +++++++++- cli/vite.config.ts | 4 +- 17 files changed, 406 insertions(+), 147 deletions(-) create mode 100644 cli/scripts/release-guards.test.ts delete mode 100755 cli/scripts/release-validate-manifest.sh diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 0c23cda5f58..51c5485ce0f 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -112,7 +112,7 @@ jobs: # ════════════════════════════════════════════════════════════════════════════ suite-framework-output-error: name: 'Suite: framework + output + error-handling' - if: ${{ inputs.suite_framework_output_error != 'false' }} + if: ${{ inputs.suite_framework_output_error }} needs: provision runs-on: ubuntu-latest timeout-minutes: 20 @@ -129,9 +129,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -159,7 +162,7 @@ jobs: # ════════════════════════════════════════════════════════════════════════════ suite-discovery: name: 'Suite: discovery' - if: ${{ inputs.suite_discovery != 'false' }} + if: ${{ inputs.suite_discovery }} needs: provision runs-on: ubuntu-latest timeout-minutes: 20 @@ -176,9 +179,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -207,7 +213,7 @@ jobs: # ════════════════════════════════════════════════════════════════════════════ suite-run: name: 'Suite: run / ${{ matrix.name }}' - if: ${{ inputs.suite_run != 'false' }} + if: ${{ inputs.suite_run }} needs: provision runs-on: ubuntu-latest timeout-minutes: 20 @@ -239,9 +245,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -284,7 +293,7 @@ jobs: # ════════════════════════════════════════════════════════════════════════════ suite-auth-safe: name: 'Suite: auth (login / status / whoami)' - if: ${{ inputs.suite_auth != 'false' }} + if: ${{ inputs.suite_auth }} needs: provision runs-on: ubuntu-latest timeout-minutes: 15 @@ -301,9 +310,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -333,7 +345,7 @@ jobs: suite-last: name: 'Suite: auth-use + devices + logout + agent (last, serial)' # Runs when auth is selected; also runs after all parallel jobs finish - if: ${{ inputs.suite_auth != 'false' || inputs.suite_agent != 'false' }} + if: ${{ inputs.suite_auth || inputs.suite_agent }} needs: - provision - suite-framework-output-error @@ -357,9 +369,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 6e0670d11e7..172ff0e8cf4 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -7,12 +7,22 @@ on: description: Dify release tag to attach difyctl assets to (blank = latest stable) required: false type: string + dry_run: + description: Build and checksum only — skip asset upload and stale-asset prune + required: false + type: boolean + default: false workflow_call: inputs: release_tag: description: Dify release tag to attach difyctl assets to (blank = latest stable) required: false type: string + dry_run: + description: Build and checksum only — skip asset upload and stale-asset prune + required: false + type: boolean + default: false release: types: [released] @@ -39,11 +49,8 @@ jobs: with: persist-credentials: false - - name: Export manifest to env - run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV" - - name: Validate manifest - run: scripts/release-validate-manifest.sh + run: node scripts/release-naming.mjs validate - name: Resolve target Dify release id: resolve @@ -75,15 +82,6 @@ jobs: DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }} run: node scripts/release-naming.mjs compat-check "$DIFY_TAG" - - name: Reject duplicate difyctl version - env: - GH_TOKEN: ${{ github.token }} - run: | - if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${difyctlTag}" >/dev/null 2>&1; then - echo "::error::difyctl ${version} already released (tag ${difyctlTag} exists); bump cli/package.json version" - exit 1 - fi - release: name: build + attach standalone binaries (all targets) needs: validate @@ -120,6 +118,7 @@ jobs: - name: Compile standalone binaries (all targets) run: | + CLI_VERSION="$version" \ DIFYCTL_COMMIT="$(git rev-parse HEAD)" \ DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \ pnpm build:bin @@ -128,6 +127,7 @@ jobs: run: scripts/release-write-checksums.sh - name: Attach difyctl assets to Dify release + if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} run: | @@ -135,6 +135,7 @@ jobs: --repo "$GITHUB_REPOSITORY" --clobber - name: Prune stale difyctl assets + if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} run: | @@ -149,18 +150,3 @@ jobs: --repo "$GITHUB_REPOSITORY" --yes fi done - - - name: Create provenance tag - env: - GH_TOKEN: ${{ github.token }} - run: | - ref="refs/tags/${difyctlTag}" - sha="$(git rev-parse HEAD)" - status="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \ - -f ref="$ref" -f sha="$sha" --silent --include 2>/dev/null \ - | awk 'NR==1 {print $2; exit}' || true)" - case "$status" in - 201) echo "::notice::created ${ref}" ;; - 422) echo "::notice::tag ${ref} already exists; skipping (immutable)" ;; - *) echo "::error::provenance tag ${ref} not created (HTTP ${status:-unknown})"; exit 1 ;; - esac diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 39fb7647177..9a3c59babc8 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -44,7 +44,7 @@ jobs: - name: Validate release manifest if: matrix.os == 'depot-ubuntu-24.04' - run: scripts/release-validate-manifest.sh + run: node scripts/release-naming.mjs validate - name: CI pipeline (tree, coverage, build) run: pnpm run ci diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 446c85abdcc..2b579f401ff 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -17,6 +17,7 @@ Run package scripts from `cli/`: - Source CLI: `pnpm dev [args...]` - Tests: `pnpm test` - Build: `pnpm build` +- Build a local binary: `pnpm build:bin:local` (pins `DIFYCTL_CHANNEL=dev` so it does not self-report the release channel) - Regenerate and verify the registry: `pnpm tree:gen` and `pnpm tree:check` Run the scoped static check from the repository root with `vp check cli`. diff --git a/cli/bin/dev.js b/cli/bin/dev.js index c0a1f3b9775..0a071a1764e 100755 --- a/cli/bin/dev.js +++ b/cli/bin/dev.js @@ -2,7 +2,9 @@ import { resolveBuildInfo } from '../scripts/lib/resolve-buildinfo.ts' -const info = resolveBuildInfo() +const info = resolveBuildInfo({ + env: { ...process.env, DIFYCTL_CHANNEL: process.env.DIFYCTL_CHANNEL ?? 'dev' }, +}) globalThis.__DIFYCTL_VERSION__ = info.version globalThis.__DIFYCTL_COMMIT__ = info.commit globalThis.__DIFYCTL_BUILD_DATE__ = info.buildDate diff --git a/cli/package.json b/cli/package.json index 6e0a20bfb1c..1c49f13137a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@langgenius/difyctl", - "version": "0.2.0-alpha", + "version": "1.17.0", "description": "Dify command-line interface", "license": "Apache-2.0", "files": [ @@ -32,7 +32,8 @@ "ci": "pnpm tree:check && pnpm test:coverage && pnpm build", "clean": "rm -rf dist node_modules/.cache", "version:info": "bun scripts/print-buildinfo.ts", - "build:bin": "scripts/release-build.sh" + "build:bin": "scripts/release-build.sh", + "build:bin:local": "DIFYCTL_CHANNEL=dev scripts/release-build.sh" }, "dependencies": { "@dify/contracts": "workspace:*", @@ -68,7 +69,7 @@ "node": "^22.22.1" }, "difyctl": { - "channel": "alpha", + "channel": "stable", "compat": { "minDify": "1.16.0", "maxDify": "1.17.0" diff --git a/cli/scripts/install-local.sh b/cli/scripts/install-local.sh index 892bddf9d90..2add943cd4c 100755 --- a/cli/scripts/install-local.sh +++ b/cli/scripts/install-local.sh @@ -1,6 +1,6 @@ #!/bin/sh # install-local.sh — install difyctl from locally built standalone binaries. -# Run via: pnpm install:local (after `pnpm build:bin`) +# Run via: pnpm install:local (after `pnpm build:bin:local`) # # Consumes the raw, self-contained binaries emitted by scripts/release-build.sh # into dist/bin (difyctl-v--). No GitHub Release needed: build on @@ -30,7 +30,7 @@ BINARY="$(ls "${ARTIFACT_DIR}"/difyctl-v*-${os}-${arch} 2>/dev/null | sort -V | if [ -z "$BINARY" ]; then echo "no binary found for ${os}-${arch} in ${ARTIFACT_DIR:-}" >&2 - echo "run: pnpm build:bin" >&2 + echo "run: pnpm build:bin:local" >&2 exit 1 fi diff --git a/cli/scripts/release-build.sh b/cli/scripts/release-build.sh index b7f5f52792b..16dd9a89081 100755 --- a/cli/scripts/release-build.sh +++ b/cli/scripts/release-build.sh @@ -14,8 +14,8 @@ # Env (all optional; defaults derived from cli/package.json + git): # CLI_VERSION — package.json `version` # DIFYCTL_CHANNEL — package.json `difyctl.channel` -# DIFYCTL_MIN_DIFY — package.json `difyctl.compat.minDify` -# DIFYCTL_MAX_DIFY — package.json `difyctl.compat.maxDify` +# DIFYCTL_MIN_DIFY — package.json `difyctl.compat.minDify`; must be X.Y.Z +# DIFYCTL_MAX_DIFY — package.json `difyctl.compat.maxDify`; must be X.Y.Z # DIFYCTL_COMMIT — `git rev-parse HEAD` (or "unknown") # DIFYCTL_BUILD_DATE — current UTC time # @@ -35,6 +35,10 @@ out_dir="${cli_root}/dist/bin" read_pkg() { node -p "require('${cli_root}/package.json').$1" 2>/dev/null; } naming() { node "${_dir}/release-naming.mjs" "$@"; } +require_bound() { + [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || die "$1 must be a plain X.Y.Z version, got '$2'" +} CLI_VERSION="${CLI_VERSION:-$(read_pkg version)}" DIFYCTL_CHANNEL="${DIFYCTL_CHANNEL:-$(read_pkg difyctl.channel)}" @@ -43,6 +47,9 @@ DIFYCTL_MAX_DIFY="${DIFYCTL_MAX_DIFY:-$(read_pkg difyctl.compat.maxDify)}" DIFYCTL_COMMIT="${DIFYCTL_COMMIT:-$(git -C "$cli_root" rev-parse HEAD 2>/dev/null || echo unknown)}" DIFYCTL_BUILD_DATE="${DIFYCTL_BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" +require_bound DIFYCTL_MIN_DIFY "$DIFYCTL_MIN_DIFY" +require_bound DIFYCTL_MAX_DIFY "$DIFYCTL_MAX_DIFY" + [[ "$CLI_VERSION" != "undefined" ]] || die "CLI_VERSION could not be derived from package.json" [[ -f "$entry" ]] || die "entry not found: $entry" diff --git a/cli/scripts/release-guards.test.ts b/cli/scripts/release-guards.test.ts new file mode 100644 index 00000000000..a6fea0f9e45 --- /dev/null +++ b/cli/scripts/release-guards.test.ts @@ -0,0 +1,120 @@ +import { spawnSync } from 'node:child_process' +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vite-plus/test' + +const posix = (p: string) => p.replace(/\\/g, '/') + +const SCRIPTS_DIR = posix(fileURLToPath(new URL('.', import.meta.url))).replace(/\/$/, '') + +const BUILD_SH = 'release-build.sh' + +type Run = { code: number; stderr: string } + +const STUB_BUN = ['#!/bin/sh', 'echo "release-guards: bun must not run" >&2', 'exit 90', ''].join( + '\n', +) + +const FAKE_MANIFEST = { + version: '7.7.7', + difyctl: { + channel: 'stable', + compat: { minDify: '2.0.0', maxDify: '2.5.0' }, + release: { + tagPrefix: 'difyctl-v', + binName: 'difyctl', + checksumsSuffix: '-checksums.txt', + targets: [{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: false }], + }, + }, +} + +function tempDir(prefix: string): string { + return posix(mkdtempSync(join(tmpdir(), prefix))) +} + +function runScript( + script: string, + cliVersion?: string, + extraEnv: Record = {}, +): Run { + const stubDir = tempDir('difyctl-stub-bun-') + writeFileSync(`${stubDir}/bun`, STUB_BUN) + chmodSync(`${stubDir}/bun`, 0o755) + try { + const merged: Record = { + ...process.env, + PATH: `${stubDir}:${process.env.PATH ?? ''}`, + CLI_VERSION: cliVersion, + ...extraEnv, + } + const childEnv: Record = {} + for (const [key, value] of Object.entries(merged)) { + if (value !== undefined) childEnv[key] = value + } + const r = spawnSync('bash', [script], { encoding: 'utf8', env: childEnv }) + return { code: r.status ?? 1, stderr: r.stderr ?? '' } + } finally { + rmSync(stubDir, { recursive: true, force: true }) + } +} + +function fakeCliRoot(scriptName: string): string { + const root = tempDir('difyctl-release-guard-') + mkdirSync(`${root}/scripts/lib`, { recursive: true }) + cpSync(`${SCRIPTS_DIR}/${scriptName}`, `${root}/scripts/${scriptName}`) + cpSync(`${SCRIPTS_DIR}/lib/common.sh`, `${root}/scripts/lib/common.sh`) + cpSync(`${SCRIPTS_DIR}/release-naming.mjs`, `${root}/scripts/release-naming.mjs`) + writeFileSync(`${root}/package.json`, JSON.stringify(FAKE_MANIFEST)) + return root +} + +// Always against a throwaway root: a valid bound carries the script through to +// `rm -rf "$out_dir"`, which against the real cli root deletes a developer's build. +function runInFakeRoot(extraEnv: Record): Run { + const root = fakeCliRoot(BUILD_SH) + try { + return runScript(`${root}/scripts/${BUILD_SH}`, '2.4.0', extraEnv) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +describe.skipIf(process.platform === 'win32')('release-build.sh compat bounds', () => { + for (const bound of ['DIFYCTL_MIN_DIFY', 'DIFYCTL_MAX_DIFY']) { + it.each(['undefined', '1.16'])(`rejects ${bound}=%s`, (bad) => { + const r = runInFakeRoot({ [bound]: bad }) + expect(r.code).not.toBe(0) + expect(r.stderr).toContain(bound) + }) + } + + it('does not wipe dist/bin when a bound guard fires', () => { + const root = fakeCliRoot(BUILD_SH) + const sentinel = `${root}/dist/bin/prior-build` + try { + mkdirSync(`${root}/dist/bin`, { recursive: true }) + writeFileSync(sentinel, 'output from an earlier build') + mkdirSync(`${root}/bin`, { recursive: true }) + writeFileSync(`${root}/bin/run.ts`, 'export {}\n') + + const r = runScript(`${root}/scripts/${BUILD_SH}`, '2.4.0', { + DIFYCTL_MIN_DIFY: 'undefined', + }) + expect(r.code).not.toBe(0) + expect(existsSync(sentinel)).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/cli/scripts/release-naming.mjs b/cli/scripts/release-naming.mjs index d9fcc75c441..2af839b1f2e 100644 --- a/cli/scripts/release-naming.mjs +++ b/cli/scripts/release-naming.mjs @@ -14,7 +14,11 @@ // channels -> one channel name per line // prerelease -> "true" | "false" // github-env -> key=value lines (all fields CI needs) for $GITHUB_ENV -// validate -> exit 1 if difyctl.release, version, or channel is malformed +// edge-version -> -edge. +// validate -> exit 1 if difyctl.release, version, channel, or +// difyctl.compat is malformed +// validate-version +// -> exit 1 unless version matches the channel's form // compat-check -> exit 1 if difyVer outside compat.minDify..maxDify import { readFileSync, realpathSync } from 'node:fs' @@ -22,6 +26,8 @@ import { fileURLToPath } from 'node:url' const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/ const SEMVER_CORE_LEN = 3 +const SEMVER_CORE_RE = /^\d+\.\d+\.\d+$/ +const COMPAT_BOUNDS = ['minDify', 'maxDify'] // Add channels here: { name, prerelease, versionForm }. const CHANNELS = [ @@ -51,7 +57,7 @@ function edgeVersion(sha) { die('edge-version requires a git short sha (7-40 hex chars)') const { version } = loadPkg() const core = versionCore(version) - if (!/^\d+\.\d+\.\d+$/.test(core)) die(`cannot derive edge base from version: ${version}`) + if (!SEMVER_CORE_RE.test(core)) die(`cannot derive edge base from version: ${version}`) return `${core}-edge.${sha}` } @@ -191,6 +197,15 @@ function validateVersionChannel(version, channel) { return problem ? [problem] : [] } +function validateCompat(compat) { + const problems = COMPAT_BOUNDS.filter((b) => !SEMVER_CORE_RE.test(compat[b] ?? '')).map( + (b) => `difyctl.compat.${b} must be a plain X.Y.Z version, found ${compat[b] ?? '(missing)'}`, + ) + if (problems.length === 0 && comparePrecedence(compat.minDify, compat.maxDify) > 0) + problems.push(`difyctl.compat.minDify (${compat.minDify}) is above maxDify (${compat.maxDify})`) + return problems +} + function main(argv) { const [cmd, ...rest] = argv switch (cmd) { @@ -236,11 +251,15 @@ function main(argv) { return String(ch.prerelease) } case 'validate': { - const { version, channel, release } = loadPkg() - const problems = [...validateRelease(release), ...validateVersionChannel(version, channel)] + const { version, channel, compat, release } = loadPkg() + const problems = [ + ...validateRelease(release), + ...validateCompat(compat), + ...validateVersionChannel(version, channel), + ] if (problems.length > 0) die(`invalid difyctl release config:\n - ${problems.join('\n - ')}`) - return `difyctl release valid: version=${version} channel=${channel} targets=${release.targets.length}` + return `difyctl release valid: version=${version} channel=${channel} compat=${compat.minDify}..${compat.maxDify} targets=${release.targets.length}` } case 'edge-version': return edgeVersion(rest[0]) diff --git a/cli/scripts/release-naming.test.ts b/cli/scripts/release-naming.test.ts index 306cf83f809..547fe55f1e4 100644 --- a/cli/scripts/release-naming.test.ts +++ b/cli/scripts/release-naming.test.ts @@ -1,4 +1,6 @@ +import type { PkgManifestOverrides } from '../test/fixtures/pkg-manifest' import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vite-plus/test' import { @@ -6,7 +8,6 @@ import { FIXTURE_COMPAT, FIXTURE_TAG_PREFIX, FIXTURE_VERSION, - FIXTURE_VERSION_CORE, pkgManifestEnv, } from '../test/fixtures/pkg-manifest' @@ -14,11 +15,13 @@ const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url)) const PKG_ENV = pkgManifestEnv() -function run(args: string[]): { code: number; stdout: string; stderr: string } { +type RunResult = { code: number; stdout: string; stderr: string } + +function exec(args: string[], pkgEnv: Record): RunResult { try { const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8', - env: { ...process.env, ...PKG_ENV }, + env: { ...process.env, ...pkgEnv }, }) return { code: 0, stdout, stderr: '' } } catch (e) { @@ -27,6 +30,38 @@ function run(args: string[]): { code: number; stdout: string; stderr: string } { } } +function run(args: string[]): RunResult { + return exec(args, PKG_ENV) +} + +function runWith(overrides: PkgManifestOverrides, args: string[]): RunResult { + return exec(args, pkgManifestEnv(overrides)) +} + +type FixtureManifest = { + version?: string + difyctl: { channel?: string; compat: { minDify?: string; maxDify?: string } } +} + +function runOnManifest(mutate: (manifest: FixtureManifest) => void, args: string[]): RunResult { + const pkgEnv = pkgManifestEnv() + const [pkgPath] = Object.values(pkgEnv) + if (!pkgPath) throw new Error('pkgManifestEnv returned no manifest path') + const manifest = JSON.parse(readFileSync(pkgPath, 'utf8')) as FixtureManifest + mutate(manifest) + writeFileSync(pkgPath, JSON.stringify(manifest)) + return exec(args, pkgEnv) +} + +function parseKeyValues(stdout: string): Record { + return Object.fromEntries( + stdout + .split('\n') + .filter(Boolean) + .map((line) => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)]), + ) +} + describe('release-naming compat-check', () => { const { minDify, maxDify } = FIXTURE_COMPAT // 2.0.0 .. 2.5.0 const compatCheck = (difyVersion?: string) => @@ -64,10 +99,6 @@ describe('release-naming compat-check', () => { expect(compatCheck(`${maxDify}+build123`)).toBe(0) }) - it('ignores build metadata when out of range', () => { - expect(compatCheck('2.5.1+build123')).not.toBe(0) - }) - it('requires a version argument', () => { expect(compatCheck()).not.toBe(0) }) @@ -75,16 +106,11 @@ describe('release-naming compat-check', () => { describe('release-naming github-env', () => { it('emits every manifest field for $GITHUB_ENV, plus a composed difyctlTag', () => { - const fields = Object.fromEntries( - run(['github-env']) - .stdout.split('\n') - .filter(Boolean) - .map((line) => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)]), - ) + const fields = parseKeyValues(run(['github-env']).stdout) expect(fields).toEqual({ version: FIXTURE_VERSION, channel: FIXTURE_CHANNEL, - prerelease: 'true', + prerelease: 'false', minDify: FIXTURE_COMPAT.minDify, maxDify: FIXTURE_COMPAT.maxDify, tagPrefix: FIXTURE_TAG_PREFIX, @@ -94,34 +120,101 @@ describe('release-naming github-env', () => { }) describe('release-naming edge channel', () => { - it('lists edge among channels', () => { - expect(run(['channels']).stdout).toMatch(/^edge$/m) - }) - - it('edge-version derives -edge. from the package version', () => { - expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe( - `${FIXTURE_VERSION_CORE}-edge.2fd7b82`, - ) + it('edge-version derives -edge. from the package version', () => { + expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe(`${FIXTURE_VERSION}-edge.2fd7b82`) }) it('edge-version accepts a 40-char sha', () => { const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000' - expect(run(['edge-version', sha]).stdout.trim()).toBe(`${FIXTURE_VERSION_CORE}-edge.${sha}`) + expect(run(['edge-version', sha]).stdout.trim()).toBe(`${FIXTURE_VERSION}-edge.${sha}`) }) it('edge-version rejects a non-hex sha', () => { expect(run(['edge-version', 'nothex!']).code).not.toBe(0) }) - it('edge-version requires a sha argument', () => { - expect(run(['edge-version']).code).not.toBe(0) - }) - - it('the edge version form matches a computed edge version', () => { - expect(run(['validate-version', '0.1.0-edge.2fd7b82', 'edge']).code).toBe(0) + it('edge-version fails when the manifest carries no version', () => { + const { code, stderr } = runOnManifest( + (m) => { + delete m.version + }, + ['edge-version', '2fd7b82'], + ) + expect(code).not.toBe(0) + expect(stderr).toContain('cannot derive edge base from version') }) it('validate-version rejects an rc string under the edge channel', () => { expect(run(['validate-version', '0.1.0-rc.1', 'edge']).code).not.toBe(0) }) }) + +describe('release-naming validate channel', () => { + const validateChannel = (channel: string) => runWith({ channel }, ['validate']) + + it.each<[string, string]>([ + ['stable', FIXTURE_VERSION], + ['alpha', `${FIXTURE_VERSION}-alpha`], + ['rc', `${FIXTURE_VERSION}-rc.1`], + ['edge', `${FIXTURE_VERSION}-edge.2fd7b82`], + ])('accepts the %s channel with a version in its form', (channel, version) => { + expect(runWith({ channel, version }, ['validate']).code).toBe(0) + }) + + it('rejects a typo of a real channel and names it', () => { + const { code, stderr } = validateChannel('stabel') + expect(code).not.toBe(0) + expect(stderr).toContain('unknown channel: stabel') + }) + + it('rejects a manifest with no channel at all', () => { + const { code, stderr } = runOnManifest( + (m) => { + delete m.difyctl.channel + }, + ['validate'], + ) + expect(code).not.toBe(0) + expect(stderr).toContain('unknown channel') + }) +}) + +describe('release-naming validate compat bounds', () => { + const validateCompat = (minDify: string, maxDify: string) => + runWith({ compat: { minDify, maxDify } }, ['validate']) + + it('accepts a well-formed window', () => { + expect(validateCompat('1.16.0', '1.17.0').code).toBe(0) + }) + + it('accepts equal bounds', () => { + expect(validateCompat('1.17.0', '1.17.0').code).toBe(0) + }) + + it('rejects an inverted window', () => { + const { code, stderr } = validateCompat('1.18.0', '1.17.0') + expect(code).not.toBe(0) + expect(stderr).toContain('is above maxDify') + }) + + it.each(['1.x', '1.16', '1.16.0-rc1', ''])('rejects %s as a bound', (bad) => { + expect(validateCompat(bad, '2.9.0').code).not.toBe(0) + expect(validateCompat('1.0.0', bad).code).not.toBe(0) + }) + + it('names the offending bound', () => { + expect(validateCompat('1.x', '1.17.0').stderr).toContain('difyctl.compat.minDify') + expect(validateCompat('1.16.0', '1.x').stderr).toContain('difyctl.compat.maxDify') + }) +}) + +describe('release-naming validate-version', () => { + it('accepts build metadata on a stable version', () => { + expect(run(['validate-version', '1.16.1+r2', 'stable']).code).toBe(0) + }) + + it('accepts an alpha version under the alpha channel, with or without a counter', () => { + expect(run(['validate-version', '1.16.1-alpha', 'alpha']).code).toBe(0) + expect(run(['validate-version', '1.16.1-alpha.2', 'alpha']).code).toBe(0) + }) +}) diff --git a/cli/scripts/release-validate-manifest.sh b/cli/scripts/release-validate-manifest.sh deleted file mode 100755 index 61f5325080b..00000000000 --- a/cli/scripts/release-validate-manifest.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -# scripts/release-validate-manifest.sh — validate cli/package.json release fields. - -set -euo pipefail - -_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=lib/common.sh -source "${_dir}/lib/common.sh" - -cd "$(cli::root)" - -SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' - -version=$(node -p "require('./package.json').version") -channel=$(node -p "require('./package.json').difyctl.channel") -min_dify=$(node -p "require('./package.json').difyctl.compat.minDify") -max_dify=$(node -p "require('./package.json').difyctl.compat.maxDify") - -# Version form (per channel) and channel validity are enforced by -# release-naming.mjs validate below — the single source for those rules. - -[[ "$min_dify" =~ $SEMVER_RE ]] || die "invalid difyctl.compat.minDify: ${min_dify}" -[[ "$max_dify" =~ $SEMVER_RE ]] || die "invalid difyctl.compat.maxDify: ${max_dify}" - -case "$min_dify" in *[xX*]*) die "wildcards not allowed in minDify: ${min_dify}" ;; esac -case "$max_dify" in *[xX*]*) die "wildcards not allowed in maxDify: ${max_dify}" ;; esac - -cmp=$(node -e " -const a = process.argv[1].split('-')[0].split('.').map(Number) -const b = process.argv[2].split('-')[0].split('.').map(Number) -for (let i = 0; i < 3; i++) { - if (a[i] !== b[i]) { console.log(a[i] < b[i] ? -1 : 1); process.exit(0) } -} -console.log(0) -" "$min_dify" "$max_dify") - -[[ "$cmp" -le 0 ]] || die "minDify (${min_dify}) > maxDify (${max_dify})" - -node "${_dir}/release-naming.mjs" validate >/dev/null - -log::info "manifest valid: version=${version} channel=${channel} compat=${min_dify}..${max_dify}" diff --git a/cli/scripts/release-write-checksums.sh b/cli/scripts/release-write-checksums.sh index b9e1cf6960e..1c7301a6125 100755 --- a/cli/scripts/release-write-checksums.sh +++ b/cli/scripts/release-write-checksums.sh @@ -19,7 +19,7 @@ cd "$(cli::root)/dist/bin" manifest="$(naming checksums "$CLI_VERSION")" asset_prefix="$(naming tag-prefix)${CLI_VERSION}-" -> "$manifest" +: > "$manifest" if command -v sha256sum >/dev/null 2>&1; then hash_cmd="sha256sum" diff --git a/cli/src/version/render.test.ts b/cli/src/version/render.test.ts index 46361bf85b0..4672bc4c7e1 100644 --- a/cli/src/version/render.test.ts +++ b/cli/src/version/render.test.ts @@ -69,18 +69,6 @@ describe('renderVersionText', () => { expect(text).toContain('install or wait for the stable channel') }) - it('appends warning when channel is alpha', () => { - const report: VersionReport = { - client: baseClient({ channel: 'alpha' }), - server: { endpoint: '', reachable: false }, - compat: { ...compatible(), status: 'unknown', detail: 'server probe skipped' }, - } - const text = renderVersionText(report) - - expect(text).toContain('WARNING: This build is a(n) alpha release') - expect(text).toContain('install or wait for the stable channel') - }) - it('appends warning when channel is edge', () => { const report: VersionReport = { client: baseClient({ channel: 'edge' }), diff --git a/cli/test/fixtures/pkg-manifest.ts b/cli/test/fixtures/pkg-manifest.ts index 7e65f1ae1bb..dd99cb6f02b 100644 --- a/cli/test/fixtures/pkg-manifest.ts +++ b/cli/test/fixtures/pkg-manifest.ts @@ -10,15 +10,14 @@ const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH' // release-naming.mjs and release-r2-edge.mjs read their data from // cli/package.json. Tests spawn them against this fixture instead, so -// assertions can name exact versions without tracking the live release. +// assertions can name exact windows without tracking the live release. // Deliberately far from any real Dify version, and min != max so "inside the // window" is a case distinct from either bound. export const FIXTURE_COMPAT = { minDify: '2.0.0', maxDify: '2.5.0' } -export const FIXTURE_VERSION_CORE = '7.7.7' -export const FIXTURE_VERSION = `${FIXTURE_VERSION_CORE}-alpha` -export const FIXTURE_CHANNEL = 'alpha' +export const FIXTURE_VERSION = '7.7.7' +export const FIXTURE_CHANNEL = 'stable' export const FIXTURE_TAG_PREFIX = 'difyctl-v' export const FIXTURE_TARGET_IDS = [ diff --git a/cli/test/scripts/resolve-buildinfo.test.ts b/cli/test/scripts/resolve-buildinfo.test.ts index 17e07a02cca..e80f16ffdb3 100644 --- a/cli/test/scripts/resolve-buildinfo.test.ts +++ b/cli/test/scripts/resolve-buildinfo.test.ts @@ -1,5 +1,15 @@ -import { describe, expect, it } from 'vite-plus/test' -import { resolveBuildInfo } from '../../scripts/lib/resolve-buildinfo.js' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, describe, expect, it } from 'vite-plus/test' +import { BUILD_CHANNELS, resolveBuildInfo } from '../../scripts/lib/resolve-buildinfo.js' +import { ENV_CACHE_DIR, ENV_CONFIG_DIR } from '../../src/store/dir.js' + +const CLI_ROOT = new URL('../../', import.meta.url) +const RELEASE_NAMING = fileURLToPath(new URL('scripts/release-naming.mjs', CLI_ROOT)) +const DEV_ENTRY = fileURLToPath(new URL('bin/dev.js', CLI_ROOT)) const FIXED_DATE = new Date('2026-05-09T12:00:00.000Z') const fixedNow = () => FIXED_DATE @@ -80,6 +90,16 @@ describe('resolveBuildInfo', () => { ).toThrow(/invalid DIFYCTL_CHANNEL: nightly/) }) + it('accepts alpha channel', () => { + const info = resolveBuildInfo({ + env: { DIFYCTL_CHANNEL: 'alpha' }, + git: noGit, + now: fixedNow, + pkg: noPkg, + }) + expect(info.channel).toBe('alpha') + }) + it('accepts rc channel', () => { const info = resolveBuildInfo({ env: { @@ -161,3 +181,50 @@ describe('resolveBuildInfo', () => { expect(info.channel).toBe('stable') }) }) + +function releaseNamingChannels(): string[] { + return execFileSync('node', [RELEASE_NAMING, 'channels'], { encoding: 'utf8' }) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) +} + +const sorted = (names: readonly string[]) => [...names].sort() + +describe('channel list parity', () => { + const LOCAL_ONLY_CHANNEL = 'dev' + + it('released channels are the build channels minus the local-only one', () => { + expect(sorted(releaseNamingChannels())).toStrictEqual( + sorted(BUILD_CHANNELS.filter((name) => name !== LOCAL_ONLY_CHANNEL)), + ) + }) +}) + +type ClientVersionReport = { client: { channel: string } } + +describe('bin/dev.js pins the local build channel', () => { + const ENV_CHANNEL = 'DIFYCTL_CHANNEL' + const stateDir = mkdtempSync(join(tmpdir(), 'difyctl-dev-channel-')) + afterAll(() => rmSync(stateDir, { recursive: true, force: true })) + + function reportedChannel(channelOverride?: string): string { + const env: NodeJS.ProcessEnv = { + ...process.env, + [ENV_CONFIG_DIR]: stateDir, + [ENV_CACHE_DIR]: stateDir, + } + if (channelOverride === undefined) delete env[ENV_CHANNEL] + else env[ENV_CHANNEL] = channelOverride + const stdout = execFileSync('bun', [DEV_ENTRY, 'version', '--client', '--output', 'json'], { + cwd: fileURLToPath(CLI_ROOT), + encoding: 'utf8', + env, + }) + return (JSON.parse(stdout) as ClientVersionReport).client.channel + } + + it('reports dev when the env does not set a channel', { timeout: 30_000 }, () => { + expect(reportedChannel()).toBe('dev') + }) +}) diff --git a/cli/vite.config.ts b/cli/vite.config.ts index 28ba19f5689..85b0e264cbe 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -2,7 +2,9 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite-plus' import { resolveBuildInfo } from './scripts/lib/resolve-buildinfo.js' -const buildInfo = resolveBuildInfo() +const buildInfo = resolveBuildInfo({ + env: { ...process.env, DIFYCTL_CHANNEL: process.env.DIFYCTL_CHANNEL ?? 'dev' }, +}) export default defineConfig({ resolve: { From 4c414988eec366d5bab03cf4e84314672b035047 Mon Sep 17 00:00:00 2001 From: Joel Date: Fri, 28 Aug 2026 07:55:59 +0000 Subject: [PATCH 02/52] fix: improve accessibility across the apps list (#41393) Co-authored-by: yyh --- .../apps/duplicate-app.steps.ts | 7 +- oxlint-suppressions.json | 8 - .../apps/__tests__/app-card.spec.tsx | 30 +- .../apps/__tests__/creators-filter.spec.tsx | 131 +++++-- ...t-from-marketplace-template-modal.spec.tsx | 57 ++- .../components/apps/__tests__/list.spec.tsx | 70 +++- .../apps/app-card/action-bar/index.tsx | 8 +- web/app/components/apps/app-card/index.tsx | 4 +- web/app/components/apps/app-list-catalog.tsx | 23 +- web/app/components/apps/app-sort-filter.tsx | 2 +- web/app/components/apps/creators-filter.tsx | 353 +++++++++--------- ...import-from-marketplace-template-modal.tsx | 31 +- web/app/components/apps/starred-app-card.tsx | 4 +- web/app/components/apps/starred-app-list.tsx | 21 +- web/app/components/base/app-icon/index.tsx | 6 +- .../main-nav/__tests__/index.spec.tsx | 51 +++ .../main-nav/components/web-apps-section.tsx | 17 +- .../snippet-list/__tests__/index.spec.tsx | 7 +- .../__tests__/tag-selector.spec.tsx | 6 + .../components/app-card-tags.tsx | 3 + .../components/tag-selector.tsx | 7 +- 21 files changed, 570 insertions(+), 276 deletions(-) diff --git a/e2e/features/step-definitions/apps/duplicate-app.steps.ts b/e2e/features/step-definitions/apps/duplicate-app.steps.ts index 707fdf13d8c..71bdf8bf0af 100644 --- a/e2e/features/step-definitions/apps/duplicate-app.steps.ts +++ b/e2e/features/step-definitions/apps/duplicate-app.steps.ts @@ -20,10 +20,13 @@ When('I open the options menu for the last created E2E app', async function (thi const page = this.getPage() await waitForAppsConsole(page, 30_000) const studio = page.getByRole('region', { name: 'Studio' }) - const appLink = studio.getByRole('link', { name: appName, exact: true }) + const appCard = studio.getByRole('listitem').filter({ + has: page.getByRole('link', { name: appName, exact: true }), + }) + const appLink = appCard.getByRole('link', { name: appName, exact: true }) await expect(appLink).toBeVisible() await appLink.hover() - await studio.getByRole('button', { name: `More actions for ${appName}`, exact: true }).click() + await appCard.getByRole('button', { name: `More actions for ${appName}`, exact: true }).click() }) When('I click {string} in the app options menu', async function (this: DifyWorld, label: string) { diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index dfbc8b6c8e8..9cb1ae7a569 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -533,14 +533,6 @@ "count": 2 } }, - "web/app/components/apps/import-from-marketplace-template-modal.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/base/agent-log-modal/detail.tsx": { "typescript/no-explicit-any": { "count": 1 diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index af53580c464..84a880c6804 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -386,15 +386,18 @@ vi.mock('@/features/tag-management/components/app-card-tags', () => ({ AppCardTags: ({ tags, canBindOrUnbindTags, + appName, }: { tags?: { id: string; name: string }[] canBindOrUnbindTags?: boolean + appName: string }) => { return React.createElement( 'div', { 'aria-label': 'tag-selector', 'data-can-bind-or-unbind-tags': String(Boolean(canBindOrUnbindTags)), + 'data-app-name': appName, }, tags?.map((tag: { id: string; name: string }) => React.createElement('span', { key: tag.id }, tag.name), @@ -472,6 +475,8 @@ describe('AppCard', () => { const card = screen.getByRole('button', { name: 'Preview Only App' }) expect(card).toHaveClass('opacity-60') + expect(screen.getByRole('listitem')).toContainElement(card) + expect(card).toHaveAccessibleDescription('Only visible metadata') expect(card).not.toHaveAttribute('aria-disabled') expect(screen.getByText('Only visible metadata')).toBeInTheDocument() expect(screen.getByText('Readonly Author')).toBeInTheDocument() @@ -552,6 +557,17 @@ describe('AppCard', () => { const emojiIcon = container.querySelector(`em-emoji[id="${mockApp.icon}"]`) const imageIcon = container.querySelector('img') expect(emojiIcon || imageIcon).toBeTruthy() + expect(emojiIcon?.parentElement).toHaveAttribute('aria-hidden', 'true') + }) + + it('should treat a redundant image icon as decorative', () => { + const imageApp = createMockApp({ + icon_type: 'image', + icon_url: 'https://example.com/app-icon.png', + }) + const { container } = render() + + expect(container.querySelector('img')).toHaveAttribute('alt', '') }) it('should render app type icon', () => { @@ -579,7 +595,7 @@ describe('AppCard', () => { } render() // Verify the tag selector component renders - expect(screen.getByLabelText('tag-selector')).toBeInTheDocument() + expect(screen.getByLabelText('tag-selector')).toHaveAttribute('data-app-name', 'Test App') }) it('should display refreshed tag names from app props when tag ids stay the same', () => { @@ -670,6 +686,10 @@ describe('AppCard', () => { const cardLink = screen.getByRole('link', { name: 'Test App' }) expect(cardLink).toHaveAttribute('href', '/app/test-app-id/configuration') + expect(cardLink).toHaveAccessibleName('Test App') + expect(cardLink).toHaveAccessibleDescription('Test app description') + expect(cardLink).toHaveAttribute('aria-describedby') + expect(screen.getByRole('listitem')).toContainElement(cardLink) }) it('should expose a visible focus ring on the card link', () => { @@ -684,7 +704,7 @@ describe('AppCard', () => { const user = userEvent.setup() render() - const starToggle = screen.getByRole('button', { name: 'app.studio.starApp' }) + const starToggle = screen.getByRole('button', { name: 'app.studio.starApp: Test App' }) expect(starToggle).toHaveAttribute('aria-pressed', 'false') await user.click(starToggle) @@ -702,9 +722,13 @@ describe('AppCard', () => { const starredApp = createMockApp({ is_starred: true }) render() - const starToggle = screen.getByRole('button', { name: 'app.studio.starApp' }) + const starToggle = screen.getByRole('button', { name: 'app.studio.starApp: Test App' }) expect(starToggle).toHaveAttribute('aria-pressed', 'true') + await user.hover(starToggle) + + expect(await screen.findByText('app.studio.starApp')).toBeInTheDocument() + await user.click(starToggle) await waitFor(() => { diff --git a/web/app/components/apps/__tests__/creators-filter.spec.tsx b/web/app/components/apps/__tests__/creators-filter.spec.tsx index 9d905f39e38..98849d51f5f 100644 --- a/web/app/components/apps/__tests__/creators-filter.spec.tsx +++ b/web/app/components/apps/__tests__/creators-filter.spec.tsx @@ -1,4 +1,6 @@ -import { fireEvent, screen, within } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { render as renderWithConsoleState } from '@/test/console/render' import CreatorsFilter from '../creators-filter' @@ -10,6 +12,11 @@ const render = (ui: Parameters[0]) => wrapper: createConsoleQueryWrapper({ accountProfile: { id: 'member-2' } }).wrapper, }) +const StatefulCreatorsFilter = ({ initialValue }: { initialValue: string[] }) => { + const [value, setValue] = useState(initialValue) + return +} + vi.mock('@/service/use-common', () => ({ useMembers: () => ({ data: { @@ -28,16 +35,13 @@ describe('CreatorsFilter', () => { vi.clearAllMocks() }) - it('should sort the current user first and filter out pending members', () => { + it('should sort the current user first and filter out pending members', async () => { + const user = userEvent.setup() render() - fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.creators/i })) + await user.click(screen.getByRole('combobox', { name: 'app.studio.filters.creators' })) - const options = screen - .getAllByRole('button') - .filter((button) => - ['Alice', 'Bob', 'Zoe'].some((name) => button.textContent?.includes(name)), - ) + const options = screen.getAllByRole('option') expect(options.map((option) => option.textContent)).toEqual([ expect.stringContaining('Alice'), @@ -48,52 +52,111 @@ describe('CreatorsFilter', () => { expect(screen.queryByText('Pending User')).not.toBeInTheDocument() }) - it('should search creators, clear keywords, and select a creator', () => { + it('should search creators, clear keywords, and select a creator', async () => { + const user = userEvent.setup() render() - fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.creators/i })) - fireEvent.change(screen.getByPlaceholderText('app.studio.filters.searchCreators'), { - target: { value: 'zo' }, + await user.click(screen.getByRole('combobox', { name: 'app.studio.filters.creators' })) + const searchInput = screen.getByRole('combobox', { + name: 'app.studio.filters.searchCreators', + }) + await user.type(searchInput, 'zo') + + const zoeOption = screen.getByRole('option', { name: /Zoe/ }) + expect(zoeOption).toBeInTheDocument() + expect(screen.queryByRole('option', { name: /Bob/ })).not.toBeInTheDocument() + await waitFor(() => { + expect(searchInput).toHaveAttribute('aria-activedescendant', zoeOption.id) }) - expect(screen.getByRole('button', { name: /Zoe/ })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Bob/ })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) - - const searchInput = screen.getByPlaceholderText('app.studio.filters.searchCreators') expect(searchInput).toHaveValue('') expect(searchInput).toHaveFocus() - fireEvent.click(screen.getByRole('button', { name: /Bob/ })) + await user.click(screen.getByRole('option', { name: /Bob/ })) expect(mockOnChange).toHaveBeenCalledWith(['member-3']) }) - it('should remove selected creators from the trigger reset and menu reset controls', () => { - const { rerender } = render( - , + it('should clear only the search query from the input action', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('combobox', { name: 'app.studio.filters.creators' })) + const searchInput = screen.getByRole('combobox', { + name: 'app.studio.filters.searchCreators', + }) + await user.type(searchInput, 'zo') + await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) + + expect(searchInput).toHaveValue('') + expect(searchInput).toHaveFocus() + expect(screen.getByRole('option', { name: /Alice/ })).toHaveAttribute('aria-selected', 'true') + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('should return focus to the trigger after clearing creators from the filter chip', async () => { + const user = userEvent.setup() + render() + + const trigger = screen.getByRole('combobox', { name: 'app.studio.filters.creators' }) + const triggerReset = screen.getByRole('button', { name: 'app.studio.filters.reset' }) + + expect(trigger).not.toContainElement(triggerReset) + + await user.click(triggerReset) + + expect(trigger).toHaveFocus() + expect( + screen.queryByRole('button', { name: 'app.studio.filters.reset' }), + ).not.toBeInTheDocument() + }) + + it('should preserve unavailable creator ids when removing an available creator', async () => { + const user = userEvent.setup() + render( + , ) - const trigger = screen.getByRole('button', { name: /app\.studio\.filters\.creators/i }) - fireEvent.click(within(trigger).getByRole('button', { name: 'app.studio.filters.reset' })) + await user.click(screen.getByRole('combobox', { name: 'app.studio.filters.creators' })) + const aliceOption = screen.getByRole('option', { name: /Alice/ }) + expect(aliceOption).toHaveAttribute('aria-selected', 'true') - expect(mockOnChange).toHaveBeenCalledWith([]) + await user.click(aliceOption) - rerender() - - fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.creators/i })) - fireEvent.click(screen.getAllByRole('button', { name: 'app.studio.filters.reset' }).at(-1)!) - - expect(mockOnChange).toHaveBeenCalledWith([]) + expect(mockOnChange).toHaveBeenCalledWith(['missing-member', 'member-3']) }) - it('should remove a selected creator when toggled from the menu', () => { + it('should expose the selected creator count from the closed trigger', () => { render() - fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.creators/i })) - fireEvent.click(screen.getByRole('button', { name: /Alice/ })) + const trigger = screen.getByRole('combobox', { name: 'app.studio.filters.creators' }) + const selectedCount = within(trigger).getByText('common.dynamicSelect.selected:{"count":2}') + expect(selectedCount).toHaveClass('sr-only') + expect(within(trigger).getByText('+2').parentElement).toHaveAttribute('aria-hidden', 'true') + }) - expect(mockOnChange).toHaveBeenCalledWith(['member-3']) + it('should expose the creator picker as a named combobox with keyboard-owned options', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('combobox', { name: 'app.studio.filters.creators' })) + + const popup = screen.getByRole('dialog', { name: 'app.studio.filters.creators' }) + const searchInput = within(popup).getByRole('combobox', { + name: 'app.studio.filters.searchCreators', + }) + expect(popup).toBeInTheDocument() + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + expect(within(popup).getByRole('option', { name: /Alice/ })).toHaveAttribute( + 'aria-selected', + 'false', + ) + + await waitFor(() => expect(searchInput).toHaveFocus()) + await user.keyboard('{ArrowDown}{Enter}') + + expect(mockOnChange).toHaveBeenCalledWith(['member-2']) }) }) diff --git a/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx b/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx index 3d02be678f3..865e6bbf4d3 100644 --- a/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx +++ b/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import ImportFromMarketplaceTemplateModal from '../import-from-marketplace-template-modal' const mockUseMarketplaceTemplateDetail = vi.fn() @@ -43,5 +43,60 @@ describe('ImportFromMarketplaceTemplateModal', () => { expect(screen.getByText('Human Input: Writing Assistant')).toBeInTheDocument() expect(screen.queryByText('technologist')).not.toBeInTheDocument() + expect(document.querySelector('em-emoji')?.parentElement).toHaveAttribute('aria-hidden', 'true') + expect( + screen.getByRole('dialog', { name: /marketplace\.template\.modalTitle/ }), + ).toBeInTheDocument() + }) + + it('exposes a named close control', () => { + const onClose = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /operation\.close/ })) + + expect(onClose).toHaveBeenCalledOnce() + }) + + it('exposes loading progress and announces the error state', () => { + mockUseMarketplaceTemplateDetail.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }) + const { rerender } = render( + , + ) + + expect(screen.getByText('common.loading').parentElement?.parentElement).toHaveAttribute( + 'aria-busy', + 'true', + ) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + + mockUseMarketplaceTemplateDetail.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }) + rerender( + , + ) + + expect(screen.getByRole('alert')).toHaveTextContent('app.marketplace.template.fetchFailed') }) }) diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 79816ab08a8..9ef5a5cdd8b 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -439,7 +439,6 @@ vi.mock('../empty', () => ({ { 'data-testid': 'empty-state', 'data-step-by-step-tour-target': stepByStepTourTarget, - role: 'status', }, 'No apps found', ) @@ -626,7 +625,7 @@ describe('List', () => { it('should render filters and search before the right aligned actions', () => { renderList() - const creatorsButton = screen.getByRole('button', { name: 'Creators' }) + const creatorsButton = screen.getByRole('combobox', { name: 'Creators' }) const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications', }) @@ -635,6 +634,7 @@ describe('List', () => { const createButton = screen.getByRole('button', { name: 'common.operation.create' }) expect(snippetsLink).toHaveAttribute('href', '/snippets') + expect(sortButton).toHaveTextContent('Sort by Last modified') expect( creatorsButton.compareDocumentPosition(sortButton) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() @@ -689,14 +689,15 @@ describe('List', () => { renderList() - const starredLabel = screen.getByText('Starred') - const starredCard = screen.getByRole('link', { name: /Starred App/ }) - const allAppsLabel = screen.getByText('All Apps') + const starredLabel = screen.getByRole('heading', { level: 2, name: 'Starred' }) + const starredCard = screen.getByRole('link', { name: 'Starred App' }) + const allAppsLabel = screen.getByRole('heading', { level: 2, name: 'All Apps' }) const firstAppCard = screen.getByTestId('app-card-app-1') const actionBar = screen.getByRole('button', { name: 'Actions for Starred App' }) expect(starredCard).toBeInTheDocument() expect(actionBar).toBeInTheDocument() + expect(screen.getAllByRole('list')).toHaveLength(2) expect( starredLabel.compareDocumentPosition(starredCard) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() @@ -739,7 +740,7 @@ describe('List', () => { const firstWorkspaceCard = screen.getByTestId('app-card-app-1') const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1') - const starredCard = screen.getByRole('link', { name: /Starred App/ }) + const starredCard = screen.getByRole('link', { name: 'Starred App' }) const starredActionBar = screen.getByRole('button', { name: 'Actions for Starred App', }) @@ -791,7 +792,7 @@ describe('List', () => { renderList() - const starredCard = screen.getByRole('link', { name: /Starred App/ }) + const starredCard = screen.getByRole('link', { name: 'Starred App' }) const firstWorkspaceCard = screen.getByTestId('app-card-app-1') const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1') @@ -862,6 +863,9 @@ describe('List', () => { it('should render drop DSL hint when app creation permission is available', () => { renderList() expect(screen.getByText('app.newApp.dropDSLToCreateApp'))!.toBeInTheDocument() + expect( + screen.queryByRole('region', { name: 'app.newApp.dropDSLToCreateApp' }), + ).not.toBeInTheDocument() }) it('should render first empty state when there are no apps and no active filters', () => { @@ -933,11 +937,35 @@ describe('List', () => { renderList('?keywords=missing+app') expect(screen.getByTestId('empty-state'))!.toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('app.filterEmpty.noApps') + expect(screen.getByRole('status')).toHaveClass('sr-only') + expect(screen.getByTestId('empty-state').parentElement).toHaveAttribute('aria-busy', 'false') expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument() expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument() expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument() }) + it('should keep the result status quiet while placeholder results are fetching', () => { + mockAppData = { pages: [{ data: [], total: 0 }] } + mockServiceState.isFetching = true + mockServiceState.isPlaceholderData = true + + renderList('?keywords=missing+app') + + expect(screen.getByRole('status')).toBeEmptyDOMElement() + expect(screen.getByTestId('empty-state').parentElement).toHaveAttribute('aria-busy', 'true') + }) + + it('should keep the settled empty status during a background refetch', () => { + mockAppData = { pages: [{ data: [], total: 0 }] } + mockServiceState.isFetching = true + + renderList('?keywords=missing+app') + + expect(screen.getByRole('status')).toHaveTextContent('app.filterEmpty.noApps') + expect(screen.getByTestId('empty-state').parentElement).toHaveAttribute('aria-busy', 'true') + }) + it('should leave the first empty state as soon as a filter changes', () => { mockAppData = { pages: [{ data: [], total: 0 }] } renderList() @@ -1073,11 +1101,12 @@ describe('List', () => { expect(scrollTo).toHaveBeenCalledWith({ top: 0 }) }) - it('should build paged query input from active filters', () => { + it('should build paged query input from active filters', async () => { + const user = userEvent.setup() renderList('?keywords=sales&category=workflow') - fireEvent.click(screen.getByRole('button', { name: 'Creators' })) - fireEvent.click(screen.getByText('Alice')) - fireEvent.click(screen.getByText('common.tag.placeholder')) + await user.click(screen.getByRole('combobox', { name: 'Creators' })) + await user.click(screen.getByRole('option', { name: /Alice/ })) + await user.click(screen.getByText('common.tag.placeholder')) const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions @@ -1096,11 +1125,12 @@ describe('List', () => { expect(options.getNextPageParam({ has_more: false, page: 2 })).toBeUndefined() }) - it('should build starred query input from active filters with the starred limit', () => { + it('should build starred query input from active filters with the starred limit', async () => { + const user = userEvent.setup() renderList('?keywords=sales&category=workflow') - fireEvent.click(screen.getByRole('button', { name: 'Creators' })) - fireEvent.click(screen.getByText('Alice')) - fireEvent.click(screen.getByText('common.tag.placeholder')) + await user.click(screen.getByRole('combobox', { name: 'Creators' })) + await user.click(screen.getByRole('option', { name: /Alice/ })) + await user.click(screen.getByText('common.tag.placeholder')) const options = mockAppStarredListQueryOptions.mock.calls.at( -1, @@ -1140,13 +1170,15 @@ describe('List', () => { }) describe('Creators Filter', () => { - it('should handle creator selection', () => { + it('should handle creator selection', async () => { + const user = userEvent.setup() renderList() - fireEvent.click(screen.getByRole('button', { name: 'Creators' })) - fireEvent.click(screen.getByRole('button', { name: /Bob/ })) + const trigger = screen.getByRole('combobox', { name: 'Creators' }) + await user.click(trigger) + await user.click(screen.getByRole('option', { name: /Bob/ })) - expect(screen.getByRole('button', { name: /Creators.*\+1/ })).toBeInTheDocument() + expect(trigger).toHaveTextContent('+1') }) }) diff --git a/web/app/components/apps/app-card/action-bar/index.tsx b/web/app/components/apps/app-card/action-bar/index.tsx index 0f50501b552..5d46c6b36b1 100644 --- a/web/app/components/apps/app-card/action-bar/index.tsx +++ b/web/app/components/apps/app-card/action-bar/index.tsx @@ -357,10 +357,8 @@ export const AppCardActionBar = memo( shouldShowSwitchOption || shouldShowAccessConfigOption || shouldShowDeleteOption - const starActionLabel = app.is_starred - ? t(($) => $['studio.unstarApp'], { ns: 'app' }) - : t(($) => $['studio.starApp'], { ns: 'app' }) const starToggleLabel = t(($) => $['studio.starApp'], { ns: 'app' }) + const starToggleAccessibleLabel = `${starToggleLabel}: ${app.name}` return ( <> @@ -383,7 +381,7 @@ export const AppCardActionBar = memo( render={ } /> - {starActionLabel} + {starToggleLabel} {shouldShowOperationsMenu && ( +
{isPreviewOnly ? ( - )} -
- {isSelected && ( - + + multiple + autoHighlight + items={creatorOptions} + value={selectedCreatorValues} + inputValue={keywords} + isItemEqualToValue={(creator, selectedCreator) => creator.id === selectedCreator.id} + itemToStringLabel={(creator) => creator.name} + itemToStringValue={(creator) => creator.id} + onInputValueChange={setKeywords} + onValueChange={handleValueChange} + > +
+ -
- {filteredCreators.map((creator) => { - const checked = value.includes(creator.id) - - return ( - - ) - })} -
- - + > + > + + + {creatorFilterLabel} + + {selectedCount > 0 ? ( + <> + + {selectedAvatarCreators.map((creator, index) => ( + 0 && '-ml-1')} + /> + ))} + + {`+${selectedCount}`} + + ) : ( + + )} + + {selectedCountLabel} + +
+ {selectedCount > 0 && ( + + + + )} +
+ + + $['studio.filters.creators'], { ns: 'app' })} + className="w-[min(280px,var(--available-width))] min-w-[min(var(--anchor-width),var(--available-width))] bg-components-panel-bg-blur text-sm text-text-secondary backdrop-blur-[5px]" + > +
+ + $['studio.filters.searchCreators'], { ns: 'app' })} + className="block h-4.5 grow px-1 py-0 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none" + placeholder={t(($) => $['studio.filters.searchCreators'], { ns: 'app' })} + /> + + {!!keywords && ( + $['operation.clear'], { ns: 'common' })} + className="me-0 shrink-0 text-text-quaternary hover:bg-transparent hover:text-text-tertiary focus-visible:bg-components-input-bg-hover focus-visible:ring-inset" + onClick={clearCreatorQuery} + > + + + )} + +
+ className="max-h-60 px-1 pt-0 pb-1"> + {(creator) => ( + + + + + + + + + + {creator.name} + {creator.isYou && ( + + {t(($) => $['studio.filters.you'], { ns: 'app' })} + + )} + + + + )} + +
+
+
+ ) } diff --git a/web/app/components/apps/import-from-marketplace-template-modal.tsx b/web/app/components/apps/import-from-marketplace-template-modal.tsx index 2891d12e620..c28a24b8230 100644 --- a/web/app/components/apps/import-from-marketplace-template-modal.tsx +++ b/web/app/components/apps/import-from-marketplace-template-modal.tsx @@ -1,7 +1,7 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' -import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' import { useCallback, useMemo, useRef, useState } from 'react' @@ -73,22 +73,34 @@ const ImportFromMarketplaceTemplateModal = ({ }} > -
- {t(($) => $['marketplace.template.modalTitle'], { ns: 'app' })} -
- -
+
+ + {t(($) => $['marketplace.template.modalTitle'], { ns: 'app' })} + + $['operation.close'], { ns: 'common' })} + className="flex size-8 cursor-pointer items-center border-none bg-transparent p-0 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + + + } + />
-
+
{isLoading && (
-
Loading...
+
+ {t(($) => $.loading, { ns: 'common' })} +
)} {isError && ( -
+
{t(($) => $['marketplace.template.fetchFailed'], { ns: 'app' })}
@@ -103,6 +115,7 @@ const ImportFromMarketplaceTemplateModal = ({ iconType={template.icon_file_key ? 'image' : 'emoji'} icon={template.icon || 'page_facing_up'} background={template.icon_file_key ? undefined : template.icon_background} + decorative imageUrl={ template.icon_file_key ? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon` diff --git a/web/app/components/apps/starred-app-card.tsx b/web/app/components/apps/starred-app-card.tsx index 5d3e7da6ae6..d6c806d20c8 100644 --- a/web/app/components/apps/starred-app-card.tsx +++ b/web/app/components/apps/starred-app-card.tsx @@ -73,6 +73,7 @@ export const StarredAppCard = memo( icon={app.icon ?? undefined} background={app.icon_background} imageUrl={app.icon_url} + decorative /> +
{isPreviewOnly ? (
) @@ -34,8 +39,15 @@ export function StarredAppList({ return ( <> - $['studio.starred'], { ns: 'app' })} /> -
+ $['studio.starred'], { ns: 'app' })} + /> +
{apps.map((app, index) => ( ))}
- $['studio.allApps'], { ns: 'app' })} /> ) } diff --git a/web/app/components/base/app-icon/index.tsx b/web/app/components/base/app-icon/index.tsx index 8fe2cb12896..8cae0adfa16 100644 --- a/web/app/components/base/app-icon/index.tsx +++ b/web/app/components/base/app-icon/index.tsx @@ -28,6 +28,7 @@ type AppIconProps = { icon?: string background?: string | null imageUrl?: string | null + decorative?: boolean className?: string innerIcon?: React.ReactNode coverElement?: React.ReactNode @@ -103,6 +104,7 @@ const AppIcon: FC = ({ icon, background, imageUrl, + decorative = false, className, innerIcon, coverElement, @@ -110,6 +112,7 @@ const AppIcon: FC = ({ showEditIcon = false, }) => { const isValidImageIcon = iconType === 'image' && imageUrl + const isDecorative = decorative && !onClick const emojiIcon = icon && icon !== '' ? icon : '🤖' const isHydrated = useIsHydrated() const Icon = isHydrated ? : emojiIcon @@ -133,9 +136,10 @@ const AppIcon: FC = ({ onKeyDown={onClick ? handleKeyDown : undefined} role={onClick ? 'button' : undefined} tabIndex={onClick ? 0 : undefined} + aria-hidden={isDecorative || undefined} > {isValidImageIcon ? ( - app icon + {isDecorative ) : ( innerIcon || Icon )} diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 390df3b4a28..9408839897d 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -1441,6 +1441,57 @@ describe('MainNav', () => { ) }) + it('announces no installed web app results only after the search settles', async () => { + const user = userEvent.setup() + let resolveSearch: (() => void) | undefined + const searchPending = new Promise((resolve) => { + resolveSearch = resolve + }) + mockInstalledApps = [createInstalledApp()] + mockInstalledAppsRequest.mockImplementation(async ({ query }: { query: { name?: string } }) => { + if (!query.name) { + return { + installed_apps: mockInstalledApps, + has_more: false, + next_cursor: null, + } + } + + await searchPending + return { + installed_apps: [], + has_more: false, + next_cursor: null, + } + }) + + renderMainNav() + + const webAppsRegion = await screen.findByRole('region', { + name: 'explore.sidebar.webApps', + }) + await user.click(screen.getByRole('button', { name: 'common.operation.search' })) + const resultStatus = within(webAppsRegion).getByRole('status') + expect(resultStatus).toBeEmptyDOMElement() + + await user.type(screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder'), 'z') + + await waitFor(() => { + expect(webAppsRegion).toHaveAttribute('aria-busy', 'true') + }) + expect(resultStatus).toBeEmptyDOMElement() + + act(() => { + resolveSearch?.() + }) + + await waitFor(() => { + expect(webAppsRegion).toHaveAttribute('aria-busy', 'false') + expect(resultStatus).toHaveTextContent('common.mainNav.webApps.noResults') + }) + expect(within(webAppsRegion).getByRole('status')).toBe(resultStatus) + }) + it('hides the installed web apps section while installed apps are loading', () => { mockInstalledAppsPending = true diff --git a/web/app/components/main-nav/components/web-apps-section.tsx b/web/app/components/main-nav/components/web-apps-section.tsx index e86f63bee3f..e12e9e7cce4 100644 --- a/web/app/components/main-nav/components/web-apps-section.tsx +++ b/web/app/components/main-nav/components/web-apps-section.tsx @@ -128,6 +128,12 @@ const WebAppsSectionContent = () => { }) const canLoadMore = !installedAppsQuery.isFetching && !installedAppsQuery.error + const noResultsMessage = t(($) => $['mainNav.webApps.noResults'], { ns: 'common' }) + const showNoResults = + !installedAppsQuery.isError && + !installedAppsQuery.isFetching && + !installedAppsQuery.isPlaceholderData && + installedApps.length === 0 const handleSearchTextChange = (value: string) => { scrollRef.current?.scrollTo({ top: 0 }) @@ -249,13 +255,16 @@ const WebAppsSectionContent = () => { $['sidebar.webApps'], { ns: 'explore' })} style={{ overflowX: 'hidden' }} className="overscroll-contain" role="region" > +
+ {showNoResults ? noResultsMessage : ''} +
{installedAppsQuery.isError && !installedAppsQuery.isFetchNextPageError && (
{
)} - {!installedAppsQuery.isError && installedApps.length === 0 && ( -
- {t(($) => $['mainNav.webApps.noResults'], { ns: 'common' })} -
+ {showNoResults && ( +
{noResultsMessage}
)} {webAppRows.length > 0 && (
{ expect(searchInput).toHaveFocus() }) - it('updates the creator query state as a multi creator filter', () => { + it('updates the creator query state as a multi creator filter', async () => { + const user = userEvent.setup() renderList() - fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.creators' })) - fireEvent.click(screen.getByRole('button', { name: /Bob/ })) + await user.click(screen.getByRole('combobox', { name: 'app.studio.filters.creators' })) + await user.click(screen.getByRole('option', { name: /Bob/ })) expect(mockSetCreatorIDs).toHaveBeenCalledWith(['creator-2']) }) diff --git a/web/features/tag-management/__tests__/tag-selector.spec.tsx b/web/features/tag-management/__tests__/tag-selector.spec.tsx index 5ef445420f8..2cb24e804f9 100644 --- a/web/features/tag-management/__tests__/tag-selector.spec.tsx +++ b/web/features/tag-management/__tests__/tag-selector.spec.tsx @@ -150,6 +150,12 @@ describe('TagSelector', () => { expect(screen.getByText('Frontend')).toBeInTheDocument() }) + it('adds the owning app name to the combobox accessible name', () => { + render() + + expect(screen.getByRole('combobox', { name: 'Frontend: Test App' })).toBeInTheDocument() + }) + it('keeps dataset tag interactions inside the tag trigger', async () => { const user = userEvent.setup() const onOuterClick = vi.fn() diff --git a/web/features/tag-management/components/app-card-tags.tsx b/web/features/tag-management/components/app-card-tags.tsx index eeaeb0e4e89..d5400e2c8f0 100644 --- a/web/features/tag-management/components/app-card-tags.tsx +++ b/web/features/tag-management/components/app-card-tags.tsx @@ -3,6 +3,7 @@ import { TagSelector } from '@/features/tag-management/components/tag-selector' type AppCardTagsProps = { appId: string + appName: string tags: Tag[] canBindOrUnbindTags?: boolean onOpenTagManagement?: () => void @@ -11,6 +12,7 @@ type AppCardTagsProps = { export const AppCardTags = ({ appId, + appName, tags, canBindOrUnbindTags, onOpenTagManagement = () => {}, @@ -20,6 +22,7 @@ export const AppCardTags = ({ & { targetId: string + contextLabel?: string type: TagType value: Tag[] canBindOrUnbindTags?: boolean @@ -62,6 +63,7 @@ export type TagSelectorProps = TagSelectorRootProps & export const TagSelector = ({ targetId, + contextLabel, type, value, canBindOrUnbindTags, @@ -106,6 +108,7 @@ export const TagSelector = ({ ? t(($) => $['tag.addTag'], { ns: 'common' }) : t(($) => $['tag.noTag'], { ns: 'common' }) const triggerLabel = tagNames.length ? tagNames.join(', ') : emptyTriggerLabel + const accessibleTriggerLabel = contextLabel ? `${triggerLabel}: ${contextLabel}` : triggerLabel const items = useMemo(() => { const tagIds = new Set() @@ -251,7 +254,7 @@ export const TagSelector = ({ > Date: Fri, 28 Aug 2026 08:50:30 +0000 Subject: [PATCH 03/52] fix(agent): add roster publication status contract (#41435) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/agent/roster.py | 77 ++++++++++++++-- api/openapi/markdown/console-openapi.md | 23 +++++ api/services/app_service.py | 63 ++++++++++--- .../console/agent/test_agent_controllers.py | 13 ++- .../unit_tests/services/test_app_service.py | 89 ++++++++++++++++++- .../generated/api/console/agent/types.gen.ts | 8 ++ .../generated/api/console/agent/zod.gen.ts | 11 +++ .../actions/__tests__/agent.spec.tsx | 1 + 8 files changed, 262 insertions(+), 23 deletions(-) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 8b9e8fb08c2..f95f9d8b00f 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -1,3 +1,4 @@ +from typing import Literal from uuid import UUID from flask import abort, request @@ -76,16 +77,25 @@ from services.agent.observability_service import ( AgentStatisticsQueryParams, ) from services.agent.roster_service import AgentRosterService -from services.app_service import AppListParams, AppService, CreateAppParams +from services.app_service import AgentAppPublicationCounts, AppListParams, AppService, CreateAppParams from services.enterprise.enterprise_service import EnterpriseService from services.entities.agent_entities import ComposerSavePayload, RosterListQuery from services.feature_service import FeatureService +AgentPublicationStatus = Literal["published", "drafts"] + class AgentInviteOptionsQuery(RosterListQuery): app_id: str | None = Field(default=None, description="Workflow app id for in-current-workflow markers") +class AgentAppListQuery(AppListQuery): + publication_status: AgentPublicationStatus | None = Field( + default=None, + description="Filter by published or draft Agent configuration status", + ) + + class AgentIdPath(BaseModel): agent_id: str @@ -299,7 +309,19 @@ class AgentSimpleResultResponse(BaseModel): result: str +class AgentPublicationCountsResponse(ResponseModel): + published: int = Field( + ge=0, + description="Published Agent Apps in the current list scope, excluding the publication status filter", + ) + drafts: int = Field( + ge=0, + description="Draft Agent Apps in the current list scope, excluding the publication status filter", + ) + + class AgentAppPagination(GenericAppPagination): + publication_counts: AgentPublicationCountsResponse data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute] validation_alias=AliasChoices("items", "data") ) @@ -314,6 +336,7 @@ register_schema_models( AgentBuildDraftCheckoutPayload, ComposerSavePayload, AgentApiStatusPayload, + AgentAppListQuery, AgentInviteOptionsQuery, AgentLogsQuery, AgentStatisticsQuery, @@ -323,6 +346,7 @@ register_schema_models( ) register_response_schema_models( console_ns, + AgentPublicationCountsResponse, AgentAppPagination, AgentApiAccessResponse, AgentAppPublishedReferenceResponse, @@ -408,7 +432,14 @@ def _serialize_agent_app_detail( return payload -def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_id: str, current_user: Account) -> dict: +def _serialize_agent_app_pagination( + session: Session, + app_pagination, + *, + tenant_id: str, + current_user: Account, + publication_counts: AgentAppPublicationCounts, +) -> dict: """Serialize Agent App lists with roster-shaped items. Each item starts from the shared App list shape, then drops @@ -441,8 +472,17 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_ account_id=current_user.id, ) payload = AgentAppPagination.model_validate( - app_pagination, - from_attributes=True, + { + "page": app_pagination.page, + "limit": app_pagination.per_page, + "total": app_pagination.total, + "has_more": app_pagination.has_next, + "data": app_pagination.items, + "publication_counts": { + "published": publication_counts.published, + "drafts": publication_counts.drafts, + }, + }, context={"session": session}, ).model_dump(mode="json") for item in payload["data"]: @@ -562,7 +602,7 @@ def _query_values(name: str, alias_name: str | None = None) -> list[str]: @console_ns.route("/agent") class AgentAppListApi(Resource): - @console_ns.doc(params=query_params_from_model(AppListQuery)) + @console_ns.doc(params=query_params_from_model(AgentAppListQuery)) @console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__]) @setup_required @login_required @@ -572,7 +612,9 @@ class AgentAppListApi(Resource): @with_current_tenant_id @with_session def get(self, session: Session, current_tenant_id: str, current_user: Account): - args = query_params_from_request(AppListQuery, list_fields=APP_LIST_QUERY_ARRAY_FIELDS) + args = query_params_from_request(AgentAppListQuery, list_fields=APP_LIST_QUERY_ARRAY_FIELDS) + agent_is_published = None if args.publication_status is None else args.publication_status == "published" + params = AppListParams( page=args.page, limit=args.limit, @@ -583,11 +625,29 @@ class AgentAppListApi(Resource): creator_ids=args.creator_ids, is_created_by_me=args.is_created_by_me, status="normal", + agent_is_published=agent_is_published, ) - app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params, session) + app_service = AppService() + publication_counts = app_service.get_agent_publication_counts( + current_user.id, + current_tenant_id, + params, + session, + ) + app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, params, session) if app_pagination is None: - empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) + empty = AgentAppPagination( + page=args.page, + limit=args.limit, + total=0, + has_more=False, + publication_counts=AgentPublicationCountsResponse( + published=publication_counts.published, + drafts=publication_counts.drafts, + ), + data=[], + ) return empty.model_dump(mode="json") return _serialize_agent_app_pagination( @@ -595,6 +655,7 @@ class AgentAppListApi(Resource): app_pagination, tenant_id=current_tenant_id, current_user=current_user, + publication_counts=publication_counts, ) @console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__]) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 63970c8f5c7..b8008497e43 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -346,6 +346,7 @@ Check if activation token is valid | mode | query | App mode filter | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | | name | query | Filter by app name | No | string | | page | query | Page number (1-99999) | No | integer,
**Default:** 1 | +| publication_status | query | Filter by published or draft Agent configuration status | No | string,
**Available values:** "drafts", "published" | | sort_by | query | Sort apps by last modified, recently created, or earliest created | No | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | | tag_ids | query | Filter by tag IDs | No | [ string ] | @@ -13490,6 +13491,20 @@ default (the config form sends the full desired feature state on save). | suggested_questions_after_answer | [AgentSuggestedQuestionsAfterAnswerFeatureConfig](#agentsuggestedquestionsafteranswerfeatureconfig) | Follow-up suggestions config, e.g. {'enabled': true} | No | | text_to_speech | [AgentTextToSpeechFeatureConfig](#agenttexttospeechfeatureconfig) | Text-to-speech config | No | +#### AgentAppListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| creator_ids | [ string ] | Filter by creator account IDs | No | +| is_created_by_me | boolean | Filter by creator | No | +| limit | integer,
**Default:** 20 | Page size (1-100) | No | +| mode | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | App mode filter
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No | +| name | string | Filter by app name | No | +| page | integer,
**Default:** 1 | Page number (1-99999) | No | +| publication_status | string | Filter by published or draft Agent configuration status | No | +| sort_by | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | Sort apps by last modified, recently created, or earliest created
*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No | +| tag_ids | [ string ] | Filter by tag IDs | No | + #### AgentAppPagination | Name | Type | Description | Required | @@ -13498,6 +13513,7 @@ default (the config form sends the full desired feature state on save). | has_more | boolean | | Yes | | limit | integer | | Yes | | page | integer | | Yes | +| publication_counts | [AgentPublicationCountsResponse](#agentpublicationcountsresponse) | | Yes | | total | integer | | Yes | #### AgentAppPartial @@ -14574,6 +14590,13 @@ section may be empty, which is how callers express "no knowledge layer". | ---- | ---- | ----------- | -------- | | AgentProviderResponse | object | | | +#### AgentPublicationCountsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| drafts | integer | Draft Agent Apps in the current list scope, excluding the publication status filter | Yes | +| published | integer | Published Agent Apps in the current list scope, excluding the publication status filter | Yes | + #### AgentPublishPayload | Name | Type | Description | Required | diff --git a/api/services/app_service.py b/api/services/app_service.py index 1a9f0497774..89b622463b7 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -90,12 +90,19 @@ class AppListBaseParams(BaseModel): class AppListParams(AppListBaseParams): status: str | None = None openapi_visible: bool = False + agent_is_published: bool | None = None class StarredAppListParams(AppListBaseParams): pass +@dataclass(frozen=True) +class AgentAppPublicationCounts: + published: int + drafts: int + + @dataclass(frozen=True) class RecentAppListItem: id: str @@ -188,6 +195,24 @@ class AppResponseView: class AppService: + @staticmethod + def _agent_app_exists_filter(tenant_id: str, *, is_published: bool | None = None) -> sa.Exists: + agent_filters = [ + Agent.tenant_id == tenant_id, + Agent.app_id == App.id, + Agent.scope == AgentScope.ROSTER, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), + Agent.status == AgentStatus.ACTIVE, + ] + if is_published is not None: + has_published_config = sa.and_( + Agent.active_config_snapshot_id.is_not(None), + Agent.active_config_is_published.is_(True), + ) + agent_filters.append(has_published_config if is_published else sa.not_(has_published_config)) + + return sa.exists().where(*agent_filters).correlate(App) + @staticmethod def _build_app_list_filters( user_id: str, tenant_id: str, params: AppListBaseParams, session: Session @@ -206,17 +231,8 @@ class AppService: filters.append(App.mode == AppMode.AGENT_CHAT) elif params.mode == "agent": filters.append(App.mode == AppMode.AGENT) - filters.append( - sa.exists() - .where( - Agent.tenant_id == tenant_id, - Agent.app_id == App.id, - Agent.scope == AgentScope.ROSTER, - Agent.source.in_(APP_BACKED_AGENT_SOURCES), - Agent.status == AgentStatus.ACTIVE, - ) - .correlate(App) - ) + publication_filter = params.agent_is_published if isinstance(params, AppListParams) else None + filters.append(AppService._agent_app_exists_filter(tenant_id, is_published=publication_filter)) elif params.mode == "all": filters.append(App.mode != AppMode.AGENT) @@ -374,6 +390,31 @@ class AppService: return app_models + def get_agent_publication_counts( + self, + user_id: str, + tenant_id: str, + params: AppListParams, + session: Session, + ) -> AgentAppPublicationCounts: + unfiltered_params = params.model_copy(update={"agent_is_published": None}) + filters = self._build_app_list_filters(user_id, tenant_id, unfiltered_params, session) + if not filters: + return AgentAppPublicationCounts(published=0, drafts=0) + + published_filter = self._agent_app_exists_filter(tenant_id, is_published=True) + draft_filter = self._agent_app_exists_filter(tenant_id, is_published=False) + published_count, draft_count = session.execute( + sa.select( + sa.func.coalesce(sa.func.sum(sa.case((published_filter, 1), else_=0)), 0), + sa.func.coalesce(sa.func.sum(sa.case((draft_filter, 1), else_=0)), 0), + ) + .select_from(App) + .where(*filters) + ).one() + + return AgentAppPublicationCounts(published=int(published_count), drafts=int(draft_count)) + def get_recent_apps( self, user_id: str, diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 9cd219971db..112b3a0264f 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -325,6 +325,11 @@ def test_agent_app_list_and_create_use_agent_route( items=[_app_detail_obj(id="app-list", bound_agent_id="agent-list")], ) + def get_agent_publication_counts(self, user_id: str, tenant_id: str, params, session): + del session + captured["counts"] = {"user_id": user_id, "tenant_id": tenant_id, "params": params} + return roster_controller.AgentAppPublicationCounts(published=1, drafts=0) + def create_app(self, tenant_id: str, params, current_user: object, *, session: object) -> object: captured["create"] = {"tenant_id": tenant_id, "params": params, "current_user": current_user} return _app_detail_obj(id="app-created", bound_agent_id="agent-created") @@ -408,7 +413,8 @@ def test_agent_app_list_and_create_use_agent_route( lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), ) with app.test_request_context( - "/console/api/agent?page=1&limit=10&mode=workflow&sort_by=recently_created&is_created_by_me=true" + "/console/api/agent?page=1&limit=10&mode=workflow&sort_by=recently_created" + "&is_created_by_me=true&publication_status=published" ): listed = unwrap(AgentAppListApi.get)( AgentAppListApi(), sqlite_session, "tenant-1", _account(account_id=account_id) @@ -416,6 +422,7 @@ def test_agent_app_list_and_create_use_agent_route( assert listed["page"] == 1 assert listed["limit"] == 10 assert listed["total"] == 1 + assert listed["publication_counts"] == {"published": 1, "drafts": 0} assert listed["data"][0]["id"] == "agent-list" assert listed["data"][0]["app_id"] == "app-list" assert listed["data"][0]["debug_conversation_id"] == "debug-conversation-list" @@ -438,7 +445,11 @@ def test_agent_app_list_and_create_use_agent_route( assert list_params.mode == "agent" assert list_params.sort_by == "recently_created" assert list_params.is_created_by_me is True + assert list_params.agent_is_published is True assert list_params.status == "normal" + count_call = cast(dict[str, object], captured["counts"]) + count_params = cast(Any, count_call["params"]) + assert count_params.agent_is_published is True with app.test_request_context( "/console/api/agent", json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"}, diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index 223be9f678b..8c71979a726 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -67,9 +67,18 @@ def _persist_app(session: Session, *, tenant_id: str, name: str = "Visible App") return app -def _persist_agent_app(session: Session, *, app_name: str = "Old", agent_name: str = "Old") -> tuple[App, Agent]: - tenant_id = str(uuid4()) - creator_id = str(uuid4()) +def _persist_agent_app( + session: Session, + *, + app_name: str = "Old", + agent_name: str = "Old", + tenant_id: str | None = None, + creator_id: str | None = None, + active_config_snapshot_id: str | None = None, + active_config_is_published: bool = False, +) -> tuple[App, Agent]: + tenant_id = tenant_id or str(uuid4()) + creator_id = creator_id or str(uuid4()) app = App( id=str(uuid4()), tenant_id=tenant_id, @@ -95,6 +104,8 @@ def _persist_agent_app(session: Session, *, app_name: str = "Old", agent_name: s icon="robot", icon_background="#fff", app_id=app.id, + active_config_snapshot_id=active_config_snapshot_id, + active_config_is_published=active_config_is_published, created_by=creator_id, ) session.add_all([app, agent]) @@ -493,6 +504,78 @@ class TestAgentAppType: params = CreateAppParams(name="Iris", mode="agent") assert params.mode == "agent" + def test_list_filter_and_counts_use_server_owned_publication_state(self, sqlite_session: Session): + tenant_id = str(uuid4()) + creator_id = str(uuid4()) + published_app, _ = _persist_agent_app( + sqlite_session, + app_name="Published", + agent_name="Published Agent", + tenant_id=tenant_id, + creator_id=creator_id, + active_config_snapshot_id=str(uuid4()), + active_config_is_published=True, + ) + draft_app, _ = _persist_agent_app( + sqlite_session, + app_name="Draft", + agent_name="Draft Agent", + tenant_id=tenant_id, + creator_id=creator_id, + active_config_snapshot_id=str(uuid4()), + ) + unpublished_app, _ = _persist_agent_app( + sqlite_session, + app_name="Unpublished", + agent_name="Unpublished Agent", + tenant_id=tenant_id, + creator_id=creator_id, + ) + _persist_agent_app( + sqlite_session, + app_name="Other tenant", + agent_name="Other Agent", + active_config_snapshot_id=str(uuid4()), + active_config_is_published=True, + ) + + service = AppService() + published_page = service.get_paginate_apps( + creator_id, + tenant_id, + AppListParams(mode="agent", status="normal", agent_is_published=True), + sqlite_session, + ) + draft_page = service.get_paginate_apps( + creator_id, + tenant_id, + AppListParams(mode="agent", status="normal", agent_is_published=False), + sqlite_session, + ) + counts = service.get_agent_publication_counts( + creator_id, + tenant_id, + AppListParams(mode="agent", status="normal", agent_is_published=True), + sqlite_session, + ) + searched_counts = service.get_agent_publication_counts( + creator_id, + tenant_id, + AppListParams(mode="agent", status="normal", name="Draft", agent_is_published=True), + sqlite_session, + ) + + assert published_page is not None + assert {app.id for app in published_page.items} == {published_app.id} + assert published_page.total == 1 + assert draft_page is not None + assert {app.id for app in draft_page.items} == {draft_app.id, unpublished_app.id} + assert draft_page.total == 2 + assert counts.published == 1 + assert counts.drafts == 2 + assert searched_counts.published == 0 + assert searched_counts.drafts == 1 + def test_bound_agent_id_is_none_for_non_agent_app(self): """Non-agent apps short-circuit without touching the DB.""" from models.model import App, AppMode diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 3991a7dfee0..1fb8deaf323 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -9,6 +9,7 @@ export type AgentAppPagination = { has_more: boolean limit: number page: number + publication_counts: AgentPublicationCountsResponse total: number } @@ -467,6 +468,11 @@ export type AgentAppPartial = { workflow?: WorkflowPartial | null } +export type AgentPublicationCountsResponse = { + drafts: number + published: number +} + export type IconType = 'emoji' | 'image' | 'link' export type DeletedTool = { @@ -1733,6 +1739,7 @@ export type AgentAppPaginationWritable = { has_more: boolean limit: number page: number + publication_counts: AgentPublicationCountsResponse total: number } @@ -1854,6 +1861,7 @@ export type GetAgentData = { | 'workflow' name?: string page?: number + publication_status?: 'drafts' | 'published' sort_by?: 'earliest_created' | 'last_modified' | 'recently_created' tag_ids?: Array } diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index d33b96e344b..a6cc2c12347 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -204,6 +204,14 @@ export const zAgentConfigSnapshotRestoreResponse = z.object({ result: z.literal('success'), }) +/** + * AgentPublicationCountsResponse + */ +export const zAgentPublicationCountsResponse = z.object({ + drafts: z.int().gte(0), + published: z.int().gte(0), +}) + /** * IconType */ @@ -871,6 +879,7 @@ export const zAgentAppPagination = z.object({ has_more: z.boolean(), limit: z.int(), page: z.int(), + publication_counts: zAgentPublicationCountsResponse, total: z.int(), }) @@ -2493,6 +2502,7 @@ export const zAgentAppPaginationWritable = z.object({ has_more: z.boolean(), limit: z.int(), page: z.int(), + publication_counts: zAgentPublicationCountsResponse, total: z.int(), }) @@ -2585,6 +2595,7 @@ export const zGetAgentQuery = z.object({ .default('all'), name: z.string().optional(), page: z.int().gte(1).lte(99999).optional().default(1), + publication_status: z.enum(['drafts', 'published']).optional(), sort_by: z .enum(['earliest_created', 'last_modified', 'recently_created']) .optional() diff --git a/web/app/components/goto-anything/actions/__tests__/agent.spec.tsx b/web/app/components/goto-anything/actions/__tests__/agent.spec.tsx index 0adc11781c8..73a2ed6bfde 100644 --- a/web/app/components/goto-anything/actions/__tests__/agent.spec.tsx +++ b/web/app/components/goto-anything/actions/__tests__/agent.spec.tsx @@ -67,6 +67,7 @@ describe('agent search query', () => { has_more: false, limit: 10, page: 1, + publication_counts: { drafts: 0, published: 1 }, total: 1, }) From 11bb82c731f3f1abfe8ae0a0dcdca48be2831e64 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:50:30 +0000 Subject: [PATCH 04/52] fix(web): align agent roster card interactions (#41436) --- .../agent-v2/roster/__tests__/page.spec.tsx | 151 +++++++- .../__tests__/agent-roster-list.spec.tsx | 138 +++++-- .../__tests__/roster-toolbar.spec.tsx | 22 +- .../roster/components/agent-roster-list.tsx | 351 ++++++++++-------- .../agent-workflow-references-dropdown.tsx | 110 +++--- .../roster/components/roster-toolbar.tsx | 14 +- web/features/agent-v2/roster/page.tsx | 85 +++-- web/features/skills/page.tsx | 3 +- 8 files changed, 577 insertions(+), 297 deletions(-) diff --git a/web/features/agent-v2/roster/__tests__/page.spec.tsx b/web/features/agent-v2/roster/__tests__/page.spec.tsx index b2cba7a2cac..bae86e97646 100644 --- a/web/features/agent-v2/roster/__tests__/page.spec.tsx +++ b/web/features/agent-v2/roster/__tests__/page.spec.tsx @@ -2,6 +2,62 @@ import { screen } from '@testing-library/react' import { renderWithConsoleQuery as render } from '@/test/console/query-data' import RosterPage from '../page' +const infiniteOptions = vi.hoisted(() => vi.fn((options) => options)) +const useInfiniteQueryOptions = vi.hoisted(() => vi.fn()) +const queryValues = vi.hoisted(() => ({ + created_by_me: false, + filter: 'all', + keyword: '', + sort_by: 'last_modified', +})) +const rosterQueryState = vi.hoisted(() => ({ + data: { + pages: [ + { + data: [], + has_more: false, + page: 1, + publication_counts: { drafts: 2, published: 1 }, + }, + ], + } as + | { + pages: Array<{ + data: never[] + has_more: boolean + page: number + publication_counts: { drafts: number; published: number } + }> + } + | undefined, +})) + +vi.mock('@/service/client', async (importOriginal) => { + const actual = await importOriginal() + const agentQuery = actual.consoleQuery.agent + const agentQueryWithInputCapture = new Proxy(agentQuery, { + get(target, property, receiver) { + if (property !== 'get') return Reflect.get(target, property, receiver) + + return { + ...agentQuery.get, + infiniteOptions, + } + }, + }) + + return { + ...actual, + consoleQuery: new Proxy(actual.consoleQuery, { + get(target, property, receiver) { + if (property === 'agent') return agentQueryWithInputCapture + + return Reflect.get(target, property, receiver) + }, + }), + } +}) + vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => path, })) @@ -10,12 +66,7 @@ vi.mock('nuqs', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useQueryState: (name: string) => { - if (name === 'keyword') return ['', vi.fn()] - if (name === 'filter') return ['all', vi.fn()] - if (name === 'created_by_me') return [false, vi.fn()] - return ['updated_at', vi.fn()] - }, + useQueryState: (name: keyof typeof queryValues) => [queryValues[name], vi.fn()], } }) @@ -23,15 +74,22 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useInfiniteQuery: () => ({ - data: { pages: [{ data: [], has_more: false, page: 1 }] }, - error: null, - fetchNextPage: vi.fn(), - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - isPending: false, - }), + useInfiniteQuery: (options: unknown) => { + useInfiniteQueryOptions(options) + return { + data: rosterQueryState.data, + error: null, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchNextPageError: false, + isFetching: false, + isFetchingNextPage: false, + isLoadingError: false, + isPending: false, + isRefetchError: false, + refetch: vi.fn(), + } + }, } }) @@ -40,18 +98,39 @@ vi.mock('../components/agent-roster-list', () => ({ })) vi.mock('../components/roster-toolbar', () => ({ - RosterToolbar: () =>
Roster toolbar
, + RosterToolbar: ({ + publicationCounts, + }: { + publicationCounts: { drafts: number; published: number } + }) => ( +
{`Roster toolbar: ${publicationCounts.published} published, ${publicationCounts.drafts} drafts`}
+ ), })) describe('RosterPage', () => { beforeEach(() => { vi.clearAllMocks() + queryValues.created_by_me = false + queryValues.filter = 'all' + queryValues.keyword = '' + queryValues.sort_by = 'last_modified' + rosterQueryState.data = { + pages: [ + { + data: [], + has_more: false, + page: 1, + publication_counts: { drafts: 2, published: 1 }, + }, + ], + } }) it('uses the localized roster title for the page heading', () => { render() expect(screen.getByRole('heading', { name: 'agentV2.roster.title' })).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'agentV2.roster.title' })).toBeInTheDocument() }) it('reconciles the route title with client branding', () => { @@ -66,4 +145,44 @@ describe('RosterPage', () => { expect(document.title).toBe('agentV2.roster.title - Acme') }) + + it('uses the generated publication filter and server-owned counts', () => { + queryValues.filter = 'drafts' + + render() + + const options = infiniteOptions.mock.lastCall?.[0] + expect(options).toBeDefined() + if (!options || typeof options.input !== 'function') + throw new Error('Expected paginated query input') + + expect(options.input(1)).toEqual({ + query: { + limit: 30, + page: 1, + publication_status: 'drafts', + sort_by: 'last_modified', + }, + }) + expect(screen.getByText('Roster toolbar: 1 published, 2 drafts')).toBeInTheDocument() + }) + + it('configures the roster query to keep previous filter data', () => { + render() + + const options = useInfiniteQueryOptions.mock.lastCall?.[0] as { + placeholderData?: (previousData: object) => object | undefined + } + const previousData = { pages: [{ data: ['previous agent'] }] } + + expect(options.placeholderData?.(previousData)).toBe(previousData) + }) + + it('renders stable zero counts before the first server response', () => { + rosterQueryState.data = undefined + + render() + + expect(screen.getByText('Roster toolbar: 0 published, 0 drafts')).toBeInTheDocument() + }) }) diff --git a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx index 25a266c2458..538e8008949 100644 --- a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx @@ -1,5 +1,5 @@ import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen' -import type { ComponentProps } from 'react' +import type { AgentRosterListState } from '../agent-roster-list' import { toast } from '@langgenius/dify-ui/toast' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor, within } from '@testing-library/react' @@ -74,26 +74,12 @@ const createAgent = (overrides: Partial = {}): AgentAppPartial ...overrides, }) -const renderList = ( - agents: AgentAppPartial[], - overrides: Partial> = {}, -) => { +const renderState = (state: AgentRosterListState) => { const queryClient = new QueryClient() const result = render( - + , ) @@ -103,6 +89,18 @@ const renderList = ( } } +type ReadyState = Extract + +const renderList = (agents: AgentAppPartial[], overrides: Partial = {}) => + renderState({ + status: 'ready', + agents, + emptyState: 'roster', + footer: { status: 'none' }, + isFetching: false, + ...overrides, + }) + describe('AgentRosterList', () => { beforeEach(() => { vi.clearAllMocks() @@ -132,7 +130,11 @@ describe('AgentRosterList', () => { it('exposes each agent card with the agent name', () => { renderList([createAgent()]) - expect(screen.getByRole('article', { name: 'Research Agent' })).toBeInTheDocument() + const card = screen.getByRole('article', { name: 'Research Agent' }) + const cardLink = within(card).getByRole('link', { name: 'Research Agent' }) + + expect(cardLink).toHaveAttribute('href', '/agents/agent-1/configure') + expect(cardLink).toHaveAccessibleDescription('Find and summarize market materials.') }) it('uses the Figma-aligned card title and role typography', () => { @@ -186,36 +188,57 @@ describe('AgentRosterList', () => { 'size-6', 'text-text-tertiary', ) - expect(placeholderGrid).toHaveClass( - 'grid', - 'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', - 'grid-rows-4', - ) - expect(placeholderGrid).not.toHaveClass( - 'grid-cols-1', - 'sm:grid-cols-2', - 'lg:grid-cols-3', - 'xl:grid-cols-4', - ) }) it('uses the same overlay treatment for empty search results', () => { - const { container } = renderList([], { isEmptySearch: true }) + const { container } = renderList([], { emptyState: 'filtered' }) expect(screen.getByRole('heading', { name: 'agentV2.roster.emptySearch' })).toBeInTheDocument() expect(container.querySelectorAll('.bg-background-default-lighter')).toHaveLength(16) expect(screen.queryByText('agentV2.roster.emptySearchDescription')).not.toBeInTheDocument() }) - it('uses the same overlay treatment for loading errors', () => { - const { container } = renderList([], { isError: true }) + it('uses the same overlay treatment for loading errors and exposes a retry action', async () => { + const user = userEvent.setup() + const onRetry = vi.fn() + const { container } = renderState({ status: 'error', onRetry }) + expect(screen.getByRole('alert', { name: 'agentV2.roster.loadingError' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'agentV2.roster.loadingError' })).toHaveClass( 'system-sm-regular', 'text-text-tertiary', ) expect(container.querySelectorAll('.bg-background-default-lighter')).toHaveLength(16) expect(container.querySelector('.bg-linear-to-b')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + expect(onRetry).toHaveBeenCalledOnce() + }) + + it('preserves loaded cards and exposes a retry action when the next page fails', async () => { + const user = userEvent.setup() + const onLoadMore = vi.fn() + renderList([createAgent()], { + footer: { status: 'error', onRetry: onLoadMore }, + }) + + expect(screen.getByRole('article', { name: 'Research Agent' })).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('agentV2.roster.loadingError') + + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + expect(onLoadMore).toHaveBeenCalledOnce() + }) + + it('preserves loaded cards and refetches when a background refresh fails', async () => { + const user = userEvent.setup() + const onRetry = vi.fn() + renderList([createAgent()], { footer: { status: 'error', onRetry } }) + + expect(screen.getByRole('article', { name: 'Research Agent' })).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('agentV2.roster.loadingError') + + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + expect(onRetry).toHaveBeenCalledOnce() }) it('opens published workflow references from the card reference trigger', async () => { @@ -235,7 +258,9 @@ describe('AgentRosterList', () => { }), ]) - await user.click(screen.getByRole('button', { name: /agentV2\.roster\.references\.trigger/ })) + await user.click( + screen.getByRole('button', { name: /agentV2\.roster\.references\.trigger.*1/ }), + ) const workflowLink = screen.getByRole('menuitem', { name: /RFP Review Flow/ }) expect(workflowLink).toHaveAttribute('href', '/app/workflow-app-id/workflow') @@ -244,6 +269,53 @@ describe('AgentRosterList', () => { expect(screen.getByText(/agentV2\.roster\.references\.label/)).toBeInTheDocument() }) + it('announces zero workflow references without exposing an inactive button', () => { + renderList([createAgent()]) + + const card = screen.getByRole('article', { name: 'Research Agent' }) + expect(within(card).getByText(/^agentV2\.roster\.references\.trigger/)).toHaveClass('sr-only') + expect( + within(card).queryByRole('button', { name: /agentV2\.roster\.references\.trigger/ }), + ).not.toBeInTheDocument() + }) + + it('keeps card navigation and independent controls in visual reading order', async () => { + const user = userEvent.setup() + renderList([ + createAgent({ + published_reference_count: 1, + published_references: [ + { + app_id: 'workflow-app-id', + app_icon: '🐍', + app_icon_background: '#E9F8D8', + app_icon_type: 'emoji', + app_name: 'RFP Review Flow', + }, + ], + }), + ]) + + const card = screen.getByRole('article', { name: 'Research Agent' }) + const cardLink = within(card).getByRole('link', { name: 'Research Agent' }) + const references = within(card).getByRole('button', { + name: /agentV2\.roster\.references\.trigger.*1/, + }) + const moreActions = within(card).getByRole('button', { + name: /agentV2\.roster\.moreActions/, + }) + + expect(cardLink).not.toContainElement(references) + expect(cardLink).not.toContainElement(moreActions) + + await user.tab() + expect(cardLink).toHaveFocus() + await user.tab() + expect(moreActions).toHaveFocus() + await user.tab() + expect(references).toHaveFocus() + }) + it('opens a duplicate dialog from the card action menu', async () => { const user = userEvent.setup() renderList([createAgent()]) diff --git a/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx b/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx index 44b27ecb9c9..ee8258b08dd 100644 --- a/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx @@ -20,15 +20,17 @@ vi.mock('@/next/navigation', () => ({ })) const renderToolbar = ({ + publicationCounts = { drafts: 2, published: 1 }, searchParams = '', }: { + publicationCounts?: { drafts: number; published: number } searchParams?: string } = {}) => { const queryClient = new QueryClient() const result = renderWithNuqs( - + , { searchParams }, ) @@ -93,6 +95,24 @@ describe('RosterToolbar', () => { expect(within(draftsFilter).getByText('2')).toBeInTheDocument() }) + it('renders zero counts before server data is available', () => { + renderToolbar({ publicationCounts: { drafts: 0, published: 0 } }) + + expect( + screen.getByRole('radio', { name: /agentV2\.roster\.filters\.published/ }), + ).toBeInTheDocument() + expect( + within(screen.getByRole('radio', { name: /agentV2\.roster\.filters\.published/ })).getByText( + '0', + ), + ).toBeInTheDocument() + expect( + within(screen.getByRole('radio', { name: /agentV2\.roster\.filters\.drafts/ })).getByText( + '0', + ), + ).toBeInTheDocument() + }) + it('renders created-by-me filtering and emits checked state', async () => { const user = userEvent.setup() const { onUrlUpdate } = renderToolbar() diff --git a/web/features/agent-v2/roster/components/agent-roster-list.tsx b/web/features/agent-v2/roster/components/agent-roster-list.tsx index c22ac30e4a3..49b73cd8c2a 100644 --- a/web/features/agent-v2/roster/components/agent-roster-list.tsx +++ b/web/features/agent-v2/roster/components/agent-roster-list.tsx @@ -1,6 +1,7 @@ 'use client' -import type { AgentAppPartial, AgentIconType } from '@dify/contracts/api/console/agent/types.gen' +import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen' +import { zAgentIconType } from '@dify/contracts/api/console/agent/zod.gen' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { @@ -10,12 +11,14 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { IconButton } from '@langgenius/dify-ui/icon-button' import { toast } from '@langgenius/dify-ui/toast' import { useId, useState } from 'react' import { useTranslation } from 'react-i18next' import { useExportAppDsl } from '@/app/components/app/use-export-app-dsl' import AppIcon from '@/app/components/base/app-icon' import { SkeletonRectangle } from '@/app/components/base/skeleton' +import { MAIN_NAV_APP_CARD_GRID_CLASS_NAME } from '@/app/components/main-nav/app-card-grid' import useTimestamp from '@/hooks/use-timestamp' import Link from '@/next/link' import { AgentWorkflowReferencesDropdown } from './agent-workflow-references-dropdown' @@ -23,30 +26,48 @@ import { DeleteAgentDialog } from './delete-agent-dialog' import { DuplicateAgentDialog } from './duplicate-agent-dialog' import { EditAgentDialog } from './edit-agent-dialog' +type AgentRosterListFooterState = + | { status: 'none' } + | { status: 'load-more'; isLoading: boolean; onLoadMore: () => void } + | { status: 'error'; onRetry: () => void } + +export type AgentRosterListState = + | { status: 'pending' } + | { status: 'error'; onRetry: () => void } + | { + status: 'ready' + agents: AgentAppPartial[] + emptyState: 'roster' | 'filtered' + footer: AgentRosterListFooterState + isFetching: boolean + } + type AgentRosterListProps = { - agents: AgentAppPartial[] - hasMore: boolean - isEmptySearch: boolean - isError: boolean - isFetching: boolean - isFetchingNextPage: boolean - isPending: boolean label: string - onLoadMore: () => void + state: AgentRosterListState } -const skeletonRows = ['primary', 'secondary', 'tertiary'] as const +const skeletonCardIds = Array.from( + { length: 6 }, + (_, index) => `agent-roster-skeleton-card-${index}`, +) +const AGENT_ROSTER_GRID_CLASS_NAME = cn('gap-2.5', MAIN_NAV_APP_CARD_GRID_CLASS_NAME) const emptyPlaceholderCardIds = Array.from( { length: 16 }, (_, index) => `agent-roster-placeholder-card-${index}`, ) function AgentRosterSkeleton() { + const { t } = useTranslation('common') + return ( <> - {skeletonRows.map((row) => ( + + {t(($) => $.loading)} + + {skeletonCardIds.map((id) => (
@@ -72,13 +93,29 @@ function AgentRosterSkeleton() { ) } -function AgentRosterPlaceholderState({ title }: { title: string }) { +function AgentRosterPlaceholderState({ + onRetry, + role, + title, +}: { + onRetry?: () => void + role?: 'alert' | 'status' + title: string +}) { + const { t } = useTranslation('common') + return (
-
+
{emptyPlaceholderCardIds.map((id) => (
))} @@ -97,6 +134,11 @@ function AgentRosterPlaceholderState({ title }: { title: string }) { > {title} + {onRetry && ( + + )}
@@ -127,9 +169,9 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { const publishedReferences = agent.published_references ?? [] const hasPublishedReferences = publishedReferences.length > 0 const isDraft = agent.active_config_is_published !== true - const imageUrl = - agent.icon_type === 'image' || agent.icon_type === 'link' ? agent.icon : undefined - const iconType = (imageUrl ? 'image' : agent.icon_type) as AgentIconType | null | undefined + const parsedIconType = zAgentIconType.safeParse(agent.icon_type).data + const imageUrl = parsedIconType === 'image' || parsedIconType === 'link' ? agent.icon : undefined + const iconType = parsedIconType === 'link' ? 'image' : parsedIconType const handleEditOpen = () => { setEditSessionKey((key) => key + 1) @@ -156,118 +198,122 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { return (
-
- -
- - - -
-

- {agent.name} -

-

{agent.role}

-
-
-
-
- {agent.description} -
-
- {isDraft && ( -
-
-
- {t(($) => $['roster.usageStatus.draft'])} -
-
- )} - -
-
- {hasPublishedReferences ? ( - - ) : ( -
- - {referenceCount} -
- )} - {updatedAt && ( - <> - - · - - {updatedAt} - - )} + +
+ + + +
+

+ {agent.name} +

+

{agent.role}

-
-
+
+ {agent.description} +
+
+
+ {isDraft && ( +
+
+
+ {t(($) => $['roster.usageStatus.draft'])} +
+
)} - > - - $['roster.moreActions'], { name: agent.name })} - className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1.5 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" - > - - {t(($) => $['roster.moreActions'], { name: agent.name })} - - - - - - - {t(($) => $['roster.editInfo'])} - - + +
+
+ + $['roster.moreActions'], { name: agent.name })} + size="lg" + className="data-popup-open:bg-state-base-hover" + > + + + } + /> + + + + {t(($) => $['roster.editInfo'])} + + + + {tCommon(($) => $['operation.duplicate'])} + + + + {tApp(($) => $.export)} + + + setIsDeleteOpen(true)} + > + + {tCommon(($) => $['operation.delete'])} + + + +
+
+
+
+ {hasPublishedReferences ? ( + + ) : ( +
- {tCommon(($) => $['operation.duplicate'])} - - - - {tApp(($) => $.export)} - - - setIsDeleteOpen(true)} - > - - {tCommon(($) => $['operation.delete'])} - - - + + {t(($) => $['roster.references.trigger'], { name: agent.name })}:{' '} + + {referenceCount} +
+ )} + {updatedAt && ( + <> + + · + + {updatedAt} + + )} +
- {isPending && } - {!isPending && isError && ( - $['roster.loadingError'])} /> - )} - {!isPending && !isError && agents.length === 0 && ( +
+ {state.status === 'pending' && } + {state.status === 'error' && ( $['roster.emptySearch']) : t(($) => $['roster.empty'])} + onRetry={state.onRetry} + role="alert" + title={t(($) => $['roster.loadingError'])} /> )} - {!isPending && - !isError && - agents.map((agent) => )} - {!isPending && !isError && hasMore && ( + {state.status === 'ready' && state.agents.length === 0 && ( + $['roster.emptySearch']) + : t(($) => $['roster.empty']) + } + /> + )} + {state.status === 'ready' && + state.agents.map((agent) => )} + {state.status === 'ready' && state.footer.status === 'error' && ( +
+ {t(($) => $['roster.loadingError'])} + +
+ )} + {state.status === 'ready' && state.footer.status === 'load-more' && (
-
diff --git a/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx b/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx index 65f99299882..f040eac268f 100644 --- a/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx +++ b/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx @@ -1,12 +1,12 @@ 'use client' -import type { - AgentAppPublishedReferenceResponse, - AgentIconType, -} from '@dify/contracts/api/console/agent/types.gen' +import type { AgentAppPublishedReferenceResponse } from '@dify/contracts/api/console/agent/types.gen' +import { zAgentIconType } from '@dify/contracts/api/console/agent/zod.gen' import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, DropdownMenuLinkItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' @@ -17,21 +17,14 @@ import Link from '@/next/link' const getWorkflowReferenceHref = (reference: AgentAppPublishedReferenceResponse) => `/app/${reference.app_id}/workflow` -const getWorkflowReferenceIconType = ( - reference: AgentAppPublishedReferenceResponse, -): AgentIconType | undefined => { - if (reference.app_icon_type === 'image' || reference.app_icon_type === 'link') return 'image' +const getWorkflowReferenceIcon = (reference: AgentAppPublishedReferenceResponse) => { + const parsedIconType = zAgentIconType.safeParse(reference.app_icon_type).data - if (reference.app_icon_type === 'emoji') return 'emoji' - - return undefined -} - -const getWorkflowReferenceImageUrl = (reference: AgentAppPublishedReferenceResponse) => { - if (reference.app_icon_type === 'image' || reference.app_icon_type === 'link') - return reference.app_icon - - return undefined + return { + iconType: parsedIconType === 'link' ? 'image' : parsedIconType, + imageUrl: + parsedIconType === 'image' || parsedIconType === 'link' ? reference.app_icon : undefined, + } } export function AgentWorkflowReferencesDropdown({ @@ -47,51 +40,54 @@ export function AgentWorkflowReferencesDropdown({ return ( - $['roster.references.trigger'], { - name: agentName, - count: referenceCount, - })} - className="relative flex h-4 shrink-0 cursor-pointer items-center gap-1 rounded-md outline-hidden before:pointer-events-none before:absolute before:-inset-x-1 before:-inset-y-0.5 before:rounded-md before:content-[''] hover:before:bg-state-base-hover focus-visible:before:ring-2 focus-visible:before:ring-state-accent-solid data-popup-open:before:bg-state-base-hover" - > + + + {t(($) => $['roster.references.trigger'], { name: agentName })}:{' '} + {referenceCount} - -
- {t(($) => $['roster.references.label'], { name: agentName })} -
- {publishedReferences.map((reference) => ( - - } - className="mx-0 h-8 gap-2 px-2 py-1 pr-2.5 system-md-regular text-text-secondary" - > - - - - {reference.app_name} - - - ))} + + + + {t(($) => $['roster.references.label'], { name: agentName })} + + {publishedReferences.map((reference) => { + const { iconType, imageUrl } = getWorkflowReferenceIcon(reference) + + return ( + + } + className="group mx-0 h-8 gap-2 px-2 py-1 pr-2.5 system-md-regular text-text-secondary" + > + + + + {reference.app_name} + + + ) + })} +
) diff --git a/web/features/agent-v2/roster/components/roster-toolbar.tsx b/web/features/agent-v2/roster/components/roster-toolbar.tsx index 29e7a21e04c..73ee69b0e83 100644 --- a/web/features/agent-v2/roster/components/roster-toolbar.tsx +++ b/web/features/agent-v2/roster/components/roster-toolbar.tsx @@ -1,5 +1,6 @@ 'use client' +import type { AgentPublicationCountsResponse } from '@dify/contracts/api/console/agent/types.gen' import type { RosterFilterValue } from './roster-filter' import { Checkbox } from '@langgenius/dify-ui/checkbox' import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control' @@ -16,8 +17,7 @@ import { RosterCreateMenu } from './roster-create-menu' import { RosterSortSelect } from './roster-sort-select' type RosterToolbarProps = { - draftAgents: number - publishedAgents: number + publicationCounts: AgentPublicationCountsResponse } type RosterFilterItemProps = { @@ -39,7 +39,7 @@ function RosterFilterItem({ count, label, value }: RosterFilterItemProps) { ) } -function RosterStatusFilter({ draftAgents, publishedAgents }: RosterToolbarProps) { +function RosterStatusFilter({ publicationCounts }: RosterToolbarProps) { const { t } = useTranslation('agentV2') const [filter, setFilter] = useQueryState(rosterQueryParamNames.filter, rosterFilterQueryParser) @@ -54,12 +54,12 @@ function RosterStatusFilter({ draftAgents, publishedAgents }: RosterToolbarProps $['roster.filters.published'])} - count={publishedAgents} + count={publicationCounts.published} /> $['roster.filters.drafts'])} - count={draftAgents} + count={publicationCounts.drafts} /> ) @@ -107,10 +107,10 @@ function RosterCreatedByMeFilter() { ) } -export function RosterToolbar({ draftAgents, publishedAgents }: RosterToolbarProps) { +export function RosterToolbar({ publicationCounts }: RosterToolbarProps) { return (
- +