release: v1.7.0

This commit is contained in:
coso
2026-04-11 01:17:15 +08:00
parent 182c0d464a
commit 26751bc117
249 changed files with 23042 additions and 10392 deletions
+152 -92
View File
@@ -132,6 +132,7 @@ import { useGeneralWorkbenchEntryPrompt } from "./hooks/useGeneralWorkbenchEntry
import { useGeneralWorkbenchEntryPromptActions } from "./hooks/useGeneralWorkbenchEntryPromptActions";
import { useGeneralWorkbenchSendBoundary } from "./hooks/useGeneralWorkbenchSendBoundary";
import { mergeThreadItems } from "./utils/threadTimelineView";
import { openCanvasForReason } from "./workspace/canvasOpenPolicy";
import { useWorkbenchStore } from "@/stores/useWorkbenchStore";
import {
asRecord,
@@ -188,12 +189,18 @@ import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSess
import { useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime";
import { useWorkspaceServiceSkillEntryActions } from "./workspace/useWorkspaceServiceSkillEntryActions";
import { useWorkspaceArtifactViewModeControl } from "./workspace/useWorkspaceArtifactViewModeControl";
import { resolveAbsoluteWorkspacePath } from "./workspace/workspacePath";
import { hasNamedGeneralCanvasFilePreview } from "./workspace/generalCanvasPreviewState";
import { resolvePreferredServiceSkillResultFileTarget } from "./workspace/serviceSkillResultFileTarget";
import {
isAbsoluteWorkspacePath,
resolveAbsoluteWorkspacePath,
} from "./workspace/workspacePath";
import {
areArtifactProtocolPathsEquivalent,
normalizeArtifactProtocolPath,
resolveArtifactProtocolFilePath,
} from "@/lib/artifact-protocol";
import { resolveSiteSavedContentTargetFromRunResult } from "./utils/siteToolResultSummary";
import type { ArtifactDocumentV1 } from "@/lib/artifact-document";
import type { ArtifactTimelineOpenTarget } from "./utils/artifactTimelineNavigation";
import { createUnifiedMemory } from "@/lib/api/unifiedMemory";
@@ -624,7 +631,6 @@ export function AgentChatWorkspace({
} | null>(null);
const handledInitialPendingServiceSkillLaunchSignatureRef = useRef("");
const handledInitialProjectFileOpenSignatureRef = useRef("");
const autoOpenedSiteSkillSavedContentSignatureRef = useRef("");
const initialCreationReplay = useMemo(
() => extractCreationReplayMetadata(initialRequestMetadata),
[initialRequestMetadata],
@@ -891,9 +897,18 @@ export function AgentChatWorkspace({
() => resolveDefaultSelectedArtifact(activeTheme, artifacts),
[activeTheme, artifacts],
);
const preferGeneralCanvasFilePreview = useMemo(
() =>
activeTheme === "general" &&
hasNamedGeneralCanvasFilePreview(generalCanvasState),
[activeTheme, generalCanvasState],
);
const liveArtifact = useMemo(
() => selectedArtifact || defaultSelectedArtifact,
[defaultSelectedArtifact, selectedArtifact],
() =>
preferGeneralCanvasFilePreview
? null
: selectedArtifact || defaultSelectedArtifact,
[defaultSelectedArtifact, preferGeneralCanvasFilePreview, selectedArtifact],
);
// Artifact 预览状态
@@ -1847,7 +1862,6 @@ export function AgentChatWorkspace({
browserAssistLaunching,
browserAssistSessionState,
siteSkillExecutionState,
siteSkillSavedContentTarget,
currentBrowserAssistScopeKey,
ensureBrowserAssistCanvas,
suppressBrowserAssistCanvasAutoOpen,
@@ -2080,6 +2094,13 @@ export function AgentChatWorkspace({
return;
}
if (preferGeneralCanvasFilePreview) {
if (selectedArtifactId !== null) {
setSelectedArtifactId(null);
}
return;
}
if (artifacts.length === 0) {
if (selectedArtifactId !== null) {
setSelectedArtifactId(null);
@@ -2113,6 +2134,7 @@ export function AgentChatWorkspace({
activeTheme,
artifacts,
defaultSelectedArtifact,
preferGeneralCanvasFilePreview,
selectedArtifact,
selectedArtifactId,
setSelectedArtifactId,
@@ -2592,7 +2614,6 @@ export function AgentChatWorkspace({
(await submitImageWorkbenchAgentCommandRef.current?.(params)) ?? false,
setCanvasState,
setInput,
setLayoutMode,
updateCurrentImageWorkbenchState,
});
const { handleImageWorkbenchCommand, resolveImageWorkbenchSkillRequest } =
@@ -3031,6 +3052,7 @@ export function AgentChatWorkspace({
setLayoutMode,
setTaskFiles,
setSelectedFileId,
setGeneralCanvasState,
setCanvasState,
});
const handleWorkspaceFileClick = useCallback(
@@ -3040,6 +3062,42 @@ export function AgentChatWorkspace({
},
[handleFileClick],
);
const openProjectFilePreviewInCanvas = useCallback(
async ({
relativePath,
absolutePath,
isCancelled,
}: {
relativePath?: string | null;
absolutePath: string;
isCancelled?: () => boolean;
}) => {
const preview = await handleHarnessLoadFilePreview(absolutePath);
if (isCancelled?.()) {
return false;
}
if (preview.error) {
toast.error(`打开导出文件失败: ${preview.error}`);
return false;
}
if (preview.isBinary) {
toast.info("导出文件是二进制格式,暂不支持在工作台预览");
return false;
}
const nextContent =
typeof preview.content === "string" ? preview.content : "";
const nextFilePath =
relativePath?.trim() || preview.path || absolutePath;
startTransition(() => {
handleWorkspaceFileClick(nextFilePath, nextContent);
});
return true;
},
[handleHarnessLoadFilePreview, handleWorkspaceFileClick],
);
const handleOpenSavedSiteContent = useCallback(
async ({
projectId: targetProjectId,
@@ -3061,23 +3119,13 @@ export function AgentChatWorkspace({
relativePath,
);
if (absolutePath) {
const preview = await handleHarnessLoadFilePreview(absolutePath);
if (preview.error) {
toast.error(`打开导出文件失败: ${preview.error}`);
return;
}
if (preview.isBinary) {
toast.info("导出文件是二进制格式,暂不支持在工作台预览");
return;
}
const nextContent =
typeof preview.content === "string" ? preview.content : "";
startTransition(() => {
handleWorkspaceFileClick(preview.path || absolutePath, nextContent);
const opened = await openProjectFilePreviewInCanvas({
relativePath,
absolutePath,
});
return;
if (opened) {
return;
}
}
}
@@ -3098,58 +3146,11 @@ export function AgentChatWorkspace({
},
[
_onNavigate,
handleHarnessLoadFilePreview,
handleWorkspaceFileClick,
openProjectFilePreviewInCanvas,
project?.rootPath,
projectId,
],
);
useEffect(() => {
if (siteSkillExecutionState?.phase === "success") {
return;
}
autoOpenedSiteSkillSavedContentSignatureRef.current = "";
}, [siteSkillExecutionState?.phase]);
useEffect(() => {
if (siteSkillExecutionState?.phase !== "success") {
return;
}
if (siteSkillSavedContentTarget?.preferredTarget !== "project_file") {
return;
}
if (!projectId || siteSkillSavedContentTarget.projectId !== projectId) {
return;
}
const relativePath =
siteSkillSavedContentTarget.projectFile?.relativePath?.trim() || "";
if (!relativePath) {
return;
}
const signature = JSON.stringify({
adapterName: siteSkillExecutionState.adapterName,
sourceUrl: siteSkillExecutionState.sourceUrl?.trim() || "",
projectId: siteSkillSavedContentTarget.projectId,
contentId: siteSkillSavedContentTarget.contentId,
relativePath,
});
if (autoOpenedSiteSkillSavedContentSignatureRef.current === signature) {
return;
}
autoOpenedSiteSkillSavedContentSignatureRef.current = signature;
void handleOpenSavedSiteContent(siteSkillSavedContentTarget);
}, [
handleOpenSavedSiteContent,
projectId,
siteSkillExecutionState?.adapterName,
siteSkillExecutionState?.phase,
siteSkillExecutionState?.sourceUrl,
siteSkillSavedContentTarget,
]);
const handleWorkspaceArtifactClick = useCallback(
(artifact: Artifact) => {
setFocusedArtifactBlockId(null);
@@ -3169,7 +3170,7 @@ export function AgentChatWorkspace({
active: true,
};
});
setLayoutMode("chat-canvas");
openCanvasForReason("user_open_message_preview", setLayoutMode);
return;
}
@@ -3191,7 +3192,7 @@ export function AgentChatWorkspace({
? preview.statusMessage?.trim() || "视频任务未成功完成"
: undefined,
});
setLayoutMode("chat-canvas");
openCanvasForReason("user_open_message_preview", setLayoutMode);
return;
}
@@ -3247,6 +3248,72 @@ export function AgentChatWorkspace({
},
[handleWorkspaceFileClick],
);
const siteSkillSavedContentTarget = useMemo(
() =>
resolveSiteSavedContentTargetFromRunResult(
siteSkillExecutionState?.result || null,
),
[siteSkillExecutionState?.result],
);
const currentTurnThreadItems = useMemo(
() =>
currentTurnId
? effectiveThreadItems.filter((item) => item.turn_id === currentTurnId)
: [],
[currentTurnId, effectiveThreadItems],
);
const preferredServiceSkillResultFileTarget = useMemo(
() =>
resolvePreferredServiceSkillResultFileTarget({
threadItems: currentTurnThreadItems,
savedContentTarget: siteSkillSavedContentTarget,
}),
[currentTurnThreadItems, siteSkillSavedContentTarget],
);
const handleOpenServiceSkillResultFile = useCallback(
async (relativePath: string) => {
const normalizedPath = relativePath.trim();
if (!normalizedPath) {
return;
}
const absolutePath = resolveAbsoluteWorkspacePath(
project?.rootPath,
normalizedPath,
);
if (absolutePath) {
const opened = await openProjectFilePreviewInCanvas({
relativePath: normalizedPath,
absolutePath,
});
if (opened) {
return;
}
}
const matchedTaskFile = taskFiles.find(
(file) =>
areArtifactProtocolPathsEquivalent(file.name, normalizedPath) ||
normalizeArtifactProtocolPath(file.name) ===
normalizeArtifactProtocolPath(normalizedPath),
);
if (matchedTaskFile) {
handleWorkspaceFileClick(
matchedTaskFile.name,
matchedTaskFile.content ?? "",
);
return;
}
toast.error("打开结果文件失败:当前工作区里还没有同步到这份文件");
},
[
handleWorkspaceFileClick,
openProjectFilePreviewInCanvas,
project?.rootPath,
taskFiles,
],
);
useEffect(() => {
const relativePath = initialProjectFileOpenTarget?.relativePath?.trim();
if (!relativePath) {
@@ -3258,6 +3325,10 @@ export function AgentChatWorkspace({
return;
}
if (!project?.rootPath && !isAbsoluteWorkspacePath(relativePath)) {
return;
}
const absolutePath = resolveAbsoluteWorkspacePath(project?.rootPath, relativePath);
if (!absolutePath) {
return;
@@ -3276,24 +3347,10 @@ export function AgentChatWorkspace({
let cancelled = false;
void (async () => {
const preview = await handleHarnessLoadFilePreview(absolutePath);
if (cancelled) {
return;
}
if (preview.error) {
toast.error(`打开导出文件失败: ${preview.error}`);
return;
}
if (preview.isBinary) {
toast.info("导出文件是二进制格式,暂不支持在工作台预览");
return;
}
const nextContent = typeof preview.content === "string" ? preview.content : "";
startTransition(() => {
handleWorkspaceFileClick(preview.path || absolutePath, nextContent);
await openProjectFilePreviewInCanvas({
relativePath,
absolutePath,
isCancelled: () => cancelled,
});
})();
@@ -3302,10 +3359,9 @@ export function AgentChatWorkspace({
};
}, [
contentId,
handleHarnessLoadFilePreview,
handleWorkspaceFileClick,
initialProjectFileOpenTarget,
isInitialContentLoading,
openProjectFilePreviewInCanvas,
project?.rootPath,
projectId,
]);
@@ -3318,12 +3374,16 @@ export function AgentChatWorkspace({
? handleOpenBrowserRuntimeForSiteSkillExecution
: undefined
}
preferredResultFileTarget={preferredServiceSkillResultFileTarget}
onOpenResultFile={handleOpenServiceSkillResultFile}
onOpenSavedSiteContent={handleOpenSavedSiteContent}
/>
),
[
handleOpenServiceSkillResultFile,
handleOpenBrowserRuntimeForSiteSkillExecution,
handleOpenSavedSiteContent,
preferredServiceSkillResultFileTarget,
siteSkillExecutionState,
],
);
+18 -14
View File
@@ -17,20 +17,21 @@ AI Agent 聊天页面,承接新建任务与任务中心两种入口。当前
### components/
| 文件 | 说明 |
| --------------------------- | ---------------------------------------------------------------------------- |
| `ChatNavbar.tsx` | 顶部导航栏(模型选择、设置等) |
| `ChatSidebar.tsx` | 侧边栏(任务列表) |
| `MessageList.tsx` | 消息列表组件 |
| `Inputbar.tsx` | 输入栏组件 |
| `EmptyState.tsx` | 空状态引导(主题选择、模式选择) |
| `CanvasWorkbenchLayout.tsx` | 画布响应式工作台,宽屏侧栏与窄屏底部面板均支持产物、文件树、变更、预览与下载 |
| 文件 | 说明 |
| -------------------------------- | ------------------------------------------------------------------------------------ |
| `ChatNavbar.tsx` | 顶部导航栏(模型选择、设置等) |
| `ChatSidebar.tsx` | 侧边栏(任务列表) |
| `MessageList.tsx` | 消息列表组件 |
| `Inputbar.tsx` | 输入栏组件 |
| `EmptyState.tsx` | 空状态引导(主题选择、模式选择) |
| `CanvasWorkbenchLayout.tsx` | 画布顶部标签工作台,统一承载 `Session · Main`、`workspace` 与动态文件标签 |
| `CanvasSessionOverviewPanel.tsx` | 会话过程面板,展示 turn、skills / tools、A2UI 与排队状态的统一过程视图 |
### hooks/
| 文件 | 说明 |
| ---------------------- | ---------------------------------------------------------------- |
| `useAsterAgentChat.ts` | 现役 Aster 聊天主 Hook |
| 文件 | 说明 |
| ---------------------- | -------------------------------------------------------------------- |
| `useAsterAgentChat.ts` | 现役 Aster 聊天主 Hook |
| `index.ts` | `useAgentChatUnified` 统一入口与 Hook 导出,旧 `useAgentChat` 已删除 |
## 核心功能
@@ -50,9 +51,12 @@ AI Agent 聊天页面,承接新建任务与任务中心两种入口。当前
### 3. 画布内工作台
- 宽屏使用右侧内嵌工作台,窄屏自动切换为底部工作台,均支持 `产物 / 全部文件 / 变更 / 预览`
- 支持画布面板折叠/展开
- 支持复制路径、系统打开、定位与文本下载
- 使用单一大画布容器 + 顶部标签导航,避免右侧工作台与正文分裂成两套心智
- 固定主标签为 `Session · Main`、`workspace`,并按真实文件动态追加文件标签
- 顶部改为紧凑标签栏,保留文件操作,删除大块摘要头,避免把说明文案继续堆在画布最上方
- 当存在自定义 `sessionView` 时,初始化默认落在 `Session · Main`,不再被默认主稿文件标签抢焦点;若已存在主稿,则面板优先打开主稿预览而不是重复显示过程总览
- `CanvasWorkbenchLayout` 仅负责壳层与标签切换,会话过程、顶部主标签语义、工作区摘要与 Team 摘要都通过 `sessionView` / `workspaceView` / `teamView` 插槽注入,避免把 slash / skill / team 逻辑硬编码进画布壳
- 文件标签内支持正文 / 变更切换、复制路径、系统打开、定位与文本下载
## 依赖模块
@@ -260,7 +260,7 @@ function createFileArtifactItem(
return {
...createBaseItem("artifact-1", 1),
type: "file_artifact",
path: ".lime/artifacts/thread-1/demo.artifact.json",
path: "exports/x-article-export/google/index.md",
source: "artifact_snapshot",
content: JSON.stringify({
schemaVersion: "artifact_document.v1",
@@ -374,12 +374,40 @@ describe("AgentThreadTimeline", () => {
expect(onOpenArtifactFromTimeline).toHaveBeenCalledWith(
expect.objectContaining({
timelineItemId: "artifact-1",
filePath: ".lime/artifacts/thread-1/demo.artifact.json",
filePath: "exports/x-article-export/google/index.md",
blockId: "hero-1",
}),
);
});
it("多个 file_artifact 应直接渲染卡片,不再重复显示产出摘要头", () => {
const container = renderTimeline([
createFileArtifactItem({
path: "workspace/index.md",
content: "# Index\n\n主文档内容",
}),
createFileArtifactItem({
...createBaseItem("artifact-2", 2),
path: "workspace/Agents.md",
content: "# Agents\n\n协作说明",
}),
]);
expect(
container.querySelector('[data-testid="agent-thread-block:1:artifact"]'),
).not.toBeNull();
expect(
container.querySelector(
'[data-testid="agent-thread-block:1:artifact:shell"]',
),
).toBeNull();
expect(
container.querySelectorAll('[data-testid="timeline-file-artifact-card"]'),
).toHaveLength(2);
expect(container.textContent).not.toContain("产出了 index.md");
expect(container.textContent).not.toContain("产出了 Agents.md");
});
it("不应把 .lime/tasks 下的内部任务快照 JSON 渲染到时间线里", () => {
const container = renderTimeline([
createFileArtifactItem({
@@ -27,7 +27,7 @@ import {
import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime";
import { isActionRequestA2UICompatible } from "../utils/actionRequestA2UI";
import { resolveInternalImageTaskDisplayName } from "../utils/internalImagePlaceholder";
import { isHiddenInternalArtifactPath } from "../utils/internalArtifactVisibility";
import { isHiddenConversationArtifactPath } from "../utils/internalArtifactVisibility";
import { parseAIResponse } from "@/lib/workspace/a2ui";
import type { A2UIResponse } from "@/lib/workspace/a2ui";
import { TIMELINE_A2UI_TASK_CARD_PRESET } from "@/lib/workspace/a2ui";
@@ -815,7 +815,7 @@ function renderGroupItemDetails(
}
if (item.type === "file_artifact") {
if (isHiddenInternalArtifactPath(item.path)) {
if (isHiddenConversationArtifactPath(item.path)) {
return null;
}
@@ -1382,6 +1382,35 @@ function TimelineBlockCard({
});
}, [focusRequestKey, hasFocusedItem]);
const shouldRenderArtifactCardsInline =
block.kind === "artifact" &&
hasDetailEntries &&
block.items.every((item) => item.type === "file_artifact");
if (shouldRenderArtifactCardsInline) {
return (
<div
className="space-y-2 py-0.5"
data-testid={dataTestId}
data-emphasis={emphasis}
>
{detailEntries.map((entry) => (
<div
key={entry.id}
data-thread-item-id={entry.id}
ref={entry.id === focusedItemId ? focusedEntryRef : null}
className={cn(
entry.id === focusedItemId &&
"rounded-2xl ring-2 ring-sky-200 ring-offset-2 ring-offset-white",
)}
>
{entry.content}
</div>
))}
</div>
);
}
const singleItemContent =
block.items.length === 1 &&
!(isThinkingOnlyBlock && block.status === "completed")
@@ -1567,7 +1596,7 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
item.type !== "agent_message" &&
!(
item.type === "file_artifact" &&
isHiddenInternalArtifactPath(item.path)
isHiddenConversationArtifactPath(item.path)
),
),
[items],
@@ -0,0 +1,589 @@
import {
AlertTriangle,
Bot,
Clock3,
FileText,
ListChecks,
Loader2,
Search,
ShieldAlert,
Sparkles,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime";
import type {
ActionRequired,
AgentThreadItem,
AgentThreadTurn,
} from "../types";
import { sortThreadItems } from "../utils/threadTimelineView";
import { extractFileNameFromPath } from "../workspace/workspacePath";
interface CanvasSessionOverviewPanelProps {
turns: AgentThreadTurn[];
threadItems: AgentThreadItem[];
currentTurnId?: string | null;
pendingActions?: ActionRequired[];
queuedTurns?: QueuedTurnSnapshot[];
isSending?: boolean;
focusedItemId?: string | null;
}
type SessionStatusTone = "default" | "accent" | "success";
interface SessionActivityView {
id: string;
title: string;
summary: string;
timeLabel: string | null;
statusLabel: string;
tone: SessionStatusTone;
icon: typeof Sparkles;
iconClassName: string;
}
function shortenText(value?: string | null, maxLength = 120): string {
const normalized = (value || "").trim().replace(/\s+/g, " ");
if (!normalized) {
return "";
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 1).trimEnd()}…`;
}
function formatTimeLabel(value?: string | null): string | null {
if (!value) {
return null;
}
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) {
return null;
}
return timestamp.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
}
function resolveTurnStatusLabel(status?: AgentThreadTurn["status"] | null): {
label: string;
tone: SessionStatusTone;
} {
if (status === "running") {
return { label: "执行中", tone: "accent" };
}
if (status === "completed") {
return { label: "已完成", tone: "success" };
}
if (status === "failed") {
return { label: "失败", tone: "default" };
}
if (status === "aborted") {
return { label: "已中断", tone: "default" };
}
return { label: "空闲", tone: "default" };
}
function resolveItemStatusLabel(item: AgentThreadItem): {
label: string;
tone: SessionStatusTone;
} {
if (item.status === "in_progress") {
return { label: "进行中", tone: "accent" };
}
if (item.status === "failed") {
return { label: "失败", tone: "default" };
}
return { label: "已完成", tone: "success" };
}
function resolvePendingActionPreview(action: ActionRequired): string {
const prompt = shortenText(action.prompt, 120);
if (prompt) {
return prompt;
}
const firstQuestion = action.questions?.[0];
const questionText = shortenText(firstQuestion?.question, 120);
if (questionText) {
return questionText;
}
if (action.toolName?.trim()) {
return action.toolName.trim();
}
return action.requestId;
}
function buildActivityView(item: AgentThreadItem): SessionActivityView | null {
const { label: statusLabel, tone } = resolveItemStatusLabel(item);
const timeLabel = formatTimeLabel(
item.updated_at || item.completed_at || item.started_at,
);
switch (item.type) {
case "tool_call":
return {
id: item.id,
title: item.tool_name || "工具调用",
summary:
shortenText(item.error, 100) ||
shortenText(
typeof item.arguments === "string"
? item.arguments
: JSON.stringify(item.arguments ?? {}, null, 2),
100,
) ||
"工具已接入当前运行轨迹。",
timeLabel,
statusLabel,
tone,
icon: Sparkles,
iconClassName: "text-sky-600",
};
case "command_execution":
return {
id: item.id,
title: "exec_command",
summary: shortenText(item.command, 100) || "命令执行中",
timeLabel,
statusLabel,
tone,
icon: ListChecks,
iconClassName: "text-slate-600",
};
case "web_search":
return {
id: item.id,
title: item.action?.trim() || "Web Search",
summary:
shortenText(item.query, 100) ||
shortenText(item.output, 100) ||
"正在检索外部信息。",
timeLabel,
statusLabel,
tone,
icon: Search,
iconClassName: "text-sky-600",
};
case "request_user_input":
return {
id: item.id,
title: "等待补充信息",
summary:
shortenText(item.prompt, 100) ||
shortenText(item.questions?.[0]?.question, 100) ||
"A2UI / 问答采集中",
timeLabel,
statusLabel,
tone,
icon: ShieldAlert,
iconClassName: "text-amber-600",
};
case "approval_request":
return {
id: item.id,
title: "等待确认",
summary:
shortenText(item.prompt, 100) ||
shortenText(item.tool_name, 100) ||
"需要用户确认后继续。",
timeLabel,
statusLabel,
tone,
icon: ShieldAlert,
iconClassName: "text-amber-600",
};
case "file_artifact":
return {
id: item.id,
title: "产物已写入",
summary:
shortenText(extractFileNameFromPath(item.path) || item.path, 100) ||
"已产生新的文件产物。",
timeLabel,
statusLabel,
tone,
icon: FileText,
iconClassName: "text-emerald-600",
};
case "subagent_activity":
return {
id: item.id,
title: item.title?.trim() || "子 Agent 活动",
summary:
shortenText(item.summary, 100) ||
shortenText(item.role, 100) ||
shortenText(item.model, 100) ||
"协作成员正在推进任务。",
timeLabel,
statusLabel,
tone,
icon: Bot,
iconClassName: "text-sky-700",
};
case "warning":
return {
id: item.id,
title: "运行警告",
summary: shortenText(item.message, 100) || "运行过程中出现警告。",
timeLabel,
statusLabel,
tone: "default",
icon: AlertTriangle,
iconClassName: "text-amber-600",
};
case "error":
return {
id: item.id,
title: "执行失败",
summary: shortenText(item.message, 100) || "当前回合执行失败。",
timeLabel,
statusLabel,
tone: "default",
icon: AlertTriangle,
iconClassName: "text-rose-600",
};
case "context_compaction":
return {
id: item.id,
title: item.stage === "started" ? "上下文压缩中" : "上下文压缩完成",
summary:
shortenText(item.detail, 100) ||
shortenText(item.trigger, 100) ||
"会话上下文正在整理。",
timeLabel,
statusLabel,
tone,
icon: Clock3,
iconClassName: "text-slate-500",
};
case "reasoning":
return {
id: item.id,
title: "思考过程",
summary:
shortenText(item.summary?.join(" "), 100) ||
shortenText(item.text, 100) ||
"模型正在整理思路。",
timeLabel,
statusLabel,
tone,
icon: Sparkles,
iconClassName: "text-violet-600",
};
case "plan":
return {
id: item.id,
title: "执行计划",
summary: shortenText(item.text, 100) || "已生成执行计划。",
timeLabel,
statusLabel,
tone,
icon: ListChecks,
iconClassName: "text-slate-600",
};
case "turn_summary":
return {
id: item.id,
title: "回合总结",
summary: shortenText(item.text, 100) || "当前回合已生成总结。",
timeLabel,
statusLabel,
tone,
icon: ListChecks,
iconClassName: "text-slate-600",
};
default:
return null;
}
}
export function CanvasSessionOverviewPanel({
turns,
threadItems,
currentTurnId = null,
pendingActions = [],
queuedTurns = [],
isSending = false,
focusedItemId = null,
}: CanvasSessionOverviewPanelProps) {
const sortedItems = sortThreadItems(threadItems).filter(
(item) => item.type !== "user_message" && item.type !== "agent_message",
);
const currentTurn =
turns.find((turn) => turn.id === currentTurnId) || turns.at(-1) || null;
const turnStatus = resolveTurnStatusLabel(
isSending ? "running" : currentTurn?.status,
);
const inProgressCount = sortedItems.filter(
(item) => item.status === "in_progress",
).length;
const recentActivity = sortedItems
.map((item) => buildActivityView(item))
.filter((item): item is SessionActivityView => Boolean(item))
.slice(-8)
.reverse();
const latestTurnPrompt =
shortenText(currentTurn?.prompt_text, 160) || "当前还没有新的运行输入。";
const latestTurnUpdatedAt = formatTimeLabel(
currentTurn?.updated_at ||
currentTurn?.completed_at ||
currentTurn?.started_at,
);
const focusedActivity =
recentActivity.find((item) => item.id === focusedItemId) ||
recentActivity[0] ||
null;
return (
<section
data-testid="canvas-session-overview-panel"
className="flex min-h-full flex-col gap-4"
>
<section className="rounded-[24px] border border-slate-200 bg-white px-5 py-5 shadow-sm shadow-slate-950/5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-slate-950">
会话过程索引
</div>
<Badge
variant="outline"
className={cn(
turnStatus.tone === "accent" &&
"border-sky-200 bg-sky-50 text-sky-700",
turnStatus.tone === "success" &&
"border-emerald-200 bg-emerald-50 text-emerald-700",
turnStatus.tone === "default" &&
"border-slate-200 bg-white text-slate-600",
)}
>
{turnStatus.label}
</Badge>
{focusedActivity ? (
<Badge
variant="outline"
className="border-slate-200 bg-slate-50 text-slate-600"
>
聚焦 {focusedActivity.title}
</Badge>
) : null}
</div>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-600">
这里只补充当前回合的执行过程、待补信息和排队状态。主稿、Markdown
与图片请直接从对话里的“在画布中打开”入口进入。
</p>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">
当前 turn:{currentTurn?.id || "尚未创建"}
</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">
{inProgressCount > 0
? `进行中 ${inProgressCount}`
: `轨迹 ${sortedItems.length}`}
</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">
{pendingActions.length > 0
? `待补信息 ${pendingActions.length}`
: queuedTurns.length > 0
? `排队 ${queuedTurns.length}`
: "无需跟进"}
</span>
</div>
</div>
<div className="grid min-w-[240px] gap-2 rounded-[20px] border border-slate-200 bg-slate-50 px-4 py-3">
<div>
<div className="text-[11px] font-medium text-slate-500">
最近更新时间
</div>
<div className="mt-1 text-sm font-semibold text-slate-900">
{latestTurnUpdatedAt || "--:--"}
</div>
</div>
<div>
<div className="text-[11px] font-medium text-slate-500">
当前焦点
</div>
<div className="mt-1 text-sm font-semibold text-slate-900">
{focusedActivity?.title || "等待新的执行事件"}
</div>
<div className="mt-1 text-xs text-slate-500">
{focusedActivity?.summary || latestTurnPrompt}
</div>
</div>
</div>
</div>
</section>
<div className="grid min-h-0 gap-4 xl:grid-cols-[minmax(0,1.45fr),minmax(320px,0.9fr)]">
<section className="rounded-[24px] border border-slate-200 bg-white">
<div className="border-b border-slate-200/80 px-5 py-4">
<div className="text-sm font-semibold text-slate-900">
执行时间线
</div>
<div className="mt-1 text-xs leading-5 text-slate-500">
这里展示当前会话的技能、工具、A2UI、文件产物与异常信号,方便直接判断下一步卡在哪里。
</div>
</div>
<div className="divide-y divide-slate-100">
{recentActivity.length > 0 ? (
recentActivity.map((item) => {
const Icon = item.icon;
return (
<div
key={item.id}
className={cn(
"flex items-start gap-3 px-5 py-4 transition-colors",
focusedItemId === item.id && "bg-sky-50/80",
)}
>
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50">
{item.tone === "accent" ? (
<Loader2
className={cn(
"h-4 w-4 animate-spin",
item.iconClassName,
)}
/>
) : (
<Icon className={cn("h-4 w-4", item.iconClassName)} />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate text-sm font-medium text-slate-900">
{item.title}
</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
item.tone === "accent" && "bg-sky-50 text-sky-700",
item.tone === "success" &&
"bg-emerald-50 text-emerald-700",
item.tone === "default" &&
"bg-slate-100 text-slate-600",
)}
>
{item.statusLabel}
</span>
</div>
<div className="mt-1 text-sm leading-6 text-slate-600">
{item.summary}
</div>
</div>
<div className="shrink-0 text-[11px] text-slate-400">
{item.timeLabel || "--:--"}
</div>
</div>
);
})
) : (
<div className="px-5 py-6 text-sm leading-6 text-slate-500">
当前还没有可展示的执行轨迹。新的工具调用、skills、A2UI
与排队变化会出现在这里。
</div>
)}
</div>
</section>
<div className="flex min-h-0 flex-col gap-4">
<section className="rounded-[24px] border border-slate-200 bg-white">
<div className="border-b border-slate-200/80 px-5 py-4">
<div className="text-sm font-semibold text-slate-900">
待处理交互
</div>
<div className="mt-1 text-xs leading-5 text-slate-500">
缺少信息时,这里会明确显示等待补充的信息入口,便于与 A2UI
收集流程对齐。
</div>
</div>
<div className="space-y-3 px-5 py-4">
{pendingActions.length > 0 ? (
pendingActions.slice(0, 4).map((action) => (
<div
key={action.requestId}
className="rounded-[20px] border border-amber-200 bg-amber-50 px-4 py-3"
>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-white px-2 py-0.5 text-[10px] font-medium text-amber-700">
{action.actionType === "tool_confirmation"
? "等待确认"
: "等待补充信息"}
</span>
<span className="text-[11px] text-amber-700/80">
{action.requestId}
</span>
</div>
<div className="mt-2 text-sm leading-6 text-amber-950">
{resolvePendingActionPreview(action)}
</div>
</div>
))
) : (
<div className="rounded-[20px] border border-dashed border-slate-200 bg-slate-50 px-4 py-5 text-sm leading-6 text-slate-500">
当前没有等待中的确认或补充信息请求。
</div>
)}
</div>
</section>
<section className="rounded-[24px] border border-slate-200 bg-white">
<div className="border-b border-slate-200/80 px-5 py-4">
<div className="text-sm font-semibold text-slate-900">
排队消息
</div>
<div className="mt-1 text-xs leading-5 text-slate-500">
多轮追问或连续提交时,这里保留稍后执行的输入快照。
</div>
</div>
<div className="space-y-3 px-5 py-4">
{queuedTurns.length > 0 ? (
queuedTurns.slice(0, 4).map((item, index) => (
<div
key={item.queued_turn_id}
className="rounded-[20px] border border-slate-200 bg-slate-50 px-4 py-3"
>
<div className="flex items-center justify-between gap-3">
<span className="rounded-full bg-white px-2 py-0.5 text-[10px] font-medium text-slate-600">
排队 {index + 1}
</span>
<span className="text-[11px] text-slate-400">
{item.image_count > 0
? `${item.image_count} 张图`
: "文本输入"}
</span>
</div>
<div className="mt-2 text-sm leading-6 text-slate-700">
{shortenText(
item.message_preview || item.message_text,
120,
)}
</div>
</div>
))
) : (
<div className="rounded-[20px] border border-dashed border-slate-200 bg-slate-50 px-4 py-5 text-sm leading-6 text-slate-500">
当前没有排队中的消息。
</div>
)}
</div>
</section>
</div>
</div>
</section>
);
}
export default CanvasSessionOverviewPanel;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,8 @@ const {
mockParseAgentEvent,
mockSafeListen,
mockToast,
mockLoadConfiguredProviders,
mockLoadProviderModels,
mockUseConfiguredProviders,
mockUseProviderModels,
mockProviderPoolGetOverview,
@@ -32,6 +34,8 @@ const {
info: vi.fn(),
warning: vi.fn(),
},
mockLoadConfiguredProviders: vi.fn(),
mockLoadProviderModels: vi.fn(),
mockUseConfiguredProviders: vi.fn(),
mockUseProviderModels: vi.fn(),
mockProviderPoolGetOverview: vi.fn(),
@@ -82,6 +86,7 @@ vi.mock("sonner", () => ({
}));
vi.mock("@/hooks/useConfiguredProviders", () => ({
loadConfiguredProviders: mockLoadConfiguredProviders,
useConfiguredProviders: mockUseConfiguredProviders,
findConfiguredProviderBySelection: (
providers: Array<{ key: string; providerId?: string }>,
@@ -110,6 +115,7 @@ vi.mock("@/hooks/useConfiguredProviders", () => ({
}));
vi.mock("@/hooks/useProviderModels", () => ({
loadProviderModels: mockLoadProviderModels,
useProviderModels: mockUseProviderModels,
}));
@@ -136,12 +142,16 @@ vi.mock("@/lib/api/channelsRuntime", () => ({
import { useAsterAgentChat } from "../hooks/useAsterAgentChat";
import { ChatModelSelector } from "./ChatModelSelector";
interface MountedHarness {
interface MountedRoot {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: MountedHarness[] = [];
interface MountedHarness extends MountedRoot {
getChat: () => ReturnType<typeof useAsterAgentChat>;
}
const mountedRoots: MountedRoot[] = [];
interface MountOptions {
onManageProviders?: () => void;
@@ -183,35 +193,51 @@ function createModel(id: string, providerId: string) {
};
}
function createConfiguredProviderFixtures() {
return [
{ key: "gemini", label: "Gemini", registryId: "gemini", type: "gemini" },
{
key: "deepseek",
label: "DeepSeek",
registryId: "deepseek",
type: "deepseek",
},
];
}
function createProviderModelsFixture(providerKey?: string | null) {
if (providerKey === "gemini") {
return [
createModel("gemini-2.5-pro", "gemini"),
createModel("gemini-2.5-flash", "gemini"),
];
}
if (providerKey === "deepseek") {
return [
createModel("deepseek-chat", "deepseek"),
createModel("deepseek-reasoner", "deepseek"),
];
}
return [];
}
function mount(
workspaceId: string,
options: MountOptions = {},
): HTMLDivElement {
): MountedHarness {
const { onManageProviders, chatModelSelectorProps } = options;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let chatValue: ReturnType<typeof useAsterAgentChat> | null = null;
function TestComponent() {
const chat = useAsterAgentChat({ workspaceId });
chatValue = chat;
return (
<div>
<button
data-testid="switch-topic-a"
onClick={() => {
void chat.switchTopic("topic-a");
}}
>
切到 topic-a
</button>
<button
data-testid="switch-topic-b"
onClick={() => {
void chat.switchTopic("topic-b");
}}
>
切到 topic-b
</button>
<ChatModelSelector
providerType={chat.providerType}
setProviderType={chat.setProviderType}
@@ -233,20 +259,16 @@ function mount(
});
mountedRoots.push({ container, root });
return container;
}
function getButtonByTestId(
container: HTMLElement,
testId: string,
): HTMLButtonElement {
const button = container.querySelector(
`[data-testid="${testId}"]`,
) as HTMLButtonElement | null;
if (!button) {
throw new Error(`未找到按钮: ${testId}`);
}
return button;
return {
container,
root,
getChat: () => {
if (!chatValue) {
throw new Error("chat 尚未初始化");
}
return chatValue;
},
};
}
function getComboboxTrigger(container: HTMLElement): HTMLButtonElement {
@@ -303,6 +325,7 @@ beforeEach(() => {
mockApiKeyProvidersGetProviders.mockResolvedValue([]);
mockEmitProviderDataChanged.mockImplementation(() => {});
mockWechatChannelSetRuntimeModel.mockResolvedValue(undefined);
const configuredProviders = createConfiguredProviderFixtures();
const createdAt = Math.floor(Date.now() / 1000);
mockListAgentRuntimeSessions.mockResolvedValue([
@@ -333,33 +356,14 @@ beforeEach(() => {
}));
mockUseConfiguredProviders.mockReturnValue({
providers: [
{ key: "gemini", label: "Gemini", registryId: "gemini", type: "gemini" },
{
key: "deepseek",
label: "DeepSeek",
registryId: "deepseek",
type: "deepseek",
},
],
providers: configuredProviders,
loading: false,
});
mockLoadConfiguredProviders.mockResolvedValue(configuredProviders);
mockUseProviderModels.mockImplementation(
(selectedProvider: { key: string } | null) => {
const key = selectedProvider?.key;
const models =
key === "gemini"
? [
createModel("gemini-2.5-pro", "gemini"),
createModel("gemini-2.5-flash", "gemini"),
]
: key === "deepseek"
? [
createModel("deepseek-chat", "deepseek"),
createModel("deepseek-reasoner", "deepseek"),
]
: [];
const models = createProviderModelsFixture(selectedProvider?.key);
return {
modelIds: models.map((item) => item.id),
@@ -369,6 +373,10 @@ beforeEach(() => {
};
},
);
mockLoadProviderModels.mockImplementation(
async (selectedProvider?: { key?: string | null } | null) =>
createProviderModelsFixture(selectedProvider?.key),
);
});
afterEach(() => {
@@ -387,15 +395,17 @@ afterEach(() => {
describe("ChatModelSelector + useAsterAgentChat 集成", () => {
it("通过 UI 选择模型后切换话题再切回,应恢复会话模型", async () => {
const workspaceId = "ws-model-selector-integration";
const container = mount(workspaceId);
const harness = mount(workspaceId);
const { container } = harness;
await flushEffects();
await flushEffects();
await act(async () => {
getButtonByTestId(container, "switch-topic-a").click();
await harness.getChat().switchTopic("topic-a");
});
await flushEffects();
await flushEffects();
await act(async () => {
getComboboxTrigger(container).click();
@@ -413,9 +423,10 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => {
await flushEffects();
await act(async () => {
getButtonByTestId(container, "switch-topic-b").click();
await harness.getChat().switchTopic("topic-b");
});
await flushEffects();
await flushEffects();
await act(async () => {
getComboboxTrigger(container).click();
@@ -433,9 +444,10 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => {
await flushEffects();
await act(async () => {
getButtonByTestId(container, "switch-topic-a").click();
await harness.getChat().switchTopic("topic-a");
});
await flushEffects();
await flushEffects();
const currentModel = container.querySelector(
'[data-testid="current-model"]',
@@ -467,6 +479,7 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => {
providers: [],
loading: false,
});
mockLoadConfiguredProviders.mockResolvedValue([]);
mockUseProviderModels.mockReturnValue({
modelIds: [],
models: [],
@@ -475,7 +488,7 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => {
});
const onManageProviders = vi.fn();
const container = mount("ws-no-provider-guide", { onManageProviders });
const { container } = mount("ws-no-provider-guide", { onManageProviders });
await flushEffects();
@@ -489,8 +502,41 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => {
expect(onManageProviders).toHaveBeenCalledTimes(1);
});
it("无 Provider 时应支持关闭配置引导", async () => {
mockUseConfiguredProviders.mockReturnValue({
providers: [],
loading: false,
});
mockLoadConfiguredProviders.mockResolvedValue([]);
mockUseProviderModels.mockReturnValue({
modelIds: [],
models: [],
loading: false,
error: null,
});
const { container } = mount("ws-no-provider-guide-dismiss");
await flushEffects();
expect(container.textContent).toContain("工具模型未配置");
const dismissButton = container.querySelector(
'button[aria-label="关闭工具模型未配置提示"]',
) as HTMLButtonElement | null;
if (!dismissButton) {
throw new Error("未找到关闭引导按钮");
}
await act(async () => {
dismissButton.click();
});
expect(container.textContent ?? "").not.toContain("工具模型未配置");
});
it("关闭后台预加载时,应在打开选择器后再加载 Provider 和模型", async () => {
const container = mount("ws-model-selector-lazy-provider-load", {
const { container } = mount("ws-model-selector-lazy-provider-load", {
chatModelSelectorProps: {
backgroundPreload: "disabled",
},
@@ -249,7 +249,12 @@ describe("EmptyState", () => {
expect(container.textContent).toContain("创作");
expect(container.textContent).toContain("青柠一下,灵感即来");
expect(container.textContent).toContain("说一句目标,剩下的交给 Lime。");
expect(container.textContent).toContain("成功做法会沉淀成技能");
expect(container.textContent).toContain(
"文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。",
);
expect(container.textContent).toContain(
"跑通过的方法会沉淀成技能、偏好和项目上下文,下次不用重新开始。",
);
expect(container.textContent).not.toContain("新建任务");
});
@@ -267,17 +272,17 @@ describe("EmptyState", () => {
expect(container.textContent).toContain("推荐方案");
expect(container.textContent).toContain("网页研究简报");
expect(container.textContent).toContain("内容主稿生成");
expect(container.textContent).toContain("前端概念方案");
expect(container.textContent).toContain("演示提纲草案");
expect(container.textContent).toContain("网页登录与采集");
expect(container.textContent).toContain("多代理拆任务");
expect(container.textContent).toContain("复制轮播帖");
expect(container.textContent).toContain("复制视频脚本");
expect(container.textContent).toContain("文章转 Slide 视频提纲");
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("文章转 Slide 视频提纲");
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("从这里开始");
expect(container.textContent).not.toContain("快速启动");
expect(container.textContent).not.toContain("生成配图");
@@ -293,17 +298,10 @@ describe("EmptyState", () => {
expect(recommendationTitles).toEqual([
"网页研究简报",
"内容主稿生成",
"前端概念方案",
"演示提纲草案",
"网页登录与采集",
"多代理拆任务",
"复制轮播帖",
"复制视频脚本",
"文章转 Slide 视频提纲",
"云端视频配音",
"视频配音成其他语言",
"每日趋势摘要",
"账号增长跟踪",
]);
});
@@ -329,6 +327,10 @@ describe("EmptyState", () => {
expect(container.textContent).toContain(label);
}
expect(container.textContent).toContain("重复任务可持续推进");
expect(container.textContent).toContain("复杂任务可拆分并行推进");
expect(container.textContent).toContain("网页登录与网页执行");
for (const alt of expectedAlts) {
const image = container.querySelector(`img[alt="${alt}"]`);
expect(image).toBeTruthy();
@@ -445,36 +447,7 @@ describe("EmptyState", () => {
);
});
it("点击多代理拆任务应开启多代理偏好并写入起始动作", async () => {
const setInput = vi.fn<(value: string) => void>();
const onSubagentEnabledChange = vi.fn<(enabled: boolean) => void>();
const container = renderEmptyState({
activeTheme: "general",
setInput,
onSubagentEnabledChange,
subagentEnabled: false,
});
await act(async () => {
await Promise.resolve();
});
const card = container.querySelector(
'[data-testid="entry-recommended-team-breakdown"]',
) as HTMLDivElement | null;
expect(card).toBeTruthy();
act(() => {
card?.click();
});
expect(onSubagentEnabledChange).toHaveBeenCalledWith(true);
expect(setInput).toHaveBeenCalledWith(
"请把这个任务按多代理方式拆解:先定义目标和约束,再拆成并行子任务,明确每个子代理的职责、产出和回收方式。",
);
});
it("指定 service skill 即使未从运行时目录注入,也应从 seeded 目录拼接到推荐区尾部", async () => {
it("指定 service skill 即使未从运行时目录注入,也应从 seeded 目录拼接到首页精选区尾部", async () => {
const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>();
const container = renderEmptyState({
activeTheme: "general",
@@ -486,11 +459,11 @@ describe("EmptyState", () => {
await Promise.resolve();
});
expect(container.textContent).toContain("每日趋势摘要");
expect(container.textContent).toContain("复制视频脚本");
expect(container.textContent).not.toContain("GitHub 仓库线索检索");
const card = container.querySelector(
'[data-testid="entry-service-skill-daily-trend-briefing"]',
'[data-testid="entry-service-skill-short-video-script-replication"]',
) as HTMLButtonElement | null;
expect(card).toBeTruthy();
@@ -500,8 +473,8 @@ describe("EmptyState", () => {
expect(onSelectServiceSkill).toHaveBeenCalledWith(
expect.objectContaining({
id: "daily-trend-briefing",
title: "每日趋势摘要",
id: "short-video-script-replication",
title: "复制视频脚本",
}),
);
});
@@ -736,9 +709,10 @@ describe("EmptyState", () => {
);
});
it("点击地球按钮应切换联网搜索开关", async () => {
it("点击高级设置中的地球按钮应切换联网搜索开关", async () => {
const onWebSearchEnabledChange = vi.fn<(enabled: boolean) => void>();
const container = renderEmptyState({
activeTheme: "general",
webSearchEnabled: false,
onWebSearchEnabledChange,
});
@@ -746,18 +720,133 @@ describe("EmptyState", () => {
await Promise.resolve();
});
const advancedToggle = container.querySelector(
'[data-testid="empty-state-advanced-toggle"]',
) as HTMLButtonElement | null;
expect(advancedToggle).toBeTruthy();
const globeToggle = container.querySelector(
'button[title="联网搜索已关闭"]',
) as HTMLButtonElement | null;
expect(globeToggle).toBeTruthy();
expect(globeToggle).toBeNull();
act(() => {
globeToggle?.click();
advancedToggle?.click();
});
const expandedGlobeToggle = container.querySelector(
'button[title="联网搜索已关闭"]',
) as HTMLButtonElement | null;
expect(expandedGlobeToggle).toBeTruthy();
act(() => {
expandedGlobeToggle?.click();
});
expect(onWebSearchEnabledChange).toHaveBeenCalledWith(true);
});
it("通用主题默认只保留最小主路径,展开高级设置后才显示进阶控制", async () => {
const onThinkingEnabledChange = vi.fn<(enabled: boolean) => void>();
const onWebSearchEnabledChange = vi.fn<(enabled: boolean) => void>();
const onSubagentEnabledChange = vi.fn<(enabled: boolean) => void>();
const setExecutionStrategy =
vi.fn<(strategy: "react" | "code_orchestrated" | "auto") => void>();
const setAccessMode =
vi.fn<(mode: "read-only" | "current" | "full-access") => void>();
const container = renderEmptyState({
activeTheme: "general",
thinkingEnabled: false,
onThinkingEnabledChange,
webSearchEnabled: false,
onWebSearchEnabledChange,
subagentEnabled: false,
onSubagentEnabledChange,
executionStrategy: "react",
setExecutionStrategy,
accessMode: "current",
setAccessMode,
});
await act(async () => {
await Promise.resolve();
});
const attachButton = container.querySelector(
'button[title="添加图片"]',
) as HTMLButtonElement | null;
expect(attachButton).toBeTruthy();
const advancedToggle = container.querySelector(
'[data-testid="empty-state-advanced-toggle"]',
) as HTMLButtonElement | null;
expect(advancedToggle).toBeTruthy();
expect(container.querySelector('button[title="深度思考已关闭"]')).toBeNull();
expect(container.querySelector('button[title="联网搜索已关闭"]')).toBeNull();
expect(
container.querySelector('[data-testid="inputbar-plan-toggle"]'),
).toBeNull();
expect(container.querySelector('button[title="多代理偏好已关闭"]')).toBeNull();
expect(
container.querySelector('[data-testid="inputbar-access-mode-select"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="chat-model-selector"]'),
).toBeNull();
expect(container.textContent).not.toContain("通用任务上下文");
act(() => {
advancedToggle?.click();
});
const thinkingButton = container.querySelector(
'button[title="深度思考已关闭"]',
) as HTMLButtonElement | null;
expect(thinkingButton).toBeTruthy();
const globeButton = container.querySelector(
'button[title="联网搜索已关闭"]',
) as HTMLButtonElement | null;
expect(globeButton).toBeTruthy();
const planButton = container.querySelector(
'[data-testid="inputbar-plan-toggle"]',
) as HTMLButtonElement | null;
expect(planButton).toBeTruthy();
const subagentButton = container.querySelector(
'button[title="多代理偏好已关闭"]',
) as HTMLButtonElement | null;
expect(subagentButton).toBeTruthy();
const accessModeSelect = container.querySelector(
'[data-testid="inputbar-access-mode-select"]',
) as HTMLSelectElement | null;
expect(accessModeSelect).toBeTruthy();
expect(
container.querySelector('[data-testid="chat-model-selector"]'),
).toBeTruthy();
expect(container.textContent).toContain("通用任务上下文");
act(() => {
thinkingButton?.click();
});
act(() => {
globeButton?.click();
});
act(() => {
planButton?.click();
});
act(() => {
subagentButton?.click();
});
act(() => {
accessModeSelect!.value = "full-access";
accessModeSelect?.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onThinkingEnabledChange).toHaveBeenCalledWith(true);
expect(onWebSearchEnabledChange).toHaveBeenCalledWith(true);
expect(setExecutionStrategy).toHaveBeenCalledWith("code_orchestrated");
expect(onSubagentEnabledChange).toHaveBeenCalledWith(true);
expect(setAccessMode).toHaveBeenCalledWith("full-access");
});
it("通用首页发送时不应自动注入任何历史默认 skill", async () => {
const onSend =
vi.fn<
@@ -882,57 +971,6 @@ describe("EmptyState", () => {
);
});
it("通用主题工具栏应包含附件、思考、Plan 与多代理开关", async () => {
const onThinkingEnabledChange = vi.fn<(enabled: boolean) => void>();
const onSubagentEnabledChange = vi.fn<(enabled: boolean) => void>();
const setExecutionStrategy =
vi.fn<(strategy: "react" | "code_orchestrated" | "auto") => void>();
const container = renderEmptyState({
activeTheme: "general",
thinkingEnabled: false,
onThinkingEnabledChange,
subagentEnabled: false,
onSubagentEnabledChange,
executionStrategy: "react",
setExecutionStrategy,
});
await act(async () => {
await Promise.resolve();
});
const attachButton = container.querySelector(
'button[title="添加图片"]',
) as HTMLButtonElement | null;
expect(attachButton).toBeTruthy();
const thinkingButton = container.querySelector(
'button[title="深度思考已关闭"]',
) as HTMLButtonElement | null;
expect(thinkingButton).toBeTruthy();
const planButton = container.querySelector(
'[data-testid="inputbar-plan-toggle"]',
) as HTMLButtonElement | null;
expect(planButton).toBeTruthy();
const subagentButton = container.querySelector(
'button[title="多代理偏好已关闭"]',
) as HTMLButtonElement | null;
expect(subagentButton).toBeTruthy();
act(() => {
thinkingButton?.click();
});
act(() => {
planButton?.click();
});
act(() => {
subagentButton?.click();
});
expect(onThinkingEnabledChange).toHaveBeenCalledWith(true);
expect(setExecutionStrategy).toHaveBeenCalledWith("code_orchestrated");
expect(onSubagentEnabledChange).toHaveBeenCalledWith(true);
});
it("通用主题应提供浏览器协助入口并触发启动回调", async () => {
const onLaunchBrowserAssist = vi.fn();
const container = renderEmptyState({
@@ -257,14 +257,15 @@ type RecommendationShelfItem =
};
const GENERAL_CATEGORY_LABEL = "通用对话";
const FEATURED_HOME_RECOMMENDED_SOLUTION_IDS = [
"web-research-brief",
"social-post-starter",
"slide-outline",
"browser-assist-task",
] as const;
const FEATURED_HOME_SERVICE_SKILL_IDS = [
"carousel-post-replication",
"short-video-script-replication",
"article-to-slide-video-outline",
"cloud-video-dubbing",
"video-dubbing-language",
"daily-trend-briefing",
"account-performance-tracking",
] as const;
function resolveFeaturedSkillExecutionKind(
@@ -339,7 +340,7 @@ const THEME_WORKBENCH_COPY: Record<
title: "",
description: "说一句目标,剩下的交给 Lime。",
supportingDescription:
"文案、图片、视频、搜索、整理与网页执行可以围绕同一目标持续推进;成功做法会沉淀成技能,偏好、参考与成果会逐渐沉淀成个人资产。",
"文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。跑通过的方法会沉淀成技能、偏好和项目上下文,下次不用重新开始。",
},
};
@@ -738,7 +739,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
title: "技能",
value: skillSummaryLabel,
description:
"把跑通过的提示、步骤和工具组合沉淀下来,下次遇到同类任务可以直接复用。",
"把常用做法沉淀下来,下次遇到同类任务可以直接复用。",
icon: <Lightbulb className="h-5 w-5" />,
imageSrc: capabilitySkillsPlaceholder,
imageAlt: "技能能力卡占位图",
@@ -748,9 +749,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
key: "automation",
eyebrow: "支撑能力",
title: "自动化",
value: planEnabled ? "当前会按步骤推进" : "重复流程可持续跑起来",
value: planEnabled ? "当前会按步骤推进" : "重复任务可持续推进",
description:
"适合长链路处理、批量任务和持续产出,让重复动作不再每次都从头手动重做。",
"适合批量任务和重复流程,让常做的事情持续推进,不再每次从头开始。",
icon: <ListChecks className="h-5 w-5" />,
imageSrc: capabilityAutomationsPlaceholder,
imageAlt: "自动化能力卡占位图",
@@ -761,10 +762,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
eyebrow: "支撑能力",
title: "多代理",
value: subagentEnabled
? "当前任务支持并行协作"
: "复杂任务可拆成并行分工",
? "当前任务支持协作分工"
: "复杂任务可拆分并行推进",
description:
"当研究、方案和执行需要同时推进时,可把任务拆给多个代理并行处理,再统一回收结论。",
"当一个目标需要调研、方案和执行同时推进时,可以拆成协作分工后统一回收结论。",
icon: <Workflow className="h-5 w-5" />,
imageSrc: capabilityAgentTeamsPlaceholder,
imageAlt: "多代理协作能力卡占位图",
@@ -776,9 +777,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
key: "browser",
eyebrow: "支撑能力",
title: "浏览器接入",
value: browserAssistLoading ? "正在检查连接状态" : "CDP / 浏览器插件复用",
value: browserAssistLoading ? "正在检查连接状态" : "网页登录与网页执行",
description:
"登录、验证和网页动作可直接复用 CDP 或浏览器插件连接,不必再切到单独工作台。",
"登录、验证和网页操作可以直接在当前任务里继续,不必再切到单独工作台。",
icon: <Globe className="h-5 w-5" />,
imageSrc: capabilityBrowserAssistPlaceholder,
imageAlt: "浏览器接入能力卡占位图",
@@ -805,6 +806,11 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const recommendationShelfItems = useMemo<RecommendationShelfItem[]>(() => {
const solutionRecommendations = entryRecommendedSolutions
.filter((solution) =>
FEATURED_HOME_RECOMMENDED_SOLUTION_IDS.includes(
solution.id as (typeof FEATURED_HOME_RECOMMENDED_SOLUTION_IDS)[number],
),
)
.map((solution) => ({
kind: "solution" as const,
key: solution.id,
@@ -234,8 +234,22 @@ function renderStatefulPanel(
return container;
}
function expandAdvancedControls(container: HTMLDivElement) {
const toggleButton = container.querySelector(
'[data-testid="empty-state-advanced-toggle"]',
) as HTMLButtonElement | null;
expect(toggleButton).toBeTruthy();
act(() => {
toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
return toggleButton;
}
describe("EmptyStateComposerPanel", () => {
it("首页空态输入区应保留技能下拉,但与 @ 面板共用同一技能入口", () => {
it("首页空态输入区默认隐藏技能入口,展开高级设置后与 @ 面板共用同一技能入口", () => {
const container = renderPanel({
isGeneralTheme: true,
skillSelection: createSkillSelection({
@@ -255,6 +269,12 @@ describe("EmptyStateComposerPanel", () => {
expect(
container.querySelector('[data-testid="empty-state-character-mention"]'),
).toBeTruthy();
expect(
container.querySelector('[data-testid="empty-state-skill-selector"]'),
).toBeNull();
expandAdvancedControls(container);
expect(
container.querySelector('[data-testid="empty-state-skill-selector"]'),
).toBeTruthy();
@@ -412,12 +432,18 @@ describe("EmptyStateComposerPanel", () => {
subagentEnabled: true,
});
expect(
container.querySelector('[data-testid="empty-state-team-selector"]'),
).toBeNull();
expandAdvancedControls(container);
expect(
container.querySelector('[data-testid="empty-state-team-selector"]'),
).toBeTruthy();
});
it("未开启 Team mode 时应只保留图标开关,不再显示重复的文字入口", () => {
it("未开启 Team mode 时默认不暴露多代理开关,只保留高级设置入口", () => {
const container = renderPanel({
isGeneralTheme: true,
subagentEnabled: false,
@@ -431,21 +457,27 @@ describe("EmptyStateComposerPanel", () => {
'[data-testid="empty-state-team-mode-enable-button"]',
),
).toBeNull();
const toggleButton = container.querySelector(
'button[title="多代理偏好已关闭"]',
) as HTMLButtonElement | null;
expect(toggleButton).toBeTruthy();
expect(
container.querySelector('button[title="多代理偏好已关闭"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="empty-state-advanced-toggle"]'),
).toBeTruthy();
});
it("应通过 Plan 开关透传执行策略切换,不再渲染执行模式下拉", () => {
it("应通过高级设置中的 Plan 开关透传执行策略切换,不再渲染执行模式下拉", () => {
const setExecutionStrategy = vi.fn();
const container = renderPanel({
executionStrategy: "react",
setExecutionStrategy,
});
expect(
container.querySelector('[data-testid="inputbar-plan-toggle"]'),
).toBeNull();
expandAdvancedControls(container);
const planToggle = container.querySelector(
'[data-testid="inputbar-plan-toggle"]',
) as HTMLButtonElement | null;
@@ -479,6 +511,9 @@ describe("EmptyStateComposerPanel", () => {
expect(enableButton).toBeNull();
expect(
container.querySelector('button[title="多代理偏好已关闭"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="empty-state-advanced-toggle"]'),
).toBeTruthy();
});
@@ -499,6 +534,8 @@ describe("EmptyStateComposerPanel", () => {
it("点击多代理图标后应自动透传 Team 配置面板打开令牌", async () => {
const container = renderStatefulPanel();
expandAdvancedControls(container);
const enableButton = container.querySelector(
'button[title="多代理偏好已关闭"]',
) as HTMLButtonElement | null;
@@ -530,6 +567,8 @@ describe("EmptyStateComposerPanel", () => {
true,
);
expandAdvancedControls(container);
expect(
container.querySelector('[data-testid="empty-state-team-selector"]'),
).toBeTruthy();
@@ -574,19 +613,44 @@ describe("EmptyStateComposerPanel", () => {
).toBeNull();
expect(
container.querySelector('button[title="多代理偏好已关闭"]'),
).toBeTruthy();
).toBeNull();
expect(enableButton).toBeTruthy();
expect(enableButton?.textContent).toContain("启用 Team");
expect(container.textContent).toContain("当前任务更适合 Team 协作");
});
it("应渲染权限模式选择并透传切换", () => {
it("折叠态应保留当前模型轻提示,展开高级设置后再允许修改", () => {
const container = renderPanel({
providerType: "claude",
model: "claude-sonnet-4-5",
});
expect(container.textContent).toContain("当前模型");
expect(container.textContent).toContain("claude-sonnet-4-5");
expect(
container.querySelector('[data-testid="empty-state-model-selector"]'),
).toBeNull();
expandAdvancedControls(container);
expect(
container.querySelector('[data-testid="empty-state-model-selector"]'),
).toBeTruthy();
});
it("应在高级设置中渲染权限模式选择并透传切换", () => {
const setAccessMode = vi.fn();
const container = renderPanel({
accessMode: "current",
setAccessMode,
});
expect(
container.querySelector('[data-testid="inputbar-access-mode-select"]'),
).toBeNull();
expandAdvancedControls(container);
const select = container.querySelector(
'[data-testid="inputbar-access-mode-select"]',
) as HTMLSelectElement | null;
@@ -1,5 +1,5 @@
import React, { useMemo, useRef, useState } from "react";
import { Globe } from "lucide-react";
import { ChevronDown, ChevronUp, Globe, Settings2 } from "lucide-react";
import {
Select,
SelectContent,
@@ -26,12 +26,19 @@ import {
EMPTY_STATE_PASSIVE_BADGE_CLASSNAME,
EMPTY_STATE_SELECT_TRIGGER_CLASSNAME,
} from "./emptyStateSurfaceTokens";
import {
MetaToggleButton,
MetaToggleCheck,
MetaToggleGlyph,
MetaToggleLabel,
} from "./Inputbar/styles";
import { getTeamSuggestion } from "../utils/teamSuggestion";
import {
buildSkillSelectionBindings,
type SkillSelectionProps,
} from "../skill-selection/skillSelectionBindings";
import type { AgentAccessMode } from "../hooks/agentChatStorage";
import { getProviderLabel } from "@/lib/constants/providerMappings";
interface EmptyStateComposerPanelProps {
input: string;
@@ -122,6 +129,7 @@ export function EmptyStateComposerPanel({
const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState<
number | null
>(null);
const [showAdvancedControls, setShowAdvancedControls] = useState(false);
const activeSkill = skillSelection.activeSkill ?? null;
const clearActiveSkill = skillSelection.onClearSkill;
const { mentionProps: mentionSkillProps, selectorProps: skillSelectorProps } =
@@ -203,94 +211,157 @@ export function EmptyStateComposerPanel({
) : undefined;
const shouldShowThemeSpecificExtra = showCreationModeSelector;
const shouldShowModelExtra = Boolean(providerType?.trim() && model?.trim());
const shouldShowLeftExtra =
const trimmedProviderType = providerType.trim();
const trimmedModel = model.trim();
const hasConfiguredModel = Boolean(trimmedProviderType && trimmedModel);
const shouldShowModelControls = true;
const currentModelSummary = hasConfiguredModel
? `${getProviderLabel(trimmedProviderType)} / ${trimmedModel}`
: null;
const hasHighlightedAdvancedPreference =
thinkingEnabled ||
webSearchEnabled ||
subagentEnabled ||
executionStrategy === "code_orchestrated" ||
accessMode === "read-only" ||
accessMode === "full-access";
const shouldShowAdvancedToggle =
isGeneralTheme ||
shouldShowTeamSelector ||
Boolean(setExecutionStrategy) ||
shouldShowModelExtra ||
shouldShowModelControls ||
Boolean(setAccessMode) ||
shouldShowThemeSpecificExtra;
const leftExtra = shouldShowLeftExtra ? (
const leftExtra = shouldShowAdvancedToggle ? (
<>
{isGeneralTheme ? <SkillSelector {...skillSelectorProps} /> : null}
{shouldShowTeamSelector ? (
<TeamSelector
activeTheme={activeTheme}
input={input}
autoOpenToken={teamSelectorAutoOpenToken}
selectedTeam={selectedTeam}
workspaceSettings={teamWorkspaceSettings}
onPersistCustomTeams={onPersistCustomTeams}
onSelectTeam={(team) => onSelectTeam?.(team)}
<MetaToggleButton
type="button"
$checked={showAdvancedControls || hasHighlightedAdvancedPreference}
aria-label={showAdvancedControls ? "收起高级设置" : "展开高级设置"}
aria-expanded={showAdvancedControls}
data-testid="empty-state-advanced-toggle"
title={showAdvancedControls ? "收起高级设置" : "展开高级设置"}
onClick={() => setShowAdvancedControls((previous) => !previous)}
>
<MetaToggleCheck
$checked={showAdvancedControls || hasHighlightedAdvancedPreference}
aria-hidden
/>
) : null}
<MetaToggleGlyph aria-hidden>
<Settings2 strokeWidth={1.8} />
</MetaToggleGlyph>
<MetaToggleLabel>高级设置</MetaToggleLabel>
{showAdvancedControls ? (
<ChevronUp className="h-3.5 w-3.5" aria-hidden />
) : (
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
)}
</MetaToggleButton>
<InputbarExecutionStrategySelect
executionStrategy={executionStrategy}
setExecutionStrategy={setExecutionStrategy}
/>
<InputbarModelExtra
providerType={providerType}
setProviderType={setProviderType}
model={model}
setModel={setModel}
activeTheme={activeTheme}
onManageProviders={onManageProviders}
/>
<InputbarAccessModeSelect
accessMode={accessMode}
setAccessMode={setAccessMode}
/>
{showCreationModeSelector ? (
<Select
value={creationMode}
onValueChange={(value) =>
onCreationModeChange?.(value as CreationMode)
}
>
<SelectTrigger
className={`${EMPTY_STATE_SELECT_TRIGGER_CLASSNAME} min-w-[120px]`}
>
<div className="flex items-center gap-2">
{CREATION_MODE_CONFIG[creationMode].icon}
<span>{CREATION_MODE_CONFIG[creationMode].name}</span>
</div>
</SelectTrigger>
<SelectContent className="min-w-[200px] p-1" side="top">
<div className="px-2 py-1.5 text-xs font-medium text-slate-500">
选择创作模式
</div>
{(
Object.entries(CREATION_MODE_CONFIG) as [
CreationMode,
(typeof CREATION_MODE_CONFIG)[CreationMode],
][]
).map(([key, config]) => (
<SelectItem key={key} value={key}>
<div className="flex items-center gap-3">
<span className="flex-shrink-0">{config.icon}</span>
<span className="font-medium">{config.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
{isGeneralTheme ? (
{!showAdvancedControls && currentModelSummary ? (
<Badge
variant="outline"
className={EMPTY_STATE_PASSIVE_BADGE_CLASSNAME}
title={`当前模型:${currentModelSummary}`}
>
<Globe className="mr-1 h-3.5 w-3.5" />
通用任务上下文
<span className="mr-1 text-slate-500">当前模型</span>
<span className="max-w-[220px] truncate">{trimmedModel}</span>
</Badge>
) : null}
{!showAdvancedControls && !hasConfiguredModel ? (
<InputbarModelExtra
providerType={providerType}
setProviderType={setProviderType}
model={model}
setModel={setModel}
activeTheme={activeTheme}
onManageProviders={onManageProviders}
/>
) : null}
{showAdvancedControls ? (
<>
{isGeneralTheme ? <SkillSelector {...skillSelectorProps} /> : null}
{shouldShowTeamSelector ? (
<TeamSelector
activeTheme={activeTheme}
input={input}
autoOpenToken={teamSelectorAutoOpenToken}
selectedTeam={selectedTeam}
workspaceSettings={teamWorkspaceSettings}
onPersistCustomTeams={onPersistCustomTeams}
onSelectTeam={(team) => onSelectTeam?.(team)}
/>
) : null}
<InputbarExecutionStrategySelect
executionStrategy={executionStrategy}
setExecutionStrategy={setExecutionStrategy}
/>
<InputbarModelExtra
providerType={providerType}
setProviderType={setProviderType}
model={model}
setModel={setModel}
activeTheme={activeTheme}
onManageProviders={onManageProviders}
/>
<InputbarAccessModeSelect
accessMode={accessMode}
setAccessMode={setAccessMode}
/>
{showCreationModeSelector ? (
<Select
value={creationMode}
onValueChange={(value) =>
onCreationModeChange?.(value as CreationMode)
}
>
<SelectTrigger
className={`${EMPTY_STATE_SELECT_TRIGGER_CLASSNAME} min-w-[120px]`}
>
<div className="flex items-center gap-2">
{CREATION_MODE_CONFIG[creationMode].icon}
<span>{CREATION_MODE_CONFIG[creationMode].name}</span>
</div>
</SelectTrigger>
<SelectContent className="min-w-[200px] p-1" side="top">
<div className="px-2 py-1.5 text-xs font-medium text-slate-500">
选择创作模式
</div>
{(
Object.entries(CREATION_MODE_CONFIG) as [
CreationMode,
(typeof CREATION_MODE_CONFIG)[CreationMode],
][]
).map(([key, config]) => (
<SelectItem key={key} value={key}>
<div className="flex items-center gap-3">
<span className="flex-shrink-0">{config.icon}</span>
<span className="font-medium">{config.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
{isGeneralTheme ? (
<Badge
variant="outline"
className={EMPTY_STATE_PASSIVE_BADGE_CLASSNAME}
>
<Globe className="mr-1 h-3.5 w-3.5" />
通用任务上下文
</Badge>
) : null}
</>
) : null}
</>
) : undefined;
@@ -340,6 +411,7 @@ export function EmptyStateComposerPanel({
visualVariant="floating"
topExtra={topExtra}
leftExtra={leftExtra}
showMetaTools={showAdvancedControls}
/>
</>
);
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { ChevronDown, ExternalLink, Loader2 } from "lucide-react";
import { ChevronDown, ExternalLink, FileText, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { MarkdownRenderer } from "./MarkdownRenderer";
import {
@@ -19,8 +19,9 @@ import {
import {
normalizeSiteToolResultSummary,
resolveSiteAdapterSourceLabel,
resolveSiteSavedContentTargetRelativePath,
resolveSiteProjectSourceLabel,
resolveSiteSavedContentTarget,
resolveSiteSavedContentTargetFromMetadata,
} from "../utils/siteToolResultSummary";
interface InlineToolProcessStepProps {
@@ -187,13 +188,13 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
() => normalizeToolResultImages(toolCall.result?.images, resultText) || [],
[resultText, toolCall.result?.images],
);
const siteSummary = useMemo(
() => normalizeSiteToolResultSummary(toolCall.result?.metadata),
const savedSiteContentTarget = useMemo(
() => resolveSiteSavedContentTargetFromMetadata(toolCall.result?.metadata),
[toolCall.result?.metadata],
);
const savedSiteContentTarget = useMemo(
() => resolveSiteSavedContentTarget(siteSummary),
[siteSummary],
const savedSiteContentRelativePath = useMemo(
() => resolveSiteSavedContentTargetRelativePath(savedSiteContentTarget),
[savedSiteContentTarget],
);
const siteNoticeLines = useMemo(
() => buildSiteNoticeLines(toolCall),
@@ -346,14 +347,27 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
<div>
<button
type="button"
className="inline-flex items-center rounded-md border border-emerald-300 px-2 py-1 text-xs font-medium text-emerald-700 transition-colors hover:bg-emerald-50"
className="flex w-full items-center gap-3 rounded-xl border border-emerald-300 bg-emerald-50/70 px-3 py-2 text-left transition-colors hover:bg-emerald-100/70"
onClick={() =>
onOpenSavedSiteContent(savedSiteContentTarget)
}
>
{savedSiteContentTarget.preferredTarget === "project_file"
? "在下方预览导出 Markdown"
: "打开已保存内容"}
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-emerald-200 bg-white text-emerald-700">
<FileText className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium leading-5 text-emerald-900">
{savedSiteContentTarget.preferredTarget === "project_file"
? "在下方预览导出 Markdown"
: "打开已保存内容"}
</span>
{savedSiteContentRelativePath ? (
<span className="block truncate text-[11px] leading-5 text-emerald-700/80">
{savedSiteContentRelativePath}
</span>
) : null}
</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-emerald-700" />
</button>
</div>
) : null}
@@ -1,4 +1,5 @@
import React, { useState } from "react";
import { ChevronDown, ChevronUp, Settings2 } from "lucide-react";
import type { ChatInputAdapter } from "@/components/input-kit/adapters/types";
import type { Character } from "@/lib/api/memory";
import type {
@@ -29,6 +30,14 @@ import type {
WorkflowQuickAction,
WorkflowStep,
} from "../../../utils/workflowInputState";
import { Badge } from "@/components/ui/badge";
import {
MetaToggleButton,
MetaToggleCheck,
MetaToggleGlyph,
MetaToggleLabel,
} from "../styles";
import { getProviderLabel } from "@/lib/constants/providerMappings";
interface InputbarComposerSectionProps {
renderWorkflowGeneratingPanel: boolean;
@@ -112,6 +121,7 @@ export const InputbarComposerSection: React.FC<
const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState<
number | null
>(null);
const [showAdvancedControls, setShowAdvancedControls] = useState(false);
const showSkillSelector =
!isWorkspaceVariant && isGeneralResearchTheme(activeTheme);
const currentPendingImages =
@@ -121,6 +131,14 @@ export const InputbarComposerSection: React.FC<
buildSkillSelectionBindings(skillSelection);
const resolvedProviderType = inputAdapter.model?.providerType;
const resolvedModel = inputAdapter.model?.model;
const trimmedProviderType = resolvedProviderType?.trim() || "";
const trimmedModel = resolvedModel?.trim() || "";
const shouldShowModelControls = !isWorkspaceVariant;
const hasConfiguredModel = Boolean(trimmedProviderType && trimmedModel);
const currentModelSummary =
shouldShowModelControls && hasConfiguredModel
? `${getProviderLabel(trimmedProviderType)} / ${trimmedModel}`
: null;
const resolvedSetProviderType =
inputAdapter.actions.setProviderType || (() => undefined);
const resolvedSetModel = inputAdapter.actions.setModel || (() => undefined);
@@ -151,6 +169,113 @@ export const InputbarComposerSection: React.FC<
}
onToolClick(tool);
};
const shouldShowTeamSelector =
isGeneralResearchTheme(activeTheme) && activeTools["subagent_mode"];
const hasHighlightedAdvancedPreference =
activeTools["thinking"] ||
activeTools["web_search"] ||
activeTools["subagent_mode"] ||
executionStrategy === "code_orchestrated" ||
accessMode === "read-only" ||
accessMode === "full-access";
const shouldShowAdvancedToggle =
showSkillSelector ||
shouldShowTeamSelector ||
Boolean(setExecutionStrategy) ||
shouldShowModelControls ||
Boolean(setAccessMode);
const leftExtra = shouldShowAdvancedToggle ? (
<>
<MetaToggleButton
type="button"
$checked={showAdvancedControls || hasHighlightedAdvancedPreference}
aria-label={showAdvancedControls ? "收起高级设置" : "展开高级设置"}
aria-expanded={showAdvancedControls}
data-testid="inputbar-advanced-toggle"
title={showAdvancedControls ? "收起高级设置" : "展开高级设置"}
onClick={() => setShowAdvancedControls((previous) => !previous)}
>
<MetaToggleCheck
$checked={showAdvancedControls || hasHighlightedAdvancedPreference}
aria-hidden
/>
<MetaToggleGlyph aria-hidden>
<Settings2 strokeWidth={1.8} />
</MetaToggleGlyph>
<MetaToggleLabel>高级设置</MetaToggleLabel>
{showAdvancedControls ? (
<ChevronUp className="h-3.5 w-3.5" aria-hidden />
) : (
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
)}
</MetaToggleButton>
{!showAdvancedControls && currentModelSummary ? (
<Badge
variant="outline"
className="h-8 max-w-[240px] items-center overflow-hidden rounded-full border-slate-200/80 bg-white/90 px-3 text-xs font-medium text-slate-600"
title={`当前模型:${currentModelSummary}`}
>
<span className="mr-1 text-slate-500">当前模型</span>
<span className="truncate">{trimmedModel}</span>
</Badge>
) : null}
{!showAdvancedControls && shouldShowModelControls && !hasConfiguredModel ? (
<InputbarModelExtra
isFullscreen={isFullscreen}
providerType={resolvedProviderType}
setProviderType={resolvedSetProviderType}
model={resolvedModel}
setModel={resolvedSetModel}
activeTheme={activeTheme}
onManageProviders={onManageProviders}
executionRuntime={executionRuntime}
/>
) : null}
{showAdvancedControls ? (
<>
{showSkillSelector ? (
<SkillSelector {...skillSelectorProps} />
) : null}
{shouldShowTeamSelector ? (
<TeamSelector
activeTheme={activeTheme}
input={input}
autoOpenToken={teamSelectorAutoOpenToken}
selectedTeam={selectedTeam}
workspaceSettings={teamWorkspaceSettings}
onPersistCustomTeams={onPersistCustomTeams}
onSelectTeam={(team) => onSelectTeam?.(team)}
/>
) : null}
<InputbarExecutionStrategySelect
isFullscreen={isFullscreen}
executionStrategy={executionStrategy}
setExecutionStrategy={setExecutionStrategy}
/>
{shouldShowModelControls ? (
<InputbarModelExtra
isFullscreen={isFullscreen}
providerType={resolvedProviderType}
setProviderType={resolvedSetProviderType}
model={resolvedModel}
setModel={resolvedSetModel}
activeTheme={activeTheme}
onManageProviders={onManageProviders}
executionRuntime={executionRuntime}
/>
) : null}
<InputbarAccessModeSelect
isFullscreen={isFullscreen}
accessMode={accessMode}
setAccessMode={setAccessMode}
/>
</>
) : null}
</>
) : undefined;
if (renderWorkflowGeneratingPanel) {
return (
@@ -215,48 +340,8 @@ export const InputbarComposerSection: React.FC<
queuedTurns={queuedTurns}
onPromoteQueuedTurn={onPromoteQueuedTurn}
onRemoveQueuedTurn={onRemoveQueuedTurn}
leftExtra={
<>
{showSkillSelector ? (
<SkillSelector {...skillSelectorProps} />
) : null}
{isGeneralResearchTheme(activeTheme) ? (
activeTools["subagent_mode"] ? (
<TeamSelector
activeTheme={activeTheme}
input={input}
autoOpenToken={teamSelectorAutoOpenToken}
selectedTeam={selectedTeam}
workspaceSettings={teamWorkspaceSettings}
onPersistCustomTeams={onPersistCustomTeams}
onSelectTeam={(team) => onSelectTeam?.(team)}
/>
) : null
) : null}
<InputbarExecutionStrategySelect
isFullscreen={isFullscreen}
executionStrategy={executionStrategy}
setExecutionStrategy={setExecutionStrategy}
/>
{!isWorkspaceVariant ? (
<InputbarModelExtra
isFullscreen={isFullscreen}
providerType={inputAdapter.model?.providerType}
setProviderType={resolvedSetProviderType}
model={inputAdapter.model?.model}
setModel={resolvedSetModel}
activeTheme={activeTheme}
onManageProviders={onManageProviders}
executionRuntime={executionRuntime}
/>
) : null}
<InputbarAccessModeSelect
isFullscreen={isFullscreen}
accessMode={accessMode}
setAccessMode={setAccessMode}
/>
</>
}
leftExtra={leftExtra}
showMetaTools={showAdvancedControls}
/>
</>
);
@@ -77,6 +77,7 @@ interface InputbarCoreProps {
queuedTurns?: QueuedTurnSnapshot[];
onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
showMetaTools?: boolean;
}
export const InputbarCore: React.FC<InputbarCoreProps> = ({
@@ -103,6 +104,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
queuedTurns = [],
onPromoteQueuedTurn,
onRemoveQueuedTurn,
showMetaTools = true,
}) => {
const [isComposerExpanded, setIsComposerExpanded] = useState(false);
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
@@ -421,7 +423,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
<BottomBar className={bottomBarClassName}>
<LeftSection className={leftSectionClassName}>
{leftExtra ? <MetaSlot>{leftExtra}</MetaSlot> : null}
{!shouldCollapseFloatingTools ? (
{!shouldCollapseFloatingTools && showMetaTools ? (
<InputbarTools
onToolClick={onToolClick}
activeTools={activeTools}
@@ -22,9 +22,9 @@ export const InputbarModelExtra: React.FC<InputbarModelExtraProps> = ({
setModel,
activeTheme,
onManageProviders,
executionRuntime = null,
executionRuntime: _executionRuntime = null,
}) => {
if (isFullscreen || !providerType || !model) {
if (isFullscreen) {
return null;
}
const selectorBackgroundPreload = hasTauriInvokeCapability()
@@ -34,9 +34,9 @@ export const InputbarModelExtra: React.FC<InputbarModelExtraProps> = ({
return (
<div className="flex items-center flex-wrap gap-2">
<ChatModelSelector
providerType={providerType}
providerType={providerType ?? ""}
setProviderType={setProviderType}
model={model}
model={model ?? ""}
setModel={setModel}
activeTheme={activeTheme}
compactTrigger
@@ -25,28 +25,33 @@ const mockInputbarCore = vi.fn(
topExtra?: React.ReactNode;
placeholder?: string;
toolMode?: "default" | "attach-only";
showMetaTools?: boolean;
}) => (
<div data-testid="inputbar-core">
<button
type="button"
data-testid="toggle-web-search"
onClick={() => props.onToolClick?.("web_search")}
>
切换联网
</button>
<span data-testid="web-search-state">
{props.activeTools?.web_search ? "on" : "off"}
</span>
<button
type="button"
data-testid="toggle-subagent-mode"
onClick={() => props.onToolClick?.("subagent_mode")}
>
切换多代理
</button>
<span data-testid="subagent-state">
{props.activeTools?.subagent_mode ? "on" : "off"}
</span>
{props.showMetaTools ? (
<>
<button
type="button"
data-testid="toggle-web-search"
onClick={() => props.onToolClick?.("web_search")}
>
切换联网
</button>
<span data-testid="web-search-state">
{props.activeTools?.web_search ? "on" : "off"}
</span>
<button
type="button"
data-testid="toggle-subagent-mode"
onClick={() => props.onToolClick?.("subagent_mode")}
>
切换多代理
</button>
<span data-testid="subagent-state">
{props.activeTools?.subagent_mode ? "on" : "off"}
</span>
</>
) : null}
<button
type="button"
data-testid="send-btn"
@@ -69,6 +74,7 @@ vi.mock("./components/InputbarCore", () => ({
topExtra?: React.ReactNode;
placeholder?: string;
toolMode?: "default" | "attach-only";
showMetaTools?: boolean;
}) => mockInputbarCore(props),
}));
@@ -252,6 +258,20 @@ function renderInputbar(
rerender: render,
};
}
function expandAdvancedControls(container: HTMLDivElement) {
const toggleButton = container.querySelector(
'[data-testid="inputbar-advanced-toggle"]',
) as HTMLButtonElement | null;
expect(toggleButton).toBeTruthy();
act(() => {
toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
return toggleButton;
}
describe("Inputbar", () => {
it("即使角色和技能为空,也应挂载 CharacterMention", async () => {
const { container } = renderInputbar();
@@ -358,7 +378,7 @@ describe("Inputbar", () => {
container.querySelector('[data-testid="inputbar-secondary-controls"]'),
).toBeNull();
});
it("工作区输入区应保留技能下拉,但与 @ 面板共用同一技能数据源", async () => {
it("工作区输入区默认隐藏技能入口,展开高级设置后与 @ 面板共用同一技能数据源", async () => {
const { container } = renderInputbar({
activeTheme: "general",
skills: [
@@ -379,13 +399,22 @@ describe("Inputbar", () => {
expect(
container.querySelector('[data-testid="skill-selector-trigger"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="inputbar-advanced-toggle"]'),
).toBeTruthy();
expect(
container.querySelector('[data-testid="character-mention-stub"]'),
).toBeTruthy();
expandAdvancedControls(container);
expect(
container.querySelector('[data-testid="skill-selector-trigger"]'),
).toBeTruthy();
});
it("存在 executionRuntime 时应保留模型选择器,但不再展示结构化输出提示", async () => {
it("存在 executionRuntime 时折叠态应保留当前模型提示,展开后再显示模型选择器", async () => {
const { container } = renderInputbar({
providerType: "openai",
setProviderType: vi.fn(),
@@ -410,7 +439,15 @@ describe("Inputbar", () => {
await Promise.resolve();
});
expect(container.textContent).toContain("当前模型");
expect(container.textContent).toContain("gpt-5.4-mini");
expect(container.textContent).not.toContain("最近执行模型");
expect(
container.querySelector('[data-testid="model-selector"]'),
).toBeNull();
expandAdvancedControls(container);
expect(
container.querySelector('[data-testid="model-selector"]'),
).toBeTruthy();
@@ -418,7 +455,7 @@ describe("Inputbar", () => {
expect(container.textContent).not.toContain("Native schema");
});
it("应将 Plan 开关与权限模式拆成独立控件,并透传对应回调", async () => {
it("应在高级设置中渲染 Plan 开关与权限模式,并透传对应回调", async () => {
const setExecutionStrategy = vi.fn();
const setAccessMode = vi.fn();
const { container } = renderInputbar({
@@ -432,6 +469,15 @@ describe("Inputbar", () => {
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="inputbar-plan-toggle"]'),
).toBeNull();
expect(
container.querySelector('select[aria-label="权限模式"]'),
).toBeNull();
expandAdvancedControls(container);
const planToggle = container.querySelector(
'[data-testid="inputbar-plan-toggle"]',
) as HTMLButtonElement | null;
@@ -479,6 +525,12 @@ describe("Inputbar", () => {
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="toggle-web-search"]'),
).toBeNull();
expandAdvancedControls(container);
const toggleButton = container.querySelector(
'[data-testid="toggle-web-search"]',
) as HTMLButtonElement | null;
@@ -565,12 +617,18 @@ describe("Inputbar", () => {
await Promise.resolve();
});
expect(
enabledContainer.querySelector('[data-testid="team-selector-stub"]'),
).toBeNull();
expandAdvancedControls(enabledContainer);
expect(
enabledContainer.querySelector('[data-testid="team-selector-stub"]'),
).toBeTruthy();
});
it("未开启 Team mode 时应只保留图标开关,并可直接启用", async () => {
it("未开启 Team mode 时默认不暴露多代理开关,展开高级设置后可启用", async () => {
const onToolStatesChange = vi.fn();
const { container } = renderInputbar({
activeTheme: "general",
@@ -589,6 +647,11 @@ describe("Inputbar", () => {
expect(
container.querySelector('[data-testid="team-mode-enable-button"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="toggle-subagent-mode"]'),
).toBeNull();
expandAdvancedControls(container);
const enableButton = container.querySelector(
'[data-testid="toggle-subagent-mode"]',
@@ -617,6 +680,8 @@ describe("Inputbar", () => {
await Promise.resolve();
});
expandAdvancedControls(container);
const enableButton = container.querySelector(
'[data-testid="toggle-subagent-mode"]',
) as HTMLButtonElement | null;
@@ -664,7 +729,7 @@ describe("Inputbar", () => {
).toBeNull();
expect(
container.querySelector('[data-testid="toggle-subagent-mode"]'),
).toBeTruthy();
).toBeNull();
expect(recommendationButton).toBeTruthy();
expect(container.textContent).toContain("当前任务更适合 Team 协作");
});
@@ -12,7 +12,7 @@ import { parseA2UIJson } from "@/lib/workspace/a2ui";
import type { A2UIFormData } from "@/lib/workspace/a2ui";
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/lib/workspace/a2ui";
import { useDebouncedValue } from "@/lib/artifact/hooks/useDebouncedValue";
import { convertLocalFileSrc } from "@/lib/api/fileSystem";
import { resolveMarkdownImageSrc } from "@/lib/markdown/resolveMarkdownImageSrc";
import { ArtifactPlaceholder } from "./ArtifactPlaceholder";
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
@@ -519,132 +519,6 @@ function extractCodeLanguageToken(className: string): string {
return (match?.[1] ?? "text").trim().toLowerCase() || "text";
}
function normalizeFilePath(value: string): string {
return value.replace(/\\/g, "/");
}
function isAbsoluteLikePath(value: string): boolean {
return (
value.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(value) ||
value.startsWith("\\\\")
);
}
function dirnameFromFilePath(value: string): string {
const normalized = normalizeFilePath(value).replace(/\/+$/, "");
const lastSlashIndex = normalized.lastIndexOf("/");
if (lastSlashIndex <= 0) {
return lastSlashIndex === 0 ? "/" : "";
}
return normalized.slice(0, lastSlashIndex);
}
function joinFilePath(parentDir: string, childPath: string): string {
if (!parentDir) {
return childPath;
}
return `${parentDir.replace(/\/+$/, "")}/${childPath.replace(/^[\\/]+/, "")}`;
}
function splitFilePathSuffix(value: string): {
pathPart: string;
suffix: string;
} {
const suffixStart = value.search(/[?#]/);
if (suffixStart < 0) {
return { pathPart: value, suffix: "" };
}
return {
pathPart: value.slice(0, suffixStart),
suffix: value.slice(suffixStart),
};
}
function normalizeResolvedFilePath(value: string): string {
const normalized = normalizeFilePath(value);
if (!normalized) {
return "";
}
let prefix = "";
let remainder = normalized;
if (remainder.startsWith("//")) {
prefix = "//";
remainder = remainder.slice(2);
} else if (/^[A-Za-z]:\//.test(remainder)) {
prefix = remainder.slice(0, 2);
remainder = remainder.slice(3);
} else if (remainder.startsWith("/")) {
prefix = "/";
remainder = remainder.slice(1);
}
const segments = remainder.split("/");
const stack: string[] = [];
for (const segment of segments) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (stack.length > 0 && stack[stack.length - 1] !== "..") {
stack.pop();
} else if (!prefix) {
stack.push("..");
}
continue;
}
stack.push(segment);
}
const joined = stack.join("/");
if (prefix === "//") {
return joined ? `//${joined}` : "//";
}
if (prefix === "/") {
return joined ? `/${joined}` : "/";
}
if (prefix) {
return joined ? `${prefix}/${joined}` : `${prefix}/`;
}
return joined;
}
function resolveMarkdownImageSrc(
rawSrc: string,
baseFilePath?: string,
): string {
const normalizedSrc = rawSrc.trim();
if (!normalizedSrc) {
return rawSrc;
}
if (
normalizedSrc.startsWith("data:") ||
normalizedSrc.startsWith("http://") ||
normalizedSrc.startsWith("https://") ||
normalizedSrc.startsWith("blob:") ||
normalizedSrc.startsWith("asset://") ||
normalizedSrc.startsWith("tauri://")
) {
return normalizedSrc;
}
const { pathPart, suffix } = splitFilePathSuffix(normalizedSrc);
const absolutePath = isAbsoluteLikePath(pathPart)
? normalizeResolvedFilePath(pathPart)
: baseFilePath
? normalizeResolvedFilePath(
joinFilePath(dirnameFromFilePath(baseFilePath), pathPart),
)
: "";
if (!absolutePath) {
return normalizedSrc;
}
return `${convertLocalFileSrc(absolutePath)}${suffix}`;
}
function resolveCodePresentationMode(
language: string,
codeContent: string,
@@ -1270,6 +1270,172 @@ describe("MessageList", () => {
).not.toBeNull();
});
it("不应把 .lime/artifacts 下的内部 artifact 文稿 JSON 渲染成尾部时间线", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-hidden-artifact-json",
role: "assistant",
content: "已生成内部文稿快照。",
timestamp: now,
},
];
const container = render(messages, {
currentTurnId: "turn-hidden-artifact-json",
turns: [
{
id: "turn-hidden-artifact-json",
thread_id: "thread-1",
prompt_text: "生成内部 artifact 文稿",
status: "completed",
started_at: "2026-04-10T10:35:00Z",
completed_at: "2026-04-10T10:35:03Z",
created_at: "2026-04-10T10:35:00Z",
updated_at: "2026-04-10T10:35:03Z",
},
],
threadItems: [
{
id: "item-hidden-artifact-json",
thread_id: "thread-1",
turn_id: "turn-hidden-artifact-json",
sequence: 1,
status: "completed",
started_at: "2026-04-10T10:35:01Z",
completed_at: "2026-04-10T10:35:02Z",
updated_at: "2026-04-10T10:35:02Z",
type: "file_artifact",
path: ".lime/artifacts/thread-1/report.artifact.json",
source: "artifact_snapshot",
content: "{\"schemaVersion\":\"artifact_document.v1\"}",
},
],
});
expect(
container.querySelector('[data-testid="agent-thread-timeline:trailing"]'),
).toBeNull();
});
it("同一路径的 file_artifact 重复出现时,尾部时间线只应保留更完整的一条", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-duplicate-artifact",
role: "assistant",
content: "导出完成。",
timestamp: now,
},
];
render(messages, {
currentTurnId: "turn-duplicate-artifact",
turns: [
{
id: "turn-duplicate-artifact",
thread_id: "thread-1",
prompt_text: "导出 index.md",
status: "completed",
started_at: "2026-04-10T09:57:00Z",
completed_at: "2026-04-10T09:57:05Z",
created_at: "2026-04-10T09:57:00Z",
updated_at: "2026-04-10T09:57:05Z",
},
],
threadItems: [
{
id: "item-artifact-duplicate-empty",
thread_id: "thread-1",
turn_id: "turn-duplicate-artifact",
sequence: 1,
status: "completed",
started_at: "2026-04-10T09:57:01Z",
completed_at: "2026-04-10T09:57:02Z",
updated_at: "2026-04-10T09:57:02Z",
type: "file_artifact",
path: "exports/x-article-export/google/index.md",
source: "artifact_snapshot",
content: "",
},
{
id: "item-artifact-duplicate-rich",
thread_id: "thread-1",
turn_id: "turn-duplicate-artifact",
sequence: 2,
status: "completed",
started_at: "2026-04-10T09:57:03Z",
completed_at: "2026-04-10T09:57:04Z",
updated_at: "2026-04-10T09:57:04Z",
type: "file_artifact",
path: "exports/x-article-export/google/index.md",
source: "artifact_snapshot",
content: "# 最新导出\n\n这里是完整预览。",
},
],
});
const trailingTimelineProps = mockAgentThreadTimeline.mock.calls.find(
([props]) => props?.placement === "trailing",
)?.[0] as { items?: Array<Record<string, unknown>> } | undefined;
expect(trailingTimelineProps?.items).toHaveLength(1);
expect(trailingTimelineProps?.items?.[0]).toEqual(
expect.objectContaining({
path: "exports/x-article-export/google/index.md",
content: "# 最新导出\n\n这里是完整预览。",
}),
);
});
it("已有尾部 file_artifact 卡片时,不应再额外渲染消息级在画布中打开入口", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-artifact-card-only",
role: "assistant",
content: "导出完成。",
timestamp: now,
},
];
const container = render(messages, {
currentTurnId: "turn-artifact-card-only",
turns: [
{
id: "turn-artifact-card-only",
thread_id: "thread-1",
prompt_text: "导出 index.md",
status: "completed",
started_at: "2026-04-10T10:20:00Z",
completed_at: "2026-04-10T10:20:05Z",
created_at: "2026-04-10T10:20:00Z",
updated_at: "2026-04-10T10:20:05Z",
},
],
threadItems: [
{
id: "item-artifact-card-only",
thread_id: "thread-1",
turn_id: "turn-artifact-card-only",
sequence: 1,
status: "completed",
started_at: "2026-04-10T10:20:01Z",
completed_at: "2026-04-10T10:20:02Z",
updated_at: "2026-04-10T10:20:02Z",
type: "file_artifact",
path: "exports/x-article-export/google/index.md",
source: "artifact_snapshot",
content: "# 最新导出\n\n这里是完整预览。",
},
],
});
expect(
container.querySelector('[data-testid="message-canvas-shortcut"]'),
).toBeNull();
});
it("运行中的 turn_summary 应作为尾部过程状态展示,而不是顶到消息头部", () => {
const now = new Date();
const messages: Message[] = [
@@ -1838,6 +2004,38 @@ describe("MessageList", () => {
expect(container.textContent).not.toContain("task-image-1.json");
});
it("不应把 .lime/artifacts 下的内部 artifact 文稿 JSON 渲染成用户可见产物卡片", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-hidden-conversation-artifact-json",
role: "assistant",
content: "内部文稿已同步。",
timestamp: now,
artifacts: [
{
id: "artifact-hidden-conversation-artifact-json",
type: "document",
title: "report.artifact.json",
content: "{\"schemaVersion\":\"artifact_document.v1\"}",
status: "complete",
meta: {
filePath: ".lime/artifacts/thread-1/report.artifact.json",
filename: "report.artifact.json",
},
position: { start: 0, end: 0 },
createdAt: now.getTime(),
updatedAt: now.getTime(),
},
],
},
];
const container = render(messages);
expect(container.textContent).toContain("内部文稿已同步。");
expect(container.textContent).not.toContain("report.artifact.json");
});
it("应先渲染思考与过程,再渲染正文,最后再落产物", () => {
const now = new Date();
const messages: Message[] = [
@@ -42,7 +42,7 @@ import {
sanitizeContentPartsForDisplay,
sanitizeMessageTextForDisplay,
} from "../utils/internalImagePlaceholder";
import { isHiddenInternalArtifactPath } from "../utils/internalArtifactVisibility";
import { isHiddenConversationArtifactPath } from "../utils/internalArtifactVisibility";
import { isInternalRoutingTurnSummaryText } from "../utils/turnSummaryPresentation";
import {
Message,
@@ -62,8 +62,15 @@ import type {
} from "@/lib/api/agentRuntime";
import { buildMessageTurnTimeline } from "../utils/threadTimelineView";
import { buildMessageTurnGroups } from "../utils/messageTurnGrouping";
import { resolveLatestProjectFileSavedSiteContentTargetFromMessage } from "../utils/latestSavedSiteContentTarget";
import {
resolveSiteSavedContentTargetDisplayName,
resolveSiteSavedContentTargetRelativePath,
} from "../utils/siteToolResultSummary";
import logoImg from "/logo.png";
import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation";
import {
type ArtifactTimelineOpenTarget,
} from "../utils/artifactTimelineNavigation";
interface MessageListProps {
messages: Message[];
@@ -156,6 +163,56 @@ function isDeferredTimelineItem(item: AgentThreadItem): boolean {
return item.type === "file_artifact" || item.type === "turn_summary";
}
function normalizeDeferredArtifactPath(path?: string | null): string {
return (path || "").trim().replace(/\\/g, "/").toLowerCase();
}
function scoreDeferredArtifactItem(
item: Extract<AgentThreadItem, { type: "file_artifact" }>,
): number {
const contentScore = (item.content || "").trim().length;
const completedAt = Date.parse(item.completed_at || item.updated_at || "");
const timestampScore = Number.isFinite(completedAt) ? completedAt : 0;
return contentScore * 1_000_000_000 + timestampScore;
}
function dedupeDeferredTimelineItems(
items: AgentThreadItem[],
): AgentThreadItem[] {
const deduped: AgentThreadItem[] = [];
const artifactIndexByPath = new Map<string, number>();
for (const item of items) {
if (item.type !== "file_artifact") {
deduped.push(item);
continue;
}
const normalizedPath = normalizeDeferredArtifactPath(item.path);
if (!normalizedPath) {
deduped.push(item);
continue;
}
const existingIndex = artifactIndexByPath.get(normalizedPath);
if (existingIndex === undefined) {
artifactIndexByPath.set(normalizedPath, deduped.length);
deduped.push(item);
continue;
}
const existingItem = deduped[existingIndex];
if (
existingItem?.type !== "file_artifact" ||
scoreDeferredArtifactItem(item) >= scoreDeferredArtifactItem(existingItem)
) {
deduped[existingIndex] = item;
}
}
return deduped;
}
function shouldRenderConversationTimelineItem(
item: AgentThreadItem,
timelineItems: AgentThreadItem[],
@@ -523,7 +580,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
})
: [];
const trailingTimelineItems = timeline
? timelineConversationItems.filter((item) => isDeferredTimelineItem(item))
? dedupeDeferredTimelineItems(
timelineConversationItems.filter((item) => isDeferredTimelineItem(item)),
).filter(
(item) =>
item.type !== "file_artifact" ||
!isHiddenConversationArtifactPath(item.path),
)
: [];
const primaryTimeline =
timeline && primaryTimelineItems.length > 0
@@ -533,6 +596,9 @@ const MessageListInner: React.FC<MessageListProps> = ({
timeline && trailingTimelineItems.length > 0
? { ...timeline, items: trailingTimelineItems }
: null;
const hasTrailingArtifactTimelineItems = trailingTimelineItems.some(
(item) => item.type === "file_artifact",
);
const timelineActionRequests = inlineProcessCoverage.actionRequestCounts.size
? undefined
: msg.actionRequests;
@@ -571,6 +637,22 @@ const MessageListInner: React.FC<MessageListProps> = ({
(msg.role === "user" && (canQuoteMessage || canCopyMessage)) ||
canSaveMessageAsSkill ||
canSaveMessageAsInspiration;
const messageSavedSiteContentTarget =
msg.role === "assistant"
? resolveLatestProjectFileSavedSiteContentTargetFromMessage(msg)
: null;
const shouldRenderMessageCanvasShortcut = Boolean(
messageSavedSiteContentTarget &&
onOpenSavedSiteContent &&
!hasTrailingArtifactTimelineItems,
);
const messageCanvasShortcutTitle = messageSavedSiteContentTarget
? resolveSiteSavedContentTargetDisplayName(messageSavedSiteContentTarget) ||
"导出稿"
: "文件";
const messageCanvasShortcutPath = messageSavedSiteContentTarget
? resolveSiteSavedContentTargetRelativePath(messageSavedSiteContentTarget)
: null;
return (
<MessageWrapper
@@ -653,6 +735,36 @@ const MessageListInner: React.FC<MessageListProps> = ({
: undefined
}
/>
{shouldRenderMessageCanvasShortcut ? (
<button
type="button"
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50/70 px-3 py-2.5 text-left transition-colors hover:bg-emerald-100/80"
data-testid="message-canvas-shortcut"
onClick={() => {
if (
messageSavedSiteContentTarget &&
onOpenSavedSiteContent
) {
onOpenSavedSiteContent(messageSavedSiteContentTarget);
}
}}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-emerald-200 bg-white text-emerald-700">
<FileText className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium leading-6 text-emerald-900">
在画布中打开 {messageCanvasShortcutTitle}
</span>
{messageCanvasShortcutPath ? (
<span className="block truncate text-xs leading-5 text-emerald-700/80">
{messageCanvasShortcutPath}
</span>
) : null}
</span>
<ExternalLink className="h-4 w-4 shrink-0 text-emerald-700" />
</button>
) : null}
{msg.imageWorkbenchPreview ? (
<ImageWorkbenchMessagePreview
preview={msg.imageWorkbenchPreview}
@@ -837,7 +949,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
const visibleArtifacts =
artifacts?.filter(
(artifact) =>
!isHiddenInternalArtifactPath(
!isHiddenConversationArtifactPath(
resolveArtifactProtocolFilePath(artifact),
),
) || [];
@@ -865,11 +977,11 @@ const MessageListInner: React.FC<MessageListProps> = ({
onClick={() => onArtifactClick?.(artifact)}
className="w-full flex items-center gap-3 rounded-lg border border-border/70 bg-background/70 px-3 py-2 text-left transition-colors hover:border-primary/50 hover:bg-background"
>
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-primary/10 text-primary shrink-0">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
{artifact.status === "streaming" ? (
<Loader2 className="w-4 h-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileText className="w-4 h-4" />
<FileText className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
@@ -894,8 +1006,8 @@ const MessageListInner: React.FC<MessageListProps> = ({
) : null}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground" />
<div className="flex shrink-0 items-center gap-2">
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</button>
);
+36 -11
View File
@@ -4,20 +4,45 @@ Agent 聊天界面的 UI 组件集合。
## 文件索引
| 文件 | 说明 |
|------|------|
| `ChatNavbar.tsx` | 聊天顶部导航栏 |
| `ChatSidebar.tsx` | 聊天侧边栏(会话列表) |
| `CanvasWorkbenchLayout.tsx` | 画布内工作台布局,宽屏侧栏/窄屏底部工作台均支持产物、全部文件、变更、预览与下载动作 |
| `EmptyState.tsx` | 空状态占位组件 |
| `MarkdownRenderer.tsx` | Markdown 渲染组件 |
| `MessageList.tsx` | 消息列表组件 |
| `StreamingRenderer.tsx` | 流式消息渲染(支持思考内容、工具调用) |
| `TokenUsageDisplay.tsx` | Token 使用量显示 |
| `ToolCallDisplay.tsx` | 工具调用显示(状态、参数、日志、结果) |
| 文件 | 说明 |
| -------------------------------- | -------------------------------------------------------------------------------------- |
| `ChatNavbar.tsx` | 聊天顶部导航栏 |
| `ChatSidebar.tsx` | 聊天侧边栏(会话列表) |
| `CanvasWorkbenchLayout.tsx` | 画布顶部标签壳,统一承载 `Session · Main`、`workspace`、Team 与动态文件标签 |
| `CanvasSessionOverviewPanel.tsx` | 解耦后的会话过程面板,用于从运行时注入 turn、tool、A2UI、queue 等过程视图 |
| `EmptyState.tsx` | 空状态占位组件 |
| `MarkdownRenderer.tsx` | Markdown 渲染组件 |
| `MessageList.tsx` | 消息列表组件 |
| `team-workspace-board/TeamWorkspaceBoardHeader.tsx` | Team 头部壳,统一承载标题、状态 badge、空 shell 展开和返回主助手入口 |
| `team-workspace-board/TeamWorkspaceCanvasLaneCard.tsx` | Team 自由画布里的 lane 卡片壳,统一承载成员进展、摘要、badge 与 resize handle |
| `team-workspace-board/TeamWorkspaceCanvasStage.tsx` | Team 自由画布 stage 壳,统一承载视口、背景网格、shortcuts、lane 容器与空态 |
| `team-workspace-board/TeamWorkspaceEmptyShellState.tsx` | Team 空 shell 折叠态横条,保持壳层紧凑与展开入口统一 |
| `team-workspace-board/TeamWorkspaceFormationPanels.tsx` | Team 运行时分工 / 计划分工展示壳,避免 Board 内重复 JSX |
| `team-workspace-board/SelectedSessionInlineDetail.tsx` | Team 当前查看成员的详情区与协作操作壳 |
| `team-workspace-board/TeamWorkspaceTeamOverviewChrome.tsx` | Team 顶部 toolbar / 协作动态壳,收口 compact 与非 compact 的重复展示逻辑 |
| `StreamingRenderer.tsx` | 流式消息渲染(支持思考内容、工具调用) |
| `TokenUsageDisplay.tsx` | Token 使用量显示 |
| `ToolCallDisplay.tsx` | 工具调用显示(状态、参数、日志、结果) |
## 核心组件
### CanvasWorkbenchLayout
- 只负责单画布壳、头部标签、文件标签与顶部操作,不直接依赖具体 slash / skill 业务
- 顶部收敛为紧凑标签栏 + 文件操作区,去掉大块摘要头,避免与正文和对话信息重复
- 当存在 `sessionView` 且同时有默认主稿时,默认焦点仍应落在 `Session · Main`,但面板内容优先展示当前主稿/产物预览,避免与对话里的过程区重复
- `workspaceView` 可以由运行时显式注入顶部标签文案和面板文案,工作区文件区是画布里真实文件的唯一事实源
- `workspace` 文件树默认隐藏 `.lime`、`exports`、`output`、`.DS_Store` 和 `output_image.*` 这类内部运行或导出产物入口,避免与真实编辑入口混淆
- `panelCopy` 允许运行时覆盖 workspace / team 面板里的引导文案、空态文案与目录区标题,避免布局壳继续承载场景描述
- `teamView` 与可选的 `tabLabel/tabBadge` 继续由运行时显式提供,布局只消费定义,不再内建 Team 协作语义
- 通过 `sessionView`、`teamView` 等插槽注入不同运行时面板,保持未来场景扩展时的边界稳定
### CanvasSessionOverviewPanel
- 统一展示当前 turn 状态、最近执行轨迹、待处理交互与排队消息
- 顶部改为“执行总览”摘要卡,优先展示当前状态、聚焦事件和 turn 信息,而不是技术性提示文案
- 让 `Session · Main` 成为真实过程页,而不是拿主稿预览兜底
### ToolCallDisplay
参考 aster UI 设计,提供完整的工具调用可视化:
@@ -676,8 +676,9 @@ describe("TeamWorkspaceBoard", () => {
expect(currentLane?.textContent).toContain(
"推理:先检查 team runtime 的控制面状态,再决定是否等待。",
);
expect(siblingLane?.textContent).toContain("页面截图");
expect(siblingLane?.textContent).toContain(
"工具 browser_snapshot:页面已刷新为最新状态并生成差异截图。",
"页面已刷新为最新状态并生成差异截图。",
);
const returnButton = Array.from(container.querySelectorAll("button")).find(
@@ -703,8 +704,9 @@ describe("TeamWorkspaceBoard", () => {
).toBeTruthy();
expect(container.textContent).toContain("检索结果");
expect(container.textContent).toContain("已汇总 5 条 roadmap 差异。");
expect(container.textContent).toContain("页面截图");
expect(container.textContent).toContain(
"工具 browser_snapshot:页面已刷新为最新状态并生成差异截图。",
"页面已刷新为最新状态并生成差异截图。",
);
expect(container.textContent).toContain("等待父线程确认是否继续扩展范围。");
@@ -985,7 +987,7 @@ describe("TeamWorkspaceBoard", () => {
"child-live-stream-1": [
{
id: "tool:child-live-stream-1:tool-1",
title: "工具 browser_snapshot",
title: "工具 页面截图",
detail: "页面结构差异已提取完成。",
statusLabel: "完成",
badgeClassName:
@@ -1000,9 +1002,9 @@ describe("TeamWorkspaceBoard", () => {
expect(container.textContent).toContain("实时片段代理");
expect(container.textContent).toContain(
"工具 browser_snapshot:页面结构差异已提取完成。",
"工具 页面截图:页面结构差异已提取完成。",
);
expect(container.textContent).toContain("工具 browser_snapshot");
expect(container.textContent).toContain("工具 页面截图");
expect(container.textContent).toContain("页面结构差异已提取完成。");
});
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,8 @@ import { TeamWorkspaceDock } from "./TeamWorkspaceDock";
const { mockGetAgentRuntimeSession } = vi.hoisted(() => ({
mockGetAgentRuntimeSession: vi.fn(),
}));
const { mockSafeListen, mockParseAgentEvent } = vi.hoisted(() => ({
const { mockSafeInvoke, mockSafeListen, mockParseAgentEvent } = vi.hoisted(() => ({
mockSafeInvoke: vi.fn(),
mockSafeListen: vi.fn(),
mockParseAgentEvent: vi.fn((payload: unknown) => payload),
}));
@@ -34,6 +35,7 @@ vi.mock("@/lib/api/agentProtocol", async () => {
});
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: mockSafeInvoke,
safeListen: mockSafeListen,
}));
@@ -76,6 +78,7 @@ beforeEach(() => {
mockGetAgentRuntimeSession.mockImplementation(async (sessionId: string) =>
createSessionDetail(sessionId),
);
mockSafeInvoke.mockResolvedValue(undefined);
mockSafeListen.mockResolvedValue(() => {});
mockParseAgentEvent.mockImplementation((payload: unknown) => payload);
});
@@ -360,7 +360,7 @@ describe("ToolCallDisplay", () => {
"结果已自动保存到项目 project-1:GitHub MCP 搜索结果 · 来自当前项目上下文",
);
expect(container.textContent).toContain(
"项目目录:/Users/coso/.proxycast/projects/project-1",
"项目目录:/Users/coso/Library/Application Support/lime/projects/project-1",
);
expect(container.textContent).toContain(
"Markdown 文件:exports/x-article-export/github-mcp/index.md",
@@ -501,6 +501,98 @@ describe("ToolCallDisplay", () => {
});
});
it("ToolSearch 展开后应展示结构化工具摘要,而不是原始 JSON", () => {
const { container } = renderTool({
id: "tool-search-bridge-1",
name: "ToolSearch",
arguments: JSON.stringify({ query: "select:Read,Write" }),
status: "completed",
result: {
success: true,
output: JSON.stringify({
query: "select:Read,Write",
caller: "assistant",
count: 2,
notes: [],
tools: [
{
name: "Read",
source: "native_registry",
description: "Read a file from disk",
always_visible: true,
},
{
name: "Write",
source: "native_registry",
description: "Write content to a file",
always_visible: true,
},
],
}),
},
startTime: new Date("2026-04-10T04:00:00.000Z"),
endTime: new Date("2026-04-10T04:00:01.000Z"),
});
act(() => {
const toggle = container.querySelector(
'button[title="查看结果"]',
) as HTMLButtonElement | null;
toggle?.click();
});
expect(
container.querySelector('[data-testid="tool-call-tool-search-result"]'),
).not.toBeNull();
expect(container.textContent).toContain("匹配工具:2 个");
expect(container.textContent).toContain("Read");
expect(container.textContent).toContain("Write");
expect(container.textContent).toContain("原生工具");
expect(container.textContent).not.toContain('"always_visible":true');
expect(
container.querySelector('[data-testid="tool-call-rendered-result"]'),
).toBeNull();
});
it("ToolSearch 在流式阶段不应自动展开内部结果", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<ToolCallDisplay
isMessageStreaming
toolCall={{
id: "tool-search-streaming-1",
name: "ToolSearch",
arguments: JSON.stringify({ query: "select:Read,Write" }),
status: "completed",
result: {
success: true,
output: JSON.stringify({
query: "select:Read,Write",
count: 2,
notes: [],
tools: [{ name: "Read" }, { name: "Write" }],
}),
},
startTime: new Date("2026-04-10T04:05:00.000Z"),
endTime: new Date("2026-04-10T04:05:01.000Z"),
}}
/>,
);
});
mountedRoots.push({ container, root });
expect(
container.querySelector('[data-testid="tool-call-tool-search-result"]'),
).toBeNull();
expect(container.textContent).not.toContain("匹配工具:2 个");
expect(container.textContent).not.toContain("Read a file from disk");
});
it("站点能力工具失败时应展示未保存原因", () => {
const { container } = renderTool({
id: "tool-site-run-2",
@@ -31,12 +31,17 @@ import {
normalizeSiteToolResultSummary,
resolveSiteAdapterSourceLabel,
resolveSiteProjectSourceLabel,
resolveSiteSavedContentTarget,
resolveSiteSavedContentTargetFromMetadata,
} from "../utils/siteToolResultSummary";
import {
classifySearchQuerySemantic,
summarizeSearchQuerySemantics,
} from "../utils/searchQueryGrouping";
import {
normalizeToolSearchResultSummary,
resolveToolSearchItemSourceLabel,
resolveToolSearchItemStatusLabel,
} from "../utils/toolSearchResultSummary";
import type { ToolCallArgumentValue } from "../utils/toolDisplayInfo";
import {
buildGroupedChildLine as buildGroupedChildLineFromInfo,
@@ -550,8 +555,8 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
[toolCall.result?.metadata],
);
const savedSiteContentTarget = useMemo(
() => resolveSiteSavedContentTarget(siteResultSummary),
[siteResultSummary],
() => resolveSiteSavedContentTargetFromMetadata(toolCall.result?.metadata),
[toolCall.result?.metadata],
);
const resultText = useMemo(() => {
const rawText = toolCall.result?.error || toolCall.result?.output || "";
@@ -766,10 +771,22 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
);
const hasResultImages = resultImages.length > 0;
const hasSearchResults = searchResultItems.length > 0;
const isToolSearch = useMemo(
() => normalizeToolNameKeyFromInfo(toolCall.name) === "toolsearch",
[toolCall.name],
);
const toolSearchSummary = useMemo(
() => (isToolSearch ? normalizeToolSearchResultSummary(resultText) : null),
[isToolSearch, resultText],
);
const hasToolSearchSummary = Boolean(toolSearchSummary);
const shouldShowRawSearchResultToggle =
hasSearchResults && resultText !== "(无输出)";
const shouldRenderResultPanel =
isExpanded && hasResult && (!hasSearchResults || showRawSearchResultOutput);
isExpanded &&
hasResult &&
(!hasSearchResults || showRawSearchResultOutput) &&
!hasToolSearchSummary;
const handleOpenExternalUrl = useCallback(async (url: string) => {
try {
@@ -786,12 +803,14 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
useEffect(() => {
if (
isMessageStreaming &&
!isToolSearch &&
(isRunning || hasResult || hasResultImages || hasSearchResults)
) {
setIsExpanded(true);
}
}, [
isMessageStreaming,
isToolSearch,
isRunning,
hasResult,
hasResultImages,
@@ -990,6 +1009,65 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
</div>
)}
{toolSearchSummary && isExpanded ? (
<div
className="mb-2 ml-6 mt-1.5 space-y-2"
data-testid="tool-call-tool-search-result"
>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
<span>匹配工具:{toolSearchSummary.count} 个</span>
{toolSearchSummary.query ? (
<span className="break-all">查询:{toolSearchSummary.query}</span>
) : null}
{typeof toolSearchSummary.totalDeferredTools === "number" ? (
<span>Deferred 总数:{toolSearchSummary.totalDeferredTools}</span>
) : null}
</div>
{toolSearchSummary.notes.length > 0 ? (
<div className="space-y-1 text-[11px] text-amber-700">
{toolSearchSummary.notes.map((note, index) => (
<div key={`${note}-${index}`}>{note}</div>
))}
</div>
) : null}
{toolSearchSummary.tools.length > 0 ? (
<div className="space-y-2">
{toolSearchSummary.tools.map((item) => {
const sourceLabel = resolveToolSearchItemSourceLabel(item);
const statusLabel = resolveToolSearchItemStatusLabel(item);
return (
<div
key={item.name}
className="rounded-[14px] border border-slate-200 bg-white p-3"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-slate-900">
{item.name}
</span>
{sourceLabel ? (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">
{sourceLabel}
</span>
) : null}
{statusLabel ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">
{statusLabel}
</span>
) : null}
</div>
{item.description ? (
<div className="mt-1 text-[11px] leading-5 text-slate-500">
{item.description}
</div>
) : null}
</div>
);
})}
</div>
) : null}
</div>
) : null}
{shouldRenderResultPanel && (
<div
className="mb-2 ml-6 mt-1.5 space-y-2"
@@ -0,0 +1,368 @@
import { Activity, Bot, Clock3, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import type { SessionActivityPreviewState } from "../../team-workspace-runtime/activityPreviewSelectors";
import type { SelectedSessionDetailDisplayState } from "../../team-workspace-runtime/selectedSessionDetailSelectors";
import type { TeamWorkspaceActivityEntry } from "../../teamWorkspaceRuntime";
type SelectedSessionInlineDetailAction =
| "close"
| "interrupt_send"
| "resume"
| "send"
| "wait";
interface SelectedSessionInlineDetailProps {
canOpenSelectedSession: boolean;
canResumeSelectedSession: boolean;
canSendSelectedSessionInput: boolean;
canStopSelectedSession: boolean;
canWaitSelectedSession: boolean;
detailSummary: string;
detailDisplay: SelectedSessionDetailDisplayState;
formatUpdatedAt: (updatedAt?: number) => string;
inlineDetailSectionClassName: string;
inlineTimelineEntryClassName: string;
inlineTimelineFeedClassName: string;
isChildSession: boolean;
onOpenSelectedSession?: () => void;
onSelectedSessionAction: (
action: "close" | "resume" | "wait",
) => void | Promise<void>;
onSelectedSessionInputDraftChange: (value: string) => void;
onSelectedSessionSendInput: (interrupt: boolean) => void | Promise<void>;
pendingAction: SelectedSessionInlineDetailAction | null;
selectedActionPending: boolean;
selectedSession: {
id: string;
isCurrent?: boolean;
updatedAt?: number;
};
selectedSessionActivityEntries: TeamWorkspaceActivityEntry[];
selectedSessionActivityPreview: SessionActivityPreviewState | null;
selectedSessionActivityPreviewText: string | null;
selectedSessionActivityShouldPoll: boolean;
selectedSessionInputDraft: string;
selectedSessionInputMessage: string;
selectedSessionSupportsActivityPreview: boolean;
}
export function SelectedSessionInlineDetail({
canOpenSelectedSession,
canResumeSelectedSession,
canSendSelectedSessionInput,
canStopSelectedSession,
canWaitSelectedSession,
detailSummary,
detailDisplay,
formatUpdatedAt,
inlineDetailSectionClassName,
inlineTimelineEntryClassName,
inlineTimelineFeedClassName,
isChildSession,
onOpenSelectedSession,
onSelectedSessionAction,
onSelectedSessionInputDraftChange,
onSelectedSessionSendInput,
pendingAction,
selectedActionPending,
selectedSession,
selectedSessionActivityEntries,
selectedSessionActivityPreview,
selectedSessionActivityPreviewText,
selectedSessionActivityShouldPoll,
selectedSessionInputDraft,
selectedSessionInputMessage,
selectedSessionSupportsActivityPreview,
}: SelectedSessionInlineDetailProps) {
return (
<div
className="mt-3 border-t border-slate-200 pt-3"
data-testid={`team-workspace-member-detail-${selectedSession.id}`}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">
<Bot className="h-3.5 w-3.5" />
<span>当前查看</span>
{selectedSession.isCurrent ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-medium tracking-normal text-slate-600 normal-case">
当前对话
</span>
) : null}
</div>
<p
className="mt-2 text-sm leading-6 text-slate-600"
data-testid="team-workspace-session-summary"
>
{detailSummary}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{canResumeSelectedSession ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={selectedActionPending}
onClick={() => void onSelectedSessionAction("resume")}
>
{selectedActionPending && pendingAction === "resume" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
{selectedActionPending && pendingAction === "resume"
? "继续中..."
: "继续处理"}
</Button>
) : null}
{canStopSelectedSession ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={selectedActionPending}
onClick={() => void onSelectedSessionAction("close")}
>
{selectedActionPending && pendingAction === "close" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
{selectedActionPending && pendingAction === "close"
? "暂停中..."
: "暂停处理"}
</Button>
) : null}
{canOpenSelectedSession ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void onOpenSelectedSession?.()}
>
{isChildSession ? "切换会话" : "打开对话"}
</Button>
) : null}
</div>
</div>
{detailDisplay.runtimeDetailSummary ? (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
{detailDisplay.runtimeDetailSummary}
</span>
</div>
) : null}
{detailDisplay.queueReason ? (
<div className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-900">
{detailDisplay.queueReason}
</div>
) : null}
{detailDisplay.hasSettings ? (
<div className={inlineDetailSectionClassName}>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<Bot className="h-3.5 w-3.5" />
<span>协作设置</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
{detailDisplay.settingBadges.map((badge) => (
<span
key={badge}
className="rounded-full border border-slate-200 bg-white px-2.5 py-1"
>
{badge}
</span>
))}
</div>
{detailDisplay.outputContract ? (
<p className="mt-3 text-sm leading-6 text-slate-600">
{detailDisplay.outputContract}
</p>
) : null}
{detailDisplay.skillBadges.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{detailDisplay.skillBadges.map((skill) => (
<span
key={`${selectedSession.id}-${skill.id}`}
className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-600"
title={skill.title}
>
{skill.label}
</span>
))}
</div>
) : null}
</div>
) : null}
{canWaitSelectedSession || canSendSelectedSessionInput ? (
<div className={inlineDetailSectionClassName}>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<Clock3 className="h-3.5 w-3.5" />
<span>继续协作</span>
{canWaitSelectedSession ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-medium tracking-normal text-slate-600 normal-case">
可直接查看结果
</span>
) : null}
</div>
{canWaitSelectedSession ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={selectedActionPending}
onClick={() => void onSelectedSessionAction("wait")}
>
{selectedActionPending && pendingAction === "wait" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
{selectedActionPending && pendingAction === "wait"
? "等待中..."
: "等待结果 30 秒"}
</Button>
<span className="text-xs leading-5 text-slate-500">
仅在当前内容确实依赖这位成员结果时使用。
</span>
</div>
) : null}
{canSendSelectedSessionInput ? (
<div className="mt-3 space-y-3">
<Textarea
value={selectedSessionInputDraft}
onChange={(event) =>
onSelectedSessionInputDraftChange(event.target.value)
}
placeholder="给这位协作成员补充说明、补充约束,或请它继续推进下一步。"
className="min-h-[96px] resize-y border-slate-200 bg-white text-sm text-slate-700 placeholder:text-slate-400"
data-testid="team-workspace-send-input-textarea"
/>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
disabled={
selectedActionPending || !selectedSessionInputMessage
}
onClick={() => void onSelectedSessionSendInput(false)}
>
{selectedActionPending && pendingAction === "send" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
{selectedActionPending && pendingAction === "send"
? "发送中..."
: "发送说明"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={
selectedActionPending || !selectedSessionInputMessage
}
onClick={() => void onSelectedSessionSendInput(true)}
>
{selectedActionPending &&
pendingAction === "interrupt_send" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
{selectedActionPending && pendingAction === "interrupt_send"
? "中断中..."
: "立即插入说明"}
</Button>
<span className="text-xs leading-5 text-slate-500">
这条说明只会发送给当前成员,不影响其他协作成员。
</span>
</div>
</div>
) : null}
</div>
) : null}
{selectedSessionSupportsActivityPreview ? (
<div className={inlineDetailSectionClassName}>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<Activity className="h-3.5 w-3.5" />
<span>完整进展</span>
{selectedSessionActivityShouldPoll ? (
<span className="rounded-full border border-sky-200 bg-white px-2 py-0.5 text-[10px] font-medium tracking-normal text-sky-700 normal-case">
处理中自动刷新
</span>
) : null}
</div>
{selectedSessionActivityPreviewText ? (
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">
{selectedSessionActivityPreviewText}
</p>
) : selectedSessionActivityPreview?.status === "error" ? (
<p className="mt-2 text-sm leading-6 text-rose-600">
最新进展暂不可用:
{selectedSessionActivityPreview.errorMessage ?? "同步失败"}
</p>
) : selectedSessionActivityPreview?.status === "ready" ? (
<p className="mt-2 text-sm leading-6 text-slate-500">
这位成员暂时还没有可展示的新进展。
</p>
) : (
<p className="mt-2 text-sm leading-6 text-slate-500">
正在同步这位成员的最新进展...
</p>
)}
{selectedSessionActivityEntries.length > 0 ? (
<div
className={inlineTimelineFeedClassName}
data-testid="team-workspace-activity-feed"
>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
<span>进展记录</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-slate-600 normal-case">
{selectedSessionActivityEntries.length} 条
</span>
</div>
<div className="mt-3 space-y-2.5">
{selectedSessionActivityEntries.map((entry) => (
<div key={entry.id} className={inlineTimelineEntryClassName}>
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="font-semibold text-slate-800">
{entry.title}
</span>
<span
className={cn(
"rounded-full px-2 py-0.5 font-medium",
entry.badgeClassName,
)}
>
{entry.statusLabel}
</span>
</div>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-600">
{entry.detail}
</p>
</div>
))}
</div>
</div>
) : null}
</div>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2.5 py-1">
<Clock3 className="h-3.5 w-3.5" />
更新于 {formatUpdatedAt(selectedSession.updatedAt)}
</span>
{detailDisplay.metadata.map((meta) => (
<span
key={meta}
className="rounded-full border border-slate-200 bg-white px-2.5 py-1"
>
{meta}
</span>
))}
</div>
</div>
);
}
@@ -0,0 +1,144 @@
import {
Activity,
ArrowUpLeft,
ChevronDown,
ChevronUp,
Workflow,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { TeamWorkspaceBoardChromeDisplayState } from "../../team-workspace-runtime/boardChromeSelectors";
import type { TeamWorkspaceRuntimeStatus } from "../../teamWorkspaceRuntime";
import {
TEAM_WORKSPACE_REALTIME_BADGE_LABEL,
TEAM_WORKSPACE_SURFACE_TITLE,
} from "../../utils/teamWorkspaceCopy";
interface TeamWorkspaceStatusMeta {
badgeClassName: string;
}
interface TeamWorkspaceBoardHeaderProps {
boardChromeDisplay: TeamWorkspaceBoardChromeDisplayState;
className: string;
createdFromTurnId?: string | null;
dataTestId?: string;
detailToggleLabel: string;
detailVisible: boolean;
isChildSession: boolean;
isEmptyShellState: boolean;
onReturnToParentSession?: () => void | Promise<void>;
onToggleDetail: () => void;
resolveStatusMeta: (
status?: TeamWorkspaceRuntimeStatus,
) => TeamWorkspaceStatusMeta;
runtimeFormationStatusLabel?: string | null;
totalTeamSessions: number;
useCompactCanvasChrome: boolean;
}
export function TeamWorkspaceBoardHeader({
boardChromeDisplay,
className,
createdFromTurnId,
dataTestId,
detailToggleLabel,
detailVisible,
isChildSession,
isEmptyShellState,
onReturnToParentSession,
onToggleDetail,
resolveStatusMeta,
runtimeFormationStatusLabel,
totalTeamSessions,
useCompactCanvasChrome,
}: TeamWorkspaceBoardHeaderProps) {
return (
<div className={className} data-testid={dataTestId}>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
<Workflow className="h-3.5 w-3.5" />
<span>{TEAM_WORKSPACE_SURFACE_TITLE}</span>
<span className="ml-1 inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-emerald-700 normal-case">
<Activity className="h-3 w-3" />
{TEAM_WORKSPACE_REALTIME_BADGE_LABEL}
</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span
className={cn(
"font-semibold text-slate-900",
useCompactCanvasChrome ? "text-sm" : "text-[15px]",
)}
>
{boardChromeDisplay.compactBoardHeadline}
</span>
{createdFromTurnId ? (
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500">
来自之前的任务 {createdFromTurnId}
</span>
) : null}
{!useCompactCanvasChrome && !isChildSession && totalTeamSessions > 0 ? (
<span className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[11px] text-sky-700">
{totalTeamSessions} 位协作成员
</span>
) : null}
</div>
{!useCompactCanvasChrome ? (
<p className="mt-1 text-xs leading-5 text-slate-500">
{boardChromeDisplay.boardHint}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{isEmptyShellState ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onToggleDetail}
data-testid="team-workspace-detail-toggle"
>
{detailVisible ? (
<ChevronUp className="mr-1.5 h-3.5 w-3.5" />
) : (
<ChevronDown className="mr-1.5 h-3.5 w-3.5" />
)}
{detailToggleLabel}
</Button>
) : null}
{isEmptyShellState ? (
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500">
{runtimeFormationStatusLabel || "还没有协作成员加入"}
</span>
) : !useCompactCanvasChrome ? (
boardChromeDisplay.statusSummaryBadges.map((badge) => {
const meta = resolveStatusMeta(badge.status);
return (
<span
key={badge.key}
className={cn(
"inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-medium",
meta.badgeClassName,
)}
>
{badge.text}
</span>
);
})
) : null}
{isChildSession && onReturnToParentSession ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void onReturnToParentSession()}
>
<ArrowUpLeft className="mr-1.5 h-3.5 w-3.5" />
返回主助手
</Button>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,272 @@
import { type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { PanelTop } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TeamWorkspaceCanvasLane } from "../../team-workspace-runtime/canvasLaneSelectors";
import type { TeamWorkspaceCanvasItemLayout } from "../../utils/teamWorkspaceCanvas";
export type TeamWorkspaceCanvasResizeDirection =
| "n"
| "s"
| "e"
| "w"
| "ne"
| "nw"
| "se"
| "sw";
interface TeamWorkspaceCanvasLaneCardProps {
expanded: boolean;
lane: TeamWorkspaceCanvasLane;
layout: TeamWorkspaceCanvasItemLayout;
onSelectLane: () => void;
onStartDrag: (event: ReactMouseEvent<HTMLDivElement>) => void;
onStartResize: (
direction: TeamWorkspaceCanvasResizeDirection,
event: ReactMouseEvent<HTMLSpanElement>,
) => void;
selected: boolean;
selectedInlineDetail?: ReactNode;
}
const RESIZE_HANDLES: Array<{
className: string;
direction: TeamWorkspaceCanvasResizeDirection;
}> = [
{
direction: "n",
className:
"left-1/2 top-0 h-3 w-14 -translate-x-1/2 -translate-y-1/2 cursor-n-resize",
},
{
direction: "s",
className:
"bottom-0 left-1/2 h-3 w-14 -translate-x-1/2 translate-y-1/2 cursor-s-resize",
},
{
direction: "e",
className:
"right-0 top-1/2 h-14 w-3 -translate-y-1/2 translate-x-1/2 cursor-e-resize",
},
{
direction: "w",
className:
"left-0 top-1/2 h-14 w-3 -translate-x-1/2 -translate-y-1/2 cursor-w-resize",
},
{
direction: "ne",
className:
"right-0 top-0 h-4 w-4 translate-x-1/2 -translate-y-1/2 cursor-ne-resize",
},
{
direction: "nw",
className:
"left-0 top-0 h-4 w-4 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize",
},
{
direction: "se",
className:
"bottom-0 right-0 h-4 w-4 translate-x-1/2 translate-y-1/2 cursor-se-resize",
},
{
direction: "sw",
className:
"bottom-0 left-0 h-4 w-4 -translate-x-1/2 translate-y-1/2 cursor-sw-resize",
},
];
export function TeamWorkspaceCanvasLaneCard({
expanded,
lane,
layout,
onSelectLane,
onStartDrag,
onStartResize,
selected,
selectedInlineDetail,
}: TeamWorkspaceCanvasLaneCardProps) {
return (
<div
data-team-workspace-canvas-pan-block="true"
data-testid={`team-workspace-member-lane-${lane.id}`}
data-lane-x={Math.round(layout.x)}
data-lane-y={Math.round(layout.y)}
data-lane-width={Math.round(layout.width)}
data-lane-height={Math.round(layout.height)}
data-expanded={expanded ? "true" : "false"}
className="absolute"
onClick={onSelectLane}
style={{
transform: `translate(${layout.x}px, ${layout.y}px)`,
width: `${layout.width}px`,
height: `${layout.height}px`,
zIndex: layout.zIndex,
}}
>
<div
role={lane.session ? "button" : undefined}
aria-pressed={lane.session ? selected : undefined}
tabIndex={lane.session ? 0 : -1}
onClick={onSelectLane}
className={cn(
"group flex h-full flex-col overflow-hidden rounded-[24px] border bg-white text-left shadow-[0_18px_52px_-32px_rgba(15,23,42,0.28)] transition",
lane.kind === "planned"
? "border-dashed border-slate-300"
: lane.kind === "runtime"
? "border-sky-200"
: "border-slate-200",
selected ? "ring-2 ring-slate-300" : "hover:border-slate-300",
)}
>
<div
data-testid={`team-workspace-member-lane-header-${lane.id}`}
className="flex cursor-grab items-start justify-between gap-3 border-b border-slate-200 bg-slate-50/88 px-4 py-3 active:cursor-grabbing"
onMouseDown={onStartDrag}
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-semibold text-slate-900">
{lane.title}
</span>
{lane.session?.isCurrent ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-medium text-slate-600">
当前
</span>
) : null}
{expanded ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-medium text-slate-700">
当前查看
</span>
) : null}
{lane.kind === "runtime" ? (
<span className="rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-medium text-sky-700">
当前分工
</span>
) : null}
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-500">
{lane.roleLabel ? (
<span className="rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-sky-700">
分工 · {lane.roleLabel}
</span>
) : null}
{lane.profileLabel ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5">
{lane.profileLabel}
</span>
) : null}
<span
className={cn(
"rounded-full px-2 py-0.5 font-medium",
lane.badgeClassName,
)}
>
{lane.badgeLabel}
</span>
</div>
</div>
<span
className={cn(
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full",
lane.dotClassName,
)}
/>
</div>
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
<p className="text-sm leading-6 text-slate-600">{lane.summary}</p>
{lane.skillLabels.length > 0 || lane.presetLabel ? (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-500">
{lane.presetLabel ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5">
{lane.presetLabel}
</span>
) : null}
{lane.skillLabels.slice(0, 4).map((skillLabel) => (
<span
key={`${lane.persistKey}-${skillLabel}`}
className="rounded-full border border-slate-200 bg-white px-2 py-0.5"
>
{skillLabel}
</span>
))}
</div>
) : null}
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-slate-200 bg-slate-50">
<div className="flex items-center gap-1.5 border-b border-slate-200 px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">
<PanelTop className="h-3 w-3" />
<span>成员进展</span>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-medium tracking-normal text-slate-600 normal-case">
{lane.kind === "session" ? "最近进展" : "等待接入"}
</span>
</div>
<div className="min-h-0 flex-1 overflow-auto px-3 py-3">
<p className="whitespace-pre-wrap break-words text-[12px] leading-5 text-slate-700">
{lane.previewText}
</p>
{lane.previewEntries && lane.previewEntries.length > 0 ? (
<div className="mt-3 space-y-2">
{lane.previewEntries.map((entry) => (
<div
key={`${lane.persistKey}-${entry.id}`}
className="rounded-2xl border border-slate-200 bg-white px-3 py-2.5"
>
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="font-semibold text-slate-800">
{entry.title}
</span>
<span
className={cn(
"rounded-full px-2 py-0.5 font-medium",
entry.badgeClassName,
)}
>
{entry.statusLabel}
</span>
</div>
<p className="mt-1.5 whitespace-pre-wrap break-words text-xs leading-5 text-slate-600">
{entry.detail}
</p>
</div>
))}
</div>
) : null}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-[11px] text-slate-500">
<div className="flex flex-wrap items-center gap-1.5">
{lane.statusHint ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5">
{lane.statusHint}
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{lane.updatedAtLabel ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5">
{lane.updatedAtLabel}
</span>
) : null}
{lane.modelLabel ? (
<span className="max-w-[180px] truncate rounded-full border border-slate-200 bg-white px-2 py-0.5">
{lane.modelLabel}
</span>
) : null}
</div>
</div>
{selectedInlineDetail}
</div>
</div>
{RESIZE_HANDLES.map((handle) => (
<span
key={`${lane.persistKey}-${handle.direction}`}
data-testid={`team-workspace-member-lane-resize-${lane.id}-${handle.direction}`}
aria-hidden="true"
className={cn(
"absolute rounded-full border border-slate-300 bg-white shadow-sm",
handle.className,
)}
onMouseDown={(event) => onStartResize(handle.direction, event)}
/>
))}
</div>
);
}
@@ -0,0 +1,168 @@
import {
type MouseEvent as ReactMouseEvent,
type Ref,
type ReactNode,
type WheelEvent as ReactWheelEvent,
} from "react";
import { cn } from "@/lib/utils";
import type { TeamWorkspaceCanvasLane } from "../../team-workspace-runtime/canvasLaneSelectors";
import type {
TeamWorkspaceCanvasItemLayout,
TeamWorkspaceCanvasLayoutState,
} from "../../utils/teamWorkspaceCanvas";
import {
TeamWorkspaceCanvasLaneCard,
type TeamWorkspaceCanvasResizeDirection,
} from "./TeamWorkspaceCanvasLaneCard";
interface TeamWorkspaceCanvasStageProps {
canvasBoundsHeight: number;
canvasBoundsWidth: number;
canvasStageHeight: number | string;
canvasStageHint: string;
expandedSessionId?: string | null;
isCanvasPanModifierActive: boolean;
laneLayouts: Record<string, TeamWorkspaceCanvasItemLayout>;
lanes: TeamWorkspaceCanvasLane[];
onCanvasWheel: (event: ReactWheelEvent<HTMLDivElement>) => void;
onSelectLane: (lane: TeamWorkspaceCanvasLane) => void;
onStartCanvasLaneDrag: (
lane: TeamWorkspaceCanvasLane,
event: ReactMouseEvent<HTMLDivElement>,
) => void;
onStartCanvasLaneResize: (
lane: TeamWorkspaceCanvasLane,
direction: TeamWorkspaceCanvasResizeDirection,
event: ReactMouseEvent<HTMLSpanElement>,
) => void;
onStartCanvasPan: (event: ReactMouseEvent<HTMLDivElement>) => void;
renderSelectedInlineDetail?: (lane: TeamWorkspaceCanvasLane) => ReactNode;
selectedSessionId?: string | null;
viewport: TeamWorkspaceCanvasLayoutState["viewport"];
viewportRef: Ref<HTMLDivElement>;
}
export function TeamWorkspaceCanvasStage({
canvasBoundsHeight,
canvasBoundsWidth,
canvasStageHeight,
canvasStageHint,
expandedSessionId,
isCanvasPanModifierActive,
laneLayouts,
lanes,
onCanvasWheel,
onSelectLane,
onStartCanvasLaneDrag,
onStartCanvasLaneResize,
onStartCanvasPan,
renderSelectedInlineDetail,
selectedSessionId,
viewport,
viewportRef,
}: TeamWorkspaceCanvasStageProps) {
return (
<div
ref={viewportRef}
className={cn(
"relative overflow-hidden rounded-[24px] border border-slate-200 bg-[linear-gradient(180deg,#f8fafc_0%,#ffffff_100%)] shadow-sm shadow-slate-950/5 cursor-grab active:cursor-grabbing",
isCanvasPanModifierActive && "cursor-grabbing",
)}
data-testid="team-workspace-rail-list"
data-layout-kind="free-canvas"
data-viewport-x={Math.round(viewport.x)}
data-viewport-y={Math.round(viewport.y)}
data-viewport-zoom={viewport.zoom.toFixed(2)}
data-pan-mode={isCanvasPanModifierActive ? "active" : "idle"}
style={{ height: canvasStageHeight }}
onMouseDown={onStartCanvasPan}
onWheel={onCanvasWheel}
>
<div
data-testid="team-workspace-canvas-pan-surface"
data-team-workspace-canvas-pan-surface="true"
className="absolute inset-0 opacity-60"
style={{
backgroundImage:
"linear-gradient(to right, rgba(148,163,184,0.08) 1px, transparent 1px), linear-gradient(to bottom, rgba(148,163,184,0.08) 1px, transparent 1px)",
backgroundSize: "32px 32px",
}}
/>
<div
data-team-workspace-canvas-pan-block="true"
className="absolute left-4 top-4 z-10 inline-flex max-w-[320px] items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[10px] text-slate-500 shadow-sm shadow-slate-950/5"
data-testid="team-workspace-canvas-shortcuts"
>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-semibold tracking-[0.12em] text-slate-500">
画布
</span>
<span className="truncate">空白处拖拽 · Space 手型 · A 整理 · F 适应</span>
</div>
{lanes.length > 0 ? (
<div
data-team-workspace-canvas-pan-surface="true"
className="absolute inset-0 overflow-hidden"
data-testid="team-workspace-canvas-stage"
>
<div
data-team-workspace-canvas-pan-surface="true"
className="absolute left-0 top-0"
style={{
transform: `translate(${viewport.x}px, ${viewport.y}px)`,
}}
>
<div
data-team-workspace-canvas-pan-surface="true"
className="relative"
style={{
width: `${canvasBoundsWidth}px`,
height: `${canvasBoundsHeight}px`,
transform: `scale(${viewport.zoom})`,
transformOrigin: "top left",
}}
>
{lanes.map((lane) => {
const layout = laneLayouts[lane.persistKey];
const selected =
lane.session?.id != null && selectedSessionId === lane.session.id;
const expanded =
selected &&
lane.session?.id != null &&
expandedSessionId === lane.session.id;
return (
<TeamWorkspaceCanvasLaneCard
key={lane.persistKey}
expanded={expanded}
lane={lane}
layout={layout}
onSelectLane={() => onSelectLane(lane)}
onStartDrag={(event) => onStartCanvasLaneDrag(lane, event)}
onStartResize={(direction, event) =>
onStartCanvasLaneResize(lane, direction, event)
}
selected={selected}
selectedInlineDetail={
expanded ? renderSelectedInlineDetail?.(lane) ?? null : null
}
/>
);
})}
</div>
</div>
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center p-8">
<div className="max-w-[520px] rounded-[24px] border border-dashed border-slate-300 bg-white/92 px-6 py-5 text-center shadow-sm shadow-slate-950/5">
<div className="text-sm font-semibold text-slate-900">
暂无协作画布
</div>
<p className="mt-2 text-sm leading-6 text-slate-500">
{canvasStageHint}
</p>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,91 @@
import { Activity, ChevronDown, Workflow } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type {
TeamWorkspaceRuntimeFormationDisplayState,
TeamWorkspaceSelectedTeamPlanDisplayState,
} from "../../team-workspace-runtime/formationDisplaySelectors";
import {
TEAM_WORKSPACE_REALTIME_BADGE_LABEL,
TEAM_WORKSPACE_SURFACE_TITLE,
} from "../../utils/teamWorkspaceCopy";
interface TeamWorkspaceEmptyShellStateProps {
className?: string;
embedded?: boolean;
hasRuntimeFormation: boolean;
onExpand: () => void;
runtimeFormationDisplay: TeamWorkspaceRuntimeFormationDisplayState;
selectedTeamPlanDisplay: TeamWorkspaceSelectedTeamPlanDisplayState;
}
export function TeamWorkspaceEmptyShellState({
className,
embedded = false,
hasRuntimeFormation,
onExpand,
runtimeFormationDisplay,
selectedTeamPlanDisplay,
}: TeamWorkspaceEmptyShellStateProps) {
const summaryBadges = hasRuntimeFormation
? runtimeFormationDisplay.summaryBadges
: selectedTeamPlanDisplay.summaryBadges;
return (
<section
className={cn(
"overflow-hidden rounded-[22px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5",
embedded && "pointer-events-auto",
embedded ? "mx-0 mt-0" : "mx-3 mt-2",
className,
)}
>
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 sm:px-5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
<Workflow className="h-3.5 w-3.5" />
<span>{TEAM_WORKSPACE_SURFACE_TITLE}</span>
<span className="ml-1 inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-emerald-700 normal-case">
<Activity className="h-3 w-3" />
{TEAM_WORKSPACE_REALTIME_BADGE_LABEL}
</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">
{hasRuntimeFormation
? runtimeFormationDisplay.panelHeadline
: "协作面板已就绪"}
</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-600">
{runtimeFormationDisplay.panelStatusLabel || "还没有协作成员加入"}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
{hasRuntimeFormation
? runtimeFormationDisplay.hint
: "这里先保持简洁,避免遮挡消息区;只有真正需要协作分工时才会展开完整面板。"}
</p>
{summaryBadges.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
{summaryBadges.map((badge) => (
<span key={badge.key} className={badge.className}>
{badge.text}
</span>
))}
</div>
) : null}
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={onExpand}
data-testid="team-workspace-detail-toggle"
>
<ChevronDown className="mr-1.5 h-3.5 w-3.5" />
查看任务进展
</Button>
</div>
</section>
);
}
@@ -0,0 +1,177 @@
import { Bot, Workflow } from "lucide-react";
import { cn } from "@/lib/utils";
import type {
TeamWorkspaceRuntimeFormationDisplayState,
TeamWorkspaceSelectedTeamPlanDisplayState,
} from "../../team-workspace-runtime/formationDisplaySelectors";
function TeamWorkspaceMemberCard(props: {
badgeClassName: string;
badgeLabel: string;
label: string;
summary: string;
}) {
return (
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-3.5 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">
{props.label}
</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
props.badgeClassName,
)}
>
{props.badgeLabel}
</span>
</div>
<p className="mt-2 text-sm leading-6 text-slate-600">{props.summary}</p>
</div>
);
}
function TeamWorkspaceRoleCard(props: { label: string; summary: string }) {
return (
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-3.5 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">
{props.label}
</span>
</div>
<p className="mt-2 text-sm leading-6 text-slate-600">{props.summary}</p>
</div>
);
}
interface TeamWorkspaceRuntimeFormationPanelProps {
runtimeFormationDisplay: TeamWorkspaceRuntimeFormationDisplayState;
showBlueprintRoleCards?: boolean;
}
export function TeamWorkspaceRuntimeFormationPanel({
runtimeFormationDisplay,
showBlueprintRoleCards = false,
}: TeamWorkspaceRuntimeFormationPanelProps) {
return (
<>
<div
data-testid="team-workspace-runtime-formation"
className="rounded-[18px] border border-slate-200 bg-white p-4 shadow-sm shadow-slate-950/5"
>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<Workflow className="h-3.5 w-3.5" />
<span>{runtimeFormationDisplay.panelTitle}</span>
{runtimeFormationDisplay.panelStatusLabel &&
runtimeFormationDisplay.panelStatusBadgeClassName ? (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium tracking-normal",
runtimeFormationDisplay.panelStatusBadgeClassName,
)}
>
{runtimeFormationDisplay.panelStatusLabel}
</span>
) : null}
{runtimeFormationDisplay.panelLabel ? (
<span className="rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-sky-700 normal-case">
{runtimeFormationDisplay.panelLabel}
</span>
) : null}
</div>
<div className="mt-2 text-sm font-semibold text-slate-900">
{runtimeFormationDisplay.panelHeadline}
</div>
<p className="mt-2 text-sm leading-6 text-slate-600">
{runtimeFormationDisplay.panelDescription}
</p>
{runtimeFormationDisplay.referenceLabel ? (
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-500">
参考方案:{runtimeFormationDisplay.referenceLabel}
</div>
) : null}
</div>
{runtimeFormationDisplay.memberCards.length > 0 ? (
<div
className="mt-3 grid gap-3 xl:grid-cols-2"
data-testid="team-workspace-runtime-members"
>
{runtimeFormationDisplay.memberCards.map((member) => (
<TeamWorkspaceMemberCard
key={`runtime-team-member-${member.id}`}
badgeClassName={member.badgeClassName}
badgeLabel={member.badgeLabel}
label={member.label}
summary={member.summary}
/>
))}
</div>
) : null}
{showBlueprintRoleCards &&
runtimeFormationDisplay.blueprintRoleCards.length > 0 ? (
<div className="rounded-[18px] border border-slate-200 bg-slate-50 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
参考分工
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-2">
{runtimeFormationDisplay.blueprintRoleCards.map((role) => (
<div
key={`runtime-blueprint-role-${role.id}`}
className="rounded-2xl border border-slate-200 bg-white px-3.5 py-3"
>
<div className="text-sm font-semibold text-slate-900">
{role.label}
</div>
<p className="mt-2 text-sm leading-6 text-slate-600">
{role.summary}
</p>
</div>
))}
</div>
</div>
) : null}
</>
);
}
interface TeamWorkspaceSelectedPlanPanelProps {
selectedTeamPlanDisplay: TeamWorkspaceSelectedTeamPlanDisplayState;
}
export function TeamWorkspaceSelectedPlanPanel({
selectedTeamPlanDisplay,
}: TeamWorkspaceSelectedPlanPanelProps) {
if (!selectedTeamPlanDisplay.hasSelectedTeamPlan) {
return null;
}
return (
<div className="rounded-[18px] border border-slate-200 bg-white p-4 shadow-sm shadow-slate-950/5">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<Bot className="h-3.5 w-3.5" />
<span>计划中的协作分工</span>
{selectedTeamPlanDisplay.label ? (
<span className="rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-sky-700 normal-case">
{selectedTeamPlanDisplay.label}
</span>
) : null}
</div>
{selectedTeamPlanDisplay.summary ? (
<p className="mt-2 text-sm leading-6 text-slate-600">
{selectedTeamPlanDisplay.summary}
</p>
) : null}
{selectedTeamPlanDisplay.roleCards.length > 0 ? (
<div className="mt-3 grid gap-3 xl:grid-cols-2">
{selectedTeamPlanDisplay.roleCards.map((role) => (
<TeamWorkspaceRoleCard
key={`planned-team-role-${role.id}`}
label={role.label}
summary={role.summary}
/>
))}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,435 @@
import { Activity, Clock3, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { TeamWorkspaceBoardChromeDisplayState } from "../../team-workspace-runtime/boardChromeSelectors";
import {
formatOperationUpdatedAt,
type TeamOperationDisplayEntry,
} from "../../team-workspace-runtime/teamOperationSelectors";
import type { TeamWorkspaceRuntimeStatus } from "../../teamWorkspaceRuntime";
interface TeamWorkspaceStatusMeta {
badgeClassName: string;
}
interface TeamWorkspaceOverviewSelectedSession {
isCurrent?: boolean;
updatedAt?: number;
}
interface TeamWorkspaceTeamOverviewChromeProps {
boardChromeDisplay: TeamWorkspaceBoardChromeDisplayState;
canCloseCompletedTeamSessions: boolean;
canWaitAnyActiveTeamSession: boolean;
completedCount: number;
embedded: boolean;
formatUpdatedAt: (updatedAt?: number) => string;
memberCanvasSubtitle: string;
memberCanvasTitle: string;
onAutoArrangeCanvas: () => void;
onCloseCompletedTeamSessions: () => void | Promise<void>;
onFitCanvasView: () => void;
onSelectTeamOperationEntry: (entry: TeamOperationDisplayEntry) => void;
onWaitAnyActiveTeamSessions: () => void | Promise<void>;
onZoomIn: () => void;
onZoomOut: () => void;
pendingTeamAction: "wait_any" | "close_completed" | null;
resolveStatusMeta: (
status?: TeamWorkspaceRuntimeStatus,
) => TeamWorkspaceStatusMeta;
selectedSession?: TeamWorkspaceOverviewSelectedSession | null;
teamOperationEntries: TeamOperationDisplayEntry[];
useCompactCanvasChrome: boolean;
waitableCount: number;
}
function TeamWorkspaceTeamActionButtons(props: {
canCloseCompletedTeamSessions: boolean;
canWaitAnyActiveTeamSession: boolean;
onCloseCompletedTeamSessions: () => void | Promise<void>;
onWaitAnyActiveTeamSessions: () => void | Promise<void>;
pendingTeamAction: "wait_any" | "close_completed" | null;
}) {
return (
<>
{props.canWaitAnyActiveTeamSession ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={props.pendingTeamAction === "wait_any"}
onClick={() => void props.onWaitAnyActiveTeamSessions()}
data-testid="team-workspace-wait-active-button"
>
{props.pendingTeamAction === "wait_any" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
{props.pendingTeamAction === "wait_any"
? "等待中..."
: "等待任一成员结果"}
</Button>
) : null}
{props.canCloseCompletedTeamSessions ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={props.pendingTeamAction === "close_completed"}
onClick={() => void props.onCloseCompletedTeamSessions()}
data-testid="team-workspace-close-completed-button"
>
{props.pendingTeamAction === "close_completed" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
{props.pendingTeamAction === "close_completed"
? "收起中..."
: "收起已完成成员"}
</Button>
) : null}
</>
);
}
function TeamWorkspaceCanvasViewButtons(props: {
onAutoArrangeCanvas: () => void;
onFitCanvasView: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
}) {
return (
<>
<Button
type="button"
size="sm"
variant="outline"
onClick={props.onAutoArrangeCanvas}
data-testid="team-workspace-auto-arrange-button"
>
整理布局
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={props.onZoomOut}
>
缩小
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={props.onZoomIn}
>
放大
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={props.onFitCanvasView}
>
适应视图
</Button>
</>
);
}
function TeamWorkspaceCompactToolbarChip(props: {
chip: TeamWorkspaceBoardChromeDisplayState["compactToolbarChips"][number];
compactCanvasMutedChipClassName: string;
compactCanvasSummaryChipClassName: string;
resolveStatusMeta: (
status?: TeamWorkspaceRuntimeStatus,
) => TeamWorkspaceStatusMeta;
}) {
if (props.chip.tone === "status") {
return (
<span
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-medium",
props.resolveStatusMeta(props.chip.status).badgeClassName,
)}
>
{props.chip.text}
</span>
);
}
return (
<span
className={
props.chip.tone === "summary"
? props.compactCanvasSummaryChipClassName
: props.compactCanvasMutedChipClassName
}
>
{props.chip.text}
</span>
);
}
function TeamWorkspaceTeamOperationsPanel(props: {
embedded: boolean;
onSelectTeamOperationEntry: (entry: TeamOperationDisplayEntry) => void;
teamOperationEntries: TeamOperationDisplayEntry[];
useCompactCanvasChrome: boolean;
}) {
if (props.teamOperationEntries.length === 0) {
return null;
}
if (props.useCompactCanvasChrome) {
return (
<div
className="mt-2 flex items-start gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
data-testid="team-workspace-team-operations"
>
<div className="sticky left-0 z-10 flex shrink-0 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-medium text-slate-500 shadow-sm shadow-slate-950/5">
<Activity className="h-3.5 w-3.5" />
<span>协作动态</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-600">
{props.teamOperationEntries.length}
</span>
</div>
{props.teamOperationEntries.map((entry) => {
const content = (
<div className="flex min-w-0 items-start gap-2">
<span
className={cn(
"mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
entry.badgeClassName,
)}
>
{entry.title}
</span>
<div className="min-w-0">
<div className="truncate text-xs leading-5 text-slate-700">
{entry.detail}
</div>
<div className="mt-0.5 text-[10px] text-slate-500">
{formatOperationUpdatedAt(entry.updatedAt)}
</div>
</div>
</div>
);
return entry.targetSessionId ? (
<button
key={entry.id}
type="button"
className="inline-flex min-w-[220px] max-w-[340px] shrink-0 rounded-[16px] border border-slate-200 bg-white px-3 py-2 text-left transition hover:border-slate-300 hover:bg-slate-50"
onClick={() => props.onSelectTeamOperationEntry(entry)}
>
{content}
</button>
) : (
<div
key={entry.id}
className="inline-flex min-w-[220px] max-w-[340px] shrink-0 rounded-[16px] border border-slate-200 bg-slate-50 px-3 py-2"
>
{content}
</div>
);
})}
</div>
);
}
return (
<div
className={cn(
props.embedded
? "mt-3 border-t border-slate-200 pt-3"
: "mt-3 rounded-[18px] border border-slate-200 bg-white p-3",
)}
data-testid="team-workspace-team-operations"
>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<Activity className="h-3.5 w-3.5" />
<span>协作动态</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-slate-600 normal-case">
最近 {props.teamOperationEntries.length} 条
</span>
</div>
<div className="mt-3 space-y-2">
{props.teamOperationEntries.map((entry) => {
const content = (
<>
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
entry.badgeClassName,
)}
>
{entry.title}
</span>
<span className="text-[11px] text-slate-500">
{formatOperationUpdatedAt(entry.updatedAt)}
</span>
</div>
<p className="mt-1 text-sm leading-6 text-slate-700">
{entry.detail}
</p>
</>
);
return entry.targetSessionId ? (
<button
key={entry.id}
type="button"
className={cn(
"w-full text-left transition",
props.embedded
? "border-l-2 border-slate-200 px-3 py-2 hover:border-slate-300 hover:bg-slate-50"
: "rounded-[14px] border border-slate-200 bg-slate-50 px-3 py-2.5 hover:border-slate-300 hover:bg-slate-50",
)}
onClick={() => props.onSelectTeamOperationEntry(entry)}
>
{content}
</button>
) : (
<div
key={entry.id}
className={cn(
props.embedded
? "border-l-2 border-slate-200 px-3 py-2"
: "rounded-[14px] border border-slate-200 bg-slate-50 px-3 py-2.5",
)}
>
{content}
</div>
);
})}
</div>
</div>
);
}
export function TeamWorkspaceTeamOverviewChrome({
boardChromeDisplay,
canCloseCompletedTeamSessions,
canWaitAnyActiveTeamSession,
completedCount,
embedded,
formatUpdatedAt,
memberCanvasSubtitle,
memberCanvasTitle,
onAutoArrangeCanvas,
onCloseCompletedTeamSessions,
onFitCanvasView,
onSelectTeamOperationEntry,
onWaitAnyActiveTeamSessions,
onZoomIn,
onZoomOut,
pendingTeamAction,
resolveStatusMeta,
selectedSession,
teamOperationEntries,
useCompactCanvasChrome,
waitableCount,
}: TeamWorkspaceTeamOverviewChromeProps) {
const compactCanvasSummaryChipClassName =
"rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500";
const compactCanvasMutedChipClassName =
"rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-500";
return (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
{useCompactCanvasChrome ? (
<>
<div
className="flex flex-wrap items-center gap-2"
data-testid="team-workspace-canvas-toolbar"
>
{boardChromeDisplay.compactToolbarChips.map((chip) => (
<TeamWorkspaceCompactToolbarChip
key={chip.key}
chip={chip}
compactCanvasMutedChipClassName={
compactCanvasMutedChipClassName
}
compactCanvasSummaryChipClassName={
compactCanvasSummaryChipClassName
}
resolveStatusMeta={resolveStatusMeta}
/>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<TeamWorkspaceTeamActionButtons
canCloseCompletedTeamSessions={canCloseCompletedTeamSessions}
canWaitAnyActiveTeamSession={canWaitAnyActiveTeamSession}
onCloseCompletedTeamSessions={onCloseCompletedTeamSessions}
onWaitAnyActiveTeamSessions={onWaitAnyActiveTeamSessions}
pendingTeamAction={pendingTeamAction}
/>
<TeamWorkspaceCanvasViewButtons
onAutoArrangeCanvas={onAutoArrangeCanvas}
onFitCanvasView={onFitCanvasView}
onZoomIn={onZoomIn}
onZoomOut={onZoomOut}
/>
</div>
</>
) : (
<>
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
{memberCanvasTitle}
</div>
<div className="mt-1 text-sm font-semibold text-slate-900">
{memberCanvasSubtitle}
</div>
</div>
{selectedSession ? (
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">
<Clock3 className="h-3.5 w-3.5" />
更新于 {formatUpdatedAt(selectedSession.updatedAt)}
</span>
{selectedSession.isCurrent ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">
当前对话
</span>
) : null}
</div>
) : null}
</>
)}
</div>
{!useCompactCanvasChrome &&
(canWaitAnyActiveTeamSession ||
canCloseCompletedTeamSessions ||
teamOperationEntries.length > 0) ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
<TeamWorkspaceTeamActionButtons
canCloseCompletedTeamSessions={canCloseCompletedTeamSessions}
canWaitAnyActiveTeamSession={canWaitAnyActiveTeamSession}
onCloseCompletedTeamSessions={onCloseCompletedTeamSessions}
onWaitAnyActiveTeamSessions={onWaitAnyActiveTeamSessions}
pendingTeamAction={pendingTeamAction}
/>
{canWaitAnyActiveTeamSession ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-500">
{waitableCount} 位成员正在处理中
</span>
) : null}
{canCloseCompletedTeamSessions ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-500">
{completedCount} 位成员已完成
</span>
) : null}
</div>
) : null}
<TeamWorkspaceTeamOperationsPanel
embedded={embedded}
onSelectTeamOperationEntry={onSelectTeamOperationEntry}
teamOperationEntries={teamOperationEntries}
useCompactCanvasChrome={useCompactCanvasChrome}
/>
</>
);
}
@@ -1,27 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { listenAgentRuntimeEvent } from "@/lib/api/agentRuntimeEvents";
import { defaultAgentRuntimeAdapter } from "./agentRuntimeAdapter";
import {
createAgentRuntimeAdapter,
defaultAgentRuntimeAdapter,
} from "./agentRuntimeAdapter";
const { mockCreateAgentRuntimeClient, mockRuntimeClient } = vi.hoisted(() => {
const mockRuntimeClient = {
compactAgentRuntimeSession: vi.fn(),
createAgentRuntimeSession: vi.fn(),
deleteAgentRuntimeSession: vi.fn(),
getAgentRuntimeSession: vi.fn(),
getAgentRuntimeThreadRead: vi.fn(),
initAsterAgent: vi.fn(),
interruptAgentRuntimeTurn: vi.fn(),
listAgentRuntimeSessions: vi.fn(),
promoteAgentRuntimeQueuedTurn: vi.fn(),
replayAgentRuntimeRequest: vi.fn(),
removeAgentRuntimeQueuedTurn: vi.fn(),
resumeAgentRuntimeThread: vi.fn(),
respondAgentRuntimeAction: vi.fn(),
submitAgentRuntimeTurn: vi.fn(),
updateAgentRuntimeSession: vi.fn(),
};
return {
mockCreateAgentRuntimeClient: vi.fn(() => mockRuntimeClient),
mockRuntimeClient,
};
});
vi.mock("@/lib/api/agentRuntimeEvents", () => ({
listenAgentRuntimeEvent: vi.fn(),
}));
vi.mock("@/lib/api/agentRuntime", () => ({
initAsterAgent: vi.fn(),
createAgentRuntimeSession: vi.fn(),
listAgentRuntimeSessions: vi.fn(),
getAgentRuntimeSession: vi.fn(),
getAgentRuntimeThreadRead: vi.fn(),
replayAgentRuntimeRequest: vi.fn(),
updateAgentRuntimeSession: vi.fn(),
deleteAgentRuntimeSession: vi.fn(),
compactAgentRuntimeSession: vi.fn(),
interruptAgentRuntimeTurn: vi.fn(),
resumeAgentRuntimeThread: vi.fn(),
promoteAgentRuntimeQueuedTurn: vi.fn(),
removeAgentRuntimeQueuedTurn: vi.fn(),
respondAgentRuntimeAction: vi.fn(),
submitAgentRuntimeTurn: vi.fn(),
createAgentRuntimeClient: mockCreateAgentRuntimeClient,
}));
describe("defaultAgentRuntimeAdapter", () => {
@@ -53,4 +67,46 @@ describe("defaultAgentRuntimeAdapter", () => {
handler,
);
});
it("应允许注入自定义 runtime 事件监听器", async () => {
const injectedListen = vi.fn().mockResolvedValue(vi.fn());
const adapter = createAgentRuntimeAdapter({
listenRuntimeEvent: injectedListen,
});
const handler = vi.fn();
await adapter.listenToTurnEvents("turn-event-2", handler);
await adapter.listenToTeamEvents("team-event-2", handler);
expect(injectedListen).toHaveBeenNthCalledWith(
1,
"turn-event-2",
handler,
);
expect(injectedListen).toHaveBeenNthCalledWith(
2,
"team-event-2",
handler,
);
});
it("应允许注入自定义 runtime client", async () => {
const client = {
...mockRuntimeClient,
createAgentRuntimeSession: vi.fn().mockResolvedValue("session-9"),
};
const adapter = createAgentRuntimeAdapter({
client,
});
await expect(
adapter.createSession("workspace-9", "新会话", "auto"),
).resolves.toBe("session-9");
expect(client.createAgentRuntimeSession).toHaveBeenCalledWith(
"workspace-9",
"新会话",
"auto",
);
});
});
@@ -4,25 +4,15 @@ import {
type AgentEvent,
type AgentOp,
} from "@/lib/api/agentProtocol";
import { listenAgentRuntimeEvent } from "@/lib/api/agentRuntimeEvents";
import {
compactAgentRuntimeSession,
createAgentRuntimeSession,
deleteAgentRuntimeSession,
listenAgentRuntimeEvent,
type AgentRuntimeEventListener,
} from "@/lib/api/agentRuntimeEvents";
import {
createAgentRuntimeClient,
type AgentRuntimeReplayedActionRequiredView,
type AgentRuntimeClient,
type AsterAgentStatus,
getAgentRuntimeSession,
getAgentRuntimeThreadRead,
initAsterAgent,
interruptAgentRuntimeTurn,
promoteAgentRuntimeQueuedTurn,
replayAgentRuntimeRequest,
removeAgentRuntimeQueuedTurn,
resumeAgentRuntimeThread,
listAgentRuntimeSessions,
respondAgentRuntimeAction,
submitAgentRuntimeTurn,
updateAgentRuntimeSession,
type AsterExecutionStrategy,
type AsterSessionDetail,
type AsterSessionInfo,
@@ -90,118 +80,153 @@ export interface AgentRuntimeAdapter {
): Promise<UnlistenFn>;
}
export const defaultAgentRuntimeAdapter: AgentRuntimeAdapter = {
async init() {
return initAsterAgent();
},
async createSession(workspaceId, name, executionStrategy) {
return createAgentRuntimeSession(workspaceId, name, executionStrategy);
},
async listSessions() {
return listAgentRuntimeSessions();
},
async getSession(sessionId) {
return getAgentRuntimeSession(sessionId);
},
async getSessionReadModel(sessionId) {
return getAgentRuntimeThreadRead(sessionId);
},
async replayRequest(sessionId, requestId) {
return replayAgentRuntimeRequest({
session_id: sessionId,
request_id: requestId,
});
},
async renameSession(sessionId, title) {
await updateAgentRuntimeSession({
session_id: sessionId,
name: title,
});
},
async deleteSession(sessionId) {
await deleteAgentRuntimeSession(sessionId);
},
async setSessionExecutionStrategy(sessionId, executionStrategy) {
await updateAgentRuntimeSession({
session_id: sessionId,
execution_strategy: executionStrategy,
});
},
async setSessionAccessMode(sessionId, accessMode) {
await updateAgentRuntimeSession({
session_id: sessionId,
recent_access_mode: accessMode,
});
},
async setSessionProviderSelection(sessionId, providerType, model) {
await updateAgentRuntimeSession({
session_id: sessionId,
provider_name: providerType,
model_name: model,
});
},
async submitOp(op) {
switch (op.type) {
case "user_input":
await submitAgentRuntimeTurn(createSubmitTurnRequestFromAgentOp(op));
return;
default:
throw new Error(`当前 runtime adapter 尚不支持 AgentOp: ${op.type}`);
}
},
async compactSession(sessionId, eventName) {
await compactAgentRuntimeSession({
session_id: sessionId,
event_name: eventName,
});
},
async interruptTurn(sessionId) {
return interruptAgentRuntimeTurn({
session_id: sessionId,
});
},
async resumeThread(sessionId) {
return resumeAgentRuntimeThread({
session_id: sessionId,
});
},
async promoteQueuedTurn(sessionId, queuedTurnId) {
return promoteAgentRuntimeQueuedTurn({
session_id: sessionId,
queued_turn_id: queuedTurnId,
});
},
async removeQueuedTurn(sessionId, queuedTurnId) {
return removeAgentRuntimeQueuedTurn({
session_id: sessionId,
queued_turn_id: queuedTurnId,
});
},
async respondToAction(request) {
await respondAgentRuntimeAction({
session_id: request.sessionId,
request_id: request.requestId,
action_type: request.actionType,
confirmed: request.confirmed,
response: request.response,
user_data: request.userData,
metadata: request.metadata,
...(request.eventName ? { event_name: request.eventName } : {}),
...(request.actionScope
? {
action_scope: {
session_id: request.actionScope.sessionId,
thread_id: request.actionScope.threadId,
turn_id: request.actionScope.turnId,
},
}
: {}),
});
},
async listenToTurnEvents(eventName, handler) {
return listenAgentRuntimeEvent(eventName, handler);
},
async listenToTeamEvents(eventName, handler) {
return listenAgentRuntimeEvent(eventName, handler);
},
};
export interface AgentRuntimeAdapterDeps {
client?: Pick<
AgentRuntimeClient,
| "compactAgentRuntimeSession"
| "createAgentRuntimeSession"
| "deleteAgentRuntimeSession"
| "getAgentRuntimeSession"
| "getAgentRuntimeThreadRead"
| "initAsterAgent"
| "interruptAgentRuntimeTurn"
| "listAgentRuntimeSessions"
| "promoteAgentRuntimeQueuedTurn"
| "replayAgentRuntimeRequest"
| "removeAgentRuntimeQueuedTurn"
| "resumeAgentRuntimeThread"
| "respondAgentRuntimeAction"
| "submitAgentRuntimeTurn"
| "updateAgentRuntimeSession"
>;
listenRuntimeEvent?: AgentRuntimeEventListener;
}
export function createAgentRuntimeAdapter({
client = createAgentRuntimeClient(),
listenRuntimeEvent = listenAgentRuntimeEvent,
}: AgentRuntimeAdapterDeps = {}): AgentRuntimeAdapter {
return {
async init() {
return client.initAsterAgent();
},
async createSession(workspaceId, name, executionStrategy) {
return client.createAgentRuntimeSession(
workspaceId,
name,
executionStrategy,
);
},
async listSessions() {
return client.listAgentRuntimeSessions();
},
async getSession(sessionId) {
return client.getAgentRuntimeSession(sessionId);
},
async getSessionReadModel(sessionId) {
return client.getAgentRuntimeThreadRead(sessionId);
},
async replayRequest(sessionId, requestId) {
return client.replayAgentRuntimeRequest({
session_id: sessionId,
request_id: requestId,
});
},
async renameSession(sessionId, title) {
await client.updateAgentRuntimeSession({
session_id: sessionId,
name: title,
});
},
async deleteSession(sessionId) {
await client.deleteAgentRuntimeSession(sessionId);
},
async setSessionExecutionStrategy(sessionId, executionStrategy) {
await client.updateAgentRuntimeSession({
session_id: sessionId,
execution_strategy: executionStrategy,
});
},
async setSessionAccessMode(sessionId, accessMode) {
await client.updateAgentRuntimeSession({
session_id: sessionId,
recent_access_mode: accessMode,
});
},
async setSessionProviderSelection(sessionId, providerType, model) {
await client.updateAgentRuntimeSession({
session_id: sessionId,
provider_name: providerType,
model_name: model,
});
},
async submitOp(op) {
switch (op.type) {
case "user_input":
await client.submitAgentRuntimeTurn(
createSubmitTurnRequestFromAgentOp(op),
);
return;
default:
throw new Error(`当前 runtime adapter 尚不支持 AgentOp: ${op.type}`);
}
},
async compactSession(sessionId, eventName) {
await client.compactAgentRuntimeSession({
session_id: sessionId,
event_name: eventName,
});
},
async interruptTurn(sessionId) {
return client.interruptAgentRuntimeTurn({
session_id: sessionId,
});
},
async resumeThread(sessionId) {
return client.resumeAgentRuntimeThread({
session_id: sessionId,
});
},
async promoteQueuedTurn(sessionId, queuedTurnId) {
return client.promoteAgentRuntimeQueuedTurn({
session_id: sessionId,
queued_turn_id: queuedTurnId,
});
},
async removeQueuedTurn(sessionId, queuedTurnId) {
return client.removeAgentRuntimeQueuedTurn({
session_id: sessionId,
queued_turn_id: queuedTurnId,
});
},
async respondToAction(request) {
await client.respondAgentRuntimeAction({
session_id: request.sessionId,
request_id: request.requestId,
action_type: request.actionType,
confirmed: request.confirmed,
response: request.response,
user_data: request.userData,
metadata: request.metadata,
...(request.eventName ? { event_name: request.eventName } : {}),
...(request.actionScope
? {
action_scope: {
session_id: request.actionScope.sessionId,
thread_id: request.actionScope.threadId,
turn_id: request.actionScope.turnId,
},
}
: {}),
});
},
async listenToTurnEvents(eventName, handler) {
return listenRuntimeEvent(eventName, handler);
},
async listenToTeamEvents(eventName, handler) {
return listenRuntimeEvent(eventName, handler);
},
};
}
export const defaultAgentRuntimeAdapter = createAgentRuntimeAdapter();
@@ -3,9 +3,26 @@ import type { AgentEvent } from "@/lib/api/agentProtocol";
import type { Message } from "../types";
import { handleTurnStreamEvent } from "./agentStreamRuntimeHandler";
const { mockToast } = vi.hoisted(() => ({
mockToast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock("sonner", () => ({
toast: mockToast,
}));
describe("agentStreamRuntimeHandler", () => {
afterEach(() => {
vi.useRealTimers();
mockToast.success.mockReset();
mockToast.error.mockReset();
mockToast.info.mockReset();
mockToast.warning.mockReset();
});
it("收到 final_done 时应把 usage 写回 assistant 消息", () => {
@@ -226,6 +243,140 @@ describe("agentStreamRuntimeHandler", () => {
);
});
it("站点导出在 tool_end 已登记结果时,空 final_done 不应误报缺少最终答复", () => {
let messages: Message[] = [
{
id: "assistant-site-export",
role: "assistant",
content: "",
timestamp: new Date("2026-04-07T10:00:00.000Z"),
isThinking: true,
toolCalls: [
{
id: "tool-site-export-1",
name: "site_run_adapter",
status: "running",
startTime: new Date("2026-04-07T10:00:00.000Z"),
},
],
},
];
const requestState = {
accumulatedContent: "",
hasMeaningfulCompletionSignal: false,
queuedTurnId: null,
requestLogId: null,
requestStartedAt: 0,
requestFinished: false,
};
const callbacks = {
activateStream: () => {},
isStreamActivated: () => true,
clearOptimisticItem: () => {},
clearOptimisticTurn: () => {},
disposeListener: () => {},
removeQueuedDraftMessages: () => {},
clearActiveStreamIfMatch: () => true,
upsertQueuedTurn: () => {},
removeQueuedTurnState: () => {},
playToolcallSound: () => {},
playTypewriterSound: () => {},
appendThinkingToParts: (parts: NonNullable<Message["contentParts"]>) =>
parts,
};
const setMessages = vi.fn(
(value: Message[] | ((prev: Message[]) => Message[])) => {
messages = typeof value === "function" ? value(messages) : value;
},
);
handleTurnStreamEvent({
data: {
type: "tool_end",
tool_id: "tool-site-export-1",
result: {
success: true,
output: "exports/x-article-export/article/index.md",
metadata: {
tool_family: "site",
saved_content: {
content_id: "content-site-export-1",
project_id: "project-site-export-1",
markdown_relative_path:
"exports/x-article-export/article/index.md",
},
},
},
} as AgentEvent,
requestState,
callbacks,
eventName: "agent-runtime-test",
pendingTurnKey: "pending-turn",
pendingItemKey: "pending-item",
assistantMsgId: "assistant-site-export",
activeSessionId: "session-1",
resolvedWorkspaceId: "workspace-1",
effectiveExecutionStrategy: "react",
content: "",
runtime: {} as never,
warnedKeysRef: { current: new Set<string>() },
actionLoggedKeys: new Set<string>(),
toolLogIdByToolId: new Map<string, string>(),
toolStartedAtByToolId: new Map<string, number>(),
toolNameByToolId: new Map<string, string>([
["tool-site-export-1", "site_run_adapter"],
]),
setMessages: setMessages as never,
setPendingActions: vi.fn() as never,
setThreadItems: vi.fn() as never,
setThreadTurns: vi.fn() as never,
setCurrentTurnId: vi.fn() as never,
setExecutionRuntime: vi.fn() as never,
setIsSending: vi.fn() as never,
});
expect(requestState.hasMeaningfulCompletionSignal).toBe(true);
handleTurnStreamEvent({
data: {
type: "final_done",
} as AgentEvent,
requestState,
callbacks,
eventName: "agent-runtime-test",
pendingTurnKey: "pending-turn",
pendingItemKey: "pending-item",
assistantMsgId: "assistant-site-export",
activeSessionId: "session-1",
resolvedWorkspaceId: "workspace-1",
effectiveExecutionStrategy: "react",
content: "",
runtime: {} as never,
warnedKeysRef: { current: new Set<string>() },
actionLoggedKeys: new Set<string>(),
toolLogIdByToolId: new Map<string, string>(),
toolStartedAtByToolId: new Map<string, number>(),
toolNameByToolId: new Map<string, string>(),
setMessages: setMessages as never,
setPendingActions: vi.fn() as never,
setThreadItems: vi.fn() as never,
setThreadTurns: vi.fn() as never,
setCurrentTurnId: vi.fn() as never,
setExecutionRuntime: vi.fn() as never,
setIsSending: vi.fn() as never,
});
expect(messages[0]?.content).toBe(
"本轮执行已完成,详细过程与产物已保留在当前对话中。",
);
expect(mockToast.error).not.toHaveBeenCalledWith(
"已完成工具执行,但模型未输出最终答复,请重试",
);
});
it("收到 queue_removed 时不应立刻清空当前 assistant 草稿", () => {
vi.useFakeTimers();
const disposeListener = vi.fn();
@@ -46,6 +46,15 @@ import {
containsAssistantProtocolResidue,
stripAssistantProtocolResidue,
} from "../utils/protocolResidue";
import { normalizeIncomingToolResult } from "./agentChatToolResult";
import {
hasMeaningfulSiteToolResultSignal,
} from "../utils/siteToolResultSummary";
import {
buildImageTaskPreviewFromToolResult,
buildTaskPreviewFromToolResult,
buildToolResultArtifactFromToolResult,
} from "../utils/taskPreviewFromToolResult";
type MessageParts = NonNullable<Message["contentParts"]>;
@@ -57,6 +66,7 @@ interface StreamObserver {
interface StreamRequestState {
accumulatedContent: string;
hasMeaningfulCompletionSignal?: boolean;
queuedTurnId: string | null;
requestLogId: string | null;
requestStartedAt: number;
@@ -146,6 +156,41 @@ function finishRequestLog(
});
}
function hasMeaningfulCompletionSignalFromToolResult(params: {
toolId: string;
toolName: string;
normalizedResult:
| {
metadata?: unknown;
}
| undefined;
}): boolean {
const resultRecord =
params.normalizedResult &&
typeof params.normalizedResult === "object" &&
!Array.isArray(params.normalizedResult)
? (params.normalizedResult as Record<string, unknown>)
: undefined;
if (hasMeaningfulSiteToolResultSignal(resultRecord?.metadata)) {
return true;
}
const previewParams = {
toolId: params.toolId,
toolName: params.toolName,
toolArguments: undefined,
toolResult: resultRecord,
fallbackPrompt: "",
};
return Boolean(
buildImageTaskPreviewFromToolResult(previewParams) ||
buildTaskPreviewFromToolResult(previewParams) ||
buildToolResultArtifactFromToolResult(previewParams),
);
}
export function handleTurnStreamEvent({
data,
requestState,
@@ -509,6 +554,19 @@ export function handleTurnStreamEvent({
case "tool_end":
activateStream();
clearOptimisticItem();
{
const normalizedResult = normalizeIncomingToolResult(data.result);
const toolName = toolNameByToolId.get(data.tool_id) || "";
if (
hasMeaningfulCompletionSignalFromToolResult({
toolId: data.tool_id,
toolName,
normalizedResult,
})
) {
requestState.hasMeaningfulCompletionSignal = true;
}
}
handleToolEndEvent({
data,
onWriteFile,
@@ -525,6 +583,7 @@ export function handleTurnStreamEvent({
case "artifact_snapshot":
activateStream();
clearOptimisticItem();
requestState.hasMeaningfulCompletionSignal = true;
handleArtifactSnapshotEvent({
data,
onWriteFile,
@@ -584,23 +643,27 @@ export function handleTurnStreamEvent({
(containsAssistantProtocolResidue(requestState.accumulatedContent) ||
!rawFinalContent);
const finalContent = resolveGracefulCompletionContent();
if (missingFinalReply) {
toast.error("已完成工具执行,但模型未输出最终答复,请重试");
}
const shouldWarnMissingFinalReply =
missingFinalReply && !requestState.hasMeaningfulCompletionSignal;
observer?.onComplete?.(finalContent);
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...updateMessageArtifactsStatus(msg, "complete"),
isThinking: false,
content: finalContent,
runtimeStatus: undefined,
usage: data.usage ?? msg.usage,
}
: msg,
),
prev.map((msg) => {
if (msg.id !== assistantMsgId) {
return msg;
}
return {
...updateMessageArtifactsStatus(msg, "complete"),
isThinking: false,
content: finalContent,
runtimeStatus: undefined,
usage: data.usage ?? msg.usage,
};
}),
);
if (shouldWarnMissingFinalReply) {
toast.error("已完成工具执行,但模型未输出最终答复,请重试");
}
clearActiveStreamIfMatch(eventName);
disposeListener();
break;
@@ -23,6 +23,7 @@ export interface ActiveStreamState {
export interface StreamRequestState {
accumulatedContent: string;
hasMeaningfulCompletionSignal?: boolean;
requestLogId: string | null;
requestStartedAt: number;
requestFinished: boolean;
@@ -77,6 +78,7 @@ export function createAgentStreamSubmissionLifecycle(
let streamActivated = false;
const requestState: StreamRequestState = {
accumulatedContent: "",
hasMeaningfulCompletionSignal: false,
requestLogId: null,
requestStartedAt: 0,
requestFinished: false,
@@ -9,10 +9,12 @@ const mockHasDevBridgeEventListenerCapability = vi.hoisted(() =>
vi.fn(() => false),
);
const mockHasTauriEventListenerCapability = vi.hoisted(() => vi.fn(() => true));
const mockSafeListen = vi.hoisted(() => vi.fn(async () => () => {}));
vi.mock("@/lib/dev-bridge", () => ({
hasDevBridgeEventListenerCapability: mockHasDevBridgeEventListenerCapability,
isDevBridgeAvailable: mockIsDevBridgeAvailable,
safeListen: mockSafeListen,
}));
vi.mock("@/lib/tauri-runtime", () => ({
@@ -112,6 +114,7 @@ describe("useAgentRuntimeSyncEffects", () => {
mockIsDevBridgeAvailable.mockReturnValue(false);
mockHasDevBridgeEventListenerCapability.mockReturnValue(false);
mockHasTauriEventListenerCapability.mockReturnValue(true);
mockSafeListen.mockResolvedValue(() => {});
});
afterEach(() => {
@@ -62,6 +62,23 @@ const {
}));
vi.mock("@/lib/api/agentRuntime", () => ({
createAgentRuntimeClient: () => ({
initAsterAgent: mockInitAsterAgent,
submitAgentRuntimeTurn: mockSubmitAgentRuntimeTurn,
createAgentRuntimeSession: mockCreateAgentRuntimeSession,
listAgentRuntimeSessions: mockListAgentRuntimeSessions,
getAgentRuntimeSession: mockGetAgentRuntimeSession,
getAgentRuntimeThreadRead: mockGetAgentRuntimeThreadRead,
updateAgentRuntimeSession: mockUpdateAgentRuntimeSession,
deleteAgentRuntimeSession: mockDeleteAgentRuntimeSession,
compactAgentRuntimeSession: mockCompactAgentRuntimeSession,
interruptAgentRuntimeTurn: mockInterruptAgentRuntimeTurn,
resumeAgentRuntimeThread: mockResumeAgentRuntimeThread,
replayAgentRuntimeRequest: mockReplayAgentRuntimeRequest,
promoteAgentRuntimeQueuedTurn: mockPromoteAgentRuntimeQueuedTurn,
removeAgentRuntimeQueuedTurn: mockRemoveAgentRuntimeQueuedTurn,
respondAgentRuntimeAction: mockRespondAgentRuntimeAction,
}),
initAsterAgent: mockInitAsterAgent,
submitAgentRuntimeTurn: mockSubmitAgentRuntimeTurn,
createAgentRuntimeSession: mockCreateAgentRuntimeSession,
@@ -2819,6 +2836,68 @@ describe("useAsterAgentChat runtime routing", () => {
}
});
it("final_done 无正文但已保存站点导出结果时不应误报缺少最终答复", async () => {
const workspaceId = "ws-empty-final-site-export";
seedSession(workspaceId, "session-empty-final-site-export");
const harness = mountHook(workspaceId);
const stream = captureTurnStream();
try {
await flushEffects();
await act(async () => {
await harness
.getValue()
.sendMessage("请导出这篇文章并保存到项目", [], false, false, false, "react");
});
act(() => {
stream.emit({
type: "tool_start",
tool_name: "site_run_adapter",
tool_id: "tool-site-export-1",
arguments: JSON.stringify({
adapter_name: "x/article-export",
}),
});
stream.emit({
type: "tool_end",
tool_id: "tool-site-export-1",
result: {
success: true,
output: "exports/x-article-export/article/index.md",
metadata: {
tool_family: "site",
saved_content: {
content_id: "content-site-export-1",
project_id: "project-site-export-1",
markdown_relative_path:
"exports/x-article-export/article/index.md",
images_relative_dir: "exports/x-article-export/article/images",
},
},
},
});
stream.emit({
type: "final_done",
});
});
const assistantMessage = [...harness.getValue().messages]
.reverse()
.find((msg) => msg.role === "assistant");
expect(assistantMessage?.content).toContain(
"本轮执行已完成,详细过程与产物已保留在当前对话中。",
);
expect(mockToast.error).not.toHaveBeenCalledWith(
"已完成工具执行,但模型未输出最终答复,请重试",
);
} finally {
harness.unmount();
}
});
it("final_done 无正文且刷新详情只返回用户消息时,也应保留本地 assistant 过程", async () => {
const workspaceId = "ws-empty-final-refresh-user-only";
const sessionId = "session-empty-final-refresh-user-only";
@@ -24,12 +24,14 @@ vi.mock("@/lib/dev-bridge", () => ({
}));
type HookProps = Parameters<typeof useTeamWorkspaceRuntime>[0];
type HookDeps = Parameters<typeof useTeamWorkspaceRuntime>[1];
let latestValue: ReturnType<typeof useTeamWorkspaceRuntime> | null = null;
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
function HookProbe(props: HookProps) {
latestValue = useTeamWorkspaceRuntime(props);
function HookProbe(props: HookProps & { deps?: HookDeps }) {
const { deps, ...hookProps } = props;
latestValue = useTeamWorkspaceRuntime(hookProps, deps);
return null;
}
@@ -40,7 +42,7 @@ async function flushHookEffects() {
});
}
async function renderHookProbe(props?: Partial<HookProps>) {
async function renderHookProbe(props?: Partial<HookProps>, deps?: HookDeps) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
@@ -64,7 +66,14 @@ async function renderHookProbe(props?: Partial<HookProps>) {
const render = async (nextProps?: Partial<HookProps>) => {
await act(async () => {
root.render(<HookProbe {...defaultProps} {...props} {...nextProps} />);
root.render(
<HookProbe
{...defaultProps}
{...props}
{...nextProps}
deps={deps}
/>,
);
await Promise.resolve();
});
await flushHookEffects();
@@ -170,6 +179,63 @@ describe("useTeamWorkspaceRuntime", () => {
);
});
it("注入自定义 eventSource 时,不应再直接依赖默认 safeListen", async () => {
const statusListeners = new Map<string, (event: { payload: unknown }) => void>();
const streamListeners = new Map<string, (event: { payload: unknown }) => void>();
const eventSource = {
listenSubagentStatus: vi.fn(
async (
sessionId: string,
handler: (event: { payload: unknown }) => void,
) => {
statusListeners.set(sessionId, handler);
return () => {
statusListeners.delete(sessionId);
};
},
),
listenSubagentStream: vi.fn(
async (
sessionId: string,
handler: (event: { payload: unknown }) => void,
) => {
streamListeners.set(sessionId, handler);
return () => {
streamListeners.delete(sessionId);
};
},
),
};
await renderHookProbe(undefined, { eventSource });
expect(mockSafeListen).not.toHaveBeenCalled();
expect(eventSource.listenSubagentStatus).toHaveBeenCalledWith(
"parent-1",
expect.any(Function),
);
expect(eventSource.listenSubagentStream).toHaveBeenCalledWith(
"child-1",
expect.any(Function),
);
await act(async () => {
statusListeners.get("parent-1")?.({
payload: {
type: "subagent_status_changed",
session_id: "child-1",
root_session_id: "parent-1",
status: "running",
},
});
await Promise.resolve();
});
expect(latestValue?.liveRuntimeBySessionId["child-1"]?.runtimeStatus).toBe(
"running",
);
});
it("收到子代理 runtime stream 事件后,应投影最近过程,并在关键完成事件后递增刷新版本", async () => {
const listeners = new Map<string, (event: { payload: unknown }) => void>();
mockSafeListen.mockImplementation(
@@ -203,11 +269,11 @@ describe("useTeamWorkspaceRuntime", () => {
});
expect(latestValue?.liveActivityBySessionId["child-1"]?.[0]?.title).toBe(
"处理中 · browser_snapshot",
"处理中 · 页面截图",
);
expect(
latestValue?.liveActivityBySessionId["child-1"]?.[0]?.detail,
).toContain("正在处理 browser_snapshot");
).toContain("正在处理 页面截图");
expect(latestValue?.activityRefreshVersionBySessionId["child-1"] ?? 0).toBe(
0,
);
@@ -227,7 +293,7 @@ describe("useTeamWorkspaceRuntime", () => {
});
expect(latestValue?.liveActivityBySessionId["child-1"]?.[0]?.title).toBe(
"处理中 · browser_snapshot",
"处理中 · 页面截图",
);
expect(
latestValue?.liveActivityBySessionId["child-1"]?.[0]?.detail,
File diff suppressed because it is too large Load Diff
+288 -47
View File
@@ -9,6 +9,7 @@ import {
it,
vi,
} from "vitest";
import * as fileBrowserModule from "@/lib/api/fileBrowser";
import {
resolveBrowserAssistSessionScopeKey,
resolveBrowserAssistSessionStorageKey,
@@ -123,14 +124,26 @@ const {
renderPreview: false,
},
mockCanvasWorkbenchLayout: vi.fn((props?: Record<string, unknown>) => {
const defaultPreview =
props?.defaultPreview && typeof props.defaultPreview === "object"
? (props.defaultPreview as {
title?: string;
content?: string;
filePath?: string;
absolutePath?: string;
})
: null;
const preview =
mockCanvasWorkbenchLayoutState.renderPreview &&
typeof props?.renderPreview === "function"
? props.renderPreview(
{
kind: "default-canvas",
title: "当前画布草稿",
content: "# 新文档\n\n在这里开始编写内容...",
title: defaultPreview?.title || "当前画布草稿",
content:
defaultPreview?.content || "# 新文档\n\n在这里开始编写内容...",
filePath: defaultPreview?.filePath,
absolutePath: defaultPreview?.absolutePath,
},
{
stackedWorkbenchTrigger: (
@@ -526,8 +539,26 @@ vi.mock("@/components/workspace/canvas/CanvasFactory", () => ({
}));
vi.mock("@/components/general-chat/bridge", () => ({
CanvasPanel: ({ toolbarActions }: { toolbarActions?: ReactNode }) => (
<div data-testid="general-canvas">
CanvasPanel: ({
toolbarActions,
state,
baseFilePath,
}: {
toolbarActions?: ReactNode;
state?: {
filename?: string;
contentType?: string;
content?: string;
};
baseFilePath?: string;
}) => (
<div
data-testid="general-canvas"
data-filename={state?.filename || ""}
data-content-type={state?.contentType || ""}
data-base-file-path={baseFilePath || ""}
data-content={state?.content || ""}
>
<div data-testid="general-canvas-toolbar">{toolbarActions}</div>
</div>
),
@@ -1677,7 +1708,7 @@ describe("AgentChatPage 侧栏显示控制", () => {
});
});
describe("AgentChatPage 通用工作台", () => {
describe("AgentChatPage 通用工作台", { timeout: 20_000 }, () => {
it("空白新建任务首页不应自动打开 fallback 新文档画布", async () => {
const container = renderPage({
agentEntry: "new-task",
@@ -2155,6 +2186,15 @@ describe("AgentChatPage 通用工作台", () => {
mounted.rerender();
await flushEffects(8);
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat");
clickButton(mounted.container, "toggle-canvas");
await flushEffects(8);
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
@@ -2193,7 +2233,7 @@ describe("AgentChatPage 通用工作台", () => {
).toBe("chat");
});
it("同一会话首次出现真实 Team 成员时,应自动切到 Team 画布", async () => {
it("同一会话首次出现真实 Team 成员时,仍应保持聊天态,等待用户手动打开画布", async () => {
const runtimeState = {
childSubagentSessions: [] as Array<{
id: string;
@@ -2290,46 +2330,7 @@ describe("AgentChatPage 通用工作台", () => {
mounted.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat-canvas");
expect(
(
mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as
| {
teamView?: {
preferFixedPanel?: boolean;
preferFullscreenPreview?: boolean;
renderPanel?: () => unknown;
} | null;
}
| undefined
)?.teamView?.preferFixedPanel,
).toBe(true);
expect(
(
mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as
| {
teamView?: {
preferFixedPanel?: boolean;
preferFullscreenPreview?: boolean;
renderPanel?: () => unknown;
} | null;
}
| undefined
)?.teamView?.preferFullscreenPreview,
).not.toBe(true);
expect(
typeof (
mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as
| {
teamView?: {
preferFixedPanel?: boolean;
preferFullscreenPreview?: boolean;
renderPanel?: () => unknown;
} | null;
}
| undefined
)?.teamView?.renderPanel,
).toBe("function");
).toBe("chat");
});
it("仅有已选 Team 偏好时,顶部展开仍可手动打开画布,且不会凭空展示协作 dock", async () => {
@@ -2509,6 +2510,246 @@ describe("AgentChatPage 通用工作台", () => {
expect(document.body.textContent).toContain("research");
});
it("历史旧导出结果与同会话新到的 saved_content 都不应自动打开画布,应等待用户手动点开", async () => {
mockCanvasWorkbenchLayoutState.renderPreview = true;
vi.spyOn(fileBrowserModule, "readFilePreview").mockResolvedValue({
path: "/tmp/project-site-export/exports/x-article-export/latest/index.md",
content: "# 最新导出\n\n![封面](images/cover.png)",
isBinary: false,
size: 42,
error: null,
});
let messages = [
{
id: "msg-site-user-1",
role: "user" as const,
content: "帮我导出这篇 X 长文",
timestamp: new Date("2026-04-08T10:00:00.000Z"),
},
{
id: "msg-site-assistant-old",
role: "assistant" as const,
content: "历史导出已完成",
timestamp: new Date("2026-04-08T10:00:01.000Z"),
toolCalls: [
{
id: "tool-site-old",
name: "site_run_adapter",
status: "completed" as const,
startTime: new Date("2026-04-08T10:00:01.100Z"),
endTime: new Date("2026-04-08T10:00:02.000Z"),
result: {
success: true,
output: "ok",
metadata: {
tool_family: "site",
saved_content: {
content_id: "content-site-export",
project_id: "project-site-export",
markdown_relative_path:
"exports/x-article-export/history/index.md",
},
},
},
},
],
},
];
mockUseAgentChatUnified.mockImplementation(
({ workspaceId }: { workspaceId: string }) => {
observedWorkspaceIds.push(workspaceId);
return createMockAgentChatUnifiedState({
messages,
sessionId: "session-site-export",
});
},
);
const mounted = mountPage({
projectId: "project-site-export",
contentId: "content-site-export",
theme: "general",
lockTheme: true,
});
await flushEffects(10);
expect(fileBrowserModule.readFilePreview).not.toHaveBeenCalled();
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat");
messages = [
...messages,
{
id: "msg-site-assistant-new",
role: "assistant" as const,
content: "最新导出已完成",
timestamp: new Date("2099-04-09T12:00:01.000Z"),
toolCalls: [
{
id: "tool-site-new",
name: "site_run_adapter",
status: "completed" as const,
startTime: new Date("2099-04-09T12:00:01.100Z"),
endTime: new Date("2099-04-09T12:00:02.000Z"),
result: {
success: true,
output: "ok",
metadata: {
tool_family: "site",
saved_content: {
content_id: "content-site-export",
project_id: "project-site-export",
markdown_relative_path:
"exports/x-article-export/latest/index.md",
},
},
},
},
],
},
];
mounted.rerender({});
await flushEffects(12);
expect(fileBrowserModule.readFilePreview).not.toHaveBeenCalled();
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat");
});
it("历史任务携带 initialProjectFileOpenTarget 时应直接恢复真实 Markdown 文件预览", async () => {
mockCanvasWorkbenchLayoutState.renderPreview = true;
vi.spyOn(fileBrowserModule, "readFilePreview").mockResolvedValue({
path:
"/tmp/project-history-export/exports/x-article-export/history/index.md",
content: "# 历史导出\n\n![插图](images/history-cover.png)",
isBinary: false,
size: 52,
error: null,
});
const container = renderPage({
projectId: "project-history-export",
contentId: "content-history-export",
theme: "general",
lockTheme: true,
initialProjectFileOpenTarget: {
relativePath: "exports/x-article-export/history/index.md",
requestKey: 20260409,
},
});
await flushEffects(12);
expect(fileBrowserModule.readFilePreview).toHaveBeenCalledTimes(1);
expect(fileBrowserModule.readFilePreview).toHaveBeenCalledWith(
"/tmp/project-history-export/exports/x-article-export/history/index.md",
64 * 1024,
);
expect(
container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat-canvas");
const generalCanvas = container.querySelector(
'[data-testid="general-canvas"]',
) as HTMLDivElement | null;
expect(generalCanvas).not.toBeNull();
expect(generalCanvas?.dataset.filename).toBe(
"exports/x-article-export/history/index.md",
);
expect(generalCanvas?.dataset.baseFilePath).toBe(
"/tmp/project-history-export/exports/x-article-export/history/index.md",
);
expect(generalCanvas?.dataset.contentType).toBe("markdown");
expect(generalCanvas?.dataset.content || "").toContain(
"![插图](images/history-cover.png)",
);
expect(
container.querySelector('[data-testid="artifact-renderer"]'),
).toBeNull();
});
it("同项目内打开 saved site content 时应直接恢复真实 Markdown 文件预览", async () => {
mockCanvasWorkbenchLayoutState.renderPreview = true;
vi.spyOn(fileBrowserModule, "readFilePreview").mockResolvedValue({
path:
"/tmp/project-inline-export/exports/x-article-export/latest/index.md",
content: "# 当前导出\n\n![封面](images/cover.png)",
isBinary: false,
size: 43,
error: null,
});
const onNavigate = vi.fn();
const container = renderPage({
projectId: "project-inline-export",
theme: "general",
lockTheme: true,
onNavigate,
});
await flushEffects(10);
const latestMessageListProps = mockMessageList.mock.calls.at(-1)?.[0] as
| {
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
preferredTarget: "project_file" | "content";
projectFile?: {
relativePath?: string | null;
} | null;
}) => void | Promise<void>;
}
| undefined;
await act(async () => {
await latestMessageListProps?.onOpenSavedSiteContent?.({
projectId: "project-inline-export",
contentId: "content-inline-export",
preferredTarget: "project_file",
projectFile: {
relativePath: "exports/x-article-export/latest/index.md",
},
});
});
await flushEffects(12);
expect(onNavigate).not.toHaveBeenCalled();
expect(fileBrowserModule.readFilePreview).toHaveBeenCalledWith(
"/tmp/project-inline-export/exports/x-article-export/latest/index.md",
64 * 1024,
);
expect(
container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat-canvas");
const generalCanvas = container.querySelector(
'[data-testid="general-canvas"]',
) as HTMLDivElement | null;
expect(generalCanvas).not.toBeNull();
expect(generalCanvas?.dataset.filename).toBe(
"exports/x-article-export/latest/index.md",
);
expect(generalCanvas?.dataset.baseFilePath).toBe(
"/tmp/project-inline-export/exports/x-article-export/latest/index.md",
);
expect(generalCanvas?.dataset.contentType).toBe("markdown");
expect(generalCanvas?.dataset.content || "").toContain(
"![封面](images/cover.png)",
);
});
it("浏览器工具返回真实会话后不应再自动打开浏览器协助画布", async () => {
mockBrowserAssistCompletedSession();
@@ -3357,7 +3598,7 @@ describe("AgentChatPage 通用工作台", () => {
});
});
describe("AgentChatPage 自动引导", () => {
describe("AgentChatPage 自动引导", { timeout: 20_000 }, () => {
it("general 空文稿应预填通用引导词且不自动发送", async () => {
renderPage({
projectId: "project-social",
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ServiceSkillItem } from "@/lib/api/serviceSkills";
import {
buildServiceSkillClawLaunchContext,
buildServiceSkillClawLaunchRequestMetadata,
buildServiceSkillNaturalLaunchMessage,
resolveServiceSkillSiteCapabilityExecution,
} from "./siteCapabilityBinding";
@@ -144,6 +146,77 @@ describe("site capability binding natural launch message", () => {
);
});
it("导出型站点技能带目标语言时应同时注入 translation_skill_launch metadata", () => {
const skill = createBrowserSkill({
id: "x-article-export",
title: "X 文章转存",
summary: "导出 X 长文并按目标语言翻译正文。",
slotSchema: [
{
key: "article_url",
label: "X 文章链接",
type: "url",
required: true,
placeholder: "https://x.com/<账号>/article/<文章ID>",
},
{
key: "target_language",
label: "目标语言",
type: "text",
required: false,
defaultValue: "中文",
placeholder: "例如 中文、英文、日文",
},
],
siteCapabilityBinding: {
autoRun: true,
requireAttachedSession: true,
saveMode: "project_resource",
siteLabel: "X",
adapterMatch: {
urlArgName: "url",
requiredCapabilities: ["article_export", "markdown_bundle"],
hostAliases: ["twitter.com", "www.twitter.com"],
},
slotArgMap: {
article_url: "url",
target_language: "target_language",
},
},
});
const context = buildServiceSkillClawLaunchContext(
skill,
{
article_url: "https://x.com/GoogleCloudTech/article/2033953579824758855",
target_language: "中文",
},
{
adapterName: "x/article-export",
projectId: "project-1",
contentId: "content-1",
},
);
expect(buildServiceSkillClawLaunchRequestMetadata(context)).toMatchObject({
harness: {
allow_model_skills: true,
translation_skill_launch: {
skill_name: "translation",
kind: "translation_request",
translation_request: {
target_language: "中文",
project_id: "project-1",
content_id: "content-1",
entry_source: "service_skill_site_export_followup",
},
},
service_skill_launch: {
adapter_name: "x/article-export",
},
},
});
});
it("应按 URL 域名、能力标签和 host alias 解析动态站点适配器", async () => {
mockSiteListAdapters.mockResolvedValueOnce([
{
@@ -44,6 +44,7 @@ export interface ServiceSkillClawLaunchContext {
skillId: string;
skillTitle: string;
adapterName: string;
isExportStyle?: boolean;
args: Record<string, unknown>;
saveMode: "current_content" | "project_resource";
saveTitle?: string;
@@ -110,6 +111,17 @@ const SITE_LABELS: Record<string, string> = {
};
const EXPORT_CAPABILITIES = new Set(["article_export", "markdown_bundle"]);
const SITE_EXPORT_TRANSLATION_FOLLOWUP_ENTRY_SOURCE =
"service_skill_site_export_followup";
interface ServiceSkillFollowupTranslationRequest {
prompt: string;
raw_text: string;
target_language: string;
project_id?: string;
content_id?: string;
entry_source: string;
}
function normalizeNaturalText(value: unknown): string | null {
if (typeof value !== "string") {
@@ -172,8 +184,19 @@ function isExportStyleSiteSkill(
): boolean {
const capabilities =
skill.siteCapabilityBinding?.adapterMatch?.requiredCapabilities ?? [];
return capabilities.some((capability) =>
EXPORT_CAPABILITIES.has(normalizeCapability(capability)),
if (
capabilities.some((capability) =>
EXPORT_CAPABILITIES.has(normalizeCapability(capability)),
)
) {
return true;
}
const adapterName = normalizeAdapterName(
skill.siteCapabilityBinding?.adapterName ?? "",
);
return (
adapterName === "article-export" || adapterName.endsWith("/article-export")
);
}
@@ -644,6 +667,7 @@ export function buildServiceSkillClawLaunchContext(
skillId: skill.id,
skillTitle: skill.title,
adapterName,
isExportStyle: isExportStyleSiteSkill(skill),
args: buildServiceSkillSiteCapabilityArgs(skill, slotValues),
saveMode: binding.saveMode ?? "project_resource",
saveTitle: buildServiceSkillSiteCapabilitySaveTitle(skill, slotValues, {
@@ -655,9 +679,35 @@ export function buildServiceSkillClawLaunchContext(
};
}
function buildServiceSkillFollowupTranslationRequest(
context: ServiceSkillClawLaunchContext,
): ServiceSkillFollowupTranslationRequest | null {
if (!context.isExportStyle) {
return null;
}
const targetLanguage = readStringArg(context.args, "target_language");
if (!targetLanguage) {
return null;
}
return {
prompt: `请读取本轮刚导出的 Markdown 文件,并将正文翻译成${quoteNaturalValue(
targetLanguage,
)},保留 Markdown 结构、代码块原文、图片链接和相对路径,再回写到原文件。`,
raw_text: `${context.skillTitle}:导出后将 Markdown 正文翻译成${targetLanguage}并回写原文件`,
target_language: targetLanguage,
...(context.projectId ? { project_id: context.projectId } : {}),
...(context.contentId ? { content_id: context.contentId } : {}),
entry_source: SITE_EXPORT_TRANSLATION_FOLLOWUP_ENTRY_SOURCE,
};
}
export function buildServiceSkillClawLaunchRequestMetadata(
context: ServiceSkillClawLaunchContext,
): Record<string, unknown> {
const followupTranslationRequest =
buildServiceSkillFollowupTranslationRequest(context);
const readyLaunchReadiness = isSiteLaunchReadinessReady(
context.launchReadiness,
)
@@ -673,6 +723,16 @@ export function buildServiceSkillClawLaunchRequestMetadata(
harness: {
browser_requirement: "required",
browser_requirement_reason: browserRequirementReason,
...(followupTranslationRequest
? {
allow_model_skills: true,
translation_skill_launch: {
skill_name: "translation",
kind: "translation_request",
translation_request: followupTranslationRequest,
},
}
: {}),
...(attachedProfileKey
? {
browser_assist: {
@@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import {
buildPreviewableRailSessionsSyncKey,
buildSelectedSessionActivityState,
collectStaleSessionActivityTargets,
type SessionActivityPreviewState,
} from "./activityPreviewSelectors";
describe("activityPreviewSelectors", () => {
it("选中会话的 activity state 应优先合并实时进展并生成预览文案", () => {
const state = buildSelectedSessionActivityState({
selectedSession: {
id: "child-1",
sessionType: "sub_agent",
runtimeStatus: "running",
latestTurnStatus: "running",
},
selectedBaseSession: {
id: "child-1",
sessionType: "sub_agent",
runtimeStatus: "queued",
latestTurnStatus: "queued",
},
liveActivityBySessionId: {
"child-1": [
{
id: "live-1",
title: "工具 页面截图",
detail: "页面结构差异已提取完成。",
statusLabel: "完成",
badgeClassName:
"border border-emerald-200 bg-emerald-50 text-emerald-700",
},
],
},
previewBySessionId: {
"child-1": {
preview: "回复:旧内容",
entries: [
{
id: "stored-1",
title: "回复",
detail: "历史里的旧内容。",
statusLabel: "消息",
badgeClassName:
"border border-slate-200 bg-slate-50 text-slate-600",
},
],
status: "ready",
fingerprint: "child-1:1:queued:queued:0",
refreshVersion: 0,
},
},
activityRefreshVersionBySessionId: {
"child-1": 2,
},
activityTimelineEntryLimit: 4,
});
expect(state.supportsPreview).toBe(true);
expect(state.activityId).toBe("child-1");
expect(state.previewText).toBe("工具 页面截图:页面结构差异已提取完成。");
expect(state.entries[0]?.title).toBe("工具 页面截图");
expect(state.refreshVersion).toBe(2);
expect(state.shouldPoll).toBe(true);
expect(state.fingerprint).toBe("child-1:0:queued:queued:0");
});
it("preview sync key 应稳定编码 fingerprint 与 refreshVersion", () => {
const syncKey = buildPreviewableRailSessionsSyncKey({
sessions: [
{
id: "child-1",
sessionType: "sub_agent",
runtimeStatus: "running",
latestTurnStatus: "running",
},
{
id: "child-2",
sessionType: "sub_agent",
runtimeStatus: "queued",
latestTurnStatus: "queued",
},
],
activityRefreshVersionBySessionId: {
"child-1": 1,
"child-2": 3,
},
});
expect(syncKey).toBe(
"child-1:child-1:0:running:running:0:1|child-2:child-2:0:queued:queued:0:3",
);
});
it("stale target 只应返回 fingerprint 或 refreshVersion 已过期的会话", () => {
const previewBySessionId: Record<string, SessionActivityPreviewState> = {
"child-1": {
preview: "最新",
entries: [],
status: "ready",
fingerprint: "child-1:0:running:running:0",
refreshVersion: 1,
},
"child-2": {
preview: "旧的",
entries: [],
status: "ready",
fingerprint: "child-2:0:queued:queued:0",
refreshVersion: 0,
},
};
const staleTargets = collectStaleSessionActivityTargets({
sessions: [
{
id: "child-1",
sessionType: "sub_agent",
runtimeStatus: "running",
latestTurnStatus: "running",
},
{
id: "child-2",
sessionType: "sub_agent",
runtimeStatus: "queued",
latestTurnStatus: "queued",
},
{
id: "child-3",
sessionType: "sub_agent",
runtimeStatus: "completed",
latestTurnStatus: "completed",
},
],
previewBySessionId,
activityRefreshVersionBySessionId: {
"child-1": 1,
"child-2": 2,
"child-3": 0,
},
});
expect(staleTargets).toEqual([
{
sessionId: "child-2",
fingerprint: "child-2:0:queued:queued:0",
refreshVersion: 2,
},
{
sessionId: "child-3",
fingerprint: "child-3:0:completed:completed:0",
refreshVersion: 0,
},
]);
});
});
@@ -0,0 +1,326 @@
import type { AgentThreadItem } from "@/lib/api/agentProtocol";
import type { AsterSessionDetail } from "@/lib/api/agentRuntime";
import {
buildTeamWorkspaceActivityEntryFromThreadItem,
buildTeamWorkspaceSessionFingerprint,
mergeSessionActivityEntries,
type TeamWorkspaceActivityEntry,
type TeamWorkspaceRuntimeStatus,
} from "../teamWorkspaceRuntime";
export interface SessionActivityPreviewState {
preview: string | null;
entries: TeamWorkspaceActivityEntry[];
status: "loading" | "ready" | "error";
errorMessage?: string;
fingerprint?: string;
refreshVersion?: number;
syncedAt?: number;
}
export interface ActivityPreviewSession {
id: string;
sessionType?: string;
runtimeStatus?: TeamWorkspaceRuntimeStatus;
latestTurnStatus?: TeamWorkspaceRuntimeStatus;
}
export interface SelectedSessionActivityState {
previewState: SessionActivityPreviewState | null;
entries: TeamWorkspaceActivityEntry[];
previewText: string | null;
supportsPreview: boolean;
activityId: string | null;
fingerprint: string | null;
refreshVersion: number;
shouldPoll: boolean;
}
export interface StaleSessionActivityTarget {
sessionId: string;
fingerprint: string;
refreshVersion: number;
}
const ACTIVITY_PREVIEW_MAX_LENGTH = 360;
const ACTIVITY_TIMELINE_DETAIL_MAX_LENGTH = 220;
function normalizeActivityPreviewText(
value?: string | null,
maxLength = ACTIVITY_PREVIEW_MAX_LENGTH,
): string | null {
const normalized = value
?.replace(/\r\n/g, "\n")
.split("\0")
.join("")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (!normalized) {
return null;
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength).trimEnd()}...`;
}
function buildActivityPreviewLine(
label: string,
value?: string | null,
): string | null {
const normalized = normalizeActivityPreviewText(value);
if (!normalized) {
return null;
}
return `${label}:${normalized}`;
}
function resolveActivityEntryStatusMeta(
item: AgentThreadItem | { type: "message_fallback" },
status?: AgentThreadItem["status"],
) {
if (item.type === "error") {
return {
label: "错误",
badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700",
};
}
if (item.type === "warning") {
return {
label: "警告",
badgeClassName: "border border-amber-200 bg-amber-50 text-amber-700",
};
}
switch (status) {
case "in_progress":
return {
label: "进行中",
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
};
case "failed":
return {
label: "失败",
badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700",
};
case "completed":
return {
label: "完成",
badgeClassName:
"border border-emerald-200 bg-emerald-50 text-emerald-700",
};
default:
return {
label: "消息",
badgeClassName: "border border-slate-200 bg-slate-50 text-slate-600",
};
}
}
export function buildActivityPreviewFromEntry(
entry?: TeamWorkspaceActivityEntry | null,
) {
if (!entry) {
return null;
}
return buildActivityPreviewLine(entry.title, entry.detail);
}
function extractMessageActivityEntries(
detail: AsterSessionDetail,
): TeamWorkspaceActivityEntry[] {
const reversedMessages = [...detail.messages].sort(
(left, right) => right.timestamp - left.timestamp,
);
for (const message of reversedMessages) {
if (message.role !== "assistant") {
continue;
}
for (const content of message.content) {
const title =
content.type === "tool_response"
? content.error
? "错误"
: content.output
? "输出"
: "回复"
: "回复";
const previewSource =
content.type === "tool_response"
? content.error || content.output
: content.type === "text" || content.type === "thinking"
? content.text
: undefined;
const detailText = normalizeActivityPreviewText(
previewSource,
ACTIVITY_TIMELINE_DETAIL_MAX_LENGTH,
);
if (detailText) {
const statusMeta = resolveActivityEntryStatusMeta(
{ type: "message_fallback" },
undefined,
);
return [
{
id: `message-${message.id ?? message.timestamp}`,
title,
detail: detailText,
statusLabel: statusMeta.label,
badgeClassName: statusMeta.badgeClassName,
},
];
}
}
}
return [];
}
export function extractSessionActivitySnapshot(
detail: AsterSessionDetail,
activityTimelineEntryLimit: number,
): {
preview: string | null;
entries: TeamWorkspaceActivityEntry[];
} {
const orderedItems = [...(detail.items ?? [])].sort(
(left, right) => right.sequence - left.sequence,
);
const entries: TeamWorkspaceActivityEntry[] = [];
for (const item of orderedItems) {
const entry = buildTeamWorkspaceActivityEntryFromThreadItem(item);
if (entry) {
entries.push(entry);
}
if (entries.length >= activityTimelineEntryLimit) {
break;
}
}
if (entries.length > 0) {
return {
preview: buildActivityPreviewFromEntry(entries[0]),
entries,
};
}
const messageEntries = extractMessageActivityEntries(detail);
return {
preview: buildActivityPreviewFromEntry(messageEntries[0]),
entries: messageEntries,
};
}
export function shouldPollSessionActivity(
session?: ActivityPreviewSession | null,
) {
const runtimeStatus = session?.runtimeStatus;
const latestTurnStatus = session?.latestTurnStatus;
return (
runtimeStatus === "running" ||
runtimeStatus === "queued" ||
latestTurnStatus === "running" ||
latestTurnStatus === "queued"
);
}
export function buildSessionActivityFingerprint(
session?: ActivityPreviewSession | null,
) {
return buildTeamWorkspaceSessionFingerprint(session);
}
export function buildSelectedSessionActivityState(params: {
selectedSession?: ActivityPreviewSession | null;
selectedBaseSession?: ActivityPreviewSession | null;
liveActivityBySessionId?: Record<string, TeamWorkspaceActivityEntry[]>;
previewBySessionId?: Record<string, SessionActivityPreviewState>;
activityRefreshVersionBySessionId?: Record<string, number>;
activityTimelineEntryLimit: number;
}): SelectedSessionActivityState {
const selectedSession = params.selectedSession ?? null;
const previewState = selectedSession
? (params.previewBySessionId?.[selectedSession.id] ?? null)
: null;
const entries = selectedSession
? mergeSessionActivityEntries(
params.liveActivityBySessionId?.[selectedSession.id],
previewState?.entries,
params.activityTimelineEntryLimit,
)
: [];
const previewText =
buildActivityPreviewFromEntry(entries[0]) ?? previewState?.preview ?? null;
const supportsPreview = Boolean(
selectedSession && selectedSession.sessionType !== "user",
);
const activityId = supportsPreview ? (selectedSession?.id ?? null) : null;
const fingerprint = supportsPreview
? buildSessionActivityFingerprint(params.selectedBaseSession)
: null;
const refreshVersion = activityId
? (params.activityRefreshVersionBySessionId?.[activityId] ?? 0)
: 0;
return {
previewState,
entries,
previewText,
supportsPreview,
activityId,
fingerprint,
refreshVersion,
shouldPoll: supportsPreview && shouldPollSessionActivity(selectedSession),
};
}
export function buildPreviewableRailSessionsSyncKey(params: {
sessions: ActivityPreviewSession[];
activityRefreshVersionBySessionId?: Record<string, number>;
}) {
return params.sessions
.map((session) => {
const fingerprint = buildSessionActivityFingerprint(session);
const refreshVersion =
params.activityRefreshVersionBySessionId?.[session.id] ?? 0;
return `${session.id}:${fingerprint}:${refreshVersion}`;
})
.join("|");
}
export function collectStaleSessionActivityTargets(params: {
sessions: ActivityPreviewSession[];
previewBySessionId?: Record<string, SessionActivityPreviewState>;
activityRefreshVersionBySessionId?: Record<string, number>;
}): StaleSessionActivityTarget[] {
return params.sessions
.map((session) => {
const fingerprint = buildSessionActivityFingerprint(session);
const cachedPreview = params.previewBySessionId?.[session.id];
const refreshVersion =
params.activityRefreshVersionBySessionId?.[session.id] ?? 0;
if (
cachedPreview?.fingerprint === fingerprint &&
(cachedPreview?.refreshVersion ?? 0) >= refreshVersion
) {
return null;
}
return {
sessionId: session.id,
fingerprint,
refreshVersion,
};
})
.filter(Boolean) as StaleSessionActivityTarget[];
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { buildTeamWorkspaceBoardChromeDisplayState } from "./boardChromeSelectors";
describe("boardChromeSelectors", () => {
it("真实成员图应产出紧凑 chrome 标题与工具条 chips", () => {
const state = buildTeamWorkspaceBoardChromeDisplayState({
hasRealTeamGraph: true,
hasRuntimeFormation: false,
isChildSession: false,
totalTeamSessions: 2,
siblingCount: 0,
selectedSession: {
name: "研究员",
runtimeStatus: "running",
updatedAt: 0,
isCurrent: true,
},
zoom: 1,
canWaitAnyActiveTeamSession: true,
waitableCount: 2,
canCloseCompletedTeamSessions: true,
completedCount: 1,
statusSummary: {
running: 1,
queued: 1,
},
});
expect(state.boardHeadline).toBe("2 位成员协作中");
expect(state.boardHint).toBe(
"这里只展示谁在帮你处理什么、处理到哪一步,以及接下来会给你什么结果。",
);
expect(state.compactBoardHeadline).toBe("2 位成员协作中");
expect(state.compactToolbarChips).toEqual([
{ key: "focus", text: "焦点 研究员", tone: "summary" },
{ key: "status", text: "处理中", tone: "status", status: "running" },
{ key: "updated-at", text: "更新于 刚刚", tone: "muted" },
{ key: "current", text: "当前对话", tone: "muted" },
{ key: "zoom", text: "缩放 100%", tone: "muted" },
{ key: "waitable", text: "2 位处理中", tone: "muted" },
{ key: "completed", text: "1 位已完成", tone: "muted" },
]);
expect(state.statusSummaryBadges).toEqual([
{ key: "running", text: "处理中 1", status: "running" },
{ key: "queued", text: "稍后开始 1", status: "queued" },
]);
});
it("forming 阶段应优先显示 runtime formation 的标题与提示", () => {
const state = buildTeamWorkspaceBoardChromeDisplayState({
hasRealTeamGraph: false,
hasRuntimeFormation: true,
runtimeFormationTitle: "协作分工已准备好",
runtimeFormationHint: "当前任务的协作分工已经准备好,成员加入后会继续接手处理。",
isChildSession: false,
totalTeamSessions: 0,
siblingCount: 0,
selectedSession: null,
zoom: 0.88,
canWaitAnyActiveTeamSession: false,
waitableCount: 0,
canCloseCompletedTeamSessions: false,
completedCount: 0,
statusSummary: {},
});
expect(state.boardHeadline).toBe("协作分工已准备好");
expect(state.boardHint).toBe(
"当前任务的协作分工已经准备好,成员加入后会继续接手处理。",
);
expect(state.compactBoardHeadline).toBe("协作分工已准备好");
expect(state.compactToolbarChips).toEqual([
{ key: "focus", text: "等待成员接入", tone: "summary" },
{ key: "zoom", text: "缩放 88%", tone: "muted" },
]);
});
it("子线程应优先显示父会话标题与 sibling 提示", () => {
const state = buildTeamWorkspaceBoardChromeDisplayState({
hasRealTeamGraph: true,
hasRuntimeFormation: false,
isChildSession: true,
parentSessionName: "主线程总览",
totalTeamSessions: 1,
siblingCount: 2,
selectedSession: null,
zoom: 1,
canWaitAnyActiveTeamSession: false,
waitableCount: 0,
canCloseCompletedTeamSessions: false,
completedCount: 0,
statusSummary: {
completed: 1,
},
});
expect(state.boardHeadline).toBe("主线程总览");
expect(state.boardHint).toBe("当前正与 2 位协作成员并行推进");
expect(state.compactBoardHeadline).toBe("主线程总览");
expect(state.statusSummaryBadges).toEqual([
{ key: "completed", text: "已完成 1", status: "completed" },
]);
});
});
@@ -0,0 +1,205 @@
import { formatRelativeTime } from "@/lib/api/project";
import type { TeamWorkspaceRuntimeStatus } from "../teamWorkspaceRuntime";
import { resolveTeamWorkspaceDisplayRuntimeStatusLabel } from "../utils/teamWorkspaceCopy";
export interface TeamWorkspaceBoardChromeSession {
name: string;
runtimeStatus?: TeamWorkspaceRuntimeStatus;
updatedAt?: number;
isCurrent?: boolean;
}
export interface TeamWorkspaceBoardChromeChip {
key: string;
text: string;
tone: "summary" | "muted" | "status";
status?: TeamWorkspaceRuntimeStatus;
}
export interface TeamWorkspaceBoardStatusSummaryBadge {
key: string;
text: string;
status?: TeamWorkspaceRuntimeStatus;
}
export interface TeamWorkspaceBoardChromeDisplayState {
boardHeadline: string;
boardHint: string;
compactBoardHeadline: string;
compactToolbarChips: TeamWorkspaceBoardChromeChip[];
statusSummaryBadges: TeamWorkspaceBoardStatusSummaryBadge[];
}
function formatUpdatedAt(updatedAt?: number) {
if (!updatedAt) {
return "刚刚";
}
return formatRelativeTime(updatedAt * 1000);
}
function buildBoardHeadline(params: {
hasRealTeamGraph: boolean;
isChildSession: boolean;
parentSessionName?: string | null;
totalTeamSessions: number;
}) {
const {
hasRealTeamGraph,
isChildSession,
parentSessionName,
totalTeamSessions,
} = params;
if (isChildSession) {
return parentSessionName?.trim() || "主助手协作区";
}
if (!hasRealTeamGraph) {
return "需要时会自动加入协作成员";
}
return totalTeamSessions > 0
? `${totalTeamSessions} 位成员协作中`
: "创作协作";
}
function buildBoardHint(params: {
hasRealTeamGraph: boolean;
isChildSession: boolean;
siblingCount: number;
}) {
const { hasRealTeamGraph, isChildSession, siblingCount } = params;
if (isChildSession) {
return siblingCount > 0
? `当前正与 ${siblingCount} 位协作成员并行推进`
: "当前由你和这位协作成员一起推进";
}
if (!hasRealTeamGraph) {
return "系统会在需要时自动邀请协作成员加入,不需要你理解内部分工方式。";
}
return "这里只展示谁在帮你处理什么、处理到哪一步,以及接下来会给你什么结果。";
}
export function buildTeamWorkspaceBoardChromeDisplayState(params: {
hasRealTeamGraph: boolean;
hasRuntimeFormation: boolean;
runtimeFormationTitle?: string | null;
runtimeFormationHint?: string | null;
isChildSession: boolean;
parentSessionName?: string | null;
totalTeamSessions: number;
siblingCount: number;
selectedSession?: TeamWorkspaceBoardChromeSession | null;
zoom: number;
canWaitAnyActiveTeamSession: boolean;
waitableCount: number;
canCloseCompletedTeamSessions: boolean;
completedCount: number;
statusSummary: Record<string, number>;
}): TeamWorkspaceBoardChromeDisplayState {
const boardHeadline =
!params.hasRealTeamGraph && params.runtimeFormationTitle
? params.runtimeFormationTitle
: buildBoardHeadline({
hasRealTeamGraph: params.hasRealTeamGraph,
isChildSession: params.isChildSession,
parentSessionName: params.parentSessionName,
totalTeamSessions: params.totalTeamSessions,
});
const boardHint =
!params.hasRealTeamGraph &&
params.hasRuntimeFormation &&
params.runtimeFormationHint
? params.runtimeFormationHint
: buildBoardHint({
hasRealTeamGraph: params.hasRealTeamGraph,
isChildSession: params.isChildSession,
siblingCount: params.siblingCount,
});
const compactBoardHeadline =
params.hasRealTeamGraph &&
!params.isChildSession &&
params.totalTeamSessions > 0
? `${params.totalTeamSessions} 位成员协作中`
: boardHeadline;
const compactToolbarChips: TeamWorkspaceBoardChromeChip[] = [
{
key: "focus",
text: params.selectedSession
? `焦点 ${params.selectedSession.name}`
: "等待成员接入",
tone: "summary",
},
...(params.selectedSession?.runtimeStatus
? [
{
key: "status",
text: resolveTeamWorkspaceDisplayRuntimeStatusLabel(
params.selectedSession.runtimeStatus,
),
tone: "status" as const,
status: params.selectedSession.runtimeStatus,
},
]
: []),
...(params.selectedSession
? [
{
key: "updated-at",
text: `更新于 ${formatUpdatedAt(params.selectedSession.updatedAt)}`,
tone: "muted" as const,
},
]
: []),
...(params.selectedSession?.isCurrent
? [
{
key: "current",
text: "当前对话",
tone: "muted" as const,
},
]
: []),
{
key: "zoom",
text: `缩放 ${Math.round(params.zoom * 100)}%`,
tone: "muted",
},
...(params.canWaitAnyActiveTeamSession
? [
{
key: "waitable",
text: `${params.waitableCount} 位处理中`,
tone: "muted" as const,
},
]
: []),
...(params.canCloseCompletedTeamSessions
? [
{
key: "completed",
text: `${params.completedCount} 位已完成`,
tone: "muted" as const,
},
]
: []),
];
return {
boardHeadline,
boardHint,
compactBoardHeadline,
compactToolbarChips,
statusSummaryBadges: Object.entries(params.statusSummary)
.filter(([, count]) => count > 0)
.map(([status, count]) => {
const normalizedStatus =
status === "idle" ? undefined : (status as TeamWorkspaceRuntimeStatus);
return {
key: status,
text: `${resolveTeamWorkspaceDisplayRuntimeStatusLabel(normalizedStatus)} ${count}`,
status: normalizedStatus,
};
}),
};
}
@@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import { buildTeamWorkspaceCanvasLanes } from "./canvasLaneSelectors";
describe("canvasLaneSelectors", () => {
it("真实成员图应把 session lane 投影为稳定的展示模型", () => {
const lanes = buildTeamWorkspaceCanvasLanes({
hasRealTeamGraph: true,
sessions: [
{
id: "child-1",
name: "执行代理",
runtimeStatus: "running",
taskSummary: "补齐 runtime selector 收口。",
roleHint: "executor",
blueprintRoleLabel: "执行",
profileId: "code-executor",
profileName: "代码执行员",
roleKey: "executor",
teamPresetId: "code-triage-team",
model: "gpt-5.4",
skills: [
{
id: "repo-exploration",
name: "仓库探索",
},
{
id: "verification-report",
name: "验证汇报",
},
],
latestTurnStatus: "running",
queuedTurnCount: 2,
teamParallelBudget: 3,
teamActiveCount: 1,
providerConcurrencyGroup: "openai",
providerParallelBudget: 1,
},
],
runtimeMembers: [
{
id: "executor",
label: "执行",
summary: "负责落地改动。",
profileId: "code-executor",
roleKey: "executor",
skillIds: [],
status: "running",
},
],
plannedRoles: [
{
id: "executor",
label: "执行",
summary: "负责落地改动。",
profileId: "code-executor",
roleKey: "executor",
},
],
liveActivityBySessionId: {
"child-1": [
{
id: "live-1",
title: "工具 页面截图",
detail: "页面结构差异已提取完成。",
statusLabel: "完成",
badgeClassName:
"border border-emerald-200 bg-emerald-50 text-emerald-700",
},
],
},
previewBySessionId: {
"child-1": {
preview: "回复:旧内容",
entries: [
{
id: "stored-1",
title: "回复",
detail: "历史里的旧内容。",
statusLabel: "消息",
badgeClassName:
"border border-slate-200 bg-slate-50 text-slate-600",
},
],
status: "ready",
},
},
activityTimelineEntryLimit: 4,
});
expect(lanes).toHaveLength(1);
expect(lanes[0]).toMatchObject({
id: "child-1",
kind: "session",
persistKey: "session:child-1",
fallbackPersistKeys: ["runtime:executor", "planned:executor"],
title: "执行代理",
summary: "补齐 runtime selector 收口。",
badgeLabel: "处理中",
roleLabel: "执行",
profileLabel: "代码执行员",
presetLabel: "代码排障团队",
modelLabel: "gpt-5.4",
statusHint: "等待中 2 · 最近进展 处理中 · 处理中 1/3 · 稳妥模式",
updatedAtLabel: "刚刚",
skillLabels: ["仓库探索", "验证汇报"],
previewText: "工具 页面截图:页面结构差异已提取完成。",
});
expect(lanes[0]?.previewEntries?.map((entry) => entry.id)).toEqual([
"live-1",
"stored-1",
]);
});
it("未形成真实成员图但已有 runtime member 时,应投影为 runtime lane", () => {
const lanes = buildTeamWorkspaceCanvasLanes({
hasRealTeamGraph: false,
sessions: [],
runtimeMembers: [
{
id: "writer",
label: "写作",
summary: "负责整理路线图结论。",
profileId: "doc-writer",
roleKey: "writer",
skillIds: [],
status: "running",
},
],
plannedRoles: [
{
id: "writer",
label: "写作",
summary: "把研究结果整理成方案。",
profileId: "doc-writer",
roleKey: "writer",
},
],
activityTimelineEntryLimit: 4,
});
expect(lanes).toEqual([
expect.objectContaining({
id: "writer",
kind: "runtime",
persistKey: "runtime:writer",
fallbackPersistKeys: ["planned:writer"],
title: "写作",
badgeLabel: "正在处理",
roleLabel: "撰写",
statusHint: "这位协作成员正在处理",
updatedAtLabel: "等待成员加入",
previewText: "负责整理路线图结论。",
}),
]);
});
it("没有 runtime member 时,应退回 planned lane", () => {
const lanes = buildTeamWorkspaceCanvasLanes({
hasRealTeamGraph: false,
sessions: [],
runtimeMembers: [],
plannedRoles: [
{
id: "reviewer",
label: "复核",
summary: "补测试并确认风险。",
roleKey: "reviewer",
},
],
activityTimelineEntryLimit: 4,
});
expect(lanes).toEqual([
expect.objectContaining({
id: "reviewer",
kind: "planned",
persistKey: "planned:reviewer",
fallbackPersistKeys: [],
title: "复核",
badgeLabel: "待开始",
roleLabel: "复核",
statusHint: "等待系统邀请协作成员加入",
updatedAtLabel: "计划分工",
previewText: "补测试并确认风险。",
}),
]);
});
});
@@ -0,0 +1,570 @@
import type { AsterSubagentSkillInfo } from "@/lib/api/agentRuntime";
import { formatRelativeTime } from "@/lib/api/project";
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
import { getTeamPresetOption } from "../utils/teamPresets";
import {
buildTeamWorkspaceSkillDisplayName,
resolveTeamWorkspaceDisplayRuntimeStatusLabel,
resolveTeamWorkspaceRoleHintLabel,
resolveTeamWorkspaceStableProcessingLabel,
} from "../utils/teamWorkspaceCopy";
import {
mergeSessionActivityEntries,
resolveRuntimeMemberStatusMeta,
type TeamWorkspaceActivityEntry,
type TeamWorkspaceRuntimeMember,
type TeamWorkspaceRuntimeStatus,
} from "../teamWorkspaceRuntime";
import {
buildActivityPreviewFromEntry,
type SessionActivityPreviewState,
} from "./activityPreviewSelectors";
const SESSION_LANE_PREVIEW_ENTRY_LIMIT = 3;
const STATUS_META: Record<
NonNullable<TeamWorkspaceRuntimeStatus> | "idle",
{
label: string;
badgeClassName: string;
dotClassName: string;
}
> = {
idle: {
label: resolveTeamWorkspaceDisplayRuntimeStatusLabel(undefined),
badgeClassName: "border border-slate-200 bg-white text-slate-600",
dotClassName: "bg-slate-300",
},
queued: {
label: resolveTeamWorkspaceDisplayRuntimeStatusLabel("queued"),
badgeClassName: "border border-amber-200 bg-amber-50 text-amber-700",
dotClassName: "bg-amber-400",
},
running: {
label: resolveTeamWorkspaceDisplayRuntimeStatusLabel("running"),
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
dotClassName: "bg-sky-500",
},
completed: {
label: resolveTeamWorkspaceDisplayRuntimeStatusLabel("completed"),
badgeClassName: "border border-emerald-200 bg-emerald-50 text-emerald-700",
dotClassName: "bg-emerald-500",
},
failed: {
label: resolveTeamWorkspaceDisplayRuntimeStatusLabel("failed"),
badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700",
dotClassName: "bg-rose-500",
},
aborted: {
label: resolveTeamWorkspaceDisplayRuntimeStatusLabel("aborted"),
badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700",
dotClassName: "bg-rose-500",
},
closed: {
label: resolveTeamWorkspaceDisplayRuntimeStatusLabel("closed"),
badgeClassName: "border border-slate-200 bg-slate-100 text-slate-600",
dotClassName: "bg-slate-400",
},
};
export interface TeamWorkspaceCanvasLaneSession {
id: string;
name: string;
isCurrent?: boolean;
runtimeStatus?: TeamWorkspaceRuntimeStatus;
taskSummary?: string;
roleHint?: string;
sessionType?: string;
updatedAt?: number;
model?: string;
blueprintRoleId?: string;
blueprintRoleLabel?: string;
profileId?: string;
profileName?: string;
roleKey?: string;
teamPresetId?: string;
skills?: AsterSubagentSkillInfo[];
latestTurnStatus?: TeamWorkspaceRuntimeStatus;
queuedTurnCount?: number;
teamParallelBudget?: number;
teamActiveCount?: number;
teamQueuedCount?: number;
providerConcurrencyGroup?: string;
providerParallelBudget?: number;
}
export interface TeamWorkspaceRuntimeDetailSession {
latestTurnStatus?: TeamWorkspaceRuntimeStatus;
queuedTurnCount?: number;
teamQueuedCount?: number;
teamActiveCount?: number;
teamParallelBudget?: number;
providerConcurrencyGroup?: string;
providerParallelBudget?: number;
}
export type TeamWorkspaceCanvasLaneKind = "session" | "runtime" | "planned";
export interface TeamWorkspaceCanvasLane {
id: string;
persistKey: string;
fallbackPersistKeys: string[];
kind: TeamWorkspaceCanvasLaneKind;
title: string;
summary: string;
badgeLabel: string;
badgeClassName: string;
dotClassName: string;
roleLabel?: string;
profileLabel?: string;
presetLabel?: string;
modelLabel?: string;
statusHint?: string | null;
updatedAtLabel?: string | null;
skillLabels: string[];
session?: TeamWorkspaceCanvasLaneSession;
previewText?: string | null;
previewEntries?: TeamWorkspaceActivityEntry[];
}
function resolveStatusMeta(status?: TeamWorkspaceRuntimeStatus) {
return STATUS_META[status ?? "idle"];
}
function normalizeComparableText(value?: string | null): string {
return value?.trim().toLocaleLowerCase() || "";
}
function formatUpdatedAt(updatedAt?: number) {
if (!updatedAt) {
return "刚刚";
}
return formatRelativeTime(updatedAt * 1000);
}
function buildSkillDisplayName(skill: AsterSubagentSkillInfo): string {
return buildTeamWorkspaceSkillDisplayName(skill);
}
export function buildRuntimeDetailSummary(
session?: TeamWorkspaceRuntimeDetailSession | null,
): string | null {
if (!session) {
return null;
}
const parts: string[] = [];
const waitingCount = session.teamQueuedCount ?? session.queuedTurnCount ?? 0;
if (waitingCount > 0) {
parts.push(`等待中 ${waitingCount}`);
}
if (session.latestTurnStatus) {
parts.push(`最近进展 ${resolveStatusMeta(session.latestTurnStatus).label}`);
}
if (
session.teamActiveCount !== undefined &&
session.teamParallelBudget !== undefined
) {
parts.push(
`处理中 ${session.teamActiveCount}/${session.teamParallelBudget}`,
);
}
if (
session.providerParallelBudget === 1 &&
session.providerConcurrencyGroup
) {
parts.push(resolveTeamWorkspaceStableProcessingLabel());
}
return parts.length > 0 ? parts.join(" · ") : null;
}
function buildSessionLaneEmptyState(params: {
session?: TeamWorkspaceCanvasLaneSession | null;
previewState?: SessionActivityPreviewState | null;
}) {
const { session, previewState } = params;
if (previewState?.status === "error") {
return previewState.errorMessage?.trim() || "同步最新内容失败";
}
if (previewState?.status === "loading") {
return "正在同步这位协作成员的最新内容...";
}
if (session?.runtimeStatus === "queued") {
return "这位协作成员已经收到任务,马上开始处理。";
}
if (session?.runtimeStatus === "running") {
return "这位协作成员正在处理,最新进展会持续刷新到这里。";
}
if (session?.runtimeStatus === "completed") {
return "这部分已经完成,结果会继续汇入当前内容。";
}
if (
session?.runtimeStatus === "failed" ||
session?.runtimeStatus === "aborted"
) {
return "这一步没有顺利完成,你可以在下方查看细节并决定是否继续。";
}
return "这位协作成员暂时还没有产出可展示的内容。";
}
function buildCanvasLaneTitleSummary(
member: Pick<TeamWorkspaceRuntimeMember, "status" | "summary" | "sessionId">,
) {
const memberMeta = resolveRuntimeMemberStatusMeta(member.status);
const statusHint =
member.status === "spawning"
? "正在接入协作成员"
: member.status === "running"
? "这位协作成员正在处理"
: member.status === "waiting"
? "等待继续补充说明"
: member.status === "completed"
? "这一步已经完成"
: member.status === "failed"
? "这一步需要重试"
: member.sessionId
? "已连接到真实成员"
: "等待协作成员加入";
return {
badgeLabel: memberMeta.label,
badgeClassName: memberMeta.badgeClassName,
dotClassName:
member.status === "failed"
? "bg-rose-500"
: member.status === "completed"
? "bg-emerald-500"
: member.status === "waiting"
? "bg-amber-400"
: "bg-sky-500",
summary: member.summary,
statusHint,
};
}
function buildPlannedRoleLaneSummary(role: TeamRoleDefinition) {
return {
badgeLabel: "待开始",
badgeClassName: "border border-slate-200 bg-slate-50 text-slate-600",
dotClassName: "bg-slate-300",
summary: role.summary,
statusHint: "等待系统邀请协作成员加入",
};
}
function resolveLaneMatchingRuntimeMemberId(
session: TeamWorkspaceCanvasLaneSession,
runtimeMembers: TeamWorkspaceRuntimeMember[],
): string | null {
if (runtimeMembers.length === 0) {
return null;
}
const explicitRoleId = session.blueprintRoleId?.trim();
if (
explicitRoleId &&
runtimeMembers.some((member) => member.id === explicitRoleId)
) {
return explicitRoleId;
}
const normalizedRoleLabel = normalizeComparableText(
session.blueprintRoleLabel || session.name,
);
const normalizedRoleKey = normalizeComparableText(
session.roleKey || session.roleHint,
);
const normalizedProfileId = normalizeComparableText(session.profileId);
const candidates = runtimeMembers
.map((member) => {
let score = 0;
if (
normalizedRoleLabel &&
normalizeComparableText(member.label) === normalizedRoleLabel
) {
score += 8;
}
if (
normalizedRoleKey &&
normalizeComparableText(member.roleKey) === normalizedRoleKey
) {
score += 5;
}
if (
normalizedProfileId &&
normalizeComparableText(member.profileId) === normalizedProfileId
) {
score += 4;
}
return {
memberId: member.id,
score,
};
})
.filter((candidate) => candidate.score > 0)
.sort((left, right) => right.score - left.score);
if (candidates.length === 0) {
return null;
}
if (candidates.length > 1 && candidates[0]?.score === candidates[1]?.score) {
return null;
}
return candidates[0]?.memberId ?? null;
}
function resolveLaneMatchingPlannedRoleId(
session: TeamWorkspaceCanvasLaneSession,
plannedRoles: TeamRoleDefinition[],
): string | null {
if (plannedRoles.length === 0) {
return null;
}
const normalizedRoleLabel = normalizeComparableText(
session.blueprintRoleLabel || session.name,
);
const normalizedRoleKey = normalizeComparableText(
session.roleKey || session.roleHint,
);
const normalizedProfileId = normalizeComparableText(session.profileId);
const candidates = plannedRoles
.map((role) => {
let score = 0;
if (
normalizedRoleLabel &&
normalizeComparableText(role.label) === normalizedRoleLabel
) {
score += 8;
}
if (
normalizedRoleKey &&
normalizeComparableText(role.roleKey) === normalizedRoleKey
) {
score += 5;
}
if (
normalizedProfileId &&
normalizeComparableText(role.profileId) === normalizedProfileId
) {
score += 4;
}
return {
roleId: role.id,
score,
};
})
.filter((candidate) => candidate.score > 0)
.sort((left, right) => right.score - left.score);
if (candidates.length === 0) {
return null;
}
if (candidates.length > 1 && candidates[0]?.score === candidates[1]?.score) {
return null;
}
return candidates[0]?.roleId ?? null;
}
function resolveRuntimeMemberMatchingPlannedRoleId(
member: TeamWorkspaceRuntimeMember,
plannedRoles: TeamRoleDefinition[],
): string | null {
if (plannedRoles.length === 0) {
return null;
}
const normalizedRoleLabel = normalizeComparableText(member.label);
const normalizedRoleKey = normalizeComparableText(member.roleKey);
const normalizedProfileId = normalizeComparableText(member.profileId);
const candidates = plannedRoles
.map((role) => {
let score = 0;
if (
normalizedRoleLabel &&
normalizeComparableText(role.label) === normalizedRoleLabel
) {
score += 8;
}
if (
normalizedRoleKey &&
normalizeComparableText(role.roleKey) === normalizedRoleKey
) {
score += 5;
}
if (
normalizedProfileId &&
normalizeComparableText(role.profileId) === normalizedProfileId
) {
score += 4;
}
return {
roleId: role.id,
score,
};
})
.filter((candidate) => candidate.score > 0)
.sort((left, right) => right.score - left.score);
if (candidates.length === 0) {
return null;
}
if (candidates.length > 1 && candidates[0]?.score === candidates[1]?.score) {
return null;
}
return candidates[0]?.roleId ?? null;
}
function buildSessionCanvasLane(params: {
session: TeamWorkspaceCanvasLaneSession;
runtimeMembers: TeamWorkspaceRuntimeMember[];
plannedRoles: TeamRoleDefinition[];
liveActivityBySessionId?: Record<string, TeamWorkspaceActivityEntry[]>;
previewBySessionId?: Record<string, SessionActivityPreviewState>;
activityTimelineEntryLimit: number;
}): TeamWorkspaceCanvasLane {
const { session, runtimeMembers, plannedRoles } = params;
const previewState = params.previewBySessionId?.[session.id] ?? null;
const mergedEntries = mergeSessionActivityEntries(
params.liveActivityBySessionId?.[session.id],
previewState?.entries,
params.activityTimelineEntryLimit,
);
const cardActivityPreview =
buildActivityPreviewFromEntry(mergedEntries[0]) ?? previewState?.preview;
const meta = resolveStatusMeta(session.runtimeStatus);
const matchedRuntimeMemberId = resolveLaneMatchingRuntimeMemberId(
session,
runtimeMembers,
);
const matchedPlannedRoleId = resolveLaneMatchingPlannedRoleId(
session,
plannedRoles,
);
const presetLabel = session.teamPresetId
? getTeamPresetOption(session.teamPresetId)?.label ?? session.teamPresetId
: undefined;
return {
id: session.id,
persistKey: `session:${session.id}`,
fallbackPersistKeys: [
matchedRuntimeMemberId ? `runtime:${matchedRuntimeMemberId}` : null,
matchedPlannedRoleId ? `planned:${matchedPlannedRoleId}` : null,
].filter(Boolean) as string[],
kind: "session",
title: session.name,
summary:
session.taskSummary ||
"暂时还没有任务摘要,打开详情后可查看完整上下文。",
badgeLabel: meta.label,
badgeClassName: meta.badgeClassName,
dotClassName: meta.dotClassName,
roleLabel:
session.blueprintRoleLabel ||
resolveTeamWorkspaceRoleHintLabel(session.roleHint) ||
undefined,
profileLabel: session.profileName || undefined,
presetLabel,
modelLabel: session.model || undefined,
statusHint: buildRuntimeDetailSummary(session),
updatedAtLabel: formatUpdatedAt(session.updatedAt),
skillLabels: (session.skills ?? [])
.slice(0, 4)
.map((skill) => buildSkillDisplayName(skill)),
session,
previewText:
cardActivityPreview ??
buildSessionLaneEmptyState({ session, previewState }),
previewEntries: mergedEntries.slice(0, SESSION_LANE_PREVIEW_ENTRY_LIMIT),
};
}
export function buildTeamWorkspaceCanvasLanes(params: {
hasRealTeamGraph: boolean;
sessions: TeamWorkspaceCanvasLaneSession[];
runtimeMembers: TeamWorkspaceRuntimeMember[];
plannedRoles: TeamRoleDefinition[];
liveActivityBySessionId?: Record<string, TeamWorkspaceActivityEntry[]>;
previewBySessionId?: Record<string, SessionActivityPreviewState>;
activityTimelineEntryLimit: number;
}): TeamWorkspaceCanvasLane[] {
if (params.hasRealTeamGraph) {
return params.sessions.map((session) =>
buildSessionCanvasLane({
session,
runtimeMembers: params.runtimeMembers,
plannedRoles: params.plannedRoles,
liveActivityBySessionId: params.liveActivityBySessionId,
previewBySessionId: params.previewBySessionId,
activityTimelineEntryLimit: params.activityTimelineEntryLimit,
}),
);
}
if (params.runtimeMembers.length > 0) {
return params.runtimeMembers.map((member) => {
const laneSummary = buildCanvasLaneTitleSummary(member);
const matchedPlannedRoleId = resolveRuntimeMemberMatchingPlannedRoleId(
member,
params.plannedRoles,
);
return {
id: member.id,
persistKey: `runtime:${member.id}`,
fallbackPersistKeys: matchedPlannedRoleId
? [`planned:${matchedPlannedRoleId}`]
: [],
kind: "runtime",
title: member.label,
summary: laneSummary.summary,
badgeLabel: laneSummary.badgeLabel,
badgeClassName: laneSummary.badgeClassName,
dotClassName: laneSummary.dotClassName,
roleLabel:
resolveTeamWorkspaceRoleHintLabel(member.roleKey) || undefined,
profileLabel: undefined,
statusHint: laneSummary.statusHint,
updatedAtLabel: "等待成员加入",
skillLabels: [],
previewText: member.summary,
previewEntries: [],
};
});
}
return params.plannedRoles.map((role) => {
const laneSummary = buildPlannedRoleLaneSummary(role);
return {
id: role.id,
persistKey: `planned:${role.id}`,
fallbackPersistKeys: [],
kind: "planned",
title: role.label,
summary: laneSummary.summary,
badgeLabel: laneSummary.badgeLabel,
badgeClassName: laneSummary.badgeClassName,
dotClassName: laneSummary.dotClassName,
roleLabel: resolveTeamWorkspaceRoleHintLabel(role.roleKey) || undefined,
profileLabel: undefined,
statusHint: laneSummary.statusHint,
updatedAtLabel: "计划分工",
skillLabels: [],
previewText: role.summary,
previewEntries: [],
};
});
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
buildRuntimeFormationDisplayState,
buildSelectedTeamPlanDisplayState,
} from "./formationDisplaySelectors";
describe("formationDisplaySelectors", () => {
it("已选 Team 但无 runtime formation 时,应产出计划分工展示模型", () => {
const state = buildSelectedTeamPlanDisplayState({
selectedTeamLabel: "代码排障团队",
selectedTeamSummary: "分析、执行、验证三段式推进。",
selectedTeamRoles: [
{
id: "explorer",
label: "分析",
summary: "负责收敛问题边界。",
},
],
});
expect(state.hasSelectedTeamPlan).toBe(true);
expect(state.summaryBadges.map((badge) => badge.text)).toEqual([
"协作方案 · 代码排障团队",
"1 个计划分工",
]);
expect(state.roleCards).toEqual([
{
id: "explorer",
label: "分析",
summary: "负责收敛问题边界。",
},
]);
});
it("runtime formation 已就绪时,应产出状态、成员与参考分工", () => {
const state = buildRuntimeFormationDisplayState({
teamDispatchPreviewState: {
requestId: "runtime-1",
status: "formed",
label: "修复 Team",
summary: "分析、执行、验证协作闭环。",
members: [
{
id: "member-1",
label: "分析",
summary: "收敛问题边界。",
skillIds: [],
status: "planned",
},
],
blueprint: {
label: "代码排障团队",
summary: "分析、执行、验证三段式推进。",
roles: [
{
id: "explorer",
label: "分析",
summary: "先定位问题与影响面。",
},
],
},
updatedAt: Date.now(),
},
});
expect(state.hasRuntimeFormation).toBe(true);
expect(state.hint).toContain("协作分工已经准备好");
expect(state.summaryBadges.map((badge) => badge.text)).toEqual([
"协作方案 · 修复 Team",
"已就绪",
"1 位当前成员",
"参考方案 · 代码排障团队",
]);
expect(state.panelHeadline).toBe("协作分工已准备好");
expect(state.memberCards[0]).toMatchObject({
label: "分析",
badgeLabel: "待分配",
});
expect(state.blueprintRoleCards[0]).toMatchObject({
label: "分析",
summary: "先定位问题与影响面。",
});
expect(state.noticeText).toContain("当前协作方案已就绪");
});
it("runtime formation 失败时,应优先使用失败原因", () => {
const state = buildRuntimeFormationDisplayState({
teamDispatchPreviewState: {
requestId: "runtime-2",
status: "failed",
label: "失败的 Team",
summary: null,
members: [],
blueprint: null,
errorMessage: "Provider 认证失败,无法生成 Team。",
updatedAt: Date.now(),
},
});
expect(state.panelDescription).toBe("Provider 认证失败,无法生成 Team。");
expect(state.emptyDetail).toBe("Provider 认证失败,无法生成 Team。");
expect(state.noticeText).toBe("Provider 认证失败,无法生成 Team。");
});
});
@@ -0,0 +1,235 @@
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
import { TEAM_WORKSPACE_PLAN_LABEL } from "../utils/teamWorkspaceCopy";
import {
resolveRuntimeFormationStatusMeta,
resolveRuntimeMemberStatusMeta,
type TeamWorkspaceRuntimeFormationState,
} from "../teamWorkspaceRuntime";
export interface TeamWorkspaceFormationBadge {
key: string;
text: string;
className: string;
}
export interface TeamWorkspaceFormationRoleCard {
id: string;
label: string;
summary: string;
}
export interface TeamWorkspaceFormationMemberCard {
id: string;
label: string;
summary: string;
badgeLabel: string;
badgeClassName: string;
}
export interface TeamWorkspaceSelectedTeamPlanDisplayState {
hasSelectedTeamPlan: boolean;
summaryBadges: TeamWorkspaceFormationBadge[];
label: string | null;
summary: string | null;
roleCards: TeamWorkspaceFormationRoleCard[];
}
export interface TeamWorkspaceRuntimeFormationDisplayState {
hasRuntimeFormation: boolean;
hint: string;
emptyDetail: string;
noticeText: string;
summaryBadges: TeamWorkspaceFormationBadge[];
panelTitle: string;
panelStatusLabel: string | null;
panelStatusBadgeClassName: string | null;
panelLabel: string | null;
panelHeadline: string;
panelDescription: string;
referenceLabel: string | null;
memberCards: TeamWorkspaceFormationMemberCard[];
blueprintRoleCards: TeamWorkspaceFormationRoleCard[];
}
export function buildRuntimeFormationHint(
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null,
) {
switch (teamDispatchPreviewState?.status) {
case "forming":
return "系统正在准备当前任务的协作分工,成员接入后会自动开始处理。";
case "formed":
return "当前任务的协作分工已经准备好,成员加入后会继续接手处理。";
case "failed":
return "当前任务的协作准备失败,但你仍然可以继续在当前对话里推进。";
default:
return "需要时这里会自动展开成协作面板。";
}
}
export function buildRuntimeFormationEmptyDetail(
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null,
) {
switch (teamDispatchPreviewState?.status) {
case "forming":
return "系统正在根据当前任务准备协作分工。完成后,这里会先展示当前成员卡片,再接入真实处理进展。";
case "formed":
return "当前协作方案已经准备好。画布会先展示当前分工,等成员真正开始处理后,再自动切换为实时进展。";
case "failed":
return (
teamDispatchPreviewState.errorMessage?.trim() ||
"当前协作准备失败,暂时无法展示当前成员。"
);
default:
return "当前还没有协作成员加入。系统开始分工后,详情区会切换为成员摘要视图。";
}
}
export function buildSelectedTeamPlanDisplayState(params: {
selectedTeamLabel?: string | null;
selectedTeamSummary?: string | null;
selectedTeamRoles?: TeamRoleDefinition[] | null;
}): TeamWorkspaceSelectedTeamPlanDisplayState {
const label = params.selectedTeamLabel?.trim() || null;
const summary = params.selectedTeamSummary?.trim() || null;
const roleCards = (params.selectedTeamRoles ?? [])
.filter((role) => role.label.trim())
.map((role) => ({
id: role.id,
label: role.label,
summary: role.summary,
}));
return {
hasSelectedTeamPlan:
Boolean(label) || Boolean(summary) || roleCards.length > 0,
summaryBadges: [
...(label
? [
{
key: "plan-label",
text: `${TEAM_WORKSPACE_PLAN_LABEL} · ${label}`,
className:
"rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-sky-700",
},
]
: []),
...(roleCards.length > 0
? [
{
key: "plan-role-count",
text: `${roleCards.length} 个计划分工`,
className:
"rounded-full border border-slate-200 bg-white px-2.5 py-1",
},
]
: []),
],
label,
summary,
roleCards,
};
}
export function buildRuntimeFormationDisplayState(params: {
teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null;
fallbackLabel?: string | null;
fallbackSummary?: string | null;
}): TeamWorkspaceRuntimeFormationDisplayState {
const state = params.teamDispatchPreviewState ?? null;
const meta = state ? resolveRuntimeFormationStatusMeta(state.status) : null;
const label =
state?.label?.trim() ||
state?.blueprint?.label?.trim() ||
params.fallbackLabel?.trim() ||
null;
const summary =
state?.summary?.trim() ||
state?.blueprint?.summary?.trim() ||
params.fallbackSummary?.trim() ||
null;
const memberCards = (state?.members ?? []).map((member) => {
const memberMeta = resolveRuntimeMemberStatusMeta(member.status);
return {
id: member.id,
label: member.label,
summary: member.summary,
badgeLabel: memberMeta.label,
badgeClassName: memberMeta.badgeClassName,
};
});
const blueprintRoleCards = (state?.blueprint?.roles ?? []).map((role) => ({
id: role.id,
label: role.label,
summary: role.summary,
}));
const noticeText =
state?.status === "forming"
? "系统正在准备当前协作分工,完成后会先展示成员卡片,后续再切换为独立的实时进展面板。"
: state?.status === "formed"
? "当前协作方案已就绪。系统开始分工后,这里会从方案视图过渡到实时协作画布。"
: state?.status === "failed"
? state.errorMessage?.trim() ||
"当前协作准备失败,暂时还没有协作成员加入。"
: "还没有协作成员加入。系统开始分工后,这里会生成独立的成员进展画布。";
return {
hasRuntimeFormation: Boolean(state),
hint: buildRuntimeFormationHint(state),
emptyDetail: buildRuntimeFormationEmptyDetail(state),
noticeText,
summaryBadges: [
...(label
? [
{
key: "runtime-label",
text: `${TEAM_WORKSPACE_PLAN_LABEL} · ${label}`,
className:
"rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-sky-700",
},
]
: []),
...(meta
? [
{
key: "runtime-status",
text: meta.label,
className: `rounded-full px-2.5 py-1 font-medium ${meta.badgeClassName}`,
},
]
: []),
...(memberCards.length > 0
? [
{
key: "runtime-member-count",
text: `${memberCards.length} 位当前成员`,
className:
"rounded-full border border-slate-200 bg-white px-2.5 py-1",
},
]
: []),
...(state?.blueprint?.label
? [
{
key: "runtime-blueprint-label",
text: `参考方案 · ${state.blueprint.label}`,
className:
"rounded-full border border-slate-200 bg-white px-2.5 py-1",
},
]
: []),
],
panelTitle: "当前协作准备",
panelStatusLabel: meta?.label ?? null,
panelStatusBadgeClassName: meta?.badgeClassName ?? null,
panelLabel: label,
panelHeadline: meta?.title || "等待协作成员加入",
panelDescription:
state?.status === "failed"
? state.errorMessage?.trim() || "当前协作准备失败,暂时无法展示更多内容。"
: summary || "这里会先展示当前协作方案,成员加入后再切换成实时进展。",
referenceLabel: state?.blueprint?.label?.trim() || null,
memberCards,
blueprintRoleCards,
};
}
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import type { AgentEvent } from "@/lib/api/agentProtocol";
import type { TeamWorkspaceRuntimeSessionSnapshot } from "../teamWorkspaceRuntime";
import {
buildStatusChangedProjection,
projectRuntimeStreamEvent,
} from "./liveRuntimeProjector";
function createSessionSnapshot(
overrides: Partial<TeamWorkspaceRuntimeSessionSnapshot> = {},
): TeamWorkspaceRuntimeSessionSnapshot {
return {
id: "child-1",
runtimeStatus: "queued",
latestTurnStatus: "queued",
queuedTurnCount: 0,
...overrides,
};
}
describe("liveRuntimeProjector", () => {
it("team 状态切换应输出稳定的中文状态文案与 runtime patch", () => {
const projection = buildStatusChangedProjection({
sessionId: "child-1",
status: "running",
session: createSessionSnapshot(),
});
expect(projection.entry.title).toBe("状态切换");
expect(projection.entry.detail).toContain("已切换为处理中");
expect(projection.liveRuntimePatch).toMatchObject({
runtimeStatus: "running",
latestTurnStatus: "running",
queuedTurnCount: 0,
});
});
it("tool_start 应把工具名映射为中文展示标题", () => {
const projection = projectRuntimeStreamEvent({
sessionId: "child-1",
session: createSessionSnapshot({
runtimeStatus: "running",
latestTurnStatus: "running",
}),
event: {
type: "tool_start",
tool_name: "browser_snapshot",
tool_id: "tool-1",
} as AgentEvent,
});
expect(projection?.entry?.title).toBe("处理中 · 页面截图");
expect(projection?.entry?.detail).toContain("正在处理 页面截图");
expect(projection?.rememberTool).toEqual({
toolId: "tool-1",
toolName: "browser_snapshot",
});
});
it("tool_end 应继续沿用已记住的中文工具标题", () => {
const projection = projectRuntimeStreamEvent({
sessionId: "child-1",
session: createSessionSnapshot({
runtimeStatus: "running",
latestTurnStatus: "running",
}),
toolNameById: {
"tool-1": "browser_snapshot",
},
event: {
type: "tool_end",
tool_id: "tool-1",
result: {
success: true,
output: "页面结构差异已提取完成。",
},
} as AgentEvent,
});
expect(projection?.entry?.title).toBe("处理中 · 页面截图");
expect(projection?.entry?.detail).toContain("页面结构差异已提取完成。");
expect(projection?.forgetToolId).toBe("tool-1");
expect(projection?.refreshPreview).toBe(true);
});
it("runtime_status 队列态应投影为稍后开始并回填队列元数据", () => {
const projection = projectRuntimeStreamEvent({
sessionId: "child-1",
session: createSessionSnapshot(),
event: {
type: "runtime_status",
status: {
title: "等待可用并发",
detail: "当前 provider 并发已满,稍后继续。",
checkpoints: ["保留当前上下文"],
metadata: {
team_phase: "queued",
team_parallel_budget: 2,
team_active_count: 1,
team_queued_count: 1,
provider_concurrency_group: "openai",
provider_parallel_budget: 1,
queue_reason: "provider_busy",
retryable_overload: true,
},
},
} as AgentEvent,
});
expect(projection?.entry?.statusLabel).toBe("稍后开始");
expect(projection?.entry?.detail).toContain("当前 provider 并发已满");
expect(projection?.liveRuntimePatch).toMatchObject({
runtimeStatus: "queued",
teamPhase: "queued",
teamParallelBudget: 2,
teamActiveCount: 1,
teamQueuedCount: 1,
providerConcurrencyGroup: "openai",
providerParallelBudget: 1,
queueReason: "provider_busy",
retryableOverload: true,
});
});
});
@@ -0,0 +1,783 @@
import {
type AgentRuntimeStatusPayload,
type AgentToolExecutionResult as ToolExecutionResult,
type AgentEvent,
} from "@/lib/api/agentProtocol";
import {
buildStatusEventActivityEntry,
buildTeamWorkspaceActivityEntryFromThreadItem,
buildTeamWorkspaceSessionFingerprint,
normalizeTeamWorkspaceRuntimeStatus,
type TeamWorkspaceActivityEntry,
type TeamWorkspaceLiveRuntimeState,
type TeamWorkspaceRuntimeSessionSnapshot,
type TeamWorkspaceRuntimeStatus,
} from "../teamWorkspaceRuntime";
import { resolveToolDisplayLabel } from "../utils/toolDisplayInfo";
import { resolveTeamWorkspaceDisplayRuntimeStatusLabel } from "../utils/teamWorkspaceCopy";
const LIVE_ACTIVITY_ENTRY_LIMIT = 3;
const LIVE_ACTIVITY_DETAIL_MAX_LENGTH = 220;
const IN_PROGRESS_BADGE_CLASS_NAME =
"border border-sky-200 bg-sky-50 text-sky-700";
const QUEUED_BADGE_CLASS_NAME =
"border border-amber-200 bg-amber-50 text-amber-700";
const COMPLETED_BADGE_CLASS_NAME =
"border border-emerald-200 bg-emerald-50 text-emerald-700";
const FAILED_BADGE_CLASS_NAME =
"border border-rose-200 bg-rose-50 text-rose-700";
const LIVE_RUNTIME_PATCH_KEYS = [
"runtimeStatus",
"latestTurnStatus",
"queuedTurnCount",
"teamPhase",
"teamParallelBudget",
"teamActiveCount",
"teamQueuedCount",
"providerConcurrencyGroup",
"providerParallelBudget",
"queueReason",
"retryableOverload",
] as const;
type LiveRuntimePatchKey = (typeof LIVE_RUNTIME_PATCH_KEYS)[number];
export interface SessionLiveStreamState {
textDraft?: string;
thinkingDraft?: string;
}
export type TeamWorkspaceLiveRuntimePatch = Partial<
Pick<TeamWorkspaceLiveRuntimeState, LiveRuntimePatchKey>
>;
export interface TeamWorkspaceRuntimeStreamProjection {
entry?: TeamWorkspaceActivityEntry | null;
clearEntryIds?: string[];
nextTextDraft?: string;
clearTextDraft?: boolean;
nextThinkingDraft?: string;
clearThinkingDraft?: boolean;
rememberTool?: { toolId: string; toolName: string };
forgetToolId?: string;
liveRuntimePatch?: TeamWorkspaceLiveRuntimePatch;
refreshPreview?: boolean;
}
export interface TeamWorkspaceStatusChangedProjection {
entry: TeamWorkspaceActivityEntry;
liveRuntimePatch: TeamWorkspaceLiveRuntimePatch;
}
function normalizeLiveActivityText(
value?: string | null,
maxLength = LIVE_ACTIVITY_DETAIL_MAX_LENGTH,
) {
const normalized = value
?.replace(/\r\n/g, "\n")
.split("\0")
.join("")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (!normalized) {
return null;
}
if (normalized.length <= maxLength) {
return normalized;
}
return `...${normalized.slice(-maxLength).trimStart()}`;
}
function appendLiveActivityDraft(previous: string | undefined, chunk: string) {
return normalizeLiveActivityText(`${previous ?? ""}${chunk}`) ?? undefined;
}
function buildActivityEntry(params: {
id: string;
title: string;
detail?: string | null;
statusLabel: string;
badgeClassName: string;
}): TeamWorkspaceActivityEntry | null {
const detail = normalizeLiveActivityText(params.detail);
if (!detail) {
return null;
}
return {
id: params.id,
title: params.title,
detail,
statusLabel: params.statusLabel,
badgeClassName: params.badgeClassName,
};
}
function buildTextDraftEntry(sessionId: string, draft?: string) {
return buildActivityEntry({
id: `stream-text:${sessionId}`,
title: "内容生成中",
detail: draft,
statusLabel: "处理中",
badgeClassName: IN_PROGRESS_BADGE_CLASS_NAME,
});
}
function buildThinkingDraftEntry(sessionId: string, draft?: string) {
return buildActivityEntry({
id: `stream-thinking:${sessionId}`,
title: "整理思路中",
detail: draft,
statusLabel: "处理中",
badgeClassName: IN_PROGRESS_BADGE_CLASS_NAME,
});
}
function buildRuntimeStatusEntry(
sessionId: string,
status: AgentRuntimeStatusPayload,
) {
const waiting =
status.metadata?.team_phase === "queued" ||
status.metadata?.concurrency_phase === "queued";
return buildActivityEntry({
id: `runtime-status:${sessionId}`,
title: status.title.trim() || "当前进展",
detail: [
status.detail,
...(status.checkpoints ?? []).map((item) => `• ${item}`),
]
.map((item) => item.trim())
.filter(Boolean)
.join("\n"),
statusLabel: waiting ? "稍后开始" : "处理中",
badgeClassName: waiting
? QUEUED_BADGE_CLASS_NAME
: IN_PROGRESS_BADGE_CLASS_NAME,
});
}
function compactLiveRuntimePatch(
patch: TeamWorkspaceLiveRuntimePatch,
): TeamWorkspaceLiveRuntimePatch | undefined {
const nextPatch: TeamWorkspaceLiveRuntimePatch = {};
for (const key of LIVE_RUNTIME_PATCH_KEYS) {
const value = patch[key];
if (value !== undefined) {
assignLiveRuntimePatchValue(nextPatch, key, value);
}
}
return LIVE_RUNTIME_PATCH_KEYS.some((key) => nextPatch[key] !== undefined)
? nextPatch
: undefined;
}
function assignLiveRuntimePatchValue<K extends LiveRuntimePatchKey>(
patch: TeamWorkspaceLiveRuntimePatch,
key: K,
value: TeamWorkspaceLiveRuntimePatch[K],
) {
patch[key] = value;
}
function buildRuntimeStatusMetadataPatch(
status: AgentRuntimeStatusPayload,
): TeamWorkspaceLiveRuntimePatch | undefined {
return compactLiveRuntimePatch({
teamPhase: status.metadata?.team_phase,
teamParallelBudget: status.metadata?.team_parallel_budget,
teamActiveCount: status.metadata?.team_active_count,
teamQueuedCount: status.metadata?.team_queued_count,
providerConcurrencyGroup: status.metadata?.provider_concurrency_group,
providerParallelBudget: status.metadata?.provider_parallel_budget,
queueReason: status.metadata?.queue_reason,
retryableOverload: status.metadata?.retryable_overload,
});
}
function buildToolActivityEntry(params: {
sessionId: string;
toolId: string;
toolName?: string;
result?: ToolExecutionResult;
}) {
const { sessionId, toolId, toolName, result } = params;
const displayToolName = toolName?.trim()
? resolveToolDisplayLabel(toolName)
: null;
const title = displayToolName ? `处理中 · ${displayToolName}` : "处理中";
const detail = result
? result.error || result.output || displayToolName || "当前步骤已完成。"
: `正在处理 ${displayToolName || "当前步骤"}。`;
const success = result ? result.success !== false : true;
return buildActivityEntry({
id: `tool:${sessionId}:${toolId}`,
title,
detail,
statusLabel: result ? (success ? "完成" : "需重试") : "处理中",
badgeClassName: result
? success
? COMPLETED_BADGE_CLASS_NAME
: FAILED_BADGE_CLASS_NAME
: IN_PROGRESS_BADGE_CLASS_NAME,
});
}
function buildLifecycleActivityEntry(params: {
sessionId: string;
key: string;
title: string;
detail: string;
statusLabel: string;
badgeClassName: string;
}) {
return buildActivityEntry({
id: `lifecycle:${params.sessionId}:${params.key}`,
title: params.title,
detail: params.detail,
statusLabel: params.statusLabel,
badgeClassName: params.badgeClassName,
});
}
function buildWarningActivityEntry(
sessionId: string,
message: string,
code?: string,
) {
return buildActivityEntry({
id: `warning:${sessionId}:${code ?? ""}:${message}`,
title: "警告",
detail: message,
statusLabel: "警告",
badgeClassName: QUEUED_BADGE_CLASS_NAME,
});
}
function buildErrorActivityEntry(sessionId: string, message: string) {
return buildActivityEntry({
id: `error:${sessionId}:${message}`,
title: "错误",
detail: message,
statusLabel: "需重试",
badgeClassName: FAILED_BADGE_CLASS_NAME,
});
}
export function areActivityEntriesEqual(
left: TeamWorkspaceActivityEntry,
right: TeamWorkspaceActivityEntry,
) {
return (
left.id === right.id &&
left.title === right.title &&
left.detail === right.detail &&
left.statusLabel === right.statusLabel &&
left.badgeClassName === right.badgeClassName
);
}
export function upsertLiveActivityEntries(
entries: TeamWorkspaceActivityEntry[],
entry: TeamWorkspaceActivityEntry,
) {
if (entries[0] && areActivityEntriesEqual(entries[0], entry)) {
return entries;
}
const filtered = entries.filter((item) => item.id !== entry.id);
const next = [entry, ...filtered].slice(0, LIVE_ACTIVITY_ENTRY_LIMIT);
if (
next.length === entries.length &&
next.every((item, index) => areActivityEntriesEqual(item, entries[index]!))
) {
return entries;
}
return next;
}
export function removeLiveActivityEntries(
entries: TeamWorkspaceActivityEntry[],
entryIds: string[],
) {
if (entryIds.length === 0) {
return entries;
}
const removeSet = new Set(entryIds);
const next = entries.filter((entry) => !removeSet.has(entry.id));
if (next.length === entries.length) {
return entries;
}
return next;
}
export function buildLiveRuntimeState(
session: TeamWorkspaceRuntimeSessionSnapshot,
baseFingerprint: string,
current: TeamWorkspaceLiveRuntimeState | undefined,
patch: TeamWorkspaceLiveRuntimePatch,
): TeamWorkspaceLiveRuntimeState {
return {
runtimeStatus:
patch.runtimeStatus ??
current?.runtimeStatus ??
session.runtimeStatus ??
session.latestTurnStatus,
latestTurnStatus:
patch.latestTurnStatus ??
current?.latestTurnStatus ??
session.latestTurnStatus ??
session.runtimeStatus,
queuedTurnCount:
patch.queuedTurnCount ??
current?.queuedTurnCount ??
session.queuedTurnCount,
teamPhase: patch.teamPhase ?? current?.teamPhase ?? session.teamPhase,
teamParallelBudget:
patch.teamParallelBudget ??
current?.teamParallelBudget ??
session.teamParallelBudget,
teamActiveCount:
patch.teamActiveCount ??
current?.teamActiveCount ??
session.teamActiveCount,
teamQueuedCount:
patch.teamQueuedCount ??
current?.teamQueuedCount ??
session.teamQueuedCount,
providerConcurrencyGroup:
patch.providerConcurrencyGroup ??
current?.providerConcurrencyGroup ??
session.providerConcurrencyGroup,
providerParallelBudget:
patch.providerParallelBudget ??
current?.providerParallelBudget ??
session.providerParallelBudget,
queueReason:
patch.queueReason ?? current?.queueReason ?? session.queueReason,
retryableOverload:
patch.retryableOverload ??
current?.retryableOverload ??
session.retryableOverload,
baseFingerprint,
};
}
export function areLiveRuntimeStatesEqual(
left: TeamWorkspaceLiveRuntimeState | undefined,
right: TeamWorkspaceLiveRuntimeState,
) {
return !!left &&
left.runtimeStatus === right.runtimeStatus &&
left.latestTurnStatus === right.latestTurnStatus &&
left.queuedTurnCount === right.queuedTurnCount &&
left.teamPhase === right.teamPhase &&
left.teamParallelBudget === right.teamParallelBudget &&
left.teamActiveCount === right.teamActiveCount &&
left.teamQueuedCount === right.teamQueuedCount &&
left.providerConcurrencyGroup === right.providerConcurrencyGroup &&
left.providerParallelBudget === right.providerParallelBudget &&
left.queueReason === right.queueReason &&
left.retryableOverload === right.retryableOverload &&
left.baseFingerprint === right.baseFingerprint;
}
function getQueuedTurnCount(
session: TeamWorkspaceRuntimeSessionSnapshot,
current?: TeamWorkspaceLiveRuntimeState,
): number {
return current?.queuedTurnCount ?? session.queuedTurnCount ?? 0;
}
function resolveQueueDrainedRuntimeStatus(
session: TeamWorkspaceRuntimeSessionSnapshot,
current?: TeamWorkspaceLiveRuntimeState,
): TeamWorkspaceRuntimeStatus {
const candidate = [
current?.latestTurnStatus,
session.latestTurnStatus,
current?.runtimeStatus,
session.runtimeStatus,
].find((status) => status && status !== "queued" && status !== "running");
return candidate ?? "idle";
}
function resolveFinalRuntimeStatus(params: {
session: TeamWorkspaceRuntimeSessionSnapshot;
current?: TeamWorkspaceLiveRuntimeState;
terminalStatus?: Extract<TeamWorkspaceRuntimeStatus, "completed" | "failed">;
queuedTurnCount?: number;
}): TeamWorkspaceRuntimeStatus {
const queuedTurnCount =
params.queuedTurnCount ??
getQueuedTurnCount(params.session, params.current);
if (queuedTurnCount > 0) {
return "queued";
}
if (params.terminalStatus) {
return params.terminalStatus;
}
const candidate = [
params.current?.latestTurnStatus,
params.session.latestTurnStatus,
params.current?.runtimeStatus,
params.session.runtimeStatus,
].find((status) => status && status !== "queued" && status !== "running");
return candidate ?? "completed";
}
export function buildStatusChangedProjection(params: {
sessionId: string;
status: TeamWorkspaceRuntimeStatus | "not_found";
session: TeamWorkspaceRuntimeSessionSnapshot;
currentRuntime?: TeamWorkspaceLiveRuntimeState;
}): TeamWorkspaceStatusChangedProjection {
const normalizedStatus = normalizeTeamWorkspaceRuntimeStatus(params.status);
const nextQueuedTurnCount =
normalizedStatus === "completed" ||
normalizedStatus === "failed" ||
normalizedStatus === "aborted" ||
normalizedStatus === "closed" ||
normalizedStatus === "idle"
? 0
: (params.currentRuntime?.queuedTurnCount ?? params.session.queuedTurnCount);
return {
entry: buildStatusEventActivityEntry(params.sessionId, params.status),
liveRuntimePatch: {
runtimeStatus: normalizedStatus,
latestTurnStatus: normalizedStatus,
queuedTurnCount: nextQueuedTurnCount,
},
};
}
export function projectRuntimeStreamEvent(params: {
sessionId: string;
session: TeamWorkspaceRuntimeSessionSnapshot;
event: AgentEvent;
currentRuntime?: TeamWorkspaceLiveRuntimeState;
streamState?: SessionLiveStreamState;
toolNameById?: Record<string, string>;
}): TeamWorkspaceRuntimeStreamProjection | null {
const {
sessionId,
session,
event,
currentRuntime,
streamState,
toolNameById,
} = params;
switch (event.type) {
case "item_started":
case "item_updated":
case "item_completed": {
const entry = buildTeamWorkspaceActivityEntryFromThreadItem(event.item);
const clearEntryIds: string[] = [];
if (
event.type === "item_completed" &&
(event.item.type === "agent_message" ||
event.item.type === "turn_summary")
) {
clearEntryIds.push(
`stream-text:${sessionId}`,
`runtime-status:${sessionId}`,
);
}
if (event.type === "item_completed" && event.item.type === "reasoning") {
clearEntryIds.push(`stream-thinking:${sessionId}`);
}
return {
entry,
clearEntryIds,
clearTextDraft:
event.type === "item_completed" &&
(event.item.type === "agent_message" ||
event.item.type === "turn_summary"),
clearThinkingDraft:
event.type === "item_completed" && event.item.type === "reasoning",
refreshPreview: event.type === "item_completed",
};
}
case "text_delta": {
const nextTextDraft = appendLiveActivityDraft(
streamState?.textDraft,
event.text,
);
return {
entry: buildTextDraftEntry(sessionId, nextTextDraft),
nextTextDraft: nextTextDraft ?? undefined,
};
}
case "thinking_delta": {
const nextThinkingDraft = appendLiveActivityDraft(
streamState?.thinkingDraft,
event.text,
);
return {
entry: buildThinkingDraftEntry(sessionId, nextThinkingDraft),
nextThinkingDraft: nextThinkingDraft ?? undefined,
};
}
case "tool_start":
return {
entry: buildToolActivityEntry({
sessionId,
toolId: event.tool_id,
toolName: event.tool_name,
}),
rememberTool: {
toolId: event.tool_id,
toolName: event.tool_name,
},
};
case "tool_end":
return {
entry: buildToolActivityEntry({
sessionId,
toolId: event.tool_id,
toolName: toolNameById?.[event.tool_id],
result: event.result,
}),
forgetToolId: event.tool_id,
refreshPreview: true,
};
case "runtime_status": {
const liveRuntimePatch = compactLiveRuntimePatch({
...buildRuntimeStatusMetadataPatch(event.status),
runtimeStatus:
event.status.metadata?.team_phase === "queued" ||
event.status.metadata?.concurrency_phase === "queued"
? "queued"
: event.status.metadata?.team_phase === "running" ||
event.status.metadata?.concurrency_phase === "running"
? "running"
: undefined,
});
return {
entry: buildRuntimeStatusEntry(sessionId, event.status),
liveRuntimePatch,
};
}
case "queue_added":
return {
entry: buildLifecycleActivityEntry({
sessionId,
key: "queue",
title: "稍后开始",
detail: "新的说明已经收到,这位协作成员会在前一项完成后继续处理。",
statusLabel: resolveTeamWorkspaceDisplayRuntimeStatusLabel("queued"),
badgeClassName: QUEUED_BADGE_CLASS_NAME,
}),
liveRuntimePatch: {
runtimeStatus: "queued",
latestTurnStatus: "queued",
queuedTurnCount: getQueuedTurnCount(session, currentRuntime) + 1,
},
};
case "queue_started":
return {
entry: buildLifecycleActivityEntry({
sessionId,
key: "queue",
title: "开始处理",
detail: "这位协作成员已经开始处理当前任务。",
statusLabel: resolveTeamWorkspaceDisplayRuntimeStatusLabel("running"),
badgeClassName: IN_PROGRESS_BADGE_CLASS_NAME,
}),
liveRuntimePatch: {
runtimeStatus: "running",
latestTurnStatus: "running",
queuedTurnCount: Math.max(
getQueuedTurnCount(session, currentRuntime) - 1,
0,
),
},
};
case "queue_removed": {
const queuedTurnCount = Math.max(
getQueuedTurnCount(session, currentRuntime) - 1,
0,
);
return {
liveRuntimePatch: compactLiveRuntimePatch({
queuedTurnCount,
runtimeStatus:
queuedTurnCount === 0 && currentRuntime?.runtimeStatus === "queued"
? resolveQueueDrainedRuntimeStatus(session, currentRuntime)
: undefined,
}),
refreshPreview: true,
};
}
case "queue_cleared":
return {
liveRuntimePatch: compactLiveRuntimePatch({
queuedTurnCount: 0,
runtimeStatus:
currentRuntime?.runtimeStatus === "queued"
? resolveQueueDrainedRuntimeStatus(session, currentRuntime)
: undefined,
}),
refreshPreview: true,
};
case "turn_started":
return {
entry: buildLifecycleActivityEntry({
sessionId,
key: "turn",
title: "继续处理",
detail: "这位协作成员正在推进当前内容。",
statusLabel: resolveTeamWorkspaceDisplayRuntimeStatusLabel("running"),
badgeClassName: IN_PROGRESS_BADGE_CLASS_NAME,
}),
liveRuntimePatch: {
runtimeStatus: "running",
latestTurnStatus: "running",
},
};
case "turn_completed": {
const queuedTurnCount = getQueuedTurnCount(session, currentRuntime);
return {
entry: buildLifecycleActivityEntry({
sessionId,
key: "turn",
title: "阶段完成",
detail: "这一步已经完成,正在同步最新结果。",
statusLabel: "完成",
badgeClassName: COMPLETED_BADGE_CLASS_NAME,
}),
liveRuntimePatch: {
runtimeStatus: resolveFinalRuntimeStatus({
session,
current: currentRuntime,
terminalStatus: "completed",
queuedTurnCount,
}),
latestTurnStatus: "completed",
queuedTurnCount,
},
clearEntryIds: [
`stream-text:${sessionId}`,
`stream-thinking:${sessionId}`,
`runtime-status:${sessionId}`,
],
clearTextDraft: true,
clearThinkingDraft: true,
refreshPreview: true,
};
}
case "turn_failed": {
const queuedTurnCount = getQueuedTurnCount(session, currentRuntime);
return {
entry: buildLifecycleActivityEntry({
sessionId,
key: "turn",
title: "处理失败",
detail:
event.turn.error_message?.trim() ||
"这一步处理失败,请查看错误详情。",
statusLabel: "需重试",
badgeClassName: FAILED_BADGE_CLASS_NAME,
}),
liveRuntimePatch: {
runtimeStatus: resolveFinalRuntimeStatus({
session,
current: currentRuntime,
terminalStatus: "failed",
queuedTurnCount,
}),
latestTurnStatus: "failed",
queuedTurnCount,
},
clearEntryIds: [
`stream-text:${sessionId}`,
`stream-thinking:${sessionId}`,
`runtime-status:${sessionId}`,
],
clearTextDraft: true,
clearThinkingDraft: true,
refreshPreview: true,
};
}
case "warning":
return {
entry: buildWarningActivityEntry(sessionId, event.message, event.code),
refreshPreview: true,
};
case "error": {
const queuedTurnCount = getQueuedTurnCount(session, currentRuntime);
return {
entry: buildErrorActivityEntry(sessionId, event.message),
liveRuntimePatch: {
runtimeStatus: resolveFinalRuntimeStatus({
session,
current: currentRuntime,
terminalStatus: "failed",
queuedTurnCount,
}),
latestTurnStatus: "failed",
queuedTurnCount,
},
clearEntryIds: [
`stream-text:${sessionId}`,
`stream-thinking:${sessionId}`,
`runtime-status:${sessionId}`,
],
clearTextDraft: true,
clearThinkingDraft: true,
refreshPreview: true,
};
}
case "done":
return null;
case "final_done": {
const queuedTurnCount = getQueuedTurnCount(session, currentRuntime);
return {
liveRuntimePatch: {
runtimeStatus: resolveFinalRuntimeStatus({
session,
current: currentRuntime,
queuedTurnCount,
}),
queuedTurnCount,
},
clearEntryIds: [
`stream-text:${sessionId}`,
`stream-thinking:${sessionId}`,
`runtime-status:${sessionId}`,
],
clearTextDraft: true,
clearThinkingDraft: true,
refreshPreview: true,
};
}
default:
return null;
}
}
export function buildLiveRuntimeBaseFingerprint(
session: TeamWorkspaceRuntimeSessionSnapshot,
) {
return buildTeamWorkspaceSessionFingerprint(session);
}
@@ -0,0 +1,161 @@
import { describe, expect, it, vi } from "vitest";
import {
buildTeamWorkspaceSessionFingerprint,
type TeamWorkspaceActivityEntry,
type TeamWorkspaceLiveRuntimeState,
type TeamWorkspaceRuntimeSessionSnapshot,
} from "../teamWorkspaceRuntime";
import type { SessionLiveStreamState } from "./liveRuntimeProjector";
import {
subscribeTeamWorkspaceStatusEvents,
subscribeTeamWorkspaceStreamEvents,
} from "./runtimeEventSubscriptions";
describe("runtimeEventSubscriptions", () => {
it("应直接处理状态监听投影,而不需要先挂 React hook", async () => {
const listeners = new Map<string, (event: { payload: unknown }) => void>();
let liveRuntimeBySessionId: Record<string, TeamWorkspaceLiveRuntimeState> =
{};
let liveActivityBySessionId: Record<string, TeamWorkspaceActivityEntry[]> =
{};
const refreshSpy = vi.fn();
const childSession: TeamWorkspaceRuntimeSessionSnapshot = {
id: "child-1",
runtimeStatus: "queued",
latestTurnStatus: "queued",
queuedTurnCount: 0,
};
const unsubscribe = await subscribeTeamWorkspaceStatusEvents({
sessionIds: ["parent-1"],
eventSource: {
listenSubagentStatus: async (sessionId, handler) => {
listeners.set(sessionId, handler);
return () => {
listeners.delete(sessionId);
};
},
},
getSnapshot: (sessionId) =>
sessionId === "child-1" ? childSession : undefined,
getBaseFingerprint: (_sessionId, session) =>
buildTeamWorkspaceSessionFingerprint(session),
getCurrentRuntime: (sessionId) => liveRuntimeBySessionId[sessionId],
setLiveRuntimeBySessionId: (update) => {
liveRuntimeBySessionId = update(liveRuntimeBySessionId);
},
setLiveActivityBySessionId: (update) => {
liveActivityBySessionId = update(liveActivityBySessionId);
},
scheduleActivityRefresh: refreshSpy,
});
listeners.get("parent-1")?.({
payload: {
type: "subagent_status_changed",
session_id: "child-1",
root_session_id: "parent-1",
status: "running",
},
});
expect(liveRuntimeBySessionId["child-1"]?.runtimeStatus).toBe("running");
expect(liveActivityBySessionId["child-1"]?.[0]?.detail).toContain(
"已切换为处理中",
);
expect(refreshSpy).toHaveBeenCalledWith("child-1");
unsubscribe();
});
it("应直接处理流式监听投影,并更新 tool 暂态与活动摘要", async () => {
const listeners = new Map<string, (event: { payload: unknown }) => void>();
let liveRuntimeBySessionId: Record<string, TeamWorkspaceLiveRuntimeState> =
{};
let liveActivityBySessionId: Record<string, TeamWorkspaceActivityEntry[]> =
{};
let liveStreamStateBySessionId: Record<string, SessionLiveStreamState> = {};
let toolNameBySessionId: Record<string, Record<string, string>> = {};
const refreshSpy = vi.fn();
const session: TeamWorkspaceRuntimeSessionSnapshot = {
id: "child-1",
runtimeStatus: "running",
latestTurnStatus: "running",
queuedTurnCount: 0,
};
const unsubscribe = await subscribeTeamWorkspaceStreamEvents({
sessionIds: ["child-1"],
eventSource: {
listenSubagentStream: async (sessionId, handler) => {
listeners.set(sessionId, handler);
return () => {
listeners.delete(sessionId);
};
},
},
getSnapshot: (sessionId) =>
sessionId === "child-1" ? session : undefined,
getBaseFingerprint: (_sessionId, snapshot) =>
buildTeamWorkspaceSessionFingerprint(snapshot),
getCurrentRuntime: (sessionId) => liveRuntimeBySessionId[sessionId],
getStreamState: (sessionId) => liveStreamStateBySessionId[sessionId],
setStreamState: (sessionId, nextState) => {
if (nextState) {
liveStreamStateBySessionId[sessionId] = nextState;
return;
}
delete liveStreamStateBySessionId[sessionId];
},
getToolNames: (sessionId) => toolNameBySessionId[sessionId],
setToolNames: (sessionId, nextToolNames) => {
if (nextToolNames) {
toolNameBySessionId[sessionId] = nextToolNames;
return;
}
delete toolNameBySessionId[sessionId];
},
setLiveRuntimeBySessionId: (update) => {
liveRuntimeBySessionId = update(liveRuntimeBySessionId);
},
setLiveActivityBySessionId: (update) => {
liveActivityBySessionId = update(liveActivityBySessionId);
},
scheduleActivityRefresh: refreshSpy,
});
listeners.get("child-1")?.({
payload: {
type: "tool_start",
tool_id: "tool-1",
tool_name: "browser_snapshot",
},
});
expect(liveActivityBySessionId["child-1"]?.[0]?.title).toBe(
"处理中 · 页面截图",
);
expect(toolNameBySessionId["child-1"]?.["tool-1"]).toBe("browser_snapshot");
listeners.get("child-1")?.({
payload: {
type: "tool_end",
tool_id: "tool-1",
result: {
success: true,
output: "页面结构差异已提取完成。",
},
},
});
expect(liveActivityBySessionId["child-1"]?.[0]?.detail).toContain(
"页面结构差异已提取完成",
);
expect(toolNameBySessionId["child-1"]).toBeUndefined();
expect(refreshSpy).toHaveBeenCalledWith("child-1");
unsubscribe();
});
});
@@ -0,0 +1,313 @@
import type { UnlistenFn } from "@tauri-apps/api/event";
import { parseAgentEvent } from "@/lib/api/agentProtocol";
import type { AgentRuntimeEventSource } from "@/lib/api/agentRuntimeEvents";
import type {
TeamWorkspaceActivityEntry,
TeamWorkspaceLiveRuntimeState,
TeamWorkspaceRuntimeSessionSnapshot,
} from "../teamWorkspaceRuntime";
import {
areLiveRuntimeStatesEqual,
buildLiveRuntimeState,
buildStatusChangedProjection,
projectRuntimeStreamEvent,
removeLiveActivityEntries,
type SessionLiveStreamState,
upsertLiveActivityEntries,
} from "./liveRuntimeProjector";
type LiveRuntimeBySessionId = Record<string, TeamWorkspaceLiveRuntimeState>;
type LiveActivityBySessionId = Record<string, TeamWorkspaceActivityEntry[]>;
type TeamWorkspaceRecordUpdater<TRecord> = (
update: (previous: TRecord) => TRecord,
) => void;
export interface TeamWorkspaceStatusEventSubscriptionDeps {
sessionIds: string[];
eventSource: Pick<AgentRuntimeEventSource, "listenSubagentStatus">;
getSnapshot(
sessionId: string,
): TeamWorkspaceRuntimeSessionSnapshot | undefined;
getBaseFingerprint(
sessionId: string,
session: TeamWorkspaceRuntimeSessionSnapshot,
): string;
getCurrentRuntime(
sessionId: string,
): TeamWorkspaceLiveRuntimeState | undefined;
setLiveRuntimeBySessionId: TeamWorkspaceRecordUpdater<LiveRuntimeBySessionId>;
setLiveActivityBySessionId: TeamWorkspaceRecordUpdater<LiveActivityBySessionId>;
scheduleActivityRefresh(sessionId: string): void;
}
export interface TeamWorkspaceStreamEventSubscriptionDeps {
sessionIds: string[];
eventSource: Pick<AgentRuntimeEventSource, "listenSubagentStream">;
getSnapshot(
sessionId: string,
): TeamWorkspaceRuntimeSessionSnapshot | undefined;
getBaseFingerprint(
sessionId: string,
session: TeamWorkspaceRuntimeSessionSnapshot,
): string;
getCurrentRuntime(
sessionId: string,
): TeamWorkspaceLiveRuntimeState | undefined;
getStreamState(sessionId: string): SessionLiveStreamState | undefined;
setStreamState(
sessionId: string,
nextState: SessionLiveStreamState | undefined,
): void;
getToolNames(sessionId: string): Record<string, string> | undefined;
setToolNames(
sessionId: string,
nextToolNames: Record<string, string> | undefined,
): void;
setLiveRuntimeBySessionId: TeamWorkspaceRecordUpdater<LiveRuntimeBySessionId>;
setLiveActivityBySessionId: TeamWorkspaceRecordUpdater<LiveActivityBySessionId>;
scheduleActivityRefresh(sessionId: string): void;
}
type SessionEventHandler = (event: { payload: unknown }) => void;
type SessionEventListener = (
sessionId: string,
handler: SessionEventHandler,
) => Promise<UnlistenFn>;
async function subscribeToSessionEvents(params: {
sessionIds: string[];
listen: SessionEventListener;
handleEvent(sessionId: string, event: { payload: unknown }): void;
}): Promise<() => void> {
let disposed = false;
const unlisteners: UnlistenFn[] = [];
for (const sessionId of params.sessionIds) {
const unlisten = await params.listen(sessionId, (event) => {
if (disposed) {
return;
}
params.handleEvent(sessionId, event);
});
if (disposed) {
unlisten();
return () => {};
}
unlisteners.push(unlisten);
}
return () => {
disposed = true;
for (const unlisten of unlisteners) {
unlisten();
}
};
}
export async function subscribeTeamWorkspaceStatusEvents(
deps: TeamWorkspaceStatusEventSubscriptionDeps,
): Promise<() => void> {
return await subscribeToSessionEvents({
sessionIds: deps.sessionIds,
listen: deps.eventSource.listenSubagentStatus,
handleEvent: (_statusSessionId, event) => {
const data = parseAgentEvent(event.payload);
if (data?.type !== "subagent_status_changed") {
return;
}
const matchingSession = deps.getSnapshot(data.session_id);
if (!matchingSession) {
return;
}
const baseFingerprint = deps.getBaseFingerprint(
data.session_id,
matchingSession,
);
const projection = buildStatusChangedProjection({
sessionId: data.session_id,
status: data.status,
session: matchingSession,
currentRuntime: deps.getCurrentRuntime(data.session_id),
});
deps.setLiveRuntimeBySessionId((previous) => {
const current = previous[data.session_id];
const nextState = buildLiveRuntimeState(
matchingSession,
baseFingerprint,
current,
projection.liveRuntimePatch,
);
if (areLiveRuntimeStatesEqual(current, nextState)) {
return previous;
}
return {
...previous,
[data.session_id]: nextState,
};
});
deps.setLiveActivityBySessionId((previous) => {
const existingEntries = previous[data.session_id] ?? [];
if (
existingEntries[0]?.title === projection.entry.title &&
existingEntries[0]?.detail === projection.entry.detail
) {
return previous;
}
return {
...previous,
[data.session_id]: upsertLiveActivityEntries(
existingEntries,
projection.entry,
),
};
});
deps.scheduleActivityRefresh(data.session_id);
},
});
}
export async function subscribeTeamWorkspaceStreamEvents(
deps: TeamWorkspaceStreamEventSubscriptionDeps,
): Promise<() => void> {
return await subscribeToSessionEvents({
sessionIds: deps.sessionIds,
listen: deps.eventSource.listenSubagentStream,
handleEvent: (sessionId, event) => {
const data = parseAgentEvent(event.payload);
if (!data) {
return;
}
const matchingSession = deps.getSnapshot(sessionId);
if (!matchingSession) {
return;
}
const projection = projectRuntimeStreamEvent({
sessionId,
session: matchingSession,
event: data,
currentRuntime: deps.getCurrentRuntime(sessionId),
streamState: deps.getStreamState(sessionId),
toolNameById: deps.getToolNames(sessionId),
});
if (!projection) {
return;
}
if (projection.rememberTool) {
deps.setToolNames(sessionId, {
...(deps.getToolNames(sessionId) ?? {}),
[projection.rememberTool.toolId]: projection.rememberTool.toolName,
});
}
if (projection.forgetToolId) {
const currentTools = deps.getToolNames(sessionId);
if (currentTools) {
const nextTools = { ...currentTools };
delete nextTools[projection.forgetToolId];
deps.setToolNames(
sessionId,
Object.keys(nextTools).length > 0 ? nextTools : undefined,
);
}
}
const nextStreamState = {
...(deps.getStreamState(sessionId) ?? {}),
};
let streamStateChanged = false;
if (projection.nextTextDraft !== undefined) {
nextStreamState.textDraft = projection.nextTextDraft;
streamStateChanged = true;
}
if (projection.clearTextDraft) {
delete nextStreamState.textDraft;
streamStateChanged = true;
}
if (projection.nextThinkingDraft !== undefined) {
nextStreamState.thinkingDraft = projection.nextThinkingDraft;
streamStateChanged = true;
}
if (projection.clearThinkingDraft) {
delete nextStreamState.thinkingDraft;
streamStateChanged = true;
}
if (streamStateChanged) {
deps.setStreamState(
sessionId,
Object.keys(nextStreamState).length > 0 ? nextStreamState : undefined,
);
}
const liveRuntimePatch = projection.liveRuntimePatch;
if (liveRuntimePatch) {
const baseFingerprint = deps.getBaseFingerprint(sessionId, matchingSession);
deps.setLiveRuntimeBySessionId((previous) => {
const current = previous[sessionId];
const nextState = buildLiveRuntimeState(
matchingSession,
baseFingerprint,
current,
liveRuntimePatch,
);
if (areLiveRuntimeStatesEqual(current, nextState)) {
return previous;
}
return {
...previous,
[sessionId]: nextState,
};
});
}
if (projection.entry || projection.clearEntryIds?.length) {
deps.setLiveActivityBySessionId((previous) => {
const existingEntries = previous[sessionId] ?? [];
const nextWithoutTransient = removeLiveActivityEntries(
existingEntries,
projection.clearEntryIds ?? [],
);
const nextEntries = projection.entry
? upsertLiveActivityEntries(nextWithoutTransient, projection.entry)
: nextWithoutTransient;
if (nextEntries === existingEntries) {
return previous;
}
if (nextEntries.length === 0) {
const { [sessionId]: _removed, ...rest } = previous;
return rest;
}
return {
...previous,
[sessionId]: nextEntries,
};
});
}
if (projection.refreshPreview) {
deps.scheduleActivityRefresh(sessionId);
}
},
});
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { TeamWorkspaceLiveRuntimeState } from "../teamWorkspaceRuntime";
import {
collectInactiveSessionIds,
pruneInactiveSessionRecord,
reconcileActiveLiveRuntimeBySessionId,
} from "./runtimeStateReconciler";
describe("runtimeStateReconciler", () => {
it("应收集 inactive session 并裁剪通用 session record", () => {
const activeSessionIds = new Set(["child-1"]);
const record = {
"child-1": { value: 1 },
"child-2": { value: 2 },
};
expect(collectInactiveSessionIds(record, activeSessionIds)).toEqual([
"child-2",
]);
expect(pruneInactiveSessionRecord(record, activeSessionIds)).toEqual({
"child-1": { value: 1 },
});
});
it("应在 baseFingerprint 追平时仅重置 liveRuntime,而不误删仍活跃 session", () => {
const activeSessionIds = new Set(["child-1", "child-2"]);
const liveRuntimeBySessionId: Record<string, TeamWorkspaceLiveRuntimeState> = {
"child-1": {
runtimeStatus: "running",
latestTurnStatus: "running",
baseFingerprint: "fp-old",
},
"child-2": {
runtimeStatus: "queued",
latestTurnStatus: "queued",
baseFingerprint: "fp-2",
},
};
const baseFingerprintById = new Map([
["child-1", "fp-new"],
["child-2", "fp-2"],
]);
expect(
reconcileActiveLiveRuntimeBySessionId(
liveRuntimeBySessionId,
activeSessionIds,
baseFingerprintById,
),
).toEqual({
"child-2": {
runtimeStatus: "queued",
latestTurnStatus: "queued",
baseFingerprint: "fp-2",
},
});
});
});
@@ -0,0 +1,53 @@
import type {
TeamWorkspaceActivityEntry,
TeamWorkspaceLiveRuntimeState,
} from "../teamWorkspaceRuntime";
export type SessionScopedRecord<TValue> = Record<string, TValue>;
export function collectInactiveSessionIds(
record: Record<string, unknown>,
activeSessionIds: Set<string>,
): string[] {
return Object.keys(record).filter((sessionId) => !activeSessionIds.has(sessionId));
}
export function pruneInactiveSessionRecord<TValue>(
record: SessionScopedRecord<TValue>,
activeSessionIds: Set<string>,
): SessionScopedRecord<TValue> {
const nextEntries = Object.entries(record).filter(([sessionId]) =>
activeSessionIds.has(sessionId),
);
if (nextEntries.length === Object.keys(record).length) {
return record;
}
return Object.fromEntries(nextEntries);
}
export function reconcileActiveLiveRuntimeBySessionId(
record: SessionScopedRecord<TeamWorkspaceLiveRuntimeState>,
activeSessionIds: Set<string>,
baseFingerprintById: Map<string, string>,
): SessionScopedRecord<TeamWorkspaceLiveRuntimeState> {
const nextEntries = Object.entries(record).filter(([sessionId, live]) => {
if (!activeSessionIds.has(sessionId)) {
return false;
}
return baseFingerprintById.get(sessionId) === live.baseFingerprint;
});
if (nextEntries.length === Object.keys(record).length) {
return record;
}
return Object.fromEntries(nextEntries);
}
export type TeamWorkspaceLiveActivityBySessionId =
SessionScopedRecord<TeamWorkspaceActivityEntry[]>;
export type TeamWorkspaceActivityRefreshVersionBySessionId =
SessionScopedRecord<number>;
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import { buildSelectedSessionDetailDisplayState } from "./selectedSessionDetailSelectors";
describe("selectedSessionDetailSelectors", () => {
it("应稳定产出选中成员详情区的 metadata、设置与技能标签", () => {
const state = buildSelectedSessionDetailDisplayState({
selectedSession: {
blueprintRoleLabel: "执行",
createdFromTurnId: "turn-42",
latestTurnStatus: "running",
model: "gpt-5.4",
originTool: "spawn_agent",
outputContract: "请输出可执行的 patch 与验证结论。",
profileName: "代码执行员",
providerConcurrencyGroup: "openai",
providerName: "OpenAI",
providerParallelBudget: 1,
queueReason: "等待上游并发窗口。",
queuedTurnCount: 1,
roleKey: "executor",
sessionType: "sub_agent",
skills: [
{
id: "bounded-implementation",
name: "边界实现",
description: "只改明确授权的边界。",
},
{
id: "verification-report",
name: "验证汇报",
directory: "verification-report",
},
],
teamActiveCount: 1,
teamParallelBudget: 3,
teamPresetId: "code-triage-team",
theme: "engineering",
},
isChildSession: true,
parentSessionName: "主线程总览",
});
expect(state.runtimeDetailSummary).toBe(
"等待中 1 · 最近进展 处理中 · 处理中 1/3 · 稳妥模式",
);
expect(state.queueReason).toBe("等待上游并发窗口。");
expect(state.metadata).toEqual([
"分工 执行",
"协作成员",
"服务 OpenAI",
"模型 gpt-5.4",
"来源 spawn_agent",
"来自之前的任务 turn-42",
"等待中 1",
"处理中 1/3",
"稳妥模式",
"最近进展 处理中",
"来自 主线程总览",
]);
expect(state.settingBadges).toEqual([
"预设 代码排障团队",
"风格 代码执行员",
"分工 执行",
"主题 engineering",
]);
expect(state.outputContract).toBe("请输出可执行的 patch 与验证结论。");
expect(state.skillBadges).toEqual([
{
id: "bounded-implementation",
label: "边界实现",
title: "只改明确授权的边界。",
},
{
id: "verification-report",
label: "验证汇报",
title: "verification-report",
},
]);
expect(state.hasSettings).toBe(true);
});
it("未知预设应保留原始 id,且无内容时不展示设置区", () => {
const state = buildSelectedSessionDetailDisplayState({
selectedSession: {
teamPresetId: "custom-ops-team",
roleKey: "unknown-role",
},
isChildSession: false,
});
expect(state.settingBadges).toEqual(["预设 custom-ops-team"]);
expect(state.metadata).toEqual([]);
expect(state.hasSettings).toBe(true);
});
it("没有会话时应返回空展示模型", () => {
const state = buildSelectedSessionDetailDisplayState({
selectedSession: null,
isChildSession: false,
});
expect(state).toEqual({
runtimeDetailSummary: null,
queueReason: null,
metadata: [],
settingBadges: [],
outputContract: null,
skillBadges: [],
hasSettings: false,
});
});
});
@@ -0,0 +1,120 @@
import type { AsterSubagentSkillInfo } from "@/lib/api/agentRuntime";
import {
buildTeamWorkspaceSkillDisplayName,
resolveTeamWorkspaceDisplayRuntimeStatusLabel,
resolveTeamWorkspaceDisplaySessionTypeLabel,
resolveTeamWorkspaceRoleHintLabel,
resolveTeamWorkspaceStableProcessingLabel,
} from "../utils/teamWorkspaceCopy";
import { getTeamPresetOption } from "../utils/teamPresets";
import type { TeamWorkspaceRuntimeStatus } from "../teamWorkspaceRuntime";
import { buildRuntimeDetailSummary } from "./canvasLaneSelectors";
export interface SelectedSessionDetailSession {
blueprintRoleLabel?: string;
createdFromTurnId?: string;
latestTurnStatus?: TeamWorkspaceRuntimeStatus;
model?: string;
originTool?: string;
outputContract?: string;
profileName?: string;
providerConcurrencyGroup?: string;
providerName?: string;
providerParallelBudget?: number;
queueReason?: string;
queuedTurnCount?: number;
roleKey?: string;
sessionType?: string;
skills?: AsterSubagentSkillInfo[];
teamActiveCount?: number;
teamParallelBudget?: number;
teamPresetId?: string;
teamQueuedCount?: number;
theme?: string;
}
export interface SelectedSessionDetailSkillBadge {
id: string;
label: string;
title?: string;
}
export interface SelectedSessionDetailDisplayState {
runtimeDetailSummary: string | null;
queueReason: string | null;
metadata: string[];
settingBadges: string[];
outputContract: string | null;
skillBadges: SelectedSessionDetailSkillBadge[];
hasSettings: boolean;
}
export function buildSelectedSessionDetailDisplayState(params: {
selectedSession?: SelectedSessionDetailSession | null;
isChildSession: boolean;
parentSessionName?: string | null;
}): SelectedSessionDetailDisplayState {
const selectedSession = params.selectedSession ?? null;
const queuedCount =
selectedSession?.teamQueuedCount ?? selectedSession?.queuedTurnCount ?? 0;
const roleLabel = resolveTeamWorkspaceRoleHintLabel(selectedSession?.roleKey);
const presetLabel = selectedSession?.teamPresetId
? getTeamPresetOption(selectedSession.teamPresetId)?.label ??
selectedSession.teamPresetId
: null;
const skillBadges = (selectedSession?.skills ?? []).map((skill) => ({
id: skill.id,
label: buildTeamWorkspaceSkillDisplayName(skill),
title: skill.description || skill.directory || undefined,
}));
const settingBadges = [
presetLabel ? `预设 ${presetLabel}` : null,
selectedSession?.profileName ? `风格 ${selectedSession.profileName}` : null,
roleLabel ? `分工 ${roleLabel}` : null,
selectedSession?.theme ? `主题 ${selectedSession.theme}` : null,
].filter(Boolean) as string[];
const metadata = [
selectedSession?.blueprintRoleLabel
? `分工 ${selectedSession.blueprintRoleLabel}`
: null,
selectedSession?.sessionType
? resolveTeamWorkspaceDisplaySessionTypeLabel(selectedSession.sessionType)
: null,
selectedSession?.providerName
? `服务 ${selectedSession.providerName}`
: null,
selectedSession?.model ? `模型 ${selectedSession.model}` : null,
selectedSession?.originTool ? `来源 ${selectedSession.originTool}` : null,
selectedSession?.createdFromTurnId
? `来自之前的任务 ${selectedSession.createdFromTurnId}`
: null,
queuedCount > 0 ? `等待中 ${queuedCount}` : null,
selectedSession?.teamActiveCount !== undefined &&
selectedSession?.teamParallelBudget !== undefined
? `处理中 ${selectedSession.teamActiveCount}/${selectedSession.teamParallelBudget}`
: null,
selectedSession?.providerParallelBudget === 1 &&
selectedSession?.providerConcurrencyGroup
? resolveTeamWorkspaceStableProcessingLabel()
: null,
selectedSession?.latestTurnStatus
? `最近进展 ${resolveTeamWorkspaceDisplayRuntimeStatusLabel(selectedSession.latestTurnStatus)}`
: null,
params.isChildSession && params.parentSessionName?.trim()
? `来自 ${params.parentSessionName.trim()}`
: null,
].filter(Boolean) as string[];
return {
runtimeDetailSummary: buildRuntimeDetailSummary(selectedSession),
queueReason: selectedSession?.queueReason?.trim() || null,
metadata,
settingBadges,
outputContract: selectedSession?.outputContract?.trim() || null,
skillBadges,
hasSettings:
settingBadges.length > 0 ||
Boolean(selectedSession?.outputContract?.trim()) ||
skillBadges.length > 0,
};
}
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
buildTeamWorkspaceSessionControlState,
isCompletedTeamSession,
isWaitableTeamSession,
} from "./sessionStateSelectors";
describe("sessionStateSelectors", () => {
it("应聚合 statusSummary,并在子线程视角纳入当前成员", () => {
const state = buildTeamWorkspaceSessionControlState({
visibleSessions: [
{
id: "child-1",
sessionType: "sub_agent",
runtimeStatus: "running",
},
{
id: "child-2",
sessionType: "sub_agent",
runtimeStatus: "queued",
},
],
railSessions: [
{
id: "child-1",
sessionType: "sub_agent",
runtimeStatus: "running",
},
{
id: "child-2",
sessionType: "sub_agent",
runtimeStatus: "queued",
},
],
currentChildSession: {
id: "child-1",
sessionType: "sub_agent",
runtimeStatus: "running",
},
isChildSession: true,
currentSessionId: "child-1",
});
expect(state.statusSummary).toEqual({
running: 1,
queued: 1,
});
expect(state.waitableSessionIds).toEqual(["child-1", "child-2"]);
expect(state.completedSessionIds).toEqual([]);
});
it("应忽略 user 与 terminal session 的 waitable 判定,并排除当前会话的 completed 聚合", () => {
const state = buildTeamWorkspaceSessionControlState({
visibleSessions: [
{
id: "parent-1",
sessionType: "user",
runtimeStatus: "running",
},
{
id: "child-1",
sessionType: "sub_agent",
runtimeStatus: "completed",
},
{
id: "child-2",
sessionType: "sub_agent",
runtimeStatus: "failed",
},
{
id: "child-3",
sessionType: "sub_agent",
runtimeStatus: "queued",
},
],
railSessions: [
{
id: "parent-1",
sessionType: "user",
runtimeStatus: "running",
},
{
id: "child-1",
sessionType: "sub_agent",
runtimeStatus: "completed",
},
{
id: "child-2",
sessionType: "sub_agent",
runtimeStatus: "failed",
},
{
id: "child-3",
sessionType: "sub_agent",
runtimeStatus: "queued",
},
],
isChildSession: false,
currentSessionId: "child-1",
});
expect(state.waitableSessionIds).toEqual(["child-3"]);
expect(state.completedSessionIds).toEqual(["child-2"]);
});
it("应暴露单条 session 的 waitable / completed 判断", () => {
expect(
isWaitableTeamSession({
id: "child-1",
sessionType: "sub_agent",
runtimeStatus: "running",
}),
).toBe(true);
expect(
isWaitableTeamSession({
id: "parent-1",
sessionType: "user",
runtimeStatus: "running",
}),
).toBe(false);
expect(
isCompletedTeamSession({
id: "child-1",
sessionType: "sub_agent",
runtimeStatus: "aborted",
}),
).toBe(true);
});
});
@@ -0,0 +1,87 @@
import type { TeamWorkspaceRuntimeStatus } from "../teamWorkspaceRuntime";
export interface TeamWorkspaceSessionStateSnapshot {
id: string;
sessionType?: string;
runtimeStatus?: TeamWorkspaceRuntimeStatus;
}
export interface TeamWorkspaceSessionControlState {
statusSummary: Record<string, number>;
waitableSessionIds: string[];
completedSessionIds: string[];
}
function dedupeSessions<T extends TeamWorkspaceSessionStateSnapshot>(
sessions: Array<T | null | undefined>,
): T[] {
const seen = new Set<string>();
const result: T[] = [];
sessions.forEach((session) => {
if (!session || seen.has(session.id)) {
return;
}
seen.add(session.id);
result.push(session);
});
return result;
}
export function isWaitableTeamSession(
session?: TeamWorkspaceSessionStateSnapshot | null,
) {
return Boolean(
session &&
session.sessionType !== "user" &&
session.runtimeStatus !== "completed" &&
session.runtimeStatus !== "failed" &&
session.runtimeStatus !== "aborted" &&
session.runtimeStatus !== "closed",
);
}
export function isCompletedTeamSession(
session?: TeamWorkspaceSessionStateSnapshot | null,
) {
return (
session?.runtimeStatus === "completed" ||
session?.runtimeStatus === "failed" ||
session?.runtimeStatus === "aborted"
);
}
export function buildTeamWorkspaceSessionControlState(
params: {
visibleSessions: TeamWorkspaceSessionStateSnapshot[];
railSessions: TeamWorkspaceSessionStateSnapshot[];
currentChildSession?: TeamWorkspaceSessionStateSnapshot | null;
isChildSession: boolean;
currentSessionId?: string | null;
},
): TeamWorkspaceSessionControlState {
const statusSummarySessions = params.isChildSession
? dedupeSessions([params.currentChildSession, ...params.visibleSessions])
: params.visibleSessions;
return {
statusSummary: statusSummarySessions.reduce(
(summary, session) => {
const key = session.runtimeStatus ?? "idle";
summary[key] = (summary[key] ?? 0) + 1;
return summary;
},
{} as Record<string, number>,
),
waitableSessionIds: params.railSessions
.filter((session) => isWaitableTeamSession(session))
.map((session) => session.id),
completedSessionIds: params.railSessions
.filter(
(session) =>
session.id !== params.currentSessionId && isCompletedTeamSession(session),
)
.map((session) => session.id),
};
}
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import {
buildTeamOperationDisplayEntries,
buildVisibleTeamOperationState,
formatOperationUpdatedAt,
} from "./teamOperationSelectors";
describe("teamOperationSelectors", () => {
it("wait 命中结果时应生成聚合结果条目", () => {
const entries = buildTeamOperationDisplayEntries({
sessionNameById: new Map([
["child-1", "研究员"],
["child-2", "执行器"],
]),
teamWaitSummary: {
awaitedSessionIds: ["child-1", "child-2"],
timedOut: false,
resolvedSessionId: "child-2",
resolvedStatus: "completed",
updatedAt: 1_710_000_100_000,
},
});
expect(entries).toEqual([
{
id: "wait-1710000100000",
title: "收到结果",
detail: "刚才等到 执行器 返回了新结果,当前状态为已完成。",
badgeClassName:
"border border-emerald-200 bg-emerald-50 text-emerald-700",
updatedAt: 1_710_000_100_000,
targetSessionId: "child-2",
},
]);
});
it("control 汇总应按 action 生成稳定标题与文案", () => {
const entries = buildTeamOperationDisplayEntries({
sessionNameById: new Map([["child-1", "研究员"]]),
teamControlSummary: {
action: "close",
requestedSessionIds: ["child-1"],
cascadeSessionIds: [],
affectedSessionIds: ["child-1"],
updatedAt: 1_710_000_200_000,
},
});
expect(entries).toEqual([
{
id: "control-close-1710000200000",
title: "暂停处理",
detail: "刚才已暂停 研究员 的处理。",
badgeClassName:
"border border-slate-200 bg-slate-100 text-slate-700",
updatedAt: 1_710_000_200_000,
targetSessionId: "child-1",
},
]);
});
it("应按更新时间倒序排序,并提供操作时间文案格式化", () => {
const entries = buildTeamOperationDisplayEntries({
sessionNameById: new Map([["child-1", "研究员"]]),
teamWaitSummary: {
awaitedSessionIds: ["child-1"],
timedOut: true,
updatedAt: 1_710_000_100_000,
},
teamControlSummary: {
action: "resume",
requestedSessionIds: ["child-1"],
cascadeSessionIds: [],
affectedSessionIds: ["child-1"],
updatedAt: 1_710_000_200_000,
},
});
expect(entries.map((entry) => entry.id)).toEqual([
"control-resume-1710000200000",
"wait-1710000100000",
]);
expect(formatOperationUpdatedAt()).toBe("刚刚");
});
it("应只保留当前画布可见的 team operation 条目", () => {
const state = buildVisibleTeamOperationState({
railSessions: [{ id: "child-1", name: "研究员" }],
teamWaitSummary: {
awaitedSessionIds: ["child-1"],
timedOut: false,
resolvedSessionId: "child-2",
resolvedStatus: "completed",
updatedAt: 1_710_000_100_000,
},
teamControlSummary: {
action: "close",
requestedSessionIds: ["child-2"],
cascadeSessionIds: [],
affectedSessionIds: ["child-1"],
updatedAt: 1_710_000_200_000,
},
});
expect(state.visibleTeamWaitSummary?.resolvedSessionId).toBe("child-2");
expect(state.visibleTeamControlSummary?.affectedSessionIds).toEqual([
"child-1",
]);
expect(state.entries).toEqual([
{
id: "control-close-1710000200000",
title: "暂停处理",
detail: "刚才已暂停 研究员 的处理。",
badgeClassName:
"border border-slate-200 bg-slate-100 text-slate-700",
updatedAt: 1_710_000_200_000,
targetSessionId: "child-1",
},
]);
});
it("未命中当前画布 session 时应隐藏 team operation 摘要", () => {
const state = buildVisibleTeamOperationState({
railSessions: [{ id: "child-1", name: "研究员" }],
teamWaitSummary: {
awaitedSessionIds: ["child-2"],
timedOut: true,
updatedAt: 1_710_000_100_000,
},
teamControlSummary: {
action: "resume",
requestedSessionIds: ["child-2"],
cascadeSessionIds: [],
affectedSessionIds: ["child-2"],
updatedAt: 1_710_000_200_000,
},
});
expect(state.visibleTeamWaitSummary).toBeNull();
expect(state.visibleTeamControlSummary).toBeNull();
expect(state.entries).toEqual([]);
});
});
@@ -0,0 +1,210 @@
import { formatRelativeTime } from "@/lib/api/project";
import {
normalizeTeamWorkspaceRuntimeStatus,
type TeamWorkspaceControlSummary,
type TeamWorkspaceWaitSummary,
} from "../teamWorkspaceRuntime";
import { resolveTeamWorkspaceRuntimeStatusLabel } from "../teamWorkspaceRuntime";
export interface TeamOperationDisplayEntry {
id: string;
title: string;
detail: string;
badgeClassName: string;
updatedAt: number;
targetSessionId?: string;
}
export interface TeamWorkspaceOperationSessionSnapshot {
id: string;
name: string;
}
export interface TeamWorkspaceVisibleOperationState {
visibleTeamWaitSummary: TeamWorkspaceWaitSummary | null;
visibleTeamControlSummary: TeamWorkspaceControlSummary | null;
entries: TeamOperationDisplayEntry[];
}
const STATUS_BADGE_CLASS_NAME = {
idle: "border border-slate-200 bg-white text-slate-600",
queued: "border border-amber-200 bg-amber-50 text-amber-700",
running: "border border-sky-200 bg-sky-50 text-sky-700",
completed: "border border-emerald-200 bg-emerald-50 text-emerald-700",
failed: "border border-rose-200 bg-rose-50 text-rose-700",
aborted: "border border-rose-200 bg-rose-50 text-rose-700",
closed: "border border-slate-200 bg-slate-100 text-slate-600",
} as const;
function resolveStatusBadgeClassName(
status?: keyof typeof STATUS_BADGE_CLASS_NAME,
) {
return STATUS_BADGE_CLASS_NAME[status ?? "idle"];
}
export function formatOperationUpdatedAt(updatedAt?: number) {
if (!updatedAt) {
return "刚刚";
}
return formatRelativeTime(updatedAt);
}
function buildTeamWaitSummaryDisplay(params: {
summary: TeamWorkspaceWaitSummary;
sessionNameById: Map<string, string>;
}) {
if (params.summary.timedOut) {
return {
text: `刚才等待结果时超时了,还有 ${params.summary.awaitedSessionIds.length} 位协作成员仍在处理中。`,
badgeClassName: "border border-amber-200 bg-amber-50 text-amber-700",
};
}
const resolvedName = params.summary.resolvedSessionId
? (params.sessionNameById.get(params.summary.resolvedSessionId) ??
params.summary.resolvedSessionId)
: "成员";
const normalizedStatus = params.summary.resolvedStatus
? normalizeTeamWorkspaceRuntimeStatus(params.summary.resolvedStatus)
: undefined;
return {
text: `刚才等到 ${resolvedName} 返回了新结果,当前状态为${resolveTeamWorkspaceRuntimeStatusLabel(params.summary.resolvedStatus)}。`,
badgeClassName: resolveStatusBadgeClassName(normalizedStatus),
};
}
function buildTeamControlSummaryDisplay(params: {
summary: TeamWorkspaceControlSummary;
sessionNameById: Map<string, string>;
}) {
const affectedCount = params.summary.affectedSessionIds.length;
const firstAffectedId = params.summary.affectedSessionIds[0];
const firstAffectedName = firstAffectedId
? (params.sessionNameById.get(firstAffectedId) ?? firstAffectedId)
: "成员";
switch (params.summary.action) {
case "resume":
return {
text:
affectedCount > 1
? `刚才已继续 ${affectedCount} 位协作成员的处理。`
: `刚才已继续 ${firstAffectedName} 的处理。`,
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
};
case "close_completed":
return {
text: `刚才已收起 ${affectedCount} 位已完成成员。`,
badgeClassName: "border border-slate-200 bg-slate-100 text-slate-700",
};
case "close":
default:
return {
text:
affectedCount > 1
? `刚才已暂停 ${affectedCount} 位协作成员的处理。`
: `刚才已暂停 ${firstAffectedName} 的处理。`,
badgeClassName: "border border-slate-200 bg-slate-100 text-slate-700",
};
}
}
export function buildTeamOperationDisplayEntries(params: {
sessionNameById: Map<string, string>;
teamWaitSummary?: TeamWorkspaceWaitSummary | null;
teamControlSummary?: TeamWorkspaceControlSummary | null;
}): TeamOperationDisplayEntry[] {
const entries: TeamOperationDisplayEntry[] = [];
if (params.teamWaitSummary) {
const display = buildTeamWaitSummaryDisplay({
summary: params.teamWaitSummary,
sessionNameById: params.sessionNameById,
});
entries.push({
id: `wait-${params.teamWaitSummary.updatedAt}`,
title: params.teamWaitSummary.timedOut ? "等待超时" : "收到结果",
detail: display.text,
badgeClassName: display.badgeClassName,
updatedAt: params.teamWaitSummary.updatedAt,
targetSessionId:
params.teamWaitSummary.resolvedSessionId ??
params.teamWaitSummary.awaitedSessionIds[0],
});
}
if (params.teamControlSummary) {
const display = buildTeamControlSummaryDisplay({
summary: params.teamControlSummary,
sessionNameById: params.sessionNameById,
});
const title = (() => {
switch (params.teamControlSummary.action) {
case "resume":
return "继续处理";
case "close_completed":
return "收起完成项";
case "close":
default:
return "暂停处理";
}
})();
entries.push({
id: `control-${params.teamControlSummary.action}-${params.teamControlSummary.updatedAt}`,
title,
detail: display.text,
badgeClassName: display.badgeClassName,
updatedAt: params.teamControlSummary.updatedAt,
targetSessionId:
params.teamControlSummary.affectedSessionIds[0] ??
params.teamControlSummary.requestedSessionIds[0],
});
}
return entries.sort((left, right) => right.updatedAt - left.updatedAt);
}
export function buildVisibleTeamOperationState(params: {
railSessions: TeamWorkspaceOperationSessionSnapshot[];
teamWaitSummary?: TeamWorkspaceWaitSummary | null;
teamControlSummary?: TeamWorkspaceControlSummary | null;
}): TeamWorkspaceVisibleOperationState {
const sessionNameById = new Map(
params.railSessions.map((session) => [session.id, session.name]),
);
const visibleSessionIds = new Set(
params.railSessions.map((session) => session.id),
);
const visibleTeamWaitSummary =
params.teamWaitSummary &&
params.teamWaitSummary.awaitedSessionIds.some((sessionId) =>
sessionNameById.has(sessionId),
)
? params.teamWaitSummary
: null;
const visibleTeamControlSummary =
params.teamControlSummary &&
[
...params.teamControlSummary.requestedSessionIds,
...params.teamControlSummary.affectedSessionIds,
].some((sessionId) => sessionNameById.has(sessionId))
? params.teamControlSummary
: null;
return {
visibleTeamWaitSummary,
visibleTeamControlSummary,
entries: buildTeamOperationDisplayEntries({
sessionNameById,
teamWaitSummary: visibleTeamWaitSummary,
teamControlSummary: visibleTeamControlSummary,
}).filter(
(entry) =>
!entry.targetSessionId || visibleSessionIds.has(entry.targetSessionId),
),
};
}
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { isHiddenInternalArtifactPath } from "./internalArtifactVisibility";
import {
isHiddenConversationArtifactPath,
isHiddenInternalArtifactPath,
} from "./internalArtifactVisibility";
describe("isHiddenInternalArtifactPath", () => {
it("应隐藏 .lime/tasks 下的内部任务快照 JSON", () => {
@@ -28,4 +31,23 @@ describe("isHiddenInternalArtifactPath", () => {
false,
);
});
it("聊天区应隐藏 .lime/artifacts 下的内部 artifact 文稿 JSON", () => {
expect(
isHiddenConversationArtifactPath(
".lime/artifacts/thread-1/report.artifact.json",
),
).toBe(true);
expect(
isHiddenConversationArtifactPath(
"/workspace/demo/.lime/artifacts/thread-1/report.artifact.json",
),
).toBe(true);
expect(isHiddenConversationArtifactPath("content-posts/demo.publish-pack.json")).toBe(
false,
);
expect(isHiddenConversationArtifactPath("exports/x-article/google/index.md")).toBe(
false,
);
});
});
@@ -15,3 +15,23 @@ export function isHiddenInternalArtifactPath(path?: string | null): boolean {
normalizedPath.includes("/.lime/tasks/")
);
}
export function isHiddenConversationArtifactPath(
path?: string | null,
): boolean {
const normalizedPath = normalizeArtifactPath(path);
if (!normalizedPath) {
return false;
}
if (isHiddenInternalArtifactPath(normalizedPath)) {
return true;
}
const isInternalArtifactDocument =
normalizedPath.endsWith(".artifact.json") &&
(normalizedPath.startsWith(".lime/artifacts/") ||
normalizedPath.includes("/.lime/artifacts/"));
return isInternalArtifactDocument;
}
@@ -0,0 +1,212 @@
import { describe, expect, it } from "vitest";
import type { Message } from "../types";
import { resolveLatestProjectFileSavedSiteContentTargetFromMessages } from "./latestSavedSiteContentTarget";
function createAssistantMessage(
overrides: Partial<Message> = {},
): Message {
return {
id: overrides.id || "assistant-message",
role: "assistant",
content: overrides.content || "",
timestamp: overrides.timestamp || new Date("2026-04-09T12:00:00.000Z"),
toolCalls: overrides.toolCalls,
contentParts: overrides.contentParts,
images: overrides.images,
isThinking: overrides.isThinking,
thinkingContent: overrides.thinkingContent,
usage: overrides.usage,
actionRequests: overrides.actionRequests,
contextTrace: overrides.contextTrace,
artifacts: overrides.artifacts,
imageWorkbenchPreview: overrides.imageWorkbenchPreview,
taskPreview: overrides.taskPreview,
runtimeStatus: overrides.runtimeStatus,
purpose: overrides.purpose,
};
}
describe("latestSavedSiteContentTarget", () => {
it("应从最新完成的站点工具结果里提取 project_file 目标", () => {
const match = resolveLatestProjectFileSavedSiteContentTargetFromMessages([
createAssistantMessage({
id: "assistant-1",
timestamp: new Date("2026-04-09T12:00:00.000Z"),
toolCalls: [
{
id: "tool-site-1",
name: "site_run_adapter",
status: "completed",
startTime: new Date("2026-04-09T12:00:00.000Z"),
endTime: new Date("2026-04-09T12:00:01.000Z"),
result: {
success: true,
output: "ok",
metadata: {
tool_family: "site",
saved_content: {
content_id: "content-1",
project_id: "project-1",
markdown_relative_path:
"exports/x-article-export/article/index.md",
},
},
},
},
],
}),
]);
expect(match).toEqual({
messageId: "assistant-1",
toolCallId: "tool-site-1",
messageTimestampMs: new Date("2026-04-09T12:00:00.000Z").getTime(),
target: {
projectId: "project-1",
contentId: "content-1",
preferredTarget: "project_file",
projectFile: {
relativePath: "exports/x-article-export/article/index.md",
},
},
});
});
it("应优先使用 contentParts 里的 tool_use,并忽略仅有 saved_content 但没有 markdown 的结果", () => {
const match = resolveLatestProjectFileSavedSiteContentTargetFromMessages([
createAssistantMessage({
id: "assistant-1",
timestamp: new Date("2026-04-09T12:00:00.000Z"),
toolCalls: [
{
id: "tool-site-older",
name: "site_run_adapter",
status: "completed",
startTime: new Date("2026-04-09T12:00:00.000Z"),
endTime: new Date("2026-04-09T12:00:01.000Z"),
result: {
success: true,
output: "ok",
metadata: {
tool_family: "site",
saved_content: {
content_id: "content-older",
project_id: "project-1",
},
},
},
},
],
contentParts: [
{
type: "tool_use",
toolCall: {
id: "tool-site-latest",
name: "site_run_adapter",
status: "completed",
startTime: new Date("2026-04-09T12:02:00.000Z"),
endTime: new Date("2026-04-09T12:02:01.000Z"),
result: {
success: true,
output: "ok",
metadata: {
tool_family: "site",
saved_content: {
content_id: "content-latest",
project_id: "project-1",
markdown_relative_path:
"exports/x-article-export/latest/index.md",
},
},
},
},
},
],
}),
]);
expect(match?.toolCallId).toBe("tool-site-latest");
expect(match?.target.projectFile?.relativePath).toBe(
"exports/x-article-export/latest/index.md",
);
});
it("没有 project_file 保存结果时应返回 null", () => {
const match = resolveLatestProjectFileSavedSiteContentTargetFromMessages([
createAssistantMessage({
toolCalls: [
{
id: "tool-search-1",
name: "ToolSearch",
status: "completed",
startTime: new Date("2026-04-09T12:00:00.000Z"),
endTime: new Date("2026-04-09T12:00:01.000Z"),
result: {
success: true,
output: "ok",
},
},
],
}),
]);
expect(match).toBeNull();
});
it("应忽略 preload 站点结果,优先选择真正完成后的导出文件", () => {
const match = resolveLatestProjectFileSavedSiteContentTargetFromMessages([
createAssistantMessage({
id: "assistant-preload-filter",
timestamp: new Date("2026-04-09T13:00:00.000Z"),
toolCalls: [
{
id: "tool-site-final",
name: "lime_site_run",
status: "completed",
startTime: new Date("2026-04-09T13:00:00.000Z"),
endTime: new Date("2026-04-09T13:00:01.000Z"),
result: {
success: true,
output: "ok",
metadata: {
tool_family: "site",
saved_content: {
content_id: "content-final",
project_id: "project-final",
markdown_relative_path:
"exports/x-article-export/final/index.md",
},
},
},
},
{
id: "tool-site-preload",
name: "lime_site_run",
status: "completed",
startTime: new Date("2026-04-09T13:00:02.000Z"),
endTime: new Date("2026-04-09T13:00:03.000Z"),
result: {
success: true,
output: "ok",
metadata: {
tool_family: "site",
execution_origin: "preload",
saved_content: {
content_id: "content-preload",
project_id: "project-final",
markdown_relative_path:
"exports/x-article-export/preload/index.md",
},
},
},
},
],
}),
]);
expect(match?.toolCallId).toBe("tool-site-final");
expect(match?.target.projectFile?.relativePath).toBe(
"exports/x-article-export/final/index.md",
);
});
});
@@ -0,0 +1,92 @@
import type { Message, SiteSavedContentTarget } from "../types";
import {
resolveSiteSavedContentTargetFromMetadata,
} from "./siteToolResultSummary";
export interface LatestSavedSiteContentTargetMatch {
messageId: string;
toolCallId: string;
messageTimestampMs: number;
target: SiteSavedContentTarget;
}
function collectAssistantToolCalls(
message: Message,
): NonNullable<Message["toolCalls"]> {
const merged = new Map<string, NonNullable<Message["toolCalls"]>[number]>();
for (const toolCall of message.toolCalls || []) {
merged.set(toolCall.id, toolCall);
}
for (const part of message.contentParts || []) {
if (part.type !== "tool_use") {
continue;
}
merged.set(part.toolCall.id, part.toolCall);
}
return Array.from(merged.values());
}
export function resolveLatestProjectFileSavedSiteContentTargetFromMessage(
message: Message,
): SiteSavedContentTarget | null {
if (message.role !== "assistant") {
return null;
}
const toolCalls = collectAssistantToolCalls(message);
for (let toolIndex = toolCalls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = toolCalls[toolIndex];
if (toolCall.status !== "completed") {
continue;
}
const target = resolveSiteSavedContentTargetFromMetadata(
toolCall.result?.metadata,
);
if (target?.preferredTarget !== "project_file") {
continue;
}
return target;
}
return null;
}
export function resolveLatestProjectFileSavedSiteContentTargetFromMessages(
messages: Message[],
): LatestSavedSiteContentTargetMatch | null {
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = messages[messageIndex];
if (message.role !== "assistant") {
continue;
}
const toolCalls = collectAssistantToolCalls(message);
for (let toolIndex = toolCalls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = toolCalls[toolIndex];
if (toolCall.status !== "completed") {
continue;
}
const target = resolveSiteSavedContentTargetFromMetadata(
toolCall.result?.metadata,
);
if (target?.preferredTarget !== "project_file") {
continue;
}
return {
messageId: message.id,
toolCallId: toolCall.id,
messageTimestampMs: message.timestamp.getTime(),
target,
};
}
}
return null;
}
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import {
hasMeaningfulSiteToolResultSignal,
isPreloadSiteToolResultMetadata,
normalizeSiteToolResultSummary,
resolveSiteSavedContentTarget,
resolveSiteSavedContentTargetFromMetadata,
resolveSiteSavedContentTargetFromRunResult,
} from "./siteToolResultSummary";
@@ -48,4 +52,70 @@ describe("siteToolResultSummary", () => {
},
});
});
it("应直接从 metadata 解析 saved content target", () => {
expect(
resolveSiteSavedContentTargetFromMetadata({
tool_family: "site",
saved_content: {
content_id: "content-x-2b",
project_id: "project-x-2b",
markdown_relative_path: "exports/x-article-export/direct/index.md",
},
}),
).toEqual({
projectId: "project-x-2b",
contentId: "content-x-2b",
preferredTarget: "project_file",
projectFile: {
relativePath: "exports/x-article-export/direct/index.md",
},
});
});
it("系统 preload 结果不应被视为最终可打开的保存目标", () => {
expect(
resolveSiteSavedContentTargetFromMetadata({
tool_family: "site",
execution_origin: "preload",
saved_content: {
content_id: "content-preload-1",
project_id: "project-preload-1",
markdown_relative_path: "exports/x-article-export/preload/index.md",
},
}),
).toBeNull();
expect(
isPreloadSiteToolResultMetadata({
tool_family: "site",
preload: true,
}),
).toBe(true);
});
it("应将保存相关 metadata 识别为有意义结果信号", () => {
expect(
hasMeaningfulSiteToolResultSignal({
tool_family: "site",
saved_content: {
content_id: "content-x-2c",
project_id: "project-x-2c",
},
}),
).toBe(true);
expect(hasMeaningfulSiteToolResultSignal(undefined)).toBe(false);
});
it("应将 legacy 项目目录展示规范到 lime 主路径", () => {
expect(
normalizeSiteToolResultSummary({
tool_family: "site",
saved_content: {
content_id: "content-x-3",
project_id: "project-x-3",
project_root_path: "/Users/coso/.proxycast/projects/default",
},
})?.savedContent?.projectRootPath,
).toBe("/Users/coso/Library/Application Support/lime/projects/default");
});
});
@@ -1,5 +1,6 @@
import type { SiteAdapterRunResult } from "@/lib/webview-api";
import type { SiteSavedContentTarget } from "../types";
import { normalizeManagedWorkspacePathForDisplay } from "../workspace/workspacePath";
export interface SiteToolResultSummary {
savedContent?: {
@@ -80,6 +81,39 @@ function readFirstFiniteNumber(
return undefined;
}
function readFirstBoolean(
candidates: Array<Record<string, unknown> | null | undefined>,
keys: string[],
): boolean | undefined {
for (const candidate of candidates) {
if (!candidate) continue;
for (const key of keys) {
const value = candidate[key];
if (typeof value === "boolean") {
return value;
}
}
}
return undefined;
}
export function isPreloadSiteToolResultMetadata(rawMetadata: unknown): boolean {
const metadata = normalizeToolResultMetadata(rawMetadata);
if (!metadata) {
return false;
}
const metadataResult = asRecord(metadata.result);
const candidates = [metadata, metadataResult];
return (
readFirstNonEmptyString(candidates, [
"execution_origin",
"executionOrigin",
]) === "preload" ||
readFirstBoolean(candidates, ["preload"]) === true
);
}
export function resolveSiteSavedContentTarget(
summary: SiteToolResultSummary | null,
): SiteSavedContentTarget | null {
@@ -108,10 +142,56 @@ export function resolveSiteSavedContentTarget(
};
}
export function resolveSiteSavedContentTargetFromMetadata(
rawMetadata: unknown,
): SiteSavedContentTarget | null {
if (isPreloadSiteToolResultMetadata(rawMetadata)) {
return null;
}
return resolveSiteSavedContentTarget(normalizeSiteToolResultSummary(rawMetadata));
}
export function hasMeaningfulSiteToolResultSignal(
rawMetadata: unknown,
): boolean {
const summary = normalizeSiteToolResultSummary(rawMetadata);
return Boolean(
summary?.savedContent ||
summary?.savedProjectId ||
summary?.saveSkippedProjectId ||
summary?.saveErrorMessage,
);
}
export function resolveSiteSavedContentTargetRelativePath(
target: SiteSavedContentTarget | null | undefined,
): string | null {
const relativePath = target?.projectFile?.relativePath?.trim();
return relativePath || null;
}
export function resolveSiteSavedContentTargetDisplayName(
target: SiteSavedContentTarget | null | undefined,
): string | null {
const relativePath = resolveSiteSavedContentTargetRelativePath(target);
if (relativePath) {
const normalized = relativePath.replace(/\\/g, "/");
const segments = normalized.split("/").filter(Boolean);
return segments.at(-1) || normalized;
}
const title = target?.title?.trim();
return title || null;
}
export function resolveSiteSavedContentTargetFromRunResult(
result: Pick<SiteAdapterRunResult, "saved_content" | "saved_project_id"> | null,
): SiteSavedContentTarget | null {
const savedContent = result?.saved_content;
if (!savedContent) {
return null;
}
const contentId = savedContent?.content_id?.trim();
if (!contentId) {
return null;
@@ -210,10 +290,13 @@ export function normalizeSiteToolResultSummary(
["project_id", "projectId"],
),
title: readFirstNonEmptyString([savedContentRecord], ["title"]),
projectRootPath: readFirstNonEmptyString(
[savedContentRecord],
["project_root_path", "projectRootPath"],
),
projectRootPath:
normalizeManagedWorkspacePathForDisplay(
readFirstNonEmptyString([savedContentRecord], [
"project_root_path",
"projectRootPath",
]),
) || undefined,
bundleRelativeDir: readFirstNonEmptyString(
[savedContentRecord],
["bundle_relative_dir", "bundleRelativeDir"],
@@ -2,6 +2,7 @@ import type { AsterSubagentSkillInfo } from "@/lib/api/agentRuntime";
import { STABLE_PROCESSING_LABEL } from "./stableProcessingExperience";
export type TeamWorkspaceDisplayRuntimeStatus =
| "idle"
| "queued"
| "running"
| "completed"
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import {
normalizeToolSearchResultSummary,
resolveToolSearchItemSourceLabel,
resolveToolSearchItemStatusLabel,
} from "./toolSearchResultSummary";
describe("toolSearchResultSummary", () => {
it("应解析 bridge 版 ToolSearch 结果", () => {
const summary = normalizeToolSearchResultSummary(
JSON.stringify({
query: "select:Read,Write",
caller: "assistant",
count: 2,
notes: [],
tools: [
{
name: "Read",
source: "native_registry",
description: "read a file",
deferred_loading: false,
always_visible: true,
},
{
name: "mcp__playwright__browser_click",
source: "extension",
extension_name: "mcp__playwright",
status: "deferred",
deferred_loading: true,
},
],
}),
);
expect(summary).toEqual({
query: "select:Read,Write",
caller: "assistant",
count: 2,
notes: [],
tools: [
{
name: "Read",
source: "native_registry",
description: "read a file",
deferredLoading: false,
alwaysVisible: true,
},
{
name: "mcp__playwright__browser_click",
source: "extension",
extensionName: "mcp__playwright",
status: "deferred",
deferredLoading: true,
},
],
});
});
it("应兼容旧 ToolSearch matches 结果", () => {
const summary = normalizeToolSearchResultSummary(
JSON.stringify({
query: "browser click",
matches: ["mcp__playwright__browser_click"],
notes: ["未命中任何 deferred 工具"],
total_deferred_tools: 12,
}),
);
expect(summary).toEqual({
query: "browser click",
count: 1,
notes: ["未命中任何 deferred 工具"],
tools: [{ name: "mcp__playwright__browser_click" }],
totalDeferredTools: 12,
});
});
it("应生成来源与状态标签", () => {
expect(
resolveToolSearchItemSourceLabel({
name: "Read",
source: "native_registry",
}),
).toBe("原生工具");
expect(
resolveToolSearchItemSourceLabel({
name: "mcp__playwright__browser_click",
source: "extension",
extensionName: "mcp__playwright",
}),
).toBe("扩展工具 · mcp__playwright");
expect(
resolveToolSearchItemStatusLabel({
name: "mcp__playwright__browser_click",
status: "deferred",
deferredLoading: true,
}),
).toBe("待加载");
});
});
@@ -0,0 +1,166 @@
export interface ToolSearchResultItemSummary {
name: string;
description?: string;
source?: string;
extensionName?: string;
status?: string;
deferredLoading?: boolean;
alwaysVisible?: boolean;
}
export interface ToolSearchResultSummary {
query?: string;
caller?: string;
count: number;
notes: string[];
tools: ToolSearchResultItemSummary[];
totalDeferredTools?: number;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function readFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => readString(item))
.filter((item): item is string => Boolean(item));
}
function normalizeToolRecord(
value: unknown,
): ToolSearchResultItemSummary | null {
if (typeof value === "string" && value.trim()) {
return { name: value.trim() };
}
const record = asRecord(value);
if (!record) {
return null;
}
const name = readString(record.name);
if (!name) {
return null;
}
return {
name,
description: readString(record.description),
source: readString(record.source),
extensionName: readString(
record.extension_name ?? record.extensionName,
),
status: readString(record.status),
deferredLoading: readBoolean(
record.deferred_loading ?? record.deferredLoading,
),
alwaysVisible: readBoolean(
record.always_visible ?? record.alwaysVisible,
),
};
}
export function normalizeToolSearchResultSummary(
rawText: string | null | undefined,
): ToolSearchResultSummary | null {
const trimmed = rawText?.trim();
if (!trimmed) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
const record = asRecord(parsed);
if (!record) {
return null;
}
const parsedTools = Array.isArray(record.tools)
? record.tools
.map((item) => normalizeToolRecord(item))
.filter((item): item is ToolSearchResultItemSummary => Boolean(item))
: [];
const legacyMatches = Array.isArray(record.matches)
? record.matches
.map((item) => normalizeToolRecord(item))
.filter((item): item is ToolSearchResultItemSummary => Boolean(item))
: [];
const tools = parsedTools.length > 0 ? parsedTools : legacyMatches;
const notes = readStringArray(record.notes);
const query = readString(record.query);
const caller = readString(record.caller);
const count = readFiniteNumber(record.count) ?? tools.length;
const totalDeferredTools = readFiniteNumber(
record.total_deferred_tools ?? record.totalDeferredTools,
);
if (!query && tools.length === 0 && notes.length === 0 && count === 0) {
return null;
}
return {
query,
caller,
count,
notes,
tools,
totalDeferredTools,
};
}
export function resolveToolSearchItemSourceLabel(
item: ToolSearchResultItemSummary,
): string | null {
if (item.source === "native_registry") {
return "原生工具";
}
if (item.source === "extension") {
return item.extensionName ? `扩展工具 · ${item.extensionName}` : "扩展工具";
}
return null;
}
export function resolveToolSearchItemStatusLabel(
item: ToolSearchResultItemSummary,
): string | null {
if (item.status === "loaded") {
return "已加载";
}
if (item.status === "visible") {
return "默认可见";
}
if (item.status === "deferred") {
return "待加载";
}
if (item.deferredLoading) {
return "延迟加载";
}
if (item.alwaysVisible) {
return "始终可见";
}
return null;
}
@@ -115,7 +115,7 @@ describe("ServiceSkillExecutionCard", () => {
expect(container.textContent).toContain("已沉淀内容:GitHub 仓库线索");
expect(container.textContent).toContain(
"项目目录:/Users/coso/.proxycast/projects/project-1",
"项目目录:/Users/coso/Library/Application Support/lime/projects/project-1",
);
expect(container.textContent).toContain(
"Markdown 文件:exports/x-article-export/github-mcp/index.md",
@@ -161,7 +161,7 @@ describe("ServiceSkillExecutionCard", () => {
'[data-testid="service-skill-execution-open-saved-content"]',
) as HTMLButtonElement | null;
expect(button?.textContent).toContain("在下方预览导出 Markdown");
expect(button?.textContent).toContain("查看采集源 Markdown");
act(() => {
button?.click();
@@ -177,4 +177,51 @@ describe("ServiceSkillExecutionCard", () => {
},
});
});
it("存在更正式的结果文件时应优先打开结果文件", () => {
const onOpenResultFile = vi.fn();
const onOpenSavedSiteContent = vi.fn();
const container = renderCard({
onOpenResultFile,
onOpenSavedSiteContent,
preferredResultFileTarget: {
relativePath: "workspace/index.md",
title: "index.md",
},
state: {
phase: "success",
adapterName: "x/article-export",
skillTitle: "X 文章转存",
message: "站点技能已完成,后续技能包已落盘",
result: {
ok: true,
adapter: "x/article-export",
domain: "x.com",
profile_key: "attached-x",
entry_url:
"https://x.com/GoogleCloudTech/article/2033953579824758855",
saved_content: {
content_id: "content-article-1",
project_id: "project-article-1",
title: "Google Cloud Tech 文章导出",
markdown_relative_path:
"exports/x-article-export/google-cloud/index.md",
},
},
},
});
const button = container.querySelector(
'[data-testid="service-skill-execution-open-saved-content"]',
) as HTMLButtonElement | null;
expect(button?.textContent).toContain("打开结果文件 index.md");
act(() => {
button?.click();
});
expect(onOpenResultFile).toHaveBeenCalledWith("workspace/index.md");
expect(onOpenSavedSiteContent).not.toHaveBeenCalled();
});
});
@@ -3,11 +3,17 @@ import { cn } from "@/lib/utils";
import type { SiteSavedContentTarget } from "../types";
import { resolveSiteSavedContentTargetFromRunResult } from "../utils/siteToolResultSummary";
import type { SiteSkillExecutionState } from "./useWorkspaceBrowserAssistRuntime";
import { normalizeManagedWorkspacePathForDisplay } from "./workspacePath";
interface ServiceSkillExecutionCardProps {
state: SiteSkillExecutionState | null;
onOpenBrowserRuntime?: () => void;
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
preferredResultFileTarget?: {
relativePath: string;
title?: string;
} | null;
onOpenResultFile?: (relativePath: string) => void;
}
const PHASE_LABELS: Record<SiteSkillExecutionState["phase"], string> = {
@@ -28,14 +34,17 @@ export function ServiceSkillExecutionCard({
state,
onOpenBrowserRuntime,
onOpenSavedSiteContent,
preferredResultFileTarget = null,
onOpenResultFile,
}: ServiceSkillExecutionCardProps) {
if (!state) {
return null;
}
const resultTitle = state.result?.saved_content?.title?.trim();
const projectRootPath =
state.result?.saved_content?.project_root_path?.trim() || "";
const projectRootPath = normalizeManagedWorkspacePathForDisplay(
state.result?.saved_content?.project_root_path,
);
const markdownRelativePath =
state.result?.saved_content?.markdown_relative_path?.trim() || "";
const imagesRelativeDir =
@@ -44,9 +53,11 @@ export function ServiceSkillExecutionCard({
const savedSiteContentTarget: SiteSavedContentTarget | null =
resolveSiteSavedContentTargetFromRunResult(state.result || null);
const savedContentActionLabel =
savedSiteContentTarget?.preferredTarget === "project_file"
? "在下方预览导出 Markdown"
: "打开已保存内容";
preferredResultFileTarget?.relativePath
? `打开结果文件 ${preferredResultFileTarget.title || "index.md"}`
: savedSiteContentTarget?.preferredTarget === "project_file"
? "查看采集源 Markdown"
: "打开已保存内容";
return (
<section
@@ -102,12 +113,21 @@ export function ServiceSkillExecutionCard({
<p className="mt-2 text-xs leading-5 opacity-80">{state.reportHint}</p>
) : null}
{savedSiteContentTarget && onOpenSavedSiteContent ? (
{(preferredResultFileTarget?.relativePath && onOpenResultFile) ||
(savedSiteContentTarget && onOpenSavedSiteContent) ? (
<div className="mt-3">
<Button
type="button"
variant="outline"
onClick={() => onOpenSavedSiteContent(savedSiteContentTarget)}
onClick={() => {
if (preferredResultFileTarget?.relativePath && onOpenResultFile) {
onOpenResultFile(preferredResultFileTarget.relativePath);
return;
}
if (savedSiteContentTarget && onOpenSavedSiteContent) {
onOpenSavedSiteContent(savedSiteContentTarget);
}
}}
data-testid="service-skill-execution-open-saved-content"
>
{savedContentActionLabel}
@@ -0,0 +1,36 @@
import type { Dispatch, SetStateAction } from "react";
import type { LayoutMode } from "@/lib/workspace/workbenchContract";
export type CanvasOpenReason =
| "user_toggle_canvas"
| "user_open_artifact"
| "user_open_file"
| "user_open_message_preview"
| "runtime_write"
| "runtime_browser_assist"
| "runtime_image_workbench"
| "runtime_team_workbench";
const MANUAL_CANVAS_OPEN_REASONS = new Set<CanvasOpenReason>([
"user_toggle_canvas",
"user_open_artifact",
"user_open_file",
"user_open_message_preview",
]);
export function shouldOpenCanvasForReason(
reason: CanvasOpenReason,
): boolean {
return MANUAL_CANVAS_OPEN_REASONS.has(reason);
}
export function openCanvasForReason(
reason: CanvasOpenReason,
setLayoutMode: Dispatch<SetStateAction<LayoutMode>>,
): void {
if (!shouldOpenCanvasForReason(reason)) {
return;
}
setLayoutMode("chat-canvas");
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { buildCanvasWorkbenchDefaultPreview } from "./canvasWorkbenchDefaultPreview";
describe("canvasWorkbenchDefaultPreview", () => {
it("通用工作区真实文件预览应生成稳定的默认 selectionKey", () => {
const preview = buildCanvasWorkbenchDefaultPreview({
workspaceRoot: "/workspace",
canvasRenderTheme: "general",
generalCanvasState: {
isOpen: true,
content: "# 标题\n\n正文",
filename: "exports/x-article-export/latest/index.md",
},
resolvedCanvasState: null,
activeCanvasTaskFile: null,
});
expect(preview).toEqual(
expect.objectContaining({
selectionKey:
"default-preview:exports/x-article-export/latest/index.md",
title: "index.md",
filePath: "exports/x-article-export/latest/index.md",
absolutePath: "/workspace/exports/x-article-export/latest/index.md",
}),
);
});
});
@@ -38,6 +38,7 @@ export function buildCanvasWorkbenchDefaultPreview({
const filePath = generalCanvasState.filename?.trim() || undefined;
return {
selectionKey: `default-preview:${filePath || "current-canvas"}`,
title: filePath ? extractFileNameFromPath(filePath) : "当前画布草稿",
content: generalCanvasState.content,
filePath,
@@ -9,3 +9,16 @@ export function hasRenderableGeneralCanvasPreview(
return state.content.trim().length > 0;
}
export function hasNamedGeneralCanvasFilePreview(
state:
| Pick<GeneralCanvasState, "isOpen" | "content" | "filename">
| null
| undefined,
): boolean {
if (!hasRenderableGeneralCanvasPreview(state)) {
return false;
}
return typeof state?.filename === "string" && state.filename.trim().length > 0;
}
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import type { AgentThreadItem, SiteSavedContentTarget } from "../types";
import { resolvePreferredServiceSkillResultFileTarget } from "./serviceSkillResultFileTarget";
function createFileArtifactItem(
overrides: Partial<Extract<AgentThreadItem, { type: "file_artifact" }>>,
): Extract<AgentThreadItem, { type: "file_artifact" }> {
return {
id: "artifact-1",
thread_id: "thread-1",
turn_id: "turn-1",
sequence: 1,
status: "completed",
started_at: "2026-04-10T10:00:00Z",
completed_at: "2026-04-10T10:00:01Z",
updated_at: "2026-04-10T10:00:01Z",
type: "file_artifact",
path: "workspace/index.md",
source: "write_file",
content: "# Index\n\n正文",
...overrides,
};
}
describe("serviceSkillResultFileTarget", () => {
it("应优先选择同回合里脱离源导出 bundle 的正式 index.md", () => {
const savedContentTarget: SiteSavedContentTarget = {
projectId: "project-1",
contentId: "content-1",
preferredTarget: "project_file",
projectFile: {
relativePath: "exports/x-article-export/google-cloud/index.md",
},
};
const target = resolvePreferredServiceSkillResultFileTarget({
savedContentTarget,
threadItems: [
createFileArtifactItem({
id: "artifact-source",
path: "exports/x-article-export/google-cloud/index.md",
sequence: 2,
}),
createFileArtifactItem({
id: "artifact-agents",
path: "workspace/Agents.md",
sequence: 3,
}),
createFileArtifactItem({
id: "artifact-final",
path: "workspace/index.md",
sequence: 4,
}),
],
});
expect(target).toEqual({
relativePath: "workspace/index.md",
title: "index.md",
});
});
it("只有源导出 bundle 时应返回 null,交给 saved_content 兜底", () => {
const savedContentTarget: SiteSavedContentTarget = {
projectId: "project-1",
contentId: "content-1",
preferredTarget: "project_file",
projectFile: {
relativePath: "exports/x-article-export/google-cloud/index.md",
},
};
const target = resolvePreferredServiceSkillResultFileTarget({
savedContentTarget,
threadItems: [
createFileArtifactItem({
id: "artifact-source",
path: "exports/x-article-export/google-cloud/index.md",
sequence: 4,
}),
],
});
expect(target).toBeNull();
});
it("导出到其他正式目录时也应优先打开结果 index.md", () => {
const savedContentTarget: SiteSavedContentTarget = {
projectId: "project-1",
contentId: "content-1",
preferredTarget: "project_file",
projectFile: {
relativePath: "exports/x-article-export/google-cloud/index.md",
},
};
const target = resolvePreferredServiceSkillResultFileTarget({
savedContentTarget,
threadItems: [
createFileArtifactItem({
id: "artifact-final",
path: "exports/social-article/google-cloud/index.md",
sequence: 6,
}),
createFileArtifactItem({
id: "artifact-agents",
path: "exports/social-article/google-cloud/Agents.md",
sequence: 7,
}),
],
});
expect(target).toEqual({
relativePath: "exports/social-article/google-cloud/index.md",
title: "index.md",
});
});
});
@@ -0,0 +1,121 @@
import type { AgentThreadItem, SiteSavedContentTarget } from "../types";
import { isHiddenConversationArtifactPath } from "../utils/internalArtifactVisibility";
import { extractFileNameFromPath } from "./workspacePath";
export interface ServiceSkillResultFileTarget {
relativePath: string;
title: string;
}
function normalizePath(value?: string | null): string {
return (value || "").trim().replace(/\\/g, "/");
}
function normalizeDirectory(value?: string | null): string {
const normalized = normalizePath(value);
if (!normalized) {
return "";
}
const segments = normalized.split("/").filter(Boolean);
segments.pop();
return segments.join("/");
}
function isMarkdownLikePath(path: string): boolean {
return /\.(md|markdown|mdx|txt|rst|adoc)$/i.test(path);
}
function scoreResultFileCandidate(
item: Extract<AgentThreadItem, { type: "file_artifact" }>,
savedContentTarget?: SiteSavedContentTarget | null,
): number {
const normalizedPath = normalizePath(item.path);
const savedPath = normalizePath(
savedContentTarget?.projectFile?.relativePath || undefined,
);
const savedDirectory = normalizeDirectory(savedPath);
const fileName = extractFileNameFromPath(normalizedPath).toLowerCase();
const inSavedBundle =
Boolean(savedPath) &&
(normalizedPath === savedPath ||
(savedDirectory.length > 0 &&
normalizedPath.startsWith(`${savedDirectory}/`)));
const outsideExports = !normalizedPath.startsWith("exports/");
let score = item.sequence;
if (fileName === "index.md") {
score += 600;
} else if (fileName === "agents.md") {
score += 260;
} else if (isMarkdownLikePath(normalizedPath)) {
score += 380;
}
if (outsideExports) {
score += 240;
}
if (!inSavedBundle) {
score += 420;
} else {
score -= 320;
}
if ((item.content || "").trim().length > 0) {
score += 8;
}
return score;
}
export function resolvePreferredServiceSkillResultFileTarget(params: {
threadItems: AgentThreadItem[];
savedContentTarget?: SiteSavedContentTarget | null;
}): ServiceSkillResultFileTarget | null {
const candidates = params.threadItems.filter(
(item): item is Extract<AgentThreadItem, { type: "file_artifact" }> =>
item.type === "file_artifact" &&
!isHiddenConversationArtifactPath(item.path) &&
isMarkdownLikePath(normalizePath(item.path)),
);
if (candidates.length === 0) {
return null;
}
const preferred = [...candidates].sort((left, right) => {
const scoreDelta =
scoreResultFileCandidate(right, params.savedContentTarget) -
scoreResultFileCandidate(left, params.savedContentTarget);
if (scoreDelta !== 0) {
return scoreDelta;
}
return right.sequence - left.sequence;
})[0];
const normalizedPath = normalizePath(preferred?.path);
if (!preferred || !normalizedPath) {
return null;
}
const savedPath = normalizePath(
params.savedContentTarget?.projectFile?.relativePath || undefined,
);
const savedDirectory = normalizeDirectory(savedPath);
if (savedPath && normalizedPath === savedPath) {
return null;
}
if (
savedDirectory &&
normalizedPath.startsWith(`${savedDirectory}/`)
) {
return null;
}
return {
relativePath: normalizedPath,
title: extractFileNameFromPath(normalizedPath),
};
}
@@ -299,6 +299,7 @@ describe("serviceSkillSceneLaunch", () => {
expect(requestMetadata).toMatchObject({
harness: {
chat_mode: "general",
allow_model_skills: true,
browser_requirement: "required",
browser_assist: {
enabled: true,
@@ -327,6 +328,15 @@ describe("serviceSkillSceneLaunch", () => {
report_hint: "将自动打开目标文章页。",
},
},
translation_skill_launch: {
skill_name: "translation",
kind: "translation_request",
translation_request: {
target_language: "中文",
project_id: "project-1",
entry_source: "service_skill_site_export_followup",
},
},
},
});
});
@@ -4,7 +4,11 @@ import {
type ComponentProps,
type ReactNode,
} from "react";
import type { CanvasWorkbenchTeamView } from "../components/CanvasWorkbenchLayout";
import type {
CanvasWorkbenchHeaderBadge,
CanvasWorkbenchSummaryStat,
CanvasWorkbenchTeamView,
} from "../components/CanvasWorkbenchLayout";
import { TeamWorkbenchSummaryPanel } from "../components/TeamWorkbenchSummaryPanel";
import { TeamWorkspaceBoard } from "../components/TeamWorkspaceBoard";
import {
@@ -203,6 +207,91 @@ export function useTeamWorkbenchPresentation({
],
);
const headerBadges = useMemo<CanvasWorkbenchHeaderBadge[]>(() => {
const badges: CanvasWorkbenchHeaderBadge[] = [
{
key: "team-runtime",
label: "Team Workbench",
tone: "accent",
},
];
if (triggerState?.label?.trim()) {
badges.push({
key: "team-trigger-state",
label: triggerState.label.trim(),
tone: triggerState.tone === "active" ? "accent" : "default",
});
}
if (teamWaitSummary?.awaitedSessionIds?.length) {
badges.push({
key: "team-awaiting",
label: `等待 ${teamWaitSummary.awaitedSessionIds.length}`,
tone: "default",
});
}
return badges;
}, [teamWaitSummary?.awaitedSessionIds, triggerState]);
const summaryStats = useMemo<CanvasWorkbenchSummaryStat[]>(() => {
const leadStatus =
triggerState?.label?.trim() || executionSummary.statusTitle || "待机";
const leadDetail =
executionSummary.statusTitle || "当前没有活跃的协作执行。";
const stats: CanvasWorkbenchSummaryStat[] = [
{
key: "team-status",
label: "协作状态",
value: leadStatus,
detail: leadDetail,
tone: triggerState?.tone === "active" ? "accent" : "default",
},
{
key: "team-members",
label: "活跃成员",
value:
executionSummary.totalSessionCount > 0
? `${executionSummary.activeSessionCount}/${executionSummary.totalSessionCount}`
: "0",
detail:
executionSummary.totalSessionCount > 0
? `${executionSummary.runningSessionCount} 位处理中,${executionSummary.queuedSessionCount} 位排队中。`
: "当前还没有可展示的协作成员。",
tone: executionSummary.activeSessionCount > 0 ? "accent" : "default",
},
];
if (teamWaitSummary?.awaitedSessionIds?.length) {
stats.push({
key: "team-awaiting",
label: "等待确认",
value: `${teamWaitSummary.awaitedSessionIds.length} 项`,
detail: teamWaitSummary.timedOut
? "等待结果超时,建议重新检查成员状态。"
: "正在等待成员完成或返回结果。",
tone: "default",
});
} else if (teamControlSummary?.affectedSessionIds?.length) {
stats.push({
key: "team-control",
label: "最近控制",
value:
teamControlSummary.action === "resume"
? "恢复执行"
: teamControlSummary.action === "close_completed"
? "清理已完成"
: "关闭协作",
detail: `影响 ${teamControlSummary.affectedSessionIds.length} 个会话。`,
tone: "default",
});
}
return stats;
}, [executionSummary, teamControlSummary, teamWaitSummary, triggerState]);
const teamWorkbenchView = useMemo<CanvasWorkbenchTeamView | null>(() => {
if (!enabled) {
return null;
@@ -215,10 +304,23 @@ export function useTeamWorkbenchPresentation({
dispatchPreviewState?.blueprint?.label?.trim() ||
surfaceProps.selectedTeamLabel ||
"团队工作台",
tabLabel: surfaceProps.selectedTeamLabel?.trim() || undefined,
tabBadge: triggerState?.label?.trim() || undefined,
tabBadgeTone:
triggerState?.tone === "error"
? "rose"
: triggerState?.tone === "active"
? "sky"
: "slate",
subtitle: "主对话保留调度记录,画布按角色分别展示执行过程与结果。",
autoFocusToken,
preferFixedPanel: true,
triggerState,
badges: headerBadges,
summaryStats,
panelCopy: {
emptyText: "当前没有可展示的 Team Workbench。",
},
renderPreview: (options?: { stackedWorkbenchTrigger?: ReactNode }) =>
renderTeamWorkbenchPreview(options?.stackedWorkbenchTrigger),
renderPanel: () => teamWorkbenchSummaryPanel,
@@ -228,6 +330,8 @@ export function useTeamWorkbenchPresentation({
enabled,
renderTeamWorkbenchPreview,
dispatchPreviewState,
headerBadges,
summaryStats,
surfaceProps.selectedTeamLabel,
teamWorkbenchSummaryPanel,
triggerState,
@@ -68,6 +68,7 @@ function renderHook(props?: Partial<HookProps>) {
setLayoutMode: vi.fn(),
setTaskFiles: vi.fn(),
setSelectedFileId: vi.fn(),
setGeneralCanvasState: vi.fn(),
setCanvasState: vi.fn(),
};
@@ -125,12 +126,14 @@ describe("useWorkspaceArtifactPreviewActions", () => {
const setSelectedArtifactId = vi.fn();
const setArtifactViewMode = vi.fn();
const setLayoutMode = vi.fn();
const setGeneralCanvasState = vi.fn();
const artifact = createArtifact();
const { render, getValue } = renderHook({
suppressBrowserAssistCanvasAutoOpen,
setSelectedArtifactId,
setArtifactViewMode,
setLayoutMode,
setGeneralCanvasState,
});
await render();
@@ -149,6 +152,7 @@ describe("useWorkspaceArtifactPreviewActions", () => {
expect(setArtifactViewMode).toHaveBeenCalledWith("preview", {
artifactId: "artifact-doc-1",
});
expect(setGeneralCanvasState).toHaveBeenCalledTimes(1);
});
it("显式打开浏览器协助 artifact 时应改走浏览器工作台入口", async () => {
@@ -189,10 +193,18 @@ describe("useWorkspaceArtifactPreviewActions", () => {
expect(setLayoutMode).not.toHaveBeenCalled();
});
it("通用模式打开文件预览时应把生成的 artifact 标记为持久项,避免后续同步被清掉", async () => {
it("通用模式打开文件预览时应直接切到真实文件画布,而不是再包装成 artifact", async () => {
const upsertGeneralArtifact = vi.fn();
const setGeneralCanvasState = vi.fn();
const setSelectedArtifactId = vi.fn();
const setLayoutMode = vi.fn();
const suppressBrowserAssistCanvasAutoOpen = vi.fn();
const { render, getValue } = renderHook({
upsertGeneralArtifact,
setGeneralCanvasState,
setSelectedArtifactId,
setLayoutMode,
suppressBrowserAssistCanvasAutoOpen,
});
await render();
@@ -204,13 +216,16 @@ describe("useWorkspaceArtifactPreviewActions", () => {
);
});
expect(upsertGeneralArtifact).toHaveBeenCalledWith(
expect(upsertGeneralArtifact).not.toHaveBeenCalled();
expect(suppressBrowserAssistCanvasAutoOpen).toHaveBeenCalledTimes(1);
expect(setSelectedArtifactId).toHaveBeenCalledWith(null);
expect(setLayoutMode).toHaveBeenCalledWith("chat-canvas");
expect(setGeneralCanvasState).toHaveBeenCalledWith(
expect.objectContaining({
id: "artifact:session:.lime/artifacts/thread-1/report.md",
meta: expect.objectContaining({
persistOutsideMessages: true,
filePath: ".lime/artifacts/thread-1/report.md",
}),
isOpen: true,
contentType: "markdown",
filename: ".lime/artifacts/thread-1/report.md",
content: "# 研究简报\n\n这里是预览内容。",
}),
);
});
@@ -12,16 +12,13 @@ import { readFilePreview } from "@/lib/api/fileBrowser";
import type { SessionFile } from "@/lib/api/session-files";
import type { Artifact } from "@/lib/artifact/types";
import {
areArtifactProtocolPathsEquivalent,
resolveArtifactProtocolFilePath,
} from "@/lib/artifact-protocol";
import type { TaskFile } from "../components/TaskFiles";
import type { HarnessFilePreviewResult } from "../components/HarnessStatusPanel";
import { useArtifactAutoPreviewSync } from "../hooks/useArtifactAutoPreviewSync";
import {
buildArtifactFromWrite,
resolveDefaultArtifactViewMode,
} from "../utils/messageArtifacts";
import { resolveDefaultArtifactViewMode } from "../utils/messageArtifacts";
import { openCanvasForReason } from "./canvasOpenPolicy";
import type { ApplyArtifactViewMode } from "./useWorkspaceArtifactViewModeControl";
import {
isRenderableTaskFile,
@@ -29,6 +26,8 @@ import {
resolveTaskFileType,
} from "./generalWorkbenchHelpers";
import { extractFileNameFromPath } from "./workspacePath";
import { buildGeneralCanvasStateFromWorkspaceFile } from "./workspaceFilePreview";
import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge";
function buildCanvasStateFromContent(params: {
previous: CanvasStateUnion | null;
@@ -66,6 +65,7 @@ interface UseWorkspaceArtifactPreviewActionsParams {
setLayoutMode: Dispatch<SetStateAction<LayoutMode>>;
setTaskFiles: Dispatch<SetStateAction<TaskFile[]>>;
setSelectedFileId: (fileId: string) => void;
setGeneralCanvasState: Dispatch<SetStateAction<GeneralCanvasState>>;
setCanvasState: Dispatch<SetStateAction<CanvasStateUnion | null>>;
}
@@ -100,6 +100,7 @@ export function useWorkspaceArtifactPreviewActions({
setLayoutMode,
setTaskFiles,
setSelectedFileId,
setGeneralCanvasState,
setCanvasState,
}: UseWorkspaceArtifactPreviewActionsParams): WorkspaceArtifactPreviewActionsResult {
const handleHarnessLoadFilePreview = useCallback(
@@ -187,6 +188,9 @@ export function useWorkspaceArtifactPreviewActions({
if (activeTheme === "general") {
suppressBrowserAssistCanvasAutoOpen();
setGeneralCanvasState((previous) =>
previous.isOpen ? { ...previous, isOpen: false } : previous,
);
}
let nextArtifact = artifact;
@@ -223,13 +227,14 @@ export function useWorkspaceArtifactPreviewActions({
}),
{ artifactId: nextArtifact.id },
);
setLayoutMode("chat-canvas");
openCanvasForReason("user_open_artifact", setLayoutMode);
},
[
activeTheme,
handleHarnessLoadFilePreview,
onOpenBrowserRuntimeForArtifact,
setArtifactViewMode,
setGeneralCanvasState,
setLayoutMode,
setSelectedArtifactId,
suppressBrowserAssistCanvasAutoOpen,
@@ -272,7 +277,7 @@ export function useWorkspaceArtifactPreviewActions({
content,
}),
);
setLayoutMode("chat-canvas");
openCanvasForReason("user_open_file", setLayoutMode);
},
[mappedTheme, setCanvasState, setLayoutMode],
);
@@ -280,33 +285,12 @@ export function useWorkspaceArtifactPreviewActions({
const handleFileClick = useCallback(
(fileName: string, content: string) => {
if (activeTheme === "general") {
const matchingArtifact = artifacts.find((artifact) => {
const artifactPath = resolveArtifactProtocolFilePath(artifact);
return (
areArtifactProtocolPathsEquivalent(artifactPath, fileName) ||
artifact.title === extractFileNameFromPath(fileName) ||
(content.trim().length > 0 && artifact.content === content)
);
});
const nextArtifact =
matchingArtifact ||
buildArtifactFromWrite({
filePath: fileName,
content,
context: {
source: "message_content",
status: content.length > 0 ? "complete" : "pending",
metadata: {
persistOutsideMessages: true,
},
},
});
if (!matchingArtifact) {
upsertGeneralArtifact(nextArtifact);
}
void openArtifactInWorkbench(nextArtifact);
suppressBrowserAssistCanvasAutoOpen();
setSelectedArtifactId(null);
setGeneralCanvasState(
buildGeneralCanvasStateFromWorkspaceFile(fileName, content),
);
openCanvasForReason("user_open_file", setLayoutMode);
return;
}
@@ -346,12 +330,13 @@ export function useWorkspaceArtifactPreviewActions({
[
activeTheme,
applyContentToCanvas,
artifacts,
isThemeWorkbench,
openArtifactInWorkbench,
setGeneralCanvasState,
setLayoutMode,
setSelectedArtifactId,
setSelectedFileId,
setTaskFiles,
upsertGeneralArtifact,
suppressBrowserAssistCanvasAutoOpen,
],
);
@@ -406,6 +391,24 @@ export function useWorkspaceArtifactPreviewActions({
const handleTaskFileClick = useCallback(
(file: TaskFile) => {
if (activeTheme === "general") {
if (!file.content?.trim()) {
toast.info("该文件为辅助产物,暂不在主稿画布渲染");
return;
}
suppressBrowserAssistCanvasAutoOpen();
setSelectedArtifactId(null);
setGeneralCanvasState(
buildGeneralCanvasStateFromWorkspaceFile(
file.name,
file.content ?? "",
),
);
openCanvasForReason("user_open_file", setLayoutMode);
return;
}
setSelectedFileId(file.id);
if (
@@ -419,7 +422,16 @@ export function useWorkspaceArtifactPreviewActions({
applyContentToCanvas(file.content ?? "");
},
[applyContentToCanvas, isThemeWorkbench, setSelectedFileId],
[
activeTheme,
applyContentToCanvas,
isThemeWorkbench,
setGeneralCanvasState,
setLayoutMode,
setSelectedArtifactId,
setSelectedFileId,
suppressBrowserAssistCanvasAutoOpen,
],
);
return {
@@ -232,7 +232,7 @@ describe("useWorkspaceBrowserAssistRuntime", () => {
expect(setLayoutMode).not.toHaveBeenCalled();
});
it("普通产物处于流式写入时仍应自动展开画布", async () => {
it("普通产物处于流式写入时不应再自动展开画布", async () => {
const setLayoutMode = vi.fn();
const { render } = renderHook({
artifacts: [createStreamingArtifact()],
@@ -241,7 +241,7 @@ describe("useWorkspaceBrowserAssistRuntime", () => {
await render();
expect(setLayoutMode).toHaveBeenCalledWith("chat-canvas");
expect(setLayoutMode).not.toHaveBeenCalled();
});
it("existing_session 启动失败时应回退到当前 Chrome 扩展桥接", async () => {
@@ -22,7 +22,6 @@ import type {
Message,
SiteSavedContentTarget,
} from "../types";
import { resolveArtifactWritePhase } from "../utils/messageArtifacts";
import { resolveSiteSavedContentTargetFromRunResult } from "../utils/siteToolResultSummary";
import {
areBrowserAssistSessionStatesEqual,
@@ -128,16 +127,6 @@ function hasActiveBrowserAssistSession(
return !["failed", "closed", "terminated"].includes(lifecycleState || "");
}
function shouldAutoOpenGeneralStreamingArtifact(artifact: Artifact): boolean {
const writePhase = resolveArtifactWritePhase(artifact);
return (
artifact.type !== "browser_assist" &&
(artifact.status === "streaming" ||
writePhase === "preparing" ||
writePhase === "streaming")
);
}
function shouldAutoOpenPassiveBrowserAssist(
artifact: Artifact | null,
launching: boolean,
@@ -224,7 +213,6 @@ export function useWorkspaceBrowserAssistRuntime({
siteSkillLaunchNonce,
artifacts,
messages,
setLayoutMode,
upsertGeneralArtifact,
generalBrowserAssistProfileKey,
}: UseWorkspaceBrowserAssistRuntimeParams): WorkspaceBrowserAssistRuntimeResult {
@@ -240,12 +228,6 @@ export function useWorkspaceBrowserAssistRuntime({
const browserAssistLaunchRequestIdRef = useRef(0);
const browserAssistAutoOpenDismissedScopeRef = useRef<string | null>(null);
const browserAssistScopeTrackerRef = useRef<string | null>(null);
const dismissedGeneralCanvasAutoOpenFingerprintRef = useRef<string | null>(
null,
);
const previousGeneralCanvasAutoOpenSessionIdRef = useRef<string | null>(
sessionId ?? null,
);
const currentBrowserAssistScopeKey = useMemo(
() =>
@@ -345,21 +327,6 @@ export function useWorkspaceBrowserAssistRuntime({
[activeTheme, projectId, sessionId],
);
const latestGeneralCanvasAutoOpenFingerprint = useMemo(() => {
if (activeTheme !== "general") {
return null;
}
const latestArtifact = [...artifacts]
.reverse()
.find(shouldAutoOpenGeneralStreamingArtifact);
if (!latestArtifact) {
return null;
}
return `${latestArtifact.id}:${latestArtifact.updatedAt}:${latestArtifact.status}`;
}, [activeTheme, artifacts]);
const canAutoRestoreDetachedBrowserAssistSession = useMemo(() => {
if (activeTheme !== "general") {
return false;
@@ -413,26 +380,7 @@ export function useWorkspaceBrowserAssistRuntime({
browserAssistAutoOpenDismissedScopeRef.current = "__dismissed__";
}, [activeTheme]);
const suppressGeneralCanvasArtifactAutoOpen = useCallback(() => {
if (activeTheme !== "general" || !latestGeneralCanvasAutoOpenFingerprint) {
return;
}
dismissedGeneralCanvasAutoOpenFingerprintRef.current =
latestGeneralCanvasAutoOpenFingerprint;
}, [activeTheme, latestGeneralCanvasAutoOpenFingerprint]);
useEffect(() => {
const normalizedSessionId = sessionId ?? null;
if (
previousGeneralCanvasAutoOpenSessionIdRef.current === normalizedSessionId
) {
return;
}
previousGeneralCanvasAutoOpenSessionIdRef.current = normalizedSessionId;
dismissedGeneralCanvasAutoOpenFingerprintRef.current = null;
}, [sessionId]);
const suppressGeneralCanvasArtifactAutoOpen = useCallback(() => {}, []);
useEffect(() => {
if (activeTheme !== "general") {
@@ -455,25 +403,6 @@ export function useWorkspaceBrowserAssistRuntime({
browserAssistScopeTrackerRef.current = currentBrowserAssistScopeKey;
}, [activeTheme, currentBrowserAssistScopeKey]);
useEffect(() => {
if (activeTheme !== "general") {
return;
}
if (!latestGeneralCanvasAutoOpenFingerprint) {
return;
}
if (
dismissedGeneralCanvasAutoOpenFingerprintRef.current ===
latestGeneralCanvasAutoOpenFingerprint
) {
return;
}
setLayoutMode("chat-canvas");
}, [activeTheme, latestGeneralCanvasAutoOpenFingerprint, setLayoutMode]);
const commitBrowserAssistSessionState = useCallback(
(candidate: BrowserAssistSessionState | null) => {
if (activeTheme !== "general" || !candidate) {
@@ -32,6 +32,8 @@ import {
} from "./workbenchPreview";
import { wrapPreviewWithWorkbenchTrigger } from "./workbenchPreviewHelpers";
import { buildCanvasWorkbenchDefaultPreview } from "./canvasWorkbenchDefaultPreview";
import { resolveAbsoluteWorkspacePath } from "./workspacePath";
import { buildGeneralCanvasStateFromWorkspaceFile } from "./workspaceFilePreview";
import {
useTeamWorkbenchPresentation,
type TeamWorkbenchSurfaceProps,
@@ -360,10 +362,15 @@ export function useWorkspaceCanvasPreviewPresentation({
const generalCanvasPanelProps = useMemo<GeneralCanvasPanelProps>(
() => ({
state: generalCanvas.state,
baseFilePath: resolveAbsoluteWorkspacePath(
defaultPreview.workspaceRoot,
generalCanvas.state.filename,
),
onClose: generalCanvas.onCloseCanvas,
onContentChange: generalCanvas.onContentChange,
}),
[
defaultPreview.workspaceRoot,
generalCanvas.onCloseCanvas,
generalCanvas.onContentChange,
generalCanvas.state,
@@ -517,6 +524,37 @@ export function useWorkspaceCanvasPreviewPresentation({
],
);
const renderGeneralCanvasPreviewTarget = useCallback(
(
target: Extract<CanvasWorkbenchPreviewTarget, { kind: "default-canvas" }>,
stackedWorkbenchTrigger?: ReactNode,
) =>
(
<GeneralCanvasPanel
state={buildGeneralCanvasStateFromWorkspaceFile(
target.filePath || target.title,
target.content,
)}
baseFilePath={
target.absolutePath ||
resolveAbsoluteWorkspacePath(
defaultPreview.workspaceRoot,
target.filePath,
)
}
onClose={artifactPreview.onCloseCanvas}
onContentChange={generalCanvas.onContentChange}
chrome="embedded"
toolbarActions={stackedWorkbenchTrigger}
/>
),
[
artifactPreview.onCloseCanvas,
defaultPreview.workspaceRoot,
generalCanvas.onContentChange,
],
);
const { renderTeamWorkbenchPreview, teamWorkbenchView } =
useTeamWorkbenchPresentation({
enabled: teamWorkbench.enabled,
@@ -553,7 +591,10 @@ export function useWorkspaceCanvasPreviewPresentation({
switch (target.kind) {
case "default-canvas":
return renderLiveCanvasPreview(stackedWorkbenchTrigger);
return renderGeneralCanvasPreviewTarget(
target,
stackedWorkbenchTrigger,
);
case "artifact":
case "synthetic-artifact":
return renderArtifactWorkbenchPreview(target.artifact, {
@@ -573,7 +614,7 @@ export function useWorkspaceCanvasPreviewPresentation({
},
[
renderArtifactWorkbenchPreview,
renderLiveCanvasPreview,
renderGeneralCanvasPreviewTarget,
renderTeamWorkbenchPreview,
],
);
@@ -239,4 +239,154 @@ describe("useWorkspaceConversationSceneRuntime", () => {
onSelectServiceSkill,
);
});
it("应向画布壳透传解耦后的 sessionView 过程面板", () => {
const params = createBaseParams({
turns: [
{
id: "turn-1",
thread_id: "thread-1",
prompt_text: "请抓取文章并整理成 markdown",
status: "running",
started_at: "2026-04-09T10:00:00.000Z",
created_at: "2026-04-09T10:00:00.000Z",
updated_at: "2026-04-09T10:00:01.000Z",
},
],
currentTurnId: "turn-1",
effectiveThreadItems: [
{
id: "item-1",
thread_id: "thread-1",
turn_id: "turn-1",
sequence: 1,
status: "in_progress",
started_at: "2026-04-09T10:00:00.000Z",
updated_at: "2026-04-09T10:00:01.000Z",
type: "tool_call",
tool_name: "Skill(url_parse)",
arguments: { url: "https://example.com" },
},
],
pendingActions: [
{
requestId: "req-1",
actionType: "elicitation",
prompt: "请补充导出目录",
status: "pending",
},
],
queuedTurns: [
{
queued_turn_id: "queued-1",
message_preview: "继续下载图片",
message_text: "继续下载图片",
created_at: 1_712_650_000,
image_count: 0,
position: 1,
},
],
settledWorkbenchArtifacts: [{ id: "artifact-1" }],
isSending: true,
focusedTimelineItemId: "item-1",
});
useWorkspaceConversationSceneRuntime(params);
const presentationParams = mockPresentation.mock.calls.at(-1)?.[0];
const sessionView = presentationParams?.canvasWorkbenchLayout?.sessionView;
expect(sessionView?.title).toBe("Session · Main");
expect(sessionView?.tabLabel).toBe("Session · Main");
expect(sessionView?.tabBadge).toBe("进行中 1");
expect(sessionView?.tabBadgeTone).toBe("sky");
expect(sessionView?.subtitle).toContain("请抓取文章并整理成 markdown");
expect(sessionView?.summaryStats).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "session-status",
label: "会话状态",
value: "执行中",
}),
expect.objectContaining({
key: "session-follow-up",
label: "待补信息",
value: "待补信息 1",
}),
]),
);
expect(sessionView?.badges).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "session-status",
label: "执行中",
}),
expect.objectContaining({
key: "session-pending-actions",
label: "待补信息 1",
}),
]),
);
expect(typeof sessionView?.renderPanel).toBe("function");
});
it("应向画布壳透传 workspaceView 头部语义", () => {
const params = createBaseParams({
settledWorkbenchArtifacts: [{ id: "artifact-1" }, { id: "artifact-2" }],
taskFiles: [{ id: "task-1", name: "draft.md" }],
projectRootPath: "/tmp/demo-project",
workspacePathMissing: false,
workspaceHealthError: false,
queuedTurns: [
{
queued_turn_id: "queued-1",
message_preview: "继续处理",
message_text: "继续处理",
created_at: 1_712_650_000,
image_count: 0,
position: 1,
},
],
});
useWorkspaceConversationSceneRuntime(params);
const presentationParams = mockPresentation.mock.calls.at(-1)?.[0];
const workspaceView =
presentationParams?.canvasWorkbenchLayout?.workspaceView;
expect(workspaceView?.title).toBe("项目工作区文件");
expect(workspaceView?.tabLabel).toBe("文件");
expect(workspaceView?.tabBadge).toBe("demo-project");
expect(workspaceView?.tabBadgeTone).toBe("sky");
expect(workspaceView?.badges).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "workspace-root",
label: "demo-project",
}),
]),
);
expect(workspaceView?.summaryStats).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "workspace-root",
label: "工作区",
value: "demo-project",
}),
expect.objectContaining({
key: "workspace-binding",
label: "目录状态",
value: "已连接",
}),
]),
);
expect(workspaceView?.panelCopy).toEqual(
expect.objectContaining({
unavailableText: "当前工作区路径不可用,暂时无法浏览项目文件。",
emptyText: "当前会话没有绑定可浏览的工作区目录。",
sectionEyebrow: "项目目录",
}),
);
});
});
@@ -5,6 +5,12 @@ import { useWorkspaceNavigationActions } from "./useWorkspaceNavigationActions";
import { useWorkspaceInputbarSceneRuntime } from "./useWorkspaceInputbarSceneRuntime";
import { useWorkspaceCanvasSceneRuntime } from "./useWorkspaceCanvasSceneRuntime";
import { useWorkspaceShellChromeRuntime } from "./useWorkspaceShellChromeRuntime";
import { CanvasSessionOverviewPanel } from "../components/CanvasSessionOverviewPanel";
import type {
CanvasWorkbenchHeaderView,
CanvasWorkbenchSessionView,
CanvasWorkbenchSummaryStat,
} from "../components/CanvasWorkbenchLayout";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { CreationMode } from "../components/types";
import type { WriteArtifactContext } from "../types";
@@ -27,6 +33,47 @@ type ConversationScenePresentationParams = Parameters<
typeof useWorkspaceConversationScenePresentation
>[0];
function shortenSessionText(value?: string | null, maxLength = 120): string {
const normalized = (value || "").trim().replace(/\s+/g, " ");
if (!normalized) {
return "";
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 1).trimEnd()}…`;
}
function resolveSessionStatusBadge(
status?: "running" | "completed" | "failed" | "aborted" | null,
): {
label: string;
tone: "default" | "accent" | "success";
} {
if (status === "running") {
return { label: "执行中", tone: "accent" };
}
if (status === "completed") {
return { label: "已完成", tone: "success" };
}
if (status === "failed") {
return { label: "失败", tone: "default" };
}
if (status === "aborted") {
return { label: "已中断", tone: "default" };
}
return { label: "空闲", tone: "default" };
}
function resolvePathLeaf(value?: string | null): string {
const normalized = (value || "").trim().replace(/\\/g, "/");
if (!normalized) {
return "";
}
const segments = normalized.split("/").filter(Boolean);
return segments.at(-1) || normalized;
}
interface UseWorkspaceConversationSceneRuntimeParams {
messageListEmptyStateVariant?: "default" | "task-center";
navbarContextVariant?: "default" | "task-center";
@@ -237,13 +284,13 @@ export function useWorkspaceConversationSceneRuntime({
currentStepIndex,
goToStep,
displayMessages,
turns,
effectiveThreadItems,
turns = [],
effectiveThreadItems = [],
currentTurnId,
threadRead,
pendingActions,
pendingActions = [],
submittedActionsInFlight,
queuedTurns,
queuedTurns = [],
isPreparingSend,
isSending,
stopSending,
@@ -307,6 +354,203 @@ export function useWorkspaceConversationSceneRuntime({
!isThemeWorkbench &&
activeTheme === "general" &&
layoutMode === "chat-canvas";
const currentSessionTurn =
turns.find((turn) => turn.id === currentTurnId) || turns.at(-1) || null;
const currentSessionStatus = resolveSessionStatusBadge(
isSending ? "running" : currentSessionTurn?.status,
);
const runtimeItemCount = effectiveThreadItems.filter(
(item) => item.type !== "user_message" && item.type !== "agent_message",
).length;
const inProgressItemCount = effectiveThreadItems.filter(
(item) => item.status === "in_progress",
).length;
const sessionSummaryStats: CanvasWorkbenchSummaryStat[] = [
{
key: "session-status",
label: "会话状态",
value: currentSessionStatus.label,
detail: "当前回合的整体推进状态。",
tone: currentSessionStatus.tone,
},
{
key: "session-runtime-items",
label: "运行轨迹",
value:
inProgressItemCount > 0
? `进行中 ${inProgressItemCount}`
: `轨迹 ${runtimeItemCount}`,
detail: "技能、工具与运行事件的实时轨迹。",
tone: inProgressItemCount > 0 ? "accent" : "default",
},
{
key: "session-follow-up",
label: pendingActions.length > 0 ? "待补信息" : "排队消息",
value:
pendingActions.length > 0
? `待补信息 ${pendingActions.length}`
: queuedTurns.length > 0
? `排队 ${queuedTurns.length}`
: "无需跟进",
detail:
pendingActions.length > 0
? "仍在等待用户补充或确认的信息。"
: queuedTurns.length > 0
? `另有 ${queuedTurns.length} 条消息正在排队。`
: "当前没有待处理的补充或排队消息。",
tone:
pendingActions.length > 0
? "accent"
: queuedTurns.length > 0
? "default"
: "default",
},
];
const sessionView: CanvasWorkbenchSessionView = {
eyebrow: "Session Runtime",
title: "Session · Main",
tabLabel: "Session · Main",
tabBadge:
inProgressItemCount > 0
? `进行中 ${inProgressItemCount}`
: queuedTurns.length > 0
? `排队 ${queuedTurns.length}`
: undefined,
tabBadgeTone: inProgressItemCount > 0 ? "sky" : "slate",
subtitle: currentSessionTurn
? `当前 turn:${shortenSessionText(currentSessionTurn.prompt_text, 160) || "暂无提示词"}`
: "展示当前会话的 turn、skills、工具轨迹、A2UI 与排队状态。",
summaryStats: sessionSummaryStats,
badges: [
{
key: "session-status",
label: currentSessionStatus.label,
tone: currentSessionStatus.tone,
},
{
key: "session-runtime-items",
label:
inProgressItemCount > 0
? `进行中 ${inProgressItemCount}`
: `轨迹 ${runtimeItemCount}`,
tone: inProgressItemCount > 0 ? "accent" : "default",
},
...(pendingActions.length > 0
? [
{
key: "session-pending-actions",
label: `待补信息 ${pendingActions.length}`,
tone: "accent" as const,
},
]
: []),
...(queuedTurns.length > 0
? [
{
key: "session-queued-turns",
label: `排队 ${queuedTurns.length}`,
tone: "default" as const,
},
]
: []),
],
renderPanel: () => (
<CanvasSessionOverviewPanel
turns={turns}
threadItems={effectiveThreadItems}
currentTurnId={currentTurnId}
pendingActions={pendingActions}
queuedTurns={queuedTurns}
isSending={isSending}
focusedItemId={focusedTimelineItemId}
/>
),
};
const workspaceRootLabel = resolvePathLeaf(projectRootPath) || "未绑定";
const workspaceBindingValue = workspacePathMissing
? "路径缺失"
: workspaceHealthError
? "状态异常"
: projectRootPath
? "已连接"
: "未绑定";
const workspaceView: CanvasWorkbenchHeaderView = {
eyebrow: "Project Workspace",
tabLabel: "文件",
tabBadge:
workspacePathMissing || workspaceHealthError
? workspaceBindingValue
: projectRootPath?.trim()
? workspaceRootLabel
: undefined,
tabBadgeTone:
workspacePathMissing || workspaceHealthError
? "rose"
: projectRootPath?.trim()
? "sky"
: undefined,
title: projectRootPath?.trim()
? "项目工作区文件"
: "当前没有可浏览的项目文件",
subtitle: projectRootPath?.trim()
? projectRootPath
: "绑定工作区目录后,这里会显示真实文件树。",
badges: [
{
key: "workspace-root",
label: projectRootPath?.trim() ? workspaceRootLabel : "未绑定工作区",
tone: projectRootPath?.trim() ? "accent" : "default",
},
...(workspacePathMissing
? [
{
key: "workspace-missing",
label: "路径缺失",
tone: "default" as const,
},
]
: workspaceHealthError
? [
{
key: "workspace-health-error",
label: "状态异常",
tone: "default" as const,
},
]
: []),
],
summaryStats: [
{
key: "workspace-root",
label: "工作区",
value: workspaceRootLabel,
detail:
projectRootPath?.trim() || "绑定工作区后,这里会展示真实文件树。",
tone: projectRootPath?.trim() ? "accent" : "default",
},
{
key: "workspace-binding",
label: "目录状态",
value: workspaceBindingValue,
detail: workspacePathMissing
? "当前工作区路径缺失,需重新选择目录。"
: workspaceHealthError
? "当前工作区状态异常,建议先修复后再继续浏览。"
: projectRootPath?.trim()
? "画布会直接读取项目里的真实文件。"
: "尚未绑定工作区目录。",
tone:
workspacePathMissing || workspaceHealthError ? "default" : "success",
},
],
panelCopy: {
unavailableText: "当前工作区路径不可用,暂时无法浏览项目文件。",
emptyText: "当前会话没有绑定可浏览的工作区目录。",
sectionEyebrow: "项目目录",
loadingText: "正在加载目录...",
emptyDirectoryText: "暂无目录内容。",
},
};
return useWorkspaceConversationScenePresentation({
scene: {
@@ -489,6 +733,8 @@ export function useWorkspaceConversationSceneRuntime({
onOpenPath: canvasScene.handleOpenCanvasWorkbenchPath,
onRevealPath: canvasScene.handleRevealCanvasWorkbenchPath,
renderPreview: canvasScene.renderCanvasWorkbenchPreview,
workspaceView,
sessionView,
onLayoutModeChange: shouldSyncCanvasWorkbenchLayoutMode
? setCanvasWorkbenchLayoutMode
: undefined,
@@ -13,6 +13,10 @@ import {
type MediaTaskArtifactOutput,
} from "@/lib/api/mediaTasks";
import { safeListen } from "@/lib/dev-bridge";
import {
hasTauriInvokeCapability,
hasTauriRuntimeMarkers,
} from "@/lib/tauri-runtime";
import type { Message } from "../types";
import {
createInitialSessionImageWorkbenchState,
@@ -23,6 +27,7 @@ import type { DirectoryListing } from "@/lib/api/fileBrowser";
import { createInitialDocumentState } from "@/lib/workspace/workbenchCanvas";
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(),
safeListen: vi.fn(),
}));
@@ -36,6 +41,11 @@ vi.mock("@/lib/api/mediaTasks", () => ({
listMediaTaskArtifacts: vi.fn(),
}));
vi.mock("@/lib/tauri-runtime", () => ({
hasTauriInvokeCapability: vi.fn(() => true),
hasTauriRuntimeMarkers: vi.fn(() => true),
}));
type HookProps = Parameters<typeof useWorkspaceImageTaskPreviewRuntime>[0];
type RuntimeHarnessProps = Omit<
HookProps,
@@ -214,6 +224,8 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
).IS_REACT_ACT_ENVIRONMENT = true;
vi.resetAllMocks();
vi.useFakeTimers();
vi.mocked(hasTauriInvokeCapability).mockReturnValue(true);
vi.mocked(hasTauriRuntimeMarkers).mockReturnValue(true);
vi.mocked(safeListen).mockResolvedValue(vi.fn());
vi.mocked(getMediaTaskArtifact).mockRejectedValue(
new Error("task artifact unavailable"),
@@ -1004,6 +1016,8 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
it("历史消息已带 taskId 时,应直接按 taskId 走媒体任务接口恢复图片结果", async () => {
const taskId = "task-image-history-direct-1";
vi.mocked(hasTauriInvokeCapability).mockReturnValue(false);
vi.mocked(hasTauriRuntimeMarkers).mockReturnValue(false);
vi.mocked(getMediaTaskArtifact).mockResolvedValueOnce(
createArtifactOutput({
task_id: taskId,
@@ -1071,6 +1085,25 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
]);
});
it("浏览器开发模式下不应触发工作区级图片任务全量恢复", async () => {
vi.mocked(hasTauriInvokeCapability).mockReturnValue(false);
vi.mocked(hasTauriRuntimeMarkers).mockReturnValue(false);
const { render, getValue } = renderHook();
await render();
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(listMediaTaskArtifacts).not.toHaveBeenCalled();
expect(listDirectory).not.toHaveBeenCalled();
expect(readFilePreview).not.toHaveBeenCalled();
expect(getValue().messages).toEqual([]);
expect(getValue().imageWorkbenchState.tasks).toEqual([]);
});
it("同一历史会话已缓存图片工作台结果时,应直接回填聊天卡而不是继续显示等待队列", async () => {
const taskId = "task-image-history-cached-1";
const cachedState = {
@@ -6,6 +6,10 @@ import {
type MediaTaskArtifactOutput,
} from "@/lib/api/mediaTasks";
import { safeListen } from "@/lib/dev-bridge";
import {
hasTauriInvokeCapability,
hasTauriRuntimeMarkers,
} from "@/lib/tauri-runtime";
import { resolveAbsoluteWorkspacePath } from "./workspacePath";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import {
@@ -1636,6 +1640,10 @@ export function useWorkspaceImageTaskPreviewRuntime({
}, [messages, projectRootPath, sessionId]);
useEffect(() => {
const shouldRestoreWorkspaceTaskCatalog =
restoreFromWorkspace &&
(hasTauriInvokeCapability() || hasTauriRuntimeMarkers());
const trackedTasks = trackedTasksRef.current;
trackedTasks.forEach((trackedTask) => {
if (trackedTask.timerId !== null) {
@@ -2233,7 +2241,7 @@ export function useWorkspaceImageTaskPreviewRuntime({
if (restoreFromWorkspace) {
void restoreTrackedTasksFromMessages().then((restoredFromMessages) => {
if (!restoredFromMessages) {
if (!restoredFromMessages && shouldRestoreWorkspaceTaskCatalog) {
void restoreTrackedTasksFromWorkspace();
}
});
@@ -114,7 +114,6 @@ function renderHook(props?: Partial<HookProps>) {
submitImageWorkbenchAgentCommand: vi.fn().mockResolvedValue(true),
setCanvasState: vi.fn(),
setInput: vi.fn(),
setLayoutMode: vi.fn(),
updateCurrentImageWorkbenchState: vi.fn(),
};
@@ -525,6 +524,56 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => {
);
});
it("应用图片结果时应只关闭图片工作台并派发插入,不主动切换布局", async () => {
const updateCurrentImageWorkbenchState = vi.fn();
const currentImageWorkbenchState: HookProps["currentImageWorkbenchState"] = {
...createInitialSessionImageWorkbenchState(),
selectedOutputId: "task-image-1:output:1",
outputs: [
{
id: "task-image-1:output:1",
taskId: "task-image-1",
hookImageId: "task-image-1:hook:1",
refId: "img-2",
url: "https://example.com/image-2.png",
prompt: "原始图片",
createdAt: Date.now(),
providerName: "fal",
modelName: "fal-ai/nano-banana-pro",
size: "1024x1024",
parentOutputId: null,
resourceSaved: false,
applyTarget: {
kind: "canvas-insert" as const,
canvasType: "document" as const,
anchorHint: "section_end" as const,
sectionTitle: "核心观点",
anchorText: "这里是核心观点段落。",
actionLabel: "插入文稿",
dispatchLabel: "已切回文稿,正在插入图片",
},
},
],
};
const { render, getValue } = renderHook({
currentImageWorkbenchState,
updateCurrentImageWorkbenchState,
});
await render();
act(() => {
getValue().handleApplySelectedImageWorkbenchOutput();
});
expect(updateCurrentImageWorkbenchState).toHaveBeenCalledTimes(1);
expect(updateCurrentImageWorkbenchState).toHaveBeenCalledWith(
expect.any(Function),
);
expect(toast.info).toHaveBeenCalledWith("已切回文稿,正在插入图片");
expect(toast.error).not.toHaveBeenCalled();
});
it("继续修图 follow-up 应回填输入框并使用中性提示文案", async () => {
const setInput = vi.fn();
const { render, getValue } = renderHook({
@@ -7,7 +7,6 @@ import {
} from "react";
import { toast } from "sonner";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import type { LayoutMode } from "@/lib/workspace/workbenchContract";
import type {
CreateImageGenerationTaskArtifactRequest,
MediaTaskArtifactOutput,
@@ -63,7 +62,6 @@ interface UseWorkspaceImageWorkbenchActionRuntimeParams {
) => Promise<boolean>;
setCanvasState: Dispatch<SetStateAction<CanvasStateUnion | null>>;
setInput: Dispatch<SetStateAction<string>>;
setLayoutMode: Dispatch<SetStateAction<LayoutMode>>;
updateCurrentImageWorkbenchState: (
updater: (
current: SessionImageWorkbenchState,
@@ -269,7 +267,6 @@ export function useWorkspaceImageWorkbenchActionRuntime({
submitImageWorkbenchAgentCommand,
setCanvasState,
setInput,
setLayoutMode,
updateCurrentImageWorkbenchState,
}: UseWorkspaceImageWorkbenchActionRuntimeParams) {
const resolveImageWorkbenchSessionKey = useCallback(
@@ -611,7 +608,6 @@ export function useWorkspaceImageWorkbenchActionRuntime({
...current,
active: false,
}));
setLayoutMode("chat-canvas");
toast.success(applyTarget.successLabel);
return;
}
@@ -636,7 +632,6 @@ export function useWorkspaceImageWorkbenchActionRuntime({
...current,
active: false,
}));
setLayoutMode("chat-canvas");
toast.info(applyTarget.dispatchLabel);
}, [
contentId,
@@ -644,7 +639,6 @@ export function useWorkspaceImageWorkbenchActionRuntime({
currentImageWorkbenchState.selectedOutputId,
projectId,
setCanvasState,
setLayoutMode,
updateCurrentImageWorkbenchState,
]);
@@ -68,7 +68,6 @@ export function useWorkspaceImageWorkbenchEventRuntime({
setImageWorkbenchSelectedProviderId,
setImageWorkbenchSelectedModelId,
setImageWorkbenchSelectedSize,
setLayoutMode,
setCanvasState,
updateCurrentImageWorkbenchState,
handleImageWorkbenchCommand,
@@ -202,9 +201,8 @@ export function useWorkspaceImageWorkbenchEventRuntime({
active: true,
};
});
setLayoutMode("chat-canvas");
});
}, [contentId, projectId, setLayoutMode, updateCurrentImageWorkbenchState]);
}, [contentId, projectId, updateCurrentImageWorkbenchState]);
useEffect(() => {
const handler = (event: Event) => {
@@ -3865,6 +3865,7 @@ describe("useWorkspaceSendActions", () => {
expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({
requestMetadata: {
harness: {
allow_model_skills: true,
browser_requirement: "required",
service_skill_launch: {
kind: "site_adapter",
@@ -3877,6 +3878,15 @@ describe("useWorkspaceSendActions", () => {
target_language: "中文",
},
},
translation_skill_launch: {
skill_name: "translation",
kind: "translation_request",
translation_request: {
target_language: "中文",
project_id: "project-1",
entry_source: "service_skill_site_export_followup",
},
},
},
},
});
@@ -1,7 +1,5 @@
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type SetStateAction,
@@ -17,107 +15,17 @@ interface UseWorkspaceTeamWorkbenchAutoOpenRuntimeParams {
}
export function useWorkspaceTeamWorkbenchAutoOpenRuntime({
hasRealTeamGraph,
layoutMode,
runtimeTeamRequestId,
sessionId,
setLayoutMode,
}: UseWorkspaceTeamWorkbenchAutoOpenRuntimeParams) {
const [teamWorkbenchAutoFocusToken, setTeamWorkbenchAutoFocusToken] =
useState(0);
const previousTeamWorkbenchSessionIdRef = useRef<string | null>(
sessionId ?? null,
);
const previousRealTeamGraphRef = useRef(hasRealTeamGraph);
const previousTeamWorkbenchLayoutModeRef = useRef<LayoutMode>(layoutMode);
const previousRuntimeTeamRequestIdRef = useRef<string | null>(
runtimeTeamRequestId,
);
const suppressedAutoOpenRef = useRef(false);
const suppressedRuntimeTeamRequestIdRef = useRef<string | null>(null);
const dismissActiveTeamWorkbenchAutoOpen = useCallback(() => {
suppressedAutoOpenRef.current = true;
if (runtimeTeamRequestId) {
suppressedRuntimeTeamRequestIdRef.current = runtimeTeamRequestId;
}
}, [runtimeTeamRequestId]);
const dismissActiveTeamWorkbenchAutoOpen = useCallback(() => {}, []);
const handleActivateTeamWorkbench = useCallback(() => {
setTeamWorkbenchAutoFocusToken((current) => current + 1);
setLayoutMode((current) => (current === "chat" ? "chat-canvas" : current));
}, [setLayoutMode]);
useEffect(() => {
const previousLayoutMode = previousTeamWorkbenchLayoutModeRef.current;
if (previousLayoutMode !== "chat" && layoutMode === "chat") {
suppressedAutoOpenRef.current = true;
if (runtimeTeamRequestId) {
suppressedRuntimeTeamRequestIdRef.current = runtimeTeamRequestId;
}
}
previousTeamWorkbenchLayoutModeRef.current = layoutMode;
}, [layoutMode, runtimeTeamRequestId]);
useEffect(() => {
const normalizedSessionId = sessionId ?? null;
if (previousTeamWorkbenchSessionIdRef.current !== normalizedSessionId) {
previousTeamWorkbenchSessionIdRef.current = normalizedSessionId;
previousRealTeamGraphRef.current = hasRealTeamGraph;
previousTeamWorkbenchLayoutModeRef.current = layoutMode;
previousRuntimeTeamRequestIdRef.current = runtimeTeamRequestId;
suppressedAutoOpenRef.current = false;
suppressedRuntimeTeamRequestIdRef.current = null;
return;
}
const previousRuntimeTeamRequestId =
previousRuntimeTeamRequestIdRef.current;
const runtimeTeamRequestIdChanged =
previousRuntimeTeamRequestId !== runtimeTeamRequestId;
if (runtimeTeamRequestIdChanged) {
const previousSuppressedRequestId =
suppressedRuntimeTeamRequestIdRef.current;
if (
!runtimeTeamRequestId ||
previousSuppressedRequestId !== runtimeTeamRequestId
) {
suppressedAutoOpenRef.current = false;
suppressedRuntimeTeamRequestIdRef.current = null;
}
}
if (
runtimeTeamRequestId &&
runtimeTeamRequestIdChanged &&
!hasRealTeamGraph &&
!suppressedAutoOpenRef.current
) {
handleActivateTeamWorkbench();
}
if (
hasRealTeamGraph &&
!previousRealTeamGraphRef.current &&
!suppressedAutoOpenRef.current
) {
handleActivateTeamWorkbench();
}
previousRuntimeTeamRequestIdRef.current = runtimeTeamRequestId;
previousRealTeamGraphRef.current = hasRealTeamGraph;
}, [
handleActivateTeamWorkbench,
hasRealTeamGraph,
layoutMode,
runtimeTeamRequestId,
sessionId,
]);
return {
teamWorkbenchAutoFocusToken,
dismissActiveTeamWorkbenchAutoOpen,
@@ -231,7 +231,7 @@ describe("useWorkspaceWriteFileAction", () => {
expect(setSelectedArtifactId).not.toHaveBeenCalled();
});
it("进行中的流式写入仍应自动展开画布,便于实时查看输出", async () => {
it("进行中的流式写入应只更新当前产物态,不再自动展开画布", async () => {
const setArtifactViewMode = vi.fn();
const setLayoutMode = vi.fn();
const { render, getValue } = renderHook({
@@ -257,7 +257,7 @@ describe("useWorkspaceWriteFileAction", () => {
artifactId: expect.any(String),
}),
);
expect(setLayoutMode).toHaveBeenCalledWith("chat-canvas");
expect(setLayoutMode).not.toHaveBeenCalled();
});
it("内容发布主稿写入时应把创作语义 metadata 一起持久化并挂到任务文件", async () => {
@@ -34,17 +34,6 @@ import type { ApplyArtifactViewMode } from "./useWorkspaceArtifactViewModeContro
const shouldLogWorkspaceWriteInfo = import.meta.env.MODE !== "test";
function shouldAutoOpenCanvasForActiveWrite(
status: Artifact["status"] | undefined,
writePhase: unknown,
): boolean {
return (
status === "streaming" ||
writePhase === "preparing" ||
writePhase === "streaming"
);
}
function shouldSkipGeneralArtifactWrite(params: {
fileName: string;
content: string;
@@ -183,8 +172,6 @@ export function useWorkspaceWriteFileAction({
upsertGeneralArtifact,
setSelectedArtifactId,
setArtifactViewMode,
setLayoutMode,
suppressCanvasAutoOpen,
completeStep,
setTaskFiles,
setSelectedFileId,
@@ -198,12 +185,6 @@ export function useWorkspaceWriteFileAction({
content.length,
"字符",
);
const shouldAutoOpenCanvas =
!suppressCanvasAutoOpen &&
shouldAutoOpenCanvasForActiveWrite(
context?.status,
context?.metadata?.writePhase,
);
if (activeTheme === "general" && !isThemeWorkbench) {
const existingArtifact = artifacts.find((artifact) => {
@@ -300,15 +281,6 @@ export function useWorkspaceWriteFileAction({
}),
{ artifactId: nextArtifact.id },
);
if (
!suppressCanvasAutoOpen &&
shouldAutoOpenCanvasForActiveWrite(
nextArtifact.status,
nextArtifact.meta.writePhase,
)
) {
setLayoutMode("chat-canvas");
}
}
return;
}
@@ -621,10 +593,6 @@ export function useWorkspaceWriteFileAction({
content,
};
});
if (shouldAutoOpenCanvas) {
setLayoutMode("chat-canvas");
}
},
[
activeTheme,
@@ -642,12 +610,10 @@ export function useWorkspaceWriteFileAction({
setArtifactViewMode,
setCanvasState,
setDocumentVersionStatusMap,
setLayoutMode,
setSelectedArtifactId,
setSelectedFileId,
setTaskFiles,
socialStageLogRef,
suppressCanvasAutoOpen,
syncGeneralArtifactToResource,
taskFilesRef,
themeWorkbenchActiveQueueItem,

Some files were not shown because too many files have changed in this diff Show More