mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.7.0
This commit is contained in:
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
+127
-42
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
+368
@@ -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>
|
||||
);
|
||||
}
|
||||
+144
@@ -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>
|
||||
);
|
||||
}
|
||||
+272
@@ -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>
|
||||
);
|
||||
}
|
||||
+168
@@ -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>
|
||||
);
|
||||
}
|
||||
+91
@@ -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>
|
||||
);
|
||||
}
|
||||
+177
@@ -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>
|
||||
);
|
||||
}
|
||||
+435
@@ -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
@@ -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",
|
||||
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",
|
||||
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(
|
||||
"",
|
||||
);
|
||||
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",
|
||||
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(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
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>;
|
||||
+112
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
+50
-1
@@ -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
Reference in New Issue
Block a user