mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v1.21.0
This commit is contained in:
+171
-73
@@ -53,9 +53,8 @@ jobs:
|
||||
NOTES_FILE="$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
if [[ "$TAG" == *-* ]]; then
|
||||
IS_PRERELEASE="true"
|
||||
else
|
||||
IS_PRERELEASE="false"
|
||||
echo "::error::Stable updater release does not accept prerelease tag: $TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f RELEASE_NOTES.md ]; then
|
||||
@@ -89,10 +88,6 @@ jobs:
|
||||
--notes-file "$NOTES_FILE"
|
||||
)
|
||||
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
create_args+=(--prerelease)
|
||||
fi
|
||||
|
||||
gh "${create_args[@]}" || gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
|
||||
|
||||
build:
|
||||
@@ -121,6 +116,7 @@ jobs:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
LIME_UPDATER_PUBLIC_KEY: ${{ secrets.LIME_UPDATER_PUBLIC_KEY }}
|
||||
LIME_UPDATES_BASE_URL: ${{ secrets.LIME_UPDATES_BASE_URL || vars.LIME_UPDATES_BASE_URL }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_RAW: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
@@ -172,12 +168,8 @@ jobs:
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "LIME_ENABLE_UPDATER_ARTIFACTS=false" >> "$GITHUB_ENV"
|
||||
echo "LIME_UPDATER_PUBLIC_KEY=" >> "$GITHUB_ENV"
|
||||
echo "TAURI_SIGNING_PRIVATE_KEY_RAW=" >> "$GITHUB_ENV"
|
||||
echo "TAURI_SIGNING_PRIVATE_KEY=" >> "$GITHUB_ENV"
|
||||
echo "TAURI_SIGNING_PRIVATE_KEY_PATH=" >> "$GITHUB_ENV"
|
||||
echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=" >> "$GITHUB_ENV"
|
||||
echo "::warning::Updater signing secrets are missing. Release will skip updater artifacts and fall back to manual-download updates."
|
||||
echo "::error::Updater signing secrets are missing. Release must produce signed updater artifacts for Cloudflare R2 distribution."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sync version from tag to tauri configs
|
||||
@@ -201,14 +193,23 @@ jobs:
|
||||
echo "tauri.conf.headless.json version is now: $(node -e "console.log(JSON.parse(require('fs').readFileSync('tauri.conf.headless.json','utf8')).version)")"
|
||||
echo "createUpdaterArtifacts: $(node -e "console.log(JSON.parse(require('fs').readFileSync('tauri.conf.json','utf8')).bundle.createUpdaterArtifacts)")"
|
||||
|
||||
- name: Resolve updater endpoint
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE_URL="${LIME_UPDATES_BASE_URL:-https://updates.limecloud.com}"
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
echo "LIME_UPDATER_ENDPOINT=${BASE_URL}/lime/stable/latest.json" >> "$GITHUB_ENV"
|
||||
echo "Updater endpoint: ${BASE_URL}/lime/stable/latest.json"
|
||||
|
||||
- name: Resolve release channel
|
||||
id: release_meta
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.version || github.ref_name }}"
|
||||
if [[ "$TAG" == *-* ]]; then
|
||||
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Release channel: prerelease"
|
||||
echo "::error::Stable updater release does not accept prerelease tag: $TAG"
|
||||
exit 1
|
||||
else
|
||||
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Release channel: stable"
|
||||
@@ -392,11 +393,11 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Tauri app (Windows installer, direct release upload)
|
||||
if: matrix.platform == 'windows-2022' && steps.updater_mode.outputs.enabled == 'true'
|
||||
- name: Build Tauri app (Windows installer, stage only)
|
||||
id: build_windows_stage
|
||||
if: matrix.platform == 'windows-2022'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# 禁用 LTO 加速编译(正式发布可改为 thin)
|
||||
CARGO_PROFILE_RELEASE_LTO: "off"
|
||||
# 增加并行编译单元
|
||||
@@ -407,42 +408,17 @@ jobs:
|
||||
with:
|
||||
tauriScript: npx tauri
|
||||
projectPath: src-tauri
|
||||
tagName: ${{ github.event.inputs.version || github.ref_name }}
|
||||
releaseName: "Lime ${{ github.event.inputs.version || github.ref_name }}"
|
||||
releaseBody: ${{ env.RELEASE_BODY }}
|
||||
releaseDraft: false
|
||||
prerelease: ${{ steps.release_meta.outputs.is_prerelease == 'true' }}
|
||||
includeUpdaterJson: ${{ steps.updater_mode.outputs.enabled == 'true' }}
|
||||
# tauri-action@v0 使用 assetNamePattern,Windows 只保留单一 setup 安装包
|
||||
assetNamePattern: "[name]_[version]_[arch][setup][ext]"
|
||||
# 默认不启用 voice feature(包含 whisper-rs,编译很慢)
|
||||
args: --target ${{ matrix.target }} --config tauri.windows.conf.json
|
||||
|
||||
- name: Build Tauri app (Windows installer, stage only)
|
||||
id: build_windows_stage
|
||||
if: matrix.platform == 'windows-2022' && steps.updater_mode.outputs.enabled == 'false'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
# 禁用 LTO 加速编译(正式发布可改为 thin)
|
||||
CARGO_PROFILE_RELEASE_LTO: "off"
|
||||
# 增加并行编译单元
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32
|
||||
CARGO_INCREMENTAL: 0
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUSTC_WRAPPER: sccache
|
||||
with:
|
||||
tauriScript: npx tauri
|
||||
projectPath: src-tauri
|
||||
# 默认不启用 voice feature(包含 whisper-rs,编译很慢)
|
||||
args: --target ${{ matrix.target }} --config tauri.windows.conf.json
|
||||
|
||||
- name: Stage Windows release asset
|
||||
if: matrix.platform == 'windows-2022' && steps.updater_mode.outputs.enabled == 'false'
|
||||
- name: Stage Windows release assets
|
||||
if: matrix.platform == 'windows-2022'
|
||||
shell: bash
|
||||
env:
|
||||
ARTIFACT_PATHS_JSON: ${{ steps.build_windows_stage.outputs.artifactPaths }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
|
||||
TARGET_TRIPLE: ${{ matrix.target }}
|
||||
STAGING_DIR: release-assets/${{ matrix.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -452,48 +428,36 @@ jobs:
|
||||
|
||||
const raw = (process.env.ARTIFACT_PATHS_JSON || '').trim();
|
||||
if (!raw) {
|
||||
throw new Error('Windows installer artifactPaths 输出为空');
|
||||
throw new Error('Windows artifactPaths 输出为空');
|
||||
}
|
||||
|
||||
let artifacts;
|
||||
try {
|
||||
artifacts = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Windows installer artifactPaths 不是合法 JSON: ${raw}\n${error}`);
|
||||
throw new Error(`Windows artifactPaths 不是合法 JSON: ${raw}\n${error}`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(artifacts) || artifacts.length === 0) {
|
||||
throw new Error(`Windows installer artifactPaths 为空数组: ${raw}`);
|
||||
throw new Error(`Windows artifactPaths 为空数组: ${raw}`);
|
||||
}
|
||||
|
||||
const installerPath = artifacts.find(
|
||||
(item) => typeof item === 'string' && item.toLowerCase().endsWith('.exe')
|
||||
);
|
||||
if (!installerPath) {
|
||||
throw new Error(`Windows installer 构建未找到 .exe 安装包: ${raw}`);
|
||||
}
|
||||
|
||||
const version = (process.env.RELEASE_TAG || '').replace(/^v/, '');
|
||||
if (!version) {
|
||||
throw new Error('RELEASE_TAG 为空,无法生成 Windows release 文件名');
|
||||
}
|
||||
|
||||
const targetTriple = process.env.TARGET_TRIPLE || '';
|
||||
const archLabel =
|
||||
targetTriple.startsWith('x86_64-') ? 'x64' :
|
||||
targetTriple.startsWith('aarch64-') ? 'aarch64' :
|
||||
targetTriple.replace(/[^A-Za-z0-9._-]+/g, '-');
|
||||
|
||||
const stagingDir = process.env.STAGING_DIR;
|
||||
fs.mkdirSync(stagingDir, { recursive: true });
|
||||
|
||||
const stagedPath = path.join(
|
||||
stagingDir,
|
||||
`Lime_${version}_${archLabel}-setup.exe`
|
||||
);
|
||||
for (const artifact of artifacts) {
|
||||
if (typeof artifact !== 'string' || !fs.existsSync(artifact) || !fs.statSync(artifact).isFile()) {
|
||||
continue;
|
||||
}
|
||||
const stagedPath = path.join(stagingDir, path.basename(artifact));
|
||||
fs.copyFileSync(artifact, stagedPath);
|
||||
console.log(`Staged Windows asset: ${artifact} -> ${stagedPath}`);
|
||||
}
|
||||
|
||||
fs.copyFileSync(installerPath, stagedPath);
|
||||
console.log(`Staged Windows installer: ${installerPath} -> ${stagedPath}`);
|
||||
const staged = fs.readdirSync(stagingDir).filter((name) => fs.statSync(path.join(stagingDir, name)).isFile());
|
||||
if (staged.length === 0) {
|
||||
throw new Error(`Windows 构建未生成可发布文件: ${raw}`);
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Build macOS app (attempt 1, verbose)
|
||||
@@ -803,6 +767,17 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.inputs.source_ref || github.ref }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Download staged release assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -833,6 +808,60 @@ jobs:
|
||||
printf 'Downloaded staged release assets:\n'
|
||||
printf ' - %s\n' "${assets[@]}"
|
||||
|
||||
- name: Prepare updater release notes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NOTES_FILE="$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json body -q .body > "$NOTES_FILE" && [ -s "$NOTES_FILE" ]; then
|
||||
echo "Loaded release notes from GitHub Release"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f RELEASE_NOTES.md ]; then
|
||||
cp RELEASE_NOTES.md "$NOTES_FILE"
|
||||
echo "Loaded release notes from RELEASE_NOTES.md"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PREV_TAG="$(git tag --sort=-v:refname | grep -v "^${RELEASE_TAG}$" | head -n 1)"
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
CHANGELOG="$(git log --pretty=format:"- %s (%h)" "${RELEASE_TAG}" 2>/dev/null || git log --pretty=format:"- %s (%h)")"
|
||||
else
|
||||
CHANGELOG="$(git log --pretty=format:"- %s (%h)" "${PREV_TAG}..${RELEASE_TAG}" 2>/dev/null || git log --pretty=format:"- %s (%h)" "${PREV_TAG}..HEAD")"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## Lime ${RELEASE_TAG}"
|
||||
echo
|
||||
echo "### Changes"
|
||||
echo "${CHANGELOG}"
|
||||
} > "$NOTES_FILE"
|
||||
echo "Generated release notes from git history"
|
||||
|
||||
- name: Generate stable updater manifest
|
||||
env:
|
||||
LIME_UPDATES_BASE_URL: ${{ secrets.LIME_UPDATES_BASE_URL || vars.LIME_UPDATES_BASE_URL }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE_URL="${LIME_UPDATES_BASE_URL:-https://updates.limecloud.com}"
|
||||
node scripts/release-updater-manifest.mjs \
|
||||
--assets-dir release-assets \
|
||||
--out-dir release-updater \
|
||||
--version "$RELEASE_TAG" \
|
||||
--base-url "$BASE_URL" \
|
||||
--notes-file "$RUNNER_TEMP/release-notes.md" \
|
||||
--channel stable
|
||||
|
||||
printf 'Generated updater manifest:\n'
|
||||
cat release-updater/latest.json
|
||||
|
||||
- name: Upload staged release assets to GitHub Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -845,7 +874,8 @@ jobs:
|
||||
while IFS= read -r asset; do
|
||||
[ -n "$asset" ] || continue
|
||||
assets+=("$asset")
|
||||
done < <(find "release-assets" -type f | sort)
|
||||
done < <(find "release-assets" -type f ! -name "latest*.json" | sort)
|
||||
assets+=("release-updater/latest.json")
|
||||
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No staged release assets found" >&2
|
||||
@@ -858,3 +888,71 @@ jobs:
|
||||
gh release upload "$TAG" "${assets[@]}" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--clobber
|
||||
|
||||
- name: Upload updater assets to Cloudflare R2
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
LIME_RELEASES_R2_BUCKET: ${{ secrets.LIME_RELEASES_R2_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUCKET="${LIME_RELEASES_R2_BUCKET:-lime-releases}"
|
||||
|
||||
BUCKET="$BUCKET" node - <<'NODE' > "$RUNNER_TEMP/r2-upload.sh"
|
||||
const fs = require('fs');
|
||||
const plan = JSON.parse(fs.readFileSync('release-updater/r2-upload-plan.json', 'utf8'));
|
||||
for (const item of plan) {
|
||||
const args = [
|
||||
'npx wrangler@latest r2 object put',
|
||||
JSON.stringify(`${process.env.BUCKET}/${item.key}`),
|
||||
'--file',
|
||||
JSON.stringify(item.file),
|
||||
'--content-type',
|
||||
JSON.stringify(item.contentType),
|
||||
'--cache-control',
|
||||
JSON.stringify(item.cacheControl),
|
||||
];
|
||||
console.log(args.join(' '));
|
||||
}
|
||||
NODE
|
||||
|
||||
bash "$RUNNER_TEMP/r2-upload.sh"
|
||||
|
||||
- name: Clean old updater assets from Cloudflare R2
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
LIME_MINIMUM_SUPPORTED_VERSION: ${{ secrets.LIME_MINIMUM_SUPPORTED_VERSION }}
|
||||
LIME_R2_KEEP_RELEASES: ${{ vars.LIME_R2_KEEP_RELEASES }}
|
||||
LIME_RELEASES_R2_BUCKET: ${{ secrets.LIME_RELEASES_R2_BUCKET }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUCKET="${LIME_RELEASES_R2_BUCKET:-lime-releases}"
|
||||
KEEP="${LIME_R2_KEEP_RELEASES:-3}"
|
||||
|
||||
npx wrangler@latest r2 object list "$BUCKET" \
|
||||
--prefix "lime/stable/v" \
|
||||
--json > "$RUNNER_TEMP/r2-objects.json"
|
||||
|
||||
node scripts/plan-r2-release-cleanup.mjs \
|
||||
--input "$RUNNER_TEMP/r2-objects.json" \
|
||||
--output "$RUNNER_TEMP/r2-delete-keys.txt" \
|
||||
--current "$RELEASE_TAG" \
|
||||
--keep "$KEEP" \
|
||||
--minimum "${LIME_MINIMUM_SUPPORTED_VERSION:-}" \
|
||||
--channel stable
|
||||
|
||||
if [ ! -s "$RUNNER_TEMP/r2-delete-keys.txt" ]; then
|
||||
echo "No old R2 updater assets to delete"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'Deleting old R2 updater assets:\n'
|
||||
cat "$RUNNER_TEMP/r2-delete-keys.txt"
|
||||
while IFS= read -r key; do
|
||||
[ -n "$key" ] || continue
|
||||
npx wrangler@latest r2 object delete "${BUCKET}/${key}"
|
||||
done < "$RUNNER_TEMP/r2-delete-keys.txt"
|
||||
|
||||
@@ -153,7 +153,7 @@ brew install --cask lime
|
||||
|
||||
#### 手动下载
|
||||
|
||||
从 [Releases](https://github.com/aiclientproxy/lime/releases) 下载对应平台安装包。
|
||||
从 [Releases](https://github.com/limecloud/lime/releases) 下载对应平台安装包。
|
||||
|
||||
- 当前仅提供 macOS 与 Windows 发布包,Linux 桌面端已暂停支持
|
||||
- Windows 用户下载 `Lime_*_x64-setup.exe`(NSIS 安装器,内置 WebView2)
|
||||
|
||||
+76
-127
@@ -1,150 +1,97 @@
|
||||
## Lime v1.20.0
|
||||
## Lime v1.21.0
|
||||
|
||||
发布日期:`2026-04-27`
|
||||
发布日期:`2026-04-28`
|
||||
|
||||
### 发布概览
|
||||
|
||||
- 本次发布目标 tag 为 `v1.20.0`。
|
||||
- 本次发布聚焦工作台(Workbench)入口上线、任务中心切换收口、会话与线程运行时增强、图片工作台状态恢复、技能选择器优化、数据库性能提升,以及大量测试覆盖补充。
|
||||
- 本轮待递交内容覆盖 Rust 后端、DevBridge、前端 Workspace / Task Center、研究文档、版本锁文件与验证产物清理。
|
||||
- 本次发布目标 tag 为 `v1.21.0`。
|
||||
- 本次发布聚焦稳定版自动更新与 R2 分发链路、OEM 云端商业闭环、工作台首页与侧栏体验、资源管理器、Provider 模型管理收口、主题外观与设置页更新。
|
||||
- 本轮待递交内容覆盖 Rust 后端、Tauri update command、DevBridge / mock、发布工作流、前端 Workspace / Settings / Provider Pool / Resource Manager、测试覆盖、版本锁文件与临时产物清理。
|
||||
|
||||
### 重点更新
|
||||
|
||||
#### 1. 工作台(Workbench)入口上线
|
||||
#### 1. 稳定版更新与 R2 发布链路
|
||||
|
||||
- 侧边栏新增「工作台」导航项,支持通过 `buildClawAgentParams` 进入 Claw Agent 工作台模式 (`sidebarNav.ts`, `AppSidebar.tsx`)
|
||||
- 新增 `sessionIdentity.ts`,提供辅助 Agent 会话识别能力 (`sessionIdentity.ts`)
|
||||
- WorkspaceShellScene 支持归档会话打开回调 (`WorkspaceShellScene.tsx`)
|
||||
- `.github/workflows/release.yml` 补齐稳定版 updater 发布门禁,要求签名密钥与更新地址就绪后再生成 updater artifacts。
|
||||
- 新增 `scripts/release-updater-manifest.mjs`,聚合各平台 `latest.json`,生成统一 `latest.json`、版本化清单、R2 上传计划与 manifest metadata。
|
||||
- 新增 `scripts/plan-r2-release-cleanup.mjs`,按稳定版本窗口规划旧 R2 updater 产物清理,避免发布桶无限增长。
|
||||
- `scripts/release-updater-manifest.test.mjs` 覆盖平台缺失、版本不匹配、同名跨平台 artifact 与旧版本清理保护逻辑。
|
||||
- `src-tauri/src/commands/update_cmd.rs` 与 `src-tauri/crates/services/src/update_check_service.rs` 切到静态清单检查 + Tauri updater 安装主链,并补齐 semver 比较与缓存兜底。
|
||||
|
||||
#### 2. 新增 Workbench 技能命令
|
||||
#### 2. 版本号同步到 v1.21.0
|
||||
|
||||
- 新增文件阅读工作台命令:`fileReadWorkbenchCommand` (`fileReadWorkbenchCommand.ts`)
|
||||
- 新增增长分析工作台命令:`growthWorkbenchCommand` (`growthWorkbenchCommand.ts`)
|
||||
- 新增 Logo 分解工作台命令:`logoDecompositionWorkbenchCommand` (`logoDecompositionWorkbenchCommand.ts`)
|
||||
- 新增写作工作台命令:`writingWorkbenchCommand` (`writingWorkbenchCommand.ts`)
|
||||
|
||||
#### 3. 会话管理增强
|
||||
|
||||
- 会话列表支持归档筛选、工作目录过滤与分页加载 (`session_runtime.rs`, `agent.rs`)
|
||||
- 新增 `SessionArchiveFilter` 枚举,支持灵活的归档会话查询 (`agent.rs`)
|
||||
- `AsterAgentWrapper::list_sessions_sync` 支持 `archive_filter`、`workspace_id`、`limit` 参数 (`session_runtime.rs`)
|
||||
- 侧边栏会话列表实现分页加载与"加载更多"机制 (`AppSidebar.tsx`)
|
||||
- 会话状态管理支持 `allowDetachedCandidate` 选项,允许恢复已断开的候选会话 (`agentSessionState.ts`)
|
||||
- 初始会话导航支持去重,防止重复导航 (`useWorkspaceInitialSessionNavigation.ts`)
|
||||
|
||||
#### 4. Chat 侧栏归档会话支持
|
||||
|
||||
- ChatSidebar 新增 `onOpenArchivedTopic` 回调,支持在 Task Center 打开归档会话 (`ChatSidebar.tsx`)
|
||||
- `handleOpenTaskItem` 根据 section 类型自动路由到归档或常规会话打开逻辑
|
||||
|
||||
#### 5. 技能选择器优化
|
||||
|
||||
- 优化输入能力分区逻辑 (`inputCapabilitySections.ts`)
|
||||
- 改进技能选择绑定配置 (`skillSelectionBindings.ts`)
|
||||
|
||||
#### 6. 数据库性能优化
|
||||
|
||||
- 新增 `idx_agent_sessions_working_dir_archived_updated_at` 复合索引,优化按工作目录和归档状态查询 (`schema.rs`)
|
||||
- 新增 `idx_agent_sessions_session_type_updated_at` 索引,优化按会话类型查询 (`schema.rs`)
|
||||
- Agent 会话仓储支持新的查询参数 (`agent_session_repository.rs`)
|
||||
|
||||
#### 7. 测试覆盖大幅补充
|
||||
|
||||
- 新增/更新 42 个测试文件,约 3185 行测试代码
|
||||
- 覆盖工作台命令、会话状态、侧栏导航、技能选择、Workspace 导航等核心模块
|
||||
- `seededCommandPackage.test.ts` 大幅扩展测试用例
|
||||
|
||||
#### 8. 其他改进
|
||||
|
||||
- 导航偏好配置更新 (`preferences.ts`)
|
||||
- 命令清单 schema 同步更新 (`agentRuntimeCommandSchema.json`)
|
||||
- 会话客户端与类型定义完善 (`sessionClient.ts`, `types.ts`)
|
||||
- Agent API 测试补充 (`agent.test.ts`)
|
||||
- `limenext-progress.md` 进度日志同步更新
|
||||
|
||||
#### 9. 测试修复
|
||||
|
||||
- 修复 `runtime_project_hooks` 中 `persist_session_access_mode` 测试用例,解决测试 DB 与全局 LimeSessionStore DB 不一致导致 `recent_access_mode` 无法正确读取的问题 (`runtime_project_hooks.rs`)
|
||||
- 测试修复后直接写入 extension_data 和测试消息到测试 DB,确保 `get_runtime_session_detail` 能正常构建 `execution_runtime`
|
||||
- 修复 `useAgentSession.ts` 在 scoped session 恢复、手动切换 topic、跨工作区恢复失败与 detail mock 污染场景下的 sessionId / topicId 回写问题,避免发送、压缩和 confirmAction 落到空会话
|
||||
- 修复 `http-client.test.ts` 中预期 reject 被 Vitest 识别为 unhandled rejection 的问题
|
||||
- 修复 `chrome-relay` 消息自动清除定时器在测试环境 teardown 后仍触发 `window is not defined` 的问题 (`src/components/settings-v2/system/chrome-relay/index.tsx`)
|
||||
- 修复 `agent-runtime-tool-surface-page-smoke.mjs` 在真实页面刷新后 composer 尚未挂载就写入 prompt 的竞态,GUI smoke 现在会先等待输入框与发送按钮就绪
|
||||
|
||||
#### 10. 任务中心与工作区切换收口
|
||||
|
||||
- 修复同一 `claw` 页面内从左侧“最近对话 / 归档”切换会话时,旧会话 Tab 被重新回灌到任务中心顶部的问题 (`AgentChatWorkspace.tsx`, `index.test.tsx`)
|
||||
- 任务中心在路由切换中会先锁定目标会话预览态,避免“只打开一个会话却出现多个任务 Tab”的视觉错觉
|
||||
- `ChatNavbar`、`TaskCenterTabStrip`、`WorkspaceMainArea`、`WorkspaceStyles` 与 `taskCenterChromeTokens.ts` 继续收口任务中心 chrome、标签栏和工作区视觉状态
|
||||
- `SplashScreen`、`ProjectSelector`、`SceneAppsPage`、开发者设置页和侧边栏导航补齐对应入口状态与回归断言
|
||||
|
||||
#### 11. Harness Engine 与会话运行时
|
||||
|
||||
- 会话存储、DAO 与 timeline 查询补齐 thread / turn / item 级状态合并,减少重复 upsert 和过期 pending 状态残留 (`session_store.rs`, `agent.rs`, `agent_timeline.rs`, `agentThreadState.ts`)
|
||||
- `agent_runtime_submit_turn` / runtime turn 链路继续补充真实 `session/thread/turn` 关联,让 evidence / telemetry / replay / review 能消费同一导出事实源 (`runtime_api.rs`, `runtime_turn.rs`, `dto.rs`)
|
||||
- `AgentThreadTimelineArtifactCard`、`MessageList`、`useAgentRuntimeSyncEffects`、`useAsterAgentChat` 和相关测试覆盖工具时间线、轻卡与运行时同步回归
|
||||
- `agentSessionScopedStorage` 增加按工作区 / 会话作用域的 transient 与 persisted 存储封装,避免跨会话状态串扰
|
||||
|
||||
#### 12. 图片工作台状态恢复
|
||||
|
||||
- 新增 `imageWorkbenchStateCache.ts`,为图片工作台 tail 状态提供同标签页缓存、持久化回退、TTL / stale grace 和 LRU 裁剪
|
||||
- `useWorkspaceImageTaskPreviewRuntime.ts` 支持只恢复近期活跃的非终态图片任务,避免旧 running / queued 任务长期误回灌
|
||||
- 图片工作台恢复逻辑支持从消息里的 `imageWorkbenchPreview` 重建状态,并通过 `imageWorkbenchStateCache.test.ts` 与 `useWorkspaceImageTaskPreviewRuntime.test.tsx` 覆盖缓存、过期和消息回放路径
|
||||
|
||||
#### 13. DevBridge、模型命令与窗口 chrome
|
||||
|
||||
- DevBridge 模型分发补齐 `get_provider_alias_config`、`get_all_models_by_provider`、`get_all_available_models`、`get_default_models_for_provider`,避免浏览器模式初始化模型时继续落入 unknown command (`dispatcher/models.rs`)
|
||||
- `agent-runtime-tool-surface-page-smoke.mjs` 同步工具面页面冒烟逻辑,覆盖真实页面输入、发送、Harness 打开与 Runtime 能力摘要检查
|
||||
- 新增 `window_chrome.rs` 并在启动链路应用主窗口 macOS overlay 标题栏兜底,避免 headless/dev 配置绕过主窗口 chrome (`mod.rs`, `runner.rs`, `tauri.conf*.json`)
|
||||
- `deepseek.json`、命令 manifest 与 runtime schema 同步模型 / 命令边界事实源
|
||||
|
||||
#### 14. 文档、研究资料与验证产物治理
|
||||
|
||||
- `docs/research/ribbi/` 新增 Ribbi 研究事实源,拆分产品意图、架构图、流程图、命令清单、Agent/tool 编排、Taste/Memory 演进与 Lime 差距分析
|
||||
- `.gitignore` 调整为允许递交 `docs/research/`,让研究资料成为 versioned artifact
|
||||
- `docs/aiprompts/overview.md` 更新开发者中心 Harness 开关语义:关闭时仍保留 Harness 入口,但不采集工具库存与额外环境摘要
|
||||
- `docs/exec-plans/limenext-progress.md` 记录任务中心切换、DevBridge 模型命令补齐、GUI smoke 与定向测试结果
|
||||
- 根目录旧 Playwright 截图、网络日志和控制台快照已从发布递交面清理,包括 `advanced-settings-open.png`、`current-lime-page.png`、`typed-message.png`、`mimo-openai-doc.png`、`console-current.txt`、`network-3030.txt` 与 `playwright-*` 快照
|
||||
- 当前工作区仍包含 `network-before.md`、`patch_dark.js`、`patch_flare.js`、`patch_navbar.js` 与 `tmp-e2e-home.png` 这类临时调试辅助产物;如果按“全部递交”处理,这些也纳入本 Release Notes 的待递交范围
|
||||
|
||||
### 待递交范围确认
|
||||
|
||||
- 版本同步与锁文件:`package.json`、`src-tauri/Cargo.toml` 已确认是 `1.20.0`;本轮待递交包含 `package-lock.json`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 的同步更新
|
||||
- Rust 主链:Agent session store、session/runtime command API、timeline DAO、数据库 schema、DevBridge dispatcher、模型 dispatcher、主窗口 chrome 与 DeepSeek provider 元数据
|
||||
- 前端主链:Workspace / Task Center / ChatNavbar / EmptyState / MessageList / AgentThreadTimelineArtifactCard / AppSidebar / Splash / ProjectSelector / SceneApps / developer settings
|
||||
- 会话与运行时状态:`agentSessionScopedStorage`、`agentSessionState`、`agentThreadState`、`useAgentSession`、`useAsterAgentChat`、runtime sync / stream handler / turn event binding
|
||||
- 图片工作台:`imageWorkbenchStateCache.ts`、`useWorkspaceImageTaskPreviewRuntime.ts` 及对应缓存、过期、消息回放测试
|
||||
- 命令与契约事实源:`commandManifest.generated.ts`、`agentRuntimeCommandSchema.json`、DevBridge mock / HTTP client 测试与 runtime tool surface 页面 smoke
|
||||
- 文档与研究资料:`docs/aiprompts/overview.md`、`docs/exec-plans/limenext-progress.md`、`docs/research/ribbi/*`
|
||||
- 发布产物治理:删除根目录旧 Playwright / 网络 / 控制台临时快照,并显式标记当前仍保留的临时调试辅助文件
|
||||
|
||||
### 版本同步
|
||||
|
||||
- 应用版本已同步为 `1.20.0`:
|
||||
- 应用版本已同步为 `1.21.0`:
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
- `src-tauri/Cargo.toml`
|
||||
- `src-tauri/Cargo.lock`
|
||||
- `src-tauri/tauri.conf.json`
|
||||
- `src-tauri/tauri.conf.headless.json`
|
||||
- `packages/lime-cli-npm/package.json` 与 `packages/lime-cli-npm/README.md` 已同步到 `1.21.0`,保持 CLI wrapper 与桌面 release 版本一致。
|
||||
- 浏览器模式默认 mock 的 update current version 已同步为 `1.21.0`。
|
||||
|
||||
#### 3. OEM 云端商业闭环
|
||||
|
||||
- 新增 `src/lib/oemCloudPaymentReturn.ts`,统一生成、解析、暂存并分发 `lime://payment/return` 支付回跳事件。
|
||||
- `useDeepLink` 识别支付回跳 deep link,直接分发云端商业刷新事件,不再走旧 `handle_deep_link` 命令分支。
|
||||
- `useOemCloudAccess` 接入云端激活、支付配置、套餐订单、充值订单、账本、积分余额与访问令牌刷新主链。
|
||||
- 套餐购买和积分充值 checkout 支持 HTTPS bridge 回跳 URL,支付完成后自动刷新云端权益、积分余额与订单 watcher。
|
||||
- `docs/exec-plans/oem-cloud-commerce-loop-progress.md` 记录当前阶段、已清退的旧支付配置入口与下一轮真实渠道沙箱验证计划。
|
||||
|
||||
#### 4. 工作台首页、侧栏与导航体验
|
||||
|
||||
- `AppSidebar` 增加最近对话 / 归档会话架、分页加载、归档切换、外观切换、账户菜单与折叠态细节。
|
||||
- 新增 `src/components/app-sidebar/AppSidebarConversationShelf.tsx`,把会话架从侧栏主体中拆出,降低侧栏单体复杂度。
|
||||
- 工作台首页空态升级为“先开始这一轮 / 继续这轮 / 直接开工”入口,强化任务起手、推荐模板与继续上下文。
|
||||
- Workspace / Task Center / ChatNavbar / Inputbar / EmptyState / Team Workspace 等主路径继续收口视觉状态、运行时状态与回归断言。
|
||||
- `src/lib/windowControls.ts`、窗口 chrome 与主窗口启动链路继续补齐 macOS / headless 场景下的窗口控制一致性。
|
||||
|
||||
#### 5. 资源管理器
|
||||
|
||||
- 新增 `src/features/resource-manager/`,提供资源管理器页面、侧栏、工具栏、预览面板、Inspector、搜索与导航意图。
|
||||
- 支持图片、文本、Markdown、PDF、Office、音视频、数据文件、压缩包与系统委托类型的分层预览渲染。
|
||||
- 支持资源下载、复制、系统打开、Finder 揭示、聊天位置与项目资源上下文回跳。
|
||||
- 补齐 `ResourceManagerPage`、资源预览搜索、会话状态和导航意图测试。
|
||||
|
||||
#### 6. Provider Pool 与设置页收口
|
||||
|
||||
- Provider 模型管理改为“启用的模型”左侧列表 + 添加模型面板,删除旧 API Key 列表 / Provider 表单 / 模型列表拆分组件。
|
||||
- 新增 `ModelProviderList`、`ModelAddPanel`、`providerConfigUtils` 与连接测试类型,统一 Provider 配置工具函数与 UI 入口。
|
||||
- 设置页 Provider、About、Developer、Experimental、Appearance、Channels 与 Automation 页面继续收口布局、状态展示和回归断言。
|
||||
- Prompt Cache 与 Anthropic-compatible 能力口径更新,避免把显式 `cache_control` 能力误显示为自动缓存。
|
||||
|
||||
#### 7. App Update 前端与 mock
|
||||
|
||||
- `src/lib/api/appUpdate.ts` 扩展 release notes URL / pubDate / 错误信息字段。
|
||||
- `src/lib/tauri-mock/core.ts` 补齐 `check_update`、`check_for_updates` 与下载无更新态 mock,浏览器模式不再落入 unknown command。
|
||||
- About 设置页更新检查、下载失败、诊断错误和版本展示补齐测试覆盖。
|
||||
|
||||
#### 8. 文档、治理与临时产物
|
||||
|
||||
- `README.md` 更新产品定位文案:从“本地优先的 AI API Proxy 桌面应用”收敛为 AI Agent 创作工作台。
|
||||
- `src/lib/governance/legacySurfaceCatalog.json` 与测试补充新的 legacy surface 口径。
|
||||
- 删除根目录临时调试产物:`monitor.sh`、`network-before.md`、`post-hmr-state.png`、`tmp-e2e-home.png`、`knip.governance.json`。
|
||||
- 新增 `theme-scope-messages-ocean.png` 作为本轮主题视觉验证产物。
|
||||
|
||||
### 待递交范围确认
|
||||
|
||||
- 版本与发布:版本文件、lockfile、CLI wrapper、release workflow、R2 updater manifest / cleanup 脚本与测试。
|
||||
- Rust 主链:update command、update service、window chrome、runner/app 模块、DevBridge dispatcher、tray 事件与菜单处理。
|
||||
- 前端主链:AppSidebar、Workspace、Task Center、EmptyState、Inputbar、Team Workspace、Settings、Provider Pool、Resource Manager、MCP、Memory、SceneApps、Resources。
|
||||
- 商业闭环:OEM cloud control plane API、支付回跳 deep link、权益 / 积分 / 账本刷新、订单 watcher 与执行计划文档。
|
||||
- 验证与治理:新增/更新测试、legacy catalog、release updater contract、删除临时调试文件与旧 Provider Pool 组件。
|
||||
|
||||
### 校验状态
|
||||
|
||||
- 本会话已实际执行并通过:
|
||||
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`
|
||||
- `cargo test --manifest-path "src-tauri/Cargo.toml"` — 1102 passed / 0 failed / 2 ignored
|
||||
- `cargo clippy --manifest-path "src-tauri/Cargo.toml" --all-targets --all-features`
|
||||
- 已通过:
|
||||
- `npm run verify:app-version`
|
||||
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`
|
||||
- `cargo test --manifest-path "src-tauri/Cargo.toml"` — 1109 passed / 0 failed / 2 ignored
|
||||
- `cargo clippy --manifest-path "src-tauri/Cargo.toml" --all-targets --all-features`
|
||||
- `npm run lint`
|
||||
- `npm test`
|
||||
- `npm test` — 43 个 Vitest smart 批次通过
|
||||
- `npm run test:contracts`
|
||||
- `node --check "scripts/agent-runtime-tool-surface-page-smoke.mjs"`
|
||||
- `npm run smoke:agent-runtime-tool-surface-page`
|
||||
- `npm run verify:gui-smoke`
|
||||
- `git diff --check`
|
||||
- 本会话定向补测已通过:
|
||||
- `npm test -- "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx"` — 149 tests
|
||||
- `npm test -- "src/lib/dev-bridge/http-client.test.ts"` — 11 tests
|
||||
- `npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx"` — 12 tests
|
||||
- `cargo test` 通过,当前存在 1 条预存 warning:
|
||||
- `write_auxiliary_runtime_projection_fixture` 的 `dead_code`
|
||||
- `cargo clippy` 通过,当前存在 4 条预存 warning:
|
||||
@@ -152,8 +99,10 @@
|
||||
- `crates/skills/src/lime_llm_provider.rs` 的 `too_many_arguments`
|
||||
- `crates/agent/src/session_execution_runtime.rs` 的 `needless_lifetimes`
|
||||
- `src/services/runtime_evidence_pack_service.rs` 的 `dead_code`
|
||||
- GUI 主路径已验证:`DevBridge` 健康检查、默认 workspace 准备态、browser runtime、site adapters、service skill entry、runtime tool surface 与真实页面 Harness summary smoke 均通过
|
||||
- GUI 主路径校验未完全通过:
|
||||
- `npm run verify:gui-smoke` 已通过 `bridge:health`、`smoke:workspace-ready`、`smoke:browser-runtime`、`smoke:site-adapters`、`smoke:agent-service-skill-entry`、`smoke:agent-runtime-tool-surface`。
|
||||
- `smoke:agent-runtime-tool-surface-page` 在等待 Harness 按钮出现时超时;单独复测时托管 Chrome 会话在提交后丢失,需继续定位真实页面发送后的会话保持 / Harness 入口暴露问题。
|
||||
|
||||
---
|
||||
|
||||
**完整变更**: `v1.19.0` -> `v1.20.0`
|
||||
**完整变更**: `v1.20.0` -> `v1.21.0`
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# OEM 云端商业闭环推进记录
|
||||
|
||||
## 主目标
|
||||
|
||||
把 Lime 客户端与 LimeCore 的云端购买链路收敛为单一 current 主链:套餐/充值下单 -> 真实支付 checkout -> 支付渠道 webhook -> 权益/积分/账本刷新 -> API Key 与网关使用。
|
||||
|
||||
## 当前阶段
|
||||
|
||||
- `current`:`useOemCloudAccess` 消费 `/client/cloud-activation`、订单、充值单、账本、支付配置与访问令牌,客户端不再本地兜底套餐、渠道或模型目录。
|
||||
- `current`:支付完成后优先通过 LimeCore HTTPS `payments/{provider}/return` bridge 回到客户端,再 302 到 `lime://payment/return`,触发云端状态刷新和订单 watcher。
|
||||
- `dead`:旧的客户端支付后台密钥配置入口已从设置页主链移除,管理型支付配置只保留在服务端管理面。
|
||||
|
||||
## 2026-04-28 进度
|
||||
|
||||
- 新增 `src/lib/oemCloudPaymentReturn.ts`,统一生成、解析、暂存和分发云端支付回跳事件。
|
||||
- `useDeepLink` 识别 `lime://payment/return`,不走旧 `handle_deep_link` 命令,直接分发 current 云端商业刷新事件。
|
||||
- `useOemCloudAccess` 在套餐购买和积分充值 checkout 中传入 `successUrl` / `cancelUrl`,支付页回跳后自动刷新云端权益、积分余额与账本,并重新接上订单 watcher。
|
||||
- 增加回归覆盖 deep link 分发、checkout 回跳 URL、支付回跳后刷新商业状态。
|
||||
|
||||
## 2026-04-28 追加进度
|
||||
|
||||
- LimeCore 新增公开 HTTPS bridge:`/api/v1/public/tenants/{tenantId}/payments/{provider}/return`,支持 GET 与 form POST。
|
||||
- bridge 只组装并 302 到 `lime://payment/return`,不在回跳页确认支付;真实权益发放仍只由 provider webhook 驱动。
|
||||
- Lime 客户端 checkout 回跳 URL 改为 HTTPS bridge,保留客户端 deep link 消费和状态刷新逻辑。
|
||||
- Creem checkout 已接入官方 `success_url` 参数,成功回跳进入 HTTPS bridge;取消回跳不伪造本地结果,继续依赖 watcher 与 webhook 后的服务端状态。
|
||||
|
||||
## 下一刀
|
||||
|
||||
继续用真实渠道沙箱串一次 EPay / Stripe / Creem / Waffo,确认各渠道回跳是否都能回到 `lime://payment/return`,同时验证 webhook finalize 后客户端账本、积分和权益刷新一致。
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@latest/schema.json",
|
||||
"entry": ["src/main.tsx"],
|
||||
"project": [
|
||||
"src/**/*.{ts,tsx,js,jsx,mjs,cjs}",
|
||||
"!src/**/*.test.{ts,tsx,js,jsx,mjs,cjs}",
|
||||
"!src/**/*.spec.{ts,tsx,js,jsx,mjs,cjs}",
|
||||
"!src/**/__tests__/**",
|
||||
"!src/**/__mocks__/**",
|
||||
"!src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 性能监控脚本
|
||||
|
||||
echo "📊 统一记忆系统 - 性能监控"
|
||||
echo ""
|
||||
|
||||
# 数据库大小
|
||||
DB_SIZE=$(du -h ~/.lime/lime.db 2>/dev/null | cut -f1)
|
||||
echo "数据库大小: ${DB_SIZE:-未知}"
|
||||
|
||||
# 记忆数量
|
||||
MEMORY_COUNT=$(sqlite3 ~/.lime/lime.db "SELECT COUNT(*) FROM unified_memory WHERE archived = 0;" 2>/dev/null)
|
||||
echo "记忆数量: ${MEMORY_COUNT:-0}"
|
||||
|
||||
# 反馈数量
|
||||
FEEDBACK_COUNT=$(sqlite3 ~/.lime/lime.db "SELECT COUNT(*) FROM memory_feedback;" 2>/dev/null)
|
||||
echo "反馈数量: ${FEEDBACK_COUNT:-0}"
|
||||
|
||||
# 批准率
|
||||
if [ "$FEEDBACK_COUNT" -gt 0 ]; then
|
||||
APPROVE_COUNT=$(sqlite3 ~/.lime/lime.db "SELECT COUNT(*) FROM memory_feedback WHERE action LIKE '%approve%';" 2>/dev/null)
|
||||
RATE=$(echo "scale=1; $APPROVE_COUNT * 100 / $FEEDBACK_COUNT" | bc 2>/dev/null)
|
||||
echo "批准率: ${RATE:-0}%"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ 监控完成"
|
||||
@@ -1,42 +0,0 @@
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"get_config"}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"agent_runtime_list_sessions","args":{"request":{"limit":37}}}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"workspace_list"}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"workspace_get_default"}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"sceneapp_list_catalog"}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"workspace_ensure_ready","args":{"id":"849e36ff-8f64-45ed-ba51-aab6b8e182e4"}}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"workspace_get","args":{"id":"849e36ff-8f64-45ed-ba51-aab6b8e182e4"}}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"get_api_key_providers"}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"get_provider_ui_state","args":{"key":"collapsed_groups"}}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"get_local_skills_for_app","args":{"app":"lime"}}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"get_automation_jobs"}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"get_provider_ui_state","args":{"key":"selected_provider"}}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"project_memory_get","args":{"projectId":"849e36ff-8f64-45ed-ba51-aab6b8e182e4"}}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"workspace_ensure_ready","args":{"id":"849e36ff-8f64-45ed-ba51-aab6b8e182e4"}}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"agent_runtime_list_sessions","args":{"request":{"workspace_id":"849e36ff-8f64-45ed-ba51-aab6b8e182e4","limit":60}}}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"aster_agent_init"}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"get_provider_ui_state","args":{"key":"collapsed_groups"}}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"get_provider_ui_state","args":{"key":"selected_provider"}}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"get_provider_pool_overview"}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"get_model_registry"}
|
||||
[POST] http://127.0.0.1:3030/invoke => [200] OK
|
||||
Request body: {"cmd":"fetch_provider_models_auto","args":{"providerId":"deepseek"}}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lime",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"dependencies": {
|
||||
"@babel/standalone": "^7.29.0",
|
||||
"@fabianlars/tauri-plugin-oauth": "^2",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
@@ -112,7 +112,7 @@ npm run build:release -- \
|
||||
```bash
|
||||
npm run build:release -- \
|
||||
--target-triple "aarch64-apple-darwin" \
|
||||
--version "1.19.0" \
|
||||
--version "1.21.0" \
|
||||
--out-dir "./dist"
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@limecloud/lime-cli",
|
||||
"version": "1.19.0",
|
||||
"version": "1.21.0",
|
||||
"description": "Lime 官方任务 CLI",
|
||||
"bin": {
|
||||
"lime": "scripts/run.js"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 667 KiB |
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const item = argv[index];
|
||||
if (!item.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
const key = item.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
args[key] = "true";
|
||||
continue;
|
||||
}
|
||||
args[key] = next;
|
||||
index += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function normalizeVersion(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/^v/, "");
|
||||
}
|
||||
|
||||
function compareStableVersionsDesc(left, right) {
|
||||
const leftParts = normalizeVersion(left)
|
||||
.split(".")
|
||||
.map((part) => Number(part));
|
||||
const rightParts = normalizeVersion(right)
|
||||
.split(".")
|
||||
.map((part) => Number(part));
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const diff = (rightParts[index] || 0) - (leftParts[index] || 0);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
return normalizeVersion(right).localeCompare(normalizeVersion(left));
|
||||
}
|
||||
|
||||
function extractKeys(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(extractKeys);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return [];
|
||||
}
|
||||
if (typeof value.key === "string") {
|
||||
return [value.key];
|
||||
}
|
||||
if (Array.isArray(value.objects)) {
|
||||
return value.objects.flatMap(extractKeys);
|
||||
}
|
||||
if (Array.isArray(value.result)) {
|
||||
return value.result.flatMap(extractKeys);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function releaseVersionFromKey(key, channel = "stable") {
|
||||
const escapedChannel = channel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = key.match(new RegExp(`^lime/${escapedChannel}/v([^/]+)/`));
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function planR2ReleaseCleanup(options) {
|
||||
const channel = options.channel || "stable";
|
||||
const keep = Math.max(1, Number(options.keep || 3));
|
||||
const keys = Array.from(new Set(options.keys || [])).sort();
|
||||
const current = normalizeVersion(options.currentVersion);
|
||||
const minimum = normalizeVersion(options.minimumSupportedVersion);
|
||||
const versions = Array.from(
|
||||
new Set(
|
||||
keys.map((key) => releaseVersionFromKey(key, channel)).filter(Boolean),
|
||||
),
|
||||
).sort(compareStableVersionsDesc);
|
||||
const protectedVersions = new Set(
|
||||
versions.slice(0, keep).map(normalizeVersion),
|
||||
);
|
||||
|
||||
if (current) {
|
||||
protectedVersions.add(current);
|
||||
}
|
||||
if (minimum) {
|
||||
protectedVersions.add(minimum);
|
||||
}
|
||||
|
||||
const deleteKeys = keys.filter((key) => {
|
||||
const version = releaseVersionFromKey(key, channel);
|
||||
return version && !protectedVersions.has(normalizeVersion(version));
|
||||
});
|
||||
|
||||
return {
|
||||
deleteKeys,
|
||||
protectedVersions: Array.from(protectedVersions).sort(
|
||||
compareStableVersionsDesc,
|
||||
),
|
||||
versions,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const inputPath = args.input;
|
||||
if (!inputPath) {
|
||||
throw new Error("--input is required");
|
||||
}
|
||||
const raw = JSON.parse(fs.readFileSync(path.resolve(inputPath), "utf8"));
|
||||
const plan = planR2ReleaseCleanup({
|
||||
channel: args.channel || process.env.LIME_RELEASE_CHANNEL || "stable",
|
||||
currentVersion:
|
||||
args.current || process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME,
|
||||
keep: args.keep || process.env.LIME_R2_KEEP_RELEASES || "3",
|
||||
keys: extractKeys(raw),
|
||||
minimumSupportedVersion:
|
||||
args.minimum || process.env.LIME_MINIMUM_SUPPORTED_VERSION || "",
|
||||
});
|
||||
|
||||
const outputPath = args.output ? path.resolve(args.output) : "";
|
||||
if (outputPath) {
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
outputPath,
|
||||
`${plan.deleteKeys.join("\n")}${plan.deleteKeys.length ? "\n" : ""}`,
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`${plan.deleteKeys.join("\n")}${plan.deleteKeys.length ? "\n" : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isCli =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isCli) {
|
||||
main();
|
||||
}
|
||||
|
||||
export { extractKeys, planR2ReleaseCleanup, releaseVersionFromKey };
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const DEFAULT_REQUIRED_PLATFORMS = [
|
||||
"darwin-aarch64",
|
||||
"darwin-x86_64",
|
||||
"windows-x86_64",
|
||||
];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const item = argv[index];
|
||||
if (!item.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
const key = item.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
args[key] = "true";
|
||||
continue;
|
||||
}
|
||||
args[key] = next;
|
||||
index += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function writeJson(filePath, data) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function walkFiles(rootDir) {
|
||||
if (!fs.existsSync(rootDir)) {
|
||||
return [];
|
||||
}
|
||||
const result = [];
|
||||
const stack = [rootDir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
result.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
function normalizeVersionTag(version) {
|
||||
const normalized = String(version || "").trim();
|
||||
if (!normalized) {
|
||||
throw new Error("release version is required");
|
||||
}
|
||||
const withoutPrefix = normalized.replace(/^v/, "");
|
||||
if (withoutPrefix.includes("-")) {
|
||||
throw new Error(
|
||||
`stable updater manifest does not accept prerelease version: ${normalized}`,
|
||||
);
|
||||
}
|
||||
if (!/^\d+\.\d+\.\d+(?:\+\S+)?$/.test(withoutPrefix)) {
|
||||
throw new Error(`release version must be stable semver: ${normalized}`);
|
||||
}
|
||||
return `v${withoutPrefix.split("+")[0]}`;
|
||||
}
|
||||
|
||||
function normalizeStableVersion(version) {
|
||||
return normalizeVersionTag(version).slice(1);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
const normalized = String(baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
if (!normalized) {
|
||||
throw new Error("base URL is required");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function encodePathSegment(value) {
|
||||
return encodeURIComponent(value).replace(
|
||||
/[!'()*]/g,
|
||||
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
function basenameFromUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return decodeURIComponent(path.posix.basename(parsed.pathname));
|
||||
} catch {
|
||||
return path.basename(String(url || ""));
|
||||
}
|
||||
}
|
||||
|
||||
function collectFilesByBasename(files) {
|
||||
const byName = new Map();
|
||||
for (const file of files) {
|
||||
const name = path.basename(file);
|
||||
if (!byName.has(name)) {
|
||||
byName.set(name, []);
|
||||
}
|
||||
byName.get(name).push(file);
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
function findLatestManifestFiles(files) {
|
||||
return files.filter((file) => /^latest.*\.json$/i.test(path.basename(file)));
|
||||
}
|
||||
|
||||
function resolveReferencedAssetFile(manifestFile, candidates) {
|
||||
const manifestDir = path.dirname(manifestFile);
|
||||
const assetCandidates = candidates.filter(
|
||||
(candidate) => !/^latest.*\.json$/i.test(path.basename(candidate)),
|
||||
);
|
||||
return (
|
||||
assetCandidates.find(
|
||||
(candidate) => path.dirname(candidate) === manifestDir,
|
||||
) || assetCandidates[0]
|
||||
);
|
||||
}
|
||||
|
||||
function buildPlatformAssetPath(platformKey, assetName) {
|
||||
return `${encodePathSegment(platformKey)}/${encodePathSegment(assetName)}`;
|
||||
}
|
||||
|
||||
function collectUpdaterManifest(options) {
|
||||
const assetsDir = path.resolve(options.assetsDir);
|
||||
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
||||
const channel = options.channel || "stable";
|
||||
const versionTag = normalizeVersionTag(options.version);
|
||||
const version = versionTag.slice(1);
|
||||
const files = walkFiles(assetsDir);
|
||||
const filesByBasename = collectFilesByBasename(files);
|
||||
const latestFiles = findLatestManifestFiles(files);
|
||||
const requiredPlatforms = (
|
||||
options.requiredPlatforms || DEFAULT_REQUIRED_PLATFORMS
|
||||
)
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (latestFiles.length === 0) {
|
||||
throw new Error(`no latest*.json files found under ${assetsDir}`);
|
||||
}
|
||||
|
||||
const platforms = {};
|
||||
const referencedFiles = new Map();
|
||||
let notes = options.notes || "";
|
||||
let pubDate = options.pubDate || "";
|
||||
|
||||
for (const manifestFile of latestFiles) {
|
||||
const manifest = readJson(manifestFile);
|
||||
const manifestVersion = normalizeStableVersion(manifest.version);
|
||||
if (manifestVersion !== version) {
|
||||
throw new Error(
|
||||
`${manifestFile} version ${manifest.version} does not match release ${versionTag}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!notes && typeof manifest.notes === "string") {
|
||||
notes = manifest.notes;
|
||||
}
|
||||
if (!pubDate && typeof manifest.pub_date === "string") {
|
||||
pubDate = manifest.pub_date;
|
||||
}
|
||||
|
||||
for (const [platformKey, platform] of Object.entries(
|
||||
manifest.platforms || {},
|
||||
)) {
|
||||
const sourceUrl = String(platform?.url || "").trim();
|
||||
const signature = String(platform?.signature || "").trim();
|
||||
if (!sourceUrl || !signature) {
|
||||
throw new Error(
|
||||
`${manifestFile} platform ${platformKey} has empty url or signature`,
|
||||
);
|
||||
}
|
||||
if (platforms[platformKey]) {
|
||||
throw new Error(
|
||||
`${manifestFile} duplicates updater platform ${platformKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
const assetName = basenameFromUrl(sourceUrl);
|
||||
const candidates = filesByBasename.get(assetName) || [];
|
||||
const assetFile = resolveReferencedAssetFile(manifestFile, candidates);
|
||||
if (!assetFile) {
|
||||
throw new Error(
|
||||
`${manifestFile} platform ${platformKey} references missing asset: ${assetName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const platformAssetPath = buildPlatformAssetPath(platformKey, assetName);
|
||||
const targetUrl = `${baseUrl}/lime/${channel}/${versionTag}/${platformAssetPath}`;
|
||||
platforms[platformKey] = {
|
||||
signature,
|
||||
url: targetUrl,
|
||||
};
|
||||
referencedFiles.set(platformAssetPath, {
|
||||
assetName,
|
||||
file: assetFile,
|
||||
platformKey,
|
||||
targetPath: platformAssetPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const missing = requiredPlatforms.filter((platform) => !platforms[platform]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`updater manifest missing required platforms: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version,
|
||||
notes,
|
||||
pub_date: pubDate || new Date().toISOString(),
|
||||
platforms,
|
||||
};
|
||||
|
||||
const r2UploadPlan = [
|
||||
...Array.from(referencedFiles.values()).map((item) => ({
|
||||
cacheControl: "public, max-age=31536000, immutable",
|
||||
contentType: "application/octet-stream",
|
||||
file: item.file,
|
||||
key: `lime/${channel}/${versionTag}/${item.targetPath}`,
|
||||
})),
|
||||
].sort((a, b) => a.key.localeCompare(b.key));
|
||||
|
||||
return {
|
||||
manifest,
|
||||
referencedFiles: Array.from(referencedFiles.values())
|
||||
.map((item) => item.file)
|
||||
.sort(),
|
||||
r2UploadPlan,
|
||||
version,
|
||||
versionTag,
|
||||
};
|
||||
}
|
||||
|
||||
function writeOutputs(result, outDir, channel) {
|
||||
const outputDir = path.resolve(outDir);
|
||||
const latestPath = path.join(outputDir, "latest.json");
|
||||
const versionedLatestPath = path.join(
|
||||
outputDir,
|
||||
result.versionTag,
|
||||
"latest.json",
|
||||
);
|
||||
const uploadPlanPath = path.join(outputDir, "r2-upload-plan.json");
|
||||
const metadataPath = path.join(outputDir, "manifest-meta.json");
|
||||
|
||||
writeJson(latestPath, result.manifest);
|
||||
writeJson(versionedLatestPath, result.manifest);
|
||||
writeJson(uploadPlanPath, [
|
||||
...result.r2UploadPlan,
|
||||
{
|
||||
cacheControl: "public, max-age=60, stale-while-revalidate=300",
|
||||
contentType: "application/json",
|
||||
file: versionedLatestPath,
|
||||
key: `lime/${channel}/${result.versionTag}/latest.json`,
|
||||
},
|
||||
{
|
||||
cacheControl: "public, max-age=60, stale-while-revalidate=300",
|
||||
contentType: "application/json",
|
||||
file: latestPath,
|
||||
key: `lime/${channel}/latest.json`,
|
||||
},
|
||||
]);
|
||||
writeJson(metadataPath, {
|
||||
platformCount: Object.keys(result.manifest.platforms).length,
|
||||
version: result.version,
|
||||
versionTag: result.versionTag,
|
||||
});
|
||||
|
||||
return {
|
||||
latestPath,
|
||||
metadataPath,
|
||||
uploadPlanPath,
|
||||
versionedLatestPath,
|
||||
};
|
||||
}
|
||||
|
||||
function loadNotes(args) {
|
||||
if (args["notes-file"]) {
|
||||
return fs.readFileSync(path.resolve(args["notes-file"]), "utf8").trim();
|
||||
}
|
||||
return process.env.RELEASE_BODY || "";
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const requiredPlatforms = (
|
||||
args["required-platforms"] ||
|
||||
process.env.REQUIRED_UPDATE_PLATFORMS ||
|
||||
""
|
||||
)
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const channel = args.channel || process.env.LIME_RELEASE_CHANNEL || "stable";
|
||||
const result = collectUpdaterManifest({
|
||||
assetsDir: args["assets-dir"] || "release-assets",
|
||||
baseUrl:
|
||||
args["base-url"] ||
|
||||
process.env.LIME_UPDATES_BASE_URL ||
|
||||
"https://updates.limecloud.com",
|
||||
channel,
|
||||
notes: loadNotes(args),
|
||||
pubDate: args["pub-date"] || process.env.RELEASE_PUB_DATE || "",
|
||||
requiredPlatforms:
|
||||
requiredPlatforms.length > 0
|
||||
? requiredPlatforms
|
||||
: DEFAULT_REQUIRED_PLATFORMS,
|
||||
version:
|
||||
args.version || process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME,
|
||||
});
|
||||
const output = writeOutputs(
|
||||
result,
|
||||
args["out-dir"] || "release-updater",
|
||||
channel,
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
...output,
|
||||
platformCount: Object.keys(result.manifest.platforms).length,
|
||||
version: result.version,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const isCli =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isCli) {
|
||||
main();
|
||||
}
|
||||
|
||||
export { collectUpdaterManifest, normalizeVersionTag, writeOutputs };
|
||||
@@ -0,0 +1,220 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { planR2ReleaseCleanup } from "./plan-r2-release-cleanup.mjs";
|
||||
import {
|
||||
collectUpdaterManifest,
|
||||
writeOutputs,
|
||||
} from "./release-updater-manifest.mjs";
|
||||
|
||||
function writeJson(filePath, data) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function writeFile(filePath, content = "asset") {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
describe("release updater manifest", () => {
|
||||
it("聚合平台 latest.json 并改写为 R2 自域名 URL", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lime-release-"));
|
||||
const assetsDir = path.join(root, "release-assets");
|
||||
|
||||
writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"));
|
||||
writeFile(
|
||||
path.join(assetsDir, "x86_64-apple-darwin", "Lime-x64.app.tar.gz"),
|
||||
);
|
||||
writeFile(path.join(assetsDir, "x86_64-pc-windows-msvc", "Lime.nsis.zip"));
|
||||
|
||||
writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), {
|
||||
version: "1.20.0",
|
||||
notes: "notes",
|
||||
pub_date: "2026-04-28T00:00:00Z",
|
||||
platforms: {
|
||||
"darwin-aarch64": {
|
||||
signature: "sig-arm",
|
||||
url: "https://github.com/limecloud/lime/releases/download/v1.20.0/Lime.app.tar.gz",
|
||||
},
|
||||
},
|
||||
});
|
||||
writeJson(path.join(assetsDir, "x86_64-apple-darwin", "latest.json"), {
|
||||
version: "1.20.0",
|
||||
platforms: {
|
||||
"darwin-x86_64": {
|
||||
signature: "sig-x64",
|
||||
url: "https://github.com/limecloud/lime/releases/download/v1.20.0/Lime-x64.app.tar.gz",
|
||||
},
|
||||
},
|
||||
});
|
||||
writeJson(path.join(assetsDir, "x86_64-pc-windows-msvc", "latest.json"), {
|
||||
version: "1.20.0",
|
||||
platforms: {
|
||||
"windows-x86_64": {
|
||||
signature: "sig-win",
|
||||
url: "https://github.com/limecloud/lime/releases/download/v1.20.0/Lime.nsis.zip",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = collectUpdaterManifest({
|
||||
assetsDir,
|
||||
baseUrl: "https://updates.limecloud.com/",
|
||||
channel: "stable",
|
||||
requiredPlatforms: ["darwin-aarch64", "darwin-x86_64", "windows-x86_64"],
|
||||
version: "v1.20.0",
|
||||
});
|
||||
|
||||
expect(result.manifest.version).toBe("1.20.0");
|
||||
expect(Object.keys(result.manifest.platforms).sort()).toEqual([
|
||||
"darwin-aarch64",
|
||||
"darwin-x86_64",
|
||||
"windows-x86_64",
|
||||
]);
|
||||
expect(result.manifest.platforms["darwin-aarch64"].url).toBe(
|
||||
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz",
|
||||
);
|
||||
expect(result.r2UploadPlan).toHaveLength(3);
|
||||
|
||||
const output = writeOutputs(result, path.join(root, "out"), "stable");
|
||||
expect(fs.existsSync(output.latestPath)).toBe(true);
|
||||
expect(fs.existsSync(output.versionedLatestPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("缺少必需平台时失败,避免发布半可用清单", () => {
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "lime-release-missing-"),
|
||||
);
|
||||
const assetsDir = path.join(root, "release-assets");
|
||||
writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"));
|
||||
writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), {
|
||||
version: "1.20.0",
|
||||
platforms: {
|
||||
"darwin-aarch64": {
|
||||
signature: "sig-arm",
|
||||
url: "https://example.com/Lime.app.tar.gz",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
collectUpdaterManifest({
|
||||
assetsDir,
|
||||
baseUrl: "https://updates.limecloud.com",
|
||||
requiredPlatforms: ["darwin-aarch64", "windows-x86_64"],
|
||||
version: "v1.20.0",
|
||||
}),
|
||||
).toThrow(/missing required platforms/);
|
||||
});
|
||||
|
||||
it("平台 latest.json 版本必须和发布 tag 一致", () => {
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "lime-release-version-"),
|
||||
);
|
||||
const assetsDir = path.join(root, "release-assets");
|
||||
writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"));
|
||||
writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), {
|
||||
version: "1.19.0",
|
||||
platforms: {
|
||||
"darwin-aarch64": {
|
||||
signature: "sig-arm",
|
||||
url: "https://example.com/Lime.app.tar.gz",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
collectUpdaterManifest({
|
||||
assetsDir,
|
||||
baseUrl: "https://updates.limecloud.com",
|
||||
requiredPlatforms: ["darwin-aarch64"],
|
||||
version: "v1.20.0",
|
||||
}),
|
||||
).toThrow(/does not match release/);
|
||||
});
|
||||
|
||||
it("同名跨平台 updater 包应写入独立 R2 路径", () => {
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "lime-release-same-name-"),
|
||||
);
|
||||
const assetsDir = path.join(root, "release-assets");
|
||||
|
||||
writeFile(
|
||||
path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"),
|
||||
"arm",
|
||||
);
|
||||
writeFile(
|
||||
path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz"),
|
||||
"x64",
|
||||
);
|
||||
|
||||
writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), {
|
||||
version: "1.20.0",
|
||||
platforms: {
|
||||
"darwin-aarch64": {
|
||||
signature: "sig-arm",
|
||||
url: "https://example.com/Lime.app.tar.gz",
|
||||
},
|
||||
},
|
||||
});
|
||||
writeJson(path.join(assetsDir, "x86_64-apple-darwin", "latest.json"), {
|
||||
version: "1.20.0",
|
||||
platforms: {
|
||||
"darwin-x86_64": {
|
||||
signature: "sig-x64",
|
||||
url: "https://example.com/Lime.app.tar.gz",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = collectUpdaterManifest({
|
||||
assetsDir,
|
||||
baseUrl: "https://updates.limecloud.com",
|
||||
channel: "stable",
|
||||
requiredPlatforms: ["darwin-aarch64", "darwin-x86_64"],
|
||||
version: "v1.20.0",
|
||||
});
|
||||
|
||||
expect(result.manifest.platforms["darwin-aarch64"].url).toBe(
|
||||
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz",
|
||||
);
|
||||
expect(result.manifest.platforms["darwin-x86_64"].url).toBe(
|
||||
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-x86_64/Lime.app.tar.gz",
|
||||
);
|
||||
expect(result.r2UploadPlan.map((item) => item.key).sort()).toEqual([
|
||||
"lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz",
|
||||
"lime/stable/v1.20.0/darwin-x86_64/Lime.app.tar.gz",
|
||||
]);
|
||||
expect(
|
||||
result.r2UploadPlan
|
||||
.map((item) => fs.readFileSync(item.file, "utf8"))
|
||||
.sort(),
|
||||
).toEqual(["arm", "x64"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("R2 release cleanup", () => {
|
||||
it("只删除超过保留窗口且未受保护的旧版本", () => {
|
||||
const keys = [
|
||||
"lime/stable/v1.20.0/latest.json",
|
||||
"lime/stable/v1.20.0/Lime.nsis.zip",
|
||||
"lime/stable/v1.19.0/Lime.nsis.zip",
|
||||
"lime/stable/v1.18.0/Lime.nsis.zip",
|
||||
"lime/stable/v1.17.0/Lime.nsis.zip",
|
||||
"lime/stable/v1.16.0/Lime.nsis.zip",
|
||||
];
|
||||
|
||||
const plan = planR2ReleaseCleanup({
|
||||
currentVersion: "v1.20.0",
|
||||
keep: 3,
|
||||
keys,
|
||||
minimumSupportedVersion: "v1.16.0",
|
||||
});
|
||||
|
||||
expect(plan.deleteKeys).toEqual(["lime/stable/v1.17.0/Lime.nsis.zip"]);
|
||||
expect(plan.protectedVersions).toContain("1.16.0");
|
||||
});
|
||||
});
|
||||
Generated
+19
-18
@@ -5101,7 +5101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -5205,7 +5205,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-agent"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -5234,7 +5234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-browser-runtime"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -5251,7 +5251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-cli"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"lime-core",
|
||||
@@ -5263,7 +5263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-config"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -5279,7 +5279,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-core"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -5319,7 +5319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-credential"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -5354,7 +5354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-gateway"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"axum 0.7.9",
|
||||
@@ -5384,7 +5384,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-infra"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -5404,7 +5404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-mcp"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -5420,7 +5420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-media-runtime"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -5451,7 +5451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-processor"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -5470,7 +5470,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-providers"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -5525,7 +5525,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-stream",
|
||||
@@ -5570,7 +5570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server-utils"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -5585,7 +5585,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-services"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -5606,6 +5606,7 @@ dependencies = [
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"semver 1.0.27",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -5627,7 +5628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-skills"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -5645,7 +5646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-websocket"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
|
||||
@@ -4,7 +4,7 @@ exclude = ["crates/aster", "crates/aster-models", "crates/aster-rust"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/lime"
|
||||
@@ -81,6 +81,7 @@ cron = "0.15"
|
||||
# 工具库
|
||||
dirs = "5"
|
||||
regex = "1"
|
||||
semver = "1"
|
||||
md5 = "0.7"
|
||||
jsonwebtoken = "9.3.1"
|
||||
urlencoding = "2"
|
||||
@@ -188,7 +189,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "lime"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -48,6 +48,7 @@ glob.workspace = true
|
||||
sysinfo.workspace = true
|
||||
whoami.workspace = true
|
||||
regex.workspace = true
|
||||
semver.workspace = true
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
md5.workspace = true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use semver::{BuildMetadata, Version};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -103,29 +104,29 @@ impl UpdateCheckService {
|
||||
}
|
||||
/// 版本比较:返回 true 如果 latest > current
|
||||
pub fn version_compare(current: &str, latest: &str) -> bool {
|
||||
let current = current.trim_start_matches('v');
|
||||
let latest = latest.trim_start_matches('v');
|
||||
let Some(mut current) = parse_semver(current) else {
|
||||
return false;
|
||||
};
|
||||
let Some(mut latest) = parse_semver(latest) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let current_parts: Vec<u32> = current.split('.').filter_map(|s| s.parse().ok()).collect();
|
||||
let latest_parts: Vec<u32> = latest.split('.').filter_map(|s| s.parse().ok()).collect();
|
||||
|
||||
let max_len = current_parts.len().max(latest_parts.len());
|
||||
|
||||
for i in 0..max_len {
|
||||
let current_part = current_parts.get(i).unwrap_or(&0);
|
||||
let latest_part = latest_parts.get(i).unwrap_or(&0);
|
||||
|
||||
if latest_part > current_part {
|
||||
return true;
|
||||
} else if latest_part < current_part {
|
||||
return false;
|
||||
}
|
||||
if !latest.pre.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
false
|
||||
current.build = BuildMetadata::EMPTY;
|
||||
latest.build = BuildMetadata::EMPTY;
|
||||
|
||||
latest > current
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_semver(value: &str) -> Option<Version> {
|
||||
let trimmed = value.trim().trim_start_matches('v');
|
||||
Version::parse(trimmed).ok()
|
||||
}
|
||||
|
||||
impl Default for UpdateCheckService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -160,6 +161,15 @@ mod tests {
|
||||
assert!(!UpdateCheckService::version_compare("0.14.1", "0.14.0"));
|
||||
assert!(!UpdateCheckService::version_compare("0.14.0", "0.14.0"));
|
||||
assert!(!UpdateCheckService::version_compare("1.0.0", "0.14.0"));
|
||||
assert!(!UpdateCheckService::version_compare(
|
||||
"1.0.0",
|
||||
"1.1.0-beta.1"
|
||||
));
|
||||
assert!(UpdateCheckService::version_compare("1.1.0-beta.1", "1.1.0"));
|
||||
assert!(!UpdateCheckService::version_compare(
|
||||
"1.0.0+build.1",
|
||||
"1.0.0+build.2"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -17,7 +17,7 @@ pub mod scheduler_service;
|
||||
mod state;
|
||||
mod types;
|
||||
mod utils;
|
||||
mod window_chrome;
|
||||
pub(crate) mod window_chrome;
|
||||
|
||||
pub use runner::run;
|
||||
pub use scheduler_service::{SchedulerService, SchedulerServiceConfig};
|
||||
|
||||
@@ -51,19 +51,6 @@ fn should_minimize_to_tray(window_label: &str, minimize_to_tray: bool) -> bool {
|
||||
minimize_to_tray && window_label == MAIN_WINDOW_LABEL
|
||||
}
|
||||
|
||||
fn reveal_main_window(window: &tauri::WebviewWindow) {
|
||||
let run_action = |action: &str, operation: &dyn Fn() -> tauri::Result<()>| {
|
||||
if let Err(error) = operation() {
|
||||
tracing::warn!("[启动] 主窗口{}失败: {}", action, error);
|
||||
}
|
||||
};
|
||||
|
||||
run_action("取消最小化", &|| window.unminimize());
|
||||
run_action("最大化", &|| window.maximize());
|
||||
run_action("显示", &|| window.show());
|
||||
run_action("聚焦", &|| window.set_focus());
|
||||
}
|
||||
|
||||
fn report_fatal_startup_error(stage: &str, error: &str) {
|
||||
let message = format!("Lime 启动失败({stage})\n\n{error}");
|
||||
tracing::error!("{message}");
|
||||
@@ -209,6 +196,11 @@ pub fn run() {
|
||||
builder = builder.plugin(tauri_plugin_deep_link::init());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
builder = builder.menu(super::window_chrome::build_lime_app_menu);
|
||||
}
|
||||
|
||||
if should_enable_single_instance() {
|
||||
builder = builder
|
||||
// 单实例插件:当第二个实例启动时,将 URL 传递给第一个实例
|
||||
@@ -218,7 +210,7 @@ pub fn run() {
|
||||
// 将窗口带到前台
|
||||
if should_reveal_main_window_on_startup() {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
reveal_main_window(&window);
|
||||
super::window_chrome::reveal_main_window(&window, "启动");
|
||||
}
|
||||
} else {
|
||||
tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)");
|
||||
@@ -327,7 +319,7 @@ pub fn run() {
|
||||
super::window_chrome::apply_main_window_chrome(&main_window);
|
||||
|
||||
if should_reveal_main_window_on_startup() {
|
||||
reveal_main_window(&main_window);
|
||||
super::window_chrome::reveal_main_window(&main_window, "启动");
|
||||
} else {
|
||||
tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)");
|
||||
}
|
||||
|
||||
@@ -1,13 +1,150 @@
|
||||
//! 主窗口系统 chrome 收口。
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum WindowRevealAction {
|
||||
Unminimize,
|
||||
Maximize,
|
||||
Show,
|
||||
Focus,
|
||||
}
|
||||
|
||||
const WINDOW_REVEAL_ACTIONS: [WindowRevealAction; 4] = [
|
||||
WindowRevealAction::Unminimize,
|
||||
WindowRevealAction::Maximize,
|
||||
WindowRevealAction::Show,
|
||||
WindowRevealAction::Focus,
|
||||
];
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const LIME_MACOS_APP_ICON_BYTES: &[u8] = include_bytes!("../../../public/logo.png");
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const LIME_MACOS_ABOUT_COPYRIGHT: &str = "Copyright © 2026 Lime";
|
||||
|
||||
impl WindowRevealAction {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unminimize => "取消最小化",
|
||||
Self::Maximize => "最大化",
|
||||
Self::Show => "显示",
|
||||
Self::Focus => "聚焦",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用主窗口的 macOS 标题栏样式,避免启动配置被 headless/dev 链路绕过。
|
||||
pub fn apply_main_window_chrome(window: &tauri::WebviewWindow) {
|
||||
apply_macos_titlebar_overlay(window);
|
||||
apply_macos_application_icon();
|
||||
}
|
||||
|
||||
/// 最大化、显示并聚焦主窗口,保持启动和托盘打开时的铺满体验。
|
||||
pub fn reveal_main_window<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>, source: &str) {
|
||||
for action in WINDOW_REVEAL_ACTIONS {
|
||||
let result = match action {
|
||||
WindowRevealAction::Unminimize => window.unminimize(),
|
||||
WindowRevealAction::Maximize => window.maximize(),
|
||||
WindowRevealAction::Show => window.show(),
|
||||
WindowRevealAction::Focus => window.set_focus(),
|
||||
};
|
||||
|
||||
if let Err(error) = result {
|
||||
tracing::warn!("[{}] 主窗口{}失败: {}", source, action.label(), error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn build_lime_app_menu<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
) -> tauri::Result<tauri::menu::Menu<R>> {
|
||||
use tauri::image::Image;
|
||||
use tauri::menu::{
|
||||
AboutMetadata, Menu, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
|
||||
};
|
||||
|
||||
let package_info = app_handle.package_info();
|
||||
let version = package_info.version.to_string();
|
||||
let icon = Image::from_bytes(LIME_MACOS_APP_ICON_BYTES)?;
|
||||
let about_metadata = AboutMetadata {
|
||||
name: Some(package_info.name.clone()),
|
||||
version: Some(version.clone()),
|
||||
short_version: Some(version),
|
||||
copyright: Some(LIME_MACOS_ABOUT_COPYRIGHT.to_string()),
|
||||
icon: Some(icon),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let window_menu = Submenu::with_id_and_items(
|
||||
app_handle,
|
||||
WINDOW_SUBMENU_ID,
|
||||
"Window",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::minimize(app_handle, None)?,
|
||||
&PredefinedMenuItem::maximize(app_handle, None)?,
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
&PredefinedMenuItem::close_window(app_handle, None)?,
|
||||
],
|
||||
)?;
|
||||
|
||||
let help_menu = Submenu::with_id_and_items(app_handle, HELP_SUBMENU_ID, "Help", true, &[])?;
|
||||
|
||||
Menu::with_items(
|
||||
app_handle,
|
||||
&[
|
||||
&Submenu::with_items(
|
||||
app_handle,
|
||||
package_info.name.clone(),
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::about(app_handle, None, Some(about_metadata))?,
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
&PredefinedMenuItem::services(app_handle, None)?,
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
&PredefinedMenuItem::hide(app_handle, None)?,
|
||||
&PredefinedMenuItem::hide_others(app_handle, None)?,
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
&PredefinedMenuItem::quit(app_handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
app_handle,
|
||||
"File",
|
||||
true,
|
||||
&[&PredefinedMenuItem::close_window(app_handle, None)?],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
app_handle,
|
||||
"Edit",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::undo(app_handle, None)?,
|
||||
&PredefinedMenuItem::redo(app_handle, None)?,
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
&PredefinedMenuItem::cut(app_handle, None)?,
|
||||
&PredefinedMenuItem::copy(app_handle, None)?,
|
||||
&PredefinedMenuItem::paste(app_handle, None)?,
|
||||
&PredefinedMenuItem::select_all(app_handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
app_handle,
|
||||
"View",
|
||||
true,
|
||||
&[&PredefinedMenuItem::fullscreen(app_handle, None)?],
|
||||
)?,
|
||||
&window_menu,
|
||||
&help_menu,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn apply_macos_titlebar_overlay(_window: &tauri::WebviewWindow) {}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn apply_macos_application_icon() {}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(deprecated)]
|
||||
fn apply_macos_titlebar_overlay(window: &tauri::WebviewWindow) {
|
||||
@@ -30,7 +167,7 @@ fn apply_macos_titlebar_overlay(window: &tauri::WebviewWindow) {
|
||||
ns_window.styleMask() | NSWindowStyleMask::NSFullSizeContentViewWindowMask;
|
||||
ns_window.setStyleMask_(style_mask);
|
||||
|
||||
hide_standard_window_buttons(ns_window);
|
||||
show_standard_window_buttons(ns_window);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -44,9 +181,38 @@ fn apply_macos_titlebar_overlay(window: &tauri::WebviewWindow) {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(deprecated)]
|
||||
unsafe fn hide_standard_window_buttons(ns_window: cocoa::base::id) {
|
||||
fn apply_macos_application_icon() {
|
||||
use cocoa::appkit::{NSApp, NSApplication, NSImage};
|
||||
use cocoa::base::{id, nil};
|
||||
use cocoa::foundation::{NSData, NSUInteger};
|
||||
use std::ffi::c_void;
|
||||
|
||||
unsafe {
|
||||
let data = <id as NSData>::dataWithBytes_length_(
|
||||
nil,
|
||||
LIME_MACOS_APP_ICON_BYTES.as_ptr() as *const c_void,
|
||||
LIME_MACOS_APP_ICON_BYTES.len() as NSUInteger,
|
||||
);
|
||||
if data == nil {
|
||||
tracing::warn!("[窗口] 创建 Lime logo 数据失败,无法设置 macOS About 图标");
|
||||
return;
|
||||
}
|
||||
|
||||
let image = <id as NSImage>::initWithData_(NSImage::alloc(nil), data);
|
||||
if image == nil {
|
||||
tracing::warn!("[窗口] 解析 Lime logo 图片失败,无法设置 macOS About 图标");
|
||||
return;
|
||||
}
|
||||
|
||||
NSApp().setApplicationIconImage_(image);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(deprecated)]
|
||||
unsafe fn show_standard_window_buttons(ns_window: cocoa::base::id) {
|
||||
use cocoa::appkit::{NSWindow, NSWindowButton};
|
||||
use cocoa::base::{nil, YES};
|
||||
use cocoa::base::{nil, NO};
|
||||
use objc::{msg_send, sel, sel_impl};
|
||||
|
||||
for button_kind in [
|
||||
@@ -56,7 +222,46 @@ unsafe fn hide_standard_window_buttons(ns_window: cocoa::base::id) {
|
||||
] {
|
||||
let button = ns_window.standardWindowButton_(button_kind);
|
||||
if button != nil {
|
||||
let _: () = msg_send![button, setHidden: YES];
|
||||
let _: () = msg_send![button, setHidden: NO];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
WindowRevealAction, LIME_MACOS_ABOUT_COPYRIGHT, LIME_MACOS_APP_ICON_BYTES,
|
||||
WINDOW_REVEAL_ACTIONS,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn reveal_actions_should_keep_maximized_startup_order() {
|
||||
assert_eq!(
|
||||
WINDOW_REVEAL_ACTIONS,
|
||||
[
|
||||
WindowRevealAction::Unminimize,
|
||||
WindowRevealAction::Maximize,
|
||||
WindowRevealAction::Show,
|
||||
WindowRevealAction::Focus,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_about_icon_should_use_lime_logo_png() {
|
||||
assert!(LIME_MACOS_APP_ICON_BYTES.starts_with(b"\x89PNG\r\n\x1a\n"));
|
||||
assert!(
|
||||
LIME_MACOS_APP_ICON_BYTES.len() > 1024,
|
||||
"Lime logo 图标资源不应为空或退回占位图"
|
||||
);
|
||||
assert!(
|
||||
tauri::image::Image::from_bytes(LIME_MACOS_APP_ICON_BYTES).is_ok(),
|
||||
"macOS About 菜单必须能把 Lime logo 解码成原生菜单图标"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_about_metadata_should_stay_minimal() {
|
||||
assert_eq!(LIME_MACOS_ABOUT_COPYRIGHT, "Copyright © 2026 Lime");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,12 @@ use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
const DAY_SECONDS: u64 = 24 * 3600;
|
||||
const UPDATE_CHECK_CACHE_TTL_SECS: u64 = 10 * 60;
|
||||
const FALLBACK_RELEASES_URL: &str = "https://github.com/aiclientproxy/lime/releases";
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"https://github.com/aiclientproxy/lime/releases/latest/download/latest.json";
|
||||
const FALLBACK_RELEASES_URL: &str = "https://github.com/limecloud/lime/releases";
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str = "https://updates.limecloud.com/lime/stable/latest.json";
|
||||
|
||||
/// 编译期注入 updater 公钥;开发环境可为空,此时仅保留手动下载兜底。
|
||||
const COMPILED_UPDATER_PUBLIC_KEY: Option<&str> = option_env!("LIME_UPDATER_PUBLIC_KEY");
|
||||
/// 编译期注入 updater manifest 地址;未配置时使用 GitHub Releases latest.json。
|
||||
/// 编译期注入 updater manifest 地址;未配置时使用 Cloudflare R2 稳定清单。
|
||||
const COMPILED_UPDATER_ENDPOINT: Option<&str> = option_env!("LIME_UPDATER_ENDPOINT");
|
||||
|
||||
/// 更新检查配置(前端可见)
|
||||
@@ -62,6 +61,8 @@ pub struct VersionCheckResult {
|
||||
pub download_url: Option<String>,
|
||||
#[serde(rename = "releaseNotes")]
|
||||
pub release_notes: Option<String>,
|
||||
#[serde(rename = "releaseNotesUrl")]
|
||||
pub release_notes_url: Option<String>,
|
||||
#[serde(rename = "pubDate")]
|
||||
pub pub_date: Option<String>,
|
||||
pub error: Option<String>,
|
||||
@@ -97,7 +98,6 @@ struct StaticUpdateManifest {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StaticUpdatePlatform {
|
||||
url: String,
|
||||
#[allow(dead_code)]
|
||||
signature: Option<String>,
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ fn updater_public_key() -> Option<&'static str> {
|
||||
|
||||
fn release_tag_url(version: &str) -> String {
|
||||
format!(
|
||||
"https://github.com/aiclientproxy/lime/releases/tag/v{}",
|
||||
"https://github.com/limecloud/lime/releases/tag/v{}",
|
||||
version.trim_start_matches('v')
|
||||
)
|
||||
}
|
||||
@@ -195,16 +195,18 @@ fn build_update_info(
|
||||
latest: Option<String>,
|
||||
release_notes: Option<String>,
|
||||
pub_date: Option<String>,
|
||||
download_url: Option<String>,
|
||||
error: Option<String>,
|
||||
) -> UpdateInfo {
|
||||
let current_version = UpdateCheckService::current_version().to_string();
|
||||
let latest_version = latest
|
||||
.as_deref()
|
||||
.map(|value| value.trim_start_matches('v').to_string());
|
||||
let download_url = latest_version
|
||||
let release_page_url = latest_version
|
||||
.as_deref()
|
||||
.map(release_tag_url)
|
||||
.or_else(|| Some(FALLBACK_RELEASES_URL.to_string()));
|
||||
let download_url = download_url.or_else(|| release_page_url.clone());
|
||||
let has_update = latest_version
|
||||
.as_deref()
|
||||
.map(|latest_version| UpdateCheckService::version_compare(¤t_version, latest_version))
|
||||
@@ -215,7 +217,7 @@ fn build_update_info(
|
||||
latest_version,
|
||||
has_update,
|
||||
download_url: download_url.clone(),
|
||||
release_notes_url: download_url,
|
||||
release_notes_url: release_page_url,
|
||||
release_notes,
|
||||
pub_date,
|
||||
checked_at: current_unix_timestamp(),
|
||||
@@ -228,20 +230,16 @@ fn build_update_info_from_cache_or_default(
|
||||
error: Option<String>,
|
||||
) -> UpdateInfo {
|
||||
if let Some(cached) = cache {
|
||||
let mut info = build_update_info(
|
||||
return build_update_info(
|
||||
cached.latest.clone(),
|
||||
cached.release_notes.clone(),
|
||||
cached.pub_date.clone(),
|
||||
cached.download_url.clone(),
|
||||
error,
|
||||
);
|
||||
if cached.download_url.is_some() {
|
||||
info.download_url = cached.download_url.clone();
|
||||
info.release_notes_url = cached.download_url.clone();
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
build_update_info(None, None, None, error)
|
||||
build_update_info(None, None, None, None, error)
|
||||
}
|
||||
|
||||
fn build_version_check_result(info: UpdateInfo) -> VersionCheckResult {
|
||||
@@ -251,6 +249,7 @@ fn build_version_check_result(info: UpdateInfo) -> VersionCheckResult {
|
||||
has_update: info.has_update,
|
||||
download_url: info.download_url,
|
||||
release_notes: info.release_notes,
|
||||
release_notes_url: info.release_notes_url,
|
||||
pub_date: info.pub_date,
|
||||
error: info.error,
|
||||
}
|
||||
@@ -259,26 +258,49 @@ fn build_version_check_result(info: UpdateInfo) -> VersionCheckResult {
|
||||
fn manifest_to_cache(manifest: &StaticUpdateManifest, checked_at: u64) -> UpdateCheckCache {
|
||||
UpdateCheckCache {
|
||||
latest: Some(manifest.version.trim_start_matches('v').to_string()),
|
||||
download_url: Some(release_tag_url(&manifest.version)),
|
||||
download_url: manifest_platform_download_url(manifest)
|
||||
.or_else(|| Some(release_tag_url(&manifest.version))),
|
||||
release_notes: manifest.notes.clone(),
|
||||
pub_date: manifest.pub_date.clone(),
|
||||
last_checked_unix: checked_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_platform_download_url(manifest: &StaticUpdateManifest) -> Option<String> {
|
||||
current_platform_key()
|
||||
.and_then(|platform_key| manifest.platforms.get(platform_key))
|
||||
.filter(|platform| {
|
||||
!platform.url.trim().is_empty()
|
||||
&& platform
|
||||
.signature
|
||||
.as_deref()
|
||||
.is_some_and(|signature| !signature.trim().is_empty())
|
||||
})
|
||||
.map(|platform| platform.url.trim())
|
||||
.filter(|url| !url.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn build_update_info_from_manifest(manifest: StaticUpdateManifest) -> UpdateInfo {
|
||||
let latest_version = manifest.version.trim_start_matches('v').to_string();
|
||||
let download_url = manifest_platform_download_url(&manifest);
|
||||
let platform_error = match current_platform_key() {
|
||||
Some(platform_key)
|
||||
if manifest
|
||||
.platforms
|
||||
.get(platform_key)
|
||||
.is_some_and(|platform| !platform.url.trim().is_empty()) =>
|
||||
.is_some_and(|platform| {
|
||||
!platform.url.trim().is_empty()
|
||||
&& platform
|
||||
.signature
|
||||
.as_deref()
|
||||
.is_some_and(|signature| !signature.trim().is_empty())
|
||||
}) =>
|
||||
{
|
||||
None
|
||||
}
|
||||
Some(platform_key) if manifest.platforms.contains_key(platform_key) => Some(format!(
|
||||
"已检测到新版本,但当前平台 {} 的安装包地址为空,请前往发布页手动下载",
|
||||
"已检测到新版本,但当前平台 {} 的更新包地址或签名为空,请前往发布页手动下载",
|
||||
platform_key
|
||||
)),
|
||||
Some(platform_key) => Some(format!(
|
||||
@@ -292,6 +314,7 @@ fn build_update_info_from_manifest(manifest: StaticUpdateManifest) -> UpdateInfo
|
||||
Some(latest_version),
|
||||
manifest.notes,
|
||||
manifest.pub_date,
|
||||
download_url,
|
||||
platform_error,
|
||||
)
|
||||
}
|
||||
@@ -840,6 +863,7 @@ mod tests {
|
||||
fn test_build_update_info_from_manifest() {
|
||||
let next_version_tag = next_patch_version_tag();
|
||||
let next_version = next_version_tag.trim_start_matches('v').to_string();
|
||||
let platform_url = "https://updates.limecloud.com/lime/stable/v9.9.9/lime.app.tar.gz";
|
||||
let manifest = StaticUpdateManifest {
|
||||
version: next_version_tag,
|
||||
notes: Some("bug fixes".to_string()),
|
||||
@@ -849,7 +873,7 @@ mod tests {
|
||||
.unwrap_or("windows-x86_64")
|
||||
.to_string(),
|
||||
StaticUpdatePlatform {
|
||||
url: "https://example.com/lime.nsis.zip".to_string(),
|
||||
url: platform_url.to_string(),
|
||||
signature: Some("sig".to_string()),
|
||||
},
|
||||
)]),
|
||||
@@ -858,6 +882,51 @@ mod tests {
|
||||
let info = build_update_info_from_manifest(manifest);
|
||||
assert_eq!(info.latest_version.as_deref(), Some(next_version.as_str()));
|
||||
assert!(info.has_update);
|
||||
assert_eq!(info.error, None);
|
||||
if current_platform_key().is_some() {
|
||||
assert_eq!(info.download_url.as_deref(), Some(platform_url));
|
||||
assert_eq!(info.error, None);
|
||||
} else {
|
||||
assert_eq!(
|
||||
info.download_url.as_deref(),
|
||||
Some(release_tag_url(&next_version).as_str())
|
||||
);
|
||||
assert!(info
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|message| message.contains("暂不支持")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_update_info_from_manifest_requires_signature() {
|
||||
let Some(platform_key) = current_platform_key() else {
|
||||
return;
|
||||
};
|
||||
let manifest = StaticUpdateManifest {
|
||||
version: next_patch_version_tag(),
|
||||
notes: None,
|
||||
pub_date: None,
|
||||
platforms: HashMap::from([(
|
||||
platform_key.to_string(),
|
||||
StaticUpdatePlatform {
|
||||
url: "https://updates.limecloud.com/lime/stable/v9.9.9/lime.app.tar.gz"
|
||||
.to_string(),
|
||||
signature: None,
|
||||
},
|
||||
)]),
|
||||
};
|
||||
|
||||
let info = build_update_info_from_manifest(manifest);
|
||||
assert!(info.has_update);
|
||||
let expected_release_url =
|
||||
release_tag_url(info.latest_version.as_deref().expect("应存在最新版本"));
|
||||
assert_eq!(
|
||||
info.download_url.as_deref(),
|
||||
Some(expected_release_url.as_str())
|
||||
);
|
||||
assert!(info
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|message| message.contains("更新包地址或签名为空")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,6 +783,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_generate_title_bridge_requires_app_handle_in_test_state() {
|
||||
let state = make_test_state();
|
||||
|
||||
let error = handle_command(
|
||||
&state,
|
||||
"agent_generate_title",
|
||||
Some(serde_json::json!({
|
||||
"sessionId": "session-1",
|
||||
"titleKind": "session"
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect_err("agent_generate_title without app handle should fail after routing");
|
||||
|
||||
assert!(error.to_string().contains("Dev Bridge 未持有 AppHandle"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_provider_models_auto_is_bridged() {
|
||||
let state = make_test_state();
|
||||
|
||||
@@ -41,7 +41,8 @@ pub(super) async fn try_handle(
|
||||
) -> Result<Option<JsonValue>, DynError> {
|
||||
if !matches!(
|
||||
cmd,
|
||||
"agent_runtime_submit_turn"
|
||||
"agent_generate_title"
|
||||
| "agent_runtime_submit_turn"
|
||||
| "agent_runtime_interrupt_turn"
|
||||
| "agent_runtime_compact_session"
|
||||
| "agent_runtime_resume_thread"
|
||||
@@ -65,6 +66,40 @@ pub(super) async fn try_handle(
|
||||
|
||||
let app_handle = require_app_handle(state)?;
|
||||
let result = match cmd {
|
||||
"agent_generate_title" => {
|
||||
let args = args_or_default(args);
|
||||
let session_id = args
|
||||
.get("sessionId")
|
||||
.or_else(|| args.get("session_id"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
let preview_text = args
|
||||
.get("previewText")
|
||||
.or_else(|| args.get("preview_text"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
let title_kind = args
|
||||
.get("titleKind")
|
||||
.or_else(|| args.get("title_kind"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
let config_manager = app_handle.state::<crate::config::GlobalConfigManagerState>();
|
||||
|
||||
serde_json::to_value(
|
||||
crate::commands::agent_cmd::agent_generate_title(
|
||||
app_handle.clone(),
|
||||
aster_state,
|
||||
db,
|
||||
config_manager,
|
||||
session_id,
|
||||
preview_text,
|
||||
title_kind,
|
||||
)
|
||||
.await?,
|
||||
)?
|
||||
}
|
||||
"agent_runtime_submit_turn" => {
|
||||
let request = parse_request::<
|
||||
crate::commands::aster_agent_cmd::AgentRuntimeSubmitTurnRequest,
|
||||
|
||||
@@ -110,27 +110,8 @@ fn show_and_focus_main_window<R: Runtime>(app: &AppHandle<R>) {
|
||||
|
||||
/// 显示并聚焦窗口的辅助函数
|
||||
fn show_and_focus_window<R: Runtime>(window: &tauri::WebviewWindow<R>) {
|
||||
// 取消最小化
|
||||
if let Err(e) = window.unminimize() {
|
||||
tracing::error!("取消最小化窗口失败: {}", e);
|
||||
}
|
||||
|
||||
// 保持与主窗口默认行为一致:显示时尽量最大化
|
||||
if let Err(e) = window.maximize() {
|
||||
tracing::error!("最大化主窗口失败: {}", e);
|
||||
}
|
||||
|
||||
// 显示窗口
|
||||
if let Err(e) = window.show() {
|
||||
tracing::error!("显示主窗口失败: {}", e);
|
||||
} else {
|
||||
info!("主窗口已显示");
|
||||
}
|
||||
|
||||
// 聚焦窗口
|
||||
if let Err(e) = window.set_focus() {
|
||||
tracing::error!("聚焦主窗口失败: {}", e);
|
||||
}
|
||||
crate::app::window_chrome::reveal_main_window(window, "托盘");
|
||||
info!("主窗口已显示");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -128,26 +128,7 @@ fn handle_open_window<R: Runtime>(app: &AppHandle<R>) {
|
||||
info!("[托盘] 用户请求打开主窗口");
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
// 取消最小化
|
||||
if let Err(e) = window.unminimize() {
|
||||
error!("[托盘] 取消最小化窗口失败: {}", e);
|
||||
}
|
||||
|
||||
// 保持与主窗口默认行为一致:显示时尽量最大化
|
||||
if let Err(e) = window.maximize() {
|
||||
error!("[托盘] 最大化主窗口失败: {}", e);
|
||||
}
|
||||
|
||||
// 显示窗口
|
||||
if let Err(e) = window.show() {
|
||||
error!("[托盘] 显示主窗口失败: {}", e);
|
||||
}
|
||||
|
||||
// 聚焦窗口
|
||||
if let Err(e) = window.set_focus() {
|
||||
error!("[托盘] 聚焦主窗口失败: {}", e);
|
||||
}
|
||||
|
||||
crate::app::window_chrome::reveal_main_window(&window, "托盘");
|
||||
info!("[托盘] 主窗口已显示并聚焦");
|
||||
} else {
|
||||
warn!("[托盘] 未找到主窗口");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Lime",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"identifier": "com.limecloud.lime.headless",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:web-bridge",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Lime",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"identifier": "com.limecloud.lime",
|
||||
"build": {
|
||||
"beforeDevCommand": "node scripts/start-tauri-dev-server.mjs",
|
||||
@@ -84,7 +84,9 @@
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["lime"]
|
||||
"schemes": [
|
||||
"lime"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+69
-2
@@ -10,6 +10,7 @@
|
||||
|
||||
import React, { Suspense, lazy, useState, useCallback } from "react";
|
||||
import styled from "styled-components";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { withI18nPatch } from "./i18n/withI18nPatch";
|
||||
import { AppPageContent } from "./components/AppPageContent";
|
||||
import { SplashScreen } from "./components/SplashScreen";
|
||||
@@ -35,6 +36,11 @@ import { useOemLimeHubProviderSync } from "./hooks/useOemLimeHubProviderSync";
|
||||
import { ComponentDebugProvider } from "./contexts/ComponentDebugContext";
|
||||
import { SoundProvider } from "./contexts/SoundProvider";
|
||||
import { ComponentDebugOverlay } from "./components/dev";
|
||||
import {
|
||||
useResourceManagerNavigationIntents,
|
||||
type ResourceManagerNavigationDestination,
|
||||
type ResourceManagerNavigationIntent,
|
||||
} from "./features/resource-manager";
|
||||
import type { OpenDeepLinkPayload } from "./hooks/useDeepLink";
|
||||
import { buildClawAgentParams } from "./lib/workspace/navigation";
|
||||
import { resolveWebsiteOpenNavigation } from "./lib/deepLink/websiteLaunch";
|
||||
@@ -46,7 +52,7 @@ const AppContainer = styled.div`
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-color: hsl(var(--background));
|
||||
background: var(--lime-app-bg, hsl(var(--background)));
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
@@ -57,6 +63,19 @@ const MainContent = styled.main<{ $withSidebarGap?: boolean }>`
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-left: ${(props) => (props.$withSidebarGap ? "10px" : "0")};
|
||||
background: var(--lime-app-bg, hsl(var(--background)));
|
||||
`;
|
||||
|
||||
const WindowDragRegion = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 160px;
|
||||
right: 0;
|
||||
height: 28px;
|
||||
z-index: 1000;
|
||||
background: transparent;
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
`;
|
||||
|
||||
const RecentImageInsertFloating = lazy(() =>
|
||||
@@ -121,6 +140,29 @@ function AppContent() {
|
||||
useServiceSkillCatalogBootstrap();
|
||||
useSiteAdapterCatalogBootstrap();
|
||||
useOemLimeHubProviderSync();
|
||||
const handleResourceManagerNavigationHandled = useCallback(
|
||||
({
|
||||
destination,
|
||||
}: {
|
||||
intent: ResourceManagerNavigationIntent;
|
||||
destination: ResourceManagerNavigationDestination;
|
||||
}) => {
|
||||
toast.success(destination.noticeTitle, {
|
||||
description: destination.noticeDescription,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleResourceManagerNavigationUnsupported = useCallback(() => {
|
||||
toast.info("当前资源暂时不能自动回跳", {
|
||||
description: "已记录来源信息,但主窗口还没有对应的业务入口。",
|
||||
});
|
||||
}, []);
|
||||
useResourceManagerNavigationIntents({
|
||||
onNavigate: handleNavigate,
|
||||
onHandled: handleResourceManagerNavigationHandled,
|
||||
onUnsupported: handleResourceManagerNavigationUnsupported,
|
||||
});
|
||||
useGlobalTrayModelSync({
|
||||
currentPage,
|
||||
pageParams,
|
||||
@@ -208,7 +250,8 @@ function AppContent() {
|
||||
|
||||
if (!resolved) {
|
||||
toast.error("无法打开这个官网入口", {
|
||||
description: "当前 slug 没有对应到桌面端可用能力,请同步官网与客户端目录。",
|
||||
description:
|
||||
"当前 slug 没有对应到桌面端可用能力,请同步官网与客户端目录。",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -253,6 +296,24 @@ function AppContent() {
|
||||
completeOnboarding();
|
||||
}, [completeOnboarding]);
|
||||
|
||||
const handleWindowDragStart = useCallback(
|
||||
async (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!hasTauriDesktopRuntime || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
const currentWindow = getCurrentWindow();
|
||||
await currentWindow.startDragging();
|
||||
} catch (error) {
|
||||
console.warn("[窗口] 主窗口拖拽启动失败:", error);
|
||||
}
|
||||
},
|
||||
[hasTauriDesktopRuntime],
|
||||
);
|
||||
|
||||
if (showSplash) {
|
||||
return <SplashScreen onComplete={handleSplashComplete} />;
|
||||
}
|
||||
@@ -273,6 +334,12 @@ function AppContent() {
|
||||
<SoundProvider>
|
||||
<ComponentDebugProvider>
|
||||
<AppContainer>
|
||||
{hasTauriDesktopRuntime ? (
|
||||
<WindowDragRegion
|
||||
data-tauri-drag-region
|
||||
onMouseDown={handleWindowDragStart}
|
||||
/>
|
||||
) : null}
|
||||
{shouldShowAppSidebar && (
|
||||
<AppSidebar
|
||||
currentPage={currentPage}
|
||||
|
||||
@@ -8,6 +8,7 @@ import App from "./App";
|
||||
import { SmartInputPage } from "./pages/smart-input";
|
||||
import { UpdateNotificationPage } from "./pages/update-notification";
|
||||
import { BrowserRuntimeDebuggerPage } from "./pages";
|
||||
import { ResourceManagerPage } from "./features/resource-manager";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { AppCrashBoundary } from "./components/layout/AppCrashBoundary";
|
||||
import { finalizeModuleImportAutoReload } from "./components/layout/CrashRecoveryPanel.helpers";
|
||||
@@ -19,6 +20,7 @@ import { getRuntimeAppVersion } from "./lib/appVersion";
|
||||
* - /smart-input: 截图对话悬浮窗口(独立 Tauri 窗口,支持语音模式)
|
||||
* - /update-notification: 更新提醒悬浮窗口(独立 Tauri 窗口)
|
||||
* - /browser-runtime-debugger: 浏览器运行时独立调试窗口
|
||||
* - /resource-manager: 独立资源管理器窗口
|
||||
* - 其他: 主应用
|
||||
*/
|
||||
export function RootRouter() {
|
||||
@@ -64,6 +66,15 @@ export function RootRouter() {
|
||||
);
|
||||
}
|
||||
|
||||
if (pathname === "/resource-manager") {
|
||||
return (
|
||||
<AppCrashBoundary>
|
||||
<ResourceManagerPage />
|
||||
<Toaster />
|
||||
</AppCrashBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// 默认渲染主应用
|
||||
return (
|
||||
<AppCrashBoundary>
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
OpenClawPageParams,
|
||||
Page,
|
||||
PageParams,
|
||||
ResourcesPageParams,
|
||||
SceneAppsPageParams,
|
||||
SettingsPageParams,
|
||||
SkillsPageParams,
|
||||
@@ -262,7 +263,10 @@ export function AppPageContent({
|
||||
if (activePage === "resources") {
|
||||
const content = (
|
||||
<div style={columnPageStyle}>
|
||||
<ResourcesPage onNavigate={onNavigate} />
|
||||
<ResourcesPage
|
||||
onNavigate={onNavigate}
|
||||
pageParams={activePageParams as ResourcesPageParams}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -5,31 +5,62 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentPageParams, Page, PageParams } from "@/types/page";
|
||||
import { SettingsTabs } from "@/types/settings";
|
||||
import { AppSidebar } from "./AppSidebar";
|
||||
import { LIME_COLOR_SCHEME_STORAGE_KEY } from "@/lib/appearance/colorSchemes";
|
||||
import { LIME_THEME_STORAGE_KEY } from "@/lib/appearance/themeMode";
|
||||
import {
|
||||
getStoredOemCloudSessionState,
|
||||
setOemCloudBootstrapSnapshot,
|
||||
setStoredOemCloudSessionState,
|
||||
} from "@/lib/oemCloudSession";
|
||||
|
||||
const {
|
||||
mockGetConfig,
|
||||
mockSaveConfig,
|
||||
mockGetPluginsForSurface,
|
||||
mockSubscribeAppConfigChanged,
|
||||
mockListAgentRuntimeSessions,
|
||||
mockUpdateAgentRuntimeSession,
|
||||
mockSetI18nLanguage,
|
||||
mockScheduleMinimumDelayIdleTask,
|
||||
mockLogoutClient,
|
||||
mockGetClientReferralDashboard,
|
||||
mockClearSiteAdapterCatalogCache,
|
||||
mockToastSuccess,
|
||||
mockToastError,
|
||||
mockToastInfo,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetConfig: vi.fn(),
|
||||
mockSaveConfig: vi.fn(),
|
||||
mockGetPluginsForSurface: vi.fn(),
|
||||
mockSubscribeAppConfigChanged: vi.fn(),
|
||||
mockListAgentRuntimeSessions: vi.fn(),
|
||||
mockUpdateAgentRuntimeSession: vi.fn(),
|
||||
mockSetI18nLanguage: vi.fn(),
|
||||
mockScheduleMinimumDelayIdleTask: vi.fn((task: () => void) => {
|
||||
task();
|
||||
return () => undefined;
|
||||
}),
|
||||
mockLogoutClient: vi.fn(),
|
||||
mockGetClientReferralDashboard: vi.fn(),
|
||||
mockClearSiteAdapterCatalogCache: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
mockToastInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/appConfig", () => ({
|
||||
getConfig: mockGetConfig,
|
||||
saveConfig: mockSaveConfig,
|
||||
subscribeAppConfigChanged: mockSubscribeAppConfigChanged,
|
||||
}));
|
||||
|
||||
vi.mock("@/i18n/I18nPatchProvider", () => ({
|
||||
useI18nPatch: () => ({
|
||||
language: "zh",
|
||||
setLanguage: mockSetI18nLanguage,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/pluginUI", () => ({
|
||||
getPluginsForSurface: mockGetPluginsForSurface,
|
||||
}));
|
||||
@@ -39,6 +70,23 @@ vi.mock("@/lib/api/agentRuntime", () => ({
|
||||
updateAgentRuntimeSession: mockUpdateAgentRuntimeSession,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/oemCloudControlPlane", () => ({
|
||||
logoutClient: mockLogoutClient,
|
||||
getClientReferralDashboard: mockGetClientReferralDashboard,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
info: mockToastInfo,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/siteAdapterCatalogBootstrap", () => ({
|
||||
clearSiteAdapterCatalogCache: mockClearSiteAdapterCatalogCache,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/scheduleMinimumDelayIdleTask", () => ({
|
||||
scheduleMinimumDelayIdleTask: mockScheduleMinimumDelayIdleTask,
|
||||
}));
|
||||
@@ -92,14 +140,88 @@ async function flushEffects(times = 1) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openAccountMenu(container: HTMLElement) {
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="app-sidebar-account-button"]',
|
||||
)
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function clickAccountMenuItem(container: HTMLElement, label: string) {
|
||||
await openAccountMenu(container);
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("AppSidebar", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
localStorage.clear();
|
||||
delete window.__LIME_BOOTSTRAP__;
|
||||
delete window.__LIME_OEM_CLOUD__;
|
||||
delete window.__LIME_SESSION_TOKEN__;
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.removeAttribute("data-lime-theme");
|
||||
document.documentElement.removeAttribute("data-lime-color-scheme");
|
||||
document.documentElement.removeAttribute("style");
|
||||
mockGetConfig.mockResolvedValue({});
|
||||
mockSaveConfig.mockResolvedValue(undefined);
|
||||
mockGetPluginsForSurface.mockResolvedValue([]);
|
||||
mockListAgentRuntimeSessions.mockResolvedValue([]);
|
||||
mockUpdateAgentRuntimeSession.mockResolvedValue(undefined);
|
||||
mockLogoutClient.mockResolvedValue(undefined);
|
||||
mockGetClientReferralDashboard.mockResolvedValue({
|
||||
code: {
|
||||
id: "refcode-001",
|
||||
tenantId: "tenant-0001",
|
||||
userId: "user-001",
|
||||
code: "LIME-2026",
|
||||
landingUrl: "https://limeai.run/invite?code=LIME-2026",
|
||||
status: "active",
|
||||
createdAt: "2026-04-28T00:00:00.000Z",
|
||||
updatedAt: "2026-04-28T00:00:00.000Z",
|
||||
},
|
||||
policy: {
|
||||
enabled: true,
|
||||
rewardCredits: 600,
|
||||
referrerRewardCredits: 480,
|
||||
inviteeRewardCredits: 120,
|
||||
claimWindowDays: 30,
|
||||
autoClaimEnabled: true,
|
||||
allowManualClaimFallback: true,
|
||||
riskReviewEnabled: false,
|
||||
},
|
||||
summary: {
|
||||
totalInvites: 0,
|
||||
successfulInvites: 0,
|
||||
totalRewardCredits: 0,
|
||||
referrerRewardCreditsTotal: 0,
|
||||
inviteeRewardCreditsTotal: 0,
|
||||
},
|
||||
events: [],
|
||||
rewards: [],
|
||||
invitedBy: {},
|
||||
share: {
|
||||
brandName: "Lime",
|
||||
code: "LIME-2026",
|
||||
landingUrl: "https://limeai.run/invite?code=LIME-2026",
|
||||
downloadUrl: "https://limeai.run",
|
||||
shareText:
|
||||
"邀请你体验Lime,让AI做牛做马,我们来做牛人!前往 https://limeai.run 下载客户端,复制邀请码 LIME-2026 激活并注册账号参与内测",
|
||||
headline: "登录后自动领取奖励",
|
||||
rules: "复制邀请码后完成注册即可参与内测。",
|
||||
},
|
||||
});
|
||||
mockClearSiteAdapterCatalogCache.mockResolvedValue(null);
|
||||
mockScheduleMinimumDelayIdleTask.mockImplementation((task: () => void) => {
|
||||
task();
|
||||
return () => undefined;
|
||||
@@ -133,6 +255,13 @@ describe("AppSidebar", () => {
|
||||
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
delete window.__LIME_BOOTSTRAP__;
|
||||
delete window.__LIME_OEM_CLOUD__;
|
||||
delete window.__LIME_SESSION_TOKEN__;
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.removeAttribute("data-lime-theme");
|
||||
document.documentElement.removeAttribute("data-lime-color-scheme");
|
||||
document.documentElement.removeAttribute("style");
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
__appConfigListener?: () => void;
|
||||
@@ -176,7 +305,7 @@ describe("AppSidebar", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("默认应渲染一级主导航和底部系统入口", async () => {
|
||||
it("默认应渲染一级主导航,并将系统入口收进用户弹框", async () => {
|
||||
const container = mountSidebarContainer({
|
||||
currentPageParams: {
|
||||
agentEntry: "new-task",
|
||||
@@ -190,9 +319,9 @@ describe("AppSidebar", () => {
|
||||
expect(container.textContent).not.toContain("生成");
|
||||
expect(container.textContent).toContain("我的方法");
|
||||
expect(container.textContent).toContain("灵感库");
|
||||
expect(container.textContent).toContain("设置");
|
||||
expect(container.textContent).toContain("持续流程");
|
||||
expect(container.textContent).toContain("消息渠道");
|
||||
expect(container.textContent).not.toContain("设置");
|
||||
expect(container.textContent).not.toContain("持续流程");
|
||||
expect(container.textContent).not.toContain("消息渠道");
|
||||
expect(container.textContent).not.toContain("插件中心");
|
||||
expect(container.textContent).not.toContain("OpenClaw");
|
||||
expect(container.textContent).not.toContain("桌宠");
|
||||
@@ -205,23 +334,433 @@ describe("AppSidebar", () => {
|
||||
const mainNavButtons = Array.from(
|
||||
container.querySelectorAll('[data-testid="app-sidebar-main-nav"] button'),
|
||||
).map((button) => button.getAttribute("aria-label"));
|
||||
const footerNavButtons = Array.from(
|
||||
container.querySelectorAll(
|
||||
'[data-testid="app-sidebar-footer-nav"] button',
|
||||
),
|
||||
).map((button) => button.getAttribute("aria-label"));
|
||||
const footerArea = container.querySelector(
|
||||
'[data-testid="app-sidebar-footer-area"]',
|
||||
);
|
||||
|
||||
expect(mainNavButtons).toEqual([
|
||||
"新建任务",
|
||||
"我的方法",
|
||||
"灵感库",
|
||||
]);
|
||||
expect(footerNavButtons).toEqual(["设置", "持续流程", "消息渠道"]);
|
||||
expect(mainNavButtons).toEqual(["新建任务", "我的方法", "灵感库"]);
|
||||
expect(
|
||||
container.querySelector('[data-testid="app-sidebar-footer-nav"]'),
|
||||
).toBeNull();
|
||||
expect(footerArea).not.toBeNull();
|
||||
expect(getComputedStyle(footerArea as Element).paddingBottom).toBe("16px");
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="app-sidebar-account-button"]',
|
||||
)
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const accountMenu = container.querySelector(
|
||||
'[data-testid="app-sidebar-account-menu"]',
|
||||
);
|
||||
expect(accountMenu?.textContent).toContain("设置");
|
||||
expect(accountMenu?.textContent).toContain("持续流程");
|
||||
expect(accountMenu?.textContent).toContain("消息渠道");
|
||||
});
|
||||
|
||||
it("Lime 首页入口应保持在左侧栏顶部,并在 macOS 预留系统按钮安全区", async () => {
|
||||
vi.stubGlobal("navigator", {
|
||||
platform: "MacIntel",
|
||||
userAgent: "Mac OS X",
|
||||
});
|
||||
|
||||
const onNavigate = vi.fn();
|
||||
const container = mountSidebarContainer({
|
||||
currentPageParams: {
|
||||
agentEntry: "new-task",
|
||||
} as AgentPageParams,
|
||||
onNavigate,
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
const sidebar = container.querySelector('[data-testid="app-sidebar"]');
|
||||
const header = container.querySelector(
|
||||
'[data-testid="app-sidebar-header"]',
|
||||
);
|
||||
const homeButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="返回 Lime 首页"]',
|
||||
);
|
||||
const mainNav = container.querySelector(
|
||||
'[data-testid="app-sidebar-main-nav"]',
|
||||
);
|
||||
|
||||
expect(sidebar?.getAttribute("data-window-controls-reserved")).toBe("true");
|
||||
expect(header).not.toBeNull();
|
||||
expect(homeButton).not.toBeNull();
|
||||
expect(header?.contains(homeButton)).toBe(true);
|
||||
expect(
|
||||
Boolean(
|
||||
header &&
|
||||
mainNav &&
|
||||
(header.compareDocumentPosition(mainNav) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING) !==
|
||||
0,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
homeButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onNavigate).toHaveBeenCalledTimes(1);
|
||||
expect(onNavigate.mock.calls[0]?.[0]).toBe("agent");
|
||||
});
|
||||
|
||||
it("首页侧边栏底部应展示紧凑用户弹框与 OEM 云端入口", async () => {
|
||||
const onNavigate = vi.fn();
|
||||
setStoredOemCloudSessionState({
|
||||
token: "session-token",
|
||||
tenant: { id: "tenant-0001" },
|
||||
user: {
|
||||
id: "user-001",
|
||||
displayName: "zhong feng shan",
|
||||
email: "user@example.com",
|
||||
},
|
||||
session: { id: "session-001" },
|
||||
});
|
||||
|
||||
const container = mountSidebarContainer({
|
||||
currentPage: "agent",
|
||||
currentPageParams: {
|
||||
agentEntry: "new-task",
|
||||
} as AgentPageParams,
|
||||
onNavigate,
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
const accountButton = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="app-sidebar-account-button"]',
|
||||
);
|
||||
expect(accountButton).not.toBeNull();
|
||||
expect(container.textContent).toContain("zhong feng shan");
|
||||
expect(container.textContent).toContain("OEM");
|
||||
|
||||
await act(async () => {
|
||||
accountButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const accountMenu = container.querySelector(
|
||||
'[data-testid="app-sidebar-account-menu"]',
|
||||
);
|
||||
expect(accountMenu).not.toBeNull();
|
||||
expect(accountMenu?.textContent).toContain("zhong feng shan");
|
||||
expect(accountMenu?.textContent).toContain("user@example.com");
|
||||
expect(accountMenu?.textContent).toContain("OEM 已连接");
|
||||
expect(accountMenu?.textContent).toContain("套餐、积分和模型目录");
|
||||
expect(accountMenu?.textContent).toContain("语言");
|
||||
expect(accountMenu?.textContent).toContain("持续流程");
|
||||
expect(accountMenu?.textContent).toContain("消息渠道");
|
||||
expect(accountMenu?.textContent).toContain("设置");
|
||||
expect(accountMenu?.textContent).toContain("用户中心");
|
||||
expect(accountMenu?.textContent).toContain("模型设置");
|
||||
expect(accountMenu?.textContent).toContain("关于");
|
||||
expect(accountMenu?.textContent).toContain("退出登录");
|
||||
expect(accountMenu?.textContent).not.toContain("连接 OEM 云端");
|
||||
expect(accountMenu?.textContent).not.toContain("主题");
|
||||
expect(accountMenu?.textContent).not.toContain("帮助中心");
|
||||
|
||||
const settingsButton = Array.from(
|
||||
accountMenu?.querySelectorAll("button") ?? [],
|
||||
).find((button) => button.textContent?.includes("设置"));
|
||||
|
||||
await act(async () => {
|
||||
settingsButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onNavigate).toHaveBeenCalledWith("settings", {
|
||||
tab: SettingsTabs.Home,
|
||||
});
|
||||
});
|
||||
|
||||
it("云端开启邀请时应在头部展示入口并读取 share 事实源", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
setStoredOemCloudSessionState({
|
||||
token: "session-token",
|
||||
tenant: { id: "tenant-0001", name: "Lime Cloud" },
|
||||
user: {
|
||||
id: "user-001",
|
||||
displayName: "晚风",
|
||||
email: "wanfeng@example.com",
|
||||
},
|
||||
session: { id: "session-001", provider: "google" },
|
||||
});
|
||||
setOemCloudBootstrapSnapshot({
|
||||
features: {
|
||||
referralEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const container = mountSidebarContainer({
|
||||
currentPage: "agent",
|
||||
currentPageParams: {
|
||||
agentEntry: "new-task",
|
||||
} as AgentPageParams,
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
const header = container.querySelector(
|
||||
'[data-testid="app-sidebar-header"]',
|
||||
);
|
||||
const inviteButton = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="app-sidebar-invite-button"]',
|
||||
);
|
||||
expect(inviteButton).not.toBeNull();
|
||||
expect(header?.contains(inviteButton)).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
inviteButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects(4);
|
||||
|
||||
expect(mockGetClientReferralDashboard).toHaveBeenCalledWith("tenant-0001");
|
||||
const dialog = document.body.querySelector(
|
||||
'[data-testid="app-sidebar-invite-dialog"]',
|
||||
);
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(dialog?.textContent).toContain("LIME-2026");
|
||||
expect(dialog?.textContent).toContain("https://limeai.run");
|
||||
expect(dialog?.textContent).toContain("480 积分");
|
||||
expect(dialog?.textContent).toContain("120 积分");
|
||||
|
||||
const copyShareButton = Array.from(
|
||||
document.body.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("复制邀请文案"));
|
||||
|
||||
await act(async () => {
|
||||
copyShareButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
"邀请你体验Lime,让AI做牛做马,我们来做牛人!前往 https://limeai.run 下载客户端,复制邀请码 LIME-2026 激活并注册账号参与内测",
|
||||
);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("已复制邀请文案");
|
||||
});
|
||||
|
||||
it("未连接 OEM 云端时应保持开源使用口径", async () => {
|
||||
const container = mountSidebarContainer({
|
||||
currentPage: "agent",
|
||||
currentPageParams: {
|
||||
agentEntry: "new-task",
|
||||
} as AgentPageParams,
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
expect(container.textContent).toContain("开源使用");
|
||||
expect(container.textContent).toContain("开源");
|
||||
expect(container.textContent).not.toContain("升级");
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="app-sidebar-account-button"]',
|
||||
)
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const accountMenu = container.querySelector(
|
||||
'[data-testid="app-sidebar-account-menu"]',
|
||||
);
|
||||
expect(accountMenu?.textContent).toContain("开源版");
|
||||
expect(accountMenu?.textContent).toContain("OEM 云端可选");
|
||||
expect(accountMenu?.textContent).toContain("模型设置");
|
||||
expect(accountMenu?.textContent).toContain("连接 OEM 云端");
|
||||
expect(accountMenu?.textContent).not.toContain("退出登录");
|
||||
});
|
||||
|
||||
it("OEM 登录完成后侧边栏应从开源态刷新为账号信息", async () => {
|
||||
const container = mountSidebarContainer({
|
||||
currentPage: "agent",
|
||||
currentPageParams: {
|
||||
agentEntry: "new-task",
|
||||
} as AgentPageParams,
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
expect(container.textContent).toContain("开源使用");
|
||||
|
||||
await act(async () => {
|
||||
setStoredOemCloudSessionState({
|
||||
token: "session-token",
|
||||
tenant: {
|
||||
id: "tenant-0001",
|
||||
name: "Lime Cloud",
|
||||
},
|
||||
user: {
|
||||
id: "user-001",
|
||||
displayName: "晚风",
|
||||
email: "wanfeng@example.com",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
},
|
||||
session: {
|
||||
id: "session-001",
|
||||
provider: "google",
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
expect(container.textContent).toContain("晚风");
|
||||
expect(container.textContent).toContain("OEM");
|
||||
|
||||
await openAccountMenu(container);
|
||||
const accountMenu = container.querySelector(
|
||||
'[data-testid="app-sidebar-account-menu"]',
|
||||
);
|
||||
expect(accountMenu?.textContent).toContain("wanfeng@example.com");
|
||||
expect(accountMenu?.textContent).toContain("Lime Cloud");
|
||||
expect(accountMenu?.textContent).toContain("登录方式:Google");
|
||||
});
|
||||
|
||||
it("用户弹框的语言入口应使用二级弹框并保存真实语言设置", async () => {
|
||||
mockGetConfig.mockResolvedValue({ language: "zh" });
|
||||
|
||||
const container = mountSidebarContainer({
|
||||
currentPage: "agent",
|
||||
currentPageParams: {
|
||||
agentEntry: "new-task",
|
||||
} as AgentPageParams,
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="app-sidebar-account-button"]',
|
||||
)
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="app-sidebar-language-menu"]'),
|
||||
).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('button[aria-label="语言"]')
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const languageMenu = container.querySelector(
|
||||
'[data-testid="app-sidebar-language-menu"]',
|
||||
);
|
||||
expect(languageMenu).not.toBeNull();
|
||||
expect(languageMenu?.textContent).toContain("中文");
|
||||
expect(languageMenu?.textContent).toContain("English");
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="切换语言为English"]',
|
||||
)
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
expect(mockSetI18nLanguage).toHaveBeenCalledWith("en");
|
||||
expect(mockSaveConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ language: "en" }),
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-testid="app-sidebar-language-menu"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("用户弹框中的收缩入口应导航到真实页面", async () => {
|
||||
const onNavigate = vi.fn();
|
||||
const container = mountSidebarContainer({
|
||||
currentPage: "agent",
|
||||
currentPageParams: {
|
||||
agentEntry: "new-task",
|
||||
} as AgentPageParams,
|
||||
onNavigate,
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
await clickAccountMenuItem(container, "设置");
|
||||
expect(onNavigate).toHaveBeenLastCalledWith("settings", {
|
||||
tab: SettingsTabs.Home,
|
||||
});
|
||||
|
||||
await clickAccountMenuItem(container, "持续流程");
|
||||
expect(onNavigate).toHaveBeenLastCalledWith("automation", undefined);
|
||||
|
||||
await clickAccountMenuItem(container, "消息渠道");
|
||||
expect(onNavigate).toHaveBeenLastCalledWith("channels", undefined);
|
||||
|
||||
await clickAccountMenuItem(container, "模型设置");
|
||||
expect(onNavigate).toHaveBeenLastCalledWith("settings", {
|
||||
tab: SettingsTabs.Providers,
|
||||
providerView: "settings",
|
||||
});
|
||||
|
||||
await clickAccountMenuItem(container, "OEM 云端");
|
||||
expect(onNavigate).toHaveBeenLastCalledWith("settings", {
|
||||
tab: SettingsTabs.Providers,
|
||||
providerView: "cloud",
|
||||
});
|
||||
|
||||
await clickAccountMenuItem(container, "关于");
|
||||
expect(onNavigate).toHaveBeenLastCalledWith("settings", {
|
||||
tab: SettingsTabs.About,
|
||||
});
|
||||
});
|
||||
|
||||
it("用户弹框退出登录应清理个人中心会话", async () => {
|
||||
setStoredOemCloudSessionState({
|
||||
token: "session-token",
|
||||
tenant: { id: "tenant-0001" },
|
||||
user: {
|
||||
id: "user-001",
|
||||
displayName: "zhong feng shan",
|
||||
},
|
||||
session: { id: "session-001" },
|
||||
});
|
||||
|
||||
const container = mountSidebarContainer();
|
||||
await flushEffects(2);
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="app-sidebar-account-button"]',
|
||||
)
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const logoutButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("退出登录"),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
logoutButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
expect(mockLogoutClient).toHaveBeenCalledWith("tenant-0001");
|
||||
expect(getStoredOemCloudSessionState()).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="app-sidebar-account-menu"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("新建任务页应高亮新建任务入口", async () => {
|
||||
@@ -265,9 +804,7 @@ describe("AppSidebar", () => {
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
expect(
|
||||
container.querySelector('button[aria-label="工作台"]'),
|
||||
).toBeNull();
|
||||
expect(container.querySelector('button[aria-label="工作台"]')).toBeNull();
|
||||
expect(onNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -284,6 +821,7 @@ describe("AppSidebar", () => {
|
||||
});
|
||||
|
||||
it("一级导航下方应继续展示最近对话与归档,并对归档列表懒加载", async () => {
|
||||
localStorage.setItem("agent_last_project_id", JSON.stringify("project-1"));
|
||||
mockListAgentRuntimeSessions.mockImplementation(
|
||||
async (options?: {
|
||||
archivedOnly?: boolean;
|
||||
@@ -326,7 +864,7 @@ describe("AppSidebar", () => {
|
||||
expect(mockListAgentRuntimeSessions).toHaveBeenCalledTimes(1);
|
||||
expect(mockListAgentRuntimeSessions).toHaveBeenCalledWith({
|
||||
limit: 37,
|
||||
workspaceId: undefined,
|
||||
workspaceId: "project-1",
|
||||
});
|
||||
|
||||
const mainNav = container.querySelector(
|
||||
@@ -375,11 +913,25 @@ describe("AppSidebar", () => {
|
||||
).toBe("auto");
|
||||
expect(mockListAgentRuntimeSessions).toHaveBeenCalledWith({
|
||||
archivedOnly: true,
|
||||
limit: 25,
|
||||
workspaceId: undefined,
|
||||
limit: 17,
|
||||
workspaceId: "project-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("没有当前工作区时不应加载全局最近对话", async () => {
|
||||
const container = mountSidebarContainer({
|
||||
currentPage: "agent",
|
||||
currentPageParams: {
|
||||
agentEntry: "new-task",
|
||||
} as AgentPageParams,
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
expect(container.textContent).toContain("最近对话");
|
||||
expect(container.textContent).toContain("还没有开始对话");
|
||||
expect(mockListAgentRuntimeSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("最近对话应限制初始渲染数量,并保留当前会话可见", async () => {
|
||||
mockListAgentRuntimeSessions.mockResolvedValue(
|
||||
Array.from({ length: 25 }, (_, index) => {
|
||||
@@ -426,7 +978,7 @@ describe("AppSidebar", () => {
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
expect(container.querySelector('button[title="会话 24"]')).not.toBeNull();
|
||||
expect(container.querySelector('button[title="会话 19"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("切换人物或项目上下文时不应把已有最近对话重置成加载态", async () => {
|
||||
@@ -478,7 +1030,7 @@ describe("AppSidebar", () => {
|
||||
expect(mounted.container.textContent).toContain("最近会话");
|
||||
expect(mounted.container.textContent).not.toContain("正在加载对话");
|
||||
expect(mockListAgentRuntimeSessions).toHaveBeenCalledTimes(2);
|
||||
expect(mockListAgentRuntimeSessions).toHaveBeenLastCalledWith({
|
||||
expect(mockListAgentRuntimeSessions).toHaveBeenCalledWith({
|
||||
limit: 37,
|
||||
workspaceId: "project-2",
|
||||
});
|
||||
@@ -502,6 +1054,7 @@ describe("AppSidebar", () => {
|
||||
currentPage: "agent",
|
||||
currentPageParams: {
|
||||
agentEntry: "new-task",
|
||||
projectId: "project-1",
|
||||
} as AgentPageParams,
|
||||
});
|
||||
await flushEffects(2);
|
||||
@@ -601,11 +1154,28 @@ describe("AppSidebar", () => {
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
expect(container.textContent).toContain("持续流程");
|
||||
expect(container.textContent).toContain("消息渠道");
|
||||
expect(container.textContent).toContain("插件中心");
|
||||
expect(container.textContent).toContain("OpenClaw");
|
||||
expect(container.textContent).toContain("桌宠");
|
||||
expect(container.textContent).not.toContain("插件中心");
|
||||
expect(container.textContent).not.toContain("OpenClaw");
|
||||
expect(container.textContent).not.toContain("桌宠");
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="app-sidebar-account-button"]',
|
||||
)
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const accountMenu = container.querySelector(
|
||||
'[data-testid="app-sidebar-account-menu"]',
|
||||
);
|
||||
expect(accountMenu?.textContent).toContain("设置");
|
||||
expect(accountMenu?.textContent).toContain("持续流程");
|
||||
expect(accountMenu?.textContent).toContain("消息渠道");
|
||||
expect(accountMenu?.textContent).toContain("插件中心");
|
||||
expect(accountMenu?.textContent).toContain("OpenClaw");
|
||||
expect(accountMenu?.textContent).toContain("桌宠");
|
||||
});
|
||||
|
||||
it("配置变更后应重新读取可选入口并刷新侧栏", async () => {
|
||||
@@ -628,11 +1198,27 @@ describe("AppSidebar", () => {
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
expect(container.textContent).toContain("持续流程");
|
||||
expect(container.textContent).toContain("消息渠道");
|
||||
expect(container.textContent).not.toContain("插件中心");
|
||||
expect(container.textContent).not.toContain("桌宠");
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="app-sidebar-account-button"]',
|
||||
)
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
let accountMenu = container.querySelector(
|
||||
'[data-testid="app-sidebar-account-menu"]',
|
||||
);
|
||||
expect(accountMenu?.textContent).toContain("设置");
|
||||
expect(accountMenu?.textContent).toContain("持续流程");
|
||||
expect(accountMenu?.textContent).toContain("消息渠道");
|
||||
expect(accountMenu?.textContent).not.toContain("插件中心");
|
||||
expect(accountMenu?.textContent).not.toContain("桌宠");
|
||||
|
||||
await act(async () => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
@@ -643,8 +1229,11 @@ describe("AppSidebar", () => {
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
expect(container.textContent).toContain("插件中心");
|
||||
expect(container.textContent).toContain("桌宠");
|
||||
accountMenu = container.querySelector(
|
||||
'[data-testid="app-sidebar-account-menu"]',
|
||||
);
|
||||
expect(accountMenu?.textContent).toContain("插件中心");
|
||||
expect(accountMenu?.textContent).toContain("桌宠");
|
||||
});
|
||||
|
||||
it("sceneapps 页面不应再在侧栏展示独立主入口", async () => {
|
||||
@@ -701,6 +1290,61 @@ describe("AppSidebar", () => {
|
||||
expect(container.textContent).not.toContain("桌宠");
|
||||
});
|
||||
|
||||
it("底部外观入口应弹出轻量快捷面板并同步主题与配色", async () => {
|
||||
const container = mountSidebarContainer({
|
||||
currentPageParams: {
|
||||
agentEntry: "new-task",
|
||||
} as AgentPageParams,
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="快速切换外观"]',
|
||||
);
|
||||
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const popover = container.querySelector(
|
||||
'[data-testid="app-sidebar-appearance-popover"]',
|
||||
);
|
||||
expect(popover).not.toBeNull();
|
||||
expect(popover?.textContent).toContain("浅色");
|
||||
expect(popover?.textContent).toContain("深色");
|
||||
expect(popover?.textContent).toContain("跟随系统");
|
||||
expect(popover?.textContent).toContain("Lime 经典");
|
||||
expect(popover?.textContent).toContain("森林");
|
||||
expect(popover?.textContent).toContain("海雾");
|
||||
expect(popover?.textContent).toContain("砂岩");
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('button[aria-label="切换配色为海雾"]')
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(localStorage.getItem(LIME_COLOR_SCHEME_STORAGE_KEY)).toBe(
|
||||
"lime-ocean",
|
||||
);
|
||||
expect(document.documentElement.dataset.limeColorScheme).toBe("lime-ocean");
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('button[aria-label="切换主题为深色"]')
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(localStorage.getItem(LIME_THEME_STORAGE_KEY)).toBe("dark");
|
||||
expect(document.documentElement.dataset.limeTheme).toBe("dark");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
|
||||
it("桌宠入口开启后,进入 companion 视图应高亮桌宠", async () => {
|
||||
mockGetConfig.mockResolvedValue({
|
||||
navigation: {
|
||||
@@ -717,6 +1361,15 @@ describe("AppSidebar", () => {
|
||||
});
|
||||
await flushEffects(2);
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="app-sidebar-account-button"]',
|
||||
)
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('button[aria-label="桌宠"][aria-current="page"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
+2424
-597
File diff suppressed because it is too large
Load Diff
@@ -114,6 +114,9 @@ describe("Modal", () => {
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
expect(overlay?.className).toContain("overflow-y-auto");
|
||||
expect(overlay?.className).toContain(
|
||||
"bg-[image:var(--lime-stage-surface-soft)]",
|
||||
);
|
||||
expect(overlay?.className).toContain("backdrop-blur-[2px]");
|
||||
expect(overlay?.className).not.toContain("bg-black/50");
|
||||
expect(dialog?.className).toContain("max-h-[calc(100vh-2rem)]");
|
||||
|
||||
@@ -146,7 +146,7 @@ export function Modal({
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-y-auto bg-[linear-gradient(180deg,rgba(240,249,255,0.82)_0%,rgba(236,253,245,0.74)_52%,rgba(255,255,255,0.86)_100%)] backdrop-blur-[2px]"
|
||||
className="fixed inset-0 z-50 overflow-y-auto bg-[image:var(--lime-stage-surface-soft)] backdrop-blur-[2px]"
|
||||
data-testid="modal-overlay"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
|
||||
@@ -274,8 +274,8 @@ interface SplashScreenProps {
|
||||
|
||||
export function SplashScreen({
|
||||
onComplete,
|
||||
duration = 1200,
|
||||
exitDuration = 320,
|
||||
duration = 420,
|
||||
exitDuration = 160,
|
||||
}: SplashScreenProps) {
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
|
||||
|
||||
@@ -329,8 +329,12 @@ import {
|
||||
} from "@/lib/sceneapp";
|
||||
|
||||
const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist";
|
||||
const BLANK_HOME_DEFERRED_LOAD_MS = 6_000;
|
||||
const BLANK_HOME_DEFERRED_LOAD_MS = 18_000;
|
||||
const SESSION_ENTRY_DEFERRED_LOAD_MS = 12_000;
|
||||
const BROWSER_WORKSPACE_HOME_HINT_STORAGE_KEY =
|
||||
"lime.agent.browser-workspace-home-hint-shown";
|
||||
const BROWSER_WORKSPACE_HOME_HINT_MESSAGE = "在这里切换或新建工作区";
|
||||
const BROWSER_WORKSPACE_HOME_HINT_AUTO_HIDE_MS = 5_500;
|
||||
const NOOP_SET_CHAT_MESSAGES: Dispatch<SetStateAction<Message[]>> = () =>
|
||||
undefined;
|
||||
|
||||
@@ -468,7 +472,7 @@ export function AgentChatWorkspace({
|
||||
onWorkflowProgressChange,
|
||||
initialUserPrompt,
|
||||
initialUserImages,
|
||||
initialSessionName,
|
||||
initialSessionName: _initialSessionName,
|
||||
entryBannerMessage,
|
||||
initialPendingServiceSkillLaunch,
|
||||
initialInputCapability,
|
||||
@@ -501,6 +505,8 @@ export function AgentChatWorkspace({
|
||||
const [entryBannerVisible, setEntryBannerVisible] = useState(
|
||||
Boolean(effectiveEntryBannerMessage),
|
||||
);
|
||||
const [browserWorkspaceHintVisible, setBrowserWorkspaceHintVisible] =
|
||||
useState(false);
|
||||
const shouldBootstrapCanvasOnEntry =
|
||||
Boolean(contentId) && isSpecializedWorkbenchTheme(normalizedEntryTheme);
|
||||
|
||||
@@ -565,7 +571,6 @@ export function AgentChatWorkspace({
|
||||
rememberProjectId,
|
||||
getRememberedProjectId,
|
||||
applyProjectSelection,
|
||||
resetProjectSelection,
|
||||
clearProjectSelectionRuntime,
|
||||
startTopicProjectResolution,
|
||||
finishTopicProjectResolution,
|
||||
@@ -588,15 +593,31 @@ export function AgentChatWorkspace({
|
||||
agentEntry === "new-task" && !contentId;
|
||||
const shouldPreserveBlankHomeSurface =
|
||||
shouldPreserveEntryThemeOnHome && normalizedEntryTheme === "general";
|
||||
const shouldUseBrowserWorkspaceHomeChrome = shouldPreserveBlankHomeSurface;
|
||||
const shouldPrioritizeInitialSessionEntry =
|
||||
normalizedInitialSessionId !== null && !contentId;
|
||||
const shouldPrioritizeInitialPromptEntry =
|
||||
agentEntry === "claw" &&
|
||||
!contentId &&
|
||||
normalizedEntryTheme === "general" &&
|
||||
Boolean(initialUserPrompt?.trim()) &&
|
||||
!initialUserImages?.length &&
|
||||
!initialSiteSkillLaunch &&
|
||||
!initialPendingServiceSkillLaunch?.skillId?.trim() &&
|
||||
!initialPendingServiceSkillLaunch?.skillKey?.trim() &&
|
||||
!initialInputCapability?.capabilityRoute &&
|
||||
!initialProjectFileOpenTarget?.relativePath?.trim();
|
||||
const shouldDeferWorkspaceAuxiliaryLoads =
|
||||
shouldPreserveBlankHomeSurface || shouldPrioritizeInitialSessionEntry;
|
||||
shouldPreserveBlankHomeSurface ||
|
||||
shouldPrioritizeInitialSessionEntry ||
|
||||
shouldPrioritizeInitialPromptEntry;
|
||||
const shouldDeferInitialTopicsLoad =
|
||||
shouldPreserveBlankHomeSurface || shouldPrioritizeInitialSessionEntry;
|
||||
shouldPreserveBlankHomeSurface ||
|
||||
shouldPrioritizeInitialSessionEntry ||
|
||||
shouldPrioritizeInitialPromptEntry;
|
||||
const deferredWorkspaceAuxiliaryLoadMs = shouldPreserveBlankHomeSurface
|
||||
? BLANK_HOME_DEFERRED_LOAD_MS
|
||||
: shouldPrioritizeInitialSessionEntry
|
||||
: shouldPrioritizeInitialSessionEntry || shouldPrioritizeInitialPromptEntry
|
||||
? SESSION_ENTRY_DEFERRED_LOAD_MS
|
||||
: undefined;
|
||||
const [isInitialContentLoading, setIsInitialContentLoading] = useState(
|
||||
@@ -626,6 +647,48 @@ export function AgentChatWorkspace({
|
||||
setEntryBannerVisible(Boolean(effectiveEntryBannerMessage));
|
||||
}, [effectiveEntryBannerMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldUseBrowserWorkspaceHomeChrome ||
|
||||
!projectId ||
|
||||
entryBannerMessage
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
window.localStorage.getItem(BROWSER_WORKSPACE_HOME_HINT_STORAGE_KEY) ===
|
||||
"true"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(
|
||||
BROWSER_WORKSPACE_HOME_HINT_STORAGE_KEY,
|
||||
"true",
|
||||
);
|
||||
} catch {
|
||||
// 本地存储不可用时仍展示一次提示,避免首开闭环静默失败。
|
||||
}
|
||||
|
||||
setBrowserWorkspaceHintVisible(true);
|
||||
}, [entryBannerMessage, projectId, shouldUseBrowserWorkspaceHomeChrome]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!browserWorkspaceHintVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setBrowserWorkspaceHintVisible(false);
|
||||
}, BROWSER_WORKSPACE_HOME_HINT_AUTO_HIDE_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [browserWorkspaceHintVisible]);
|
||||
|
||||
const pageMountedAtRef = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -794,6 +857,76 @@ export function AgentChatWorkspace({
|
||||
projectId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldUseBrowserWorkspaceHomeChrome ||
|
||||
projectId ||
|
||||
externalProjectId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const startedAt = Date.now();
|
||||
|
||||
logAgentDebug("AgentChatPage", "homeDefaultWorkspace.resolve.start", {
|
||||
agentEntry,
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const defaultProject = await getOrCreateDefaultProject();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defaultProject?.id) {
|
||||
logAgentDebug(
|
||||
"AgentChatPage",
|
||||
"homeDefaultWorkspace.resolve.empty",
|
||||
{
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
{ level: "warn" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
applyProjectSelection(defaultProject.id);
|
||||
setProject(defaultProject);
|
||||
logAgentDebug("AgentChatPage", "homeDefaultWorkspace.resolve.success", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
projectId: defaultProject.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn("[AgentChatPage] 准备默认工作区失败:", error);
|
||||
logAgentDebug(
|
||||
"AgentChatPage",
|
||||
"homeDefaultWorkspace.resolve.error",
|
||||
{
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
},
|
||||
{ level: "warn" },
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
agentEntry,
|
||||
applyProjectSelection,
|
||||
externalProjectId,
|
||||
projectId,
|
||||
shouldUseBrowserWorkspaceHomeChrome,
|
||||
]);
|
||||
|
||||
// 画布状态(支持多种画布类型)
|
||||
const [canvasState, setCanvasState] = useState<CanvasStateUnion | null>(
|
||||
() => {
|
||||
@@ -2010,7 +2143,9 @@ export function AgentChatWorkspace({
|
||||
}
|
||||
|
||||
setImageWorkbenchBySessionId((previous) => {
|
||||
if (isSessionImageWorkbenchStateMeaningful(previous[normalizedSessionId])) {
|
||||
if (
|
||||
isSessionImageWorkbenchStateMeaningful(previous[normalizedSessionId])
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
@@ -2447,6 +2582,7 @@ export function AgentChatWorkspace({
|
||||
contextWorkspace,
|
||||
isThemeWorkbench,
|
||||
harnessPanelVisible,
|
||||
setHarnessPanelVisible,
|
||||
harnessPendingCount,
|
||||
showHarnessToggle,
|
||||
harnessAttentionLevel,
|
||||
@@ -2505,6 +2641,10 @@ export function AgentChatWorkspace({
|
||||
selectedText,
|
||||
defaultToolPreferences: effectiveChatToolPreferences,
|
||||
onNavigate: _onNavigate,
|
||||
catalogLoadMode: shouldDeferWorkspaceAuxiliaryLoads
|
||||
? "deferred"
|
||||
: "immediate",
|
||||
catalogDeferredDelayMs: deferredWorkspaceAuxiliaryLoadMs,
|
||||
});
|
||||
const handlePendingServiceSkillLaunchSubmit =
|
||||
workspaceServiceSkillEntryActions.handlePendingServiceSkillLaunchSubmit;
|
||||
@@ -3142,17 +3282,13 @@ export function AgentChatWorkspace({
|
||||
clearRuntimeTeamState,
|
||||
clearProjectSelectionRuntime,
|
||||
resetRestoredSessionState,
|
||||
resetProjectSelection,
|
||||
resetGuideState,
|
||||
hasHandledNewChatRequest,
|
||||
markNewChatRequestHandled,
|
||||
createFreshSession,
|
||||
defaultTopicSidebarVisible,
|
||||
normalizedInitialTheme: normalizedEntryTheme,
|
||||
initialCreationMode,
|
||||
newChatAt,
|
||||
initialSessionName,
|
||||
projectId,
|
||||
externalProjectId,
|
||||
onNavigate: _onNavigate,
|
||||
autoCollapsedTopicSidebarRef,
|
||||
@@ -3166,8 +3302,6 @@ export function AgentChatWorkspace({
|
||||
setTaskFiles,
|
||||
setSelectedFileId,
|
||||
setMentionedCharacters,
|
||||
setProject,
|
||||
setProjectMemory,
|
||||
setActiveTheme,
|
||||
setCreationMode,
|
||||
});
|
||||
@@ -4156,6 +4290,23 @@ export function AgentChatWorkspace({
|
||||
sessionId,
|
||||
taskCenterEmbeddedHomeSessionIds,
|
||||
]);
|
||||
const suppressHomeNavbarUtilityActions =
|
||||
shouldUseBrowserWorkspaceHomeChrome || shouldRenderTaskCenterEmbeddedHome;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!suppressHomeNavbarUtilityActions ||
|
||||
!harnessPanelVisible
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHarnessPanelVisible(false);
|
||||
}, [
|
||||
harnessPanelVisible,
|
||||
setHarnessPanelVisible,
|
||||
suppressHomeNavbarUtilityActions,
|
||||
]);
|
||||
const shouldHideDetachedTaskCenterTabs = useMemo(
|
||||
() =>
|
||||
shouldHideTaskCenterTabsForDetachedSession({
|
||||
@@ -4327,6 +4478,33 @@ export function AgentChatWorkspace({
|
||||
layoutMode,
|
||||
taskCenterTabItems,
|
||||
]);
|
||||
const browserWorkspaceHomeTabsNode = useMemo(() => {
|
||||
if (!shouldUseBrowserWorkspaceHomeChrome) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const homeTab: TaskCenterTabItem = {
|
||||
id: "new-task-home",
|
||||
title: "新对话",
|
||||
status: "draft",
|
||||
updatedAt: new Date(newChatAt ?? pageMountedAtRef.current),
|
||||
isActive: true,
|
||||
hasUnread: false,
|
||||
isPinned: false,
|
||||
closable: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<TaskCenterTabStrip
|
||||
items={[homeTab]}
|
||||
onSelectTask={() => undefined}
|
||||
onCloseTask={() => undefined}
|
||||
onCreateTask={() => {
|
||||
handleBackHome();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}, [handleBackHome, newChatAt, shouldUseBrowserWorkspaceHomeChrome]);
|
||||
const handleOpenTaskCenterSkillsPage = useCallback(() => {
|
||||
if (!_onNavigate) {
|
||||
return;
|
||||
@@ -5867,7 +6045,9 @@ export function AgentChatWorkspace({
|
||||
const isWorkspaceCompactChrome = topBarChrome === "workspace-compact";
|
||||
const shouldRenderBrandedEmptyState =
|
||||
!showChatLayout && !shouldRenderTaskCenterEmbeddedHome;
|
||||
const shouldRenderTopBar = !hideTopBar && !shouldRenderBrandedEmptyState;
|
||||
const shouldRenderTopBar =
|
||||
!hideTopBar &&
|
||||
(!shouldRenderBrandedEmptyState || shouldUseBrowserWorkspaceHomeChrome);
|
||||
const shouldRenderInlineA2UI = isSpecializedThemeMode;
|
||||
|
||||
const shouldUseTeamPrimaryChatPanelWidth =
|
||||
@@ -5913,6 +6093,7 @@ export function AgentChatWorkspace({
|
||||
layoutMode,
|
||||
queuedTurns.length,
|
||||
shouldRenderTaskCenterEmbeddedHome,
|
||||
shouldUseBrowserWorkspaceHomeChrome,
|
||||
shouldUseCompactGeneralWorkbench,
|
||||
teamDispatchPreviewState,
|
||||
teamSessionRuntime.hasRuntimeSessions,
|
||||
@@ -5958,10 +6139,14 @@ export function AgentChatWorkspace({
|
||||
const generalWorkbenchHarnessDialog = (
|
||||
<GeneralWorkbenchHarnessDialogSection
|
||||
enabled={
|
||||
!suppressHomeNavbarUtilityActions &&
|
||||
contextHarnessRuntime.workbenchEnabled &&
|
||||
contextHarnessRuntime.isThemeWorkbench
|
||||
}
|
||||
open={contextHarnessRuntime.harnessPanelVisible}
|
||||
open={
|
||||
!suppressHomeNavbarUtilityActions &&
|
||||
contextHarnessRuntime.harnessPanelVisible
|
||||
}
|
||||
onOpenChange={contextHarnessRuntime.setHarnessPanelVisible}
|
||||
harnessState={harnessState}
|
||||
environment={contextHarnessRuntime.harnessEnvironment}
|
||||
@@ -6261,8 +6446,11 @@ export function AgentChatWorkspace({
|
||||
generalWorkbenchEntryPrompt,
|
||||
handleRestartGeneralWorkbenchEntryPrompt,
|
||||
handleContinueGeneralWorkbenchEntryPrompt,
|
||||
generalWorkbenchEnabled: generalHarnessEntryEnabled,
|
||||
harnessPanelVisible: contextHarnessRuntime.harnessPanelVisible,
|
||||
generalWorkbenchEnabled:
|
||||
generalHarnessEntryEnabled && !suppressHomeNavbarUtilityActions,
|
||||
harnessPanelVisible:
|
||||
!suppressHomeNavbarUtilityActions &&
|
||||
contextHarnessRuntime.harnessPanelVisible,
|
||||
setHarnessPanelVisible: contextHarnessRuntime.setHarnessPanelVisible,
|
||||
harnessState,
|
||||
harnessEnvironment: contextHarnessRuntime.harnessEnvironment,
|
||||
@@ -6321,6 +6509,7 @@ export function AgentChatWorkspace({
|
||||
handleCanvasSelectionTextChange,
|
||||
projectId: projectId ?? null,
|
||||
contentId: contentId ?? null,
|
||||
sourceThreadId: sessionId ?? null,
|
||||
projectName: project?.name || undefined,
|
||||
providerType,
|
||||
setProviderType,
|
||||
@@ -6393,7 +6582,10 @@ export function AgentChatWorkspace({
|
||||
const conversationSceneRuntime = useWorkspaceConversationSceneRuntime({
|
||||
messageListEmptyStateVariant:
|
||||
agentEntry === "claw" ? "task-center" : "default",
|
||||
navbarContextVariant: agentEntry === "claw" ? "task-center" : "default",
|
||||
navbarContextVariant:
|
||||
agentEntry === "claw" || shouldUseBrowserWorkspaceHomeChrome
|
||||
? "task-center"
|
||||
: "default",
|
||||
navigationActions,
|
||||
inputbarScene,
|
||||
canvasScene,
|
||||
@@ -6403,6 +6595,15 @@ export function AgentChatWorkspace({
|
||||
teamWorkspaceEnabled: teamSessionRuntime.teamWorkspaceEnabled,
|
||||
currentImageWorkbenchActive: currentImageWorkbenchState.active,
|
||||
projectId: projectId ?? null,
|
||||
deferWorkspaceListLoad: shouldUseBrowserWorkspaceHomeChrome,
|
||||
workspaceHintMessage: shouldUseBrowserWorkspaceHomeChrome
|
||||
? BROWSER_WORKSPACE_HOME_HINT_MESSAGE
|
||||
: undefined,
|
||||
workspaceHintVisible:
|
||||
shouldUseBrowserWorkspaceHomeChrome && browserWorkspaceHintVisible,
|
||||
onDismissWorkspaceHint: () => {
|
||||
setBrowserWorkspaceHintVisible(false);
|
||||
},
|
||||
projectRootPath: project?.rootPath || null,
|
||||
projectCharacters: projectMemory?.characters || [],
|
||||
generalCanvasContent: generalCanvasState.content,
|
||||
@@ -6461,7 +6662,9 @@ export function AgentChatWorkspace({
|
||||
recentSessionActionLabel,
|
||||
handleResumeRecentSession,
|
||||
handleOpenSceneAppsDirectory,
|
||||
taskCenterTabsNode,
|
||||
taskCenterTabsNode:
|
||||
agentEntry === "claw" ? taskCenterTabsNode : browserWorkspaceHomeTabsNode,
|
||||
suppressNavbarUtilityActions: suppressHomeNavbarUtilityActions,
|
||||
hideHistoryToggle,
|
||||
showChatPanel: effectiveShowChatPanel,
|
||||
topBarChrome,
|
||||
@@ -6469,11 +6672,19 @@ export function AgentChatWorkspace({
|
||||
fromResources,
|
||||
handleBackHome,
|
||||
handleToggleSidebar,
|
||||
showHarnessToggle,
|
||||
navbarHarnessPanelVisible,
|
||||
harnessPendingCount,
|
||||
harnessAttentionLevel,
|
||||
harnessToggleLabel,
|
||||
showHarnessToggle:
|
||||
!suppressHomeNavbarUtilityActions && showHarnessToggle,
|
||||
navbarHarnessPanelVisible:
|
||||
!suppressHomeNavbarUtilityActions && navbarHarnessPanelVisible,
|
||||
harnessPendingCount: suppressHomeNavbarUtilityActions
|
||||
? 0
|
||||
: harnessPendingCount,
|
||||
harnessAttentionLevel: suppressHomeNavbarUtilityActions
|
||||
? "idle"
|
||||
: harnessAttentionLevel,
|
||||
harnessToggleLabel: suppressHomeNavbarUtilityActions
|
||||
? undefined
|
||||
: harnessToggleLabel,
|
||||
isAutoRestoringSession:
|
||||
isAutoRestoringSession || taskCenterSessionSwitchPending,
|
||||
sessionId,
|
||||
|
||||
@@ -37,10 +37,7 @@ export async function loadModuleWithRetry<TModule>(
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const nextDelayMs = retryDelaysMs[attempt];
|
||||
if (
|
||||
nextDelayMs === undefined ||
|
||||
!isRetryableModuleImportFailure(error)
|
||||
) {
|
||||
if (nextDelayMs === undefined || !isRetryableModuleImportFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -48,9 +45,7 @@ export async function loadModuleWithRetry<TModule>(
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error("模块加载失败");
|
||||
throw lastError instanceof Error ? lastError : new Error("模块加载失败");
|
||||
}
|
||||
|
||||
export function loadAgentChatWorkspaceModule() {
|
||||
|
||||
@@ -148,9 +148,13 @@ export function AgentThreadFileCheckpointDialog({
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setListState(createAsyncState<AgentRuntimeFileCheckpointListResult>("loading"));
|
||||
setListState(
|
||||
createAsyncState<AgentRuntimeFileCheckpointListResult>("loading"),
|
||||
);
|
||||
setDetailState(createAsyncState<AgentRuntimeFileCheckpointDetail>("idle"));
|
||||
setDiffState(createAsyncState<AgentRuntimeFileCheckpointDiffResult>("idle"));
|
||||
setDiffState(
|
||||
createAsyncState<AgentRuntimeFileCheckpointDiffResult>("idle"),
|
||||
);
|
||||
setSelectedCheckpointId("");
|
||||
|
||||
void listAgentRuntimeFileCheckpoints({
|
||||
@@ -165,7 +169,9 @@ export function AgentThreadFileCheckpointDialog({
|
||||
const defaultCheckpoint =
|
||||
checkpoints.find(
|
||||
(checkpoint) => checkpoint.checkpoint_id === defaultCheckpointId,
|
||||
) || checkpoints[0] || null;
|
||||
) ||
|
||||
checkpoints[0] ||
|
||||
null;
|
||||
|
||||
setListState(
|
||||
createAsyncState<AgentRuntimeFileCheckpointListResult>(
|
||||
@@ -202,8 +208,12 @@ export function AgentThreadFileCheckpointDialog({
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDetailState(createAsyncState<AgentRuntimeFileCheckpointDetail>("loading"));
|
||||
setDiffState(createAsyncState<AgentRuntimeFileCheckpointDiffResult>("loading"));
|
||||
setDetailState(
|
||||
createAsyncState<AgentRuntimeFileCheckpointDetail>("loading"),
|
||||
);
|
||||
setDiffState(
|
||||
createAsyncState<AgentRuntimeFileCheckpointDiffResult>("loading"),
|
||||
);
|
||||
|
||||
void Promise.allSettled([
|
||||
getAgentRuntimeFileCheckpoint({
|
||||
@@ -291,8 +301,8 @@ export function AgentThreadFileCheckpointDialog({
|
||||
<DialogTitle className="pr-8">文件快照详情</DialogTitle>
|
||||
<DialogDescription className="space-y-1 text-xs leading-5">
|
||||
<span className="block">
|
||||
当前弹窗直接消费 runtime file checkpoint 的 current 主链:
|
||||
`list / detail / diff`。
|
||||
当前弹窗直接消费 runtime file checkpoint 的 current 主链: `list /
|
||||
detail / diff`。
|
||||
</span>
|
||||
<span className="block font-mono text-[11px] text-muted-foreground">
|
||||
session={sessionId}
|
||||
@@ -617,7 +627,11 @@ export function AgentThreadFileCheckpointDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -97,7 +97,9 @@ function DetailPanel(props: {
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<article className={cn("rounded-xl border px-3 py-3", SLATE_PANEL_CLASS_NAME)}>
|
||||
<article
|
||||
className={cn("rounded-xl border px-3 py-3", SLATE_PANEL_CLASS_NAME)}
|
||||
>
|
||||
<div className={cn("text-xs font-medium", SLATE_TITLE_CLASS_NAME)}>
|
||||
{props.title}
|
||||
</div>
|
||||
|
||||
@@ -567,9 +567,7 @@ describe("AgentThreadReliabilityPanel", () => {
|
||||
});
|
||||
expect(document.body.textContent).toContain("文件快照详情");
|
||||
expect(document.body.textContent).toContain("缺少 reviewer 字段");
|
||||
expect(document.body.textContent).toContain(
|
||||
"新增可靠性面板快照详情入口",
|
||||
);
|
||||
expect(document.body.textContent).toContain("新增可靠性面板快照详情入口");
|
||||
expect(document.body.textContent).toContain("artifact-document:req-2:v7");
|
||||
expect(document.body.textContent).toContain(
|
||||
".lime/artifacts/thread-file/persistence-map.artifact.json",
|
||||
|
||||
@@ -999,8 +999,11 @@ export const AgentThreadReliabilityPanel: React.FC<
|
||||
const oemPolicy = threadRead?.oem_policy || null;
|
||||
const fallbackChain = Array.isArray(threadRead?.fallback_chain)
|
||||
? threadRead?.fallback_chain || []
|
||||
: Array.isArray((runtimeSummary as { fallbackChain?: unknown } | null)?.fallbackChain)
|
||||
? (((runtimeSummary as { fallbackChain?: string[] | null }).fallbackChain || []) as string[])
|
||||
: Array.isArray(
|
||||
(runtimeSummary as { fallbackChain?: unknown } | null)?.fallbackChain,
|
||||
)
|
||||
? (((runtimeSummary as { fallbackChain?: string[] | null })
|
||||
.fallbackChain || []) as string[])
|
||||
: [];
|
||||
const runtimeDecisionReason =
|
||||
threadRead?.decision_reason ||
|
||||
@@ -1405,7 +1408,9 @@ export const AgentThreadReliabilityPanel: React.FC<
|
||||
|
||||
{runtimeDecisionReason || fallbackChain.length > 0 || oemPolicy ? (
|
||||
<div className="mt-4 rounded-2xl border border-sky-200 bg-sky-50/80 px-4 py-3">
|
||||
<div className="text-sm font-medium text-foreground">当前路由事实</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
当前路由事实
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-slate-700">
|
||||
{runtimeDecisionReason ? (
|
||||
<div>
|
||||
@@ -1437,11 +1442,17 @@ export const AgentThreadReliabilityPanel: React.FC<
|
||||
OEM 额度偏低
|
||||
</Badge>
|
||||
) : null}
|
||||
{((oemPolicy as { defaultModel?: string | null }).defaultModel ||
|
||||
(oemPolicy as { selectedModel?: string | null }).selectedModel) ? (
|
||||
{(oemPolicy as { defaultModel?: string | null }).defaultModel ||
|
||||
(oemPolicy as { selectedModel?: string | null })
|
||||
.selectedModel ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
OEM 模型 {((oemPolicy as { defaultModel?: string | null }).defaultModel ||
|
||||
(oemPolicy as { selectedModel?: string | null }).selectedModel)!}
|
||||
OEM 模型{" "}
|
||||
{
|
||||
((oemPolicy as { defaultModel?: string | null })
|
||||
.defaultModel ||
|
||||
(oemPolicy as { selectedModel?: string | null })
|
||||
.selectedModel)!
|
||||
}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -905,7 +905,9 @@ describe("AgentThreadTimeline", () => {
|
||||
summary?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const markdownBlocks = container.querySelectorAll('[data-testid="markdown-renderer"]');
|
||||
const markdownBlocks = container.querySelectorAll(
|
||||
'[data-testid="markdown-renderer"]',
|
||||
);
|
||||
expect(markdownBlocks[0]?.textContent).toBe(
|
||||
"目录也不存在。可能整个 .lime 目录都不存在。",
|
||||
);
|
||||
|
||||
@@ -834,7 +834,8 @@ function renderGroupItemDetails(
|
||||
|
||||
if (item.type === "subagent_activity") {
|
||||
const subagentSessionId = item.session_id?.trim();
|
||||
const displayTitle = resolveInternalImageTaskDisplayName(item.title) || "子任务";
|
||||
const displayTitle =
|
||||
resolveInternalImageTaskDisplayName(item.title) || "子任务";
|
||||
|
||||
return (
|
||||
<SurfaceCard
|
||||
|
||||
@@ -619,6 +619,18 @@ describe("CanvasWorkbenchLayout", () => {
|
||||
.querySelector('[data-testid="canvas-workbench-shell"]')
|
||||
?.getAttribute("data-layout-mode"),
|
||||
).toBe("split");
|
||||
expect(
|
||||
container.querySelector('[data-testid="canvas-workbench-shell"]')
|
||||
?.className,
|
||||
).toContain("lime-workbench-theme-scope");
|
||||
expect(
|
||||
container.querySelector('[data-testid="canvas-workbench-shell"]')
|
||||
?.className,
|
||||
).toContain("lime-workbench-surface-scope");
|
||||
expect(
|
||||
container.querySelector('[data-testid="canvas-workbench-shell"]')
|
||||
?.className,
|
||||
).toContain("bg-[color:var(--lime-surface)]");
|
||||
expect(
|
||||
container.querySelector(
|
||||
'button[aria-label="切换画布标签-Session · Main"]',
|
||||
@@ -691,7 +703,8 @@ describe("CanvasWorkbenchLayout", () => {
|
||||
workspaceRoot: "/workspace",
|
||||
workspaceUnavailable: false,
|
||||
defaultPreview: {
|
||||
selectionKey: "default-preview:exports/x-article-export/latest/index.md",
|
||||
selectionKey:
|
||||
"default-preview:exports/x-article-export/latest/index.md",
|
||||
title: "index.md",
|
||||
content: "# 导出结果\n\n这是正文。",
|
||||
filePath: "exports/x-article-export/latest/index.md",
|
||||
@@ -735,11 +748,13 @@ describe("CanvasWorkbenchLayout", () => {
|
||||
expect(workspaceButtons).toContain("展开目录-skills");
|
||||
expect(workspaceButtons).toContain("展开目录-images");
|
||||
|
||||
const indexButtonPosition = workspaceButtons.indexOf("选择工作区文件-index.md");
|
||||
const indexButtonPosition =
|
||||
workspaceButtons.indexOf("选择工作区文件-index.md");
|
||||
const agentsButtonPosition =
|
||||
workspaceButtons.indexOf("选择工作区文件-Agents.md");
|
||||
const manifestButtonPosition =
|
||||
workspaceButtons.indexOf("选择工作区文件-manifest.json");
|
||||
const manifestButtonPosition = workspaceButtons.indexOf(
|
||||
"选择工作区文件-manifest.json",
|
||||
);
|
||||
|
||||
expect(indexButtonPosition).toBeGreaterThanOrEqual(0);
|
||||
expect(agentsButtonPosition).toBeGreaterThan(indexButtonPosition);
|
||||
@@ -1093,7 +1108,9 @@ describe("CanvasWorkbenchLayout", () => {
|
||||
await flushEffects();
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="canvas-workbench-panel-document"]'),
|
||||
container.querySelector(
|
||||
'[data-testid="canvas-workbench-panel-document"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="canvas-workbench-panel-session"]'),
|
||||
@@ -1144,7 +1161,9 @@ describe("CanvasWorkbenchLayout", () => {
|
||||
await flushEffects();
|
||||
|
||||
expect(
|
||||
harness.container.querySelector('[data-testid="canvas-workbench-panel-session"]'),
|
||||
harness.container.querySelector(
|
||||
'[data-testid="canvas-workbench-panel-session"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
harness.container.querySelector('[data-testid="session-view-panel"]'),
|
||||
@@ -1289,7 +1308,8 @@ describe("CanvasWorkbenchLayout", () => {
|
||||
workspaceRoot: "/workspace",
|
||||
workspaceUnavailable: false,
|
||||
defaultPreview: {
|
||||
selectionKey: "default-preview:exports/x-article-export/latest/index.md",
|
||||
selectionKey:
|
||||
"default-preview:exports/x-article-export/latest/index.md",
|
||||
title: "index.md",
|
||||
content: "# 导出结果\n\n这是正文。",
|
||||
filePath: "exports/x-article-export/latest/index.md",
|
||||
@@ -1400,9 +1420,7 @@ describe("CanvasWorkbenchLayout", () => {
|
||||
container.querySelector('[data-testid="team-panel"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'button[aria-label="切换画布标签-生成"]',
|
||||
),
|
||||
container.querySelector('button[aria-label="切换画布标签-生成"]'),
|
||||
).not.toBeNull();
|
||||
expect(renderPreview).toHaveBeenCalled();
|
||||
expect(renderTeamPanel).toHaveBeenCalled();
|
||||
|
||||
@@ -258,22 +258,22 @@ export interface CanvasWorkbenchLayoutProps {
|
||||
}
|
||||
|
||||
const WORKBENCH_SHELL_CLASSNAME =
|
||||
"rounded-[28px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5";
|
||||
"rounded-[28px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] shadow-sm shadow-slate-950/5";
|
||||
|
||||
const WORKBENCH_PANEL_CLASSNAME =
|
||||
"rounded-[24px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5";
|
||||
"rounded-[24px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] shadow-sm shadow-slate-950/5";
|
||||
|
||||
const WORKBENCH_MUTED_PANEL_CLASSNAME =
|
||||
"rounded-[24px] border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-sm text-slate-500";
|
||||
"rounded-[24px] border border-dashed border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)] px-4 py-6 text-sm text-[color:var(--lime-text-muted)]";
|
||||
|
||||
const WORKBENCH_BUTTON_CLASSNAME =
|
||||
"border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900";
|
||||
"border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)] text-[color:var(--lime-text)] hover:border-[color:var(--lime-surface-border-strong)] hover:bg-[color:var(--lime-surface)] hover:text-[color:var(--lime-text-strong)]";
|
||||
|
||||
const WORKBENCH_ACTIVE_BUTTON_CLASSNAME =
|
||||
"border-slate-300 bg-white text-slate-950 shadow-sm shadow-slate-950/5";
|
||||
"border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-surface)] text-[color:var(--lime-text-strong)] shadow-sm shadow-slate-950/5";
|
||||
|
||||
const WORKBENCH_GHOST_BUTTON_CLASSNAME =
|
||||
"border-slate-200/80 text-slate-500 hover:bg-slate-50 hover:text-slate-900";
|
||||
"border-[color:var(--lime-surface-border)] text-[color:var(--lime-text-muted)] hover:bg-[color:var(--lime-surface-soft)] hover:text-[color:var(--lime-text-strong)]";
|
||||
|
||||
const STACKED_LAYOUT_BREAKPOINT = 1040;
|
||||
|
||||
@@ -341,7 +341,10 @@ function resolveSavedContentBundleRoot(
|
||||
workspaceRoot: string | null | undefined,
|
||||
selectionPath: string | null | undefined,
|
||||
): string | null {
|
||||
const relativePath = resolveWorkspaceRelativePath(workspaceRoot, selectionPath);
|
||||
const relativePath = resolveWorkspaceRelativePath(
|
||||
workspaceRoot,
|
||||
selectionPath,
|
||||
);
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
@@ -415,7 +418,10 @@ function sortWorkspaceListingEntries(
|
||||
if (entry.isDir && normalizedName === "skills") {
|
||||
return 2;
|
||||
}
|
||||
if (entry.isDir && (normalizedName === "images" || normalizedName === "assets")) {
|
||||
if (
|
||||
entry.isDir &&
|
||||
(normalizedName === "images" || normalizedName === "assets")
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
if (entry.isDir) {
|
||||
@@ -589,10 +595,7 @@ function buildEntries(
|
||||
}),
|
||||
subtitle: taskFile.name,
|
||||
filePath: taskFile.name,
|
||||
absolutePath: resolveAbsoluteWorkspacePath(
|
||||
workspaceRoot,
|
||||
taskFile.name,
|
||||
),
|
||||
absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, taskFile.name),
|
||||
previewText: taskFile.content?.trim().slice(0, 180),
|
||||
createdAt: taskFile.updatedAt,
|
||||
badgeLabel: taskFile.type === "document" ? "文档" : undefined,
|
||||
@@ -612,7 +615,10 @@ function buildEntries(
|
||||
.flatMap((artifact) => {
|
||||
const filePath = resolveArtifactProtocolFilePath(artifact);
|
||||
const writePhase = resolveArtifactWritePhase(artifact);
|
||||
const absolutePath = resolveAbsoluteWorkspacePath(workspaceRoot, filePath);
|
||||
const absolutePath = resolveAbsoluteWorkspacePath(
|
||||
workspaceRoot,
|
||||
filePath,
|
||||
);
|
||||
const pathKey = normalizePath(absolutePath || filePath || "");
|
||||
if (pathKey) {
|
||||
if (taskFilePathSet.has(pathKey) || seenArtifactPaths.has(pathKey)) {
|
||||
@@ -621,25 +627,27 @@ function buildEntries(
|
||||
seenArtifactPaths.add(pathKey);
|
||||
}
|
||||
|
||||
return [{
|
||||
key: `artifact:${artifact.id}`,
|
||||
source: "artifact",
|
||||
artifact,
|
||||
title: resolveContentPostArtifactDisplayTitle({
|
||||
title: artifact.title,
|
||||
return [
|
||||
{
|
||||
key: `artifact:${artifact.id}`,
|
||||
source: "artifact",
|
||||
artifact,
|
||||
title: resolveContentPostArtifactDisplayTitle({
|
||||
title: artifact.title,
|
||||
filePath,
|
||||
metadata: artifact.meta,
|
||||
}),
|
||||
subtitle: filePath,
|
||||
filePath,
|
||||
metadata: artifact.meta,
|
||||
}),
|
||||
subtitle: filePath,
|
||||
filePath,
|
||||
absolutePath,
|
||||
previewText: resolveArtifactPreviewText(artifact),
|
||||
createdAt: artifact.updatedAt || artifact.createdAt,
|
||||
badgeLabel: writePhase
|
||||
? formatArtifactWritePhaseLabel(writePhase)
|
||||
: undefined,
|
||||
kindLabel: "产物",
|
||||
}];
|
||||
absolutePath,
|
||||
previewText: resolveArtifactPreviewText(artifact),
|
||||
createdAt: artifact.updatedAt || artifact.createdAt,
|
||||
badgeLabel: writePhase
|
||||
? formatArtifactWritePhaseLabel(writePhase)
|
||||
: undefined,
|
||||
kindLabel: "产物",
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (isDocumentCanvasState(canvasState)) {
|
||||
@@ -1403,7 +1411,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
resolveSavedContentBundleRoot(
|
||||
workspaceRoot,
|
||||
documentContext?.selectionPath || sessionContext?.selectionPath,
|
||||
) || workspaceRoot || null,
|
||||
) ||
|
||||
workspaceRoot ||
|
||||
null,
|
||||
[
|
||||
documentContext?.selectionPath,
|
||||
sessionContext?.selectionPath,
|
||||
@@ -1580,9 +1590,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
{
|
||||
key: "team" as const,
|
||||
label:
|
||||
teamView.tabLabel?.trim() ||
|
||||
teamView.title?.trim() ||
|
||||
"生成",
|
||||
teamView.tabLabel?.trim() || teamView.title?.trim() || "生成",
|
||||
badge:
|
||||
teamView.tabBadge?.trim() ||
|
||||
teamView.triggerState?.label?.trim() ||
|
||||
@@ -1958,7 +1966,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="刷新工作区文件树"
|
||||
onClick={() => void refreshDirectorySubtree(workspacePanelRootPath)}
|
||||
onClick={() =>
|
||||
void refreshDirectorySubtree(workspacePanelRootPath)
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex h-9 w-9 items-center justify-center rounded-xl border transition-colors",
|
||||
WORKBENCH_GHOST_BUTTON_CLASSNAME,
|
||||
@@ -1977,7 +1987,8 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
renderDirectoryNode(workspacePanelRootPath)
|
||||
) : (
|
||||
<div className="px-2 py-4 text-sm text-slate-500">
|
||||
{workspaceView?.panelCopy?.emptyDirectoryText || "暂无目录内容。"}
|
||||
{workspaceView?.panelCopy?.emptyDirectoryText ||
|
||||
"暂无目录内容。"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2028,8 +2039,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
return (
|
||||
<div data-testid="canvas-workbench-panel-team" className="p-5">
|
||||
<div className={WORKBENCH_MUTED_PANEL_CLASSNAME}>
|
||||
{teamView?.panelCopy?.emptyText ||
|
||||
"当前没有可展示的生成结果。"}
|
||||
{teamView?.panelCopy?.emptyText || "当前没有可展示的生成结果。"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2174,18 +2184,20 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
data-testid="canvas-workbench-shell"
|
||||
data-layout-mode={isStackedLayout ? "stacked" : "split"}
|
||||
className={cn(
|
||||
"lime-workbench-theme-scope",
|
||||
"lime-workbench-surface-scope",
|
||||
WORKBENCH_SHELL_CLASSNAME,
|
||||
"relative flex h-full min-h-0 flex-col overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<header className="border-b border-slate-200/80 bg-white px-4 py-3">
|
||||
<header className="border-b border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] px-4 py-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3",
|
||||
isStackedLayout && "flex-col items-stretch",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 rounded-[24px] border border-slate-200 bg-slate-100 p-1.5">
|
||||
<div className="min-w-0 flex-1 rounded-[24px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)] p-1.5">
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{primaryTabs.map((tab) =>
|
||||
renderTopTab({
|
||||
@@ -2197,7 +2209,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
)}
|
||||
|
||||
{documentTabs.length > 0 ? (
|
||||
<div className="mx-1 h-6 w-px shrink-0 bg-slate-300/80" />
|
||||
<div className="mx-1 h-6 w-px shrink-0 bg-[color:var(--lime-surface-border-strong)]" />
|
||||
) : null}
|
||||
|
||||
{documentTabs.map((tab) =>
|
||||
@@ -2255,15 +2267,15 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
<div
|
||||
data-testid="canvas-workbench-layout"
|
||||
data-panel-placement="canvas"
|
||||
className="min-h-0 flex-1 overflow-hidden bg-slate-50"
|
||||
className="min-h-0 flex-1 overflow-hidden bg-[image:var(--lime-stage-surface-soft)]"
|
||||
>
|
||||
{activeTab === "workspace"
|
||||
? renderWorkspacePanel()
|
||||
: activeTab === "team"
|
||||
? renderTeamPanel()
|
||||
: activeTab === "session"
|
||||
? renderSessionPanel()
|
||||
: renderDocumentPanel()}
|
||||
? renderTeamPanel()
|
||||
: activeTab === "session"
|
||||
? renderSessionPanel()
|
||||
: renderDocumentPanel()}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -236,10 +236,48 @@ describe("ChatNavbar", () => {
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(workspaceBar?.style.zIndex).toBe("8");
|
||||
expect(workspaceShell?.className).toContain("h-6");
|
||||
expect(workspaceShell?.className).toContain("min-w-[132px]");
|
||||
expect(workspaceShell?.className).toContain("max-w-[196px]");
|
||||
expect(workspaceShell?.style.backgroundColor).toBe("rgb(248, 252, 249)");
|
||||
expect(workspaceShell?.className).toContain("h-9");
|
||||
expect(workspaceShell?.className).toContain("min-w-[148px]");
|
||||
expect(workspaceShell?.className).toContain("max-w-[224px]");
|
||||
expect(workspaceShell?.className).toContain(
|
||||
"bg-[color:var(--lime-chrome-tab-active-surface)]",
|
||||
);
|
||||
expect(workspaceShell?.querySelectorAll("span[aria-hidden]")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("任务中心工作区提示应浮在加号上方,并在点击加号时关闭", () => {
|
||||
const onDismissWorkspaceHint = vi.fn();
|
||||
const container = renderChatNavbar({
|
||||
contextVariant: "task-center",
|
||||
projectId: "project-1",
|
||||
workspaceType: "general",
|
||||
workspaceHintVisible: true,
|
||||
workspaceHintMessage: "在这里切换或新建工作区",
|
||||
onDismissWorkspaceHint,
|
||||
});
|
||||
|
||||
const hint = container.querySelector(
|
||||
'[data-testid="task-center-workspace-hint"]',
|
||||
) as HTMLElement | null;
|
||||
const menuTrigger = container.querySelector(
|
||||
'button[aria-label="展开工作区菜单"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(hint).not.toBeNull();
|
||||
expect(hint?.textContent).toContain("在这里切换或新建工作区");
|
||||
expect(hint?.className).toContain("bottom-full");
|
||||
expect(hint?.className).toContain(
|
||||
"border-[color:var(--lime-surface-border)]",
|
||||
);
|
||||
expect(hint?.className).not.toContain("sky");
|
||||
|
||||
act(() => {
|
||||
menuTrigger?.click();
|
||||
});
|
||||
|
||||
expect(onDismissWorkspaceHint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("任务中心顶栏应保留 Harness 状态入口", () => {
|
||||
@@ -317,8 +355,11 @@ describe("ChatNavbar", () => {
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.className).toContain("border-amber-300");
|
||||
expect(button?.className).toContain("text-amber-800");
|
||||
expect(button?.className).toContain(
|
||||
"border-[color:var(--lime-warning-border)]",
|
||||
);
|
||||
expect(button?.className).toContain("text-[color:var(--lime-warning)]");
|
||||
expect(button?.className).not.toContain("amber-300");
|
||||
});
|
||||
|
||||
it("压缩上下文运行中时应禁用顶栏操作", () => {
|
||||
|
||||
@@ -14,7 +14,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { ProjectSelector } from "@/components/projects/ProjectSelector";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Navbar } from "../styles";
|
||||
import { TASK_CENTER_CHROME_SURFACE } from "../workspace/taskCenterChromeTokens";
|
||||
import {
|
||||
TASK_CENTER_CHROME_ACTIVE_TAB,
|
||||
TASK_CENTER_CHROME_RAIL_SURFACE,
|
||||
} from "../workspace/taskCenterChromeTokens";
|
||||
|
||||
interface ChatNavbarProps {
|
||||
isRunning: boolean;
|
||||
@@ -36,6 +39,10 @@ interface ChatNavbarProps {
|
||||
projectId?: string | null;
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
workspaceType?: string;
|
||||
deferWorkspaceListLoad?: boolean;
|
||||
workspaceHintMessage?: string;
|
||||
workspaceHintVisible?: boolean;
|
||||
onDismissWorkspaceHint?: () => void;
|
||||
showHarnessToggle?: boolean;
|
||||
harnessPanelVisible?: boolean;
|
||||
onToggleHarnessPanel?: () => void;
|
||||
@@ -48,33 +55,34 @@ interface ChatNavbarProps {
|
||||
}
|
||||
|
||||
const toolbarGroupClassName =
|
||||
"flex items-center rounded-[20px] border border-slate-200/80 bg-white/90 p-1.5 shadow-sm shadow-slate-950/5 backdrop-blur-sm";
|
||||
"flex items-center rounded-[20px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-subtle)] p-1.5 shadow-sm shadow-slate-950/5 backdrop-blur-sm";
|
||||
|
||||
const toolbarDividerClassName = "mx-1.5 h-6 w-px shrink-0 bg-slate-200/80";
|
||||
const toolbarDividerClassName =
|
||||
"mx-1.5 h-6 w-px shrink-0 bg-[color:var(--lime-surface-border)]";
|
||||
|
||||
const toolbarEmbeddedButtonClassName =
|
||||
"h-9 rounded-2xl border border-transparent px-3.5 text-xs shadow-none";
|
||||
|
||||
const toolbarGhostIconButtonClassName =
|
||||
"h-9 w-9 rounded-2xl text-slate-500 hover:bg-slate-100 hover:text-slate-900";
|
||||
"h-9 w-9 rounded-2xl text-[color:var(--lime-text-muted)] hover:bg-[color:var(--lime-surface-hover)] hover:text-[color:var(--lime-text)]";
|
||||
|
||||
const toolbarTextButtonClassName =
|
||||
"gap-1.5 text-slate-700 hover:bg-white hover:text-slate-900";
|
||||
"gap-1.5 text-[color:var(--lime-text)] hover:bg-[color:var(--lime-surface)] hover:text-[color:var(--lime-text-strong)]";
|
||||
|
||||
const taskCenterTopRailClassName =
|
||||
"relative flex h-7 w-full items-start overflow-visible bg-transparent pl-5 pr-3 pt-[3px]";
|
||||
"relative flex h-[42px] w-full items-end overflow-visible bg-[color:var(--lime-chrome-rail)] px-4 pt-1";
|
||||
|
||||
const taskCenterWorkspaceTabClassName =
|
||||
"relative z-20 flex h-6 min-w-[132px] max-w-[196px] items-start rounded-t-[10px] border border-b-0 border-slate-200/70 px-2 pt-px text-sm font-medium text-slate-700 shadow-[0_-1px_0_rgba(255,255,255,0.86)] dark:border-white/10 dark:bg-slate-900 dark:text-slate-300";
|
||||
"relative z-20 flex h-9 min-w-[148px] max-w-[224px] items-center rounded-t-[18px] rounded-b-none border border-b-0 border-[color:var(--lime-chrome-border)] bg-[color:var(--lime-chrome-tab-active-surface)] px-2 text-sm font-medium text-[color:var(--lime-chrome-text)] shadow-[inset_0_1px_0_rgba(255,255,255,0.72)] dark:border-white/10 dark:bg-slate-900 dark:text-slate-300";
|
||||
|
||||
const taskCenterWorkspaceTabCurveClassName =
|
||||
"pointer-events-none absolute bottom-0 h-3.5 w-3.5 bg-transparent";
|
||||
"pointer-events-none absolute bottom-0 h-[18px] w-[18px] bg-transparent";
|
||||
|
||||
const taskCenterIconButtonClassName =
|
||||
"h-7 w-7 rounded-[10px] border border-transparent bg-transparent text-slate-500 shadow-none transition-[background-color,color] hover:bg-white/70 hover:text-slate-900";
|
||||
"h-7 w-7 rounded-[12px] border border-transparent bg-transparent text-[color:var(--lime-chrome-muted)] shadow-none transition-[background-color,color] hover:bg-[color:var(--lime-chrome-tab-hover)] hover:text-[color:var(--lime-chrome-text)]";
|
||||
|
||||
const taskCenterPillButtonClassName =
|
||||
"h-7 rounded-[10px] border border-transparent bg-transparent px-2 text-[11px] font-medium text-slate-700 shadow-none transition-[background-color,color] hover:bg-white/70 hover:text-slate-900";
|
||||
"h-7 rounded-[12px] border border-transparent bg-transparent px-2 text-[11px] font-medium text-[color:var(--lime-chrome-text)] shadow-none transition-[background-color,color] hover:bg-[color:var(--lime-chrome-tab-hover)] hover:text-[color:var(--lime-text-strong)]";
|
||||
|
||||
export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
isRunning: _isRunning,
|
||||
@@ -96,6 +104,10 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
projectId = null,
|
||||
onProjectChange,
|
||||
workspaceType,
|
||||
deferWorkspaceListLoad,
|
||||
workspaceHintMessage,
|
||||
workspaceHintVisible = false,
|
||||
onDismissWorkspaceHint,
|
||||
showHarnessToggle = false,
|
||||
harnessPanelVisible = false,
|
||||
onToggleHarnessPanel,
|
||||
@@ -106,7 +118,8 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
contextCompactionRunning = false,
|
||||
onCompactContext,
|
||||
}) => {
|
||||
const [workspaceSelectorOpen, setWorkspaceSelectorOpen] = React.useState(false);
|
||||
const [workspaceSelectorOpen, setWorkspaceSelectorOpen] =
|
||||
React.useState(false);
|
||||
const isTaskCenterChrome = contextVariant === "task-center";
|
||||
const isWorkspaceCompact = chrome === "workspace-compact";
|
||||
const effectiveCollapseChrome = collapseChrome && !isTaskCenterChrome;
|
||||
@@ -114,7 +127,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
toolbarGroupClassName,
|
||||
(isWorkspaceCompact || effectiveCollapseChrome) && "rounded-[18px] p-1",
|
||||
effectiveCollapseChrome &&
|
||||
"border-slate-200/70 bg-white shadow-sm shadow-slate-950/4 backdrop-blur-0",
|
||||
"border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] shadow-sm shadow-slate-950/5 backdrop-blur-0",
|
||||
);
|
||||
const dividerClassName = cn(
|
||||
toolbarDividerClassName,
|
||||
@@ -122,7 +135,8 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
);
|
||||
const embeddedButtonClassName = cn(
|
||||
toolbarEmbeddedButtonClassName,
|
||||
(isWorkspaceCompact || effectiveCollapseChrome) && "h-8 rounded-[18px] px-3",
|
||||
(isWorkspaceCompact || effectiveCollapseChrome) &&
|
||||
"h-8 rounded-[18px] px-3",
|
||||
);
|
||||
const ghostIconButtonClassName = cn(
|
||||
toolbarGhostIconButtonClassName,
|
||||
@@ -145,6 +159,8 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
? "min-w-[184px] max-w-[248px]"
|
||||
: "min-w-[196px] max-w-[280px]";
|
||||
const showEntryContext = Boolean(entryContextLabel);
|
||||
const shouldDeferWorkspaceListLoad =
|
||||
deferWorkspaceListLoad ?? isTaskCenterChrome;
|
||||
|
||||
if (isTaskCenterChrome) {
|
||||
return (
|
||||
@@ -161,33 +177,29 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
zIndex: 8,
|
||||
}}
|
||||
>
|
||||
<div className={taskCenterTopRailClassName}>
|
||||
<div
|
||||
className={taskCenterTopRailClassName}
|
||||
style={{ background: TASK_CENTER_CHROME_RAIL_SURFACE }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={taskCenterWorkspaceTabClassName}
|
||||
data-testid="task-center-workspace-shell"
|
||||
style={{ backgroundColor: TASK_CENTER_CHROME_SURFACE }}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
taskCenterWorkspaceTabCurveClassName,
|
||||
"-left-3.5",
|
||||
)}
|
||||
className={cn(taskCenterWorkspaceTabCurveClassName, "-left-4")}
|
||||
style={{
|
||||
borderBottomRightRadius: 14,
|
||||
boxShadow: `5px 5px 0 5px ${TASK_CENTER_CHROME_SURFACE}`,
|
||||
borderBottomRightRadius: 18,
|
||||
boxShadow: `5px 5px 0 5px ${TASK_CENTER_CHROME_ACTIVE_TAB}`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
taskCenterWorkspaceTabCurveClassName,
|
||||
"-right-3.5",
|
||||
)}
|
||||
className={cn(taskCenterWorkspaceTabCurveClassName, "-right-4")}
|
||||
style={{
|
||||
borderBottomLeftRadius: 14,
|
||||
boxShadow: `-5px 5px 0 5px ${TASK_CENTER_CHROME_SURFACE}`,
|
||||
borderBottomLeftRadius: 18,
|
||||
boxShadow: `-5px 5px 0 5px ${TASK_CENTER_CHROME_ACTIVE_TAB}`,
|
||||
}}
|
||||
/>
|
||||
<ProjectSelector
|
||||
@@ -203,21 +215,49 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
enableManagement={workspaceType === "general"}
|
||||
density="compact"
|
||||
chrome="workspace-tab"
|
||||
className="w-auto max-w-[196px]"
|
||||
deferProjectListLoad={shouldDeferWorkspaceListLoad}
|
||||
skipDefaultWorkspaceReadyCheck={isTaskCenterChrome}
|
||||
className="w-auto max-w-[224px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 mt-[1px] flex items-center">
|
||||
<div className="relative ml-2 flex h-9 items-center pb-1">
|
||||
{workspaceHintVisible && workspaceHintMessage ? (
|
||||
<div
|
||||
className="absolute bottom-full left-1/2 z-40 mb-2 flex w-max max-w-[220px] -translate-x-1/2 items-center gap-2 rounded-2xl border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] px-3 py-2 text-xs font-medium text-[color:var(--lime-text)] shadow-lg shadow-slate-950/10 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
data-testid="task-center-workspace-hint"
|
||||
role="status"
|
||||
>
|
||||
<span>{workspaceHintMessage}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full px-1 text-[color:var(--lime-text-muted)] transition hover:bg-[color:var(--lime-surface-hover)] hover:text-[color:var(--lime-text)] dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||
aria-label="关闭工作区提示"
|
||||
onClick={onDismissWorkspaceHint}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-1/2 top-full h-2 w-2 -translate-x-1/2 -translate-y-1/2 rotate-45 border-b border-r border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-none bg-transparent text-slate-500 shadow-none hover:bg-transparent hover:text-slate-900 dark:text-slate-300 dark:hover:text-white"
|
||||
className="h-8 w-8 rounded-[14px] bg-transparent text-[color:var(--lime-chrome-muted)] shadow-none hover:bg-[color:var(--lime-chrome-tab-hover)] hover:text-[color:var(--lime-chrome-text)] dark:text-slate-300 dark:hover:text-white"
|
||||
onClick={() => {
|
||||
onDismissWorkspaceHint?.();
|
||||
setWorkspaceSelectorOpen((current) => !current);
|
||||
}}
|
||||
aria-label={workspaceSelectorOpen ? "收起工作区菜单" : "展开工作区菜单"}
|
||||
aria-label={
|
||||
workspaceSelectorOpen ? "收起工作区菜单" : "展开工作区菜单"
|
||||
}
|
||||
aria-expanded={workspaceSelectorOpen}
|
||||
title={workspaceSelectorOpen ? "收起工作区菜单" : "展开工作区菜单"}
|
||||
title={
|
||||
workspaceSelectorOpen ? "收起工作区菜单" : "展开工作区菜单"
|
||||
}
|
||||
data-testid="task-center-workspace-menu-trigger"
|
||||
>
|
||||
<Plus size={17} strokeWidth={1.7} />
|
||||
@@ -225,7 +265,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto mt-[2px] flex shrink-0 items-center gap-1">
|
||||
<div className="ml-auto flex h-9 shrink-0 items-center gap-1 pb-1">
|
||||
{showContextCompactionAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -237,7 +277,9 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
aria-label={
|
||||
contextCompactionRunning ? "正在压缩上下文" : "压缩上下文"
|
||||
}
|
||||
title={contextCompactionRunning ? "正在压缩上下文" : "压缩上下文"}
|
||||
title={
|
||||
contextCompactionRunning ? "正在压缩上下文" : "压缩上下文"
|
||||
}
|
||||
>
|
||||
<Box size={15} />
|
||||
</Button>
|
||||
@@ -251,10 +293,11 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
className={cn(
|
||||
taskCenterPillButtonClassName,
|
||||
"gap-1 px-2.5",
|
||||
harnessPanelVisible && "bg-white text-slate-900",
|
||||
harnessPanelVisible &&
|
||||
"bg-[color:var(--lime-chrome-tab-active-surface)] text-[color:var(--lime-text)]",
|
||||
harnessAttentionLevel === "warning" &&
|
||||
!harnessPanelVisible &&
|
||||
"bg-amber-50/90 text-amber-800 hover:bg-amber-100 hover:text-amber-900",
|
||||
"bg-[color:var(--lime-warning-soft)] text-[color:var(--lime-warning)] hover:bg-[color:var(--lime-warning-soft)] hover:text-[color:var(--lime-warning)]",
|
||||
)}
|
||||
onClick={onToggleHarnessPanel}
|
||||
aria-label={
|
||||
@@ -268,7 +311,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
<Sparkles size={12} />
|
||||
<span>{harnessToggleLabel}</span>
|
||||
{harnessPendingCount > 0 ? (
|
||||
<span className="rounded-full border border-emerald-200 bg-white px-1.5 py-0.5 text-[10px] font-medium leading-none text-emerald-700">
|
||||
<span className="rounded-full border border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-surface)] px-1.5 py-0.5 text-[10px] font-medium leading-none text-[color:var(--lime-brand-strong)]">
|
||||
{harnessPendingCount > 99 ? "99+" : harnessPendingCount}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -396,7 +439,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="inline-flex w-fit items-center rounded-full border border-slate-200/80 bg-slate-50 px-3 py-1 text-[11px] font-medium text-slate-600">
|
||||
<span className="inline-flex w-fit items-center rounded-full border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-muted)] px-3 py-1 text-[11px] font-medium text-[color:var(--lime-text-muted)]">
|
||||
{entryContextLabel}
|
||||
</span>
|
||||
{!isWorkspaceCompact && entryContextHint ? (
|
||||
@@ -424,6 +467,8 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
enableManagement={workspaceType === "general"}
|
||||
density="compact"
|
||||
chrome="embedded"
|
||||
deferProjectListLoad={shouldDeferWorkspaceListLoad}
|
||||
skipDefaultWorkspaceReadyCheck={shouldDeferWorkspaceListLoad}
|
||||
className={compactProjectSelectorClassName}
|
||||
/>
|
||||
{onToggleSettings ? (
|
||||
@@ -494,10 +539,11 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
className={cn(
|
||||
embeddedButtonClassName,
|
||||
toolbarTextButtonClassName,
|
||||
harnessPanelVisible && "bg-slate-100 text-slate-900",
|
||||
harnessPanelVisible &&
|
||||
"bg-[color:var(--lime-surface-hover)] text-[color:var(--lime-text)]",
|
||||
harnessAttentionLevel === "warning" &&
|
||||
!harnessPanelVisible &&
|
||||
"border-amber-300 bg-amber-50/75 text-amber-800 hover:bg-amber-100 hover:text-amber-900",
|
||||
"border-[color:var(--lime-warning-border)] bg-[color:var(--lime-warning-soft)] text-[color:var(--lime-warning)] hover:bg-[color:var(--lime-warning-soft)] hover:text-[color:var(--lime-warning)]",
|
||||
)}
|
||||
onClick={onToggleHarnessPanel}
|
||||
aria-label={
|
||||
@@ -511,7 +557,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
<Sparkles size={14} />
|
||||
<span>{harnessToggleLabel}</span>
|
||||
{harnessPendingCount > 0 ? (
|
||||
<span className="rounded-full border border-emerald-200 bg-white/90 px-1.5 py-0.5 text-[10px] font-medium leading-none text-emerald-700 shadow-sm shadow-emerald-950/10">
|
||||
<span className="rounded-full border border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-surface)] px-1.5 py-0.5 text-[10px] font-medium leading-none text-[color:var(--lime-brand-strong)] shadow-sm shadow-slate-950/10">
|
||||
{harnessPendingCount > 99 ? "99+" : harnessPendingCount}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowUpLeft,
|
||||
BrainCircuit,
|
||||
@@ -720,25 +726,26 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
const hasAnyTasks = topics.length > 0;
|
||||
const hasFilteredResults = filteredTaskItems.length > 0;
|
||||
const taskHeadingLabel = isTaskCenter ? "最近对话" : "任务";
|
||||
const taskHeadingHint =
|
||||
isTaskCenter ? "继续最近对话,待处理会话会优先显示在前面。" : null;
|
||||
const searchPlaceholder =
|
||||
isTaskCenter ? "搜索对话标题或摘要" : "搜索任务标题或摘要";
|
||||
const taskHeadingHint = isTaskCenter
|
||||
? "继续最近对话,待处理会话会优先显示在前面。"
|
||||
: null;
|
||||
const searchPlaceholder = isTaskCenter
|
||||
? "搜索对话标题或摘要"
|
||||
: "搜索任务标题或摘要";
|
||||
const newChatLabel = isTaskCenter ? "新建对话" : "新建任务";
|
||||
const allTasksLabel = isTaskCenter ? "全部对话" : "全部任务";
|
||||
const activeTasksLabel = isTaskCenter ? "待继续" : "仅看进行中";
|
||||
const emptyStateTitle =
|
||||
isTaskCenter ? "还没有最近对话" : "还没有任务";
|
||||
const emptyStateDescription =
|
||||
isTaskCenter
|
||||
? "从“新建对话”开始后,最近对话会自动出现在这里。"
|
||||
: "从“新建任务”开始输入需求,创建后会出现在这里。";
|
||||
const emptyStateTitle = isTaskCenter ? "还没有最近对话" : "还没有任务";
|
||||
const emptyStateDescription = isTaskCenter
|
||||
? "从“新建对话”开始后,最近对话会自动出现在这里。"
|
||||
: "从“新建任务”开始输入需求,创建后会出现在这里。";
|
||||
const noResultsTitle = isTaskCenter ? "没有匹配的对话" : "没有匹配的任务";
|
||||
const noResultsDescription = isTaskCenter
|
||||
? "试试搜索对话标题、摘要或状态关键词。"
|
||||
: "试试搜索标题、执行摘要或状态关键词。";
|
||||
const olderSectionMoreLabel =
|
||||
isTaskCenter ? "查看更多归档对话" : "查看更多历史任务";
|
||||
const olderSectionMoreLabel = isTaskCenter
|
||||
? "查看更多归档对话"
|
||||
: "查看更多历史任务";
|
||||
const taskCountLabel = searchKeyword.trim()
|
||||
? `${filteredTaskItems.length} 个结果`
|
||||
: `${topics.length} 个${isTaskCenter ? "对话" : "任务"}`;
|
||||
@@ -987,10 +994,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={sidebarShellClassName}
|
||||
data-testid="chat-sidebar"
|
||||
>
|
||||
<aside className={sidebarShellClassName} data-testid="chat-sidebar">
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
@@ -1094,7 +1098,9 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
|
||||
<div className="mt-3 flex items-center justify-between px-0.5 text-[11px] text-slate-400 dark:text-slate-500">
|
||||
<span>
|
||||
{statusFilter === "active" ? activeTasksLabel : allTasksLabel}
|
||||
{statusFilter === "active"
|
||||
? activeTasksLabel
|
||||
: allTasksLabel}
|
||||
</span>
|
||||
<span>{taskCountLabel}</span>
|
||||
</div>
|
||||
@@ -1248,12 +1254,12 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
|
||||
<div className="rounded-[20px] border border-slate-200/80 bg-white/86 px-3.5 py-3 shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{resolveSidebarDisplayTitle(
|
||||
currentTaskItem?.title,
|
||||
"当前子任务",
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{resolveSidebarDisplayTitle(
|
||||
currentTaskItem?.title,
|
||||
"当前子任务",
|
||||
)}
|
||||
</div>
|
||||
<Badge className="border border-emerald-200 bg-white/90 text-emerald-700 shadow-sm shadow-emerald-950/10 dark:border-white/10 dark:bg-white/10 dark:text-slate-100">
|
||||
当前子任务
|
||||
</Badge>
|
||||
|
||||
@@ -115,6 +115,50 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("共享启动弹层应接入工作台主题变量,而不是固定白绿底色", async () => {
|
||||
const task = findCuratedTaskTemplateById("daily-trend-briefing");
|
||||
expect(task).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<CuratedTaskLauncherDialog
|
||||
open
|
||||
task={task}
|
||||
onOpenChange={() => undefined}
|
||||
onConfirm={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = document.body.querySelector('[role="dialog"]');
|
||||
expect(dialog?.className).toContain("lime-workbench-theme-scope");
|
||||
expect(dialog?.className).toContain("lime-workbench-surface-scope");
|
||||
expect(dialog?.className).toContain("bg-[color:var(--lime-surface)]");
|
||||
expect(dialog?.className).toContain(
|
||||
"border-[color:var(--lime-surface-border)]",
|
||||
);
|
||||
|
||||
const header = Array.from(dialog?.querySelectorAll("div") ?? []).find(
|
||||
(element) =>
|
||||
(element as HTMLElement).className.includes(
|
||||
"bg-[image:var(--lime-card-subtle)]",
|
||||
),
|
||||
) as HTMLElement | undefined;
|
||||
expect(header?.className).toContain(
|
||||
"border-[color:var(--lime-surface-border)]",
|
||||
);
|
||||
|
||||
const confirmButton = document.body.querySelector(
|
||||
'[data-testid="curated-task-launcher-confirm"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(confirmButton?.className).toContain(
|
||||
"bg-[color:var(--lime-brand-strong)]",
|
||||
);
|
||||
expect(confirmButton?.className).toContain(
|
||||
"hover:bg-[color:var(--lime-brand)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("应把参考对象来源收成轻量元信息,而不是标签墙", async () => {
|
||||
const task = findCuratedTaskTemplateById("account-project-review");
|
||||
expect(task).not.toBeNull();
|
||||
@@ -180,7 +224,8 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
{
|
||||
session_id: "session-review-needs-evidence",
|
||||
decision_status: "needs_more_evidence",
|
||||
decision_summary: "这轮结果还缺证据,需要回到账号表现和爆款样本继续补证据。",
|
||||
decision_summary:
|
||||
"这轮结果还缺证据,需要回到账号表现和爆款样本继续补证据。",
|
||||
chosen_fix_strategy: "先补账号数据复盘,再拆一轮高表现内容做对照。",
|
||||
risk_level: "medium",
|
||||
risk_tags: ["证据不足", "需要复盘"],
|
||||
@@ -208,7 +253,9 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
'[data-testid="curated-task-launcher-review-feedback-banner"]',
|
||||
);
|
||||
expect(banner?.textContent).toContain("围绕最近判断");
|
||||
expect(banner?.textContent).toContain("最近判断已更新:短视频编排 · 补证据");
|
||||
expect(banner?.textContent).toContain(
|
||||
"最近判断已更新:短视频编排 · 补证据",
|
||||
);
|
||||
expect(banner?.textContent).toContain("这轮结果还缺证据");
|
||||
});
|
||||
|
||||
@@ -221,7 +268,8 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
{
|
||||
session_id: "session-review-switch",
|
||||
decision_status: "needs_more_evidence",
|
||||
decision_summary: "这轮结果还缺证据,需要回到账号表现和爆款样本继续补证据。",
|
||||
decision_summary:
|
||||
"这轮结果还缺证据,需要回到账号表现和爆款样本继续补证据。",
|
||||
chosen_fix_strategy: "先补账号数据复盘,再拆一轮高表现内容做对照。",
|
||||
risk_level: "medium",
|
||||
risk_tags: ["证据不足", "需要复盘"],
|
||||
@@ -239,9 +287,7 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
open
|
||||
task={task}
|
||||
projectId="project-review-switch"
|
||||
initialReferenceEntries={[
|
||||
buildExperienceMemoryReferenceEntry(),
|
||||
]}
|
||||
initialReferenceEntries={[buildExperienceMemoryReferenceEntry()]}
|
||||
onOpenChange={() => undefined}
|
||||
onApplyReviewSuggestion={onApplyReviewSuggestion}
|
||||
onConfirm={() => undefined}
|
||||
@@ -257,8 +303,12 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
'[data-testid="curated-task-launcher-review-feedback-banner-action"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(banner?.textContent).toContain("最近判断已更新:短视频编排 · 补证据");
|
||||
expect(banner?.textContent).toContain("这轮判断更建议优先回到「复盘这个账号/项目」");
|
||||
expect(banner?.textContent).toContain(
|
||||
"最近判断已更新:短视频编排 · 补证据",
|
||||
);
|
||||
expect(banner?.textContent).toContain(
|
||||
"这轮判断更建议优先回到「复盘这个账号/项目」",
|
||||
);
|
||||
expect(actionButton?.textContent).toContain("改用「复盘这个账号/项目」");
|
||||
|
||||
await act(async () => {
|
||||
@@ -384,7 +434,9 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
<CuratedTaskLauncherDialog
|
||||
open
|
||||
task={task}
|
||||
initialReferenceEntries={memoryReferenceEntry ? [memoryReferenceEntry] : []}
|
||||
initialReferenceEntries={
|
||||
memoryReferenceEntry ? [memoryReferenceEntry] : []
|
||||
}
|
||||
onOpenChange={() => undefined}
|
||||
onConfirm={() => undefined}
|
||||
/>,
|
||||
@@ -396,10 +448,10 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
'[data-testid="curated-task-launcher-sceneapp-baseline-card"]',
|
||||
);
|
||||
const themeField = document.body.querySelector(
|
||||
'#curated-task-daily-trend-briefing-theme_target',
|
||||
"#curated-task-daily-trend-briefing-theme_target",
|
||||
) as HTMLInputElement | null;
|
||||
const platformField = document.body.querySelector(
|
||||
'#curated-task-daily-trend-briefing-platform_region',
|
||||
"#curated-task-daily-trend-briefing-platform_region",
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
expect(baselineCard?.textContent).toContain("当前结果基线");
|
||||
@@ -424,7 +476,9 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
<CuratedTaskLauncherDialog
|
||||
open
|
||||
task={task}
|
||||
initialReferenceEntries={memoryReferenceEntry ? [memoryReferenceEntry] : []}
|
||||
initialReferenceEntries={
|
||||
memoryReferenceEntry ? [memoryReferenceEntry] : []
|
||||
}
|
||||
onOpenChange={() => undefined}
|
||||
onConfirm={() => undefined}
|
||||
/>,
|
||||
@@ -433,10 +487,10 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
});
|
||||
|
||||
const subjectField = document.body.querySelector(
|
||||
'#curated-task-social-post-starter-subject_or_product',
|
||||
"#curated-task-social-post-starter-subject_or_product",
|
||||
) as HTMLTextAreaElement | null;
|
||||
const audienceField = document.body.querySelector(
|
||||
'#curated-task-social-post-starter-target_audience',
|
||||
"#curated-task-social-post-starter-target_audience",
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
expect(subjectField?.value).toContain("当前主题:短视频编排");
|
||||
@@ -538,7 +592,8 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
taskPrefillByTaskId: {
|
||||
"account-project-review": {
|
||||
project_goal: "AI 内容周报",
|
||||
existing_results: "当前已有一轮项目结果,可直接作为复盘基线。",
|
||||
existing_results:
|
||||
"当前已有一轮项目结果,可直接作为复盘基线。",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -574,9 +629,7 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
expect(confirmButton?.getAttribute("disabled")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
confirmButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
@@ -709,7 +762,8 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
taskPrefillByTaskId: {
|
||||
"account-project-review": {
|
||||
project_goal: "AI 内容周报",
|
||||
existing_results: "当前已有一轮项目结果,可直接作为复盘基线。",
|
||||
existing_results:
|
||||
"当前已有一轮项目结果,可直接作为复盘基线。",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -756,9 +810,7 @@ describe("CuratedTaskLauncherDialog", () => {
|
||||
expect(confirmButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
confirmButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
|
||||
@@ -311,7 +311,10 @@ export function CuratedTaskLauncherDialog({
|
||||
});
|
||||
}, [latestReviewTaskSignal, task]);
|
||||
const primarySuggestedTask = useMemo(() => {
|
||||
if (!reviewFeedbackProjection || reviewFeedbackProjection.matchedCurrentTask) {
|
||||
if (
|
||||
!reviewFeedbackProjection ||
|
||||
reviewFeedbackProjection.matchedCurrentTask
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -415,10 +418,10 @@ export function CuratedTaskLauncherDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(640px,calc(100vw-32px))] max-w-none overflow-hidden border-slate-200 bg-white p-0">
|
||||
<DialogContent className="lime-workbench-theme-scope lime-workbench-surface-scope w-[min(640px,calc(100vw-32px))] max-w-none overflow-hidden border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] p-0 text-[color:var(--lime-text)]">
|
||||
{task ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[28px] bg-white">
|
||||
<DialogHeader className="shrink-0 border-b border-slate-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.96)_0%,rgba(255,255,255,1)_100%)] px-5 py-4">
|
||||
<DialogHeader className="shrink-0 border-b border-[color:var(--lime-surface-border)] bg-[image:var(--lime-card-subtle)] px-5 py-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -444,11 +447,15 @@ export function CuratedTaskLauncherDialog({
|
||||
{launcherStarterContract ? (
|
||||
<div className="rounded-[18px] border border-slate-200 bg-slate-50 px-3.5 py-3 text-xs leading-5 text-slate-600">
|
||||
<div>
|
||||
<span className="font-medium text-slate-700">你先给:</span>
|
||||
<span className="font-medium text-slate-700">
|
||||
你先给:
|
||||
</span>
|
||||
{launcherStarterContract.requiredSummary}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<span className="font-medium text-slate-700">会拿到:</span>
|
||||
<span className="font-medium text-slate-700">
|
||||
会拿到:
|
||||
</span>
|
||||
{launcherStarterContract.outputSummary}
|
||||
</div>
|
||||
{launcherStarterContract.followUpSummary ? (
|
||||
@@ -560,9 +567,7 @@ export function CuratedTaskLauncherDialog({
|
||||
"mt-2 rounded-[16px] border-slate-200 bg-slate-50 focus-visible:ring-emerald-300";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
>
|
||||
<div key={field.key}>
|
||||
<div className="rounded-[20px] border border-slate-200 bg-white px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label
|
||||
@@ -651,13 +656,12 @@ export function CuratedTaskLauncherDialog({
|
||||
!selected &&
|
||||
selectedReferenceEntryIds.length >=
|
||||
MAX_REFERENCE_SELECTION_COUNT;
|
||||
const entryReviewSnapshot =
|
||||
task
|
||||
? buildSceneAppExecutionReviewPrefillSnapshot({
|
||||
referenceEntries: [entry],
|
||||
taskId: task.id,
|
||||
})
|
||||
: null;
|
||||
const entryReviewSnapshot = task
|
||||
? buildSceneAppExecutionReviewPrefillSnapshot({
|
||||
referenceEntries: [entry],
|
||||
taskId: task.id,
|
||||
})
|
||||
: null;
|
||||
const entryReviewHighlights =
|
||||
buildSceneAppExecutionReviewPrefillHighlights(
|
||||
entryReviewSnapshot,
|
||||
@@ -707,11 +711,14 @@ export function CuratedTaskLauncherDialog({
|
||||
<div className="rounded-[14px] border border-emerald-200 bg-emerald-50/80 px-3 py-2 text-xs leading-5 text-emerald-900">
|
||||
<div className="font-medium text-emerald-900">
|
||||
当前结果基线:
|
||||
{entryReviewSnapshot?.sourceTitle || entry.title}
|
||||
{entryReviewSnapshot?.sourceTitle ||
|
||||
entry.title}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{entryReviewHighlights.map((item) => (
|
||||
<div key={`${entry.id}-${item}`}>{item}</div>
|
||||
<div key={`${entry.id}-${item}`}>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -754,7 +761,8 @@ export function CuratedTaskLauncherDialog({
|
||||
|
||||
{selectedReferenceEntryIds.length > 0 ? (
|
||||
<div className="mt-4 rounded-[18px] border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
已选择 {selectedReferenceEntryIds.length} 条参考对象,本轮会一起带入生成。
|
||||
已选择 {selectedReferenceEntryIds.length}{" "}
|
||||
条参考对象,本轮会一起带入生成。
|
||||
{missingSelectedReferenceCount > 0
|
||||
? ` 其中 ${missingSelectedReferenceCount} 条未出现在最近列表里,但发送时仍会保留。`
|
||||
: ""}
|
||||
@@ -787,7 +795,7 @@ export function CuratedTaskLauncherDialog({
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="curated-task-launcher-confirm"
|
||||
className="rounded-2xl border border-slate-900 bg-slate-900 px-4 text-white shadow-sm shadow-slate-950/10 hover:bg-slate-800"
|
||||
className="rounded-2xl border border-[color:var(--lime-brand-strong)] bg-[color:var(--lime-brand-strong)] px-4 text-white shadow-sm shadow-slate-950/10 hover:bg-[color:var(--lime-brand)]"
|
||||
disabled={isLaunchDisabled}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
|
||||
@@ -40,31 +40,30 @@ const mockListUnifiedMemories = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<UnifiedMemory[]>>(async () => []),
|
||||
);
|
||||
|
||||
const mockCharacterMention =
|
||||
vi.fn<
|
||||
(props: {
|
||||
characters?: Character[];
|
||||
skills?: Skill[];
|
||||
serviceSkills?: ServiceSkillHomeItem[];
|
||||
onSelectSkill?: (skill: Skill) => void;
|
||||
onSelectInputCapability?: (
|
||||
capability: InputCapabilitySelection,
|
||||
options?: { replayText?: string },
|
||||
) => void;
|
||||
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
|
||||
defaultCuratedTaskReferenceMemoryIds?: string[];
|
||||
defaultCuratedTaskReferenceEntries?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
category: string;
|
||||
categoryLabel: string;
|
||||
tags: string[];
|
||||
}>;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) => React.ReactNode
|
||||
>();
|
||||
const mockCharacterMention = vi.fn<
|
||||
(props: {
|
||||
characters?: Character[];
|
||||
skills?: Skill[];
|
||||
serviceSkills?: ServiceSkillHomeItem[];
|
||||
onSelectSkill?: (skill: Skill) => void;
|
||||
onSelectInputCapability?: (
|
||||
capability: InputCapabilitySelection,
|
||||
options?: { replayText?: string },
|
||||
) => void;
|
||||
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
|
||||
defaultCuratedTaskReferenceMemoryIds?: string[];
|
||||
defaultCuratedTaskReferenceEntries?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
category: string;
|
||||
categoryLabel: string;
|
||||
tags: string[];
|
||||
}>;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) => React.ReactNode
|
||||
>();
|
||||
|
||||
vi.mock("@/lib/api/appConfig", () => ({
|
||||
getConfig: mockGetConfig,
|
||||
@@ -187,13 +186,8 @@ vi.mock("@/components/ui/textarea", () => {
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({
|
||||
open,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => (open ? <div>{children}</div> : null),
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
@@ -203,7 +197,9 @@ vi.mock("@/components/ui/dialog", () => ({
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
@@ -486,9 +482,7 @@ describe("EmptyState", () => {
|
||||
);
|
||||
expect(container.textContent).toContain("先从这个开始");
|
||||
expect(container.textContent).toContain("其他起手结果");
|
||||
expect(container.textContent).toContain(
|
||||
"也可以直接按做法开工",
|
||||
);
|
||||
expect(container.textContent).toContain("也可以直接按做法开工");
|
||||
expect(container.textContent).toContain(
|
||||
"本轮产出会先写回当前任务,后续继续也会优先从这里接回。",
|
||||
);
|
||||
@@ -555,7 +549,8 @@ describe("EmptyState", () => {
|
||||
{
|
||||
session_id: "session-review-needs-evidence",
|
||||
decision_status: "needs_more_evidence",
|
||||
decision_summary: "这轮结果还缺证据,需要回到账号表现和爆款样本继续补证据。",
|
||||
decision_summary:
|
||||
"这轮结果还缺证据,需要回到账号表现和爆款样本继续补证据。",
|
||||
chosen_fix_strategy: "先补账号数据复盘,再拆一轮高表现内容做对照。",
|
||||
risk_level: "medium",
|
||||
risk_tags: ["证据不足", "需要复盘"],
|
||||
@@ -614,7 +609,9 @@ describe("EmptyState", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("开始这一步前,我先确认几件事。");
|
||||
expect(document.body.textContent).toContain(
|
||||
"开始这一步前,我先确认几件事。",
|
||||
);
|
||||
expect(document.body.textContent).toContain("复盘这个账号/项目");
|
||||
});
|
||||
|
||||
@@ -734,12 +731,12 @@ describe("EmptyState", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="empty-state-runtime-tool-warning"]'),
|
||||
container.querySelector(
|
||||
'[data-testid="empty-state-runtime-tool-warning"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(container.textContent).not.toContain("当前 runtime tool surface");
|
||||
expect(container.textContent).not.toContain(
|
||||
"联网搜索偏好本轮可能不会生效",
|
||||
);
|
||||
expect(container.textContent).not.toContain("联网搜索偏好本轮可能不会生效");
|
||||
expect(container.textContent).not.toContain(
|
||||
"任务拆分偏好本轮可能不会完全生效",
|
||||
);
|
||||
@@ -1084,11 +1081,10 @@ describe("EmptyState", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("传入项目切换能力时应继续保留项目选择器入口", async () => {
|
||||
it("首页主体不再重复展示项目选择器入口", async () => {
|
||||
const container = renderEmptyState({
|
||||
activeTheme: "general",
|
||||
projectId: "project-brand",
|
||||
onProjectChange: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -1097,7 +1093,8 @@ describe("EmptyState", () => {
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="project-selector-stub"]'),
|
||||
).toBeTruthy();
|
||||
).toBeNull();
|
||||
expect(mockProjectSelector).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("本轮产出会沉淀到当前项目");
|
||||
expect(
|
||||
container.querySelector('[data-testid^="entry-project-continuation-"]'),
|
||||
@@ -1187,7 +1184,9 @@ describe("EmptyState", () => {
|
||||
expect(container.textContent).not.toContain("Runtime Tool Surface");
|
||||
expect(container.textContent).not.toContain("Thread Runtime");
|
||||
expect(container.textContent).not.toContain("消息渠道 + 浏览器连接器");
|
||||
expect(container.textContent).not.toContain("当前回合 / 长期 / Team / 压缩");
|
||||
expect(container.textContent).not.toContain(
|
||||
"当前回合 / 长期 / Team / 压缩",
|
||||
);
|
||||
expect(container.textContent).not.toContain("频道入口");
|
||||
expect(container.textContent).not.toContain("浏览器连接器");
|
||||
expect(container.textContent).not.toContain("OpenClaw 兼容入口");
|
||||
@@ -1686,19 +1685,14 @@ describe("EmptyState", () => {
|
||||
act(() => {
|
||||
sendButton?.click();
|
||||
});
|
||||
expect(onSend).toHaveBeenCalledWith(
|
||||
"帮我设计封面",
|
||||
"react",
|
||||
undefined,
|
||||
{
|
||||
capabilityRoute: {
|
||||
kind: "installed_skill",
|
||||
skillKey: "canvas-design",
|
||||
skillName: "canvas-design",
|
||||
},
|
||||
displayContent: "帮我设计封面",
|
||||
expect(onSend).toHaveBeenCalledWith("帮我设计封面", "react", undefined, {
|
||||
capabilityRoute: {
|
||||
kind: "installed_skill",
|
||||
skillKey: "canvas-design",
|
||||
skillName: "canvas-design",
|
||||
},
|
||||
);
|
||||
displayContent: "帮我设计封面",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
sendButton?.click();
|
||||
@@ -2210,7 +2204,8 @@ describe("EmptyState", () => {
|
||||
memory_type: "project",
|
||||
category: "experience",
|
||||
title: "账号复盘结论",
|
||||
summary: "最近两次反馈都提示封面信息过密,需要继续复盘增长数据并优化结构。",
|
||||
summary:
|
||||
"最近两次反馈都提示封面信息过密,需要继续复盘增长数据并优化结构。",
|
||||
content:
|
||||
"最近两次反馈都提示封面信息过密,需要继续复盘增长数据并优化结构。",
|
||||
tags: ["复盘", "反馈", "增长"],
|
||||
@@ -2290,7 +2285,10 @@ describe("EmptyState", () => {
|
||||
|
||||
await act(async () => {
|
||||
updateFieldValue(initialThemeInput, initialInputValues.theme_target);
|
||||
updateFieldValue(initialPlatformInput, initialInputValues.platform_region);
|
||||
updateFieldValue(
|
||||
initialPlatformInput,
|
||||
initialInputValues.platform_region,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
@@ -2415,9 +2413,7 @@ describe("EmptyState", () => {
|
||||
});
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
expect(textarea?.getAttribute("placeholder")).toContain(
|
||||
"先说这轮要做什么",
|
||||
);
|
||||
expect(textarea?.getAttribute("placeholder")).toContain("先说这轮要做什么");
|
||||
});
|
||||
|
||||
it("点击高级设置中的地球按钮应切换联网搜索开关", async () => {
|
||||
@@ -2772,11 +2768,7 @@ describe("EmptyState", () => {
|
||||
sendButton?.click();
|
||||
});
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith(
|
||||
"整理最近发布计划",
|
||||
"react",
|
||||
undefined,
|
||||
);
|
||||
expect(onSend).toHaveBeenCalledWith("整理最近发布计划", "react", undefined);
|
||||
});
|
||||
|
||||
it("通用主题应提供浏览器协助入口并触发启动回调", async () => {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import {
|
||||
Settings2,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { getConfig } from "@/lib/api/appConfig";
|
||||
import type { CreationMode } from "./types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProjectSelector } from "@/components/projects/ProjectSelector";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
buildRecommendationPrompt,
|
||||
@@ -114,15 +110,20 @@ const PageContainer = styled.div.attrs({
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 8% 12%,
|
||||
rgba(132, 204, 22, 0.08),
|
||||
rgba(132, 204, 22, 0) 28%
|
||||
var(--lime-home-glow-primary, rgba(132, 204, 22, 0.08)),
|
||||
transparent 28%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 76% 16%,
|
||||
rgba(186, 230, 253, 0.16),
|
||||
rgba(186, 230, 253, 0) 30%
|
||||
var(--lime-home-glow-secondary, rgba(186, 230, 253, 0.16)),
|
||||
transparent 30%
|
||||
),
|
||||
linear-gradient(180deg, #f8fcf7 0%, #f9fbf8 36%, #f5faf7 100%);
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--lime-home-bg-start, #f8fcf7) 0%,
|
||||
var(--lime-home-bg-mid, #f9fbf8) 42%,
|
||||
var(--lime-home-bg-end, #f5faf7) 100%
|
||||
);
|
||||
`;
|
||||
|
||||
const ContentWrapper = styled.div.attrs({
|
||||
@@ -145,10 +146,14 @@ const RecommendationShelf = styled.div`
|
||||
flex-direction: column;
|
||||
gap: 0.88rem;
|
||||
border-radius: 30px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.92);
|
||||
background: linear-gradient(180deg, #fcfffb 0%, #f8fcfa 100%);
|
||||
border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.92));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--lime-surface-subtle, #fcfffb) 0%,
|
||||
var(--lime-surface-soft, #f8fcfa) 100%
|
||||
);
|
||||
padding: 1rem 1.05rem 0.95rem;
|
||||
box-shadow: 0 22px 42px -38px rgba(15, 23, 42, 0.16);
|
||||
box-shadow: 0 22px 42px -38px var(--lime-shadow-color);
|
||||
`;
|
||||
|
||||
const RecommendationShelfHeader = styled.div`
|
||||
@@ -172,45 +177,45 @@ const RecommendationShelfHeaderTitle = styled.div`
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgb(71 85 105);
|
||||
color: var(--lime-text, rgb(71 85 105));
|
||||
`;
|
||||
|
||||
const RecommendationShelfHeaderDescription = styled.div`
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: rgb(100 116 139);
|
||||
color: var(--lime-text-muted, rgb(100 116 139));
|
||||
`;
|
||||
|
||||
const RecommendationShelfInlineBadge = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgb(226 232 240);
|
||||
background: rgb(248 250 252);
|
||||
border: 1px solid var(--lime-surface-border, rgb(226 232 240));
|
||||
background: var(--lime-surface-soft, rgb(248 250 252));
|
||||
padding: 0.1rem 0.38rem;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: rgb(100 116 139);
|
||||
color: var(--lime-text-muted, rgb(100 116 139));
|
||||
`;
|
||||
|
||||
const RecommendationShelfBadge = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgb(209 250 229);
|
||||
background: rgb(236 253 245);
|
||||
border: 1px solid var(--lime-surface-border-strong, rgb(209 250 229));
|
||||
background: var(--lime-brand-soft, rgb(236 253 245));
|
||||
padding: 0.18rem 0.42rem;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: rgb(5 150 105);
|
||||
color: var(--lime-brand-strong, rgb(5 150 105));
|
||||
`;
|
||||
|
||||
const RecommendationShelfEmptyState = styled.div`
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: rgb(148 163 184);
|
||||
color: var(--lime-text-muted, rgb(148 163 184));
|
||||
padding: 0.1rem 0;
|
||||
`;
|
||||
|
||||
@@ -219,14 +224,18 @@ const RecommendationSignalBanner = styled.div`
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(191, 219, 254, 0.95);
|
||||
border: 1px solid var(--lime-home-card-border, rgba(191, 219, 254, 0.95));
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top right,
|
||||
rgba(186, 230, 253, 0.26),
|
||||
var(--lime-home-glow-secondary, rgba(186, 230, 253, 0.26)),
|
||||
rgba(255, 255, 255, 0) 48%
|
||||
),
|
||||
linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(255, 255, 255, 0.98));
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--lime-surface-soft, rgba(248, 250, 252, 0.96)),
|
||||
var(--lime-surface, rgba(255, 255, 255, 0.98))
|
||||
);
|
||||
padding: 0.9rem 0.95rem;
|
||||
`;
|
||||
|
||||
@@ -241,19 +250,19 @@ const RecommendationSignalBannerTitle = styled.div`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
color: rgb(15 23 42);
|
||||
color: var(--lime-text-strong, rgb(15 23 42));
|
||||
`;
|
||||
|
||||
const RecommendationSignalBannerSummary = styled.div`
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: rgb(71 85 105);
|
||||
color: var(--lime-text, rgb(71 85 105));
|
||||
`;
|
||||
|
||||
const RecommendationSignalBannerFootnote = styled.div`
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: rgb(14 116 144);
|
||||
color: var(--lime-info, rgb(14 116 144));
|
||||
`;
|
||||
|
||||
const RecommendationLeadCard = styled.button`
|
||||
@@ -264,25 +273,19 @@ const RecommendationLeadCard = styled.button`
|
||||
align-items: flex-start;
|
||||
gap: 0.72rem;
|
||||
border-radius: 30px;
|
||||
border: 1px solid rgba(191, 219, 254, 0.92);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top right,
|
||||
rgba(186, 230, 253, 0.48),
|
||||
rgba(255, 255, 255, 0) 42%
|
||||
),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(240, 249, 255, 0.94));
|
||||
border: 1px solid var(--lime-home-card-border, rgba(191, 219, 254, 0.92));
|
||||
background: var(--lime-home-card-surface-strong);
|
||||
padding: 1.22rem 1.25rem 1.18rem;
|
||||
text-align: left;
|
||||
color: rgb(51 65 85);
|
||||
color: var(--lime-text, rgb(51 65 85));
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
transform 180ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgb(147 197 253);
|
||||
box-shadow: 0 18px 42px -30px rgba(59, 130, 246, 0.22);
|
||||
border-color: var(--lime-home-card-hover-border, rgb(147 197 253));
|
||||
box-shadow: 0 18px 42px -30px var(--lime-shadow-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
`;
|
||||
@@ -301,26 +304,26 @@ const RecommendationLeadEyebrow = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: rgb(3 105 161);
|
||||
color: var(--lime-info, rgb(3 105 161));
|
||||
`;
|
||||
|
||||
const RecommendationLeadTitle = styled.div`
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: rgb(15 23 42);
|
||||
color: var(--lime-text-strong, rgb(15 23 42));
|
||||
`;
|
||||
|
||||
const RecommendationLeadSummary = styled.div`
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: rgb(71 85 105);
|
||||
color: var(--lime-text, rgb(71 85 105));
|
||||
`;
|
||||
|
||||
const RecommendationLeadMeta = styled.div`
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: rgb(100 116 139);
|
||||
color: var(--lime-text-muted, rgb(100 116 139));
|
||||
`;
|
||||
|
||||
const RecommendationLeadFooter = styled.div`
|
||||
@@ -330,7 +333,7 @@ const RecommendationLeadFooter = styled.div`
|
||||
padding-top: 0.2rem;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgb(15 23 42);
|
||||
color: var(--lime-text-strong, rgb(15 23 42));
|
||||
`;
|
||||
|
||||
const RecommendationAssistGroup = styled.div`
|
||||
@@ -344,7 +347,7 @@ const RecommendationAssistLabel = styled.div`
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: rgb(71 85 105);
|
||||
color: var(--lime-text, rgb(71 85 105));
|
||||
`;
|
||||
|
||||
const RecommendationAssistList = styled.div`
|
||||
@@ -369,20 +372,20 @@ const RecommendationAssistCard = styled.button`
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(219, 234, 254, 0.98);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(239, 246, 255, 0.92));
|
||||
border: 1px solid
|
||||
var(--lime-home-card-border-muted, rgba(219, 234, 254, 0.98));
|
||||
background: var(--lime-home-card-surface-strong);
|
||||
padding: 0.96rem 1rem;
|
||||
text-align: left;
|
||||
color: rgb(51 65 85);
|
||||
color: var(--lime-text, rgb(51 65 85));
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background-color 180ms ease,
|
||||
transform 180ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgb(147 197 253);
|
||||
background: rgb(255 255 255);
|
||||
border-color: var(--lime-home-card-hover-border, rgb(147 197 253));
|
||||
background: var(--lime-surface, rgb(255 255 255));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
`;
|
||||
@@ -400,19 +403,19 @@ const RecommendationAssistCardTitle = styled.div`
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: rgb(15 23 42);
|
||||
color: var(--lime-text-strong, rgb(15 23 42));
|
||||
`;
|
||||
|
||||
const RecommendationAssistCardSummary = styled.div`
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: rgb(100 116 139);
|
||||
color: var(--lime-text-muted, rgb(100 116 139));
|
||||
`;
|
||||
|
||||
const RecommendationAssistFootnote = styled.div`
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: rgb(148 163 184);
|
||||
color: var(--lime-text-muted, rgb(148 163 184));
|
||||
`;
|
||||
|
||||
const RecommendationSupplementalPanel = styled.div`
|
||||
@@ -421,13 +424,13 @@ const RecommendationSupplementalPanel = styled.div`
|
||||
flex-direction: column;
|
||||
gap: 0.56rem;
|
||||
padding-top: 0.2rem;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.88);
|
||||
border-top: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.88));
|
||||
`;
|
||||
|
||||
const RecommendationSupplementalLabel = styled.div`
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: rgb(148 163 184);
|
||||
color: var(--lime-text-muted, rgb(148 163 184));
|
||||
`;
|
||||
|
||||
const RecommendationSupplementalRow = styled.div`
|
||||
@@ -442,22 +445,22 @@ const RecommendationSupplementalLink = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgb(226 232 240);
|
||||
background: rgb(255 255 255);
|
||||
border: 1px solid var(--lime-surface-border, rgb(226 232 240));
|
||||
background: var(--lime-surface, rgb(255 255 255));
|
||||
padding: 0.42rem 0.78rem;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: rgb(71 85 105);
|
||||
color: var(--lime-text, rgb(71 85 105));
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background-color 180ms ease,
|
||||
color 180ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgb(203 213 225);
|
||||
background: rgb(248 250 252);
|
||||
color: rgb(15 23 42);
|
||||
border-color: var(--lime-surface-border-strong, rgb(203 213 225));
|
||||
background: var(--lime-surface-soft, rgb(248 250 252));
|
||||
color: var(--lime-text-strong, rgb(15 23 42));
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -486,17 +489,21 @@ const RecommendationTabButton = styled.button<{ $active: boolean }>`
|
||||
border-radius: 9999px;
|
||||
border: 1px solid
|
||||
${({ $active }) =>
|
||||
$active ? "rgba(134, 239, 172, 0.72)" : "rgba(226, 232, 240, 0.94)"};
|
||||
$active
|
||||
? "var(--lime-surface-border-strong, rgba(134, 239, 172, 0.72))"
|
||||
: "var(--lime-surface-border, rgba(226, 232, 240, 0.94))"};
|
||||
background: ${({ $active }) =>
|
||||
$active
|
||||
? "linear-gradient(180deg, rgba(240, 253, 244, 0.98), rgba(220, 252, 231, 0.92))"
|
||||
: "rgba(255, 255, 255, 0.94)"};
|
||||
? "var(--lime-brand-soft, linear-gradient(180deg, rgba(240, 253, 244, 0.98), rgba(220, 252, 231, 0.92)))"
|
||||
: "var(--lime-surface, rgba(255, 255, 255, 0.94))"};
|
||||
padding: 0.5rem 0.85rem;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: ${({ $active }) =>
|
||||
$active ? "rgb(22 101 52)" : "rgb(71 85 105)"};
|
||||
$active
|
||||
? "var(--lime-brand-strong, rgb(22 101 52))"
|
||||
: "var(--lime-text, rgb(71 85 105))"};
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background-color 180ms ease,
|
||||
@@ -506,13 +513,15 @@ const RecommendationTabButton = styled.button<{ $active: boolean }>`
|
||||
|
||||
&:hover {
|
||||
border-color: ${({ $active }) =>
|
||||
$active ? "rgba(110, 231, 183, 0.84)" : "rgba(203, 213, 225, 0.96)"};
|
||||
color: rgb(15 23 42);
|
||||
$active
|
||||
? "var(--lime-surface-border-strong, rgba(110, 231, 183, 0.84))"
|
||||
: "var(--lime-surface-border, rgba(203, 213, 225, 0.96))"};
|
||||
color: var(--lime-text-strong, rgb(15 23 42));
|
||||
background: ${({ $active }) =>
|
||||
$active
|
||||
? "linear-gradient(180deg, rgba(236, 253, 245, 1), rgba(220, 252, 231, 0.98))"
|
||||
: "rgb(255 255 255)"};
|
||||
box-shadow: 0 14px 28px -28px rgba(15, 23, 42, 0.16);
|
||||
? "var(--lime-brand-soft, linear-gradient(180deg, rgba(236, 253, 245, 1), rgba(220, 252, 231, 0.98)))"
|
||||
: "var(--lime-surface, rgb(255 255 255))"};
|
||||
box-shadow: 0 14px 28px -28px var(--lime-shadow-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
`;
|
||||
@@ -525,22 +534,28 @@ const RecommendationTabCount = styled.span<{ $active: boolean }>`
|
||||
height: 1.35rem;
|
||||
border-radius: 9999px;
|
||||
background: ${({ $active }) =>
|
||||
$active ? "rgba(255, 255, 255, 0.96)" : "rgba(248, 250, 252, 0.98)"};
|
||||
$active
|
||||
? "var(--lime-surface, rgba(255, 255, 255, 0.96))"
|
||||
: "var(--lime-surface-soft, rgba(248, 250, 252, 0.98))"};
|
||||
border: 1px solid
|
||||
${({ $active }) =>
|
||||
$active ? "rgba(134, 239, 172, 0.64)" : "rgba(226, 232, 240, 0.96)"};
|
||||
$active
|
||||
? "var(--lime-surface-border-strong, rgba(134, 239, 172, 0.64))"
|
||||
: "var(--lime-surface-border, rgba(226, 232, 240, 0.96))"};
|
||||
padding: 0 0.35rem;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: ${({ $active }) =>
|
||||
$active ? "rgb(21 128 61)" : "rgb(100 116 139)"};
|
||||
$active
|
||||
? "var(--lime-brand-strong, rgb(21 128 61))"
|
||||
: "var(--lime-text-muted, rgb(100 116 139))"};
|
||||
`;
|
||||
|
||||
const RecommendationTabCaption = styled.div`
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: rgb(100 116 139);
|
||||
color: var(--lime-text-muted, rgb(100 116 139));
|
||||
`;
|
||||
|
||||
const RecommendationPanels = styled.div`
|
||||
@@ -561,7 +576,6 @@ const RecommendationTabPanel = styled.section`
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
interface EmptyStateProps extends SkillSelectionSourceProps {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
@@ -640,10 +654,6 @@ interface EmptyStateProps extends SkillSelectionSourceProps {
|
||||
projectId?: string | null;
|
||||
/** 当前会话 ID */
|
||||
sessionId?: string | null;
|
||||
/** 项目切换 */
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
/** 打开设置 */
|
||||
onOpenSettings?: () => void;
|
||||
/** 当前 runtime tool surface */
|
||||
runtimeToolAvailability?: RuntimeToolAvailability | null;
|
||||
/** 当前执行态摘要 */
|
||||
@@ -864,8 +874,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
onOpenSceneAppsDirectory,
|
||||
projectId = null,
|
||||
sessionId = null,
|
||||
onProjectChange,
|
||||
onOpenSettings,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
creationReplaySurface = null,
|
||||
@@ -941,10 +949,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
appendSelectedTextToRecommendation,
|
||||
setAppendSelectedTextToRecommendation,
|
||||
] = useState(true);
|
||||
const [
|
||||
curatedTaskTemplatesVersion,
|
||||
setCuratedTaskTemplatesVersion,
|
||||
] = useState(0);
|
||||
const [curatedTaskTemplatesVersion, setCuratedTaskTemplatesVersion] =
|
||||
useState(0);
|
||||
const [
|
||||
curatedTaskRecommendationSignalsVersion,
|
||||
setCuratedTaskRecommendationSignalsVersion,
|
||||
@@ -1060,18 +1066,17 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
void curatedTaskTemplatesVersion;
|
||||
void curatedTaskRecommendationSignalsVersion;
|
||||
return listCuratedTaskTemplates();
|
||||
}, [
|
||||
curatedTaskRecommendationSignalsVersion,
|
||||
curatedTaskTemplatesVersion,
|
||||
]);
|
||||
}, [curatedTaskRecommendationSignalsVersion, curatedTaskTemplatesVersion]);
|
||||
|
||||
const latestReviewRecommendationSignal = useMemo(() => {
|
||||
void curatedTaskRecommendationSignalsVersion;
|
||||
return listCuratedTaskRecommendationSignals({
|
||||
projectId,
|
||||
})
|
||||
.filter((signal) => signal.source === "review_feedback")
|
||||
.sort((left, right) => right.createdAt - left.createdAt)[0] ?? null;
|
||||
return (
|
||||
listCuratedTaskRecommendationSignals({
|
||||
projectId,
|
||||
})
|
||||
.filter((signal) => signal.source === "review_feedback")
|
||||
.sort((left, right) => right.createdAt - left.createdAt)[0] ?? null
|
||||
);
|
||||
}, [curatedTaskRecommendationSignalsVersion, projectId]);
|
||||
|
||||
const recentSceneUsageBySceneKey = useMemo(() => {
|
||||
@@ -1236,19 +1241,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
const mergedReferenceMemoryIds =
|
||||
normalizeCuratedTaskReferenceMemoryIds([
|
||||
...(initialReferenceMemoryIds ?? []),
|
||||
...(extractCuratedTaskReferenceMemoryIds(
|
||||
mergedReferenceEntries,
|
||||
) ?? []),
|
||||
...(extractCuratedTaskReferenceMemoryIds(mergedReferenceEntries) ??
|
||||
[]),
|
||||
...effectiveDefaultCuratedTaskReferenceMemoryIds,
|
||||
]) ?? null;
|
||||
setCuratedTaskLauncherTask(template);
|
||||
setCuratedTaskLauncherInitialInputValues(initialInputValues ?? null);
|
||||
setCuratedTaskLauncherInitialReferenceMemoryIds(
|
||||
mergedReferenceMemoryIds,
|
||||
);
|
||||
setCuratedTaskLauncherInitialReferenceEntries(
|
||||
mergedReferenceEntries,
|
||||
);
|
||||
setCuratedTaskLauncherInitialReferenceMemoryIds(mergedReferenceMemoryIds);
|
||||
setCuratedTaskLauncherInitialReferenceEntries(mergedReferenceEntries);
|
||||
setCuratedTaskLauncherPrefillHint(prefillHint ?? null);
|
||||
},
|
||||
[
|
||||
@@ -1324,7 +1324,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
void onLaunchBrowserAssist?.();
|
||||
}
|
||||
|
||||
const resolvedTemplate = findCuratedTaskTemplateById(template.id) ?? template;
|
||||
const resolvedTemplate =
|
||||
findCuratedTaskTemplateById(template.id) ?? template;
|
||||
const launchPrompt = buildCuratedTaskLaunchPrompt({
|
||||
task: resolvedTemplate,
|
||||
inputValues,
|
||||
@@ -1338,7 +1339,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
const promptWithSelection = replaceCuratedTaskLaunchPromptInInput({
|
||||
currentInput: input,
|
||||
previousPrompt:
|
||||
activeCuratedTask?.id === template.id ? activeCuratedTask.prompt : null,
|
||||
activeCuratedTask?.id === template.id
|
||||
? activeCuratedTask.prompt
|
||||
: null,
|
||||
nextPrompt,
|
||||
});
|
||||
setActiveCapability({
|
||||
@@ -1378,13 +1381,15 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
).map((featured) => {
|
||||
const template = featured.template;
|
||||
const launchPrefill = resolveCuratedTaskTemplateLaunchPrefill(template);
|
||||
const reviewPrefillSnapshot = buildSceneAppExecutionReviewPrefillSnapshot({
|
||||
referenceEntries: [
|
||||
...effectiveDefaultCuratedTaskReferenceEntries,
|
||||
...(launchPrefill?.referenceEntries ?? []),
|
||||
],
|
||||
taskId: template.id,
|
||||
});
|
||||
const reviewPrefillSnapshot = buildSceneAppExecutionReviewPrefillSnapshot(
|
||||
{
|
||||
referenceEntries: [
|
||||
...effectiveDefaultCuratedTaskReferenceEntries,
|
||||
...(launchPrefill?.referenceEntries ?? []),
|
||||
],
|
||||
taskId: template.id,
|
||||
},
|
||||
);
|
||||
const metaPrefix = featured.reasonSummary
|
||||
? `${featured.reasonSummary} · `
|
||||
: "";
|
||||
@@ -1499,7 +1504,11 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
recommendationSolutionItems[0] ??
|
||||
recommendationShelfItems[0] ??
|
||||
null,
|
||||
[primaryRecommendationItems, recommendationShelfItems, recommendationSolutionItems],
|
||||
[
|
||||
primaryRecommendationItems,
|
||||
recommendationShelfItems,
|
||||
recommendationSolutionItems,
|
||||
],
|
||||
);
|
||||
|
||||
const alternativeRecommendationItems = useMemo(() => {
|
||||
@@ -1541,7 +1550,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
.join(" "),
|
||||
152,
|
||||
),
|
||||
nextSteps: highlightedRecommendations.map((item) => item.title).join(" / "),
|
||||
nextSteps: highlightedRecommendations
|
||||
.map((item) => item.title)
|
||||
.join(" / "),
|
||||
actionLabel: primarySuggestedRecommendation
|
||||
? `继续去「${primarySuggestedRecommendation.title}」`
|
||||
: null,
|
||||
@@ -1713,10 +1724,15 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
return "methods";
|
||||
}
|
||||
return "recommended";
|
||||
}, [continuationShelfItems.length, directMethodItems.length, leadRecommendationItem]);
|
||||
}, [
|
||||
continuationShelfItems.length,
|
||||
directMethodItems.length,
|
||||
leadRecommendationItem,
|
||||
]);
|
||||
|
||||
const [launchDeckTab, setLaunchDeckTab] =
|
||||
useState<LaunchDeckTab>(preferredLaunchDeckTab);
|
||||
const [launchDeckTab, setLaunchDeckTab] = useState<LaunchDeckTab>(
|
||||
preferredLaunchDeckTab,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const availableTabs = new Set<LaunchDeckTab>();
|
||||
@@ -1956,7 +1972,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 rounded-full border-sky-200 bg-white px-3 text-xs font-medium text-slate-700 hover:border-sky-300 hover:bg-sky-50"
|
||||
className="h-8 rounded-full border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-surface)] px-3 text-xs font-medium text-[color:var(--lime-text)] hover:border-[color:var(--lime-surface-border-strong)] hover:bg-[color:var(--lime-brand-soft)]"
|
||||
data-testid="entry-review-feedback-banner-action"
|
||||
onClick={() => reviewFeedbackBanner.onAction?.()}
|
||||
>
|
||||
@@ -1996,7 +2012,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
onClick={() => setLaunchDeckTab("continuation")}
|
||||
>
|
||||
继续这轮
|
||||
<RecommendationTabCount $active={launchDeckTab === "continuation"}>
|
||||
<RecommendationTabCount
|
||||
$active={launchDeckTab === "continuation"}
|
||||
>
|
||||
{continuationShelfItems.length}
|
||||
</RecommendationTabCount>
|
||||
</RecommendationTabButton>
|
||||
@@ -2019,7 +2037,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
) : null}
|
||||
</RecommendationTabsRail>
|
||||
|
||||
<RecommendationTabCaption>{launchDeckTabCaption}</RecommendationTabCaption>
|
||||
<RecommendationTabCaption>
|
||||
{launchDeckTabCaption}
|
||||
</RecommendationTabCaption>
|
||||
</RecommendationTabsRow>
|
||||
|
||||
<RecommendationPanels>
|
||||
@@ -2028,7 +2048,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
<RecommendationLeadCard
|
||||
type="button"
|
||||
data-testid={leadRecommendationItem.testId}
|
||||
title={[leadRecommendationItem.summary, leadRecommendationItem.meta]
|
||||
title={[
|
||||
leadRecommendationItem.summary,
|
||||
leadRecommendationItem.meta,
|
||||
]
|
||||
.filter((segment) => segment.trim().length > 0)
|
||||
.join(" · ")}
|
||||
onClick={() => {
|
||||
@@ -2056,7 +2079,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
{leadRecommendationItem.contextSummary}
|
||||
</RecommendationLeadMeta>
|
||||
) : null}
|
||||
<RecommendationLeadMeta>{leadRecommendationItem.meta}</RecommendationLeadMeta>
|
||||
<RecommendationLeadMeta>
|
||||
{leadRecommendationItem.meta}
|
||||
</RecommendationLeadMeta>
|
||||
<RecommendationLeadFooter>
|
||||
开始这一轮
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
@@ -2070,7 +2095,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
|
||||
{alternativeRecommendationItems.length > 0 ? (
|
||||
<RecommendationAssistGroup>
|
||||
<RecommendationAssistLabel>其他起手结果</RecommendationAssistLabel>
|
||||
<RecommendationAssistLabel>
|
||||
其他起手结果
|
||||
</RecommendationAssistLabel>
|
||||
<RecommendationAssistList>
|
||||
{alternativeRecommendationItems.map((item) => (
|
||||
<RecommendationAssistCard
|
||||
@@ -2126,7 +2153,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
{item.title}
|
||||
</RecommendationAssistCardTitle>
|
||||
{item.badge ? (
|
||||
<RecommendationShelfBadge>{item.badge}</RecommendationShelfBadge>
|
||||
<RecommendationShelfBadge>
|
||||
{item.badge}
|
||||
</RecommendationShelfBadge>
|
||||
) : null}
|
||||
</RecommendationAssistCardHeader>
|
||||
<RecommendationAssistCardSummary>
|
||||
@@ -2145,7 +2174,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
|
||||
<RecommendationTabPanel hidden={launchDeckTab !== "methods"}>
|
||||
<RecommendationAssistGroup>
|
||||
<RecommendationAssistLabel>也可以直接按做法开工</RecommendationAssistLabel>
|
||||
<RecommendationAssistLabel>
|
||||
也可以直接按做法开工
|
||||
</RecommendationAssistLabel>
|
||||
{directMethodItems.length > 0 ? (
|
||||
<RecommendationAssistList>
|
||||
{directMethodItems.map((item) => (
|
||||
@@ -2163,7 +2194,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
{item.title}
|
||||
</RecommendationAssistCardTitle>
|
||||
{item.badge ? (
|
||||
<RecommendationShelfBadge>{item.badge}</RecommendationShelfBadge>
|
||||
<RecommendationShelfBadge>
|
||||
{item.badge}
|
||||
</RecommendationShelfBadge>
|
||||
) : null}
|
||||
</RecommendationAssistCardHeader>
|
||||
<RecommendationAssistCardSummary>
|
||||
@@ -2280,44 +2313,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
</RecommendationSupplementalPanel>
|
||||
) : null;
|
||||
|
||||
const headerControls = onProjectChange ? (
|
||||
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
|
||||
<div className="inline-flex max-w-full items-center rounded-[28px] border border-slate-200/90 bg-white/96 p-1.5 shadow-[0_18px_40px_-30px_rgba(15,23,42,0.25)]">
|
||||
<ProjectSelector
|
||||
value={projectId ?? null}
|
||||
onChange={onProjectChange}
|
||||
workspaceType={activeTheme}
|
||||
placeholder="选择项目"
|
||||
dropdownSide="bottom"
|
||||
dropdownAlign="end"
|
||||
enableManagement={activeTheme === "general"}
|
||||
density="compact"
|
||||
chrome="embedded"
|
||||
className="min-w-[210px] max-w-[320px]"
|
||||
/>
|
||||
{onOpenSettings ? (
|
||||
<>
|
||||
<div
|
||||
className="mx-1.5 h-7 w-px shrink-0 bg-slate-200/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-[20px] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={onOpenSettings}
|
||||
aria-label="打开设置"
|
||||
title="打开设置"
|
||||
>
|
||||
<Settings2 size={18} />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<ContentWrapper>
|
||||
@@ -2339,7 +2334,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
defaultQuickActionsPanel
|
||||
)
|
||||
}
|
||||
headerControls={headerControls}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
<CuratedTaskLauncherDialog
|
||||
|
||||
@@ -284,12 +284,12 @@ describe("EmptyStateComposerPanel", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="empty-state-runtime-tool-warning"]'),
|
||||
container.querySelector(
|
||||
'[data-testid="empty-state-runtime-tool-warning"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(container.textContent).not.toContain("当前 runtime tool surface");
|
||||
expect(container.textContent).not.toContain(
|
||||
"联网搜索偏好本轮可能不会生效",
|
||||
);
|
||||
expect(container.textContent).not.toContain("联网搜索偏好本轮可能不会生效");
|
||||
expect(container.textContent).not.toContain(
|
||||
"任务拆分偏好本轮可能不会完全生效",
|
||||
);
|
||||
|
||||
@@ -75,9 +75,7 @@ interface EmptyStateComposerPanelProps {
|
||||
onSelectInputCapability?: SelectInputCapabilityHandler;
|
||||
onClearInputCapability?: () => void;
|
||||
onEditCuratedTask?: () => void;
|
||||
onApplyCuratedTaskReviewSuggestion?: (
|
||||
task: CuratedTaskTemplateItem,
|
||||
) => void;
|
||||
onApplyCuratedTaskReviewSuggestion?: (task: CuratedTaskTemplateItem) => void;
|
||||
creationReplaySurface?: CreationReplaySurfaceModel | null;
|
||||
projectId?: string | null;
|
||||
sessionId?: string | null;
|
||||
@@ -178,7 +176,7 @@ export function EmptyStateComposerPanel({
|
||||
const activeSkill =
|
||||
activeCapability?.kind === "installed_skill"
|
||||
? activeCapability.skill
|
||||
: skillSelection.activeSkill ?? null;
|
||||
: (skillSelection.activeSkill ?? null);
|
||||
const clearActiveSkill = skillSelection.onClearSkill;
|
||||
const { mentionProps: mentionSkillProps, selectorProps: skillSelectorProps } =
|
||||
buildSkillSelectionBindings(skillSelection);
|
||||
@@ -287,7 +285,7 @@ export function EmptyStateComposerPanel({
|
||||
className={`${EMPTY_STATE_PASSIVE_BADGE_CLASSNAME} max-w-[320px] justify-start gap-1.5`}
|
||||
title={`${creationReplaySurface.eyebrow} · ${creationReplaySurface.title} · ${creationReplaySurface.summary}`}
|
||||
>
|
||||
<span className="shrink-0 text-emerald-700">
|
||||
<span className="shrink-0 text-[color:var(--lime-brand-strong)]">
|
||||
{creationReplaySurface.badgeLabel}
|
||||
</span>
|
||||
<span className="truncate">{creationReplaySurface.title}</span>
|
||||
|
||||
@@ -139,7 +139,7 @@ const EyebrowText = styled.div`
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgb(5 150 105 / 0.82);
|
||||
color: rgb(47 111 70 / 0.86);
|
||||
`;
|
||||
|
||||
const LeadBody = styled.div`
|
||||
@@ -169,8 +169,13 @@ const SloganGlow = styled.div`
|
||||
inset: 52% -6% -4% -6%;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle, rgba(132, 204, 22, 0.18) 0%, rgba(34, 197, 94, 0.1) 22%, transparent 58%),
|
||||
linear-gradient(90deg, rgba(16, 185, 129, 0) 0%, rgba(16, 185, 129, 0.12) 50%, rgba(16, 185, 129, 0) 100%);
|
||||
radial-gradient(
|
||||
circle,
|
||||
var(--lime-home-glow-primary, rgba(132, 204, 22, 0.08)) 0%,
|
||||
var(--lime-home-glow-secondary, rgba(186, 230, 253, 0.16)) 22%,
|
||||
transparent 58%
|
||||
),
|
||||
var(--lime-home-beam-gradient);
|
||||
filter: blur(14px);
|
||||
opacity: 0.82;
|
||||
animation: ${sloganHaloPulse} 3.8s ease-in-out infinite;
|
||||
@@ -181,14 +186,7 @@ const SloganBeam = styled.div`
|
||||
position: absolute;
|
||||
inset: 8% 0 2%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(16, 185, 129, 0) 0%,
|
||||
rgba(16, 185, 129, 0.05) 40%,
|
||||
rgba(255, 255, 255, 0.42) 50%,
|
||||
rgba(16, 185, 129, 0.05) 60%,
|
||||
rgba(16, 185, 129, 0) 100%
|
||||
);
|
||||
background: var(--lime-home-beam-gradient);
|
||||
mix-blend-mode: screen;
|
||||
filter: blur(2px);
|
||||
animation: ${sloganSweep} 2.9s linear infinite;
|
||||
@@ -204,14 +202,7 @@ const SloganLine = styled.p`
|
||||
gap: 1.05rem;
|
||||
isolation: isolate;
|
||||
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(160 96% 28%) 0%,
|
||||
hsl(142 68% 27%) 22%,
|
||||
hsl(86 58% 58%) 50%,
|
||||
hsl(142 68% 27%) 78%,
|
||||
hsl(160 96% 28%) 100%
|
||||
);
|
||||
background: var(--lime-home-title-gradient);
|
||||
background-size: 220% auto;
|
||||
color: transparent;
|
||||
-webkit-background-clip: text;
|
||||
@@ -222,10 +213,7 @@ const SloganLine = styled.p`
|
||||
line-height: 0.92;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.05em;
|
||||
text-shadow:
|
||||
0 0 8px rgba(132, 204, 22, 0.16),
|
||||
0 12px 26px rgba(101, 163, 13, 0.12),
|
||||
0 0 24px rgba(250, 204, 21, 0.08);
|
||||
text-shadow: var(--lime-home-title-shadow);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
@@ -233,14 +221,8 @@ const SloganLine = styled.p`
|
||||
height: 1.05rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(132, 204, 22, 0.94),
|
||||
rgba(16, 185, 129, 0.8)
|
||||
);
|
||||
box-shadow:
|
||||
0 0 0 12px rgba(132, 204, 22, 0.1),
|
||||
0 0 20px rgba(132, 204, 22, 0.26);
|
||||
background: var(--lime-home-dot-gradient);
|
||||
box-shadow: var(--lime-home-dot-shadow);
|
||||
}
|
||||
|
||||
&::after {
|
||||
@@ -249,14 +231,7 @@ const SloganLine = styled.p`
|
||||
inset: auto 10% -10% 10%;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(132, 204, 22, 0) 0%,
|
||||
rgba(132, 204, 22, 0.12) 22%,
|
||||
rgba(255, 255, 255, 0.34) 50%,
|
||||
rgba(250, 204, 21, 0.16) 78%,
|
||||
rgba(132, 204, 22, 0) 100%
|
||||
);
|
||||
background: var(--lime-home-beam-gradient);
|
||||
filter: blur(8px);
|
||||
opacity: 0.62;
|
||||
z-index: -1;
|
||||
@@ -269,7 +244,7 @@ const LeadDescriptionText = styled.p`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.72;
|
||||
color: rgb(51 65 85);
|
||||
color: var(--lime-text, #1a3b2b);
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 17px;
|
||||
@@ -281,7 +256,7 @@ const LeadSupportingText = styled.p`
|
||||
max-width: 48rem;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: rgb(100 116 139);
|
||||
color: var(--lime-text-muted, #6b826b);
|
||||
`;
|
||||
|
||||
const PriorityShell = styled.div<{ $delay: number }>`
|
||||
@@ -341,8 +316,8 @@ const HeroCard = styled.article.attrs({
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(203, 213, 225, 0.96);
|
||||
box-shadow: 0 14px 28px -24px rgba(15, 23, 42, 0.14);
|
||||
border-color: var(--lime-home-card-hover-border, #93c5fd);
|
||||
box-shadow: 0 14px 28px -24px var(--lime-shadow-color);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -388,19 +363,19 @@ const CardValueText = styled.div`
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: rgb(100 116 139);
|
||||
color: var(--lime-text-muted, #6b826b);
|
||||
`;
|
||||
|
||||
const CardEyebrow = styled.span`
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.88);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.88));
|
||||
background: var(--lime-surface, rgba(255, 255, 255, 0.92));
|
||||
padding: 0.18rem 0.5rem;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgb(148 163 184);
|
||||
color: var(--lime-text-muted, #6b826b);
|
||||
`;
|
||||
|
||||
export interface EmptyStateHeroCard {
|
||||
@@ -463,13 +438,18 @@ export function EmptyStateHero({
|
||||
</SloganWrap>
|
||||
) : null}
|
||||
{title ? (
|
||||
<h1 className="max-w-[18ch] text-[28px] font-semibold leading-[1.04] tracking-tight text-slate-900 drop-shadow-[0_1px_0_rgba(255,255,255,0.9),0_12px_26px_rgba(132,204,22,0.16)] md:text-[38px]">
|
||||
<h1
|
||||
className="max-w-[18ch] text-[28px] font-semibold leading-[1.04] tracking-tight text-[color:var(--lime-text-strong)] md:text-[38px]"
|
||||
style={{ textShadow: "var(--lime-home-title-shadow)" }}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
) : null}
|
||||
<LeadDescriptionText>{description}</LeadDescriptionText>
|
||||
{supportingDescription ? (
|
||||
<LeadSupportingText>{supportingDescription}</LeadSupportingText>
|
||||
<LeadSupportingText>
|
||||
{supportingDescription}
|
||||
</LeadSupportingText>
|
||||
) : null}
|
||||
</LeadTextGroup>
|
||||
</LeadBody>
|
||||
@@ -506,7 +486,7 @@ export function EmptyStateHero({
|
||||
|
||||
<div className="card-content mt-2.5">
|
||||
<CardTitleRow>
|
||||
<div className="card-title text-sm font-semibold text-slate-900">
|
||||
<div className="card-title text-sm font-semibold text-[color:var(--lime-text-strong)]">
|
||||
{card.title}
|
||||
</div>
|
||||
<WorkbenchInfoTip
|
||||
@@ -535,7 +515,7 @@ export function EmptyStateHero({
|
||||
onClick={card.onMediaAction}
|
||||
disabled={card.mediaActionDisabled}
|
||||
aria-label={card.mediaActionLabel || card.title}
|
||||
className="card-preview block overflow-hidden rounded-[16px] border border-slate-200/70 bg-slate-50/88 text-left transition hover:border-slate-300/90 disabled:cursor-default disabled:opacity-70"
|
||||
className="card-preview block overflow-hidden rounded-[16px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)] text-left transition hover:border-[color:var(--lime-surface-border-strong)] disabled:cursor-default disabled:opacity-70"
|
||||
>
|
||||
<img
|
||||
src={card.imageSrc}
|
||||
@@ -544,7 +524,7 @@ export function EmptyStateHero({
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="card-preview overflow-hidden rounded-[16px] border border-slate-200/70 bg-slate-50/88">
|
||||
<div className="card-preview overflow-hidden rounded-[16px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)]">
|
||||
<img
|
||||
src={card.imageSrc}
|
||||
alt={card.imageAlt || card.title}
|
||||
|
||||
@@ -42,11 +42,7 @@ const QuickActionsPanel = styled.section<{ $embedded: boolean }>`
|
||||
inset: 0 auto auto 1.1rem;
|
||||
width: 8rem;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(16, 185, 129, 0.55) 0%,
|
||||
rgba(56, 189, 248, 0.24) 100%
|
||||
);
|
||||
background: var(--lime-home-dot-gradient);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@@ -88,7 +84,7 @@ const RecommendationCard = styled.div.attrs({
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(203, 213, 225, 0.98);
|
||||
border-color: var(--lime-home-card-hover-border, #93c5fd);
|
||||
box-shadow: 0 16px 28px -22px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
@@ -198,10 +194,13 @@ interface EmptyStateQuickActionsProps {
|
||||
}
|
||||
|
||||
const STATUS_TONE_CLASSNAMES = {
|
||||
slate: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
sky: "border-sky-200 bg-sky-50 text-sky-700",
|
||||
emerald: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
amber: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
slate:
|
||||
"border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)] text-[color:var(--lime-text-muted)]",
|
||||
sky: "border-[color:var(--lime-info-border)] bg-[color:var(--lime-info-soft)] text-[color:var(--lime-info)]",
|
||||
emerald:
|
||||
"border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-brand-soft)] text-[color:var(--lime-brand-strong)]",
|
||||
amber:
|
||||
"border-[color:var(--lime-warning-border)] bg-[color:var(--lime-warning-soft)] text-[color:var(--lime-warning)]",
|
||||
} as const;
|
||||
|
||||
export function EmptyStateQuickActions({
|
||||
@@ -234,8 +233,10 @@ export function EmptyStateQuickActions({
|
||||
>
|
||||
<div className="flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">{title}</div>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500 md:text-sm">
|
||||
<div className="text-sm font-semibold text-[color:var(--lime-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-[color:var(--lime-text-muted)] md:text-sm">
|
||||
{description}
|
||||
</p>
|
||||
{headerAddon ? <div className="mt-2.5">{headerAddon}</div> : null}
|
||||
@@ -263,20 +264,20 @@ export function EmptyStateQuickActions({
|
||||
) : null}
|
||||
|
||||
{selectedTextPreview ? (
|
||||
<div className="mt-2.5 rounded-2xl border border-sky-200/70 bg-sky-50/85 px-3.5 py-2.5 text-xs leading-5 text-slate-600">
|
||||
<div className="mt-2.5 rounded-2xl border border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-brand-soft)] px-3.5 py-2.5 text-xs leading-5 text-[color:var(--lime-text-muted)]">
|
||||
已检测到当前选中内容,点击推荐动作时会自动携带上下文:
|
||||
<span className="ml-1 font-medium text-slate-900">
|
||||
<span className="ml-1 font-medium text-[color:var(--lime-text-strong)]">
|
||||
“{selectedTextPreview}”
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="mt-3 rounded-2xl border border-slate-200/80 bg-white/80 px-4 py-3 text-xs leading-5 text-slate-500">
|
||||
<div className="mt-3 rounded-2xl border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] px-4 py-3 text-xs leading-5 text-[color:var(--lime-text-muted)]">
|
||||
正在加载推荐方案…
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="mt-3 rounded-2xl border border-slate-200/80 bg-white/80 px-4 py-3 text-xs leading-5 text-slate-500">
|
||||
<div className="mt-3 rounded-2xl border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] px-4 py-3 text-xs leading-5 text-[color:var(--lime-text-muted)]">
|
||||
当前目录暂无可用项。
|
||||
</div>
|
||||
) : (
|
||||
@@ -301,19 +302,19 @@ export function EmptyStateQuickActions({
|
||||
<span className={EMPTY_STATE_META_PILL_CLASSNAME}>
|
||||
{item.badge}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-slate-400 transition-colors group-hover:text-slate-500">
|
||||
<span className="text-[11px] font-medium text-[color:var(--lime-text-muted)] transition-colors group-hover:text-[color:var(--lime-text)]">
|
||||
{item.actionLabel || "立即开始"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
<div className="text-sm font-semibold text-[color:var(--lime-text-strong)]">
|
||||
{item.title}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-[color:var(--lime-text-muted)]">
|
||||
{item.description}
|
||||
</p>
|
||||
{item.outputHint ? (
|
||||
<p className="mt-2 text-[11px] leading-5 text-slate-400">
|
||||
<p className="mt-2 text-[11px] leading-5 text-[color:var(--lime-text-muted)]">
|
||||
产出:{item.outputHint}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -348,7 +349,7 @@ export function EmptyStateQuickActions({
|
||||
</span>
|
||||
)}
|
||||
{item.secondaryStatusDescription ? (
|
||||
<span className="text-[11px] leading-5 text-slate-400">
|
||||
<span className="text-[11px] leading-5 text-[color:var(--lime-text-muted)]">
|
||||
{item.secondaryStatusDescription}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -26,10 +26,7 @@ export function EmptyStateSceneAppsPanel({
|
||||
正在整理可直接续上的整套做法…
|
||||
</span>
|
||||
) : hasLaunchableEntries ? (
|
||||
<div
|
||||
data-testid="sceneapps-home-directory"
|
||||
className="contents"
|
||||
>
|
||||
<div data-testid="sceneapps-home-directory" className="contents">
|
||||
{items.map((item) => {
|
||||
const isLaunching = launchingSceneAppId === item.id;
|
||||
const disabled =
|
||||
@@ -40,7 +37,7 @@ export function EmptyStateSceneAppsPanel({
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-testid={`sceneapp-launch-${item.id}`}
|
||||
className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-medium text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="inline-flex items-center rounded-full border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] px-3 py-1.5 text-[11px] font-medium text-[color:var(--lime-text-muted)] transition-colors hover:border-[color:var(--lime-surface-border-strong)] hover:bg-[color:var(--lime-surface-soft)] hover:text-[color:var(--lime-text-strong)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title={`${item.businessLabel} · ${item.summary}`}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
|
||||
@@ -204,7 +204,9 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
}
|
||||
|
||||
expect(container.textContent).toContain("生成侧栏");
|
||||
expect(container.textContent).toContain("聚焦当前结果、下一步与可继续的版本。");
|
||||
expect(container.textContent).toContain(
|
||||
"聚焦当前结果、下一步与可继续的版本。",
|
||||
);
|
||||
expect(container.textContent).toContain("当前进展");
|
||||
expect(container.textContent).toContain("当前焦点");
|
||||
expect(container.textContent).toContain("撰写主稿");
|
||||
@@ -245,9 +247,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
? taskSection.compareDocumentPosition(branchSection) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
: 0;
|
||||
expect(
|
||||
taskSectionOrder,
|
||||
).toBeTruthy();
|
||||
expect(taskSectionOrder).toBeTruthy();
|
||||
});
|
||||
|
||||
it("传入折叠回调时应显示折叠按钮并可触发", () => {
|
||||
@@ -781,7 +781,9 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
}
|
||||
|
||||
expect(container.querySelector("button[aria-label='切换可继续记录']")).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector("button[aria-label='切换可继续记录']"),
|
||||
).toBeTruthy();
|
||||
expect(container.querySelector("button[aria-label='删除分支']")).toBeNull();
|
||||
expect(container.textContent).toContain("当前焦点落在");
|
||||
|
||||
@@ -859,7 +861,9 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
}
|
||||
|
||||
expect(container.textContent).toContain("最近一组:content_post_with_cover");
|
||||
expect(container.textContent).toContain(
|
||||
"最近一组:content_post_with_cover",
|
||||
);
|
||||
|
||||
const activityToggle = container.querySelector(
|
||||
"button[aria-label='切换执行经过']",
|
||||
@@ -1125,9 +1129,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
expect(baselineCard?.textContent).toContain("当前结果基线");
|
||||
expect(baselineCard?.textContent).toContain("AI 内容周报");
|
||||
expect(baselineCard?.textContent).toContain("当前判断:适合继续放量");
|
||||
expect(baselineCard?.textContent).toContain(
|
||||
"经营动作:保留品牌联名方向",
|
||||
);
|
||||
expect(baselineCard?.textContent).toContain("经营动作:保留品牌联名方向");
|
||||
expect(baselineCard?.textContent).toContain(
|
||||
"更适合去向:内容主稿生成 / 渠道改写",
|
||||
);
|
||||
@@ -1192,9 +1194,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
expect(baselineCard?.textContent).toContain("当前结果基线");
|
||||
expect(baselineCard?.textContent).toContain("AI 内容周报");
|
||||
expect(baselineCard?.textContent).toContain("当前判断:适合继续放量");
|
||||
expect(baselineCard?.textContent).toContain(
|
||||
"经营动作:保留品牌联名方向",
|
||||
);
|
||||
expect(baselineCard?.textContent).toContain("经营动作:保留品牌联名方向");
|
||||
});
|
||||
|
||||
it("命中最近判断偏好模板时,应在当前进展里显影判断建议", () => {
|
||||
@@ -1202,7 +1202,8 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
{
|
||||
session_id: "session-review-workflow",
|
||||
decision_status: "needs_more_evidence",
|
||||
decision_summary: "这轮结果还缺证据,需要回到账号复盘和高表现样本继续补证据。",
|
||||
decision_summary:
|
||||
"这轮结果还缺证据,需要回到账号复盘和高表现样本继续补证据。",
|
||||
chosen_fix_strategy: "先补账号数据复盘,再拆一轮高表现内容做对照。",
|
||||
risk_level: "medium",
|
||||
risk_tags: ["证据不足", "需要复盘"],
|
||||
@@ -1258,7 +1259,9 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
"最近判断已更新:短视频编排 · 补证据",
|
||||
);
|
||||
expect(reviewBanner?.textContent).toContain("这轮结果还缺证据");
|
||||
expect(reviewBanner?.textContent).toContain("围绕「复盘这个账号/项目」继续推进");
|
||||
expect(reviewBanner?.textContent).toContain(
|
||||
"围绕「复盘这个账号/项目」继续推进",
|
||||
);
|
||||
});
|
||||
|
||||
it("复盘结果带着 sceneapp 基线时,应在当前查看运行里继续显影当前结果基线", () => {
|
||||
@@ -1329,9 +1332,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
expect(baselineCard?.textContent).toContain("当前结果基线");
|
||||
expect(baselineCard?.textContent).toContain("AI 内容周报");
|
||||
expect(baselineCard?.textContent).toContain("当前判断:适合继续放量");
|
||||
expect(baselineCard?.textContent).toContain(
|
||||
"经营动作:保留品牌联名方向",
|
||||
);
|
||||
expect(baselineCard?.textContent).toContain("经营动作:保留品牌联名方向");
|
||||
expect(baselineCard?.textContent).toContain(
|
||||
"更适合去向:内容主稿生成 / 渠道改写",
|
||||
);
|
||||
@@ -1342,7 +1343,8 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
{
|
||||
session_id: "session-review-run-detail",
|
||||
decision_status: "needs_more_evidence",
|
||||
decision_summary: "这轮结果还缺证据,需要回到账号复盘和高表现样本继续补证据。",
|
||||
decision_summary:
|
||||
"这轮结果还缺证据,需要回到账号复盘和高表现样本继续补证据。",
|
||||
chosen_fix_strategy: "先补账号数据复盘,再拆一轮高表现内容做对照。",
|
||||
risk_level: "medium",
|
||||
risk_tags: ["证据不足", "需要复盘"],
|
||||
@@ -1407,7 +1409,9 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
"最近判断已更新:短视频编排 · 补证据",
|
||||
);
|
||||
expect(reviewBanner?.textContent).toContain("这轮结果还缺证据");
|
||||
expect(reviewBanner?.textContent).toContain("围绕「复盘这个账号/项目」继续推进");
|
||||
expect(reviewBanner?.textContent).toContain(
|
||||
"围绕「复盘这个账号/项目」继续推进",
|
||||
);
|
||||
});
|
||||
|
||||
it("点击当前进展里的复盘建议时,应把结果基线 continuation 回传给工作区", () => {
|
||||
@@ -1671,35 +1675,33 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
}
|
||||
|
||||
expect(onApplyFollowUpAction).toHaveBeenCalledWith(
|
||||
{
|
||||
expect(onApplyFollowUpAction).toHaveBeenCalledWith({
|
||||
prompt: "请基于「每日趋势摘要」这轮结果继续:继续展开其中一个选题",
|
||||
bannerMessage:
|
||||
"已按“继续展开其中一个选题”接着推进「每日趋势摘要」,可继续改写后发送。",
|
||||
capabilityRoute: {
|
||||
kind: "curated_task",
|
||||
taskId: "daily-trend-briefing",
|
||||
taskTitle: "每日趋势摘要",
|
||||
prompt: "请基于「每日趋势摘要」这轮结果继续:继续展开其中一个选题",
|
||||
bannerMessage:
|
||||
"已按“继续展开其中一个选题”接着推进「每日趋势摘要」,可继续改写后发送。",
|
||||
capabilityRoute: {
|
||||
kind: "curated_task",
|
||||
taskId: "daily-trend-briefing",
|
||||
taskTitle: "每日趋势摘要",
|
||||
prompt: "请基于「每日趋势摘要」这轮结果继续:继续展开其中一个选题",
|
||||
launchInputValues: {
|
||||
theme_target: "AI 内容创作",
|
||||
platform_region: "X 与 TikTok 北美区",
|
||||
},
|
||||
referenceMemoryIds: ["memory-1"],
|
||||
referenceEntries: [
|
||||
{
|
||||
id: "memory-1",
|
||||
sourceKind: "memory",
|
||||
title: "品牌风格样本",
|
||||
summary: "保留轻盈但专业的表达。",
|
||||
category: "context",
|
||||
categoryLabel: "参考",
|
||||
tags: ["品牌", "语气"],
|
||||
},
|
||||
],
|
||||
launchInputValues: {
|
||||
theme_target: "AI 内容创作",
|
||||
platform_region: "X 与 TikTok 北美区",
|
||||
},
|
||||
referenceMemoryIds: ["memory-1"],
|
||||
referenceEntries: [
|
||||
{
|
||||
id: "memory-1",
|
||||
sourceKind: "memory",
|
||||
title: "品牌风格样本",
|
||||
summary: "保留轻盈但专业的表达。",
|
||||
category: "context",
|
||||
categoryLabel: "参考",
|
||||
tags: ["品牌", "语气"],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("复盘结果点击建议下一步时,应切到下游结果模板而不是继续停在复盘模板", () => {
|
||||
@@ -1819,7 +1821,8 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
tags: ["复盘", "增长"],
|
||||
task_prefill_by_task_id: {
|
||||
"social-post-starter": {
|
||||
subject_or_product: "基于本周账号复盘,整理下一轮内容方向与重点动作。",
|
||||
subject_or_product:
|
||||
"基于本周账号复盘,整理下一轮内容方向与重点动作。",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1986,9 +1989,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
expect(container.textContent).toContain(
|
||||
"工作流 social_content_pipeline_v1",
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"选题闸门 → 写作闸门 → 发布闸门",
|
||||
);
|
||||
expect(container.textContent).toContain("选题闸门 → 写作闸门 → 发布闸门");
|
||||
expect(container.textContent).toContain("content-posts/demo.md");
|
||||
expect(container.textContent).toContain(
|
||||
"content-posts/demo.publish-pack.json",
|
||||
|
||||
@@ -23,8 +23,7 @@ const SIDEBAR_CONTAINER_CLASSNAME =
|
||||
const SIDEBAR_COLLAPSE_HANDLE_CLASSNAME =
|
||||
"absolute right-[-10px] top-1/2 z-[2] inline-flex h-[60px] w-4 -translate-y-1/2 items-center justify-center rounded-r-[10px] border border-l-0 border-slate-200 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900";
|
||||
|
||||
const SIDEBAR_HEADER_CLASSNAME =
|
||||
"border-b border-slate-200 bg-white px-4 py-4";
|
||||
const SIDEBAR_HEADER_CLASSNAME = "border-b border-slate-200 bg-white px-4 py-4";
|
||||
|
||||
const SIDEBAR_HEADER_META_ROW_CLASSNAME =
|
||||
"flex items-center justify-between gap-2.5";
|
||||
@@ -32,8 +31,7 @@ const SIDEBAR_HEADER_META_ROW_CLASSNAME =
|
||||
const SIDEBAR_HEADER_ACTION_SLOT_CLASSNAME =
|
||||
"mt-[-2px] inline-flex shrink-0 items-center justify-center";
|
||||
|
||||
const SIDEBAR_EYEBROW_CLASSNAME =
|
||||
"text-[10px] font-semibold text-slate-500";
|
||||
const SIDEBAR_EYEBROW_CLASSNAME = "text-[10px] font-semibold text-slate-500";
|
||||
|
||||
const SIDEBAR_TITLE_CLASSNAME =
|
||||
"mt-2.5 text-base font-semibold leading-6 text-slate-900";
|
||||
|
||||
@@ -232,10 +232,8 @@ function StatusBadge({
|
||||
"inline-flex rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
|
||||
$status === "merged" &&
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
$status === "in_progress" &&
|
||||
"border-sky-200 bg-sky-50 text-sky-700",
|
||||
$status === "pending" &&
|
||||
"border-amber-200 bg-amber-50 text-amber-700",
|
||||
$status === "in_progress" && "border-sky-200 bg-sky-50 text-sky-700",
|
||||
$status === "pending" && "border-amber-200 bg-amber-50 text-amber-700",
|
||||
$status !== "merged" &&
|
||||
$status !== "in_progress" &&
|
||||
$status !== "pending" &&
|
||||
@@ -325,7 +323,9 @@ const ActivityLogTitle = createDiv(
|
||||
"truncate text-[12px] font-medium leading-5 text-slate-900",
|
||||
);
|
||||
|
||||
const ActivityLogMetaRow = createDiv("mt-1 flex flex-wrap items-center gap-1.5");
|
||||
const ActivityLogMetaRow = createDiv(
|
||||
"mt-1 flex flex-wrap items-center gap-1.5",
|
||||
);
|
||||
|
||||
const ActivityLogBadge = createDiv(
|
||||
"inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium text-slate-500",
|
||||
@@ -419,7 +419,8 @@ function getStepIcon(status: StepStatus) {
|
||||
function getStatusBadgeClassName(status: StepStatus) {
|
||||
return cn(
|
||||
"inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold",
|
||||
status === "completed" && "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
status === "completed" &&
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
status === "error" && "border-rose-200 bg-rose-50 text-rose-700",
|
||||
status === "active" && "border-sky-200 bg-sky-50 text-sky-700",
|
||||
status === "pending" && "border-amber-200 bg-amber-50 text-amber-700",
|
||||
@@ -844,7 +845,8 @@ function buildCuratedTaskFollowUpActionPayload(params: {
|
||||
}
|
||||
|
||||
const capabilityRoute =
|
||||
(targetTask?.id && targetTask.title.trim()) || (currentTaskId && currentTaskTitle)
|
||||
(targetTask?.id && targetTask.title.trim()) ||
|
||||
(currentTaskId && currentTaskTitle)
|
||||
? {
|
||||
kind: "curated_task" as const,
|
||||
taskId: targetTask?.id ?? currentTaskId!,
|
||||
@@ -952,7 +954,8 @@ function getActivityStatusBadgeClassName(
|
||||
"inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium",
|
||||
status === "running" && "border-sky-200 bg-sky-50 text-sky-700",
|
||||
status === "failed" && "border-rose-200 bg-rose-50 text-rose-700",
|
||||
status === "completed" && "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
status === "completed" &&
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1103,8 +1106,12 @@ function renderActivityLogItem(
|
||||
<ActivityLogTitleBlock>
|
||||
<ActivityLogTitle>{primaryLog?.name || "执行经过"}</ActivityLogTitle>
|
||||
<ActivityLogMetaRow>
|
||||
{sourceLabel ? <ActivityLogBadge>{sourceLabel}</ActivityLogBadge> : null}
|
||||
{gateLabel ? <ActivityLogBadge>{gateLabel}</ActivityLogBadge> : null}
|
||||
{sourceLabel ? (
|
||||
<ActivityLogBadge>{sourceLabel}</ActivityLogBadge>
|
||||
) : null}
|
||||
{gateLabel ? (
|
||||
<ActivityLogBadge>{gateLabel}</ActivityLogBadge>
|
||||
) : null}
|
||||
{group.logs.length > 1 ? (
|
||||
<ActivityLogBadge>{`${group.logs.length} 步`}</ActivityLogBadge>
|
||||
) : null}
|
||||
@@ -1316,7 +1323,8 @@ function GeneralWorkbenchWorkflowPanelComponent({
|
||||
const workflowSummaryText = buildWorkflowSummaryText({
|
||||
leadingStep: currentWorkflowStep,
|
||||
remainingCount: remainingSteps,
|
||||
emptyLabel: workflowSteps.length > 0 ? "当前流程已完成" : "等待创建第一条任务",
|
||||
emptyLabel:
|
||||
workflowSteps.length > 0 ? "当前流程已完成" : "等待创建第一条任务",
|
||||
});
|
||||
const workflowProgressLabel = formatWorkflowProgressLabel({
|
||||
completedCount: completedSteps,
|
||||
@@ -1325,9 +1333,12 @@ function GeneralWorkbenchWorkflowPanelComponent({
|
||||
const branchSectionTitle = getBranchSectionTitle(isVersionMode);
|
||||
const branchCreateLabel = getBranchCreateLabel(isVersionMode);
|
||||
const branchPrimaryActionLabel = getBranchPrimaryActionLabel(isVersionMode);
|
||||
const branchSecondaryActionLabel = getBranchSecondaryActionLabel(isVersionMode);
|
||||
const branchSecondaryActionLabel =
|
||||
getBranchSecondaryActionLabel(isVersionMode);
|
||||
const currentBranchItem =
|
||||
sortedBranchItems.find((item) => item.isCurrent) ?? sortedBranchItems[0] ?? null;
|
||||
sortedBranchItems.find((item) => item.isCurrent) ??
|
||||
sortedBranchItems[0] ??
|
||||
null;
|
||||
const secondaryBranchCount = Math.max(
|
||||
sortedBranchItems.length - (currentBranchItem ? 1 : 0),
|
||||
0,
|
||||
@@ -1404,8 +1415,8 @@ function GeneralWorkbenchWorkflowPanelComponent({
|
||||
);
|
||||
const shouldShowFollowUpHint = Boolean(
|
||||
reviewFeedbackProjection ||
|
||||
curatedTaskFollowUpHintText ||
|
||||
sceneAppReviewBaselineSnapshot,
|
||||
curatedTaskFollowUpHintText ||
|
||||
sceneAppReviewBaselineSnapshot,
|
||||
);
|
||||
const activitySectionSummary = buildActivitySectionSummary({
|
||||
groups: groupedActivityLogs,
|
||||
@@ -1663,9 +1674,7 @@ function GeneralWorkbenchWorkflowPanelComponent({
|
||||
<ActivityMeta>{getEmptyBranchText(isVersionMode)}</ActivityMeta>
|
||||
) : (
|
||||
<>
|
||||
<BranchSectionSummary
|
||||
data-testid="workflow-sidebar-branch-summary"
|
||||
>
|
||||
<BranchSectionSummary data-testid="workflow-sidebar-branch-summary">
|
||||
{branchSectionSummaryText}
|
||||
</BranchSectionSummary>
|
||||
{showBranchRecords ? (
|
||||
@@ -1682,7 +1691,9 @@ function GeneralWorkbenchWorkflowPanelComponent({
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<BranchTitleButton onClick={() => onSwitchTopic(item.id)}>
|
||||
<BranchTitleButton
|
||||
onClick={() => onSwitchTopic(item.id)}
|
||||
>
|
||||
{item.title}
|
||||
</BranchTitleButton>
|
||||
<StatusBadge $status={item.status}>
|
||||
@@ -1699,22 +1710,30 @@ function GeneralWorkbenchWorkflowPanelComponent({
|
||||
</DeleteButton>
|
||||
) : null}
|
||||
</div>
|
||||
<BranchMeta>{getBranchMetaText(item, isVersionMode)}</BranchMeta>
|
||||
<BranchMeta>
|
||||
{getBranchMetaText(item, isVersionMode)}
|
||||
</BranchMeta>
|
||||
{item.isCurrent ? (
|
||||
<ActionRow>
|
||||
<TinyButton
|
||||
onClick={() => onSetBranchStatus(item.id, "merged")}
|
||||
onClick={() =>
|
||||
onSetBranchStatus(item.id, "merged")
|
||||
}
|
||||
>
|
||||
{branchPrimaryActionLabel}
|
||||
</TinyButton>
|
||||
<TinyButton
|
||||
onClick={() => onSetBranchStatus(item.id, "pending")}
|
||||
onClick={() =>
|
||||
onSetBranchStatus(item.id, "pending")
|
||||
}
|
||||
>
|
||||
{branchSecondaryActionLabel}
|
||||
</TinyButton>
|
||||
</ActionRow>
|
||||
) : (
|
||||
<BranchHint>切到当前焦点后再继续处理这一条记录</BranchHint>
|
||||
<BranchHint>
|
||||
切到当前焦点后再继续处理这一条记录
|
||||
</BranchHint>
|
||||
)}
|
||||
</div>
|
||||
</BranchHead>
|
||||
@@ -1779,7 +1798,9 @@ function GeneralWorkbenchWorkflowPanelComponent({
|
||||
<CreationTaskTitle>
|
||||
{getCreationTaskTitle(task.path)}
|
||||
</CreationTaskTitle>
|
||||
<CreationTaskTime>{task.timeLabel}</CreationTaskTime>
|
||||
<CreationTaskTime>
|
||||
{task.timeLabel}
|
||||
</CreationTaskTime>
|
||||
</CreationTaskTitleRow>
|
||||
<CreationTaskPath>{task.path}</CreationTaskPath>
|
||||
</CreationTaskContent>
|
||||
@@ -1921,7 +1942,9 @@ function GeneralWorkbenchWorkflowPanelComponent({
|
||||
) : null}
|
||||
{curatedTaskFollowUpHintText ? (
|
||||
<>
|
||||
<RunDetailSummary>{curatedTaskFollowUpHintText}</RunDetailSummary>
|
||||
<RunDetailSummary>
|
||||
{curatedTaskFollowUpHintText}
|
||||
</RunDetailSummary>
|
||||
<CuratedTaskFollowUpActions
|
||||
curatedTask={runMetadataSummary.curatedTask}
|
||||
onApplyFollowUpAction={onApplyFollowUpAction}
|
||||
|
||||
@@ -673,6 +673,12 @@ describe("HarnessStatusPanel", () => {
|
||||
expect(document.body.textContent).toContain("上下文");
|
||||
expect(document.body.textContent).not.toContain("展开详情");
|
||||
expect(document.body.textContent).not.toContain("收起详情");
|
||||
expect(panel?.className).toContain("lime-workbench-theme-scope");
|
||||
expect(panel?.className).toContain("lime-workbench-surface-scope");
|
||||
expect(panel?.className).toContain("bg-[color:var(--lime-surface)]");
|
||||
expect(panel?.className).toContain(
|
||||
"border-[color:var(--lime-surface-border)]",
|
||||
);
|
||||
expect(panel?.className).toContain("flex");
|
||||
expect(panel?.className).toContain("h-full");
|
||||
expect(panel?.children.length).toBe(2);
|
||||
@@ -787,7 +793,8 @@ describe("HarnessStatusPanel", () => {
|
||||
threadRead: {
|
||||
thread_id: "thread-1",
|
||||
status: "running",
|
||||
decision_reason: "当前 provider 候选池共有 3 个兼容候选,已按连续性、能力与成本优选。",
|
||||
decision_reason:
|
||||
"当前 provider 候选池共有 3 个兼容候选,已按连续性、能力与成本优选。",
|
||||
fallback_chain: ["openai:gpt-5.4", "openai:gpt-5.4-mini"],
|
||||
oem_policy: {
|
||||
locked: true,
|
||||
@@ -2516,7 +2523,9 @@ describe("HarnessStatusPanel", () => {
|
||||
expect(summary?.textContent).toContain("Team current tools 缺 3 项");
|
||||
expect(summary?.textContent).toContain("Task current tools 缺 6 项");
|
||||
expect(summary?.textContent).toContain("SendMessage");
|
||||
expect(summary?.textContent).toContain("TeamCreate / TeamDelete / ListPeers");
|
||||
expect(summary?.textContent).toContain(
|
||||
"TeamCreate / TeamDelete / ListPeers",
|
||||
);
|
||||
expect(summary?.textContent).toContain(
|
||||
"TaskCreate / TaskGet / TaskList / TaskUpdate / TaskOutput / TaskStop",
|
||||
);
|
||||
|
||||
@@ -2078,33 +2078,35 @@ export function HarnessStatusPanel({
|
||||
const runtimeFactSummary = useMemo(() => {
|
||||
const decisionReason =
|
||||
threadRead?.decision_reason ||
|
||||
((threadRead?.runtime_summary as { decisionReason?: string | null } | null)
|
||||
?.decisionReason ??
|
||||
((
|
||||
threadRead?.runtime_summary as { decisionReason?: string | null } | null
|
||||
)?.decisionReason ??
|
||||
null);
|
||||
const fallbackChain = Array.isArray(threadRead?.fallback_chain)
|
||||
? threadRead?.fallback_chain || []
|
||||
: Array.isArray(
|
||||
(threadRead?.runtime_summary as { fallbackChain?: string[] | null } | null)
|
||||
?.fallbackChain,
|
||||
(
|
||||
threadRead?.runtime_summary as {
|
||||
fallbackChain?: string[] | null;
|
||||
} | null
|
||||
)?.fallbackChain,
|
||||
)
|
||||
? ((threadRead?.runtime_summary as { fallbackChain?: string[] | null })
|
||||
.fallbackChain || [])
|
||||
? (threadRead?.runtime_summary as { fallbackChain?: string[] | null })
|
||||
.fallbackChain || []
|
||||
: [];
|
||||
const oemPolicy = threadRead?.oem_policy as
|
||||
| {
|
||||
locked?: boolean | null;
|
||||
quotaLow?: boolean | null;
|
||||
defaultModel?: string | null;
|
||||
selectedModel?: string | null;
|
||||
quotaStatus?: string | null;
|
||||
offerState?: string | null;
|
||||
providerSource?: string | null;
|
||||
providerKey?: string | null;
|
||||
fallbackToLocalAllowed?: boolean | null;
|
||||
canInvoke?: boolean | null;
|
||||
tenantId?: string | null;
|
||||
}
|
||||
| null;
|
||||
const oemPolicy = threadRead?.oem_policy as {
|
||||
locked?: boolean | null;
|
||||
quotaLow?: boolean | null;
|
||||
defaultModel?: string | null;
|
||||
selectedModel?: string | null;
|
||||
quotaStatus?: string | null;
|
||||
offerState?: string | null;
|
||||
providerSource?: string | null;
|
||||
providerKey?: string | null;
|
||||
fallbackToLocalAllowed?: boolean | null;
|
||||
canInvoke?: boolean | null;
|
||||
tenantId?: string | null;
|
||||
} | null;
|
||||
|
||||
if (!decisionReason && fallbackChain.length === 0 && !oemPolicy) {
|
||||
return null;
|
||||
@@ -2694,12 +2696,12 @@ export function HarnessStatusPanel({
|
||||
data-testid="harness-status-panel"
|
||||
data-layout={layout}
|
||||
className={cn(
|
||||
"bg-muted/30",
|
||||
"lime-workbench-theme-scope lime-workbench-surface-scope text-[color:var(--lime-text)]",
|
||||
layout === "sidebar"
|
||||
? "rounded-xl border border-border"
|
||||
? "rounded-xl border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)]"
|
||||
: layout === "dialog"
|
||||
? "flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-border/70 bg-background"
|
||||
: "mx-3 mt-2 rounded-2xl border border-border",
|
||||
? "flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)]"
|
||||
: "mx-3 mt-2 rounded-2xl border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -4649,7 +4651,8 @@ export function HarnessStatusPanel({
|
||||
OEM 当前不可调用
|
||||
</Badge>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.fallbackToLocalAllowed === true ? (
|
||||
{runtimeFactSummary.oemPolicy
|
||||
.fallbackToLocalAllowed === true ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-300 bg-white text-emerald-700"
|
||||
@@ -4662,23 +4665,37 @@ export function HarnessStatusPanel({
|
||||
{runtimeFactSummary.oemPolicy.defaultModel ||
|
||||
runtimeFactSummary.oemPolicy.selectedModel ? (
|
||||
<span>
|
||||
OEM 模型 {runtimeFactSummary.oemPolicy.defaultModel || runtimeFactSummary.oemPolicy.selectedModel}
|
||||
OEM 模型{" "}
|
||||
{runtimeFactSummary.oemPolicy.defaultModel ||
|
||||
runtimeFactSummary.oemPolicy.selectedModel}
|
||||
</span>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.quotaStatus ? (
|
||||
<span>额度状态 {runtimeFactSummary.oemPolicy.quotaStatus}</span>
|
||||
<span>
|
||||
额度状态{" "}
|
||||
{runtimeFactSummary.oemPolicy.quotaStatus}
|
||||
</span>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.offerState ? (
|
||||
<span>策略状态 {runtimeFactSummary.oemPolicy.offerState}</span>
|
||||
<span>
|
||||
策略状态 {runtimeFactSummary.oemPolicy.offerState}
|
||||
</span>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.providerSource ? (
|
||||
<span>来源 {runtimeFactSummary.oemPolicy.providerSource}</span>
|
||||
<span>
|
||||
来源 {runtimeFactSummary.oemPolicy.providerSource}
|
||||
</span>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.providerKey ? (
|
||||
<span>Provider Key {runtimeFactSummary.oemPolicy.providerKey}</span>
|
||||
<span>
|
||||
Provider Key{" "}
|
||||
{runtimeFactSummary.oemPolicy.providerKey}
|
||||
</span>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.tenantId ? (
|
||||
<span>租户 {runtimeFactSummary.oemPolicy.tenantId}</span>
|
||||
<span>
|
||||
租户 {runtimeFactSummary.oemPolicy.tenantId}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,9 +39,11 @@ export function HarnessVerificationSummarySection({
|
||||
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50/80 p-3">
|
||||
<div className="text-sm font-medium text-amber-900">验证失败焦点</div>
|
||||
<div className="mt-2 space-y-1 text-xs text-amber-800">
|
||||
{summary.focus_verification_failure_outcomes.map((outcome, index) => (
|
||||
<div key={`${outcome}-${index}`}>{outcome}</div>
|
||||
))}
|
||||
{summary.focus_verification_failure_outcomes.map(
|
||||
(outcome, index) => (
|
||||
<div key={`${outcome}-${index}`}>{outcome}</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -2,6 +2,15 @@ import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockOpenResourceManager } = vi.hoisted(() => ({
|
||||
mockOpenResourceManager: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/resource-manager", () => ({
|
||||
openResourceManager: mockOpenResourceManager,
|
||||
}));
|
||||
|
||||
import { ImageTaskViewer } from "./ImageTaskViewer";
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
@@ -56,7 +65,6 @@ function createProps(
|
||||
savingToResource: false,
|
||||
onViewportChange: vi.fn(),
|
||||
onSelectOutput: vi.fn(),
|
||||
onOpenImage: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
@@ -120,9 +128,12 @@ describe("ImageTaskViewer", () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("点击大图应打开应用内逐张预览,而不是直接外跳", () => {
|
||||
const onOpenImage = vi.fn();
|
||||
const { container } = renderComponent({ onOpenImage });
|
||||
it("点击大图应打开独立资源管理器并透传图片任务上下文", () => {
|
||||
const { container } = renderComponent({
|
||||
sourceProjectId: "project-1",
|
||||
sourceContentId: "content-1",
|
||||
sourceThreadId: "thread-1",
|
||||
});
|
||||
|
||||
const openButton = container.querySelector(
|
||||
'[data-testid="image-task-viewer-open-image"]',
|
||||
@@ -133,38 +144,51 @@ describe("ImageTaskViewer", () => {
|
||||
openButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onOpenImage).not.toHaveBeenCalled();
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="image-task-viewer-open-external"]'),
|
||||
).toBeTruthy();
|
||||
expect(document.body.textContent).toContain("在新窗口打开");
|
||||
});
|
||||
|
||||
it("预览弹窗里的外链按钮应复用 onOpenImage 打开当前图片", () => {
|
||||
const onOpenImage = vi.fn();
|
||||
const { container } = renderComponent({ onOpenImage });
|
||||
|
||||
const openButton = container.querySelector(
|
||||
'[data-testid="image-task-viewer-open-image"]',
|
||||
expect(mockOpenResourceManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceLabel: "Image Generation",
|
||||
sourceContext: expect.objectContaining({
|
||||
kind: "image_task",
|
||||
projectId: "project-1",
|
||||
contentId: "content-1",
|
||||
taskId: "task-1",
|
||||
outputId: "output-1",
|
||||
threadId: "thread-1",
|
||||
sourcePage: "image-task-viewer",
|
||||
}),
|
||||
initialIndex: 0,
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
id: "output-1",
|
||||
kind: "image",
|
||||
src: "https://example.com/image-1.png",
|
||||
title: "广州塔主视觉",
|
||||
sourceContext: expect.objectContaining({
|
||||
kind: "image_task",
|
||||
projectId: "project-1",
|
||||
contentId: "content-1",
|
||||
taskId: "task-1",
|
||||
outputId: "output-1",
|
||||
threadId: "thread-1",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "output-2",
|
||||
kind: "image",
|
||||
src: "https://example.com/image-2.png",
|
||||
title: "广州塔夜景",
|
||||
sourceContext: expect.objectContaining({
|
||||
kind: "image_task",
|
||||
projectId: "project-1",
|
||||
contentId: "content-1",
|
||||
taskId: "task-1",
|
||||
outputId: "output-2",
|
||||
threadId: "thread-1",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(openButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
openButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const externalButton = document.body.querySelector(
|
||||
'[data-testid="image-task-viewer-open-external"]',
|
||||
);
|
||||
expect(externalButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
externalButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onOpenImage).toHaveBeenCalledWith("https://example.com/image-1.png");
|
||||
});
|
||||
|
||||
it("大图舞台应保留稳定内边距,避免图片贴近上下边线", () => {
|
||||
@@ -288,7 +312,7 @@ describe("ImageTaskViewer", () => {
|
||||
expect(container.textContent).toContain("第 1 格");
|
||||
});
|
||||
|
||||
it("3x3 分镜应支持逐张预览与缩略带切换", () => {
|
||||
it("3x3 分镜应把分镜元信息传给独立资源管理器", () => {
|
||||
const onSelectOutput = vi.fn();
|
||||
const outputs = Array.from({ length: 3 }, (_, index) => ({
|
||||
id: `output-storyboard-preview-${index + 1}`,
|
||||
@@ -336,21 +360,31 @@ describe("ImageTaskViewer", () => {
|
||||
openButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="image-task-viewer-open-external"]'),
|
||||
).toBeTruthy();
|
||||
expect(document.body.textContent).toContain("刘备亮相");
|
||||
|
||||
const nextButton = Array.from(
|
||||
document.body.querySelectorAll("button"),
|
||||
).find((button) => button.getAttribute("aria-label") === "查看下一张图片");
|
||||
expect(nextButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
nextButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSelectOutput).toHaveBeenCalledWith("output-storyboard-preview-2");
|
||||
expect(mockOpenResourceManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialIndex: 0,
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "output-storyboard-preview-1",
|
||||
title: "刘备亮相",
|
||||
metadata: expect.objectContaining({
|
||||
slotLabel: "刘备亮相",
|
||||
prompt: "第 1 格完整提示词",
|
||||
}),
|
||||
sourceContext: expect.objectContaining({
|
||||
kind: "image_task",
|
||||
taskId: "task-storyboard-preview-1",
|
||||
outputId: "output-storyboard-preview-1",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "output-storyboard-preview-2",
|
||||
title: "曹操压迫感",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(onSelectOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("运行中的 3x3 分镜任务应先渲染 9 个固定槽位", () => {
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
LoaderCircle,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useMemo } from "react";
|
||||
import { LoaderCircle, Sparkles, X } from "lucide-react";
|
||||
import { openResourceManager } from "@/features/resource-manager";
|
||||
import type { ResourceManagerSourceContext } from "@/features/resource-manager";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RenderableTaskImage } from "./RenderableTaskImage";
|
||||
import type { ImageTaskViewerProps } from "./imageWorkbenchTypes";
|
||||
@@ -270,20 +258,44 @@ function resolveSourcePlaceholderLabel(
|
||||
: "来源图待同步";
|
||||
}
|
||||
|
||||
function normalizeSourceContextText(value?: string | null): string | null {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function buildImageTaskResourceSourceContext(params: {
|
||||
taskId?: string | null;
|
||||
outputId?: string | null;
|
||||
projectId?: string | null;
|
||||
contentId?: string | null;
|
||||
threadId?: string | null;
|
||||
}): ResourceManagerSourceContext {
|
||||
return {
|
||||
kind: "image_task",
|
||||
projectId: normalizeSourceContextText(params.projectId),
|
||||
contentId: normalizeSourceContextText(params.contentId),
|
||||
taskId: normalizeSourceContextText(params.taskId),
|
||||
outputId: normalizeSourceContextText(params.outputId),
|
||||
threadId: normalizeSourceContextText(params.threadId),
|
||||
sourcePage: "image-task-viewer",
|
||||
};
|
||||
}
|
||||
|
||||
export function ImageTaskViewer({
|
||||
tasks,
|
||||
outputs,
|
||||
selectedOutputId,
|
||||
sourceProjectId,
|
||||
sourceContentId,
|
||||
sourceThreadId,
|
||||
savingToResource,
|
||||
onSaveSelectedToLibrary,
|
||||
applySelectedOutputLabel,
|
||||
onApplySelectedOutput,
|
||||
onSeedFollowUpCommand,
|
||||
onSelectOutput,
|
||||
onOpenImage,
|
||||
onClose,
|
||||
}: ImageTaskViewerProps) {
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
const selectedOutput =
|
||||
outputs.find((item) => item.id === selectedOutputId) ?? outputs[0] ?? null;
|
||||
const selectedTask =
|
||||
@@ -389,32 +401,54 @@ export function ImageTaskViewer({
|
||||
if (!selectedOutput) {
|
||||
return;
|
||||
}
|
||||
setPreviewDialogOpen(true);
|
||||
};
|
||||
const handleOpenSelectedImageInNewTab = () => {
|
||||
if (!selectedOutput) {
|
||||
return;
|
||||
}
|
||||
if (onOpenImage) {
|
||||
onOpenImage(selectedOutput.url);
|
||||
return;
|
||||
}
|
||||
window.open(selectedOutput.url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
const handlePreviewStep = (direction: -1 | 1) => {
|
||||
if (selectedOutputIndex < 0 || selectedTaskOutputs.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex =
|
||||
(selectedOutputIndex + direction + selectedTaskOutputs.length) %
|
||||
selectedTaskOutputs.length;
|
||||
const nextOutput = selectedTaskOutputs[nextIndex];
|
||||
if (!nextOutput) {
|
||||
return;
|
||||
}
|
||||
void openResourceManager({
|
||||
sourceLabel: resolveModeEyebrow(selectedTask?.mode),
|
||||
sourceContext: buildImageTaskResourceSourceContext({
|
||||
taskId: selectedTask?.id ?? selectedOutput.taskId,
|
||||
outputId: selectedOutput.id,
|
||||
projectId: sourceProjectId,
|
||||
contentId: sourceContentId,
|
||||
threadId: sourceThreadId,
|
||||
}),
|
||||
initialIndex: selectedOutputIndex >= 0 ? selectedOutputIndex : 0,
|
||||
items: selectedTaskOutputs.map((output, index) => {
|
||||
const taskSlotLabel = selectedTask?.storyboardSlots?.find(
|
||||
(slot) =>
|
||||
slot.slotIndex ===
|
||||
resolveOutputDisplayIndex(index, output.slotIndex),
|
||||
)?.label;
|
||||
const slotLabel = resolveStoryboardSlotLabel({
|
||||
layoutHint: selectedTask?.layoutHint,
|
||||
outputIndex: index,
|
||||
slotIndex: output.slotIndex,
|
||||
slotLabel: output.slotLabel,
|
||||
taskSlotLabel,
|
||||
});
|
||||
|
||||
onSelectOutput(nextOutput.id);
|
||||
return {
|
||||
id: output.id,
|
||||
kind: "image" as const,
|
||||
src: output.url,
|
||||
title: slotLabel || output.prompt || prompt,
|
||||
description: output.slotPrompt || output.prompt || prompt,
|
||||
metadata: {
|
||||
prompt: output.slotPrompt || output.prompt || prompt,
|
||||
slotLabel,
|
||||
size: output.size,
|
||||
providerName: output.providerName,
|
||||
modelName: output.modelName,
|
||||
},
|
||||
sourceContext: buildImageTaskResourceSourceContext({
|
||||
taskId: output.taskId || selectedTask?.id,
|
||||
outputId: output.id,
|
||||
projectId: sourceProjectId,
|
||||
contentId: sourceContentId,
|
||||
threadId: sourceThreadId,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -754,7 +788,9 @@ export function ImageTaskViewer({
|
||||
alt={output.prompt || "图片结果缩略图"}
|
||||
className={cn(
|
||||
"w-full object-cover",
|
||||
resolveOutputTileAspectClass(selectedTask?.layoutHint),
|
||||
resolveOutputTileAspectClass(
|
||||
selectedTask?.layoutHint,
|
||||
),
|
||||
)}
|
||||
renderFallback={() => (
|
||||
<div
|
||||
@@ -773,7 +809,9 @@ export function ImageTaskViewer({
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col items-center justify-center gap-2 bg-[linear-gradient(180deg,rgba(248,250,252,0.96),rgba(241,245,249,0.98))] px-3 text-center",
|
||||
resolveOutputTileAspectClass(selectedTask?.layoutHint),
|
||||
resolveOutputTileAspectClass(
|
||||
selectedTask?.layoutHint,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{selectedTask?.status === "queued" ||
|
||||
@@ -821,157 +859,6 @@ export function ImageTaskViewer({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
|
||||
<DialogContent
|
||||
maxWidth="max-w-6xl"
|
||||
className="overflow-hidden border-slate-200 bg-white p-0"
|
||||
>
|
||||
<DialogHeader className="border-b border-slate-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.96),rgba(255,255,255,1))] px-6 py-4">
|
||||
<DialogTitle className="pr-10 text-xl font-semibold leading-8 text-slate-950">
|
||||
{selectedStoryboardSlot?.label || prompt}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-1 text-sm leading-6 text-slate-600">
|
||||
<span className="block">{prompt}</span>
|
||||
{selectedStoryboardSlot?.prompt ? (
|
||||
<span className="block text-slate-500">
|
||||
{selectedStoryboardSlot.prompt}
|
||||
</span>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-6 py-5">
|
||||
<div className="relative min-h-[min(68vh,640px)] flex-1 overflow-hidden rounded-[24px] border border-slate-200 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.10),transparent_40%),linear-gradient(180deg,rgba(248,250,252,0.96),rgba(241,245,249,0.98))]">
|
||||
{selectedOutput ? (
|
||||
<RenderableTaskImage
|
||||
src={selectedOutput.url}
|
||||
alt={selectedOutput.prompt || "图片任务结果"}
|
||||
className="h-full w-full object-contain"
|
||||
renderFallback={(reason) => (
|
||||
<div className="flex h-full min-h-[320px] items-center justify-center px-6 text-center">
|
||||
<div className="max-w-sm space-y-3">
|
||||
<Sparkles className="mx-auto h-8 w-8 text-slate-400" />
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{reason === "error" ? "预览加载失败" : "暂无可预览图片"}
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-slate-500">
|
||||
{reason === "error"
|
||||
? "这张图片暂时无法在预览窗口中展示,请稍后重试或在新窗口打开。"
|
||||
: "当前没有可展示的图片结果。"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{selectedTaskOutputs.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="查看上一张图片"
|
||||
onClick={() => handlePreviewStep(-1)}
|
||||
className="absolute left-4 top-1/2 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full border border-slate-200/80 bg-white/92 text-slate-700 shadow-sm shadow-slate-950/10 transition hover:border-slate-300 hover:text-slate-950"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="查看下一张图片"
|
||||
onClick={() => handlePreviewStep(1)}
|
||||
className="absolute right-4 top-1/2 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full border border-slate-200/80 bg-white/92 text-slate-700 shadow-sm shadow-slate-950/10 transition hover:border-slate-300 hover:text-slate-950"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs text-slate-600">
|
||||
{selectedOutputLabel || `${selectedTaskOutputs.length} 张结果`}
|
||||
</span>
|
||||
{selectedOutput?.size ? (
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs text-slate-600">
|
||||
{selectedOutput.size}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenSelectedImageInNewTab}
|
||||
data-testid="image-task-viewer-open-external"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:text-slate-900"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
在新窗口打开
|
||||
</button>
|
||||
</div>
|
||||
{selectedTaskOutputs.length > 1 ? (
|
||||
<div className="mt-4 max-h-[min(24vh,220px)] overflow-y-auto pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]">
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||
{selectedTaskOutputs.map((output, index) => {
|
||||
const active = output.id === selectedOutput?.id;
|
||||
const taskSlotLabel = selectedTask?.storyboardSlots?.find(
|
||||
(slot) =>
|
||||
slot.slotIndex ===
|
||||
resolveOutputDisplayIndex(index, output.slotIndex),
|
||||
)?.label;
|
||||
const storyboardSlotLabel = resolveStoryboardSlotLabel({
|
||||
layoutHint: selectedTask?.layoutHint,
|
||||
outputIndex: index,
|
||||
slotIndex: output.slotIndex,
|
||||
slotLabel: output.slotLabel,
|
||||
taskSlotLabel,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
key={output.id}
|
||||
type="button"
|
||||
onClick={() => onSelectOutput(output.id)}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[18px] border bg-white text-left transition",
|
||||
active
|
||||
? "border-sky-300 shadow-sm shadow-sky-500/10"
|
||||
: "border-slate-200 hover:border-slate-300",
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<RenderableTaskImage
|
||||
src={output.url}
|
||||
alt={output.prompt || "图片结果缩略图"}
|
||||
className={cn(
|
||||
"w-full object-cover",
|
||||
resolveOutputTileAspectClass(selectedTask?.layoutHint),
|
||||
)}
|
||||
renderFallback={() => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center bg-slate-50 px-3 text-center text-[11px] font-medium text-slate-400",
|
||||
resolveOutputTileAspectClass(
|
||||
selectedTask?.layoutHint,
|
||||
),
|
||||
)}
|
||||
>
|
||||
预览失败
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<span className="absolute left-2 top-2 inline-flex h-6 min-w-6 items-center justify-center rounded-full border border-slate-200/80 bg-white/95 px-1.5 text-[11px] font-semibold text-slate-700 shadow-sm shadow-slate-950/5">
|
||||
{resolveOutputDisplayIndex(index, output.slotIndex)}
|
||||
</span>
|
||||
</div>
|
||||
{storyboardSlotLabel ? (
|
||||
<div className="line-clamp-2 px-2.5 py-2 text-[11px] font-medium leading-4 text-slate-700">
|
||||
{storyboardSlotLabel}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,8 +283,10 @@ function resolveStoryboardSlotLabel(
|
||||
preview: MessageImageWorkbenchPreview,
|
||||
index: number,
|
||||
): string | null {
|
||||
return preview.storyboardSlots?.find((slot) => slot.slotIndex === index + 1)
|
||||
?.label || null;
|
||||
return (
|
||||
preview.storyboardSlots?.find((slot) => slot.slotIndex === index + 1)
|
||||
?.label || null
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePreviewMetaLabels(
|
||||
@@ -352,8 +354,7 @@ function renderPreviewMedia(preview: MessageImageWorkbenchPreview) {
|
||||
>
|
||||
{Array.from({ length: visibleCount }, (_, index) => {
|
||||
const url = previewImages[index];
|
||||
const isLastWithOverflow =
|
||||
extraCount > 0 && index === visibleCount - 1;
|
||||
const isLastWithOverflow = extraCount > 0 && index === visibleCount - 1;
|
||||
const storyboardSlotLabel = isStoryboardGrid
|
||||
? resolveStoryboardSlotLabel(preview, index)
|
||||
: null;
|
||||
|
||||
@@ -111,9 +111,13 @@ describe("InlineToolProcessStep", () => {
|
||||
expect(container.textContent).not.toContain("查询:");
|
||||
expect(container.textContent).not.toContain("select:Read,Write");
|
||||
expect(
|
||||
container.querySelector('[data-testid="inline-tool-process-tool-search-result"]'),
|
||||
container.querySelector(
|
||||
'[data-testid="inline-tool-process-tool-search-result"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="markdown-renderer"]'),
|
||||
).toBeNull();
|
||||
expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain('"tools"');
|
||||
});
|
||||
|
||||
@@ -135,7 +139,9 @@ describe("InlineToolProcessStep", () => {
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("已查看 main.ts");
|
||||
expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="markdown-renderer"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("工具详情遇到伪标签输出时应先转义再渲染", () => {
|
||||
@@ -159,7 +165,9 @@ describe("InlineToolProcessStep", () => {
|
||||
toggle?.click();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("<text>正在整理</text>");
|
||||
expect(container.textContent).toContain(
|
||||
"<text>正在整理</text>",
|
||||
);
|
||||
expect(container.textContent).toContain("<spinner />");
|
||||
});
|
||||
|
||||
@@ -229,13 +237,17 @@ describe("InlineToolProcessStep", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="inline-tool-process-tool-search-result"]'),
|
||||
container.querySelector(
|
||||
'[data-testid="inline-tool-process-tool-search-result"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain("已确认可用工具 2 个");
|
||||
expect(container.textContent).toContain("找到工具:2 个");
|
||||
expect(container.textContent).toContain("查看文件");
|
||||
expect(container.textContent).toContain("保存文件");
|
||||
expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="markdown-renderer"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).not.toContain('"always_visible":true');
|
||||
expect(container.textContent).not.toContain("Read a file from disk");
|
||||
expect(container.textContent).not.toContain("查询:select:Read,Write");
|
||||
@@ -280,14 +292,18 @@ describe("InlineToolProcessStep", () => {
|
||||
expect(container.textContent).toContain(
|
||||
"Friday morning news: March 13, 2026 | WORLD - wng.org",
|
||||
);
|
||||
expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="markdown-renderer"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("WebSearch 协议错误应展示可操作提示,并保留原始错误供排查", () => {
|
||||
const { container } = renderTool({
|
||||
id: "tool-search-web-failed-1",
|
||||
name: "WebSearch",
|
||||
arguments: JSON.stringify({ query: "AI Agent trends X Twitter April 2026" }),
|
||||
arguments: JSON.stringify({
|
||||
query: "AI Agent trends X Twitter April 2026",
|
||||
}),
|
||||
status: "failed",
|
||||
result: {
|
||||
success: false,
|
||||
@@ -309,7 +325,9 @@ describe("InlineToolProcessStep", () => {
|
||||
toggle?.click();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("原始错误:-32603: -32002: WebSearch");
|
||||
expect(container.textContent).toContain(
|
||||
"原始错误:-32603: -32002: WebSearch",
|
||||
);
|
||||
});
|
||||
|
||||
it("完成态过程卡不应重复展示执行完成与原始工具名", () => {
|
||||
@@ -364,7 +382,9 @@ describe("InlineToolProcessStep", () => {
|
||||
{ onOpenSavedSiteContent },
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("已保存到当前项目:Google Cloud 周报");
|
||||
expect(container.textContent).toContain(
|
||||
"已保存到当前项目:Google Cloud 周报",
|
||||
);
|
||||
expect(container.textContent).toContain("已导出 Markdown 文稿");
|
||||
expect(container.textContent).toContain("附带图片 3 张");
|
||||
|
||||
|
||||
@@ -88,9 +88,9 @@ function sanitizeToolResultDetailMarkdown(value: string): string {
|
||||
return value.replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function summarizeToolSearchPreview(value: ReturnType<
|
||||
typeof normalizeToolSearchResultSummary
|
||||
>): string | null {
|
||||
function summarizeToolSearchPreview(
|
||||
value: ReturnType<typeof normalizeToolSearchResultSummary>,
|
||||
): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
@@ -224,7 +224,9 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
|
||||
return rawResultText;
|
||||
}
|
||||
|
||||
return resolveToolErrorDetailText(toolCall.name, rawResultText) || rawResultText;
|
||||
return (
|
||||
resolveToolErrorDetailText(toolCall.name, rawResultText) || rawResultText
|
||||
);
|
||||
}, [rawResultText, toolCall.name, toolCall.status]);
|
||||
const resultDetailMarkdown = useMemo(
|
||||
() => sanitizeToolResultDetailMarkdown(resultText),
|
||||
@@ -235,7 +237,8 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
|
||||
[resultText],
|
||||
);
|
||||
const resultImages = useMemo(
|
||||
() => normalizeToolResultImages(toolCall.result?.images, rawResultText) || [],
|
||||
() =>
|
||||
normalizeToolResultImages(toolCall.result?.images, rawResultText) || [],
|
||||
[rawResultText, toolCall.result?.images],
|
||||
);
|
||||
const isToolSearch = useMemo(
|
||||
@@ -472,7 +475,8 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-medium leading-5 text-emerald-900">
|
||||
{savedSiteContentTarget.preferredTarget === "project_file"
|
||||
{savedSiteContentTarget.preferredTarget ===
|
||||
"project_file"
|
||||
? "在下方预览导出 Markdown"
|
||||
: "打开已保存内容"}
|
||||
</span>
|
||||
@@ -504,7 +508,9 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!toolSearchSummary && searchResultItems.length === 0 && resultText ? (
|
||||
{!toolSearchSummary &&
|
||||
searchResultItems.length === 0 &&
|
||||
resultText ? (
|
||||
<div className="text-sm leading-6 text-slate-700">
|
||||
<MarkdownRenderer content={resultDetailMarkdown} />
|
||||
</div>
|
||||
|
||||
@@ -126,7 +126,9 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
|
||||
disabled,
|
||||
});
|
||||
const hasInlineComposerContent =
|
||||
text.trim().length > 0 || pendingImages.length > 0 || queuedTurns.length > 0;
|
||||
text.trim().length > 0 ||
|
||||
pendingImages.length > 0 ||
|
||||
queuedTurns.length > 0;
|
||||
const shouldCollapseFloatingTools =
|
||||
isFloatingVariant &&
|
||||
toolMode === "attach-only" &&
|
||||
|
||||
+4
-1
@@ -26,7 +26,10 @@ vi.mock("@/hooks/useConfiguredProviders", () => ({
|
||||
) ??
|
||||
null;
|
||||
|
||||
if ((selectedProvider?.type || "").trim().toLowerCase() === "anthropic-compatible") {
|
||||
if (
|
||||
(selectedProvider?.type || "").trim().toLowerCase() ===
|
||||
"anthropic-compatible"
|
||||
) {
|
||||
return {
|
||||
label: "未声明自动缓存",
|
||||
detail:
|
||||
|
||||
+9
-7
@@ -46,9 +46,7 @@ function formatElapsed(ms: number): string {
|
||||
)}`;
|
||||
}
|
||||
|
||||
function resolveStatusMeta(
|
||||
status: InputbarRuntimeStatusLineModel["status"],
|
||||
): {
|
||||
function resolveStatusMeta(status: InputbarRuntimeStatusLineModel["status"]): {
|
||||
label: string;
|
||||
toneClassName: string;
|
||||
dotClassName: string;
|
||||
@@ -115,8 +113,8 @@ export const InputbarRuntimeStatusLine: React.FC<
|
||||
);
|
||||
const shouldInspectPromptCacheNotice = Boolean(
|
||||
providerType?.trim() &&
|
||||
runtime?.usage &&
|
||||
resolvePromptCacheActivity(runtime.usage) <= 0,
|
||||
runtime?.usage &&
|
||||
resolvePromptCacheActivity(runtime.usage) <= 0,
|
||||
);
|
||||
const { providers } = useConfiguredProviders({
|
||||
autoLoad: shouldInspectPromptCacheNotice,
|
||||
@@ -175,8 +173,12 @@ export const InputbarRuntimeStatusLine: React.FC<
|
||||
: null;
|
||||
const segments = [
|
||||
elapsedText,
|
||||
runtime.batchDescriptor ? `工具 ${runtime.batchDescriptor.countLabel}` : null,
|
||||
runtime.pendingRequestCount > 0 ? `待补 ${runtime.pendingRequestCount}` : null,
|
||||
runtime.batchDescriptor
|
||||
? `工具 ${runtime.batchDescriptor.countLabel}`
|
||||
: null,
|
||||
runtime.pendingRequestCount > 0
|
||||
? `待补 ${runtime.pendingRequestCount}`
|
||||
: null,
|
||||
runtime.queuedTurnCount > 0 ? `排队 ${runtime.queuedTurnCount}` : null,
|
||||
runtime.subtaskStats?.total
|
||||
? `任务 ${runtime.subtaskStats.active}/${runtime.subtaskStats.total}`
|
||||
|
||||
+8
-11
@@ -1,10 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, ChevronDown, Clock3, Loader2 } from "lucide-react";
|
||||
import styled from "styled-components";
|
||||
import type {
|
||||
WorkflowGateState,
|
||||
@@ -363,11 +358,11 @@ export function InputbarWorkflowStatusPanel({
|
||||
<PanelWrap>
|
||||
<StatusCard data-testid="workflow-status-card">
|
||||
<SummaryRow>
|
||||
<SummaryIcon $tone={summaryTone}>{renderToneIcon(summaryTone)}</SummaryIcon>
|
||||
<SummaryIcon $tone={summaryTone}>
|
||||
{renderToneIcon(summaryTone)}
|
||||
</SummaryIcon>
|
||||
<SummaryBody>
|
||||
<SummaryEyebrow>
|
||||
当前进展
|
||||
</SummaryEyebrow>
|
||||
<SummaryEyebrow>当前进展</SummaryEyebrow>
|
||||
<SummaryTitle data-testid="workflow-current-title">
|
||||
{summaryTitle}
|
||||
</SummaryTitle>
|
||||
@@ -436,7 +431,9 @@ export function InputbarWorkflowStatusPanel({
|
||||
<TaskTitle>{item.title}</TaskTitle>
|
||||
<TaskMeta>
|
||||
{getStatusLabel(tone)}
|
||||
{totalCount > 0 ? ` · ${completedCount}/${totalCount}` : ""}
|
||||
{totalCount > 0
|
||||
? ` · ${completedCount}/${totalCount}`
|
||||
: ""}
|
||||
</TaskMeta>
|
||||
</TaskBody>
|
||||
</TaskRow>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { BuiltinCommandBadge } from "../components/BuiltinCommandBadge";
|
||||
import { RuntimeSceneBadge } from "../components/RuntimeSceneBadge";
|
||||
import { CuratedTaskBadge } from "../../../skill-selection/CuratedTaskBadge";
|
||||
@@ -113,9 +119,10 @@ export function useInputbarController({
|
||||
const [activeCapability, setActiveCapability] =
|
||||
useState<InputCapabilitySelection | null>(null);
|
||||
const [editingCuratedTaskCapability, setEditingCuratedTaskCapability] =
|
||||
useState<Extract<InputCapabilitySelection, { kind: "curated_task" }> | null>(
|
||||
null,
|
||||
);
|
||||
useState<Extract<
|
||||
InputCapabilitySelection,
|
||||
{ kind: "curated_task" }
|
||||
> | null>(null);
|
||||
const [curatedTaskEditorPrefillHint, setCuratedTaskEditorPrefillHint] =
|
||||
useState<string | null>(null);
|
||||
const handledInitialInputCapabilitySignatureRef = useRef("");
|
||||
|
||||
@@ -90,9 +90,7 @@ export function useInputbarToolState({
|
||||
thinking: thinkingEnabled,
|
||||
subagent: nextSubagent,
|
||||
});
|
||||
toast.info(
|
||||
`任务拆分${nextSubagent ? "偏好已开启" : "偏好已关闭"}`,
|
||||
);
|
||||
toast.info(`任务拆分${nextSubagent ? "偏好已开启" : "偏好已关闭"}`);
|
||||
break;
|
||||
}
|
||||
case "attach":
|
||||
|
||||
@@ -6,7 +6,10 @@ import { Inputbar } from "./index";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
|
||||
import type { BuiltinInputCommand, RuntimeSceneSlashCommand } from "../../skill-selection/builtinCommands";
|
||||
import type {
|
||||
BuiltinInputCommand,
|
||||
RuntimeSceneSlashCommand,
|
||||
} from "../../skill-selection/builtinCommands";
|
||||
import type { InputCapabilitySelection } from "../../skill-selection/inputCapabilitySelection";
|
||||
import {
|
||||
buildCuratedTaskLaunchPrompt,
|
||||
@@ -14,29 +17,28 @@ import {
|
||||
resolveCuratedTaskTemplateLaunchPrefill,
|
||||
} from "../../utils/curatedTaskTemplates";
|
||||
|
||||
const mockCharacterMention =
|
||||
vi.fn<
|
||||
(props: {
|
||||
characters?: Character[];
|
||||
skills?: Skill[];
|
||||
serviceSkills?: ServiceSkillHomeItem[];
|
||||
onSelectInputCapability?: (
|
||||
capability: InputCapabilitySelection,
|
||||
options?: { replayText?: string },
|
||||
) => void;
|
||||
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
|
||||
defaultCuratedTaskReferenceMemoryIds?: string[];
|
||||
defaultCuratedTaskReferenceEntries?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
category: string;
|
||||
categoryLabel: string;
|
||||
tags: string[];
|
||||
}>;
|
||||
inputCompletionEnabled?: boolean;
|
||||
}) => React.ReactNode
|
||||
>();
|
||||
const mockCharacterMention = vi.fn<
|
||||
(props: {
|
||||
characters?: Character[];
|
||||
skills?: Skill[];
|
||||
serviceSkills?: ServiceSkillHomeItem[];
|
||||
onSelectInputCapability?: (
|
||||
capability: InputCapabilitySelection,
|
||||
options?: { replayText?: string },
|
||||
) => void;
|
||||
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
|
||||
defaultCuratedTaskReferenceMemoryIds?: string[];
|
||||
defaultCuratedTaskReferenceEntries?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
category: string;
|
||||
categoryLabel: string;
|
||||
tags: string[];
|
||||
}>;
|
||||
inputCompletionEnabled?: boolean;
|
||||
}) => React.ReactNode
|
||||
>();
|
||||
const mockInputbarCore = vi.fn(
|
||||
(props: {
|
||||
onToolClick?: (tool: string) => void;
|
||||
@@ -108,18 +110,18 @@ vi.mock("../../skill-selection/CharacterMention", () => ({
|
||||
capability: InputCapabilitySelection,
|
||||
options?: { replayText?: string },
|
||||
) => void;
|
||||
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
|
||||
defaultCuratedTaskReferenceMemoryIds?: string[];
|
||||
defaultCuratedTaskReferenceEntries?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
|
||||
defaultCuratedTaskReferenceMemoryIds?: string[];
|
||||
defaultCuratedTaskReferenceEntries?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
category: string;
|
||||
categoryLabel: string;
|
||||
tags: string[];
|
||||
}>;
|
||||
inputCompletionEnabled?: boolean;
|
||||
}) => {
|
||||
categoryLabel: string;
|
||||
tags: string[];
|
||||
}>;
|
||||
inputCompletionEnabled?: boolean;
|
||||
}) => {
|
||||
mockCharacterMention(props);
|
||||
return <div data-testid="character-mention-stub" />;
|
||||
},
|
||||
@@ -276,12 +278,12 @@ vi.mock("../CuratedTaskLauncherDialog", () => ({
|
||||
},
|
||||
) => void;
|
||||
}) => {
|
||||
const [inputValues, setInputValues] = React.useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [referenceMemoryIds, setReferenceMemoryIds] = React.useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [inputValues, setInputValues] = React.useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [referenceMemoryIds, setReferenceMemoryIds] = React.useState<
|
||||
string[]
|
||||
>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!props.open || !props.task) {
|
||||
@@ -846,7 +848,8 @@ describe("Inputbar", () => {
|
||||
it("选择结果模板发送时,应透传 curated_task capability route", async () => {
|
||||
const onSend = vi.fn();
|
||||
renderInputbar({
|
||||
input: "请先帮我起草一版内容首稿:明确目标受众、标题方向、正文结构、核心观点和可继续扩写的角度,并给我一版适合继续打磨的正文。",
|
||||
input:
|
||||
"请先帮我起草一版内容首稿:明确目标受众、标题方向、正文结构、核心观点和可继续扩写的角度,并给我一版适合继续打磨的正文。",
|
||||
onSend,
|
||||
activeTheme: "general",
|
||||
});
|
||||
@@ -903,7 +906,9 @@ describe("Inputbar", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(document.querySelector('[data-testid="curated-task-badge"]')).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector('[data-testid="curated-task-badge"]'),
|
||||
).toBeTruthy();
|
||||
|
||||
const sendButton = document.querySelector(
|
||||
'[data-testid="send-btn"]',
|
||||
@@ -1899,7 +1904,9 @@ describe("Inputbar", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(document.querySelector('[data-testid="runtime-scene-badge"]')).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector('[data-testid="runtime-scene-badge"]'),
|
||||
).toBeTruthy();
|
||||
|
||||
const sendButton = document.querySelector(
|
||||
'[data-testid="send-btn"]',
|
||||
@@ -2159,7 +2166,9 @@ describe("Inputbar", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="agent-task-strip"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-task-strip"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("工作区输入区默认隐藏技能入口,展开高级设置后与 @ 面板共用同一技能数据源", async () => {
|
||||
@@ -2656,7 +2665,9 @@ describe("Inputbar", () => {
|
||||
const latestCall =
|
||||
mockInputbarCore.mock.calls[mockInputbarCore.mock.calls.length - 1]?.[0];
|
||||
expect(latestCall).toBeTruthy();
|
||||
expect(latestCall.placeholder).toContain("继续补充这轮生成,或回到左侧继续旧历史");
|
||||
expect(latestCall.placeholder).toContain(
|
||||
"继续补充这轮生成,或回到左侧继续旧历史",
|
||||
);
|
||||
});
|
||||
|
||||
it("工作区工作流在待启动状态下不应显示闸门条", async () => {
|
||||
|
||||
@@ -39,12 +39,12 @@ export const Container = styled.div`
|
||||
|
||||
export const InputBarContainer = styled.div`
|
||||
position: relative;
|
||||
border: 1px solid rgba(110, 231, 183, 0.55);
|
||||
border: 1px solid var(--lime-composer-border, rgba(110, 231, 183, 0.84));
|
||||
border-radius: 22px;
|
||||
padding: 10px 12px 10px 10px;
|
||||
background: linear-gradient(180deg, #fdfffb 0%, #f6fbf7 100%);
|
||||
background: var(--lime-composer-surface);
|
||||
box-shadow:
|
||||
0 10px 28px rgba(22, 101, 52, 0.04),
|
||||
0 10px 28px var(--lime-shadow-color),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
@@ -52,32 +52,26 @@ export const InputBarContainer = styled.div`
|
||||
background 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgba(74, 222, 128, 0.65);
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f4fbf5 100%);
|
||||
border-color: var(--lime-composer-border-focus, rgba(74, 222, 128, 0.7));
|
||||
background: var(--lime-composer-surface-focus);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(74, 222, 128, 0.22),
|
||||
0 14px 32px rgba(22, 101, 52, 0.06),
|
||||
0 0 0 3px var(--lime-focus-ring, rgba(74, 222, 128, 0.24)),
|
||||
0 14px 32px var(--lime-shadow-color),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
&.file-dragging {
|
||||
border: 2px dashed #2ecc71;
|
||||
background-color: rgba(46, 204, 113, 0.03);
|
||||
border: 2px dashed var(--lime-brand, #10b981);
|
||||
background-color: var(--lime-brand-soft, #ecfdf5);
|
||||
}
|
||||
|
||||
&.floating-composer {
|
||||
border-radius: 34px;
|
||||
padding: 20px 24px 18px 22px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top right,
|
||||
rgba(220, 252, 231, 0.48),
|
||||
rgba(255, 255, 255, 0) 34%
|
||||
),
|
||||
linear-gradient(180deg, #ffffff 0%, #f7fcf8 100%);
|
||||
border-color: rgba(110, 231, 183, 0.84);
|
||||
background: var(--lime-composer-surface-floating);
|
||||
border-color: var(--lime-composer-border, rgba(110, 231, 183, 0.84));
|
||||
box-shadow:
|
||||
0 28px 56px -38px rgba(22, 101, 52, 0.24),
|
||||
0 28px 56px -38px var(--lime-shadow-color),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
@@ -87,11 +81,11 @@ export const InputBarContainer = styled.div`
|
||||
}
|
||||
|
||||
&.floating-composer:focus-within {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0fdf4 100%);
|
||||
border-color: rgba(74, 222, 128, 0.7);
|
||||
background: var(--lime-composer-surface-focus);
|
||||
border-color: var(--lime-composer-border-focus, rgba(74, 222, 128, 0.7));
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(74, 222, 128, 0.25),
|
||||
0 28px 56px -34px rgba(22, 101, 52, 0.24),
|
||||
0 0 0 3px var(--lime-focus-ring, rgba(74, 222, 128, 0.24)),
|
||||
0 28px 56px -34px var(--lime-shadow-color),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
`;
|
||||
@@ -220,8 +214,8 @@ export const MetaSelectWrap = styled.label`
|
||||
min-height: 28px;
|
||||
padding: 0 12px 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid var(--lime-surface-border, rgba(226, 240, 226, 0.82));
|
||||
background: var(--lime-surface, #ffffff);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition:
|
||||
@@ -245,10 +239,10 @@ export const MetaSelectWrap = styled.label`
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgba(167, 243, 208, 0.42);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-color: var(--lime-surface-border-strong, #bbf7d0);
|
||||
background: var(--lime-surface, #ffffff);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(167, 243, 208, 0.28),
|
||||
0 0 0 3px var(--lime-focus-ring, rgba(74, 222, 128, 0.24)),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
@@ -304,9 +298,13 @@ export const MetaToggleButton = styled.button<{
|
||||
border-radius: 999px;
|
||||
border: 1px solid
|
||||
${({ $checked }) =>
|
||||
$checked ? "rgba(167, 243, 208, 0.4)" : "rgba(148, 163, 184, 0.24)"};
|
||||
$checked
|
||||
? "var(--lime-surface-border-strong, #bbf7d0)"
|
||||
: "var(--lime-surface-border, rgba(226, 240, 226, 0.82))"};
|
||||
background: ${({ $checked }) =>
|
||||
$checked ? "rgba(240, 253, 244, 0.98)" : "rgba(255, 255, 255, 0.9)"};
|
||||
$checked
|
||||
? "var(--lime-brand-soft, #ecfdf5)"
|
||||
: "var(--lime-surface, #ffffff)"};
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
||||
color: ${({ $checked }) =>
|
||||
$checked ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"};
|
||||
@@ -320,18 +318,22 @@ export const MetaToggleButton = styled.button<{
|
||||
|
||||
&:hover {
|
||||
border-color: ${({ $checked }) =>
|
||||
$checked ? "rgba(167, 243, 208, 0.5)" : "rgba(148, 163, 184, 0.34)"};
|
||||
$checked
|
||||
? "var(--lime-surface-border-strong, #bbf7d0)"
|
||||
: "var(--lime-surface-border, rgba(226, 240, 226, 0.82))"};
|
||||
background: ${({ $checked }) =>
|
||||
$checked ? "rgba(220, 252, 231, 1)" : "rgba(255, 255, 255, 0.98)"};
|
||||
$checked
|
||||
? "var(--lime-brand-soft, #ecfdf5)"
|
||||
: "var(--lime-surface-hover, #f4fdf4)"};
|
||||
color: hsl(var(--foreground));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border-color: rgba(167, 243, 208, 0.44);
|
||||
border-color: var(--lime-surface-border-strong, #bbf7d0);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(167, 243, 208, 0.28),
|
||||
0 0 0 3px var(--lime-focus-ring, rgba(74, 222, 128, 0.24)),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
@@ -353,9 +355,13 @@ export const MetaToggleCheck = styled.span<{
|
||||
border-radius: 4px;
|
||||
border: 1px solid
|
||||
${({ $checked }) =>
|
||||
$checked ? "rgba(34, 197, 94, 0.45)" : "rgba(148, 163, 184, 0.42)"};
|
||||
$checked
|
||||
? "var(--lime-brand, #10b981)"
|
||||
: "var(--lime-surface-border, rgba(226, 240, 226, 0.82))"};
|
||||
background: ${({ $checked }) =>
|
||||
$checked ? "rgba(220, 252, 231, 0.9)" : "rgba(255, 255, 255, 0.96)"};
|
||||
$checked
|
||||
? "var(--lime-brand-soft, #ecfdf5)"
|
||||
: "var(--lime-surface, #ffffff)"};
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88);
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -428,9 +434,9 @@ export const ToolButton = styled.button`
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #14532d;
|
||||
border-color: rgba(167, 243, 208, 0.44);
|
||||
background: rgba(220, 252, 231, 0.82);
|
||||
color: var(--lime-brand-strong, #166534);
|
||||
border-color: var(--lime-surface-border-strong, #bbf7d0);
|
||||
background: var(--lime-brand-soft, #ecfdf5);
|
||||
}
|
||||
|
||||
span {
|
||||
@@ -463,7 +469,7 @@ export const InputIconButton = styled.button<{
|
||||
: "rgba(148, 163, 184, 0.28)"};
|
||||
background: ${({ $primary, $destructive }) =>
|
||||
$primary
|
||||
? "#1aa34e"
|
||||
? "var(--lime-brand, #10b981)"
|
||||
: $destructive
|
||||
? "rgba(255, 226, 234, 0.92)"
|
||||
: "rgba(255, 255, 255, 0.9)"};
|
||||
@@ -484,7 +490,7 @@ export const InputIconButton = styled.button<{
|
||||
transform: translateY(-1px);
|
||||
background: ${({ $primary, $destructive }) =>
|
||||
$primary
|
||||
? "#168840"
|
||||
? "var(--lime-brand-strong, #166534)"
|
||||
: $destructive
|
||||
? "rgba(255, 221, 229, 1)"
|
||||
: "rgba(255, 255, 255, 1)"};
|
||||
@@ -497,21 +503,21 @@ export const InputIconButton = styled.button<{
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: rgba(167, 243, 208, 0.44);
|
||||
background: rgba(220, 252, 231, 0.82);
|
||||
color: #14532d;
|
||||
border-color: var(--lime-surface-border-strong, #bbf7d0);
|
||||
background: var(--lime-brand-soft, #ecfdf5);
|
||||
color: var(--lime-brand-strong, #166534);
|
||||
}
|
||||
|
||||
&.is-recording {
|
||||
border-color: rgba(120, 235, 190, 0.6);
|
||||
background: rgba(120, 235, 190, 0.12);
|
||||
color: #14532d;
|
||||
border-color: var(--lime-brand, #10b981);
|
||||
background: var(--lime-brand-soft, #ecfdf5);
|
||||
color: var(--lime-brand-strong, #166534);
|
||||
}
|
||||
|
||||
&.is-processing {
|
||||
border-color: rgba(167, 243, 208, 0.6);
|
||||
background: rgba(167, 243, 208, 0.12);
|
||||
color: #14532d;
|
||||
border-color: var(--lime-surface-border-strong, #bbf7d0);
|
||||
background: var(--lime-brand-soft, #ecfdf5);
|
||||
color: var(--lime-brand-strong, #166534);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@@ -293,8 +293,7 @@ describe("MarkdownRenderer", () => {
|
||||
|
||||
it("应通过同目录 meta.json 将远程图片替换为本地下载资源", async () => {
|
||||
vi.mocked(fileBrowserModule.readFilePreview).mockResolvedValue({
|
||||
path:
|
||||
"/Users/coso/.lime/projects/default/exports/x-article/google/meta.json",
|
||||
path: "/Users/coso/.lime/projects/default/exports/x-article/google/meta.json",
|
||||
content: JSON.stringify({
|
||||
markdown_relative_path: "exports/x-article/google/index.md",
|
||||
images: [
|
||||
@@ -334,10 +333,13 @@ describe("MarkdownRenderer", () => {
|
||||
});
|
||||
|
||||
it("应归一化 ./ 和 ../ 相对图片路径并保留查询串", () => {
|
||||
const container = render("", {
|
||||
baseFilePath:
|
||||
"/Users/coso/.proxycast/projects/default/exports/x-article/google/nested/index.md",
|
||||
});
|
||||
const container = render(
|
||||
"",
|
||||
{
|
||||
baseFilePath:
|
||||
"/Users/coso/.proxycast/projects/default/exports/x-article/google/nested/index.md",
|
||||
},
|
||||
);
|
||||
|
||||
const image = container.querySelector("img");
|
||||
expect(image).not.toBeNull();
|
||||
@@ -428,7 +430,9 @@ describe("MarkdownRenderer", () => {
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MarkdownRenderer content={"## 小结\n\n这段正文应该和聊天正文保持同一字号与主色。"} />,
|
||||
<MarkdownRenderer
|
||||
content={"## 小结\n\n这段正文应该和聊天正文保持同一字号与主色。"}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -655,7 +655,8 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
|
||||
return "";
|
||||
}
|
||||
const normalizedSrc = src.trim();
|
||||
const overriddenSrc = bundleImageOverrides[normalizedSrc] || normalizedSrc;
|
||||
const overriddenSrc =
|
||||
bundleImageOverrides[normalizedSrc] || normalizedSrc;
|
||||
return resolveMarkdownImageSrc(overriddenSrc, baseFilePath);
|
||||
},
|
||||
[baseFilePath, bundleImageOverrides],
|
||||
|
||||
@@ -52,11 +52,13 @@ vi.mock("@/hooks/useConfiguredProviders", () => ({
|
||||
providers: MockConfiguredProvider[],
|
||||
selection?: string | null,
|
||||
) => {
|
||||
const selectedProvider =
|
||||
mockFindConfiguredProviderBySelection(providers, selection);
|
||||
const normalizedConfiguredType = (
|
||||
selectedProvider?.type || ""
|
||||
).trim().toLowerCase();
|
||||
const selectedProvider = mockFindConfiguredProviderBySelection(
|
||||
providers,
|
||||
selection,
|
||||
);
|
||||
const normalizedConfiguredType = (selectedProvider?.type || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedSelection = (selection || "").trim().toLowerCase();
|
||||
|
||||
if (normalizedConfiguredType === "anthropic-compatible") {
|
||||
@@ -333,9 +335,7 @@ describe("MessageList", () => {
|
||||
);
|
||||
expect(container.textContent).toContain("左侧会优先显示待继续的对话");
|
||||
expect(container.textContent).toContain("最近对话和归档会按时间自动整理");
|
||||
expect(container.textContent).toContain(
|
||||
"恢复中的会话会自动回到这里继续",
|
||||
);
|
||||
expect(container.textContent).toContain("恢复中的会话会自动回到这里继续");
|
||||
expect(container.textContent).not.toContain("开始一段新的对话吧");
|
||||
});
|
||||
|
||||
@@ -676,7 +676,9 @@ describe("MessageList", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="agent-task-strip"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-task-strip"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="assistant-message-meta-footer"]'),
|
||||
).not.toBeNull();
|
||||
@@ -1695,8 +1697,9 @@ describe("MessageList", () => {
|
||||
status: "complete",
|
||||
imageCount: 9,
|
||||
imageUrl: "https://example.com/storyboard-primary.png",
|
||||
previewImages: Array.from({ length: 9 }, (_, index) =>
|
||||
`https://example.com/storyboard-${index + 1}.png`,
|
||||
previewImages: Array.from(
|
||||
{ length: 9 },
|
||||
(_, index) => `https://example.com/storyboard-${index + 1}.png`,
|
||||
),
|
||||
layoutHint: "storyboard_3x3",
|
||||
projectId: "project-1",
|
||||
@@ -2328,7 +2331,7 @@ describe("MessageList", () => {
|
||||
type: "file_artifact",
|
||||
path: ".lime/artifacts/thread-1/report.artifact.json",
|
||||
source: "artifact_snapshot",
|
||||
content: "{\"schemaVersion\":\"artifact_document.v1\"}",
|
||||
content: '{"schemaVersion":"artifact_document.v1"}',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -3086,7 +3089,7 @@ describe("MessageList", () => {
|
||||
id: "artifact-hidden-conversation-artifact-json",
|
||||
type: "document",
|
||||
title: "report.artifact.json",
|
||||
content: "{\"schemaVersion\":\"artifact_document.v1\"}",
|
||||
content: '{"schemaVersion":"artifact_document.v1"}',
|
||||
status: "complete",
|
||||
meta: {
|
||||
filePath: ".lime/artifacts/thread-1/report.artifact.json",
|
||||
|
||||
@@ -74,9 +74,7 @@ import type {
|
||||
AgentRuntimeThreadReadModel,
|
||||
QueuedTurnSnapshot,
|
||||
} from "@/lib/api/agentRuntime";
|
||||
import {
|
||||
buildMessageTurnTimeline,
|
||||
} from "../utils/threadTimelineView";
|
||||
import { buildMessageTurnTimeline } from "../utils/threadTimelineView";
|
||||
import { buildMessageTurnGroups } from "../utils/messageTurnGrouping";
|
||||
import { resolveLatestProjectFileSavedSiteContentTargetFromMessage } from "../utils/latestSavedSiteContentTarget";
|
||||
import {
|
||||
@@ -208,7 +206,9 @@ const MessageRuntimeStatusPill: React.FC<{
|
||||
.slice(0, 2)
|
||||
.join(" · ");
|
||||
const tooltip = [titleText, detailText, checkpointsText]
|
||||
.filter((item, index, array) => Boolean(item) && array.indexOf(item) === index)
|
||||
.filter(
|
||||
(item, index, array) => Boolean(item) && array.indexOf(item) === index,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
@@ -573,10 +573,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
} | null>(null);
|
||||
const [renderedMessageCount, setRenderedMessageCount] = useState(() =>
|
||||
shouldUseProgressiveRender
|
||||
? Math.min(
|
||||
visibleMessages.length,
|
||||
MESSAGE_LIST_INITIAL_RENDER_COUNT,
|
||||
)
|
||||
? Math.min(visibleMessages.length, MESSAGE_LIST_INITIAL_RENDER_COUNT)
|
||||
: visibleMessages.length,
|
||||
);
|
||||
|
||||
@@ -614,10 +611,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
setRenderedMessageCount((current) =>
|
||||
Math.min(
|
||||
visibleMessages.length,
|
||||
Math.max(
|
||||
current + appendedCount,
|
||||
MESSAGE_LIST_INITIAL_RENDER_COUNT,
|
||||
),
|
||||
Math.max(current + appendedCount, MESSAGE_LIST_INITIAL_RENDER_COUNT),
|
||||
),
|
||||
);
|
||||
}, [
|
||||
@@ -677,10 +671,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
() =>
|
||||
hiddenHistoryCount > 0
|
||||
? turns.slice(
|
||||
-Math.max(
|
||||
renderedMessageCount,
|
||||
MESSAGE_LIST_INITIAL_RENDER_COUNT,
|
||||
),
|
||||
-Math.max(renderedMessageCount, MESSAGE_LIST_INITIAL_RENDER_COUNT),
|
||||
)
|
||||
: turns,
|
||||
[hiddenHistoryCount, renderedMessageCount, turns],
|
||||
@@ -776,7 +767,8 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
() =>
|
||||
messageGroups.map((group) => {
|
||||
const lastAssistantId =
|
||||
group.assistantMessages[group.assistantMessages.length - 1]?.id ?? null;
|
||||
group.assistantMessages[group.assistantMessages.length - 1]?.id ??
|
||||
null;
|
||||
const mappedTimeline =
|
||||
group.assistantMessages
|
||||
.map((message) => timelineByMessageId.get(message.id))
|
||||
@@ -785,8 +777,11 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
Boolean(lastAssistantId) &&
|
||||
currentTurnTimeline?.messageId === lastAssistantId;
|
||||
const isActiveGroup =
|
||||
Boolean(lastAssistantId) && lastAssistantId === lastAssistantMessageId;
|
||||
const timeline = isCurrentTurnGroup ? currentTurnTimeline : mappedTimeline;
|
||||
Boolean(lastAssistantId) &&
|
||||
lastAssistantId === lastAssistantMessageId;
|
||||
const timeline = isCurrentTurnGroup
|
||||
? currentTurnTimeline
|
||||
: mappedTimeline;
|
||||
|
||||
return {
|
||||
...group,
|
||||
@@ -817,7 +812,9 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
}
|
||||
|
||||
const hasRunningToolCall =
|
||||
(message.toolCalls || []).some((toolCall) => toolCall.status === "running") ||
|
||||
(message.toolCalls || []).some(
|
||||
(toolCall) => toolCall.status === "running",
|
||||
) ||
|
||||
(message.contentParts || []).some(
|
||||
(part) =>
|
||||
part.type === "tool_use" && part.toolCall.status === "running",
|
||||
@@ -1115,9 +1112,9 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
const shouldCollapseAssistantShell =
|
||||
msg.role === "assistant" &&
|
||||
!hasVisibleAssistantText &&
|
||||
!(conversationContentParts?.length) &&
|
||||
!conversationContentParts?.length &&
|
||||
!conversationThinkingContent?.trim() &&
|
||||
!(conversationToolCalls?.length) &&
|
||||
!conversationToolCalls?.length &&
|
||||
!((msg.actionRequests || []).length > 0) &&
|
||||
!primaryTimeline &&
|
||||
!trailingTimeline &&
|
||||
@@ -1544,8 +1541,8 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
为了更快打开旧对话,当前先展示最近{" "}
|
||||
{sessionHistoryWindow?.loadedMessages ?? renderedMessages.length} /{" "}
|
||||
{sessionHistoryWindow?.totalMessages ?? renderedMessages.length}{" "}
|
||||
{sessionHistoryWindow?.loadedMessages ?? renderedMessages.length}{" "}
|
||||
/ {sessionHistoryWindow?.totalMessages ?? renderedMessages.length}{" "}
|
||||
条消息。
|
||||
{sessionHistoryWindow?.error ? (
|
||||
<span className="ml-2 text-red-600">
|
||||
@@ -1577,8 +1574,8 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200/80 bg-slate-50/80 px-4 py-3 text-sm text-slate-600"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
为了更快打开对话,当前先展示最近 {renderedMessages.length} 条消息,
|
||||
更早的 {hiddenHistoryCount} 条会在空闲时继续补齐。
|
||||
为了更快打开对话,当前先展示最近 {renderedMessages.length}{" "}
|
||||
条消息, 更早的 {hiddenHistoryCount} 条会在空闲时继续补齐。
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -45,7 +45,8 @@ function render(text: string): HTMLDivElement {
|
||||
|
||||
describe("RuntimePeerMessageCards", () => {
|
||||
it("应把计划审批请求渲染为专门卡片", () => {
|
||||
const container = render(`<teammate-message teammate_id="team-lead" summary="等待审批">
|
||||
const container =
|
||||
render(`<teammate-message teammate_id="team-lead" summary="等待审批">
|
||||
{"type":"plan_approval_request","from":"researcher","plan_file_path":"plans/alpha.md","plan_content":"# 计划\\n- 第一步"}
|
||||
</teammate-message>`);
|
||||
|
||||
@@ -71,7 +72,8 @@ describe("RuntimePeerMessageCards", () => {
|
||||
});
|
||||
|
||||
it("应在任务分配卡片里显示 assignedBy", () => {
|
||||
const container = render(`<teammate-message teammate_id="worker-1" summary="等待执行">
|
||||
const container =
|
||||
render(`<teammate-message teammate_id="worker-1" summary="等待执行">
|
||||
{"type":"task_assignment","taskId":"task-7","subject":"对齐 current surface","description":"补齐 display 语义","assignedBy":"team-lead"}
|
||||
</teammate-message>`);
|
||||
|
||||
@@ -84,20 +86,25 @@ describe("RuntimePeerMessageCards", () => {
|
||||
});
|
||||
|
||||
it("应静默 shutdown approved 和 idle lifecycle 消息", () => {
|
||||
const approvedContainer = render(`<teammate-message teammate_id="researcher">
|
||||
const approvedContainer =
|
||||
render(`<teammate-message teammate_id="researcher">
|
||||
{"type":"shutdown_approved","request_id":"req-1","from":"researcher"}
|
||||
</teammate-message>`);
|
||||
const mixedContainer = render([
|
||||
`<teammate-message teammate_id="researcher">
|
||||
const mixedContainer = render(
|
||||
[
|
||||
`<teammate-message teammate_id="researcher">
|
||||
{"type":"idle_notification","completedTaskId":"task-1","completedStatus":"completed","summary":"等待新任务"}
|
||||
</teammate-message>`,
|
||||
`<teammate-message teammate_id="researcher">
|
||||
`<teammate-message teammate_id="researcher">
|
||||
{"type":"task_completed","taskId":"task-2","taskSubject":"收口 peer message"}
|
||||
</teammate-message>`,
|
||||
].join("\n"));
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(
|
||||
approvedContainer.querySelector('[data-testid="runtime-peer-message-cards"]'),
|
||||
approvedContainer.querySelector(
|
||||
'[data-testid="runtime-peer-message-cards"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(approvedContainer.textContent).toBe("");
|
||||
expect(mixedContainer.textContent).toContain("任务完成");
|
||||
|
||||
@@ -122,7 +122,9 @@ function resolveEnvelopeTone(
|
||||
function renderEnvelopeDetail(body: RuntimePeerMessageBody): React.ReactNode {
|
||||
switch (body.kind) {
|
||||
case "plain":
|
||||
return body.markdown ? <MarkdownRenderer content={body.markdown} /> : null;
|
||||
return body.markdown ? (
|
||||
<MarkdownRenderer content={body.markdown} />
|
||||
) : null;
|
||||
case "shutdown_request":
|
||||
return body.reason ? (
|
||||
<div className="text-sm leading-6 text-slate-700 dark:text-slate-200">
|
||||
@@ -259,7 +261,9 @@ function RuntimePeerMessageCard({
|
||||
<span>来自 {envelope.sender}</span>
|
||||
{envelope.summary ? <span>{envelope.summary}</span> : null}
|
||||
</div>
|
||||
<div className="mt-3 min-w-0">{renderEnvelopeDetail(envelope.body)}</div>
|
||||
<div className="mt-3 min-w-0">
|
||||
{renderEnvelopeDetail(envelope.body)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -371,9 +371,7 @@ describe("StreamingRenderer", () => {
|
||||
|
||||
const markdownButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) =>
|
||||
button.textContent?.includes("在下方预览导出 Markdown"),
|
||||
);
|
||||
).find((button) => button.textContent?.includes("在下方预览导出 Markdown"));
|
||||
expect(
|
||||
container.querySelector('[data-testid="inline-tool-process-step"]'),
|
||||
).toBeTruthy();
|
||||
@@ -422,9 +420,7 @@ describe("StreamingRenderer", () => {
|
||||
|
||||
const markdownButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) =>
|
||||
button.textContent?.includes("在下方预览导出 Markdown"),
|
||||
);
|
||||
).find((button) => button.textContent?.includes("在下方预览导出 Markdown"));
|
||||
expect(markdownButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
@@ -862,7 +858,9 @@ describe("StreamingRenderer", () => {
|
||||
|
||||
expect(mockMarkdownRenderer).toHaveBeenCalled();
|
||||
const latestCall =
|
||||
mockMarkdownRenderer.mock.calls[mockMarkdownRenderer.mock.calls.length - 1];
|
||||
mockMarkdownRenderer.mock.calls[
|
||||
mockMarkdownRenderer.mock.calls.length - 1
|
||||
];
|
||||
expect(latestCall?.[0]?.content).toBe(
|
||||
"目录也不存在。可能整个 .lime 目录都不存在。",
|
||||
);
|
||||
|
||||
@@ -7,11 +7,7 @@
|
||||
|
||||
import React, { memo, useMemo, useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { ChevronDown, ExternalLink, FileText } from "lucide-react";
|
||||
import { useDebouncedValue } from "@/lib/artifact/hooks/useDebouncedValue";
|
||||
import { MarkdownRenderer } from "./MarkdownRenderer";
|
||||
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
|
||||
@@ -699,9 +695,7 @@ type StreamingProcessEntry =
|
||||
actionRequired: ActionRequired;
|
||||
};
|
||||
|
||||
function buildStreamingProcessSummary(
|
||||
entries: StreamingProcessEntry[],
|
||||
): {
|
||||
function buildStreamingProcessSummary(entries: StreamingProcessEntry[]): {
|
||||
summaryText: string;
|
||||
descriptor: ToolBatchSummaryDescriptor | null;
|
||||
metaText: string | null;
|
||||
@@ -744,7 +738,9 @@ function buildStreamingProcessSummary(
|
||||
|
||||
const prompt = entry.actionRequired.prompt?.trim();
|
||||
if (prompt) {
|
||||
return prompt.length <= 72 ? prompt : `${prompt.slice(0, 71).trimEnd()}…`;
|
||||
return prompt.length <= 72
|
||||
? prompt
|
||||
: `${prompt.slice(0, 71).trimEnd()}…`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1272,16 +1268,17 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
(entry) => entry.kind === "tool",
|
||||
).length;
|
||||
if (toolCount > 0 && entries.length > 1) {
|
||||
const batchDescriptor =
|
||||
entries.every((entry) => entry.kind === "tool")
|
||||
? summarizeStreamingToolBatch(
|
||||
entries.map(
|
||||
(entry) =>
|
||||
(entry as Extract<StreamingProcessEntry, { kind: "tool" }>)
|
||||
.toolCall,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
const batchDescriptor = entries.every(
|
||||
(entry) => entry.kind === "tool",
|
||||
)
|
||||
? summarizeStreamingToolBatch(
|
||||
entries.map(
|
||||
(entry) =>
|
||||
(entry as Extract<StreamingProcessEntry, { kind: "tool" }>)
|
||||
.toolCall,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<StreamingProcessGroup
|
||||
key={key}
|
||||
|
||||
@@ -81,8 +81,16 @@ describe("TaskCenterTabStrip", () => {
|
||||
) as HTMLElement | null;
|
||||
expect(strip).toBeTruthy();
|
||||
expect(strip?.className).toContain("z-10");
|
||||
expect(strip?.className).toContain("min-h-[34px]");
|
||||
expect(strip?.style.backgroundColor).toBe("rgb(248, 252, 249)");
|
||||
expect(strip?.className).toContain("min-h-[42px]");
|
||||
expect(strip?.className).toContain(
|
||||
"bg-[color:var(--lime-chrome-tab-active-surface)]",
|
||||
);
|
||||
expect(
|
||||
strip?.style.getPropertyValue("--task-center-tab-strip-background"),
|
||||
).toContain("--lime-chrome-stage-blend");
|
||||
expect(
|
||||
strip?.style.getPropertyValue("--task-center-tab-strip-seam"),
|
||||
).toContain("--lime-chrome-stage-seam");
|
||||
expect(strip?.className).not.toContain("bg-[#fbfdfb]");
|
||||
expect(strip?.className).not.toContain("ml-[");
|
||||
expect(container.textContent).toContain("任务 A");
|
||||
@@ -95,14 +103,17 @@ describe("TaskCenterTabStrip", () => {
|
||||
'[data-testid="task-center-tab-topic-a"]',
|
||||
) as HTMLElement | null;
|
||||
expect(activeTab?.getAttribute("data-active")).toBe("true");
|
||||
expect(activeTab?.style.backgroundColor).toBe("rgb(238, 243, 239)");
|
||||
expect(activeTab?.className).toContain("border-slate-200/80");
|
||||
expect(activeTab?.className).toContain(
|
||||
"bg-[color:var(--lime-chrome-tab-hover)]",
|
||||
);
|
||||
expect(activeTab?.className).toContain(
|
||||
"border-[color:var(--lime-chrome-divider)]",
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-center-tab-unread-topic-a"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-testid="task-center-tab-close-topic-b"]')
|
||||
container.querySelector('[data-testid="task-center-tab-close-topic-b"]')
|
||||
?.className,
|
||||
).toContain("group-hover:opacity-100");
|
||||
});
|
||||
@@ -139,6 +150,32 @@ describe("TaskCenterTabStrip", () => {
|
||||
expect(onSelectTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("不可关闭标签不应渲染关闭按钮", () => {
|
||||
const { container } = renderTabStrip({
|
||||
items: [
|
||||
{
|
||||
id: "new-task-home",
|
||||
title: "新对话",
|
||||
status: "draft",
|
||||
updatedAt: new Date("2026-04-24T10:00:00.000Z"),
|
||||
isActive: true,
|
||||
hasUnread: false,
|
||||
isPinned: false,
|
||||
closable: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-center-tab-new-task-home"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-center-tab-close-new-task-home"]',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("点击加号应触发新建对话回调", () => {
|
||||
const onCreateTask = vi.fn();
|
||||
const { container } = renderTabStrip({ onCreateTask });
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { TaskStatus } from "../hooks/agentChatShared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
TASK_CENTER_CHROME_ACTIVE_TAB,
|
||||
TASK_CENTER_CHROME_SURFACE,
|
||||
TASK_CENTER_CHROME_STAGE_BLEND,
|
||||
TASK_CENTER_CHROME_STAGE_SEAM,
|
||||
} from "../workspace/taskCenterChromeTokens";
|
||||
|
||||
const TASK_CENTER_TAB_STATUS_META: Record<
|
||||
@@ -20,23 +21,23 @@ const TASK_CENTER_TAB_STATUS_META: Record<
|
||||
> = {
|
||||
draft: {
|
||||
label: "待补充",
|
||||
iconClassName: "text-slate-500",
|
||||
iconClassName: "text-[color:var(--lime-text-muted)]",
|
||||
},
|
||||
running: {
|
||||
label: "进行中",
|
||||
iconClassName: "text-sky-500",
|
||||
iconClassName: "text-[color:var(--lime-info)]",
|
||||
},
|
||||
waiting: {
|
||||
label: "待继续",
|
||||
iconClassName: "text-amber-500",
|
||||
iconClassName: "text-[color:var(--lime-warning)]",
|
||||
},
|
||||
done: {
|
||||
label: "已完成",
|
||||
iconClassName: "text-emerald-500",
|
||||
iconClassName: "text-[color:var(--lime-brand-strong)]",
|
||||
},
|
||||
failed: {
|
||||
label: "有异常",
|
||||
iconClassName: "text-rose-500",
|
||||
iconClassName: "text-[color:var(--lime-danger)]",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -48,6 +49,7 @@ export interface TaskCenterTabItem {
|
||||
isActive: boolean;
|
||||
hasUnread: boolean;
|
||||
isPinned: boolean;
|
||||
closable?: boolean;
|
||||
}
|
||||
|
||||
interface TaskCenterTabStripProps {
|
||||
@@ -64,29 +66,39 @@ interface TaskCenterTabStripProps {
|
||||
|
||||
function formatTaskTabTitle(item: TaskCenterTabItem): string {
|
||||
const statusMeta =
|
||||
TASK_CENTER_TAB_STATUS_META[item.status] ?? TASK_CENTER_TAB_STATUS_META.done;
|
||||
TASK_CENTER_TAB_STATUS_META[item.status] ??
|
||||
TASK_CENTER_TAB_STATUS_META.done;
|
||||
return `${item.title} · ${statusMeta.label} · 更新于 ${item.updatedAt.toLocaleString(
|
||||
"zh-CN",
|
||||
)}`;
|
||||
}
|
||||
|
||||
const conversationTabShellClassName =
|
||||
"group flex h-[26px] items-center gap-0 rounded-[13px] border border-transparent px-1 transition-[background-color,border-color,box-shadow,color] duration-150 ease-out";
|
||||
"group flex h-[28px] items-center gap-0 rounded-[14px] border border-transparent px-1 transition-[background-color,border-color,box-shadow,color] duration-150 ease-out";
|
||||
|
||||
const activeConversationTabClassName =
|
||||
"border-slate-200/80 text-slate-950 shadow-[0_4px_12px_-9px_rgba(15,23,42,0.32),inset_0_1px_0_rgba(255,255,255,0.9)] dark:bg-slate-700 dark:text-slate-100";
|
||||
"border-[color:var(--lime-chrome-divider)] bg-[color:var(--lime-chrome-tab-hover)] text-[color:var(--lime-text-strong)] shadow-[inset_0_1px_0_rgba(255,255,255,0.5)] dark:bg-slate-700 dark:text-slate-100";
|
||||
|
||||
const inactiveConversationTabClassName =
|
||||
"bg-transparent text-slate-500 hover:bg-white/58 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200";
|
||||
"bg-transparent text-[color:var(--lime-chrome-muted)] hover:bg-[color:var(--lime-chrome-tab-hover)] hover:text-[color:var(--lime-text)] dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200";
|
||||
|
||||
const conversationTabButtonClassName =
|
||||
"flex h-full min-w-0 items-center gap-1 rounded-[12px] px-1.5 text-left";
|
||||
"flex h-full min-w-0 items-center gap-1 rounded-[13px] px-1.5 text-left";
|
||||
|
||||
const tabUtilityButtonClassName =
|
||||
"inline-flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-[13px] bg-transparent text-slate-500 transition-colors hover:bg-white/60 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-white/10 dark:hover:text-slate-200";
|
||||
"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[14px] bg-transparent text-[color:var(--lime-chrome-muted)] transition-colors hover:bg-[color:var(--lime-chrome-tab-hover)] hover:text-[color:var(--lime-text)] dark:text-slate-400 dark:hover:bg-white/10 dark:hover:text-slate-200";
|
||||
|
||||
const tabWorkbenchButtonClassName =
|
||||
"inline-flex h-[26px] shrink-0 items-center gap-1 rounded-[13px] border border-transparent px-2 text-[11px] font-medium text-slate-600 transition-[background-color,border-color,box-shadow,color] hover:bg-white/60 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-white/10 dark:hover:text-slate-100";
|
||||
"inline-flex h-7 shrink-0 items-center gap-1 rounded-[14px] border border-transparent px-2 text-[11px] font-medium text-[color:var(--lime-text-muted)] transition-[background-color,border-color,box-shadow,color] hover:bg-[color:var(--lime-chrome-tab-hover)] hover:text-[color:var(--lime-text)] dark:text-slate-400 dark:hover:bg-white/10 dark:hover:text-slate-100";
|
||||
|
||||
const taskCenterTabStripStyle = {
|
||||
"--task-center-tab-strip-background": TASK_CENTER_CHROME_STAGE_BLEND,
|
||||
"--task-center-tab-strip-seam": TASK_CENTER_CHROME_STAGE_SEAM,
|
||||
background: "var(--task-center-tab-strip-background)",
|
||||
borderBottomColor: "var(--task-center-tab-strip-seam)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 rgba(255, 255, 255, 0.34), 0 12px 24px -34px var(--lime-shadow-color)",
|
||||
} as CSSProperties;
|
||||
|
||||
export function TaskCenterTabStrip({
|
||||
items,
|
||||
@@ -103,9 +115,9 @@ export function TaskCenterTabStrip({
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative z-10 -mt-px min-h-[34px] shrink-0 border-b border-slate-200/60 px-5 pb-1.5 pt-0.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]"
|
||||
className="relative z-10 min-h-[42px] shrink-0 border-b border-[color:var(--lime-chrome-divider)] bg-[color:var(--lime-chrome-tab-active-surface)] px-4 pb-2 pt-1.5"
|
||||
data-testid="task-center-tab-strip"
|
||||
style={{ backgroundColor: TASK_CENTER_CHROME_SURFACE }}
|
||||
style={taskCenterTabStripStyle}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="min-w-0 flex-1 overflow-x-auto [scrollbar-width:none]">
|
||||
@@ -126,11 +138,6 @@ export function TaskCenterTabStrip({
|
||||
)}
|
||||
data-testid={`task-center-tab-${item.id}`}
|
||||
data-active={item.isActive ? "true" : "false"}
|
||||
style={
|
||||
item.isActive
|
||||
? { backgroundColor: TASK_CENTER_CHROME_ACTIVE_TAB }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -142,7 +149,10 @@ export function TaskCenterTabStrip({
|
||||
}}
|
||||
>
|
||||
<MessageSquareText
|
||||
className={cn("h-3.5 w-3.5 shrink-0", statusMeta.iconClassName)}
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
statusMeta.iconClassName,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate text-[11px] font-semibold">
|
||||
@@ -150,35 +160,37 @@ export function TaskCenterTabStrip({
|
||||
</span>
|
||||
{item.hasUnread ? (
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full bg-sky-500"
|
||||
className="h-2 w-2 shrink-0 rounded-full bg-[color:var(--lime-brand)]"
|
||||
data-testid={`task-center-tab-unread-${item.id}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{item.isPinned ? (
|
||||
<Pin
|
||||
className="h-2.5 w-2.5 shrink-0 text-slate-400"
|
||||
className="h-2.5 w-2.5 shrink-0 text-[color:var(--lime-text-muted)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"mr-1 rounded-full p-1 text-slate-400 transition hover:bg-black/5 hover:text-slate-800 focus-visible:text-slate-800 dark:hover:bg-white/10 dark:hover:text-slate-200",
|
||||
item.isActive
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
aria-label={`关闭 ${item.title}`}
|
||||
data-testid={`task-center-tab-close-${item.id}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void onCloseTask(item.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
{item.closable === false ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"mr-1 rounded-full p-1 text-[color:var(--lime-text-muted)] transition hover:bg-[color:var(--lime-chrome-tab-hover)] hover:text-[color:var(--lime-text)] focus-visible:text-[color:var(--lime-text)] dark:hover:bg-white/10 dark:hover:text-slate-200",
|
||||
item.isActive
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
aria-label={`关闭 ${item.title}`}
|
||||
data-testid={`task-center-tab-close-${item.id}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void onCloseTask(item.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -198,7 +210,7 @@ export function TaskCenterTabStrip({
|
||||
|
||||
{showToolbarActions ? (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1 border-l border-slate-200/80 pl-1.5 dark:border-slate-700/80"
|
||||
className="flex shrink-0 items-center gap-1 border-l border-[color:var(--lime-chrome-divider)] pl-1.5 dark:border-slate-700/80"
|
||||
data-testid="task-center-tab-toolbar"
|
||||
>
|
||||
{showHistoryToggle ? (
|
||||
@@ -219,7 +231,7 @@ export function TaskCenterTabStrip({
|
||||
className={cn(
|
||||
tabWorkbenchButtonClassName,
|
||||
isCanvasOpen &&
|
||||
"border-slate-200/70 bg-white text-slate-900 shadow-[0_2px_8px_-6px_rgba(15,23,42,0.26),inset_0_1px_0_rgba(255,255,255,0.94)] dark:bg-slate-700 dark:text-slate-100",
|
||||
"border-[color:var(--lime-chrome-border)] bg-[color:var(--lime-chrome-tab-active-surface)] text-[color:var(--lime-text)] shadow-[inset_0_1px_0_rgba(255,255,255,0.72)] dark:bg-slate-700 dark:text-slate-100",
|
||||
)}
|
||||
data-testid="task-center-tab-workbench"
|
||||
aria-label={isCanvasOpen ? "收起工作台" : "展开工作台"}
|
||||
|
||||
@@ -128,7 +128,8 @@ export function TeamWorkbenchSummaryPanel({
|
||||
label: normalizeTeamWorkspaceDisplayValue(role.label) || role.label,
|
||||
roleKey: role.roleKey,
|
||||
profileId: role.profileId,
|
||||
summary: normalizeTeamWorkspaceDisplayValue(role.summary) || role.summary,
|
||||
summary:
|
||||
normalizeTeamWorkspaceDisplayValue(role.summary) || role.summary,
|
||||
skillIds: role.skillIds ?? [],
|
||||
statusMeta: null,
|
||||
}));
|
||||
|
||||
@@ -790,6 +790,8 @@ describe("TeamWorkspaceBoard", () => {
|
||||
);
|
||||
|
||||
expect(embeddedShell?.className).toContain("pointer-events-auto");
|
||||
expect(embeddedShell?.className).toContain("lime-workbench-theme-scope");
|
||||
expect(embeddedShell?.className).toContain("lime-workbench-surface-scope");
|
||||
expect(embeddedShell?.className).toContain("overflow-hidden");
|
||||
expect(embeddedShell?.className).toContain("flex-col");
|
||||
expect(embeddedShell?.className).toContain("rounded-[24px]");
|
||||
|
||||
@@ -111,10 +111,7 @@ export function TeamWorkspaceBoard({
|
||||
teamDispatchPreviewState,
|
||||
});
|
||||
const { isEmptyShellState } = sessionGraphState;
|
||||
const {
|
||||
emptyShellProps,
|
||||
shellProps,
|
||||
} = useTeamWorkspaceBoardComposer({
|
||||
const { emptyShellProps, shellProps } = useTeamWorkspaceBoardComposer({
|
||||
activityRefreshVersionBySessionId,
|
||||
canvasViewportFallbackHeight,
|
||||
className,
|
||||
|
||||
@@ -8,11 +8,13 @@ import { TeamWorkspaceDock } from "./TeamWorkspaceDock";
|
||||
const { mockGetAgentRuntimeSession } = vi.hoisted(() => ({
|
||||
mockGetAgentRuntimeSession: vi.fn(),
|
||||
}));
|
||||
const { mockSafeInvoke, mockSafeListen, mockParseAgentEvent } = vi.hoisted(() => ({
|
||||
mockSafeInvoke: vi.fn(),
|
||||
mockSafeListen: vi.fn(),
|
||||
mockParseAgentEvent: vi.fn((payload: unknown) => payload),
|
||||
}));
|
||||
const { mockSafeInvoke, mockSafeListen, mockParseAgentEvent } = vi.hoisted(
|
||||
() => ({
|
||||
mockSafeInvoke: vi.fn(),
|
||||
mockSafeListen: vi.fn(),
|
||||
mockParseAgentEvent: vi.fn((payload: unknown) => payload),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/api/agentRuntime", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/agentRuntime")>(
|
||||
@@ -502,6 +504,8 @@ describe("TeamWorkspaceDock", () => {
|
||||
expect(dock).toBeTruthy();
|
||||
expect(panelInContainer).toBeNull();
|
||||
expect(panelInBody).toBeTruthy();
|
||||
expect(panelInBody?.className).toContain("lime-workbench-theme-scope");
|
||||
expect(panelInBody?.className).toContain("lime-workbench-surface-scope");
|
||||
expect(getComputedStyle(dock as HTMLElement).position).toBe("relative");
|
||||
expect(getComputedStyle(dock as HTMLElement).zIndex).toBe("120");
|
||||
expect(getComputedStyle(panelInBody as HTMLElement).position).toBe("fixed");
|
||||
|
||||
@@ -124,12 +124,12 @@ const DockPanel = styled.div<{
|
||||
|
||||
const dockAttentionPulse = keyframes`
|
||||
0%, 100% {
|
||||
box-shadow: 0 16px 40px -28px rgba(15, 23, 42, 0.28);
|
||||
box-shadow: 0 16px 40px -28px var(--lime-shadow-color, rgba(15, 23, 42, 0.18));
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 18px 46px -28px rgba(15, 23, 42, 0.3),
|
||||
0 0 0 8px rgba(56, 189, 248, 0.08);
|
||||
0 18px 46px -28px var(--lime-shadow-color, rgba(15, 23, 42, 0.2)),
|
||||
0 0 0 8px color-mix(in srgb, var(--lime-info, #0ea5e9) 12%, transparent);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -164,21 +164,21 @@ const DockToggle = styled.button<{
|
||||
border: 1px solid
|
||||
${({ $active, $expanded }) =>
|
||||
$expanded
|
||||
? "rgba(14, 116, 144, 0.24)"
|
||||
? "var(--lime-info-border, rgba(14, 116, 144, 0.24))"
|
||||
: $active
|
||||
? "rgba(186, 230, 253, 0.96)"
|
||||
: "rgba(203, 213, 225, 0.9)"};
|
||||
? "var(--lime-info-border, rgba(186, 230, 253, 0.96))"
|
||||
: "var(--lime-surface-border, rgba(203, 213, 225, 0.9))"};
|
||||
background: ${({ $launcherOnly, $expanded }) =>
|
||||
$launcherOnly
|
||||
? "rgba(255, 255, 255, 0.98)"
|
||||
? "var(--lime-surface, rgba(255, 255, 255, 0.98))"
|
||||
: $expanded
|
||||
? "#ffffff"
|
||||
: "#f8fafc"};
|
||||
color: #0f172a;
|
||||
? "var(--lime-surface, #ffffff)"
|
||||
: "var(--lime-surface-soft, #f8fafc)"};
|
||||
color: var(--lime-text-strong, #0f172a);
|
||||
box-shadow: ${({ $launcherOnly }) =>
|
||||
$launcherOnly
|
||||
? "0 10px 24px -18px rgba(15, 23, 42, 0.18)"
|
||||
: "0 12px 28px -24px rgba(15, 23, 42, 0.22)"};
|
||||
? "0 10px 24px -18px var(--lime-shadow-color, rgba(15, 23, 42, 0.18))"
|
||||
: "0 12px 28px -24px var(--lime-shadow-color, rgba(15, 23, 42, 0.22))"};
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
@@ -201,8 +201,8 @@ const DockToggle = styled.button<{
|
||||
transform: translateY(-1px);
|
||||
box-shadow: ${({ $launcherOnly }) =>
|
||||
$launcherOnly
|
||||
? "0 12px 28px -18px rgba(15, 23, 42, 0.22)"
|
||||
: "0 14px 30px -24px rgba(15, 23, 42, 0.24)"};
|
||||
? "0 12px 28px -18px var(--lime-shadow-color, rgba(15, 23, 42, 0.22))"
|
||||
: "0 14px 30px -24px var(--lime-shadow-color, rgba(15, 23, 42, 0.24))"};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -217,17 +217,19 @@ const DockIconShell = styled.span<{
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.9));
|
||||
background: ${({ $launcherOnly }) =>
|
||||
$launcherOnly ? "#ffffff" : "rgba(255, 255, 255, 0.92)"};
|
||||
color: #475569;
|
||||
$launcherOnly
|
||||
? "var(--lime-surface, #ffffff)"
|
||||
: "var(--lime-surface, rgba(255, 255, 255, 0.92))"};
|
||||
color: var(--lime-text-muted, #475569);
|
||||
|
||||
${({ $attention }) =>
|
||||
$attention
|
||||
? css`
|
||||
color: #0369a1;
|
||||
border-color: rgba(125, 211, 252, 0.72);
|
||||
background: #eff6ff;
|
||||
color: var(--lime-info, #0369a1);
|
||||
border-color: var(--lime-info-border, rgba(125, 211, 252, 0.72));
|
||||
background: var(--lime-info-soft, #eff6ff);
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
@@ -249,7 +251,7 @@ const DockSummaryStack = styled.span`
|
||||
|
||||
const DockSummaryEyebrow = styled.span`
|
||||
min-width: 0;
|
||||
color: #64748b;
|
||||
color: var(--lime-text-muted, #64748b);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
@@ -269,7 +271,7 @@ const DockPrimaryText = styled.span<{ $launcherOnly: boolean }>`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #0f172a;
|
||||
color: var(--lime-text-strong, #0f172a);
|
||||
font-size: ${({ $launcherOnly }) => ($launcherOnly ? "13px" : "14px")};
|
||||
font-weight: 700;
|
||||
`;
|
||||
@@ -288,28 +290,28 @@ const DockStatusBadge = styled.span<{
|
||||
border: 1px solid
|
||||
${({ $tone }) =>
|
||||
$tone === "success"
|
||||
? "rgba(167, 243, 208, 0.72)"
|
||||
? "var(--lime-surface-border-strong, rgba(167, 243, 208, 0.72))"
|
||||
: $tone === "error"
|
||||
? "rgba(254, 205, 211, 0.72)"
|
||||
? "var(--lime-danger-border, rgba(254, 205, 211, 0.72))"
|
||||
: $tone === "active"
|
||||
? "rgba(186, 230, 253, 0.72)"
|
||||
: "rgba(226, 232, 240, 0.88)"};
|
||||
? "var(--lime-info-border, rgba(186, 230, 253, 0.72))"
|
||||
: "var(--lime-surface-border, rgba(226, 232, 240, 0.88))"};
|
||||
background: ${({ $tone }) =>
|
||||
$tone === "success"
|
||||
? "rgba(236, 253, 245, 0.86)"
|
||||
? "var(--lime-brand-soft, rgba(236, 253, 245, 0.86))"
|
||||
: $tone === "error"
|
||||
? "rgba(255, 241, 242, 0.86)"
|
||||
? "var(--lime-danger-soft, rgba(255, 241, 242, 0.86))"
|
||||
: $tone === "active"
|
||||
? "rgba(240, 249, 255, 0.86)"
|
||||
: "rgba(248, 250, 252, 0.92)"};
|
||||
? "var(--lime-info-soft, rgba(240, 249, 255, 0.86))"
|
||||
: "var(--lime-surface-soft, rgba(248, 250, 252, 0.92))"};
|
||||
color: ${({ $tone }) =>
|
||||
$tone === "success"
|
||||
? "#047857"
|
||||
? "var(--lime-brand-strong, #047857)"
|
||||
: $tone === "error"
|
||||
? "#be123c"
|
||||
? "var(--lime-danger, #be123c)"
|
||||
: $tone === "active"
|
||||
? "#0369a1"
|
||||
: "#64748b"};
|
||||
? "var(--lime-info, #0369a1)"
|
||||
: "var(--lime-text-muted, #64748b)"};
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
@@ -320,12 +322,12 @@ const DockStatusBadge = styled.span<{
|
||||
border-radius: 999px;
|
||||
background: ${({ $tone }) =>
|
||||
$tone === "success"
|
||||
? "#10b981"
|
||||
? "var(--lime-brand, #10b981)"
|
||||
: $tone === "error"
|
||||
? "#f43f5e"
|
||||
? "var(--lime-danger, #f43f5e)"
|
||||
: $tone === "active"
|
||||
? "#0ea5e9"
|
||||
: "#94a3b8"};
|
||||
? "var(--lime-info, #0ea5e9)"
|
||||
: "var(--lime-text-muted, #94a3b8)"};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -337,15 +339,16 @@ const DockSignal = styled.span`
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
border-radius: 999px;
|
||||
background: #0ea5e9;
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.12);
|
||||
background: var(--lime-info, #0ea5e9);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--lime-info, #0ea5e9) 14%, transparent);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: rgba(14, 165, 233, 0.36);
|
||||
background: color-mix(in srgb, var(--lime-info, #0ea5e9) 36%, transparent);
|
||||
animation: ${dockSignalRipple} 1.8s ease-out infinite;
|
||||
}
|
||||
`;
|
||||
@@ -353,10 +356,11 @@ const DockSignal = styled.span`
|
||||
const EmptyStateCard = styled.section`
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(226, 232, 240, 0.96);
|
||||
border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.96));
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 52px -34px rgba(15, 23, 42, 0.24);
|
||||
background: var(--lime-surface, #ffffff);
|
||||
box-shadow: 0 18px 52px -34px
|
||||
var(--lime-shadow-color, rgba(15, 23, 42, 0.24));
|
||||
`;
|
||||
|
||||
const EmptyStateBody = styled.div`
|
||||
@@ -368,7 +372,7 @@ const EmptyStateEyebrow = styled.div`
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #475569;
|
||||
color: var(--lime-text-muted, #475569);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -381,10 +385,16 @@ const EmptyStateBadge = styled.span<{ $tone?: "neutral" | "success" }>`
|
||||
border: 1px solid
|
||||
${({ $tone }) =>
|
||||
$tone === "success"
|
||||
? "rgba(167, 243, 208, 0.96)"
|
||||
: "rgba(226, 232, 240, 0.96)"};
|
||||
background: ${({ $tone }) => ($tone === "success" ? "#ecfdf5" : "#f8fafc")};
|
||||
color: ${({ $tone }) => ($tone === "success" ? "#047857" : "#64748b")};
|
||||
? "var(--lime-surface-border-strong, rgba(167, 243, 208, 0.96))"
|
||||
: "var(--lime-surface-border, rgba(226, 232, 240, 0.96))"};
|
||||
background: ${({ $tone }) =>
|
||||
$tone === "success"
|
||||
? "var(--lime-brand-soft, #ecfdf5)"
|
||||
: "var(--lime-surface-soft, #f8fafc)"};
|
||||
color: ${({ $tone }) =>
|
||||
$tone === "success"
|
||||
? "var(--lime-brand-strong, #047857)"
|
||||
: "var(--lime-text-muted, #64748b)"};
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -393,7 +403,7 @@ const EmptyStateBadge = styled.span<{ $tone?: "neutral" | "success" }>`
|
||||
|
||||
const EmptyStateTitle = styled.div`
|
||||
margin-top: 12px;
|
||||
color: #0f172a;
|
||||
color: var(--lime-text-strong, #0f172a);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
@@ -401,7 +411,7 @@ const EmptyStateTitle = styled.div`
|
||||
|
||||
const EmptyStateDescription = styled.p`
|
||||
margin-top: 8px;
|
||||
color: #475569;
|
||||
color: var(--lime-text, #475569);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
@@ -416,8 +426,8 @@ const EmptyStateFooter = styled.div`
|
||||
const EmptyStateDetailCard = styled.div`
|
||||
margin-top: 14px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.96);
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.96));
|
||||
background: var(--lime-surface-soft, #f8fafc);
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
@@ -429,17 +439,17 @@ const EmptyStateDetailToggle = styled.button`
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
color: var(--lime-text-strong, #0f172a);
|
||||
text-align: left;
|
||||
transition: background 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
background: var(--lime-surface-hover, rgba(255, 255, 255, 0.72));
|
||||
}
|
||||
`;
|
||||
|
||||
const EmptyStateDetailTitle = styled.div`
|
||||
color: #0f172a;
|
||||
color: var(--lime-text-strong, #0f172a);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
@@ -447,7 +457,7 @@ const EmptyStateDetailTitle = styled.div`
|
||||
|
||||
const EmptyStateDetailHint = styled.div`
|
||||
margin-top: 2px;
|
||||
color: #64748b;
|
||||
color: var(--lime-text-muted, #64748b);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
@@ -460,13 +470,13 @@ const EmptyStateRoleList = styled.div`
|
||||
|
||||
const EmptyStateRoleItem = styled.div`
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.96);
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.96));
|
||||
background: var(--lime-surface, #ffffff);
|
||||
padding: 10px 12px;
|
||||
`;
|
||||
|
||||
const EmptyStateRoleName = styled.div`
|
||||
color: #0f172a;
|
||||
color: var(--lime-text-strong, #0f172a);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
@@ -474,7 +484,7 @@ const EmptyStateRoleName = styled.div`
|
||||
|
||||
const EmptyStateRoleSummary = styled.div`
|
||||
margin-top: 4px;
|
||||
color: #475569;
|
||||
color: var(--lime-text, #475569);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
`;
|
||||
@@ -579,9 +589,8 @@ export function TeamWorkspaceDock({
|
||||
const runtimeBlueprintSummary = normalizeTeamWorkspaceDisplayValue(
|
||||
dispatchPreviewState?.blueprint?.summary,
|
||||
);
|
||||
const normalizedSelectedTeamSummary = normalizeTeamWorkspaceDisplayValue(
|
||||
selectedTeamSummary,
|
||||
);
|
||||
const normalizedSelectedTeamSummary =
|
||||
normalizeTeamWorkspaceDisplayValue(selectedTeamSummary);
|
||||
const runtimeTeamSummary =
|
||||
normalizeTeamWorkspaceDisplayValue(dispatchPreviewState?.summary) ||
|
||||
runtimeBlueprintSummary ||
|
||||
@@ -725,8 +734,7 @@ export function TeamWorkspaceDock({
|
||||
return [
|
||||
{
|
||||
label: `${executionSummary.totalSessionCount} 项任务`,
|
||||
tone:
|
||||
executionSummary.totalSessionCount > 0 ? "success" : "idle",
|
||||
tone: executionSummary.totalSessionCount > 0 ? "success" : "idle",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1046,6 +1054,7 @@ export function TeamWorkspaceDock({
|
||||
$compact={isCompact}
|
||||
$placement={shouldPortalPanel ? "portal" : placement}
|
||||
$portalDirection={inlinePanelLayout?.direction}
|
||||
className="lime-workbench-theme-scope lime-workbench-surface-scope"
|
||||
data-testid="team-workspace-dock-panel"
|
||||
style={
|
||||
shouldPortalPanel && inlinePanelLayout
|
||||
|
||||
@@ -83,7 +83,9 @@ export const TokenUsageDisplay: React.FC<TokenUsageDisplayProps> = ({
|
||||
>
|
||||
<UsageIcon />
|
||||
<UsageText>{formatCompactTokenCount(total)} tokens</UsageText>
|
||||
{promptCacheMetaText ? <UsageMeta>{`· ${promptCacheMetaText}`}</UsageMeta> : null}
|
||||
{promptCacheMetaText ? (
|
||||
<UsageMeta>{`· ${promptCacheMetaText}`}</UsageMeta>
|
||||
) : null}
|
||||
{missingPromptCacheNotice ? (
|
||||
<UsageMeta data-testid="token-usage-prompt-cache-notice">
|
||||
{`· ${missingPromptCacheNotice.label}`}
|
||||
|
||||
@@ -622,9 +622,9 @@ describe("ToolCallDisplay", () => {
|
||||
|
||||
const overlayButton = Array.from(
|
||||
document.body.querySelectorAll("button"),
|
||||
).find((button) =>
|
||||
button.querySelector('img[alt="工具结果图片大图"]'),
|
||||
) as HTMLButtonElement | undefined;
|
||||
).find((button) => button.querySelector('img[alt="工具结果图片大图"]')) as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
|
||||
expect(overlayButton).toBeTruthy();
|
||||
expect(overlayButton?.className).toContain("backdrop-blur-[2px]");
|
||||
|
||||
@@ -1,49 +1,55 @@
|
||||
export type EmptyStateTone = "slate" | "sky" | "emerald" | "amber" | "lime";
|
||||
|
||||
export const EMPTY_STATE_PANEL_CLASSNAME =
|
||||
"rounded-[26px] border border-lime-200/80 bg-white p-4 shadow-sm shadow-slate-950/5 md:p-5";
|
||||
"rounded-[26px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] p-4 shadow-sm shadow-slate-950/5 md:p-5";
|
||||
|
||||
export const EMPTY_STATE_PANEL_EMBEDDED_CLASSNAME =
|
||||
"rounded-[22px] border border-lime-200/80 bg-white p-3.5 shadow-sm shadow-slate-950/5";
|
||||
"rounded-[22px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] p-3.5 shadow-sm shadow-slate-950/5";
|
||||
|
||||
export const EMPTY_STATE_CARD_SURFACE_CLASSNAME =
|
||||
"overflow-hidden rounded-[22px] border border-lime-200/80 bg-white p-3.5 shadow-sm shadow-slate-950/5";
|
||||
"overflow-hidden rounded-[22px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] p-3.5 shadow-sm shadow-slate-950/5";
|
||||
|
||||
export const EMPTY_STATE_BADGE_BASE_CLASSNAME =
|
||||
"inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium shadow-sm shadow-slate-950/5";
|
||||
|
||||
export const EMPTY_STATE_BADGE_TONE_CLASSNAMES: Record<EmptyStateTone, string> =
|
||||
{
|
||||
slate: "border-slate-200 bg-white/90 text-slate-700",
|
||||
sky: "border-sky-200 bg-sky-50/90 text-sky-700",
|
||||
emerald: "border-emerald-200 bg-emerald-50/90 text-emerald-700",
|
||||
amber: "border-amber-200 bg-amber-50/90 text-amber-700",
|
||||
lime: "border-lime-200 bg-lime-50/90 text-lime-700",
|
||||
slate:
|
||||
"border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] text-[color:var(--lime-text)]",
|
||||
sky: "border-[color:var(--lime-info-border)] bg-[color:var(--lime-info-soft)] text-[color:var(--lime-info)]",
|
||||
emerald:
|
||||
"border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-brand-soft)] text-[color:var(--lime-brand-strong)]",
|
||||
amber:
|
||||
"border-[color:var(--lime-warning-border)] bg-[color:var(--lime-warning-soft)] text-[color:var(--lime-warning)]",
|
||||
lime: "border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-brand-soft)] text-[color:var(--lime-brand-strong)]",
|
||||
};
|
||||
|
||||
export const EMPTY_STATE_ICON_TONE_CLASSNAMES: Record<EmptyStateTone, string> =
|
||||
{
|
||||
slate: "border-slate-200 bg-slate-100/90 text-slate-700",
|
||||
sky: "border-sky-200 bg-sky-100/90 text-sky-700",
|
||||
emerald: "border-emerald-200 bg-emerald-100/90 text-emerald-700",
|
||||
amber: "border-amber-200 bg-amber-100/90 text-amber-700",
|
||||
lime: "border-lime-200 bg-lime-100/90 text-lime-700",
|
||||
slate:
|
||||
"border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-muted)] text-[color:var(--lime-text)]",
|
||||
sky: "border-[color:var(--lime-info-border)] bg-[color:var(--lime-info-soft)] text-[color:var(--lime-info)]",
|
||||
emerald:
|
||||
"border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-brand-soft)] text-[color:var(--lime-brand-strong)]",
|
||||
amber:
|
||||
"border-[color:var(--lime-warning-border)] bg-[color:var(--lime-warning-soft)] text-[color:var(--lime-warning)]",
|
||||
lime: "border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-brand-soft)] text-[color:var(--lime-brand-strong)]",
|
||||
};
|
||||
|
||||
export const EMPTY_STATE_META_PILL_CLASSNAME =
|
||||
"rounded-full border border-lime-200/80 bg-lime-50/50 px-2 py-0.5 text-[10px] font-medium text-slate-600";
|
||||
"rounded-full border border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-brand-soft)] px-2 py-0.5 text-[10px] font-medium text-[color:var(--lime-text)]";
|
||||
|
||||
export const EMPTY_STATE_PRESET_BUTTON_CLASSNAME =
|
||||
"inline-flex items-center gap-2 rounded-full border border-lime-200/80 bg-lime-50/50 px-3 py-1.5 text-[13px] text-slate-600 shadow-sm shadow-slate-950/5 transition-colors hover:border-lime-300 hover:bg-white hover:text-slate-900";
|
||||
"inline-flex items-center gap-2 rounded-full border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)] px-3 py-1.5 text-[13px] text-[color:var(--lime-text)] shadow-sm shadow-slate-950/5 transition-colors hover:border-[color:var(--lime-surface-border-strong)] hover:bg-[color:var(--lime-surface)] hover:text-[color:var(--lime-text-strong)]";
|
||||
|
||||
export const EMPTY_STATE_SECONDARY_ACTION_BUTTON_CLASSNAME =
|
||||
"h-8 w-full rounded-full border border-lime-200/80 bg-lime-50/50 text-xs text-slate-700 shadow-none transition-colors hover:border-lime-300 hover:bg-white";
|
||||
"h-8 w-full rounded-full border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)] text-xs text-[color:var(--lime-text)] shadow-none transition-colors hover:border-[color:var(--lime-surface-border-strong)] hover:bg-[color:var(--lime-surface)]";
|
||||
|
||||
export const EMPTY_STATE_PRIMARY_ACTION_BUTTON_CLASSNAME =
|
||||
"h-9 w-full rounded-full border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] px-5 text-white shadow-sm shadow-emerald-950/15 transition hover:opacity-95 sm:w-auto";
|
||||
"h-9 w-full rounded-full border border-[color:var(--lime-surface-border-strong)] bg-[image:var(--lime-primary-gradient)] px-5 text-white shadow-sm shadow-emerald-950/15 transition hover:opacity-95 sm:w-auto";
|
||||
|
||||
export const EMPTY_STATE_RECOMMENDATION_CARD_CLASSNAME =
|
||||
"group flex min-w-0 flex-col items-start gap-2 rounded-[20px] border border-lime-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.97)_0%,rgba(247,254,231,0.9)_100%)] px-3.5 py-3.5 text-left shadow-sm shadow-slate-950/5 transition-colors hover:border-lime-300 hover:bg-white";
|
||||
"group flex min-w-0 flex-col items-start gap-2 rounded-[20px] border border-[color:var(--lime-surface-border)] bg-[image:var(--lime-home-card-surface)] px-3.5 py-3.5 text-left shadow-sm shadow-slate-950/5 transition-colors hover:border-[color:var(--lime-home-card-hover-border)] hover:bg-[color:var(--lime-surface)]";
|
||||
|
||||
export const EMPTY_STATE_PAGE_CONTAINER_CLASSNAME =
|
||||
"relative flex min-h-full flex-1 flex-col items-stretch justify-start overflow-y-auto bg-transparent px-2 pb-5 pt-[clamp(2px,0.6vw,6px)] md:px-3";
|
||||
@@ -52,27 +58,27 @@ export const EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME =
|
||||
"relative z-[1] mx-auto flex min-h-full w-full max-w-[1360px] flex-1 flex-col items-stretch gap-3";
|
||||
|
||||
export const EMPTY_STATE_THEME_TABS_CONTAINER_CLASSNAME =
|
||||
"flex w-full max-w-full flex-nowrap justify-start gap-1.5 overflow-x-auto overflow-y-hidden rounded-[20px] border border-lime-200/80 bg-lime-50/50 p-[5px] shadow-[0_10px_24px_-22px_rgba(15,23,42,0.18)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden";
|
||||
"flex w-full max-w-full flex-nowrap justify-start gap-1.5 overflow-x-auto overflow-y-hidden rounded-[20px] border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)] p-[5px] shadow-[0_10px_24px_-22px_rgba(15,23,42,0.18)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden";
|
||||
|
||||
export const EMPTY_STATE_SELECT_TRIGGER_CLASSNAME =
|
||||
"h-8 rounded-full border-lime-200/80 bg-white/92 px-3 text-xs text-slate-700 shadow-none transition-colors hover:border-lime-300 hover:bg-white focus:ring-1 focus:ring-lime-200";
|
||||
"h-8 rounded-full border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] px-3 text-xs text-[color:var(--lime-text)] shadow-none transition-colors hover:border-[color:var(--lime-surface-border-strong)] hover:bg-[color:var(--lime-surface)] focus:ring-1 focus:ring-[color:var(--lime-surface-border-strong)]";
|
||||
|
||||
export const EMPTY_STATE_PASSIVE_BADGE_CLASSNAME =
|
||||
"h-8 rounded-full border border-lime-200/80 bg-lime-50/50 px-3 text-xs font-normal text-slate-600 shadow-none hover:border-lime-300 hover:bg-white hover:text-slate-900";
|
||||
"h-8 rounded-full border border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface-soft)] px-3 text-xs font-normal text-[color:var(--lime-text)] shadow-none hover:border-[color:var(--lime-surface-border-strong)] hover:bg-[color:var(--lime-surface)] hover:text-[color:var(--lime-text-strong)]";
|
||||
|
||||
export const EMPTY_STATE_ICON_TOOL_BUTTON_CLASSNAME =
|
||||
"ml-1 h-8 w-8 rounded-full border-lime-200/80 bg-white text-slate-500 shadow-none transition-colors hover:border-lime-300 hover:bg-lime-50 hover:text-slate-700";
|
||||
"ml-1 h-8 w-8 rounded-full border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] text-[color:var(--lime-text-muted)] shadow-none transition-colors hover:border-[color:var(--lime-surface-border-strong)] hover:bg-[color:var(--lime-brand-soft)] hover:text-[color:var(--lime-brand-strong)]";
|
||||
|
||||
const EMPTY_STATE_TOOL_TOGGLE_TONE_CLASSNAMES: Record<EmptyStateTone, string> =
|
||||
{
|
||||
slate:
|
||||
"border-slate-400 bg-slate-200 text-slate-900 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)] hover:border-slate-400 hover:bg-slate-200 hover:text-slate-900",
|
||||
sky: "border-sky-300 bg-sky-50 text-sky-700 hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700",
|
||||
sky: "border-[color:var(--lime-info-border)] bg-[color:var(--lime-info-soft)] text-[color:var(--lime-info)] hover:border-[color:var(--lime-info-border)] hover:bg-[color:var(--lime-info-soft)] hover:text-[color:var(--lime-info)]",
|
||||
emerald:
|
||||
"border-emerald-300 bg-emerald-50 text-emerald-700 hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-700",
|
||||
"border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-brand-soft)] text-[color:var(--lime-brand-strong)] hover:border-[color:var(--lime-surface-border-strong)] hover:bg-[color:var(--lime-brand-soft)] hover:text-[color:var(--lime-brand-strong)]",
|
||||
amber:
|
||||
"border-amber-300 bg-amber-50 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700",
|
||||
lime: "border-lime-300 bg-lime-50 text-lime-700 hover:border-lime-300 hover:bg-lime-50 hover:text-lime-700",
|
||||
"border-[color:var(--lime-warning-border)] bg-[color:var(--lime-warning-soft)] text-[color:var(--lime-warning)] hover:border-[color:var(--lime-warning-border)] hover:bg-[color:var(--lime-warning-soft)] hover:text-[color:var(--lime-warning)]",
|
||||
lime: "border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-brand-soft)] text-[color:var(--lime-brand-strong)] hover:border-[color:var(--lime-surface-border-strong)] hover:bg-[color:var(--lime-brand-soft)] hover:text-[color:var(--lime-brand-strong)]",
|
||||
};
|
||||
|
||||
export function getEmptyStateIconToolButtonClassName(
|
||||
@@ -91,8 +97,8 @@ export function getEmptyStateThemeTabClassName(active: boolean) {
|
||||
return [
|
||||
"flex flex-none items-center gap-1.5 rounded-xl border px-3 py-[7px] text-xs font-medium leading-none transition-[background-color,border-color,color,box-shadow]",
|
||||
active
|
||||
? "border-lime-300 bg-white/95 text-slate-900 shadow-[0_10px_22px_-20px_rgba(15,23,42,0.24)]"
|
||||
: "border-transparent text-slate-600 hover:border-lime-200/90 hover:bg-white/80 hover:text-slate-900",
|
||||
? "border-[color:var(--lime-surface-border-strong)] bg-[color:var(--lime-surface)] text-[color:var(--lime-text-strong)] shadow-[0_10px_22px_-20px_rgba(15,23,42,0.24)]"
|
||||
: "border-transparent text-[color:var(--lime-text)] hover:border-[color:var(--lime-surface-border)] hover:bg-[color:var(--lime-surface)] hover:text-[color:var(--lime-text-strong)]",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@@ -100,7 +106,7 @@ export function getEmptyStateThemeTabIconClassName(active: boolean) {
|
||||
return [
|
||||
"flex h-5 w-5 items-center justify-center rounded-full border text-[11px]",
|
||||
active
|
||||
? "border-lime-200 bg-white text-slate-700"
|
||||
: "border-transparent bg-transparent text-slate-400",
|
||||
? "border-[color:var(--lime-surface-border)] bg-[color:var(--lime-surface)] text-[color:var(--lime-text)]"
|
||||
: "border-transparent bg-transparent text-[color:var(--lime-text-muted)]",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@@ -48,8 +48,7 @@ describe("generalWorkbenchWorkflowData", () => {
|
||||
curatedTask: {
|
||||
taskId: "daily-trend-briefing",
|
||||
taskTitle: "每日趋势摘要",
|
||||
resultDestination:
|
||||
"趋势摘要会先写回当前内容,方便继续展开选题和主稿。",
|
||||
resultDestination: "趋势摘要会先写回当前内容,方便继续展开选题和主稿。",
|
||||
followUpActions: ["继续展开其中一个选题", "生成首条内容主稿"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -211,14 +211,15 @@ function readCuratedTaskReferenceEntries(
|
||||
record.task_prefill_by_task_id ?? record.taskPrefillByTaskId,
|
||||
) ?? {},
|
||||
)
|
||||
.map(([taskId, inputValues]) => [
|
||||
taskId,
|
||||
readCuratedTaskLaunchInputValues(inputValues),
|
||||
] as const)
|
||||
.map(
|
||||
([taskId, inputValues]) =>
|
||||
[
|
||||
taskId,
|
||||
readCuratedTaskLaunchInputValues(inputValues),
|
||||
] as const,
|
||||
)
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is [string, CuratedTaskInputValues] =>
|
||||
(item): item is [string, CuratedTaskInputValues] =>
|
||||
Boolean(item[0]) && Boolean(item[1]),
|
||||
),
|
||||
)
|
||||
@@ -301,13 +302,16 @@ export function parseGeneralWorkbenchRunMetadataSummary(
|
||||
curatedTaskRecord?.task_title ?? curatedTaskRecord?.taskTitle,
|
||||
);
|
||||
const curatedTaskResultDestination = readString(
|
||||
curatedTaskRecord?.result_destination ?? curatedTaskRecord?.resultDestination,
|
||||
curatedTaskRecord?.result_destination ??
|
||||
curatedTaskRecord?.resultDestination,
|
||||
);
|
||||
const curatedTaskFollowUpActions = readStringArray(
|
||||
curatedTaskRecord?.follow_up_actions ?? curatedTaskRecord?.followUpActions,
|
||||
curatedTaskRecord?.follow_up_actions ??
|
||||
curatedTaskRecord?.followUpActions,
|
||||
);
|
||||
const launchInputValues = readCuratedTaskLaunchInputValues(
|
||||
curatedTaskRecord?.launch_input_values ?? curatedTaskRecord?.launchInputValues,
|
||||
curatedTaskRecord?.launch_input_values ??
|
||||
curatedTaskRecord?.launchInputValues,
|
||||
);
|
||||
const referenceEntries = readCuratedTaskReferenceEntries(
|
||||
curatedTaskRecord?.reference_entries ??
|
||||
@@ -326,9 +330,7 @@ export function parseGeneralWorkbenchRunMetadataSummary(
|
||||
? findCuratedTaskTemplateById(curatedTaskId)
|
||||
: null;
|
||||
const curatedTask =
|
||||
curatedTaskTemplate ||
|
||||
curatedTaskId ||
|
||||
curatedTaskTitle
|
||||
curatedTaskTemplate || curatedTaskId || curatedTaskTitle
|
||||
? {
|
||||
taskId: curatedTaskTemplate?.id ?? curatedTaskId,
|
||||
taskTitle: curatedTaskTemplate?.title ?? curatedTaskTitle,
|
||||
|
||||
@@ -59,6 +59,9 @@ export interface ImageTaskViewerProps {
|
||||
tasks: ImageWorkbenchTaskView[];
|
||||
outputs: ImageWorkbenchOutputView[];
|
||||
selectedOutputId: string | null;
|
||||
sourceProjectId?: string | null;
|
||||
sourceContentId?: string | null;
|
||||
sourceThreadId?: string | null;
|
||||
viewport: ImageWorkbenchViewport;
|
||||
preferenceSummary?: string | null;
|
||||
preferenceWarning?: string | null;
|
||||
@@ -83,6 +86,5 @@ export interface ImageTaskViewerProps {
|
||||
applySelectedOutputLabel?: string;
|
||||
onApplySelectedOutput?: () => void;
|
||||
onSeedFollowUpCommand?: (command: string) => void;
|
||||
onOpenImage?: (url: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
+4
-1
@@ -52,7 +52,10 @@ export function TeamWorkspaceBoardCanvasSection({
|
||||
{fallbackDetailProps ? (
|
||||
<TeamWorkspaceFallbackDetailSection
|
||||
{...fallbackDetailProps}
|
||||
detailCardClassName={cn("mt-3", fallbackDetailProps.detailCardClassName)}
|
||||
detailCardClassName={cn(
|
||||
"mt-3",
|
||||
fallbackDetailProps.detailCardClassName,
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user