chore: release v1.21.0

This commit is contained in:
coso
2026-04-28 09:10:18 +08:00
parent 1c2f52927a
commit b35e74c8cb
297 changed files with 28665 additions and 15287 deletions
+171 -73
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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 后客户端账本、积分和权益刷新一致。
-12
View File
@@ -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
View File
@@ -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 "✅ 监控完成"
-42
View File
@@ -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"}}
+2 -2
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "1.20.0",
"version": "1.21.0",
"type": "module",
"engines": {
"node": ">=22.0.0"
+1 -1
View File
@@ -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 -1
View File
@@ -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

+149
View File
@@ -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 };
+358
View File
@@ -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 };
+220
View File
@@ -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");
});
});
+19 -18
View File
@@ -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",
+3 -2
View File
@@ -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"
+1
View File
@@ -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]
+1 -1
View File
@@ -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};
+7 -15
View File
@@ -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 模式)");
}
+209 -4
View File
@@ -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");
}
}
+89 -20
View File
@@ -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(&current_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("更新包地址或签名为空")));
}
}
+18
View File
@@ -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,
+2 -21
View File
@@ -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)]
+1 -20
View File
@@ -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 -1
View File
@@ -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",
+4 -2
View File
@@ -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
View File
@@ -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}
+11
View File
@@ -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>
+5 -1
View File
@@ -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>
);
+685 -32
View File
@@ -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();
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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)]");
+1 -1
View File
@@ -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}
>
+2 -2
View File
@@ -274,8 +274,8 @@ interface SplashScreenProps {
export function SplashScreen({
onComplete,
duration = 1200,
exitDuration = 320,
duration = 420,
exitDuration = 160,
}: SplashScreenProps) {
const [isExiting, setIsExiting] = useState(false);
+235 -24
View File
@@ -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("&lt;text&gt;正在整理&lt;/text&gt;");
expect(container.textContent).toContain(
"&lt;text&gt;正在整理&lt;/text&gt;",
);
expect(container.textContent).toContain("&lt;spinner /&gt;");
});
@@ -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, "&lt;").replace(/>/g, "&gt;");
}
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" &&
@@ -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:
@@ -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}`
@@ -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("![配图](./images/../images/hero.png?raw=1#preview)", {
baseFilePath:
"/Users/coso/.proxycast/projects/default/exports/x-article/google/nested/index.md",
});
const container = render(
"![配图](./images/../images/hero.png?raw=1#preview)",
{
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;
}
@@ -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