release: v0.93.0

This commit is contained in:
coso
2026-03-21 23:10:41 +08:00
parent 43e79847b2
commit dc37baff36
193 changed files with 30459 additions and 13754 deletions
@@ -3,19 +3,99 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentChatHomeShell } from "./AgentChatHomeShell";
import { SettingsTabs } from "@/types/settings";
const { mockBuildClawAgentParams, mockSaveChatToolPreferences } = vi.hoisted(() => ({
mockBuildClawAgentParams: vi.fn((overrides?: Record<string, unknown>) => ({
agentEntry: "claw",
...(overrides || {}),
})),
mockSaveChatToolPreferences: vi.fn(),
}));
const {
mockBuildClawAgentParams,
mockHomeShellExecutionStrategy,
mockHomeShellModel,
mockHomeShellProviderType,
mockSetExecutionStrategy,
mockSetModel,
mockSetProviderType,
mockLoadConfiguredProviders,
mockLoadProviderModels,
mockFilterModelsByTheme,
mockSaveChatToolPreferences,
mockPrepareClawSolution,
mockUseClawSolutions,
mockRecordClawSolutionUsage,
mockClawSolutions,
} = vi.hoisted(() => {
const mockClawSolutions = [
{
id: "social-post-starter",
title: "社媒主稿生成",
summary: "进入社媒专项工作台并生成一版首稿。",
outputHint: "社媒首稿 + 平台结构",
recommendedCapabilities: ["模型", "社媒主题"],
readiness: "ready",
readinessMessage: "可直接开始",
badge: "社媒方案",
recentUsedAt: null,
isRecent: false,
readinessLabel: "可直接开始",
readinessTone: "emerald",
},
{
id: "team-breakdown",
title: "多代理拆任务",
summary: "默认启用多代理偏好,按 team runtime 方式展开任务。",
outputHint: "任务拆解 + 分工执行",
recommendedCapabilities: ["模型", "多代理"],
readiness: "ready",
readinessMessage: "可直接开始,进入后会启用多代理偏好",
reasonCode: "team_recommended",
badge: "多代理",
recentUsedAt: null,
isRecent: false,
readinessLabel: "可直接开始",
readinessTone: "emerald",
},
];
const mockRecordClawSolutionUsage = vi.fn();
return {
mockBuildClawAgentParams: vi.fn((overrides?: Record<string, unknown>) => ({
agentEntry: "claw",
...(overrides || {}),
})),
mockHomeShellProviderType: { current: "mock-provider" },
mockHomeShellModel: { current: "mock-model" },
mockHomeShellExecutionStrategy: { current: "react" },
mockSetProviderType: vi.fn(),
mockSetModel: vi.fn(),
mockSetExecutionStrategy: vi.fn(),
mockLoadConfiguredProviders: vi.fn(async () => []),
mockLoadProviderModels: vi.fn(async () => []),
mockFilterModelsByTheme: vi.fn(
(_theme: string | undefined, models: unknown[]) => ({
models,
usedFallback: false,
filteredOutCount: 0,
policyName: "mock",
}),
),
mockSaveChatToolPreferences: vi.fn(),
mockPrepareClawSolution: vi.fn(),
mockUseClawSolutions: vi.fn(() => ({
solutions: mockClawSolutions,
isLoading: false,
error: null,
refresh: vi.fn(),
recordUsage: mockRecordClawSolutionUsage,
})),
mockRecordClawSolutionUsage,
mockClawSolutions,
};
});
vi.mock("./components/EmptyState", () => ({
EmptyState: ({
onSend,
onRecommendationClick,
supportingSlotOverride,
}: {
onSend: (
value: string,
@@ -23,6 +103,7 @@ vi.mock("./components/EmptyState", () => ({
images?: Array<{ data: string; mediaType: string }>,
) => void;
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
supportingSlotOverride?: React.ReactNode;
}) => (
<>
<button
@@ -44,6 +125,7 @@ vi.mock("./components/EmptyState", () => ({
>
Team 推荐
</button>
{supportingSlotOverride}
</>
),
}));
@@ -72,6 +154,10 @@ vi.mock("./hooks/agentChatStorage", () => ({
})),
loadPersisted: vi.fn((_key: string, fallback: unknown) => fallback),
loadPersistedString: vi.fn(() => ""),
resolveWorkspaceAgentPreferences: vi.fn(() => ({
providerType: "mock-provider",
model: "mock-model",
})),
savePersisted: vi.fn(),
}));
@@ -79,6 +165,17 @@ vi.mock("./hooks/agentChatCoreUtils", () => ({
normalizeExecutionStrategy: vi.fn((value: string) => value || "react"),
}));
vi.mock("./hooks/useHomeShellAgentPreferences", () => ({
useHomeShellAgentPreferences: vi.fn(() => ({
providerType: mockHomeShellProviderType.current,
setProviderType: mockSetProviderType,
model: mockHomeShellModel.current,
setModel: mockSetModel,
executionStrategy: mockHomeShellExecutionStrategy.current,
setExecutionStrategy: mockSetExecutionStrategy,
})),
}));
vi.mock("./utils/chatToolPreferences", () => ({
loadChatToolPreferences: vi.fn(() => ({
webSearch: false,
@@ -93,6 +190,49 @@ vi.mock("@/lib/workspace/navigation", () => ({
buildClawAgentParams: mockBuildClawAgentParams,
}));
vi.mock("@/lib/api/clawSolutions", () => ({
prepareClawSolution: mockPrepareClawSolution,
}));
vi.mock("@/hooks/useConfiguredProviders", () => ({
loadConfiguredProviders: mockLoadConfiguredProviders,
}));
vi.mock("@/hooks/useProviderModels", () => ({
loadProviderModels: mockLoadProviderModels,
}));
vi.mock("./utils/modelThemePolicy", () => ({
filterModelsByTheme: mockFilterModelsByTheme,
}));
vi.mock("./claw-solutions/useClawSolutions", () => ({
useClawSolutions: mockUseClawSolutions,
}));
vi.mock("./claw-solutions/ClawHomeSolutionsPanel", () => ({
ClawHomeSolutionsPanel: ({
solutions,
onSelect,
}: {
solutions: Array<{ id: string; title: string }>;
onSelect: (solution: { id: string; title: string }) => void;
}) => (
<>
{solutions.map((solution) => (
<button
key={solution.id}
type="button"
data-testid={`home-shell-solution-${solution.id}`}
onClick={() => onSelect(solution)}
>
{solution.title}
</button>
))}
</>
),
}));
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
@@ -101,6 +241,26 @@ beforeEach(() => {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
mockHomeShellProviderType.current = "mock-provider";
mockHomeShellModel.current = "mock-model";
mockHomeShellExecutionStrategy.current = "react";
mockUseClawSolutions.mockImplementation(() => ({
solutions: mockClawSolutions,
isLoading: false,
error: null,
refresh: vi.fn(),
recordUsage: mockRecordClawSolutionUsage,
}));
mockLoadConfiguredProviders.mockResolvedValue([]);
mockLoadProviderModels.mockResolvedValue([]);
mockFilterModelsByTheme.mockImplementation(
(_theme: string | undefined, models: unknown[]) => ({
models,
usedFallback: false,
filteredOutCount: 0,
policyName: "mock",
}),
);
});
afterEach(() => {
@@ -241,4 +401,304 @@ describe("AgentChatHomeShell", () => {
}),
);
});
it("点击社媒方案时应切换到 social-media 工作区", async () => {
const onNavigate = vi.fn();
mockLoadConfiguredProviders.mockResolvedValueOnce([
{
key: "custom-social-provider",
label: "Custom Social Provider",
registryId: "custom-social-provider",
fallbackRegistryId: "openai",
type: "openai",
},
]);
mockLoadProviderModels.mockResolvedValueOnce([
{
id: "social-model-1",
display_name: "Social Model 1",
provider_id: "custom-social-provider",
provider_name: "Custom Social Provider",
family: null,
tier: "pro",
capabilities: {
vision: false,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: false,
},
pricing: null,
limits: {
context_length: null,
max_output_tokens: null,
requests_per_minute: null,
tokens_per_minute: null,
},
status: "active",
release_date: null,
is_latest: true,
description: "social",
source: "custom",
created_at: 0,
updated_at: 0,
},
]);
mockPrepareClawSolution.mockResolvedValueOnce({
solutionId: "social-post-starter",
actionType: "navigate_theme",
prompt: "请先帮我起草一版社媒内容首稿",
themeTarget: "social-media",
shouldLaunchBrowserAssist: false,
shouldEnableTeamMode: false,
readiness: "ready",
readinessMessage: "可直接开始",
});
const { container } = renderShell({
onNavigate,
});
await flushEffects();
const socialSolutionButton = container.querySelector(
'[data-testid="home-shell-solution-social-post-starter"]',
) as HTMLButtonElement | null;
expect(socialSolutionButton).toBeTruthy();
act(() => {
socialSolutionButton?.click();
});
await flushEffects();
expect(mockSetProviderType).toHaveBeenCalledWith("custom-social-provider");
expect(mockSetModel).toHaveBeenCalledWith("social-model-1");
expect(mockPrepareClawSolution).toHaveBeenCalledWith(
"social-post-starter",
{
projectId: "project-1",
userInput: undefined,
},
);
expect(mockBuildClawAgentParams).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "project-1",
theme: "social-media",
initialUserPrompt: "请先帮我起草一版社媒内容首稿",
}),
);
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
agentEntry: "claw",
theme: "social-media",
initialUserPrompt: "请先帮我起草一版社媒内容首稿",
}),
);
expect(mockSetProviderType.mock.invocationCallOrder[0]).toBeLessThan(
onNavigate.mock.invocationCallOrder[0],
);
expect(mockSetModel.mock.invocationCallOrder[0]).toBeLessThan(
onNavigate.mock.invocationCallOrder[0],
);
expect(mockRecordClawSolutionUsage).toHaveBeenCalledWith({
solutionId: "social-post-starter",
actionType: "navigate_theme",
themeTarget: "social-media",
});
});
it("当前 provider 已可用时应保留 custom provider id 并仅切换模型", async () => {
const onNavigate = vi.fn();
mockHomeShellProviderType.current = "custom-social-provider";
mockHomeShellModel.current = "legacy-model";
mockLoadConfiguredProviders.mockResolvedValueOnce([
{
key: "custom-social-provider",
label: "Custom Social Provider",
registryId: "custom-social-provider",
fallbackRegistryId: "openai",
type: "openai",
},
{
key: "other-provider",
label: "Other Provider",
registryId: "other-provider",
type: "openai",
},
]);
mockLoadProviderModels.mockResolvedValueOnce([
{
id: "custom-social-model",
display_name: "Custom Social Model",
provider_id: "custom-social-provider",
provider_name: "Custom Social Provider",
family: null,
tier: "pro",
capabilities: {
vision: false,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: false,
},
pricing: null,
limits: {
context_length: null,
max_output_tokens: null,
requests_per_minute: null,
tokens_per_minute: null,
},
status: "active",
release_date: null,
is_latest: true,
description: "custom-social-model",
source: "custom",
created_at: 0,
updated_at: 0,
},
]);
mockPrepareClawSolution.mockResolvedValueOnce({
solutionId: "social-post-starter",
actionType: "navigate_theme",
prompt: "请先帮我起草一版社媒内容首稿",
themeTarget: "social-media",
shouldLaunchBrowserAssist: false,
shouldEnableTeamMode: false,
readiness: "ready",
readinessMessage: "可直接开始",
});
const { container } = renderShell({
onNavigate,
});
await flushEffects();
const socialSolutionButton = container.querySelector(
'[data-testid="home-shell-solution-social-post-starter"]',
) as HTMLButtonElement | null;
expect(socialSolutionButton).toBeTruthy();
act(() => {
socialSolutionButton?.click();
});
await flushEffects();
expect(mockSetProviderType).not.toHaveBeenCalled();
expect(mockSetModel).toHaveBeenCalledWith("custom-social-model");
expect(mockLoadProviderModels).toHaveBeenCalledWith(
expect.objectContaining({
key: "custom-social-provider",
}),
);
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
theme: "social-media",
}),
);
});
it("点击多代理方案时应开启多代理偏好并进入工作区", async () => {
const onEnterWorkspace = vi.fn();
mockPrepareClawSolution.mockResolvedValueOnce({
solutionId: "team-breakdown",
actionType: "enable_team_mode",
prompt: "请把这个任务按多代理方式拆解",
shouldLaunchBrowserAssist: false,
shouldEnableTeamMode: true,
readiness: "ready",
readinessMessage: "可直接开始,进入后会启用多代理偏好",
});
const { container } = renderShell({
onNavigate: undefined,
onEnterWorkspace,
});
await flushEffects();
const teamSolutionButton = container.querySelector(
'[data-testid="home-shell-solution-team-breakdown"]',
) as HTMLButtonElement | null;
expect(teamSolutionButton).toBeTruthy();
act(() => {
teamSolutionButton?.click();
});
await flushEffects();
expect(mockSaveChatToolPreferences).toHaveBeenLastCalledWith(
expect.objectContaining({
webSearch: false,
thinking: false,
task: false,
subagent: true,
}),
"general",
);
expect(onEnterWorkspace).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "project-1",
theme: "general",
initialCreationMode: "guided",
initialUserPrompt: "请把这个任务按多代理方式拆解",
}),
);
expect(mockRecordClawSolutionUsage).toHaveBeenCalledWith({
solutionId: "team-breakdown",
actionType: "enable_team_mode",
themeTarget: null,
});
});
it("方案未就绪且缺少模型时应直接跳到供应商设置", async () => {
const onNavigate = vi.fn();
const onEnterWorkspace = vi.fn();
mockPrepareClawSolution.mockResolvedValueOnce({
solutionId: "social-post-starter",
actionType: "navigate_theme",
prompt: "请先帮我起草一版社媒内容首稿",
themeTarget: "social-media",
shouldLaunchBrowserAssist: false,
shouldEnableTeamMode: false,
readiness: "needs_setup",
readinessMessage: "请先配置至少一个可用模型",
reasonCode: "missing_model",
});
const { container } = renderShell({
onNavigate,
onEnterWorkspace,
});
await flushEffects();
const socialSolutionButton = container.querySelector(
'[data-testid="home-shell-solution-social-post-starter"]',
) as HTMLButtonElement | null;
expect(socialSolutionButton).toBeTruthy();
act(() => {
socialSolutionButton?.click();
});
await flushEffects();
expect(onNavigate).toHaveBeenCalledWith("settings", {
tab: SettingsTabs.Providers,
});
expect(onEnterWorkspace).not.toHaveBeenCalled();
expect(mockRecordClawSolutionUsage).not.toHaveBeenCalled();
});
});
+187 -345
View File
@@ -1,36 +1,40 @@
import { useCallback, useEffect, useState } from "react";
import styled from "styled-components";
import { toast } from "sonner";
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import { getProjectMemory, type ProjectMemory } from "@/lib/api/memory";
import { logAgentDebug } from "@/lib/agentDebug";
import { skillsApi, type Skill } from "@/lib/api/skills";
import { prepareClawSolution } from "@/lib/api/clawSolutions";
import type { Page, PageParams } from "@/types/page";
import { SettingsTabs } from "@/types/settings";
import type { ThemeType } from "@/components/content-creator/types";
import { EmptyState } from "./components/EmptyState";
import type { CreationMode } from "./components/types";
import { buildClawAgentParams } from "@/lib/workspace/navigation";
import {
DEFAULT_AGENT_MODEL,
DEFAULT_AGENT_PROVIDER,
GLOBAL_MODEL_PREF_KEY,
GLOBAL_PROVIDER_PREF_KEY,
getAgentPreferenceKeys,
loadPersisted,
loadPersistedString,
savePersisted,
} from "./hooks/agentChatStorage";
import { normalizeExecutionStrategy } from "./hooks/agentChatCoreUtils";
import type { MessageImage } from "./types";
import {
loadChatToolPreferences,
saveChatToolPreferences,
type ChatToolPreferences,
} from "./utils/chatToolPreferences";
import { isTeamRuntimeRecommendation } from "./utils/contextualRecommendations";
import { resolveClawWorkspaceProviderSelection } from "./utils/clawWorkspaceProviderSelection";
import { normalizeProjectId } from "./utils/topicProjectResolution";
import {
LAST_PROJECT_ID_KEY,
usePersistedProjectId,
} from "./hooks/agentProjectStorage";
import { useHomeShellAgentPreferences } from "./hooks/useHomeShellAgentPreferences";
import { useHomeShellProjectMemory } from "./hooks/useHomeShellProjectMemory";
import { useHomeShellSkills } from "./hooks/useHomeShellSkills";
import { useThemeScopedChatToolPreferences } from "./hooks/useThemeScopedChatToolPreferences";
import { useSelectedTeamPreference } from "./hooks/useSelectedTeamPreference";
import {
enableSubagentPreference,
resolveClawSolutionLaunch,
resolveClawSolutionSetupTarget,
} from "./claw-solutions/actionDispatcher";
import { useClawSolutions } from "./claw-solutions/useClawSolutions";
import { ClawHomeSolutionsPanel } from "./claw-solutions/ClawHomeSolutionsPanel";
import type { ClawSolutionHomeItem } from "./claw-solutions/types";
import {
type AgentChatWorkspaceBootstrap,
resolveHomeShellWorkspaceEntry,
type HomeShellEnterWorkspacePayload,
} from "./homeShellEntry";
const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
"general",
@@ -44,10 +48,6 @@ const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
"novel",
];
const HOME_ENHANCEMENT_IDLE_TIMEOUT_MS = 1_500;
const HOME_ENHANCEMENT_FALLBACK_DELAY_MS = 180;
const LAST_PROJECT_ID_KEY = "agent_last_project_id";
const PageContainer = styled.div<{ $compact?: boolean }>`
display: flex;
height: 100%;
@@ -152,107 +152,17 @@ function normalizeInitialTheme(value?: string): ThemeType {
return "general";
}
function scheduleDeferredHomeEnhancement(task: () => void): () => void {
if (typeof window === "undefined") {
return () => undefined;
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof window.requestIdleCallback === "function") {
const idleId = window.requestIdleCallback(() => task(), {
timeout: HOME_ENHANCEMENT_IDLE_TIMEOUT_MS,
});
return () => {
if (typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(idleId);
}
};
if (typeof error === "string") {
return error;
}
const timeoutId = window.setTimeout(task, HOME_ENHANCEMENT_FALLBACK_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
return "请稍后重试";
}
function loadPersistedProjectId(key: string): string | null {
try {
const stored = localStorage.getItem(key);
if (!stored) {
return null;
}
try {
const parsed = JSON.parse(stored);
return normalizeProjectId(typeof parsed === "string" ? parsed : stored);
} catch {
return normalizeProjectId(stored);
}
} catch {
return null;
}
}
function savePersistedProjectId(key: string, projectId: string) {
const normalized = normalizeProjectId(projectId);
if (!normalized) {
return;
}
try {
localStorage.setItem(key, JSON.stringify(normalized));
} catch {
// ignore write errors
}
}
function resolveExecutionStrategyStorageKey(
projectId?: string | null,
): string | null {
const normalizedProjectId = normalizeProjectId(projectId);
if (!normalizedProjectId) {
return null;
}
return `aster_execution_strategy_${normalizedProjectId}`;
}
function resolvePersistedProviderModel(projectId?: string | null): {
providerType: string;
model: string;
} {
const { providerKey, modelKey } = getAgentPreferenceKeys(projectId);
return {
providerType:
loadPersistedString(providerKey) ||
loadPersistedString(GLOBAL_PROVIDER_PREF_KEY) ||
DEFAULT_AGENT_PROVIDER,
model:
loadPersistedString(modelKey) ||
loadPersistedString(GLOBAL_MODEL_PREF_KEY) ||
DEFAULT_AGENT_MODEL,
};
}
function resolvePersistedExecutionStrategy(
projectId?: string | null,
): AsterExecutionStrategy {
const storageKey = resolveExecutionStrategyStorageKey(projectId);
if (!storageKey) {
return "react";
}
return normalizeExecutionStrategy(loadPersisted<string | null>(storageKey, "react"));
}
export interface AgentChatWorkspaceBootstrap {
projectId?: string;
initialUserPrompt?: string;
initialUserImages?: MessageImage[];
theme?: string;
initialCreationMode?: CreationMode;
openBrowserAssistOnMount?: boolean;
newChatAt?: number;
}
export type { AgentChatWorkspaceBootstrap } from "./homeShellEntry";
interface AgentChatHomeShellProps {
onNavigate?: (page: Page, params?: PageParams) => void;
@@ -277,35 +187,35 @@ export function AgentChatHomeShell({
const [creationMode, setCreationMode] = useState<CreationMode>(
initialCreationMode ?? "guided",
);
const [chatToolPreferences, setChatToolPreferences] =
useState<ChatToolPreferences>(() =>
loadChatToolPreferences(normalizedEntryTheme),
);
const [chatToolPreferencesTheme, setChatToolPreferencesTheme] =
useState<string>(normalizedEntryTheme);
const [currentProjectId, setCurrentProjectId] = useState<string | null>(
() =>
normalizeProjectId(externalProjectId) ??
loadPersistedProjectId(LAST_PROJECT_ID_KEY),
);
const initialProviderModel = resolvePersistedProviderModel(currentProjectId);
const [providerType, setProviderTypeState] = useState(
initialProviderModel.providerType,
);
const [model, setModelState] = useState(initialProviderModel.model);
const [executionStrategy, setExecutionStrategyState] =
useState<AsterExecutionStrategy>(() =>
resolvePersistedExecutionStrategy(currentProjectId),
);
const [projectMemory, setProjectMemory] = useState<ProjectMemory | null>(null);
const [skills, setSkills] = useState<Skill[]>([]);
const [skillsLoading, setSkillsLoading] = useState(false);
const { chatToolPreferences, setChatToolPreferences } =
useThemeScopedChatToolPreferences(activeTheme);
const {
projectId: currentProjectId,
setProjectId: setCurrentProjectId,
rememberProjectId,
} = usePersistedProjectId(externalProjectId, LAST_PROJECT_ID_KEY);
const {
providerType,
setProviderType,
model,
setModel,
executionStrategy,
setExecutionStrategy,
} = useHomeShellAgentPreferences(currentProjectId);
const projectMemory = useHomeShellProjectMemory(currentProjectId);
const { skills, skillsLoading, refreshSkills } = useHomeShellSkills();
const [browserAssistLoading, setBrowserAssistLoading] = useState(false);
const {
selectedTeam,
setSelectedTeam: handleSelectTeam,
enableSuggestedTeam: handleEnableSuggestedTeam,
} = useSelectedTeamPreference(activeTheme);
const {
solutions: clawSolutions,
isLoading: clawSolutionsLoading,
error: clawSolutionsError,
recordUsage: recordClawSolutionUsage,
} = useClawSolutions(activeTheme === "general");
useEffect(() => {
setActiveTheme(normalizeInitialTheme(initialTheme));
@@ -319,161 +229,16 @@ export function AgentChatHomeShell({
}, [initialCreationMode]);
useEffect(() => {
setCurrentProjectId(
normalizeProjectId(externalProjectId) ??
loadPersistedProjectId(LAST_PROJECT_ID_KEY),
);
}, [externalProjectId]);
useEffect(() => {
if (chatToolPreferencesTheme === activeTheme) {
if (activeTheme !== "general" || !clawSolutionsError) {
return;
}
setChatToolPreferences(loadChatToolPreferences(activeTheme));
setChatToolPreferencesTheme(activeTheme);
}, [activeTheme, chatToolPreferencesTheme]);
useEffect(() => {
if (chatToolPreferencesTheme !== activeTheme) {
return;
}
saveChatToolPreferences(chatToolPreferences, activeTheme);
}, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]);
useEffect(() => {
const nextPreferences = resolvePersistedProviderModel(currentProjectId);
setProviderTypeState(nextPreferences.providerType);
setModelState(nextPreferences.model);
setExecutionStrategyState(resolvePersistedExecutionStrategy(currentProjectId));
}, [currentProjectId]);
useEffect(() => {
const normalizedProjectId = normalizeProjectId(currentProjectId);
if (!normalizedProjectId) {
setProjectMemory(null);
return;
}
let cancelled = false;
const startedAt = Date.now();
logAgentDebug("AgentChatHomeShell", "loadProjectMemory.start", {
projectId: normalizedProjectId,
});
void getProjectMemory(normalizedProjectId)
.then((memory) => {
if (cancelled) {
return;
}
setProjectMemory(memory);
logAgentDebug("AgentChatHomeShell", "loadProjectMemory.success", {
durationMs: Date.now() - startedAt,
projectId: normalizedProjectId,
charactersCount: memory.characters.length,
});
})
.catch((error) => {
if (cancelled) {
return;
}
setProjectMemory(null);
logAgentDebug(
"AgentChatHomeShell",
"loadProjectMemory.error",
{
durationMs: Date.now() - startedAt,
error,
projectId: normalizedProjectId,
},
{ level: "warn" },
);
});
return () => {
cancelled = true;
};
}, [currentProjectId]);
const loadSkills = useCallback(
async (includeRemote = false): Promise<Skill[]> => {
const startedAt = Date.now();
logAgentDebug("AgentChatHomeShell", "loadSkills.start", {
includeRemote,
});
setSkillsLoading(true);
try {
const loadedSkills = includeRemote
? await skillsApi.getAll("lime")
: await skillsApi.getLocal("lime");
setSkills(loadedSkills);
logAgentDebug("AgentChatHomeShell", "loadSkills.success", {
durationMs: Date.now() - startedAt,
includeRemote,
skillsCount: loadedSkills.length,
});
return loadedSkills;
} catch (error) {
setSkills([]);
logAgentDebug(
"AgentChatHomeShell",
"loadSkills.error",
{
durationMs: Date.now() - startedAt,
error,
includeRemote,
},
{ level: "warn" },
);
return [];
} finally {
setSkillsLoading(false);
}
},
[],
);
useEffect(() => {
return scheduleDeferredHomeEnhancement(() => {
void loadSkills(false);
});
}, [loadSkills]);
const setProviderType = useCallback(
(nextProviderType: string) => {
setProviderTypeState(nextProviderType);
const { providerKey } = getAgentPreferenceKeys(currentProjectId);
savePersisted(providerKey, nextProviderType);
},
[currentProjectId],
);
const setModel = useCallback(
(nextModel: string) => {
setModelState(nextModel);
const { modelKey } = getAgentPreferenceKeys(currentProjectId);
savePersisted(modelKey, nextModel);
},
[currentProjectId],
);
const setExecutionStrategy = useCallback(
(nextExecutionStrategy: AsterExecutionStrategy) => {
const normalized = normalizeExecutionStrategy(nextExecutionStrategy);
setExecutionStrategyState(normalized);
const storageKey = resolveExecutionStrategyStorageKey(currentProjectId);
if (!storageKey) {
return;
}
savePersisted(storageKey, normalized);
},
[currentProjectId],
);
toast.error(`加载 Claw 方案失败:${clawSolutionsError}`);
}, [activeTheme, clawSolutionsError]);
const handleRefreshSkills = useCallback(async () => {
await loadSkills(true);
}, [loadSkills]);
await refreshSkills(true);
}, [refreshSkills]);
const handleProjectChange = useCallback(
(nextProjectId: string) => {
@@ -484,76 +249,143 @@ export function AgentChatHomeShell({
const normalizedProjectId = normalizeProjectId(nextProjectId);
setCurrentProjectId(normalizedProjectId);
if (normalizedProjectId) {
savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId);
rememberProjectId(normalizedProjectId);
}
},
[externalProjectId],
[externalProjectId, rememberProjectId, setCurrentProjectId],
);
const handleEnterWorkspace = useCallback(
(payload: {
prompt?: string;
images?: MessageImage[];
openBrowserAssistOnMount?: boolean;
toolPreferences?: ChatToolPreferences;
}) => {
(payload: HomeShellEnterWorkspacePayload) => {
const normalizedProjectId = normalizeProjectId(currentProjectId);
const hasPrompt = Boolean(payload.prompt?.trim());
const hasImages = Boolean(payload.images?.length);
const effectiveToolPreferences =
payload.toolPreferences ?? chatToolPreferences;
const resolved = resolveHomeShellWorkspaceEntry({
projectId: normalizedProjectId,
activeTheme,
creationMode,
defaultToolPreferences: chatToolPreferences,
payload,
});
if (!payload.openBrowserAssistOnMount && !normalizedProjectId) {
toast.error("缺少项目工作区,请先选择项目后再使用 Agent");
return;
}
if (!payload.openBrowserAssistOnMount && !hasPrompt && !hasImages) {
return;
if (!resolved.ok) {
if (resolved.reason === "missing_project") {
toast.error("缺少项目工作区,请先选择项目后再使用 Agent");
}
return false;
}
if (normalizedProjectId) {
savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId);
rememberProjectId(normalizedProjectId);
}
saveChatToolPreferences(effectiveToolPreferences, activeTheme);
const nextNewChatAt = Date.now();
saveChatToolPreferences(resolved.toolPreferences, resolved.targetTheme);
if (onNavigate) {
onNavigate(
"agent",
buildClawAgentParams({
projectId: normalizedProjectId ?? undefined,
theme: activeTheme,
initialCreationMode: creationMode,
initialUserPrompt: payload.prompt,
initialUserImages: payload.images,
openBrowserAssistOnMount: payload.openBrowserAssistOnMount,
newChatAt: nextNewChatAt,
}),
);
return;
onNavigate("agent", resolved.navigationParams);
return true;
}
onEnterWorkspace({
projectId: normalizedProjectId ?? undefined,
initialUserPrompt: payload.prompt,
initialUserImages: payload.images,
theme: activeTheme,
initialCreationMode: creationMode,
openBrowserAssistOnMount: payload.openBrowserAssistOnMount,
newChatAt: nextNewChatAt,
});
onEnterWorkspace(resolved.workspaceBootstrap);
return true;
},
[
activeTheme,
chatToolPreferences,
creationMode,
currentProjectId,
rememberProjectId,
onEnterWorkspace,
onNavigate,
],
);
const handleClawSolutionSelect = useCallback(
async (solution: ClawSolutionHomeItem) => {
try {
const preparation = await prepareClawSolution(solution.id, {
projectId: normalizeProjectId(currentProjectId) ?? undefined,
userInput: input.trim() || undefined,
});
if (preparation.readiness !== "ready") {
const setupTab = resolveClawSolutionSetupTarget(
preparation.readiness,
preparation.reasonCode,
);
if (setupTab && onNavigate) {
onNavigate("settings", { tab: setupTab });
return;
}
toast.error(preparation.readinessMessage);
return;
}
const launch = resolveClawSolutionLaunch(
preparation,
chatToolPreferences,
);
const targetTheme =
launch.enterWorkspacePayload.themeOverride ?? activeTheme;
try {
const providerSelection = await resolveClawWorkspaceProviderSelection({
currentProviderType: providerType,
currentModel: model,
theme: targetTheme,
});
if (providerSelection) {
if (providerSelection.providerType !== providerType) {
setProviderType(providerSelection.providerType);
}
if (providerSelection.model !== model) {
setModel(providerSelection.model);
}
}
} catch (selectionError) {
console.warn(
"[AgentChatHomeShell] 解析 Claw 工作区默认 provider/model 失败,继续沿用当前选择:",
selectionError,
);
}
if (launch.preferencesChanged) {
setChatToolPreferences(launch.nextToolPreferences);
}
if (launch.shouldStartBrowserAssistLoading) {
setBrowserAssistLoading(true);
}
const entered = handleEnterWorkspace(launch.enterWorkspacePayload);
if (!entered) {
if (launch.shouldStartBrowserAssistLoading) {
setBrowserAssistLoading(false);
}
return;
}
recordClawSolutionUsage(launch.usageRecord);
} catch (error) {
setBrowserAssistLoading(false);
toast.error(`启动方案失败:${getErrorMessage(error)}`);
}
},
[
chatToolPreferences,
currentProjectId,
handleEnterWorkspace,
input,
model,
onNavigate,
providerType,
recordClawSolutionUsage,
setChatToolPreferences,
setModel,
setProviderType,
activeTheme,
],
);
const handleRecommendationClick = useCallback(
(shortLabel: string, fullPrompt: string) => {
setInput(fullPrompt);
@@ -565,14 +397,10 @@ export function AgentChatHomeShell({
return;
}
const nextToolPreferences = chatToolPreferences.subagent
? chatToolPreferences
: {
...chatToolPreferences,
subagent: true,
};
const { nextToolPreferences, changed } =
enableSubagentPreference(chatToolPreferences);
if (!chatToolPreferences.subagent) {
if (changed) {
setChatToolPreferences(nextToolPreferences);
}
saveChatToolPreferences(nextToolPreferences, activeTheme);
@@ -581,7 +409,12 @@ export function AgentChatHomeShell({
toolPreferences: nextToolPreferences,
});
},
[activeTheme, chatToolPreferences, handleEnterWorkspace],
[
activeTheme,
chatToolPreferences,
handleEnterWorkspace,
setChatToolPreferences,
],
);
return (
@@ -658,6 +491,15 @@ export function AgentChatHomeShell({
hasContentId={false}
selectedText=""
onRecommendationClick={handleRecommendationClick}
supportingSlotOverride={
activeTheme === "general" ? (
<ClawHomeSolutionsPanel
solutions={clawSolutions}
loading={clawSolutionsLoading}
onSelect={handleClawSolutionSelect}
/>
) : undefined
}
characters={projectMemory?.characters || []}
skills={skills}
isSkillsLoading={skillsLoading}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ClawHomeSolutionsPanel } from "./ClawHomeSolutionsPanel";
import type { ClawSolutionHomeItem } from "./types";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
function renderPanel(
props: React.ComponentProps<typeof ClawHomeSolutionsPanel>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(<ClawHomeSolutionsPanel {...props} />);
});
mountedRoots.push({ root, container });
return container;
}
describe("ClawHomeSolutionsPanel", () => {
it("应渲染方案信息并透传选择回调", () => {
const solutions: ClawSolutionHomeItem[] = [
{
id: "social-post-starter",
title: "社媒主稿生成",
summary: "进入社媒专项工作台并生成一版首稿。",
outputHint: "社媒首稿 + 平台结构",
recommendedCapabilities: ["模型", "社媒主题"],
readiness: "ready",
readinessMessage: "可直接开始",
badge: "社媒方案",
recentUsedAt: null,
isRecent: false,
readinessLabel: "可直接开始",
readinessTone: "emerald",
},
];
const onSelect = vi.fn();
const container = renderPanel({
solutions,
onSelect,
});
expect(container.textContent).toContain("推荐方案");
expect(container.textContent).toContain("社媒主稿生成");
expect(container.textContent).toContain("产出:社媒首稿 + 平台结构");
expect(container.textContent).toContain("可直接开始");
expect(container.textContent).toContain("立即开始");
const solutionButton = container.querySelector(
'[data-testid="claw-solution-social-post-starter"]',
) as HTMLButtonElement | null;
expect(solutionButton).toBeTruthy();
act(() => {
solutionButton?.click();
});
expect(onSelect).toHaveBeenCalledWith(solutions[0]);
});
it("加载中且无方案时应展示加载状态", () => {
const container = renderPanel({
solutions: [],
loading: true,
onSelect: vi.fn(),
});
expect(container.textContent).toContain("正在加载推荐方案");
});
it("未就绪方案应展示去配置动作文案", () => {
const solutions: ClawSolutionHomeItem[] = [
{
id: "web-research-brief",
title: "网页研究简报",
summary: "先配置模型后再开始。",
outputHint: "研究提纲 + 结论摘要",
recommendedCapabilities: ["模型"],
readiness: "needs_setup",
readinessMessage: "请先配置模型",
reasonCode: "missing_model",
badge: "Claw 方案",
recentUsedAt: null,
isRecent: false,
readinessLabel: "先配置模型",
readinessTone: "amber",
},
];
const container = renderPanel({
solutions,
onSelect: vi.fn(),
});
expect(container.textContent).toContain("先配置模型");
expect(container.textContent).toContain("去配置");
});
});
@@ -0,0 +1,54 @@
import { useMemo } from "react";
import { EmptyStateQuickActions } from "../components/EmptyStateQuickActions";
import type { ClawSolutionHomeItem } from "./types";
interface ClawHomeSolutionsPanelProps {
solutions: ClawSolutionHomeItem[];
loading?: boolean;
onSelect: (solution: ClawSolutionHomeItem) => void | Promise<void>;
}
export function ClawHomeSolutionsPanel({
solutions,
loading = false,
onSelect,
}: ClawHomeSolutionsPanelProps) {
const items = useMemo(
() =>
solutions.map((solution) => ({
key: solution.id,
title: solution.title,
description: solution.summary,
badge: solution.badge,
prompt: "",
actionLabel: solution.readiness === "ready" ? "立即开始" : "去配置",
outputHint: solution.outputHint,
statusLabel: solution.readinessLabel,
statusTone: solution.readinessTone,
statusDescription: solution.readinessMessage,
solutionId: solution.id,
testId: `claw-solution-${solution.id}`,
})),
[solutions],
);
return (
<EmptyStateQuickActions
title="推荐方案"
description="先选一个方案,Claw 会自动进入对应工作模式并带好起始动作。"
items={items}
embedded
loading={loading}
onAction={(item) => {
const solution = solutions.find(
(candidate) => candidate.id === item.solutionId,
);
if (solution) {
void onSelect(solution);
}
}}
/>
);
}
export default ClawHomeSolutionsPanel;
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
enableSubagentPreference,
resolveClawSolutionLaunch,
resolveClawSolutionSetupTarget,
} from "./actionDispatcher";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { ClawSolutionPreparation } from "./types";
import { SettingsTabs } from "@/types/settings";
const defaultPreferences: ChatToolPreferences = {
webSearch: false,
thinking: false,
task: false,
subagent: false,
};
describe("claw solution action dispatcher", () => {
it("应在未开启时启用多代理偏好", () => {
expect(enableSubagentPreference(defaultPreferences)).toEqual({
nextToolPreferences: {
webSearch: false,
thinking: false,
task: false,
subagent: true,
},
changed: true,
});
});
it("已开启多代理时应保持原偏好", () => {
const current = {
...defaultPreferences,
subagent: true,
};
expect(enableSubagentPreference(current)).toEqual({
nextToolPreferences: current,
changed: false,
});
});
it("应把社媒方案转换为工作区进入载荷", () => {
const preparation: ClawSolutionPreparation = {
solutionId: "social-post-starter",
actionType: "navigate_theme",
prompt: "请先帮我起草一版社媒内容首稿",
themeTarget: "social-media",
shouldLaunchBrowserAssist: false,
shouldEnableTeamMode: false,
readiness: "ready",
readinessMessage: "可直接开始",
};
expect(resolveClawSolutionLaunch(preparation, defaultPreferences)).toEqual({
nextToolPreferences: defaultPreferences,
preferencesChanged: false,
shouldStartBrowserAssistLoading: false,
enterWorkspacePayload: {
prompt: "请先帮我起草一版社媒内容首稿",
openBrowserAssistOnMount: false,
toolPreferences: defaultPreferences,
themeOverride: "social-media",
},
usageRecord: {
solutionId: "social-post-starter",
actionType: "navigate_theme",
themeTarget: "social-media",
},
});
});
it("应把多代理方案转换为开启 team 模式的载荷", () => {
const preparation: ClawSolutionPreparation = {
solutionId: "team-breakdown",
actionType: "enable_team_mode",
prompt: "请把这个任务按多代理方式拆解",
shouldLaunchBrowserAssist: false,
shouldEnableTeamMode: true,
readiness: "ready",
readinessMessage: "可直接开始,进入后会启用多代理偏好",
};
expect(resolveClawSolutionLaunch(preparation, defaultPreferences)).toEqual({
nextToolPreferences: {
webSearch: false,
thinking: false,
task: false,
subagent: true,
},
preferencesChanged: true,
shouldStartBrowserAssistLoading: false,
enterWorkspacePayload: {
prompt: "请把这个任务按多代理方式拆解",
openBrowserAssistOnMount: false,
toolPreferences: {
webSearch: false,
thinking: false,
task: false,
subagent: true,
},
themeOverride: undefined,
},
usageRecord: {
solutionId: "team-breakdown",
actionType: "enable_team_mode",
themeTarget: null,
},
});
});
it("应把未就绪方案映射到对应配置入口", () => {
expect(
resolveClawSolutionSetupTarget("needs_setup", "missing_model"),
).toBe(SettingsTabs.Providers);
expect(
resolveClawSolutionSetupTarget(
"needs_capability",
"missing_skill_dependency",
),
).toBe(SettingsTabs.Skills);
expect(
resolveClawSolutionSetupTarget(
"needs_capability",
"missing_browser_capability",
),
).toBe(SettingsTabs.ChromeRelay);
});
});
@@ -0,0 +1,95 @@
import { SettingsTabs } from "@/types/settings";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type {
ClawSolutionPreparation,
ClawSolutionReadiness,
ClawSolutionReasonCode,
RecordClawSolutionUsageInput,
} from "./types";
export interface ClawSolutionEnterWorkspacePayload {
prompt: string;
openBrowserAssistOnMount?: boolean;
toolPreferences: ChatToolPreferences;
themeOverride?: string;
}
export interface ResolvedClawSolutionLaunch {
nextToolPreferences: ChatToolPreferences;
preferencesChanged: boolean;
shouldStartBrowserAssistLoading: boolean;
enterWorkspacePayload: ClawSolutionEnterWorkspacePayload;
usageRecord: RecordClawSolutionUsageInput;
}
export function resolveClawSolutionSetupTarget(
readiness: ClawSolutionReadiness,
reasonCode?: ClawSolutionReasonCode,
): SettingsTabs | null {
if (readiness === "needs_setup") {
return SettingsTabs.Providers;
}
if (readiness !== "needs_capability") {
return null;
}
if (reasonCode === "missing_skill_dependency") {
return SettingsTabs.Skills;
}
if (reasonCode === "missing_browser_capability") {
return SettingsTabs.ChromeRelay;
}
return SettingsTabs.Providers;
}
export function enableSubagentPreference(preferences: ChatToolPreferences): {
nextToolPreferences: ChatToolPreferences;
changed: boolean;
} {
if (preferences.subagent) {
return {
nextToolPreferences: preferences,
changed: false,
};
}
return {
nextToolPreferences: {
...preferences,
subagent: true,
},
changed: true,
};
}
export function resolveClawSolutionLaunch(
preparation: ClawSolutionPreparation,
currentToolPreferences: ChatToolPreferences,
): ResolvedClawSolutionLaunch {
const { nextToolPreferences, changed } = preparation.shouldEnableTeamMode
? enableSubagentPreference(currentToolPreferences)
: {
nextToolPreferences: currentToolPreferences,
changed: false,
};
return {
nextToolPreferences,
preferencesChanged: changed,
shouldStartBrowserAssistLoading: preparation.shouldLaunchBrowserAssist,
enterWorkspacePayload: {
prompt: preparation.prompt,
openBrowserAssistOnMount: preparation.shouldLaunchBrowserAssist,
toolPreferences: nextToolPreferences,
themeOverride: preparation.themeTarget,
},
usageRecord: {
solutionId: preparation.solutionId,
actionType: preparation.actionType,
themeTarget: preparation.themeTarget ?? null,
},
};
}
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
getClawSolutionUsageMap,
listClawSolutionUsage,
recordClawSolutionUsage,
} from "./storage";
describe("claw solution storage", () => {
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
});
it("应按最近时间记录方案使用", () => {
recordClawSolutionUsage({
solutionId: "web-research-brief",
usedAt: 100,
actionType: "fill_input",
});
recordClawSolutionUsage({
solutionId: "team-breakdown",
usedAt: 300,
actionType: "enable_team_mode",
});
recordClawSolutionUsage({
solutionId: "web-research-brief",
usedAt: 500,
actionType: "fill_input",
themeTarget: "general",
});
expect(listClawSolutionUsage()).toEqual([
{
solutionId: "web-research-brief",
usedAt: 500,
actionType: "fill_input",
themeTarget: "general",
},
{
solutionId: "team-breakdown",
usedAt: 300,
actionType: "enable_team_mode",
themeTarget: null,
},
]);
});
it("应提供按方案 ID 查询的映射", () => {
recordClawSolutionUsage({
solutionId: "social-post-starter",
usedAt: 200,
actionType: "navigate_theme",
themeTarget: "social-media",
});
const usageMap = getClawSolutionUsageMap();
expect(usageMap.get("social-post-starter")).toEqual({
solutionId: "social-post-starter",
usedAt: 200,
actionType: "navigate_theme",
themeTarget: "social-media",
});
});
});
@@ -0,0 +1,87 @@
import type {
ClawSolutionUsageRecord,
RecordClawSolutionUsageInput,
} from "./types";
const CLAW_SOLUTION_USAGE_STORAGE_KEY = "lime:claw-solution-usage:v1";
const MAX_CLAW_SOLUTION_USAGE_RECORDS = 12;
function isValidUsageRecord(value: unknown): value is ClawSolutionUsageRecord {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Partial<ClawSolutionUsageRecord>;
return (
typeof record.solutionId === "string" &&
record.solutionId.length > 0 &&
typeof record.usedAt === "number" &&
Number.isFinite(record.usedAt)
);
}
export function listClawSolutionUsage(): ClawSolutionUsageRecord[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = window.localStorage.getItem(CLAW_SOLUTION_USAGE_STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter(isValidUsageRecord)
.sort((left, right) => right.usedAt - left.usedAt)
.slice(0, MAX_CLAW_SOLUTION_USAGE_RECORDS);
} catch {
return [];
}
}
export function getClawSolutionUsageMap(): Map<
string,
ClawSolutionUsageRecord
> {
return new Map(
listClawSolutionUsage().map((record) => [record.solutionId, record]),
);
}
export function recordClawSolutionUsage(
input: RecordClawSolutionUsageInput,
): ClawSolutionUsageRecord[] {
const nextRecord: ClawSolutionUsageRecord = {
solutionId: input.solutionId,
usedAt: input.usedAt ?? Date.now(),
actionType: input.actionType,
themeTarget: input.themeTarget ?? null,
};
const nextRecords = [
nextRecord,
...listClawSolutionUsage().filter(
(record) => record.solutionId !== nextRecord.solutionId,
),
].slice(0, MAX_CLAW_SOLUTION_USAGE_RECORDS);
if (typeof window === "undefined") {
return nextRecords;
}
try {
window.localStorage.setItem(
CLAW_SOLUTION_USAGE_STORAGE_KEY,
JSON.stringify(nextRecords),
);
} catch {
// ignore write errors
}
return nextRecords;
}
@@ -0,0 +1,43 @@
import type {
ClawSolutionActionType,
ClawSolutionDetail,
ClawSolutionPreparation,
ClawSolutionReadiness,
ClawSolutionReadinessResult,
ClawSolutionReasonCode,
ClawSolutionSummary,
} from "@/lib/api/clawSolutions";
export type {
ClawSolutionActionType,
ClawSolutionDetail,
ClawSolutionPreparation,
ClawSolutionReadiness,
ClawSolutionReadinessResult,
ClawSolutionReasonCode,
ClawSolutionSummary,
};
export type ClawSolutionTone = "slate" | "sky" | "emerald" | "amber";
export interface ClawSolutionHomeItem extends ClawSolutionSummary {
badge: string;
recentUsedAt: number | null;
isRecent: boolean;
readinessLabel: string;
readinessTone: ClawSolutionTone;
}
export interface ClawSolutionUsageRecord {
solutionId: string;
usedAt: number;
actionType?: ClawSolutionActionType;
themeTarget?: string | null;
}
export interface RecordClawSolutionUsageInput {
solutionId: string;
usedAt?: number;
actionType?: ClawSolutionActionType;
themeTarget?: string | null;
}
@@ -0,0 +1,153 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { listClawSolutions } from "@/lib/api/clawSolutions";
import { getClawSolutionUsageMap, recordClawSolutionUsage } from "./storage";
import type {
ClawSolutionHomeItem,
ClawSolutionReadiness,
ClawSolutionTone,
ClawSolutionSummary,
RecordClawSolutionUsageInput,
} from "./types";
function getReadinessLabel(readiness: ClawSolutionReadiness): string {
if (readiness === "needs_setup") {
return "先配置模型";
}
if (readiness === "needs_capability") {
return "补齐能力后开始";
}
return "可直接开始";
}
function getReadinessTone(readiness: ClawSolutionReadiness): ClawSolutionTone {
if (readiness === "needs_setup") {
return "amber";
}
if (readiness === "needs_capability") {
return "sky";
}
return "emerald";
}
function getSolutionBadge(
summary: ClawSolutionSummary,
isRecent: boolean,
): string {
if (isRecent) {
return "最近使用";
}
if (summary.id === "social-post-starter") {
return "社媒方案";
}
if (summary.id === "browser-assist-task") {
return "浏览器协助";
}
if (summary.id === "team-breakdown") {
return "多代理";
}
return "Claw 方案";
}
function buildHomeItems(
summaries: ClawSolutionSummary[],
): ClawSolutionHomeItem[] {
const usageMap = getClawSolutionUsageMap();
const items: InternalClawSolutionHomeItem[] = summaries.map(
(summary, index) => {
const recent = usageMap.get(summary.id);
const recentUsedAt = recent?.usedAt ?? null;
const isRecent = typeof recentUsedAt === "number";
return {
...summary,
badge: getSolutionBadge(summary, isRecent),
recentUsedAt,
isRecent,
readinessLabel: getReadinessLabel(summary.readiness),
readinessTone: getReadinessTone(summary.readiness),
_sortIndex: index,
};
},
);
return items
.sort((left, right) => {
if (left.recentUsedAt && right.recentUsedAt) {
if (left.recentUsedAt !== right.recentUsedAt) {
return right.recentUsedAt - left.recentUsedAt;
}
} else if (left.recentUsedAt) {
return -1;
} else if (right.recentUsedAt) {
return 1;
}
return left._sortIndex - right._sortIndex;
})
.map(({ _sortIndex, ...item }) => item);
}
type InternalClawSolutionHomeItem = ClawSolutionHomeItem & {
_sortIndex: number;
};
interface UseClawSolutionsResult {
solutions: ClawSolutionHomeItem[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
recordUsage: (input: RecordClawSolutionUsageInput) => void;
}
export function useClawSolutions(enabled = true): UseClawSolutionsResult {
const [summaries, setSummaries] = useState<ClawSolutionSummary[]>([]);
const [isLoading, setIsLoading] = useState(enabled);
const [error, setError] = useState<string | null>(null);
const [usageVersion, setUsageVersion] = useState(0);
const refresh = useCallback(async () => {
if (!enabled) {
setSummaries([]);
setError(null);
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const nextSummaries = await listClawSolutions();
setSummaries(nextSummaries);
setError(null);
} catch (reason) {
setSummaries([]);
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
setIsLoading(false);
}
}, [enabled]);
useEffect(() => {
void refresh();
}, [refresh]);
const recordUsageAndRefresh = useCallback(
(input: RecordClawSolutionUsageInput) => {
recordClawSolutionUsage(input);
setUsageVersion((previous) => previous + 1);
},
[],
);
const solutions = useMemo(() => {
void usageVersion;
return buildHomeItems(summaries);
}, [summaries, usageVersion]);
return {
solutions,
isLoading,
error,
refresh,
recordUsage: recordUsageAndRefresh,
};
}
@@ -758,6 +758,9 @@ describe("AgentThreadTimeline", () => {
clickTimelineToggle(container);
expect(container.textContent).toContain("图片任务 1");
expect(container.textContent).not.toContain("Image #1");
const button = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((element) => element.textContent?.includes("打开子会话"));
@@ -35,6 +35,7 @@ import {
type AgentThreadSummaryChip,
} from "../utils/agentThreadGrouping";
import { isActionRequestA2UICompatible } from "../utils/actionRequestA2UI";
import { resolveInternalImageTaskDisplayName } from "../utils/internalImagePlaceholder";
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
import { TIMELINE_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
@@ -725,11 +726,13 @@ function renderGroupItemDetails(
if (item.type === "subagent_activity") {
const subagentSessionId = item.session_id?.trim();
const displayTitle =
resolveInternalImageTaskDisplayName(item.title) || "子代理协作";
return (
<SurfaceCard
icon={Bot}
title={item.title || "子代理协作"}
title={displayTitle}
badge={
<Badge variant={resolveStatusBadgeVariant(item.status)}>
{item.status_label}
@@ -48,6 +48,7 @@ vi.mock("@/lib/api/fileBrowser", () => ({
interface MountedHarness {
container: HTMLDivElement;
root: Root;
rerender: (props: React.ComponentProps<typeof CanvasWorkbenchLayout>) => void;
}
const mountedRoots: MountedHarness[] = [];
@@ -95,16 +96,34 @@ function createTaskFile(
function mount(
props: React.ComponentProps<typeof CanvasWorkbenchLayout>,
): HTMLDivElement {
return mountHarness(props).container;
}
function mountHarness(
props: React.ComponentProps<typeof CanvasWorkbenchLayout>,
): MountedHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let currentProps = props;
act(() => {
root.render(<CanvasWorkbenchLayout {...props} />);
root.render(<CanvasWorkbenchLayout {...currentProps} />);
});
mountedRoots.push({ container, root });
return container;
const harness: MountedHarness = {
container,
root,
rerender: (nextProps) => {
currentProps = nextProps;
act(() => {
root.render(<CanvasWorkbenchLayout {...currentProps} />);
});
},
};
mountedRoots.push(harness);
return harness;
}
async function flushEffects(times = 6) {
@@ -115,7 +134,7 @@ async function flushEffects(times = 6) {
}
}
async function resizeWorkbench(width: number) {
async function resizeWorkbench(width: number, height = 720) {
resizeObserverState.width = width;
await act(async () => {
resizeObserverState.observers.forEach((observer) => {
@@ -128,7 +147,7 @@ async function resizeWorkbench(width: number) {
target: observer.target,
contentRect: {
width,
height: 720,
height,
},
},
],
@@ -445,7 +464,187 @@ describe("CanvasWorkbenchLayout", () => {
expect(container.textContent).toContain("该文件为二进制内容");
});
it("容器变窄时应切换为右侧抽屉布局并保持工作台可展开收起", async () => {
it("启用 teamView 且没有默认预览时应优先展示 Team Workbench", async () => {
const renderPreview = vi.fn((_target: CanvasWorkbenchPreviewTarget) => (
<div data-testid="fallback-preview">fallback</div>
));
const renderTeamPreview = vi.fn(
(_options?: { stackedWorkbenchTrigger?: React.ReactNode }) => (
<div data-testid="team-preview">team-preview</div>
),
);
const renderTeamPanel = vi.fn(() => (
<div data-testid="team-panel">team-panel</div>
));
const container = mount({
artifacts: [],
canvasState: null,
taskFiles: [],
workspaceRoot: "/workspace",
workspaceUnavailable: false,
defaultPreview: null,
loadFilePreview: vi.fn(async (path: string) => ({
path,
content: "",
isBinary: false,
size: 0,
error: null,
})),
onOpenPath: vi.fn(async () => undefined),
onRevealPath: vi.fn(async () => undefined),
renderPreview,
teamView: {
enabled: true,
title: "Team Workbench",
subtitle: "多 agent 实时协作",
renderPreview: renderTeamPreview,
renderPanel: renderTeamPanel,
},
});
await flushEffects();
expect(container.querySelector('[data-testid="team-preview"]')).not.toBeNull();
expect(container.querySelector('[data-testid="team-panel"]')).not.toBeNull();
expect(container.textContent).toContain("多 agent 实时协作");
expect(renderTeamPreview).toHaveBeenCalled();
expect(renderTeamPanel).toHaveBeenCalled();
expect(renderPreview).not.toHaveBeenCalled();
});
it("teamView 的 autoFocusToken 变化时应切到 Team Workbench", async () => {
const renderPreview = vi.fn((target: CanvasWorkbenchPreviewTarget) => (
<div data-testid="fallback-preview">
fallback:{target.kind}
</div>
));
const renderTeamPreview = vi.fn(
(_options?: { stackedWorkbenchTrigger?: React.ReactNode }) => (
<div data-testid="team-preview">team-preview</div>
),
);
const renderTeamPanel = vi.fn(() => (
<div data-testid="team-panel">team-panel</div>
));
const baseProps: React.ComponentProps<typeof CanvasWorkbenchLayout> = {
artifacts: [
createArtifact("artifact-1", "draft.md", "标题\n当前内容", 20),
],
canvasState: null,
taskFiles: [],
workspaceRoot: "/workspace",
workspaceUnavailable: false,
defaultPreview: {
selectionKey: "artifact:artifact-1",
title: "draft.md",
content: "标题\n当前内容",
filePath: "draft.md",
absolutePath: "/workspace/draft.md",
previousContent: null,
},
loadFilePreview: vi.fn(async (path: string) => ({
path,
content: "",
isBinary: false,
size: 0,
error: null,
})),
onOpenPath: vi.fn(async () => undefined),
onRevealPath: vi.fn(async () => undefined),
renderPreview,
teamView: {
enabled: true,
title: "Team Workbench",
subtitle: "多成员实时协作",
autoFocusToken: 1,
renderPreview: renderTeamPreview,
renderPanel: renderTeamPanel,
},
};
const harness = mountHarness(baseProps);
await flushEffects();
expect(harness.container.querySelector('[data-testid="team-preview"]')).toBeNull();
expect(harness.container.querySelector('[data-testid="team-panel"]')).toBeNull();
expect(harness.container.querySelector('[data-testid="fallback-preview"]')).not.toBeNull();
harness.rerender({
...baseProps,
teamView: {
...baseProps.teamView!,
autoFocusToken: 2,
},
});
await flushEffects();
expect(harness.container.querySelector('[data-testid="team-preview"]')).not.toBeNull();
expect(harness.container.querySelector('[data-testid="team-panel"]')).not.toBeNull();
expect(harness.container.textContent).toContain("多成员实时协作");
});
it("teamView 存在活动态提示时,应在窄屏悬浮入口显示状态标签", async () => {
const container = mount({
artifacts: [
createArtifact("artifact-1", "draft.md", "标题\n当前内容", 20),
],
canvasState: null,
taskFiles: [],
workspaceRoot: "/workspace",
workspaceUnavailable: false,
defaultPreview: {
selectionKey: "artifact:artifact-1",
title: "draft.md",
content: "标题\n当前内容",
filePath: "draft.md",
absolutePath: "/workspace/draft.md",
previousContent: null,
},
loadFilePreview: vi.fn(async (path: string) => ({
path,
content: "",
isBinary: false,
size: 0,
error: null,
})),
onOpenPath: vi.fn(async () => undefined),
onRevealPath: vi.fn(async () => undefined),
renderPreview: (target, options) => (
<div data-testid="preview-panel">
{options?.stackedWorkbenchTrigger}
{target.kind}:{target.title}
</div>
),
teamView: {
enabled: true,
title: "Team Workbench",
subtitle: "多成员实时协作",
triggerState: {
tone: "active",
label: "组建中",
},
renderPreview: () => <div data-testid="team-preview">team-preview</div>,
renderPanel: () => <div data-testid="team-panel">team-panel</div>,
},
});
await flushEffects();
await resizeWorkbench(820);
await flushEffects();
const trigger = container.querySelector<HTMLElement>(
'[data-testid="canvas-workbench-trigger"]',
);
expect(trigger).toBeTruthy();
expect(trigger?.textContent).toContain("工作台");
expect(trigger?.textContent).toContain("组建中");
expect(trigger?.className).toContain("bg-sky-50");
});
it("容器变窄时应切换为底部工作台布局并保持工作台可展开收起", async () => {
const container = mount({
artifacts: [
createArtifact("artifact-new", "draft.md", "标题\n产物版本", 20),
@@ -506,13 +705,17 @@ describe("CanvasWorkbenchLayout", () => {
expect(
container.querySelector('button[aria-label="展开画布工作台"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="canvas-workbench-trigger"]')
?.textContent,
).toContain("工作台");
clickButtonByLabel(container, "展开画布工作台");
expect(
container
.querySelector('[data-testid="canvas-workbench-layout"]')
?.getAttribute("data-panel-placement"),
).toBe("overlay-right");
).toBe("overlay-bottom");
expect(
container.querySelector('button[aria-label="折叠画布工作台"]'),
).not.toBeNull();
@@ -526,4 +729,110 @@ describe("CanvasWorkbenchLayout", () => {
clickButtonByLabel(container, "切换画布标签-预览");
expect(container.textContent).toContain("当前画布正文");
});
it("窄屏底部工作台应支持拖拽调整高度", async () => {
const container = mount({
artifacts: [
createArtifact("artifact-new", "draft.md", "标题\n产物版本", 20),
],
canvasState: null,
taskFiles: [],
workspaceRoot: "/workspace",
workspaceUnavailable: false,
defaultPreview: {
selectionKey: "artifact:artifact-new",
title: "draft.md",
content: "标题\n产物版本",
filePath: "draft.md",
absolutePath: "/workspace/draft.md",
previousContent: "标题\n上一版本",
} satisfies CanvasWorkbenchDefaultPreview,
loadFilePreview: vi.fn(async (path: string) => ({
path,
content: "README 内容",
isBinary: false,
size: 12,
error: null,
})),
onOpenPath: vi.fn(async () => undefined),
onRevealPath: vi.fn(async () => undefined),
renderPreview: (target, options) => (
<div data-testid="preview-panel">
{options?.stackedWorkbenchTrigger}
{target.kind}:{target.title}
</div>
),
});
await flushEffects();
await resizeWorkbench(820, 640);
await flushEffects();
clickButtonByLabel(container, "展开画布工作台");
await flushEffects();
const layout = container.querySelector<HTMLElement>(
'[data-testid="canvas-workbench-layout"]',
);
const resizeHandle = container.querySelector<HTMLElement>(
'[data-testid="canvas-workbench-resize-handle"]',
);
expect(layout).toBeTruthy();
expect(resizeHandle).toBeTruthy();
const initialHeight = Number.parseFloat(layout?.style.height || "0");
expect(initialHeight).toBeGreaterThan(0);
await act(async () => {
resizeHandle?.dispatchEvent(
new MouseEvent("mousedown", {
bubbles: true,
clientY: 520,
}),
);
window.dispatchEvent(
new MouseEvent("mousemove", {
bubbles: true,
clientY: 420,
}),
);
window.dispatchEvent(
new MouseEvent("mouseup", {
bubbles: true,
clientY: 420,
}),
);
await Promise.resolve();
});
const expandedHeight = Number.parseFloat(layout?.style.height || "0");
expect(expandedHeight).toBeGreaterThan(initialHeight);
await act(async () => {
resizeHandle?.dispatchEvent(
new MouseEvent("mousedown", {
bubbles: true,
clientY: 420,
}),
);
window.dispatchEvent(
new MouseEvent("mousemove", {
bubbles: true,
clientY: 560,
}),
);
window.dispatchEvent(
new MouseEvent("mouseup", {
bubbles: true,
clientY: 560,
}),
);
await Promise.resolve();
});
const reducedHeight = Number.parseFloat(layout?.style.height || "0");
expect(reducedHeight).toBeLessThan(expandedHeight);
expect(reducedHeight).toBeGreaterThanOrEqual(220);
});
});
@@ -5,6 +5,7 @@ import {
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import {
@@ -43,7 +44,12 @@ import {
type CanvasWorkbenchDiffLine,
} from "../utils/canvasWorkbenchDiff";
type CanvasWorkbenchTab = "artifacts" | "files" | "changes" | "preview";
type CanvasWorkbenchTab =
| "artifacts"
| "files"
| "changes"
| "preview"
| "team";
export type CanvasWorkbenchLayoutMode = "split" | "stacked";
interface CanvasWorkbenchEntryBase {
@@ -126,8 +132,26 @@ export type CanvasWorkbenchPreviewTarget =
| {
kind: "empty";
title: string;
}
| {
kind: "team-workbench";
title: string;
};
export interface CanvasWorkbenchTeamView {
enabled: boolean;
title?: string;
subtitle?: string;
autoFocusToken?: string | number | null;
triggerState?: {
tone: "idle" | "active" | "error";
label?: string | null;
} | null;
renderPreview: (options?: { stackedWorkbenchTrigger?: ReactNode }) => ReactNode;
renderPanel?: () => ReactNode;
renderFooter?: () => ReactNode;
}
interface WorkspaceFileSelection {
path: string;
title: string;
@@ -155,20 +179,14 @@ export interface CanvasWorkbenchLayoutProps {
},
) => ReactNode;
onLayoutModeChange?: (mode: CanvasWorkbenchLayoutMode) => void;
teamView?: CanvasWorkbenchTeamView | null;
}
const TAB_META: Array<{ key: CanvasWorkbenchTab; label: string }> = [
{ key: "artifacts", label: "产物" },
{ key: "files", label: "全部文件" },
{ key: "changes", label: "变更" },
{ key: "preview", label: "预览" },
];
const WORKBENCH_PANEL_CLASSNAME =
"rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.94)_100%)] shadow-sm shadow-slate-950/5";
"rounded-[24px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5";
const WORKBENCH_MUTED_PANEL_CLASSNAME =
"rounded-[24px] border border-dashed border-slate-200/90 bg-slate-50/82 px-4 py-6 text-sm text-slate-500";
"rounded-[24px] border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-sm text-slate-500";
const WORKBENCH_BUTTON_CLASSNAME =
"border-slate-200/80 bg-white/90 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900";
@@ -181,6 +199,47 @@ const WORKBENCH_GHOST_BUTTON_CLASSNAME =
const STACKED_LAYOUT_BREAKPOINT = 1040;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function resolveStackedWorkbenchMetrics(shellHeight: number): {
minHeight: number;
maxHeight: number;
defaultHeight: number;
} {
const safeShellHeight = shellHeight > 0 ? shellHeight : 720;
const minHeight = Math.max(220, Math.min(300, Math.round(safeShellHeight * 0.32)));
const maxHeight = Math.max(
minHeight + 80,
Math.min(560, safeShellHeight - 56),
);
const defaultHeight = clamp(
Math.round(safeShellHeight * 0.48),
minHeight,
maxHeight,
);
return {
minHeight,
maxHeight,
defaultHeight,
};
}
function resolveStackedTriggerClassName(
tone: "idle" | "active" | "error",
): string {
switch (tone) {
case "active":
return "border-sky-200 bg-sky-50 text-sky-700 shadow-sky-950/10 hover:bg-sky-100 hover:text-sky-800";
case "error":
return "border-rose-200 bg-rose-50 text-rose-700 shadow-rose-950/10 hover:bg-rose-100 hover:text-rose-800";
case "idle":
default:
return "border-slate-200 bg-white text-slate-600 shadow-slate-950/10 hover:bg-slate-50 hover:text-slate-900";
}
}
function normalizePath(value: string): string {
return value.replace(/\\/g, "/");
}
@@ -469,12 +528,20 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
onRevealPath,
renderPreview,
onLayoutModeChange,
teamView = null,
}: CanvasWorkbenchLayoutProps) {
const shellRef = useRef<HTMLDivElement | null>(null);
const [activeTab, setActiveTab] = useState<CanvasWorkbenchTab>("artifacts");
const stackedResizeCleanupRef = useRef<(() => void) | null>(null);
const [activeTab, setActiveTab] = useState<CanvasWorkbenchTab>(() =>
teamView?.enabled && !defaultPreview ? "team" : "artifacts",
);
const [collapsed, setCollapsed] = useState(false);
const [isStackedLayout, setIsStackedLayout] = useState(false);
const [stackedWorkbenchOpen, setStackedWorkbenchOpen] = useState(false);
const [shellHeight, setShellHeight] = useState(720);
const [stackedWorkbenchHeight, setStackedWorkbenchHeight] = useState<number | null>(
null,
);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [directoryCache, setDirectoryCache] = useState<Record<string, DirectoryListing>>(
{},
@@ -488,6 +555,16 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
const [workspaceFileSelections, setWorkspaceFileSelections] = useState<
Record<string, WorkspaceFileSelection>
>({});
const availableTabs = useMemo<Array<{ key: CanvasWorkbenchTab; label: string }>>(
() => [
...(teamView?.enabled ? [{ key: "team" as const, label: "Team" }] : []),
{ key: "artifacts" as const, label: "产物" },
{ key: "files" as const, label: "全部文件" },
{ key: "changes" as const, label: "变更" },
{ key: "preview" as const, label: "预览" },
],
[teamView?.enabled],
);
const entries = useMemo(
() => buildEntries(artifacts, canvasState, taskFiles, workspaceRoot),
@@ -568,27 +645,37 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
return;
}
const updateLayout = (width: number) => {
const updateLayout = (width: number, height: number) => {
if (width <= 0) {
return;
}
setIsStackedLayout(width < STACKED_LAYOUT_BREAKPOINT);
if (height > 0) {
setShellHeight(height);
}
};
const fallbackWidth =
node.getBoundingClientRect().width || node.clientWidth || window.innerWidth;
updateLayout(fallbackWidth);
const fallbackHeight =
node.getBoundingClientRect().height || node.clientHeight || window.innerHeight;
updateLayout(fallbackWidth, fallbackHeight);
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver((entries) => {
const contentRect = entries[0]?.contentRect;
const nextWidth =
entries[0]?.contentRect.width ||
contentRect?.width ||
node.getBoundingClientRect().width ||
node.clientWidth;
updateLayout(nextWidth);
const nextHeight =
contentRect?.height ||
node.getBoundingClientRect().height ||
node.clientHeight;
updateLayout(nextWidth, nextHeight);
});
observer.observe(node);
@@ -607,6 +694,57 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
}
}, [isStackedLayout]);
const stackedWorkbenchMetrics = useMemo(
() => resolveStackedWorkbenchMetrics(shellHeight),
[shellHeight],
);
useEffect(() => {
setStackedWorkbenchHeight((previous) =>
previous == null
? stackedWorkbenchMetrics.defaultHeight
: clamp(
previous,
stackedWorkbenchMetrics.minHeight,
stackedWorkbenchMetrics.maxHeight,
),
);
}, [stackedWorkbenchMetrics]);
useEffect(() => {
return () => {
stackedResizeCleanupRef.current?.();
stackedResizeCleanupRef.current = null;
};
}, []);
useEffect(() => {
if (!availableTabs.some((tab) => tab.key === activeTab)) {
setActiveTab(availableTabs[0]?.key || "artifacts");
}
}, [activeTab, availableTabs]);
const teamAutoFocusTokenRef = useRef<string | number | null | undefined>(
teamView?.autoFocusToken,
);
useEffect(() => {
if (!teamView?.enabled || teamView.autoFocusToken == null) {
return;
}
if (teamAutoFocusTokenRef.current === teamView.autoFocusToken) {
return;
}
teamAutoFocusTokenRef.current = teamView.autoFocusToken;
setActiveTab("team");
setCollapsed(false);
if (isStackedLayout) {
setStackedWorkbenchOpen(true);
}
}, [isStackedLayout, teamView?.autoFocusToken, teamView?.enabled]);
const handleToggleDirectory = useCallback(
(path: string) => {
setExpandedDirectories((previous) => {
@@ -687,6 +825,13 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
: null;
const currentTarget = useMemo<CanvasWorkbenchPreviewTarget>(() => {
if (activeTab === "team" && teamView?.enabled) {
return {
kind: "team-workbench",
title: teamView.title || "Team Workbench",
};
}
if (selectedWorkspaceFile) {
if (selectedWorkspaceFile.status === "loading") {
return {
@@ -796,7 +941,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
kind: "empty",
title: "暂无可预览内容",
};
}, [defaultPreview, selectedEntry, selectedWorkspaceFile]);
}, [activeTab, defaultPreview, selectedEntry, selectedWorkspaceFile, teamView]);
const currentContent = useMemo(() => {
if (currentTarget.kind === "default-canvas") {
@@ -1146,30 +1291,72 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
);
};
const renderTeamTab = () => {
if (!teamView?.enabled) {
return (
<div className={WORKBENCH_MUTED_PANEL_CLASSNAME}>
当前没有可展示的 Team 工作台。
</div>
);
}
return teamView.renderPanel ? (
teamView.renderPanel()
) : (
<div className={WORKBENCH_MUTED_PANEL_CLASSNAME}>
Team 工作台已启用。
</div>
);
};
const renderTabButtons = (stacked: boolean) => (
<div
className={cn(
"mt-4 grid gap-2",
stacked ? "grid-cols-2" : "grid-cols-4",
)}
>
{TAB_META.map((tab) => (
<button
key={tab.key}
type="button"
aria-label={`切换画布标签-${tab.label}`}
onClick={() => setActiveTab(tab.key)}
className={cn(
"rounded-2xl border px-2 py-2 text-xs font-medium transition-colors",
activeTab === tab.key
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
: WORKBENCH_BUTTON_CLASSNAME,
)}
>
{tab.label}
</button>
))}
</div>
stacked ? (
<div className="mt-3 flex gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{availableTabs.map((tab) => (
<button
key={tab.key}
type="button"
aria-label={`切换画布标签-${tab.label}`}
onClick={() => setActiveTab(tab.key)}
className={cn(
"shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
activeTab === tab.key
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
: WORKBENCH_BUTTON_CLASSNAME,
)}
>
{tab.label}
</button>
))}
</div>
) : (
<div
className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3"
style={{
gridTemplateColumns: `repeat(${Math.min(
Math.max(availableTabs.length, 1),
5,
)}, minmax(0, 1fr))`,
}}
>
{availableTabs.map((tab) => (
<button
key={tab.key}
type="button"
aria-label={`切换画布标签-${tab.label}`}
onClick={() => setActiveTab(tab.key)}
className={cn(
"rounded-2xl border px-2 py-2 text-xs font-medium transition-colors",
activeTab === tab.key
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
: WORKBENCH_BUTTON_CLASSNAME,
)}
>
{tab.label}
</button>
))}
</div>
)
);
const renderWorkbenchHeader = (
@@ -1184,33 +1371,62 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
stacked ? "px-3 py-3" : "px-4 py-4",
)}
>
{stacked ? (
<div className="mb-3 flex justify-center">
<span className="h-1.5 w-10 rounded-full bg-slate-200" />
</div>
) : null}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-slate-500">
Canvas Workbench
{activeTab === "team" ? "Team Workbench" : "Canvas Workbench"}
</div>
<div className="mt-1 truncate text-sm font-semibold text-foreground">
{currentTarget.title}
<div
className={cn(
"mt-1 text-sm font-semibold text-foreground",
stacked ? "line-clamp-2" : "truncate",
)}
>
{activeTab === "team"
? teamView?.title || currentTarget.title
: currentTarget.title}
</div>
{selectionPath ? (
<div className="mt-1 truncate text-xs text-slate-500">
{activeTab === "team" && teamView?.subtitle ? (
<div
className={cn(
"mt-1 text-xs text-slate-500",
stacked ? "line-clamp-2" : "truncate",
)}
>
{teamView.subtitle}
</div>
) : null}
{activeTab !== "team" && selectionPath ? (
<div
className={cn(
"mt-1 text-xs text-slate-500",
stacked ? "line-clamp-2 break-all" : "truncate",
)}
>
{selectionPath}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<button
type="button"
aria-label="下载当前画布项"
disabled={!currentContent.trim()}
onClick={handleDownload}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-xl border transition-colors disabled:cursor-not-allowed disabled:opacity-50",
WORKBENCH_GHOST_BUTTON_CLASSNAME,
)}
>
<Download className="h-4 w-4" />
</button>
{activeTab !== "team" ? (
<button
type="button"
aria-label="下载当前画布项"
disabled={!currentContent.trim()}
onClick={handleDownload}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-xl border transition-colors disabled:cursor-not-allowed disabled:opacity-50",
WORKBENCH_GHOST_BUTTON_CLASSNAME,
)}
>
<Download className="h-4 w-4" />
</button>
) : null}
{stacked || options?.showCollapseButton ? (
<button
type="button"
@@ -1237,6 +1453,16 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
);
const renderWorkbenchFooter = (stacked: boolean) => (
activeTab === "team" && teamView?.renderFooter ? (
<div
className={cn(
"border-t border-slate-200/80",
stacked ? "px-3 py-3" : "px-4 py-3",
)}
>
{teamView.renderFooter()}
</div>
) : (
<div
className={cn(
"border-t border-slate-200/80",
@@ -1301,10 +1527,13 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
</div>
) : null}
</div>
)
);
const renderActiveTab = () =>
activeTab === "artifacts"
activeTab === "team"
? renderTeamTab()
: activeTab === "artifacts"
? renderEntriesTab()
: activeTab === "files"
? renderFilesTab()
@@ -1312,6 +1541,52 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
? renderChangesTab()
: renderPreviewTab();
const handleStartStackedResize = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!isStackedLayout || !stackedWorkbenchOpen) {
return;
}
event.preventDefault();
stackedResizeCleanupRef.current?.();
const startY = event.clientY;
const startHeight =
stackedWorkbenchHeight ?? stackedWorkbenchMetrics.defaultHeight;
const handleMouseMove = (moveEvent: MouseEvent) => {
const nextHeight = clamp(
startHeight + (startY - moveEvent.clientY),
stackedWorkbenchMetrics.minHeight,
stackedWorkbenchMetrics.maxHeight,
);
setStackedWorkbenchHeight(nextHeight);
};
const handleMouseUp = () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
stackedResizeCleanupRef.current = null;
};
stackedResizeCleanupRef.current = () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
},
[
isStackedLayout,
stackedWorkbenchHeight,
stackedWorkbenchMetrics.defaultHeight,
stackedWorkbenchMetrics.maxHeight,
stackedWorkbenchMetrics.minHeight,
stackedWorkbenchOpen,
],
);
const stackedWorkbenchTrigger =
isStackedLayout && !stackedWorkbenchOpen ? (
<button
@@ -1319,9 +1594,28 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
aria-label="展开画布工作台"
title="工作台"
onClick={() => setStackedWorkbenchOpen(true)}
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200/80 bg-white/88 text-slate-500 shadow-sm shadow-slate-950/5 transition-all hover:bg-white hover:text-slate-900"
data-testid="canvas-workbench-trigger"
className={cn(
"inline-flex h-10 items-center gap-2 rounded-full border px-3.5 text-sm font-medium shadow-lg transition-all hover:-translate-y-0.5",
resolveStackedTriggerClassName(teamView?.triggerState?.tone || "idle"),
)}
>
<PanelRightOpen className="h-4 w-4" />
<span>{activeTab === "team" ? "Team 工作台" : "工作台"}</span>
{teamView?.triggerState?.label ? (
<span
className={cn(
"rounded-full border px-2 py-0.5 text-[10px] font-semibold leading-none",
teamView.triggerState.tone === "error"
? "border-rose-200 bg-white/90 text-rose-700"
: teamView.triggerState.tone === "active"
? "border-sky-200 bg-white/90 text-sky-700"
: "border-slate-200 bg-white/90 text-slate-600",
)}
>
{teamView.triggerState.label}
</span>
) : null}
</button>
) : undefined;
@@ -1331,7 +1625,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
data-testid="canvas-workbench-shell"
data-layout-mode={isStackedLayout ? "stacked" : "split"}
className={cn(
"relative h-full min-h-0 overflow-hidden rounded-[28px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.94)_100%)] shadow-sm shadow-slate-950/5",
"relative h-full min-h-0 overflow-hidden rounded-[28px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5",
isStackedLayout ? "block" : "flex flex-row",
)}
>
@@ -1341,29 +1635,58 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
isStackedLayout ? "h-full" : "flex-1",
)}
>
{renderPreview(currentTarget, {
stackedWorkbenchTrigger,
})}
{activeTab === "team" && teamView?.enabled
? teamView.renderPreview({
stackedWorkbenchTrigger,
})
: renderPreview(currentTarget, {
stackedWorkbenchTrigger,
})}
</div>
{isStackedLayout ? (
stackedWorkbenchOpen ? (
<section
data-testid="canvas-workbench-layout"
data-panel-placement="overlay-right"
className="absolute inset-y-3 right-3 z-10 flex w-[min(25rem,calc(100%-1.5rem))] max-w-full flex-col overflow-hidden rounded-[24px] border border-slate-200/80 bg-white/95 shadow-[0_24px_80px_rgba(15,23,42,0.14)] backdrop-blur-md"
>
{renderWorkbenchHeader(true)}
<div className="flex-1 overflow-auto px-3 py-3">{renderActiveTab()}</div>
{renderWorkbenchFooter(true)}
</section>
<>
<button
type="button"
aria-label="折叠画布工作台遮罩"
onClick={() => setStackedWorkbenchOpen(false)}
className="absolute inset-0 z-10 bg-slate-950/5"
/>
<section
data-testid="canvas-workbench-layout"
data-panel-placement="overlay-bottom"
className="absolute inset-x-3 bottom-3 z-20 flex max-w-full flex-col overflow-hidden rounded-[24px] border border-slate-200 bg-white shadow-[0_24px_80px_rgba(15,23,42,0.16)]"
style={{
height: `${stackedWorkbenchHeight ?? stackedWorkbenchMetrics.defaultHeight}px`,
minHeight: `${stackedWorkbenchMetrics.minHeight}px`,
maxHeight: `${stackedWorkbenchMetrics.maxHeight}px`,
}}
>
<div
role="separator"
aria-orientation="horizontal"
aria-label="调整画布工作台高度"
data-testid="canvas-workbench-resize-handle"
onMouseDown={handleStartStackedResize}
className="flex h-5 shrink-0 cursor-row-resize items-center justify-center border-b border-slate-200/80 bg-white"
>
<span className="h-1.5 w-10 rounded-full bg-slate-200" />
</div>
{renderWorkbenchHeader(true)}
<div className="flex-1 overflow-auto px-3 py-3">
{renderActiveTab()}
</div>
{renderWorkbenchFooter(true)}
</section>
</>
) : null
) : (
<aside
data-testid="canvas-workbench-layout"
data-panel-placement="side"
className={cn(
"relative flex h-full flex-col border-l border-slate-200/80 bg-white/82 backdrop-blur-sm transition-[width] duration-200",
"relative flex h-full flex-col border-l border-slate-200/80 bg-white transition-[width] duration-200",
collapsed ? "w-12" : "w-[360px]",
)}
>
@@ -1380,7 +1703,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
>
<PanelRightOpen className="h-4 w-4" />
</button>
{TAB_META.map((tab) => (
{availableTabs.map((tab) => (
<button
key={tab.key}
type="button"
@@ -865,4 +865,42 @@ describe("ChatSidebar", () => {
expect(onOpenSubagentSession).toHaveBeenCalledWith("child-2");
});
it("内部图片子代理标题应显示为用户文案", () => {
const container = renderSidebar({
topics: [
{
...defaultTopics[0],
id: "child-image",
title: "Image #1",
sourceSessionId: "child-image",
},
],
currentTopicId: "child-image",
subagentParentContext: {
parent_session_id: "parent-1",
parent_session_name: "主线程",
role_hint: "image_editor",
task_summary: "处理图片细节。",
created_from_turn_id: "turn-42",
sibling_subagent_sessions: [
{
id: "child-2",
name: "Image #2",
created_at: 1_742_288_430,
updated_at: 1_742_288_530,
session_type: "sub_agent",
task_summary: "检查图片导出尺寸。",
role_hint: "image_reviewer",
runtime_status: "queued",
},
],
},
});
expect(container.textContent).toContain("图片任务 1");
expect(container.textContent).toContain("图片任务 2");
expect(container.textContent).not.toContain("Image #1");
expect(container.textContent).not.toContain("Image #2");
});
});
@@ -37,6 +37,7 @@ import {
type TaskStatusReason,
} from "../hooks/agentChatShared";
import type { Message } from "../types";
import { resolveInternalImageTaskDisplayName } from "../utils/internalImagePlaceholder";
const RECENT_TASK_WINDOW_MS = 1000 * 60 * 60 * 24 * 3;
const OLDER_TASKS_INITIAL_COUNT = 8;
@@ -169,6 +170,13 @@ function normalizePreviewText(value: string) {
return value.trim().replace(/\s+/g, " ").slice(0, 72);
}
function resolveSidebarDisplayTitle(
value: string | null | undefined,
fallback: string,
) {
return resolveInternalImageTaskDisplayName(value) || fallback;
}
function resolveCurrentTaskPreview(messages: Message[]) {
return extractTaskPreviewFromMessages(messages);
}
@@ -540,7 +548,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
return {
id: topic.id,
title: topic.title || "未命名任务",
title: resolveSidebarDisplayTitle(topic.title, "未命名任务"),
updatedAt: topic.updatedAt || topic.createdAt,
messagesCount: topic.messagesCount,
status,
@@ -834,7 +842,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{session.name || "未命名子代理"}
{resolveSidebarDisplayTitle(session.name, "未命名子代理")}
</div>
<Badge className={statusMeta.badgeClassName}>
{statusMeta.label}
@@ -1086,7 +1094,10 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
<div className="rounded-[20px] border border-slate-200/80 bg-white/86 px-3.5 py-3 shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
<div className="flex items-center gap-2">
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{currentTaskItem?.title || "当前子代理"}
{resolveSidebarDisplayTitle(
currentTaskItem?.title,
"当前子代理",
)}
</div>
<Badge className="border border-slate-900 bg-slate-900 text-white dark:border-white dark:bg-white dark:text-slate-900">
当前子代理
@@ -529,4 +529,20 @@ describe("EmptyState", () => {
expect(onLaunchBrowserAssist).toHaveBeenCalledTimes(1);
});
it("应允许外部覆盖 supportingSlot", async () => {
const container = renderEmptyState({
supportingSlotOverride: (
<div data-testid="custom-supporting-slot">自定义入口层</div>
),
});
await act(async () => {
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="custom-supporting-slot"]'),
).toBeTruthy();
expect(container.textContent).toContain("自定义入口层");
});
});
@@ -49,6 +49,7 @@ import {
import { useActiveSkill } from "./Inputbar/hooks/useActiveSkill";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { WorkspaceSettings } from "@/types/workspace";
import type { MessageImage } from "../types";
import type { TeamDefinition } from "../utils/teamDefinitions";
import { isGeneralResearchTheme } from "../utils/generalAgentPrompt";
@@ -202,6 +203,8 @@ interface EmptyStateProps {
selectedTeam?: TeamDefinition | null;
onSelectTeam?: (team: TeamDefinition | null) => void;
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
teamWorkspaceSettings?: WorkspaceSettings | null;
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
hasCanvasContent?: boolean;
hasContentId?: boolean;
selectedText?: string;
@@ -235,6 +238,8 @@ interface EmptyStateProps {
modelSelectorBackgroundPreload?: ModelSelectorProps["backgroundPreload"];
/** 配置读取策略 */
configLoadStrategy?: "immediate" | "idle";
/** 覆盖默认支持面板 */
supportingSlotOverride?: React.ReactNode;
}
const ENTRY_THEME_ID = "social-media";
@@ -390,6 +395,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
selectedTeam = null,
onSelectTeam,
onEnableSuggestedTeam,
teamWorkspaceSettings,
onPersistCustomTeams,
hasCanvasContent = false,
hasContentId = false,
selectedText = "",
@@ -408,6 +415,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
deferProjectSelectorListLoad = false,
modelSelectorBackgroundPreload = "immediate",
configLoadStrategy = "immediate",
supportingSlotOverride,
}) => {
const { activeSkill, setActiveSkill, clearActiveSkill, wrapTextWithSkill } =
useActiveSkill();
@@ -1101,6 +1109,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
setProviderType={setProviderType}
model={model}
setModel={setModel}
workspaceId={projectId}
executionStrategy={executionStrategy}
executionStrategyLabel={executionStrategyLabel}
setExecutionStrategy={setExecutionStrategy}
@@ -1148,6 +1157,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onSubagentEnabledChange={onSubagentEnabledChange}
selectedTeam={selectedTeam}
onSelectTeam={onSelectTeam}
teamWorkspaceSettings={teamWorkspaceSettings}
onPersistCustomTeams={onPersistCustomTeams}
onEnableSuggestedTeam={onEnableSuggestedTeam}
webSearchEnabled={webSearchEnabled}
onWebSearchEnabledChange={onWebSearchEnabledChange}
@@ -1158,7 +1169,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
/>
);
const quickActionsPanel = (
const defaultQuickActionsPanel = (
<EmptyStateQuickActions
title="快速启动"
description="先选一个任务模板,再在当前会话里继续补充和追问。"
@@ -1233,7 +1244,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
cards={workspaceCards}
features={workspaceFeatures}
prioritySlot={composerPanel}
supportingSlot={quickActionsPanel}
supportingSlot={supportingSlotOverride ?? defaultQuickActionsPanel}
themeTabs={themeTabs}
headerControls={headerControls}
/>
@@ -21,7 +21,12 @@ vi.mock("./Inputbar/components/SkillSelector", () => ({
}));
vi.mock("./Inputbar/components/TeamSelector", () => ({
TeamSelector: () => <div data-testid="empty-state-team-selector" />,
TeamSelector: (props: { autoOpenToken?: number | null }) => (
<div
data-testid="empty-state-team-selector"
data-auto-open-token={String(props.autoOpenToken ?? "")}
/>
),
}));
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
@@ -129,6 +134,95 @@ function renderPanel(
return container;
}
function renderStatefulPanel(
props?: Partial<React.ComponentProps<typeof EmptyStateComposerPanel>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const StatefulPanel = () => {
const [subagentEnabled, setSubagentEnabled] = React.useState(false);
return (
<EmptyStateComposerPanel
input=""
setInput={vi.fn()}
placeholder="输入内容"
onSend={vi.fn()}
activeTheme="general"
providerType="openai"
setProviderType={vi.fn()}
model="gpt-4.1"
setModel={vi.fn()}
executionStrategy="react"
executionStrategyLabel="ReAct"
setExecutionStrategy={vi.fn()}
onManageProviders={vi.fn()}
isGeneralTheme
isEntryTheme={false}
entryTaskType="direct"
entryTaskTypes={[]}
getEntryTaskTemplate={vi.fn()}
entryTemplate={{
type: "direct",
label: "直接写作",
description: "直接按需求写作",
pattern: "{input}",
slots: [],
}}
entryPreview=""
entrySlotValues={{}}
onEntryTaskTypeChange={vi.fn()}
onEntrySlotChange={vi.fn()}
characters={[]}
skills={[]}
activeSkill={null}
setActiveSkill={vi.fn()}
clearActiveSkill={vi.fn()}
isSkillsLoading={false}
onNavigateToSettings={vi.fn()}
onImportSkill={vi.fn()}
onRefreshSkills={vi.fn()}
showCreationModeSelector={false}
creationMode="guided"
onCreationModeChange={vi.fn()}
platform="xiaohongshu"
setPlatform={vi.fn()}
depth="deep"
setDepth={vi.fn()}
ratio="3:4"
setRatio={vi.fn()}
style="minimal"
setStyle={vi.fn()}
ratioPopoverOpen={false}
setRatioPopoverOpen={vi.fn()}
stylePopoverOpen={false}
setStylePopoverOpen={vi.fn()}
thinkingEnabled={false}
onThinkingEnabledChange={vi.fn()}
taskEnabled={false}
onTaskEnabledChange={vi.fn()}
subagentEnabled={subagentEnabled}
onSubagentEnabledChange={setSubagentEnabled}
webSearchEnabled={false}
onWebSearchEnabledChange={vi.fn()}
pendingImages={[]}
onFileSelect={vi.fn()}
onPaste={vi.fn()}
onRemoveImage={vi.fn()}
{...props}
/>
);
};
act(() => {
root.render(<StatefulPanel />);
});
mountedRoots.push({ root, container });
return container;
}
describe("EmptyStateComposerPanel", () => {
it("应将 onPaste 绑定到输入框", () => {
const onPaste = vi.fn();
@@ -229,4 +323,68 @@ describe("EmptyStateComposerPanel", () => {
container.querySelector('[data-testid="empty-state-team-selector"]'),
).toBeTruthy();
});
it("未开启 Team mode 时应显示显式开启按钮,并可直接启用", () => {
const onSubagentEnabledChange = vi.fn();
const container = renderPanel({
isGeneralTheme: true,
subagentEnabled: false,
onSubagentEnabledChange,
});
const enableButton = container.querySelector(
'[data-testid="empty-state-team-mode-enable-button"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
expect(enableButton?.textContent).toContain("开启 Team");
act(() => {
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSubagentEnabledChange).toHaveBeenCalledWith(true);
});
it("点击开启 Team 后应自动透传 Team 配置面板打开令牌", async () => {
const container = renderStatefulPanel();
const enableButton = container.querySelector(
'[data-testid="empty-state-team-mode-enable-button"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
act(() => {
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const teamSelector = container.querySelector(
'[data-testid="empty-state-team-selector"]',
) as HTMLDivElement | null;
expect(teamSelector).toBeTruthy();
expect(teamSelector?.getAttribute("data-auto-open-token")).toBe("1");
});
it("复杂任务但未开启 Team 时,首页开启按钮应显示推荐态", () => {
const container = renderPanel({
isGeneralTheme: true,
subagentEnabled: false,
input: "请拆成多个子任务分别分析、实现、验证,并最终统一回归验收",
});
const enableButton = container.querySelector(
'[data-testid="empty-state-team-mode-enable-button"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
expect(enableButton?.textContent).toContain("开启 Team");
expect(enableButton?.textContent).toContain("推荐");
});
});
@@ -35,6 +35,8 @@ import { CharacterMention } from "./Inputbar/components/CharacterMention";
import { SkillBadge } from "./Inputbar/components/SkillBadge";
import { SkillSelector } from "./Inputbar/components/SkillSelector";
import { TeamSelector } from "./Inputbar/components/TeamSelector";
import { TeamModeEntryButton } from "./Inputbar/components/TeamModeEntryButton";
import type { WorkspaceSettings } from "@/types/workspace";
import { CREATION_MODE_CONFIG } from "./constants";
import type {
CreationMode,
@@ -478,6 +480,7 @@ interface EmptyStateComposerPanelProps {
setProviderType: (type: string) => void;
model: string;
setModel: (model: string) => void;
workspaceId?: string | null;
executionStrategy?: "react" | "code_orchestrated" | "auto";
executionStrategyLabel: string;
setExecutionStrategy?: (
@@ -527,6 +530,8 @@ interface EmptyStateComposerPanelProps {
onSubagentEnabledChange?: (enabled: boolean) => void;
selectedTeam?: TeamDefinition | null;
onSelectTeam?: (team: TeamDefinition | null) => void;
teamWorkspaceSettings?: WorkspaceSettings | null;
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
webSearchEnabled: boolean;
onWebSearchEnabledChange?: (enabled: boolean) => void;
@@ -546,6 +551,7 @@ export function EmptyStateComposerPanel({
setProviderType,
model,
setModel,
workspaceId,
executionStrategy = "react",
executionStrategyLabel,
setExecutionStrategy,
@@ -593,6 +599,8 @@ export function EmptyStateComposerPanel({
onSubagentEnabledChange,
selectedTeam,
onSelectTeam,
teamWorkspaceSettings,
onPersistCustomTeams,
onEnableSuggestedTeam,
webSearchEnabled,
onWebSearchEnabledChange,
@@ -606,6 +614,9 @@ export function EmptyStateComposerPanel({
const [dismissedSuggestionKey, setDismissedSuggestionKey] = useState<
string | null
>(null);
const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState<
number | null
>(null);
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey) {
@@ -643,6 +654,20 @@ export function EmptyStateComposerPanel({
setDismissedSuggestionKey(suggestionKey);
};
const handleEnableTeamMode = () => {
if (!subagentEnabled && !selectedTeam) {
setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1);
}
onSubagentEnabledChange?.(true);
};
const handleToggleSubagentMode = () => {
if (!subagentEnabled && !selectedTeam) {
setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1);
}
onSubagentEnabledChange?.(!subagentEnabled);
};
return (
<InputCard>
{isEntryTheme && (
@@ -784,9 +809,24 @@ export function EmptyStateComposerPanel({
<TeamSelector
activeTheme={activeTheme}
input={input}
workspaceId={workspaceId}
providerType={providerType}
model={model}
executionStrategy={executionStrategy}
autoOpenToken={teamSelectorAutoOpenToken}
selectedTeam={selectedTeam}
workspaceSettings={teamWorkspaceSettings}
onPersistCustomTeams={onPersistCustomTeams}
onSelectTeam={(team) => onSelectTeam?.(team)}
/>
) : isGeneralTheme && onSubagentEnabledChange ? (
<TeamModeEntryButton
selectedTeamLabel={selectedTeam?.label}
dataTestId="empty-state-team-mode-enable-button"
recommended={teamSuggestion.shouldSuggest}
hint={teamSuggestion.reasons[0]}
onClick={handleEnableTeamMode}
/>
) : null}
<ChatModelSelector
@@ -1058,7 +1098,7 @@ export function EmptyStateComposerPanel({
subagentEnabled,
"slate",
)}
onClick={() => onSubagentEnabledChange?.(!subagentEnabled)}
onClick={handleToggleSubagentMode}
aria-pressed={subagentEnabled}
title={subagentEnabled ? "关闭多代理偏好" : "开启多代理偏好"}
>
@@ -103,6 +103,13 @@ export interface EmptyStateQuickActionItem {
description: string;
badge: string;
prompt: string;
actionLabel?: string;
outputHint?: string;
statusLabel?: string;
statusTone?: "slate" | "sky" | "emerald" | "amber";
statusDescription?: string;
testId?: string;
solutionId?: string;
}
export interface EmptyStateQuickPresetItem {
@@ -119,10 +126,18 @@ interface EmptyStateQuickActionsProps {
presets?: EmptyStateQuickPresetItem[];
items: EmptyStateQuickActionItem[];
embedded?: boolean;
loading?: boolean;
onPresetAction?: (item: EmptyStateQuickPresetItem) => void;
onAction: (item: EmptyStateQuickActionItem) => void;
}
const STATUS_TONE_CLASSNAMES = {
slate: "border-slate-200 bg-slate-50 text-slate-600",
sky: "border-sky-200 bg-sky-50 text-sky-700",
emerald: "border-emerald-200 bg-emerald-50 text-emerald-700",
amber: "border-amber-200 bg-amber-50 text-amber-700",
} as const;
export function EmptyStateQuickActions({
title,
description,
@@ -130,10 +145,11 @@ export function EmptyStateQuickActions({
presets = [],
items,
embedded = false,
loading = false,
onPresetAction,
onAction,
}: EmptyStateQuickActionsProps) {
if (items.length === 0 && presets.length === 0) {
if (!loading && items.length === 0 && presets.length === 0) {
return null;
}
@@ -141,7 +157,9 @@ export function EmptyStateQuickActions({
<QuickActionsPanel
$embedded={embedded}
className={
embedded ? EMPTY_STATE_PANEL_EMBEDDED_CLASSNAME : EMPTY_STATE_PANEL_CLASSNAME
embedded
? EMPTY_STATE_PANEL_EMBEDDED_CLASSNAME
: EMPTY_STATE_PANEL_CLASSNAME
}
>
<div className="flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
@@ -182,33 +200,55 @@ export function EmptyStateQuickActions({
</div>
) : null}
<div className="mt-2.5 grid gap-2 md:grid-cols-2">
{items.map((item, index) => (
<RecommendationCard
key={item.key}
$index={index}
type="button"
onClick={() => onAction(item)}
>
<div className="flex w-full items-start justify-between gap-3">
<span className={EMPTY_STATE_META_PILL_CLASSNAME}>
{item.badge}
</span>
<span className="text-[11px] font-medium text-slate-400 transition-colors group-hover:text-slate-500">
立即开始
</span>
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900">
{item.title}
{loading && items.length === 0 ? (
<div className="mt-3 rounded-2xl border border-slate-200/80 bg-white/80 px-4 py-3 text-xs leading-5 text-slate-500">
正在加载推荐方案…
</div>
) : (
<div className="mt-2.5 grid gap-2 md:grid-cols-2">
{items.map((item, index) => (
<RecommendationCard
key={item.key}
$index={index}
type="button"
data-testid={item.testId}
onClick={() => onAction(item)}
>
<div className="flex w-full items-start justify-between gap-3">
<span className={EMPTY_STATE_META_PILL_CLASSNAME}>
{item.badge}
</span>
<span className="text-[11px] font-medium text-slate-400 transition-colors group-hover:text-slate-500">
{item.actionLabel || "立即开始"}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">
{item.description}
</p>
</div>
</RecommendationCard>
))}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900">
{item.title}
</div>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">
{item.description}
</p>
{item.outputHint ? (
<p className="mt-2 text-[11px] leading-5 text-slate-400">
产出:{item.outputHint}
</p>
) : null}
{item.statusLabel ? (
<div className="mt-2 flex items-center justify-start">
<span
className={`inline-flex items-center rounded-full border px-2 py-1 text-[11px] font-medium ${STATUS_TONE_CLASSNAMES[item.statusTone ?? "slate"]}`}
title={item.statusDescription}
>
{item.statusLabel}
</span>
</div>
) : null}
</div>
</RecommendationCard>
))}
</div>
)}
</QuickActionsPanel>
);
}
@@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import type { ChatInputAdapter } from "@/components/input-kit/adapters/types";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
@@ -8,12 +8,15 @@ import { CharacterMention } from "./CharacterMention";
import { InputbarCore } from "./InputbarCore";
import { SkillSelector } from "./SkillSelector";
import { TeamSelector } from "./TeamSelector";
import { TeamModeEntryButton } from "./TeamModeEntryButton";
import { ThemeWorkbenchStatusPanel } from "./ThemeWorkbenchStatusPanel";
import { InputbarModelExtra } from "./InputbarModelExtra";
import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice";
import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect";
import { isGeneralResearchTheme } from "../../../utils/generalAgentPrompt";
import type { TeamDefinition } from "../../../utils/teamDefinitions";
import { getTeamSuggestion } from "../../../utils/teamSuggestion";
import type { WorkspaceSettings } from "@/types/workspace";
import type {
ThemeWorkbenchGateState,
ThemeWorkbenchQuickAction,
@@ -40,6 +43,11 @@ interface InputbarComposerSectionProps {
onRefreshSkills?: () => void | Promise<void>;
selectedTeam?: TeamDefinition | null;
onSelectTeam?: (team: TeamDefinition | null) => void;
teamWorkspaceSettings?: WorkspaceSettings | null;
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
workspaceId?: string | null;
providerType?: string;
model?: string;
onSend: () => void;
onToolClick: (tool: string) => void;
activeTools: Record<string, boolean>;
@@ -83,6 +91,11 @@ export const InputbarComposerSection: React.FC<
onRefreshSkills,
selectedTeam,
onSelectTeam,
teamWorkspaceSettings,
onPersistCustomTeams,
workspaceId,
providerType,
model,
onSend,
onToolClick,
activeTools,
@@ -101,11 +114,29 @@ export const InputbarComposerSection: React.FC<
onPromoteQueuedTurn,
onRemoveQueuedTurn,
}) => {
const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState<
number | null
>(null);
const showSkillSelector =
!isThemeWorkbenchVariant && isGeneralResearchTheme(activeTheme);
const teamSuggestion =
!activeTools["subagent_mode"] && isGeneralResearchTheme(activeTheme)
? getTeamSuggestion({
input,
activeTheme,
subagentEnabled: false,
})
: null;
const shouldRecommendTeamEntry = Boolean(teamSuggestion?.shouldSuggest);
const currentPendingImages =
(inputAdapter.state.attachments as MessageImage[] | undefined) ||
pendingImages;
const handleEnableTeamMode = () => {
if (!selectedTeam) {
setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1);
}
onToolClick("subagent_mode");
};
if (renderThemeWorkbenchGeneratingPanel) {
return (
@@ -198,13 +229,30 @@ export const InputbarComposerSection: React.FC<
onRefreshSkills={onRefreshSkills}
/>
) : null}
{activeTools["subagent_mode"] ? (
<TeamSelector
activeTheme={activeTheme}
input={input}
selectedTeam={selectedTeam}
onSelectTeam={(team) => onSelectTeam?.(team)}
/>
{isGeneralResearchTheme(activeTheme) ? (
activeTools["subagent_mode"] ? (
<TeamSelector
activeTheme={activeTheme}
input={input}
workspaceId={workspaceId}
providerType={providerType}
model={model}
executionStrategy={executionStrategy}
autoOpenToken={teamSelectorAutoOpenToken}
selectedTeam={selectedTeam}
workspaceSettings={teamWorkspaceSettings}
onPersistCustomTeams={onPersistCustomTeams}
onSelectTeam={(team) => onSelectTeam?.(team)}
/>
) : (
<TeamModeEntryButton
selectedTeamLabel={selectedTeam?.label}
dataTestId="team-mode-enable-button"
recommended={shouldRecommendTeamEntry}
hint={teamSuggestion?.reasons?.[0]}
onClick={handleEnableTeamMode}
/>
)
) : null}
<InputbarModelExtra
isFullscreen={isFullscreen}
@@ -0,0 +1,56 @@
import React from "react";
import { Workflow } from "lucide-react";
import { cn } from "@/lib/utils";
interface TeamModeEntryButtonProps {
selectedTeamLabel?: string | null;
onClick: () => void;
className?: string;
dataTestId?: string;
recommended?: boolean;
hint?: string;
}
export const TeamModeEntryButton: React.FC<TeamModeEntryButtonProps> = ({
selectedTeamLabel,
onClick,
className,
dataTestId,
recommended = false,
hint,
}) => {
const trimmedLabel = selectedTeamLabel?.trim() || "";
const buttonLabel = trimmedLabel ? `开启 Team · ${trimmedLabel}` : "开启 Team";
const title =
hint?.trim() ||
(recommended
? "当前任务更适合拆分协作,但仍由你手动决定是否启用 Team"
: "仅在当前任务适合拆分协作时启用 Team");
return (
<button
type="button"
data-testid={dataTestId}
onClick={onClick}
className={cn(
"inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium shadow-none transition-colors",
recommended
? "border-sky-300 bg-sky-50 text-sky-700 hover:border-sky-400 hover:bg-sky-100"
: "border-fuchsia-200 bg-fuchsia-50 text-fuchsia-700 hover:border-fuchsia-300 hover:bg-fuchsia-100",
className,
)}
title={title}
aria-label={buttonLabel}
>
<Workflow className="h-3.5 w-3.5" />
<span className="max-w-[180px] truncate">{buttonLabel}</span>
{recommended ? (
<span className="rounded-full border border-sky-200 bg-white px-1.5 py-0.5 text-[10px] font-semibold leading-none text-sky-700">
推荐
</span>
) : null}
</button>
);
};
export default TeamModeEntryButton;
@@ -8,6 +8,7 @@ import React, {
import { Users } from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { WorkspaceSettings } from "@/types/workspace";
import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload";
import type { TeamDefinition } from "../../../utils/teamDefinitions";
@@ -21,8 +22,15 @@ const TeamSelectorPanel = lazy(async () => {
interface TeamSelectorProps {
activeTheme?: string;
input?: string;
workspaceId?: string | null;
providerType?: string;
model?: string;
executionStrategy?: "react" | "code_orchestrated" | "auto";
autoOpenToken?: number | null;
selectedTeam?: TeamDefinition | null;
onSelectTeam: (team: TeamDefinition | null) => void;
workspaceSettings?: WorkspaceSettings | null;
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
triggerLabel?: string;
className?: string;
}
@@ -30,9 +38,16 @@ interface TeamSelectorProps {
export const TeamSelector: React.FC<TeamSelectorProps> = ({
activeTheme,
input,
workspaceId,
providerType,
model,
executionStrategy,
autoOpenToken = null,
selectedTeam = null,
onSelectTeam,
triggerLabel = "Team",
workspaceSettings,
onPersistCustomTeams,
triggerLabel = "配置 Team",
className,
}) => {
const [open, setOpen] = useState(false);
@@ -43,6 +58,13 @@ export const TeamSelector: React.FC<TeamSelectorProps> = ({
});
}, []);
useEffect(() => {
if (autoOpenToken === null || autoOpenToken === undefined) {
return;
}
setOpen(true);
}, [autoOpenToken]);
const resolvedLabel = useMemo(() => {
if (!selectedTeam?.label?.trim()) {
return triggerLabel;
@@ -90,7 +112,13 @@ export const TeamSelector: React.FC<TeamSelectorProps> = ({
<TeamSelectorPanel
activeTheme={activeTheme}
input={input}
workspaceId={workspaceId}
providerType={providerType}
model={model}
executionStrategy={executionStrategy}
selectedTeam={selectedTeam}
workspaceSettings={workspaceSettings}
onPersistCustomTeams={onPersistCustomTeams}
onSelectTeam={(team) => {
onSelectTeam(team);
setOpen(false);
@@ -1,5 +1,6 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { WorkspaceSettings } from "@/types/workspace";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TeamSelectorPanel } from "./TeamSelectorPanel";
import {
@@ -93,6 +94,14 @@ describe("TeamSelectorPanel", () => {
vi.clearAllMocks();
});
it("不应再展示模型生成 Team 入口", async () => {
const { container } = renderPanel();
await flushEffects();
expect(container.textContent).not.toContain("模型生成 Team");
});
it("应保存自定义 Team 的 profileId、roleKey 与 skillIds", async () => {
const onSelectTeam = vi.fn();
const selectedTeam = createTeamDefinitionFromPreset(
@@ -146,6 +155,8 @@ describe("TeamSelectorPanel", () => {
saveButton?.click();
});
await flushEffects();
const savedTeam = onSelectTeam.mock.calls[0]?.[0] as TeamDefinition | undefined;
expect(savedTeam).toBeTruthy();
@@ -158,4 +169,50 @@ describe("TeamSelectorPanel", () => {
]);
expect(mockToast.success).toHaveBeenCalled();
});
it("项目级自定义 Team 应通过回调持久化", async () => {
const onPersistCustomTeams = vi.fn().mockResolvedValue(undefined);
const workspaceSettings: WorkspaceSettings = {
agentTeam: {
customTeams: [
{
id: "custom-team-project-1",
label: "项目联调 Team",
description: "面向当前项目的联调与修复。",
roles: [
{
id: "planner",
label: "分析",
summary: "先定位问题再拆解实施。",
},
],
},
],
},
};
const { container } = renderPanel({
workspaceSettings,
onPersistCustomTeams,
});
await flushEffects();
expect(container.textContent).toContain("项目联调 Team");
expect(container.textContent).toContain("当前项目 Team");
const deleteButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("删除这个 Team"),
);
expect(deleteButton).toBeTruthy();
act(() => {
deleteButton?.click();
});
await flushEffects();
expect(onPersistCustomTeams).toHaveBeenCalledWith([]);
expect(mockToast.success).toHaveBeenCalled();
});
});
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import type { WorkspaceSettings } from "@/types/workspace";
import { toast } from "sonner";
import {
BUILTIN_TEAM_PROFILE_OPTIONS,
@@ -32,13 +33,22 @@ import {
type TeamRoleDefinition,
} from "../../../utils/teamDefinitions";
import { getTeamSuggestion } from "../../../utils/teamSuggestion";
import { loadCustomTeams, saveCustomTeams } from "../../../utils/teamStorage";
import {
resolveCustomTeams,
saveCustomTeams,
} from "../../../utils/teamStorage";
interface TeamSelectorPanelProps {
activeTheme?: string;
input?: string;
workspaceId?: string | null;
providerType?: string;
model?: string;
executionStrategy?: "react" | "code_orchestrated" | "auto";
selectedTeam?: TeamDefinition | null;
onSelectTeam: (team: TeamDefinition | null) => void;
workspaceSettings?: WorkspaceSettings | null;
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
onClose?: () => void;
}
@@ -288,18 +298,27 @@ function TeamCard({
export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
activeTheme,
input,
workspaceId: _workspaceId,
providerType: _providerType,
model: _model,
executionStrategy: _executionStrategy,
selectedTeam = null,
onSelectTeam,
workspaceSettings,
onPersistCustomTeams,
onClose,
}) => {
const [query, setQuery] = useState("");
const [customTeams, setCustomTeams] = useState<TeamDefinition[]>([]);
const [draft, setDraft] = useState<TeamDraft | null>(null);
const [expandedTeamId, setExpandedTeamId] = useState<string | null>(null);
const isProjectScopedCustomTeam = Boolean(
workspaceSettings && onPersistCustomTeams,
);
useEffect(() => {
setCustomTeams(loadCustomTeams());
}, []);
setCustomTeams(resolveCustomTeams(workspaceSettings));
}, [workspaceSettings]);
const suggestion = useMemo(
() =>
@@ -370,7 +389,16 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
setDraft(buildDraftFromTeam(team));
};
const handleSaveDraft = () => {
const persistCustomTeams = async (nextCustomTeams: TeamDefinition[]) => {
if (onPersistCustomTeams) {
await Promise.resolve(onPersistCustomTeams(nextCustomTeams));
return;
}
saveCustomTeams(nextCustomTeams);
};
const handleSaveDraft = async () => {
const normalized = normalizeTeamDefinition({
id: draft?.id,
source: "custom",
@@ -400,23 +428,46 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
const nextCustomTeams = [...customTeams.filter((team) => team.id !== nextTeam.id), nextTeam].sort(
(left, right) => (right.updatedAt || 0) - (left.updatedAt || 0),
);
setCustomTeams(nextCustomTeams);
saveCustomTeams(nextCustomTeams);
setDraft(null);
onSelectTeam(nextTeam);
onClose?.();
toast.success(`已保存 Team「${nextTeam.label}」`);
try {
await persistCustomTeams(nextCustomTeams);
setCustomTeams(nextCustomTeams);
setDraft(null);
onSelectTeam(nextTeam);
onClose?.();
toast.success(
isProjectScopedCustomTeam
? `已将 Team「${nextTeam.label}」保存到当前项目`
: `已保存 Team「${nextTeam.label}」`,
);
} catch (error) {
toast.error(
`保存 Team 失败:${error instanceof Error ? error.message : String(error)}`,
);
}
};
const handleDeleteCustom = (team: TeamDefinition) => {
const handleDeleteCustom = async (team: TeamDefinition) => {
const nextCustomTeams = customTeams.filter((item) => item.id !== team.id);
setCustomTeams(nextCustomTeams);
saveCustomTeams(nextCustomTeams);
setDraft((currentDraft) => (currentDraft?.id === team.id ? null : currentDraft));
if (selectedTeam?.id === team.id) {
onSelectTeam(null);
try {
await persistCustomTeams(nextCustomTeams);
setCustomTeams(nextCustomTeams);
setDraft((currentDraft) =>
currentDraft?.id === team.id ? null : currentDraft,
);
if (selectedTeam?.id === team.id) {
onSelectTeam(null);
}
toast.success(
isProjectScopedCustomTeam
? `已从当前项目删除 Team「${team.label}」`
: `已删除 Team「${team.label}」`,
);
} catch (error) {
toast.error(
`删除 Team 失败:${error instanceof Error ? error.message : String(error)}`,
);
}
toast.success(`已删除 Team「${team.label}」`);
};
const handleClearSelection = () => {
@@ -606,7 +657,9 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
type="button"
variant="outline"
className="border-rose-200 bg-white text-rose-600 hover:bg-rose-50"
onClick={() => handleDeleteCustom(inspectorTeam)}
onClick={() => {
void handleDeleteCustom(inspectorTeam);
}}
>
<Trash2 className="mr-1.5 h-4 w-4" />
删除这个 Team
@@ -930,7 +983,9 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<Button
type="button"
className="bg-slate-900 text-white hover:bg-slate-800"
onClick={handleSaveDraft}
onClick={() => {
void handleSaveDraft();
}}
>
保存 Team
</Button>
@@ -953,6 +1008,11 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<div className="mt-1 text-sm text-slate-700">
只在当前任务适合拆分协作时,为主代理提供团队结构参考。
</div>
<div className="mt-2 text-xs text-slate-500">
{isProjectScopedCustomTeam
? "自定义 Team 会保存到当前项目,便于项目级复用与评审。"
: "自定义 Team 当前保存在本地设备,用于快速测试与个人偏好。"}
</div>
</div>
{selectedTeam ? (
<button
@@ -1002,7 +1062,10 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
className="bg-slate-900 text-white hover:bg-slate-800"
onClick={() =>
handleStartCreate(
selectedTeam || recommendedTeam || builtinTeams[0] || null,
selectedTeam ||
recommendedTeam ||
builtinTeams[0] ||
null,
)
}
>
@@ -1049,7 +1112,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<section className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold tracking-[0.08em] text-slate-500">
我的 Team
{isProjectScopedCustomTeam ? "当前项目 Team" : "我的 Team"}
</div>
<Button
type="button"
@@ -1058,7 +1121,10 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
className="h-8 border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
onClick={() =>
handleStartCreate(
selectedTeam || recommendedTeam || builtinTeams[0] || null,
selectedTeam ||
recommendedTeam ||
builtinTeams[0] ||
null,
)
}
>
@@ -1083,14 +1149,18 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
}
onCopy={() => handleStartCreate(team)}
onEdit={() => handleStartEdit(team)}
onDelete={() => handleDeleteCustom(team)}
onDelete={() => {
void handleDeleteCustom(team);
}}
/>
))}
</div>
) : (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
<div>
还没有自定义 Team。可以从推荐方案或系统模板复制一份后再改。
{isProjectScopedCustomTeam
? "当前项目还没有自定义 Team。可以从推荐方案或系统模板复制一份后再改。"
: "还没有自定义 Team。可以从推荐方案或系统模板复制一份后再改。"}
</div>
<Button
type="button"
@@ -1099,7 +1169,10 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
className="mt-3 border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
onClick={() =>
handleStartCreate(
selectedTeam || recommendedTeam || builtinTeams[0] || null,
selectedTeam ||
recommendedTeam ||
builtinTeams[0] ||
null,
)
}
>
@@ -1,7 +1,15 @@
const IDLE_PRELOAD_TIMEOUT_MS = 1_500;
const IDLE_PRELOAD_FALLBACK_DELAY_MS = 180;
function shouldSkipIdleModulePreload(): boolean {
return Boolean(import.meta.env?.MODE === "test" || import.meta.env?.VITEST);
}
export function scheduleIdleModulePreload(task: () => void): () => void {
if (shouldSkipIdleModulePreload()) {
return () => {};
}
if (typeof window === "undefined") {
task();
return () => {};
@@ -85,7 +85,12 @@ vi.mock("./components/SkillBadge", () => ({
}));
vi.mock("./components/TeamSelector", () => ({
TeamSelector: () => <div data-testid="team-selector-stub" />,
TeamSelector: (props: { autoOpenToken?: number | null }) => (
<div
data-testid="team-selector-stub"
data-auto-open-token={String(props.autoOpenToken ?? "")}
/>
),
}));
vi.mock("../ChatModelSelector", () => ({
@@ -322,6 +327,9 @@ describe("Inputbar", () => {
expect(
container.querySelector('[data-testid="team-selector-stub"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="team-mode-enable-button"]'),
).toBeTruthy();
const enabledContainer = renderInputbar({
activeTheme: "general",
@@ -340,6 +348,103 @@ describe("Inputbar", () => {
expect(
enabledContainer.querySelector('[data-testid="team-selector-stub"]'),
).toBeTruthy();
expect(
enabledContainer.querySelector('[data-testid="team-mode-enable-button"]'),
).toBeNull();
});
it("未开启 Team mode 时应显示显式开启按钮,并可直接启用", async () => {
const onToolStatesChange = vi.fn();
const container = renderInputbar({
activeTheme: "general",
toolStates: {
webSearch: false,
thinking: false,
task: false,
subagent: false,
},
onToolStatesChange,
});
await act(async () => {
await Promise.resolve();
});
const enableButton = container.querySelector(
'[data-testid="team-mode-enable-button"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
expect(enableButton?.textContent).toContain("开启 Team");
act(() => {
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onToolStatesChange).toHaveBeenCalledWith({
webSearch: false,
thinking: false,
task: false,
subagent: true,
});
});
it("点击开启 Team 后应自动透传 Team 配置面板打开令牌", async () => {
const container = renderInputbar({
activeTheme: "general",
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const enableButton = container.querySelector(
'[data-testid="team-mode-enable-button"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
act(() => {
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const teamSelector = container.querySelector(
'[data-testid="team-selector-stub"]',
) as HTMLDivElement | null;
expect(teamSelector).toBeTruthy();
expect(teamSelector?.getAttribute("data-auto-open-token")).toBe("1");
});
it("复杂任务但未开启 Team 时,开启按钮应显示推荐态", async () => {
const container = renderInputbar({
activeTheme: "general",
input: "请把这个跨模块问题拆分成分析、实现、验证三个并行子任务再汇总",
toolStates: {
webSearch: false,
thinking: false,
task: false,
subagent: false,
},
});
await act(async () => {
await Promise.resolve();
});
const enableButton = container.querySelector(
'[data-testid="team-mode-enable-button"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
expect(enableButton?.textContent).toContain("开启 Team");
expect(enableButton?.textContent).toContain("推荐");
});
it("社媒主题默认应自动注入 social_post_with_cover skill", async () => {
@@ -19,6 +19,7 @@ import type {
import { type InputbarToolStates } from "./hooks/useInputbarToolState";
import { useInputbarController } from "./hooks/useInputbarController";
import type { TeamDefinition } from "../../utils/teamDefinitions";
import type { WorkspaceSettings } from "@/types/workspace";
interface InputbarProps {
input: string;
@@ -69,6 +70,7 @@ interface InputbarProps {
setProviderType?: (type: string) => void;
model?: string;
setModel?: (model: string) => void;
workspaceId?: string | null;
executionStrategy?: "react" | "code_orchestrated" | "auto";
setExecutionStrategy?: (
strategy: "react" | "code_orchestrated" | "auto",
@@ -93,6 +95,8 @@ interface InputbarProps {
selectedTeam?: TeamDefinition | null;
onSelectTeam?: (team: TeamDefinition | null) => void;
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
teamWorkspaceSettings?: WorkspaceSettings | null;
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
}
export const Inputbar: React.FC<InputbarProps> = ({
@@ -122,6 +126,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
setProviderType,
model,
setModel,
workspaceId,
executionStrategy,
setExecutionStrategy,
toolStates,
@@ -141,6 +146,8 @@ export const Inputbar: React.FC<InputbarProps> = ({
selectedTeam,
onSelectTeam,
onEnableSuggestedTeam,
teamWorkspaceSettings,
onPersistCustomTeams,
}) => {
const {
textareaRef,
@@ -245,6 +252,9 @@ export const Inputbar: React.FC<InputbarProps> = ({
onRefreshSkills={onRefreshSkills}
selectedTeam={selectedTeam}
onSelectTeam={onSelectTeam}
teamWorkspaceSettings={teamWorkspaceSettings}
onPersistCustomTeams={onPersistCustomTeams}
workspaceId={workspaceId}
onSend={handleSend}
onToolClick={handleToolClick}
activeTools={activeTools}
@@ -256,6 +266,8 @@ export const Inputbar: React.FC<InputbarProps> = ({
isCanvasOpen={isCanvasOpen}
isThemeWorkbenchVariant={isThemeWorkbenchVariant}
activeTheme={activeTheme}
providerType={providerType}
model={model}
onManageProviders={onManageProviders}
setExecutionStrategy={setExecutionStrategy}
topExtra={topExtra}
@@ -204,6 +204,51 @@ describe("MessageList", () => {
expect(groups[1]?.textContent).toContain("已继续执行发布流程。");
});
it("用户图片消息不应渲染内部图片占位文本", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-user-image",
role: "user",
content: "[Image #1]",
images: [
{
mediaType: "image/png",
data: "aGVsbG8=",
},
],
timestamp: now,
},
];
const container = render(messages);
expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull();
const image = container.querySelector('img[alt="attachment"]');
expect(image).toBeTruthy();
expect(container.textContent).not.toContain("[Image #1]");
});
it("助手内部图片标签应在主消息里隐藏", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-image",
role: "assistant",
content: "[Image #1]",
timestamp: now,
},
];
render(messages);
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
content: "",
}),
);
});
it("助手消息包含 artifacts 时应渲染产物卡片并响应点击", () => {
const now = new Date();
const onArtifactClick = vi.fn();
@@ -33,6 +33,11 @@ import {
resolveArtifactPreviewText,
resolveArtifactWritePhase,
} from "../utils/messageArtifacts";
import {
sanitizeContentPartsForDisplay,
sanitizeMessageTextForDisplay,
sanitizeMessageTextForPreview,
} from "../utils/internalImagePlaceholder";
import {
Message,
type AgentThreadItem,
@@ -255,6 +260,18 @@ const MessageListInner: React.FC<MessageListProps> = ({
showIdentity?: boolean;
},
) => {
const hasImages = Array.isArray(msg.images) && msg.images.length > 0;
const displayContent = sanitizeMessageTextForDisplay(msg.content || "", {
role: msg.role,
hasImages,
});
const displayContentParts = sanitizeContentPartsForDisplay(
msg.contentParts,
{
role: msg.role,
hasImages,
},
);
const mappedTimeline = timelineByMessageId.get(msg.id);
const timeline =
msg.role !== "assistant"
@@ -330,13 +347,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
</div>
) : msg.role === "assistant" ? (
<StreamingRenderer
content={msg.content}
content={displayContent}
isStreaming={msg.isThinking}
toolCalls={msg.toolCalls}
showCursor={msg.isThinking && !msg.content}
showCursor={msg.isThinking && !displayContent}
thinkingContent={msg.thinkingContent}
runtimeStatus={msg.runtimeStatus}
contentParts={msg.contentParts}
contentParts={displayContentParts}
actionRequests={msg.actionRequests}
onA2UISubmit={
onA2UISubmit
@@ -366,15 +383,17 @@ const MessageListInner: React.FC<MessageListProps> = ({
renderProposedPlanBlocks={!timeline}
/>
) : (
<MarkdownRenderer
content={msg.content}
onA2UISubmit={
onA2UISubmit
? (formData) => onA2UISubmit(formData, msg.id)
: undefined
}
renderA2UIInline={renderA2UIInline}
/>
displayContent ? (
<MarkdownRenderer
content={displayContent}
onA2UISubmit={
onA2UISubmit
? (formData) => onA2UISubmit(formData, msg.id)
: undefined
}
renderA2UIInline={renderA2UIInline}
/>
) : null
)}
{msg.images && msg.images.length > 0 && (
@@ -549,10 +568,24 @@ const MessageListInner: React.FC<MessageListProps> = ({
)}
{messageGroups.map((group, groupIndex) => {
const previewSource =
group.userMessage?.content ||
group.assistantMessages[0]?.content ||
"继续当前任务";
const previewSource = group.userMessage
? sanitizeMessageTextForPreview(group.userMessage.content || "", {
role: group.userMessage.role,
hasImages:
Array.isArray(group.userMessage.images) &&
group.userMessage.images.length > 0,
}) || "继续当前任务"
: group.assistantMessages[0]
? sanitizeMessageTextForPreview(
group.assistantMessages[0].content || "",
{
role: group.assistantMessages[0].role,
hasImages:
Array.isArray(group.assistantMessages[0].images) &&
group.assistantMessages[0].images.length > 0,
},
) || "继续当前任务"
: "继续当前任务";
const hasTimeline = group.messages.some((message) =>
timelineByMessageId.has(message.id),
);
@@ -23,6 +23,7 @@ import {
} from "@/lib/api/project";
import { toast } from "sonner";
import { CreateProjectDialog } from "@/components/projects/CreateProjectDialog";
import { notifyProjectCreatedWithRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService";
interface ProjectSelectorProps {
/** 当前激活的主题(用于过滤项目) */
@@ -110,7 +111,7 @@ export function ProjectSelector({
workspaceType: type,
});
toast.success("项目创建成功");
notifyProjectCreatedWithRuntimeAgentsGuide(newProject, "项目创建成功");
await loadProjects();
onSelectProject(newProject.id);
} catch (error) {
@@ -0,0 +1,362 @@
import { Activity, Bot, Clock3, Sparkles, Workflow } from "lucide-react";
import type {
AsterSubagentParentContext,
AsterSubagentSessionInfo,
} from "@/lib/api/agentRuntime";
import { cn } from "@/lib/utils";
import type {
TeamWorkspaceActivityEntry,
TeamWorkspaceControlSummary,
TeamWorkspaceLiveRuntimeState,
TeamWorkspaceRuntimeFormationState,
TeamWorkspaceWaitSummary,
} from "../teamWorkspaceRuntime";
import {
resolveRuntimeFormationStatusMeta,
resolveRuntimeMemberStatusMeta,
summarizeTeamWorkspaceExecution,
} from "../teamWorkspaceRuntime";
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
interface TeamWorkbenchSummaryPanelProps {
currentSessionId?: string | null;
currentSessionRuntimeStatus?: AsterSubagentSessionInfo["runtime_status"];
currentSessionLatestTurnStatus?: AsterSubagentSessionInfo["runtime_status"];
currentSessionQueuedTurnCount?: number;
childSubagentSessions?: AsterSubagentSessionInfo[];
subagentParentContext?: AsterSubagentParentContext | null;
liveRuntimeBySessionId?: Record<string, TeamWorkspaceLiveRuntimeState>;
liveActivityBySessionId?: Record<string, TeamWorkspaceActivityEntry[]>;
teamWaitSummary?: TeamWorkspaceWaitSummary | null;
teamControlSummary?: TeamWorkspaceControlSummary | null;
selectedTeamLabel?: string | null;
selectedTeamSummary?: string | null;
selectedTeamRoles?: TeamRoleDefinition[] | null;
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null;
}
function buildOperationSummary(params: {
teamWaitSummary?: TeamWorkspaceWaitSummary | null;
teamControlSummary?: TeamWorkspaceControlSummary | null;
}) {
if (params.teamControlSummary) {
const affectedCount = params.teamControlSummary.affectedSessionIds.length;
switch (params.teamControlSummary.action) {
case "resume":
return `最近一次恢复操作影响 ${affectedCount} 个 agent。`;
case "close_completed":
return `最近一次收尾操作关闭了 ${affectedCount} 位已完成成员。`;
case "close":
default:
return `最近一次关闭操作影响 ${affectedCount} 个 agent。`;
}
}
if (params.teamWaitSummary) {
return params.teamWaitSummary.timedOut
? `最近一次等待超时,仍有 ${params.teamWaitSummary.awaitedSessionIds.length} 位成员在推进。`
: "最近一次等待已收到成员结果。";
}
return null;
}
export function TeamWorkbenchSummaryPanel({
currentSessionId,
currentSessionRuntimeStatus,
currentSessionLatestTurnStatus,
currentSessionQueuedTurnCount = 0,
childSubagentSessions = [],
subagentParentContext = null,
liveRuntimeBySessionId = {},
liveActivityBySessionId = {},
teamWaitSummary = null,
teamControlSummary = null,
selectedTeamLabel,
selectedTeamSummary,
selectedTeamRoles = [],
runtimeTeamState = null,
}: TeamWorkbenchSummaryPanelProps) {
const executionSummary = summarizeTeamWorkspaceExecution({
currentSessionId,
currentSessionRuntimeStatus,
currentSessionLatestTurnStatus,
currentSessionQueuedTurnCount,
childSubagentSessions,
subagentParentContext,
liveRuntimeBySessionId,
});
const hasRealTeamGraph =
childSubagentSessions.length > 0 || Boolean(subagentParentContext);
const runtimeFormationMeta = runtimeTeamState
? resolveRuntimeFormationStatusMeta(runtimeTeamState.status)
: null;
const runtimeTeamLabel =
runtimeTeamState?.label?.trim() ||
runtimeTeamState?.blueprint?.label?.trim() ||
selectedTeamLabel?.trim() ||
null;
const runtimeSummaryText =
runtimeTeamState?.summary?.trim() ||
runtimeTeamState?.blueprint?.summary?.trim() ||
selectedTeamSummary ||
null;
const operationSummary = buildOperationSummary({
teamWaitSummary,
teamControlSummary,
});
const latestActivity = Object.values(liveActivityBySessionId)
.flat()
.slice(0, 4);
const displayRoleCount = hasRealTeamGraph
? executionSummary.totalSessionCount
: runtimeTeamState?.members.length ?? 0;
const roleCards =
runtimeTeamState?.members.length
? runtimeTeamState.members.map((member) => ({
id: member.id,
label: member.label,
roleKey: member.roleKey,
profileId: member.profileId,
summary: member.summary,
skillIds: member.skillIds,
statusMeta: resolveRuntimeMemberStatusMeta(member.status),
}))
: (selectedTeamRoles ?? []).map((role) => ({
id: role.id,
label: role.label,
roleKey: role.roleKey,
profileId: role.profileId,
summary: role.summary,
skillIds: role.skillIds ?? [],
statusMeta: null,
}));
const summaryCards = [
{
label: "活跃 Agent",
value: String(executionSummary.activeSessionCount),
hint: executionSummary.hasActiveRuntime ? "正在协作" : "尚未运行",
},
{
label: "运行中",
value: String(executionSummary.runningSessionCount),
hint: executionSummary.runningSessionCount > 0 ? "并发执行" : "暂无",
},
{
label: "排队中",
value: String(executionSummary.queuedSessionCount),
hint: executionSummary.queuedSessionCount > 0 ? "等待调度" : "暂无",
},
{
label: hasRealTeamGraph
? "总会话"
: runtimeTeamState
? "当前成员"
: "总会话",
value: String(
hasRealTeamGraph ? executionSummary.totalSessionCount : displayRoleCount,
),
hint: hasRealTeamGraph
? "已进入团队图谱"
: runtimeTeamState
? "本轮编队"
: "等待创建",
},
];
return (
<div className="space-y-4">
<section className="rounded-[24px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
<Workflow className="h-3.5 w-3.5" />
<span>Team Workbench</span>
{runtimeTeamLabel ? (
<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">
{runtimeTeamLabel}
</span>
) : null}
{runtimeFormationMeta ? (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium tracking-normal normal-case",
runtimeFormationMeta.badgeClassName,
)}
>
{runtimeFormationMeta.label}
</span>
) : null}
</div>
<div className="mt-2 text-sm font-semibold text-slate-900">
{executionSummary.statusTitle ||
runtimeFormationMeta?.title ||
"团队已就绪,等待主代理开始编排"}
</div>
<p className="mt-2 text-xs leading-5 text-slate-500">
{runtimeTeamState?.status === "failed"
? runtimeTeamState.errorMessage?.trim() ||
"本轮 Team 准备失败,可继续在当前对话中推进。"
: runtimeSummaryText ||
"这里展示团队总览、运行密度和最近动态;详细协作过程在左侧主画布查看。"}
</p>
{!hasRealTeamGraph && runtimeTeamState ? (
<div
className={cn(
"mt-3 rounded-2xl border px-3 py-2 text-xs leading-5",
runtimeFormationMeta?.badgeClassName ||
"border border-slate-200 bg-slate-50 text-slate-700",
)}
>
{runtimeTeamState.status === "forming"
? "模型正在依据本轮任务准备 Team,真实成员加入后会自动切换到实时协作轨道。"
: runtimeTeamState.status === "formed"
? `已准备 ${runtimeTeamState.members.length} 个成员,当前等待系统分派真实成员进入协作。`
: runtimeTeamState.errorMessage?.trim() ||
"暂未成功准备本轮 Team。"}
</div>
) : null}
{!hasRealTeamGraph && !runtimeTeamState ? (
<div className="mt-3 rounded-2xl border border-sky-100 bg-sky-50 px-3 py-2 text-xs leading-5 text-sky-700">
尚未出现真实团队成员。开始分派成员后,左侧 Team 画布会自动切换为实时协作视图。
</div>
) : null}
</section>
<section className="grid gap-3 sm:grid-cols-2">
{summaryCards.map((card) => (
<div
key={card.label}
className="rounded-[20px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5"
>
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
{card.label}
</div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{card.value}
</div>
<div className="mt-1 text-xs text-slate-500">{card.hint}</div>
</div>
))}
</section>
{roleCards.length > 0 ? (
<section className="rounded-[24px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
<Bot className="h-3.5 w-3.5" />
<span>{runtimeTeamState ? "当前成员" : "角色分工"}</span>
</div>
<div className="mt-3 space-y-2">
{roleCards.map((role) => (
<div
key={`team-workbench-role-${role.id}`}
className="rounded-2xl border border-slate-200 bg-slate-50 px-3 py-3"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">
{role.label}
</span>
{role.statusMeta ? (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
role.statusMeta.badgeClassName,
)}
>
{role.statusMeta.label}
</span>
) : null}
{role.roleKey ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
{role.roleKey}
</span>
) : null}
{role.profileId ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
{role.profileId}
</span>
) : null}
</div>
<div className="mt-1.5 text-xs leading-5 text-slate-600">
{role.summary}
</div>
{role.skillIds.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{role.skillIds.map((skillId) => (
<span
key={`${role.id}-${skillId}`}
className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500"
>
{skillId}
</span>
))}
</div>
) : null}
</div>
))}
</div>
{runtimeTeamState?.blueprint?.label ? (
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-500">
参考蓝图 Team:{runtimeTeamState.blueprint.label}
</div>
) : null}
</section>
) : null}
{(operationSummary || latestActivity.length > 0) && (
<section className="rounded-[24px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
<Activity className="h-3.5 w-3.5" />
<span>最近动态</span>
</div>
{operationSummary ? (
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm leading-6 text-slate-700">
{operationSummary}
</div>
) : null}
{latestActivity.length > 0 ? (
<div className="mt-3 space-y-2">
{latestActivity.map((entry) => (
<div
key={`team-workbench-activity-${entry.id}`}
className="rounded-2xl border border-slate-200 bg-white px-3 py-3"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">
{entry.title}
</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
entry.badgeClassName,
)}
>
{entry.statusLabel}
</span>
</div>
<div className="mt-1.5 text-xs leading-5 text-slate-600">
{entry.detail}
</div>
</div>
))}
</div>
) : null}
</section>
)}
<div className="rounded-[24px] border border-slate-200/80 bg-slate-50 px-4 py-3 text-xs leading-5 text-slate-500">
<div className="flex items-center gap-2 font-semibold uppercase tracking-[0.12em] text-slate-500">
<Clock3 className="h-3.5 w-3.5" />
<span>交互说明</span>
</div>
<div className="mt-2">
左侧主画布负责展示成员轨道、实时过程与细节;右侧用于总览、角色结构与快速理解当前协作状态。
</div>
<div className="mt-2 flex items-center gap-2 text-sky-700">
<Sparkles className="h-3.5 w-3.5" />
<span>这套布局会复用到图片、多文件和其他专用画布。</span>
</div>
</div>
</div>
);
}
export default TeamWorkbenchSummaryPanel;
@@ -125,7 +125,7 @@ describe("TeamWorkspaceBoard", () => {
});
expect(container.textContent).toContain("Team Workspace");
expect(container.textContent).toContain("尚未创建真实 child session");
expect(container.textContent).toContain("尚未出现真实团队成员");
expect(container.textContent).toContain("查看详情");
expect(container.textContent).not.toContain("spawn_agent");
expect(container.textContent).not.toContain("Explorer 槽位");
@@ -147,12 +147,58 @@ describe("TeamWorkspaceBoard", () => {
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("等待真实子代理");
expect(container.textContent).toContain("spawn_agent");
expect(container.textContent).toContain("等待团队成员加入");
expect(container.textContent).toContain("分派成员");
expect(container.textContent).toContain("收起");
});
it("父线程视角应展示 child session、最近过程并支持打开焦点会话", async () => {
it("已选 Team 但尚无真实子会话时,应在主画布展示计划角色", async () => {
const container = await renderBoard({
shellVisible: true,
childSubagentSessions: [],
selectedTeamLabel: "临时修复 Team",
selectedTeamSummary: "分析、执行、验证三段式推进。",
selectedTeamRoles: [
{
id: "explorer",
label: "分析",
summary: "负责定位问题与影响范围。",
profileId: "code-explorer",
roleKey: "explorer",
skillIds: ["repo-exploration"],
},
{
id: "executor",
label: "执行",
summary: "负责完成改动并给出结果。",
profileId: "code-executor",
roleKey: "executor",
skillIds: ["bounded-implementation"],
},
],
});
expect(container.textContent).toContain("临时修复 Team");
expect(container.textContent).toContain("2 个计划角色");
const expandButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("查看详情"),
);
expect(expandButton).toBeTruthy();
act(() => {
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("计划中的 Team 角色");
expect(container.textContent).toContain("分析");
expect(container.textContent).toContain("执行");
expect(container.textContent).toContain("Role · explorer");
expect(container.textContent).toContain("Profile · code-explorer");
expect(container.textContent).toContain("Skill · repo-exploration");
});
it("主会话视角应展示团队成员、最近过程并支持打开焦点会话", async () => {
const onOpenSubagentSession = vi.fn();
mockGetAgentRuntimeSession.mockImplementation(async (sessionId: string) => {
if (sessionId === "child-2") {
@@ -249,7 +295,7 @@ describe("TeamWorkspaceBoard", () => {
expect(container.textContent).toContain("Team Workspace");
expect(container.textContent).toContain("研究员");
expect(container.textContent).toContain("执行器");
expect(container.textContent).toContain("2 个 child session 已加入");
expect(container.textContent).toContain("2 位成员已加入");
expect(container.textContent).toContain("代码分析员");
expect(container.textContent).toContain("代码排障团队");
expect(container.textContent).toContain("仓库探索");
@@ -268,7 +314,7 @@ describe("TeamWorkspaceBoard", () => {
expect(container.textContent).toContain("已对 3 个来源完成去重校验。");
const openButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("打开会话"),
(element) => element.textContent?.includes("查看对话"),
);
expect(openButton).toBeTruthy();
@@ -480,6 +526,60 @@ describe("TeamWorkspaceBoard", () => {
expect(summary?.textContent).toContain("整理竞品与数据来源");
});
it("嵌入态真实 team 应支持收起并重新展开详情", async () => {
const container = await renderBoard({
embedded: true,
childSubagentSessions: [
{
id: "child-collapse-1",
name: "研究员",
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
session_type: "sub_agent",
runtime_status: "running",
latest_turn_status: "running",
task_summary: "整理竞品与数据来源,补齐关键差异点与证据链。",
role_hint: "explorer",
},
],
});
expect(
container.querySelector('[data-testid="team-workspace-detail-section"]'),
).toBeTruthy();
const collapseButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("收起详情"),
);
expect(collapseButton).toBeTruthy();
act(() => {
collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(
container.querySelector('[data-testid="team-workspace-detail-section"]'),
).toBeFalsy();
expect(
container.querySelector('[data-testid="team-workspace-compact-summary"]'),
).toBeTruthy();
expect(container.textContent).toContain("紧凑视图");
expect(container.textContent).toContain("展开详情");
const expandButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("展开详情"),
);
expect(expandButton).toBeTruthy();
act(() => {
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(
container.querySelector('[data-testid="team-workspace-detail-section"]'),
).toBeTruthy();
});
it("注入 live runtime props 后应立即投影状态与最近轨迹", async () => {
mockGetAgentRuntimeSession.mockImplementation(async (sessionId: string) =>
createSessionDetail(sessionId),
@@ -600,7 +700,7 @@ describe("TeamWorkspaceBoard", () => {
expect(container.textContent).toContain("页面结构差异已提取完成。");
});
it("选中运行中的子代理时应展示关闭操作,关闭后触发回调", async () => {
it("选中运行中的子代理时应展示停止操作,停止后触发回调", async () => {
const onCloseSubagentSession = vi.fn();
const container = await renderBoard({
childSubagentSessions: [
@@ -619,8 +719,12 @@ describe("TeamWorkspaceBoard", () => {
onCloseSubagentSession,
});
expect(container.textContent).toContain(
"停止会中断当前执行并保留会话,可稍后恢复。",
);
const closeButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("关闭子代理"),
(element) => element.textContent?.includes("停止成员"),
);
expect(closeButton).toBeTruthy();
@@ -652,9 +756,12 @@ describe("TeamWorkspaceBoard", () => {
});
const resumeButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("恢复子代理"),
(element) => element.textContent?.includes("恢复成员"),
);
expect(resumeButton).toBeTruthy();
expect(container.textContent).toContain(
"停止会中断当前执行并保留会话,可稍后恢复。",
);
await act(async () => {
resumeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -684,7 +791,7 @@ describe("TeamWorkspaceBoard", () => {
});
const waitButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("等待 30 秒"),
(element) => element.textContent?.includes("等待结果 30 秒"),
);
expect(waitButton).toBeTruthy();
@@ -737,11 +844,11 @@ describe("TeamWorkspaceBoard", () => {
onWaitActiveTeamSessions,
});
expect(container.textContent).toContain("等待任一活跃 agent");
expect(container.textContent).toContain("2 个活跃 agent 可聚合 wait");
expect(container.textContent).toContain("等待任一活跃成员");
expect(container.textContent).toContain("2 位活跃成员可统一等待");
const waitAnyButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("等待任一活跃 agent"),
(element) => element.textContent?.includes("等待任一活跃成员"),
);
expect(waitAnyButton).toBeTruthy();
@@ -796,8 +903,8 @@ describe("TeamWorkspaceBoard", () => {
);
expect(operations).toBeTruthy();
expect(operations?.textContent).toContain("Team wait 命中");
expect(operations?.textContent).toContain("最近一次 team wait 命中 执行器");
expect(operations?.textContent).toContain("等待命中");
expect(operations?.textContent).toContain("最近一次统一等待命中 执行器");
expect(operations?.textContent).toContain("已进入已完成状态");
expect(container.textContent).toContain("输出落地方案");
});
@@ -885,9 +992,9 @@ describe("TeamWorkspaceBoard", () => {
);
expect(operations).toBeTruthy();
expect(operations?.textContent).toContain("级联关闭");
expect(operations?.textContent).toContain("级联停止");
expect(operations?.textContent).toContain(
"最近一次 close_agent 已级联关闭 2 个会话",
"最近一次停止操作已级联停止 2 位成员",
);
});
@@ -940,7 +1047,7 @@ describe("TeamWorkspaceBoard", () => {
expect(summary?.textContent).toContain("输出落地方案");
const controlEntry = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("级联关闭"),
(element) => element.textContent?.includes("级联停止"),
);
expect(controlEntry).toBeTruthy();
@@ -997,12 +1104,12 @@ describe("TeamWorkspaceBoard", () => {
onCloseCompletedTeamSessions,
});
expect(container.textContent).toContain("关闭已完成 agent");
expect(container.textContent).toContain("2 个已完成 agent 可释放 slot");
expect(container.textContent).toContain("清理已完成成员");
expect(container.textContent).toContain("2 位已完成成员可清理");
const closeCompletedButton = Array.from(
container.querySelectorAll("button"),
).find((element) => element.textContent?.includes("关闭已完成 agent"));
).find((element) => element.textContent?.includes("清理已完成成员"));
expect(closeCompletedButton).toBeTruthy();
await act(async () => {
@@ -1058,11 +1165,11 @@ describe("TeamWorkspaceBoard", () => {
});
const sendButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("发送补充任务"),
(element) => element.textContent?.includes("发送补充说明"),
);
const interruptButton = Array.from(
container.querySelectorAll("button"),
).find((element) => element.textContent?.includes("中断后发送"));
).find((element) => element.textContent?.includes("中断并发送"));
expect(sendButton).toBeTruthy();
expect(interruptButton).toBeTruthy();
@@ -1127,4 +1234,107 @@ describe("TeamWorkspaceBoard", () => {
expect(header?.className).toContain("sticky");
expect(header?.className).toContain("top-0");
});
it("本轮 Team 准备中时,应在空 shell 展示组建状态", async () => {
const container = await renderBoard({
shellVisible: true,
runtimeTeamState: {
requestId: "runtime-forming-1",
status: "forming",
label: "排障 Team",
summary: "围绕当前任务组织最轻量可用的运行时团队。",
members: [],
blueprint: {
label: "代码排障团队",
summary: "分析、执行、验证三段式推进。",
roles: [],
},
updatedAt: Date.now(),
},
});
expect(container.textContent).toContain("正在准备本轮 Team");
expect(container.textContent).toContain("组建中");
expect(container.textContent).toContain("Team · 排障 Team");
expect(container.textContent).toContain("参考蓝图 · 代码排障团队");
});
it("本轮 Team 已就绪时,应在无真实子会话下展示当前成员", async () => {
const container = await renderBoard({
shellVisible: true,
defaultShellExpanded: true,
runtimeTeamState: {
requestId: "runtime-formed-1",
status: "formed",
label: "修复 Team",
summary: "分析、执行、验证协作闭环。",
members: [
{
id: "runtime-explorer",
label: "分析",
summary: "收敛问题边界并整理影响范围。",
roleKey: "explorer",
profileId: "code-explorer",
skillIds: ["repo-exploration"],
status: "planned",
},
{
id: "runtime-executor",
label: "执行",
summary: "在边界内落地修复并汇报结果。",
roleKey: "executor",
profileId: "code-executor",
skillIds: ["bounded-implementation"],
status: "planned",
},
],
blueprint: {
label: "代码排障团队",
summary: "分析、执行、验证三段式推进。",
roles: [
{
id: "explorer",
label: "分析",
summary: "先定位问题与影响面。",
},
],
},
updatedAt: Date.now(),
},
});
expect(
container.querySelector('[data-testid="team-workspace-runtime-formation"]'),
).toBeTruthy();
expect(
container.querySelector('[data-testid="team-workspace-runtime-members"]'),
).toBeTruthy();
expect(container.textContent).toContain("本轮 Team 已就绪");
expect(container.textContent).toContain("分析");
expect(container.textContent).toContain("执行");
expect(container.textContent).toContain("Skill · repo-exploration");
expect(container.textContent).toContain("参考蓝图角色");
});
it("本轮 Team 准备失败时,应展示失败原因", async () => {
const container = await renderBoard({
shellVisible: true,
defaultShellExpanded: true,
runtimeTeamState: {
requestId: "runtime-failed-1",
status: "failed",
label: "失败的 Team",
summary: null,
members: [],
blueprint: null,
errorMessage: "Provider 认证失败,无法生成 Team。",
updatedAt: Date.now(),
},
});
expect(container.textContent).toContain("Team 生成失败");
expect(container.textContent).toContain(
"Provider 认证失败,无法生成 Team。",
);
});
});
@@ -7,6 +7,7 @@ import {
ChevronUp,
Clock3,
Loader2,
PanelTop,
Workflow,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
@@ -23,17 +24,22 @@ import type {
import { formatRelativeTime } from "@/lib/api/project";
import { cn } from "@/lib/utils";
import { getTeamPresetOption } from "../utils/teamPresets";
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
import {
applyLiveRuntimeState,
buildTeamWorkspaceActivityEntryFromThreadItem,
buildTeamWorkspaceSessionFingerprint,
isTeamWorkspaceActiveStatus,
isTeamWorkspaceTerminalStatus,
mergeSessionActivityEntries,
normalizeTeamWorkspaceRuntimeStatus,
resolveRuntimeFormationStatusMeta,
resolveRuntimeMemberStatusMeta,
resolveTeamWorkspaceRuntimeStatusLabel,
type TeamWorkspaceActivityEntry,
type TeamWorkspaceControlSummary,
type TeamWorkspaceLiveRuntimeState,
type TeamWorkspaceRuntimeFormationState,
type TeamWorkspaceWaitSummary,
} from "../teamWorkspaceRuntime";
@@ -76,6 +82,10 @@ interface TeamWorkspaceBoardProps {
onReturnToParentSession?: () => void | Promise<void>;
teamWaitSummary?: TeamWorkspaceWaitSummary | null;
teamControlSummary?: TeamWorkspaceControlSummary | null;
selectedTeamLabel?: string | null;
selectedTeamSummary?: string | null;
selectedTeamRoles?: TeamRoleDefinition[] | null;
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null;
}
interface TeamSessionCard {
@@ -176,7 +186,7 @@ const STATUS_META: Record<
dotClassName: "bg-rose-500",
},
closed: {
label: "已关闭",
label: "已停止",
badgeClassName: "border border-slate-200 bg-slate-100 text-slate-600",
cardClassName: "border-slate-200 bg-slate-50",
dotClassName: "bg-slate-400",
@@ -190,7 +200,7 @@ function resolveStatusMeta(status?: RuntimeStatus) {
function resolveSessionTypeLabel(value?: string) {
switch (value) {
case "sub_agent":
return "子代理";
return "协作成员";
case "fork":
return "分支会话";
case "user":
@@ -443,21 +453,21 @@ function buildTeamWaitSummaryDisplay(
} {
if (summary.timedOut) {
return {
text: `最近一次 team wait 已超时,${summary.awaitedSessionIds.length} 个活跃 agent 仍在推进。`,
text: `最近一次统一等待已超时,${summary.awaitedSessionIds.length} 位活跃成员仍在推进。`,
badgeClassName: "border border-amber-200 bg-amber-50 text-amber-700",
};
}
const resolvedName = summary.resolvedSessionId
? sessionNameById.get(summary.resolvedSessionId) ?? summary.resolvedSessionId
: "子代理";
: "成员";
const normalizedStatus = summary.resolvedStatus
? normalizeTeamWorkspaceRuntimeStatus(summary.resolvedStatus)
: undefined;
const statusMeta = resolveStatusMeta(normalizedStatus);
return {
text: `最近一次 team wait 命中 ${resolvedName},已进入${resolveTeamWorkspaceRuntimeStatusLabel(summary.resolvedStatus)}状态。`,
text: `最近一次统一等待命中 ${resolvedName},已进入${resolveTeamWorkspaceRuntimeStatusLabel(summary.resolvedStatus)}状态。`,
badgeClassName: statusMeta.badgeClassName,
};
}
@@ -473,20 +483,20 @@ function buildTeamControlSummaryDisplay(
const firstAffectedId = summary.affectedSessionIds[0];
const firstAffectedName = firstAffectedId
? sessionNameById.get(firstAffectedId) ?? firstAffectedId
: "子代理";
: "成员";
switch (summary.action) {
case "resume":
return {
text:
affectedCount > 1
? `最近一次 resume_agent 已级联恢复 ${affectedCount} 个会话。`
: `最近一次 resume_agent 已恢复 ${firstAffectedName}。`,
? `最近一次恢复操作已级联恢复 ${affectedCount} 位成员。`
: `最近一次恢复操作已恢复 ${firstAffectedName}。`,
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
};
case "close_completed":
return {
text: `最近一次批量 close_agent 已关闭 ${affectedCount} 个会话。`,
text: `最近一次批量清理已关闭 ${affectedCount} 位成员。`,
badgeClassName: "border border-slate-200 bg-slate-100 text-slate-700",
};
case "close":
@@ -494,8 +504,8 @@ function buildTeamControlSummaryDisplay(
return {
text:
affectedCount > 1
? `最近一次 close_agent 已级联关闭 ${affectedCount} 个会话。`
: `最近一次 close_agent 已关闭 ${firstAffectedName}。`,
? `最近一次停止操作已级联停止 ${affectedCount} 位成员。`
: `最近一次停止操作已停止 ${firstAffectedName}。`,
badgeClassName: "border border-slate-200 bg-slate-100 text-slate-700",
};
}
@@ -515,7 +525,7 @@ function buildTeamOperationDisplayEntries(params: {
);
entries.push({
id: `wait-${params.teamWaitSummary.updatedAt}`,
title: params.teamWaitSummary.timedOut ? "Team wait 超时" : "Team wait 命中",
title: params.teamWaitSummary.timedOut ? "等待超时" : "等待命中",
detail: display.text,
badgeClassName: display.badgeClassName,
updatedAt: params.teamWaitSummary.updatedAt,
@@ -538,7 +548,7 @@ function buildTeamOperationDisplayEntries(params: {
return "批量关闭";
case "close":
default:
return "级联关闭";
return "级联停止";
}
})();
@@ -571,7 +581,7 @@ function buildCurrentChildSession(
return {
id: currentSessionId,
name: currentSessionName?.trim() || "当前子代理",
name: currentSessionName?.trim() || "当前成员",
runtimeStatus: currentSessionRuntimeStatus,
taskSummary: subagentParentContext.task_summary,
roleHint: subagentParentContext.role_hint,
@@ -603,10 +613,10 @@ function buildOrchestratorSession(
return {
id: currentSessionId,
name: currentSessionName?.trim() || "主线程编排器",
name: currentSessionName?.trim() || "主会话编排器",
runtimeStatus: currentSessionRuntimeStatus,
taskSummary:
"当前主线程负责拆分任务、启动子代理、等待结果并在同一团队面板中汇总结论。",
"当前主会话负责拆分任务、启动成员协作、等待结果并在同一团队面板中汇总结论。",
roleHint: "orchestrator",
sessionType: "user",
isCurrent: true,
@@ -647,9 +657,9 @@ function buildBoardHeadline(params: {
return subagentParentContext?.parent_session_name?.trim() || "父会话团队";
}
if (!hasRealTeamGraph) {
return "等待真实子代理";
return "等待团队成员加入";
}
return totalTeamSessions > 0 ? `团队中有 ${totalTeamSessions} 个 agent` : "团队协作";
return totalTeamSessions > 0 ? `团队中有 ${totalTeamSessions} 位成员` : "团队协作";
}
function buildBoardHint(params: {
@@ -660,10 +670,10 @@ function buildBoardHint(params: {
const { hasRealTeamGraph, isChildSession, siblingCount } = params;
if (isChildSession) {
return siblingCount > 0 ? `当前与 ${siblingCount} 个 sibling 同组协作` : "当前为唯一子代理";
return siblingCount > 0 ? `当前与 ${siblingCount} 位同组成员协作` : "当前为唯一成员";
}
if (!hasRealTeamGraph) {
return "只有出现真实 child session 才会展开团队视图";
return "只有出现真实团队成员时才会展开团队视图";
}
return "仅在任务需要拆分时进入 team,不默认使用多代理";
}
@@ -676,15 +686,48 @@ function buildFallbackSummary(params: {
const { hasRealTeamGraph, isChildSession, selectedSession } = params;
if (!hasRealTeamGraph) {
return "尚未创建真实 child session。下一次 spawn_agent 成功后,这里会展示团队成员与最新状态。";
return "尚未出现真实团队成员。开始分派成员后,这里会展示团队成员与最新状态。";
}
if (selectedSession?.sessionType === "user") {
return "主线程负责拆分任务、分派团队成员,并在必要时等待结果后汇总。";
return "主会话负责拆分任务、分派团队成员,并在必要时等待结果后汇总。";
}
if (isChildSession) {
return "当前 agent 正在执行父线程分派的子任务,可在这里快速切换到 siblings 或返回父会话。";
return "当前成员正在执行主会话分派的子任务,可在这里快速切换到同组成员或返回主会话。";
}
return "选择一位团队成员后,这里会展示它的任务摘要、运行状态与模型信息。";
}
function buildRuntimeFormationHint(
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null,
) {
switch (runtimeTeamState?.status) {
case "forming":
return "先准备本轮 Team,再等待真实团队成员接入实时轨道。";
case "formed":
return "本轮 Team 已就绪,真实团队成员加入后会自动接管实时协作。";
case "failed":
return "Team 准备失败,但主会话仍可继续执行或稍后重试。";
default:
return "只有出现真实团队成员时才会展开团队视图";
}
}
function buildRuntimeFormationEmptyDetail(
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null,
) {
switch (runtimeTeamState?.status) {
case "forming":
return "当前正在根据本轮任务准备 Team。完成后,这里会先展示本轮成员卡片,随后等待真实团队成员接入。";
case "formed":
return "本轮 Team 已就绪。当前画布先展示本轮成员蓝图,待系统真正分派成员并开始协作后,会自动切换到真实协作轨道。";
case "failed":
return (
runtimeTeamState.errorMessage?.trim() ||
"本轮 Team 准备失败,暂时无法展示本轮成员。"
);
default:
return "当前还没有真实团队成员。系统开始分派成员后,详情区会切换为选中成员的摘要视图。";
}
return "选择一位 team agent 后,这里会展示它的任务摘要、运行状态与模型信息。";
}
export function TeamWorkspaceBoard({
@@ -712,6 +755,10 @@ export function TeamWorkspaceBoard({
onReturnToParentSession,
teamWaitSummary = null,
teamControlSummary = null,
selectedTeamLabel = null,
selectedTeamSummary = null,
selectedTeamRoles = [],
runtimeTeamState = null,
}: TeamWorkspaceBoardProps) {
const isChildSession = Boolean(subagentParentContext);
const [shellExpanded, setShellExpanded] = useState(defaultShellExpanded);
@@ -857,6 +904,260 @@ export function TeamWorkspaceBoard({
const hasRealTeamGraph = isChildSession || visibleSessions.length > 0;
const isEmptyShellState =
!isChildSession && shellVisible && visibleSessions.length === 0;
const normalizedSelectedTeamLabel = selectedTeamLabel?.trim() || null;
const normalizedSelectedTeamSummary = selectedTeamSummary?.trim() || null;
const normalizedSelectedTeamRoles = (selectedTeamRoles ?? []).filter((role) =>
role.label.trim(),
);
const runtimeFormationMeta = runtimeTeamState
? resolveRuntimeFormationStatusMeta(runtimeTeamState.status)
: null;
const runtimeFormationLabel =
runtimeTeamState?.label?.trim() ||
runtimeTeamState?.blueprint?.label?.trim() ||
normalizedSelectedTeamLabel;
const runtimeFormationSummary =
runtimeTeamState?.summary?.trim() ||
runtimeTeamState?.blueprint?.summary?.trim() ||
normalizedSelectedTeamSummary;
const runtimeMembers = runtimeTeamState?.members ?? [];
const runtimeBlueprintRoles = runtimeTeamState?.blueprint?.roles ?? [];
const hasRuntimeFormation = Boolean(runtimeTeamState);
const hasSelectedTeamPlan =
Boolean(normalizedSelectedTeamLabel) ||
Boolean(normalizedSelectedTeamSummary) ||
normalizedSelectedTeamRoles.length > 0;
const renderSelectedTeamPlanSummary = () => {
if (!hasSelectedTeamPlan) {
return null;
}
return (
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
{normalizedSelectedTeamLabel ? (
<span className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-sky-700">
Team · {normalizedSelectedTeamLabel}
</span>
) : null}
{normalizedSelectedTeamRoles.length > 0 ? (
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
{normalizedSelectedTeamRoles.length} 个计划角色
</span>
) : null}
</div>
);
};
const renderSelectedTeamPlanPanel = () => {
if (!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>计划中的 Team 角色</span>
{normalizedSelectedTeamLabel ? (
<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">
{normalizedSelectedTeamLabel}
</span>
) : null}
</div>
{normalizedSelectedTeamSummary ? (
<p className="mt-2 text-sm leading-6 text-slate-600">
{normalizedSelectedTeamSummary}
</p>
) : null}
{normalizedSelectedTeamRoles.length > 0 ? (
<div className="mt-3 grid gap-3 xl:grid-cols-2">
{normalizedSelectedTeamRoles.map((role) => (
<div
key={`planned-team-role-${role.id}`}
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">
{role.label}
</span>
{role.roleKey ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
Role · {role.roleKey}
</span>
) : null}
{role.profileId ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
Profile · {role.profileId}
</span>
) : null}
</div>
<p className="mt-2 text-sm leading-6 text-slate-600">
{role.summary}
</p>
{role.skillIds?.length ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{role.skillIds.map((skillId) => (
<span
key={`${role.id}-${skillId}`}
className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500"
>
Skill · {skillId}
</span>
))}
</div>
) : null}
</div>
))}
</div>
) : null}
</div>
);
};
const renderRuntimeFormationSummary = () => {
if (!hasRuntimeFormation) {
return null;
}
return (
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
{runtimeFormationLabel ? (
<span className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-sky-700">
Team · {runtimeFormationLabel}
</span>
) : null}
{runtimeFormationMeta ? (
<span
className={cn(
"rounded-full px-2.5 py-1 font-medium",
runtimeFormationMeta.badgeClassName,
)}
>
{runtimeFormationMeta.label}
</span>
) : null}
{runtimeMembers.length > 0 ? (
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
{runtimeMembers.length} 个当前成员
</span>
) : null}
{runtimeTeamState?.blueprint?.label ? (
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1">
参考蓝图 · {runtimeTeamState.blueprint.label}
</span>
) : null}
</div>
);
};
const renderRuntimeFormationPanel = () => {
if (!runtimeTeamState || !runtimeFormationMeta) {
return null;
}
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>本轮编队</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium tracking-normal",
runtimeFormationMeta.badgeClassName,
)}
>
{runtimeFormationMeta.label}
</span>
{runtimeFormationLabel ? (
<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">
{runtimeFormationLabel}
</span>
) : null}
</div>
<div className="mt-2 text-sm font-semibold text-slate-900">
{runtimeFormationMeta.title}
</div>
<p className="mt-2 text-sm leading-6 text-slate-600">
{runtimeTeamState.status === "failed"
? runtimeTeamState.errorMessage?.trim() ||
"本轮 Team 准备失败,暂时无法展示更多内容。"
: runtimeFormationSummary ||
"这里展示本轮 Team 规划结果,真实成员加入后会接管实时协作视图。"}
</p>
{runtimeTeamState.blueprint?.label ? (
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-500">
参考蓝图 Team:{runtimeTeamState.blueprint.label}
</div>
) : null}
</div>
);
};
const renderRuntimeMemberPanel = () => {
if (runtimeMembers.length === 0) {
return null;
}
return (
<div
className="mt-3 grid gap-3 xl:grid-cols-2"
data-testid="team-workspace-runtime-members"
>
{runtimeMembers.map((member) => {
const memberMeta = resolveRuntimeMemberStatusMeta(member.status);
return (
<div
key={`runtime-team-member-${member.id}`}
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">
{member.label}
</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
memberMeta.badgeClassName,
)}
>
{memberMeta.label}
</span>
{member.roleKey ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
Role · {member.roleKey}
</span>
) : null}
{member.profileId ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
Profile · {member.profileId}
</span>
) : null}
</div>
<p className="mt-2 text-sm leading-6 text-slate-600">
{member.summary}
</p>
{member.skillIds.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{member.skillIds.map((skillId) => (
<span
key={`${member.id}-${skillId}`}
className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500"
>
Skill · {skillId}
</span>
))}
</div>
) : null}
</div>
);
})}
</div>
);
};
const railSessions = useMemo(
() =>
@@ -1258,10 +1559,12 @@ export function TeamWorkspaceBoard({
onSendSubagentInput &&
selectedSession.id !== currentSessionId,
);
const canCloseSelectedSession = Boolean(
const canStopSelectedSession = Boolean(
selectedSession &&
selectedSession.sessionType !== "user" &&
selectedSession.runtimeStatus !== "closed" &&
isTeamWorkspaceActiveStatus(
selectedSession.runtimeStatus ?? selectedSession.latestTurnStatus,
) &&
onCloseSubagentSession,
);
const canResumeSelectedSession = Boolean(
@@ -1456,15 +1759,20 @@ export function TeamWorkspaceBoard({
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">
Team 运行时已就绪
{runtimeFormationMeta?.title || "Team 运行时已就绪"}
</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-600">
尚未创建真实 child session
{runtimeFormationMeta?.label || "尚未出现真实团队成员"}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
这里先保持轻量状态条,避免遮挡消息区。只有真正创建子代理后,团队面板才需要展开。
{hasRuntimeFormation
? buildRuntimeFormationHint(runtimeTeamState)
: "这里先保持轻量状态条,避免遮挡消息区。只有真正出现团队成员后,团队面板才需要展开。"}
</p>
{hasRuntimeFormation
? renderRuntimeFormationSummary()
: renderSelectedTeamPlanSummary()}
</div>
<Button
type="button"
@@ -1482,17 +1790,30 @@ export function TeamWorkspaceBoard({
const selectedStatusMeta = resolveStatusMeta(selectedSession?.runtimeStatus);
const detailVisible = isEmptyShellState ? shellExpanded : detailExpanded;
const boardHeadline = buildBoardHeadline({
hasRealTeamGraph,
isChildSession,
subagentParentContext,
totalTeamSessions,
});
const boardHint = buildBoardHint({
hasRealTeamGraph,
isChildSession,
siblingCount,
});
const detailToggleLabel = isEmptyShellState
? detailVisible
? "收起面板"
: "展开详情"
: detailVisible
? "收起详情"
: "展开详情";
const boardHeadline =
!hasRealTeamGraph && runtimeFormationMeta
? runtimeFormationMeta.title
: buildBoardHeadline({
hasRealTeamGraph,
isChildSession,
subagentParentContext,
totalTeamSessions,
});
const boardHint =
!hasRealTeamGraph && hasRuntimeFormation
? buildRuntimeFormationHint(runtimeTeamState)
: buildBoardHint({
hasRealTeamGraph,
isChildSession,
siblingCount,
});
const detailSummary =
selectedSession?.taskSummary ||
buildFallbackSummary({
@@ -1528,7 +1849,7 @@ export function TeamWorkspaceBoard({
const stackedRail = embedded;
const boardShellClassName = cn(
embedded
? "pointer-events-auto flex min-h-0 flex-1 flex-col overflow-hidden rounded-[26px] border border-slate-200 bg-white shadow-[0_18px_64px_-36px_rgba(15,23,42,0.18)]"
? "pointer-events-auto flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-[26px] border border-slate-200 bg-white shadow-[0_18px_64px_-36px_rgba(15,23,42,0.18)]"
: "overflow-hidden rounded-[26px] border border-slate-200 bg-white shadow-[0_18px_64px_-42px_rgba(15,23,42,0.24)]",
embedded ? "mx-0 mt-0" : "mx-3 mt-2",
className,
@@ -1540,7 +1861,7 @@ export function TeamWorkspaceBoard({
: "border-b border-slate-200 bg-slate-50",
);
const boardBodyClassName = embedded
? "min-h-0 flex-1 overflow-y-auto p-3 sm:p-4 space-y-3"
? "min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4 space-y-3"
: "p-3 sm:p-4";
const railCardClassName = embedded
? "pointer-events-auto space-y-4"
@@ -1563,14 +1884,20 @@ export function TeamWorkspaceBoard({
const timelineEntryClassName = embedded
? "border-l-2 border-slate-200 pl-3"
: "rounded-[14px] border border-slate-200 bg-white p-3";
const railTitle = isChildSession ? "同组 Agent 轨道" : "Team Agent 轨道";
const railTitle = isChildSession ? "同组成员轨道" : "团队成员轨道";
const railSubtitle = isChildSession
? siblingCount > 0
? `当前 agent 与 ${siblingCount} 个 sibling 同组`
: "当前只有一个真实子线程"
? `当前成员与 ${siblingCount} 位同组成员协作`
: "当前只有一位成员"
: hasRealTeamGraph
? `${visibleSessions.length} 个 child session 已加入`
: "等待真实 child session";
? `${visibleSessions.length} 位成员已加入`
: runtimeTeamState?.status === "forming"
? "正在准备本轮成员"
: runtimeTeamState?.status === "formed"
? `${runtimeMembers.length} 个成员待启动`
: runtimeTeamState?.status === "failed"
? "Team 准备失败"
: "等待成员加入";
return (
<section
@@ -1602,7 +1929,7 @@ export function TeamWorkspaceBoard({
) : null}
{!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} 个活跃 agent
{totalTeamSessions} 位活跃成员
</span>
) : null}
</div>
@@ -1629,13 +1956,13 @@ export function TeamWorkspaceBoard({
) : (
<ChevronDown className="mr-1.5 h-3.5 w-3.5" />
)}
{detailVisible ? "收起" : "展开详情"}
{detailToggleLabel}
</Button>
)}
{isEmptyShellState
? (
<span className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500">
尚未创建真实 child session
{runtimeFormationMeta?.label || "尚未出现真实团队成员"}
</span>
)
: Object.entries(statusSummary)
@@ -1712,14 +2039,14 @@ export function TeamWorkspaceBoard({
{pendingTeamAction === "wait_any" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
{pendingTeamAction === "wait_any"
? "等待中..."
: "等待任一活跃 agent"}
{pendingTeamAction === "wait_any"
? "等待中..."
: "等待任一活跃成员"}
</Button>
) : null}
{canWaitAnyActiveTeamSession ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-500">
{waitableTeamSessionIds.length} 个活跃 agent 可聚合 wait
{waitableTeamSessionIds.length} 位活跃成员可统一等待
</span>
) : null}
{canCloseCompletedTeamSessions ? (
@@ -1734,14 +2061,14 @@ export function TeamWorkspaceBoard({
{pendingTeamAction === "close_completed" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
{pendingTeamAction === "close_completed"
? "关闭中..."
: "关闭已完成 agent"}
{pendingTeamAction === "close_completed"
? "关闭中..."
: "清理已完成成员"}
</Button>
) : null}
{canCloseCompletedTeamSessions ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-500">
{completedTeamSessionIds.length} 个已完成 agent 可释放 slot
{completedTeamSessionIds.length} 位已完成成员可清理
</span>
) : null}
</div>
@@ -1959,15 +2286,39 @@ export function TeamWorkspaceBoard({
})}
</div>
) : (
<div className="mt-4 rounded-[20px] border border-dashed border-slate-200 bg-slate-50 p-5 text-sm leading-6 text-slate-500">
还没有真实 child session。下一次{" "}
<span className="font-mono text-slate-700">spawn_agent</span>{" "}
成功后,这里会生成可纵向浏览的 team 轨道。
</div>
<>
{hasRuntimeFormation ? (
<>
{renderRuntimeFormationPanel()}
{renderRuntimeMemberPanel()}
</>
) : (
renderSelectedTeamPlanPanel()
)}
<div className="mt-4 rounded-[20px] border border-dashed border-slate-200 bg-slate-50 p-5 text-sm leading-6 text-slate-500">
{runtimeTeamState?.status === "forming" ? (
"模型正在准备本轮 Team,完成后这里会先展示本轮成员;后续真实成员加入时,再切换为可纵向浏览的团队轨道。"
) : runtimeTeamState?.status === "formed" ? (
<>
本轮 Team 已就绪。系统开始分派成员后,这里会从当前编队过渡到真实团队轨道。
</>
) : runtimeTeamState?.status === "failed" ? (
runtimeTeamState.errorMessage?.trim() ||
"Team 准备失败,暂时还没有真实团队成员。"
) : (
<>
还没有真实团队成员。系统开始分派成员后,这里会生成可纵向浏览的团队轨道。
</>
)}
</div>
</>
)}
{detailVisible ? (
<div className={cn("mt-3", detailCardClassName)}>
<div
className={cn("mt-3", detailCardClassName)}
data-testid="team-workspace-detail-section"
>
{selectedSession ? (
<>
<div className="flex flex-wrap items-start justify-between gap-3">
@@ -2014,10 +2365,10 @@ export function TeamWorkspaceBoard({
{selectedActionPending &&
pendingSessionAction?.action === "resume"
? "恢复中..."
: "恢复子代理"}
: "恢复成员"}
</Button>
) : null}
{canCloseSelectedSession ? (
{canStopSelectedSession ? (
<Button
type="button"
size="sm"
@@ -2033,10 +2384,15 @@ export function TeamWorkspaceBoard({
) : null}
{selectedActionPending &&
pendingSessionAction?.action === "close"
? "关闭中..."
: "关闭子代理"}
? "停止中..."
: "停止成员"}
</Button>
) : null}
{canStopSelectedSession || canResumeSelectedSession ? (
<span className="text-xs leading-5 text-slate-500">
停止会中断当前执行并保留会话,可稍后恢复。
</span>
) : null}
{canOpenSelectedSession ? (
<Button
type="button"
@@ -2046,7 +2402,7 @@ export function TeamWorkspaceBoard({
void onOpenSubagentSession?.(selectedSession.id)
}
>
{isChildSession ? "切换" : "打开会话"}
{isChildSession ? "切换" : "查看对话"}
</Button>
) : null}
</div>
@@ -2125,10 +2481,10 @@ export function TeamWorkspaceBoard({
<div className={secondarySectionClassName}>
<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>Team 控制</span>
<span>协作控制</span>
{canWaitSelectedSession ? (
<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">
可直接 wait_agent
可直接等待结果
</span>
) : null}
</div>
@@ -2150,10 +2506,10 @@ export function TeamWorkspaceBoard({
{selectedActionPending &&
pendingSessionAction?.action === "wait"
? "等待中..."
: "等待 30 秒"}
: "等待结果 30 秒"}
</Button>
<span className="text-xs leading-5 text-slate-500">
仅在主线程确实被该子代理结果阻塞时使用。
仅在当前对话确实依赖该成员结果时使用。
</span>
</div>
) : null}
@@ -2166,7 +2522,7 @@ export function TeamWorkspaceBoard({
event.target.value,
)
}
placeholder="向该子代理补充新的指令、澄清约束,或要求它继续推进下一步。"
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"
/>
@@ -2189,7 +2545,7 @@ export function TeamWorkspaceBoard({
{selectedActionPending &&
pendingSessionAction?.action === "send"
? "发送中..."
: "发送补充任务"}
: "发送补充说明"}
</Button>
<Button
type="button"
@@ -2212,10 +2568,10 @@ export function TeamWorkspaceBoard({
pendingSessionAction?.action ===
"interrupt_send"
? "中断中..."
: "中断后发送"}
: "中断并发送"}
</Button>
<span className="text-xs leading-5 text-slate-500">
`send_input` 只针对该子代理,不影响其他团队成员。
这条补充说明只会发送给当前成员,不影响其他团队成员。
</span>
</div>
</div>
@@ -2246,11 +2602,11 @@ export function TeamWorkspaceBoard({
</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>
)}
@@ -2317,21 +2673,71 @@ export function TeamWorkspaceBoard({
<span>焦点详情</span>
</div>
<div className="mt-2 text-base font-semibold text-slate-900">
等待真实子代理加入
{runtimeFormationMeta?.title || "等待团队成员加入"}
</div>
<p className="mt-3 text-sm leading-6 text-slate-600">
现在只有 team shell,没有真实 child session。主线程下一步应显式调用{" "}
<span className="font-mono text-slate-700">spawn_agent</span>{" "}
创建团队成员;创建成功后,详情区会切换为选中 agent 的摘要视图。
{buildRuntimeFormationEmptyDetail(runtimeTeamState)}
</p>
{hasRuntimeFormation ? (
<div className="mt-4 space-y-4">
{renderRuntimeFormationPanel()}
{renderRuntimeMemberPanel()}
{runtimeBlueprintRoles.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">
{runtimeBlueprintRoles.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}
</div>
) : hasSelectedTeamPlan ? (
<div className="mt-4">{renderSelectedTeamPlanPanel()}</div>
) : null}
<div className="mt-4 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">
推荐链路:spawn_agent → wait_agent → send_input
推荐流程:分派成员 → 等待结果 → 补充说明
</span>
</div>
</>
)}
</div>
) : railSessions.length > 0 ? (
<div
className={cn(
"mt-3 rounded-[18px] border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-500",
embedded ? "shadow-none" : "shadow-sm shadow-slate-950/5",
)}
data-testid="team-workspace-compact-summary"
>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<PanelTop className="h-3.5 w-3.5" />
<span>紧凑视图</span>
{selectedSession ? (
<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">
焦点成员 · {selectedSession.name}
</span>
) : null}
</div>
<p className="mt-2">
已收起焦点详情、执行配置、协作控制与最近过程,当前仅保留团队轨道与 Team
轨迹,便于在小屏幕下先浏览整体进度。
</p>
</div>
) : null}
</div>
</div>
@@ -174,8 +174,8 @@ describe("TeamWorkspaceDock", () => {
toggleButton?.click();
});
expect(document.body.textContent).toContain("等待真实子代理");
expect(document.body.textContent).toContain("spawn_agent");
expect(document.body.textContent).toContain("等待团队成员加入");
expect(document.body.textContent).toContain("分派团队成员");
expect(document.body.textContent).toContain("不遮挡画布");
expect(document.body.textContent).toContain("当前 Team:前端联调团队");
expect(document.body.textContent).toContain("分析、实现、验证三段式推进。");
@@ -260,6 +260,95 @@ describe("TeamWorkspaceDock", () => {
expect(document.body.textContent).toContain("研究员");
});
it("仅处于 Team 组建中时,不应自动展开面板,但应显示提醒", async () => {
const { container } = await renderDock({
runtimeTeamState: {
requestId: "runtime-forming-1",
status: "forming",
label: "修复 Team",
summary: "正在根据任务准备协作成员。",
members: [],
blueprint: {
label: "代码排障团队",
summary: "分析、执行、验证三段式推进。",
roles: [],
},
updatedAt: Date.now(),
},
});
expect(
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
).toBeNull();
expect(container.textContent).toContain("查看 Team · 组建中");
expect(
container.querySelector('[data-testid="team-workspace-dock-signal"]'),
).toBeTruthy();
});
it("Team 已就绪但真实成员未出现时,不应自动展开面板", async () => {
const { container } = await renderDock({
runtimeTeamState: {
requestId: "runtime-formed-2",
status: "formed",
label: "修复 Team",
summary: "分析、执行、验证协作闭环。",
members: [
{
id: "runtime-explorer",
label: "分析",
summary: "负责定位问题边界。",
roleKey: "explorer",
profileId: "code-explorer",
skillIds: ["repo-exploration"],
status: "planned",
},
],
blueprint: {
label: "代码排障团队",
summary: "分析、执行、验证三段式推进。",
roles: [],
},
updatedAt: Date.now(),
},
});
expect(
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
).toBeNull();
expect(container.textContent).toContain("查看 Team · 1");
expect(
container.querySelector('[data-testid="team-workspace-dock-signal"]'),
).toBeTruthy();
});
it("Team 准备失败时,不应自动展开面板,但应保留提醒入口", async () => {
const { container } = await renderDock({
runtimeTeamState: {
requestId: "runtime-failed-1",
status: "failed",
label: "修复 Team",
summary: "分析、执行、验证协作闭环。",
members: [],
blueprint: {
label: "代码排障团队",
summary: "分析、执行、验证三段式推进。",
roles: [],
},
errorMessage: "Team 生成失败",
updatedAt: Date.now(),
},
});
expect(
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
).toBeNull();
expect(container.textContent).toContain("查看 Team · 失败");
expect(
container.querySelector('[data-testid="team-workspace-dock-signal"]'),
).toBeTruthy();
});
it("真实 team 图谱折叠时,应显示查看入口和动态提示", async () => {
const { container } = await renderDock({
childSubagentSessions: [
@@ -332,4 +421,183 @@ describe("TeamWorkspaceDock", () => {
expect(getComputedStyle(panelInBody as HTMLElement).position).toBe("fixed");
expect(getComputedStyle(panelInBody as HTMLElement).zIndex).toBe("10010");
});
it("用户手动收起后,同一轮不应再次自动展开", async () => {
const { container, render } = await renderDock({
placement: "inline",
});
await render({
placement: "inline",
childSubagentSessions: [
{
id: "child-1",
name: "研究员",
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
session_type: "sub_agent",
runtime_status: "running",
task_summary: "整理竞品与数据来源",
role_hint: "explorer",
},
],
});
const toggleButton = container.querySelector<HTMLButtonElement>(
'[data-testid="team-workspace-dock-toggle"]',
);
expect(toggleButton?.textContent).toContain("收起 Team");
act(() => {
toggleButton?.click();
});
expect(
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
).toBeNull();
await render({
placement: "inline",
childSubagentSessions: [
{
id: "child-1",
name: "研究员",
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
session_type: "sub_agent",
runtime_status: "running",
task_summary: "整理竞品与数据来源",
role_hint: "explorer",
},
{
id: "child-2",
name: "执行者",
created_at: 1_710_000_010,
updated_at: 1_710_000_120,
session_type: "sub_agent",
runtime_status: "running",
task_summary: "提交修复方案",
role_hint: "executor",
},
],
});
expect(
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
).toBeNull();
expect(toggleButton?.textContent).toContain("查看 Team · 2");
});
it("切换会话后,新的真实成员出现应再次自动展开", async () => {
const { container, render } = await renderDock({
placement: "inline",
});
await render({
placement: "inline",
childSubagentSessions: [
{
id: "child-1",
name: "研究员",
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
session_type: "sub_agent",
runtime_status: "running",
task_summary: "整理竞品与数据来源",
role_hint: "explorer",
},
],
});
const toggleButton = container.querySelector<HTMLButtonElement>(
'[data-testid="team-workspace-dock-toggle"]',
);
act(() => {
toggleButton?.click();
});
await render({
placement: "inline",
currentSessionId: "parent-2",
childSubagentSessions: [],
});
expect(
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
).toBeNull();
await render({
placement: "inline",
currentSessionId: "parent-2",
childSubagentSessions: [
{
id: "child-2",
name: "执行者",
created_at: 1_710_000_010,
updated_at: 1_710_000_120,
session_type: "sub_agent",
runtime_status: "running",
task_summary: "提交修复方案",
role_hint: "executor",
},
],
});
expect(
document.body.querySelector('[data-testid="team-workspace-dock-panel"]'),
).toBeTruthy();
expect(container.textContent).toContain("收起 Team");
});
it("本轮 Team 已就绪时,应在空态 Dock 展示成员摘要", async () => {
const { container } = await renderDock({
runtimeTeamState: {
requestId: "runtime-formed-1",
status: "formed",
label: "修复 Team",
summary: "分析、执行、验证协作闭环。",
members: [
{
id: "runtime-explorer",
label: "分析",
summary: "负责定位问题边界。",
roleKey: "explorer",
profileId: "code-explorer",
skillIds: ["repo-exploration"],
status: "planned",
},
],
blueprint: {
label: "代码排障团队",
summary: "分析、执行、验证三段式推进。",
roles: [],
},
updatedAt: Date.now(),
},
});
const toggleButton = container.querySelector<HTMLButtonElement>(
'[data-testid="team-workspace-dock-toggle"]',
);
expect(toggleButton?.textContent).toContain("查看 Team · 1");
act(() => {
toggleButton?.click();
});
const detailToggle = document.body.querySelector<HTMLButtonElement>(
'[data-testid="team-workspace-selected-team-toggle"]',
);
expect(detailToggle).toBeTruthy();
act(() => {
detailToggle?.click();
});
expect(document.body.textContent).toContain("本轮 Team 已就绪");
expect(document.body.textContent).toContain("修复 Team");
expect(document.body.textContent).toContain("分析");
expect(document.body.textContent).toContain("repo-exploration");
});
});
@@ -10,8 +10,13 @@ import type {
TeamWorkspaceActivityEntry,
TeamWorkspaceControlSummary,
TeamWorkspaceLiveRuntimeState,
TeamWorkspaceRuntimeFormationState,
TeamWorkspaceWaitSummary,
} from "../teamWorkspaceRuntime";
import {
resolveRuntimeFormationStatusMeta,
resolveRuntimeMemberStatusMeta,
} from "../teamWorkspaceRuntime";
import { TeamWorkspaceBoard } from "./TeamWorkspaceBoard";
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
@@ -352,6 +357,7 @@ interface TeamWorkspaceDockProps {
shellVisible?: boolean;
withBottomOverlay?: boolean;
placement?: "floating" | "inline";
onActivateWorkbench?: () => void;
currentSessionId?: string | null;
currentSessionName?: string | null;
currentSessionRuntimeStatus?: AsterSubagentSessionInfo["runtime_status"];
@@ -387,6 +393,7 @@ interface TeamWorkspaceDockProps {
selectedTeamLabel?: string | null;
selectedTeamSummary?: string | null;
selectedTeamRoles?: TeamRoleDefinition[] | null;
runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null;
}
interface InlinePanelLayout {
@@ -400,6 +407,7 @@ export function TeamWorkspaceDock({
shellVisible = false,
withBottomOverlay = false,
placement = "floating",
onActivateWorkbench,
currentSessionId,
currentSessionName,
currentSessionRuntimeStatus,
@@ -423,27 +431,54 @@ export function TeamWorkspaceDock({
selectedTeamLabel,
selectedTeamSummary,
selectedTeamRoles = [],
runtimeTeamState = null,
}: TeamWorkspaceDockProps) {
const hasRealTeamGraph =
childSubagentSessions.length > 0 || Boolean(subagentParentContext);
const hasRuntimeFormation = Boolean(runtimeTeamState);
const runtimeFormationMeta = runtimeTeamState
? resolveRuntimeFormationStatusMeta(runtimeTeamState.status)
: null;
const runtimeTeamLabel =
runtimeTeamState?.label?.trim() ||
runtimeTeamState?.blueprint?.label?.trim() ||
selectedTeamLabel?.trim() ||
null;
const runtimeTeamSummary =
runtimeTeamState?.summary?.trim() ||
runtimeTeamState?.blueprint?.summary?.trim() ||
selectedTeamSummary?.trim() ||
null;
const [expanded, setExpanded] = useState(
() => placement === "inline" && hasRealTeamGraph,
);
const [userDismissedAutoExpand, setUserDismissedAutoExpand] = useState(false);
const hasInitializedRef = useRef(false);
const previousSessionIdRef = useRef<string | null>(currentSessionId ?? null);
const previousHasRealGraphRef = useRef(false);
const previousPlacementRef = useRef(placement);
const toggleRef = useRef<HTMLButtonElement | null>(null);
const [inlinePanelLayout, setInlinePanelLayout] =
useState<InlinePanelLayout | null>(null);
const isCompact = !hasRealTeamGraph;
const showAttentionCue = hasRealTeamGraph && !expanded;
const isCompact = !hasRealTeamGraph && !hasRuntimeFormation;
const showAttentionCue =
!expanded &&
(hasRealTeamGraph ||
runtimeTeamState?.status === "forming" ||
runtimeTeamState?.status === "formed" ||
runtimeTeamState?.status === "failed");
const dockCount = subagentParentContext
? (subagentParentContext.sibling_subagent_sessions?.length ?? 0) + 1
: childSubagentSessions.length;
const shouldPortalPanel = expanded;
const [teamDetailExpanded, setTeamDetailExpanded] = useState(false);
const hasSelectedTeamDetails =
Boolean(selectedTeamSummary?.trim()) || (selectedTeamRoles?.length ?? 0) > 0;
Boolean(runtimeTeamSummary) ||
(runtimeTeamState?.members.length ?? 0) > 0 ||
Boolean(runtimeTeamState?.blueprint?.summary?.trim()) ||
(runtimeTeamState?.blueprint?.roles.length ?? 0) > 0 ||
Boolean(selectedTeamSummary?.trim()) ||
(selectedTeamRoles?.length ?? 0) > 0;
const toggleLabel = useMemo(() => {
if (expanded) {
return "收起 Team";
@@ -451,38 +486,70 @@ export function TeamWorkspaceDock({
if (hasRealTeamGraph) {
return `查看 Team · ${dockCount}`;
}
if (runtimeTeamState?.status === "forming") {
return "查看 Team · 组建中";
}
if (runtimeTeamState?.status === "formed") {
return `查看 Team · ${runtimeTeamState.members.length}`;
}
if (runtimeTeamState?.status === "failed") {
return "查看 Team · 失败";
}
return "Team";
}, [dockCount, expanded, hasRealTeamGraph]);
}, [dockCount, expanded, hasRealTeamGraph, runtimeTeamState]);
useEffect(() => {
const normalizedSessionId = currentSessionId ?? null;
if (!hasInitializedRef.current) {
if (placement === "inline" && hasRealTeamGraph) {
setExpanded(true);
}
previousSessionIdRef.current = normalizedSessionId;
previousHasRealGraphRef.current = hasRealTeamGraph;
previousPlacementRef.current = placement;
hasInitializedRef.current = true;
return;
}
if (previousSessionIdRef.current !== normalizedSessionId) {
previousSessionIdRef.current = normalizedSessionId;
previousHasRealGraphRef.current = hasRealTeamGraph;
previousPlacementRef.current = placement;
setUserDismissedAutoExpand(false);
return;
}
const graphAppeared =
!previousHasRealGraphRef.current && hasRealTeamGraph;
const graphCleared =
previousHasRealGraphRef.current && !hasRealTeamGraph;
if (
previousPlacementRef.current !== placement &&
placement === "inline" &&
hasRealTeamGraph
hasRealTeamGraph &&
!userDismissedAutoExpand
) {
setExpanded(true);
previousSessionIdRef.current = normalizedSessionId;
previousHasRealGraphRef.current = hasRealTeamGraph;
previousPlacementRef.current = placement;
return;
}
if (!previousHasRealGraphRef.current && hasRealTeamGraph) {
if (graphCleared) {
setUserDismissedAutoExpand(false);
}
if (graphAppeared && !userDismissedAutoExpand) {
setExpanded(true);
}
previousSessionIdRef.current = normalizedSessionId;
previousHasRealGraphRef.current = hasRealTeamGraph;
previousPlacementRef.current = placement;
}, [hasRealTeamGraph, placement]);
}, [currentSessionId, hasRealTeamGraph, placement, userDismissedAutoExpand]);
const updateInlinePanelLayout = useCallback(() => {
if (
@@ -536,6 +603,8 @@ export function TeamWorkspaceDock({
return null;
}
const launcherOnly = typeof onActivateWorkbench === "function";
const panelContent = hasRealTeamGraph ? (
<TeamWorkspaceBoard
embedded={true}
@@ -557,11 +626,15 @@ export function TeamWorkspaceDock({
onCloseCompletedTeamSessions={onCloseCompletedTeamSessions}
onCloseSubagentSession={onCloseSubagentSession}
onResumeSubagentSession={onResumeSubagentSession}
onOpenSubagentSession={onOpenSubagentSession}
onReturnToParentSession={onReturnToParentSession}
teamWaitSummary={teamWaitSummary}
teamControlSummary={teamControlSummary}
/>
onOpenSubagentSession={onOpenSubagentSession}
onReturnToParentSession={onReturnToParentSession}
teamWaitSummary={teamWaitSummary}
teamControlSummary={teamControlSummary}
selectedTeamLabel={selectedTeamLabel}
selectedTeamSummary={selectedTeamSummary}
selectedTeamRoles={selectedTeamRoles}
runtimeTeamState={runtimeTeamState}
/>
) : (
<EmptyStateCard data-testid="team-workspace-empty-card" role="status">
<EmptyStateBody>
@@ -571,13 +644,28 @@ export function TeamWorkspaceDock({
<span>Team 已启用</span>
</span>
<EmptyStateBadge $tone="success">实时订阅</EmptyStateBadge>
{runtimeFormationMeta ? (
<EmptyStateBadge>{runtimeFormationMeta.label}</EmptyStateBadge>
) : null}
</EmptyStateEyebrow>
<EmptyStateTitle>等待真实子代理</EmptyStateTitle>
<EmptyStateTitle>
{runtimeFormationMeta?.title || "等待团队成员加入"}
</EmptyStateTitle>
<EmptyStateDescription>
模型首次成功调用 <span className="font-mono text-slate-700">spawn_agent</span>{" "}
后,这里会自动切换到真实团队工作台。
{runtimeTeamState?.status === "forming"
? "系统正在根据本轮任务准备成员,真实团队成员加入后会自动切换到完整团队工作台。"
: runtimeTeamState?.status === "formed"
? `已准备 ${runtimeTeamState.members.length} 个成员,后续真实成员会接管实时协作轨道。`
: runtimeTeamState?.status === "failed"
? runtimeTeamState.errorMessage?.trim() ||
"本轮 Team 准备失败,你仍然可以继续在当前对话中执行。"
: (
<>
当系统开始真正分派团队成员后,这里会自动切换到真实团队工作台。
</>
)}
</EmptyStateDescription>
{selectedTeamLabel && hasSelectedTeamDetails ? (
{runtimeTeamLabel && hasSelectedTeamDetails ? (
<EmptyStateDetailCard>
<EmptyStateDetailToggle
type="button"
@@ -586,9 +674,11 @@ export function TeamWorkspaceDock({
data-testid="team-workspace-selected-team-toggle"
>
<div>
<EmptyStateDetailTitle>{selectedTeamLabel}</EmptyStateDetailTitle>
<EmptyStateDetailTitle>{runtimeTeamLabel}</EmptyStateDetailTitle>
<EmptyStateDetailHint>
查看当前 Team 配置与角色分工
{runtimeTeamState
? "查看本轮成员与参考蓝图"
: "查看当前 Team 配置与角色分工"}
</EmptyStateDetailHint>
</div>
{teamDetailExpanded ? (
@@ -599,30 +689,88 @@ export function TeamWorkspaceDock({
</EmptyStateDetailToggle>
{teamDetailExpanded ? (
<EmptyStateRoleList data-testid="team-workspace-selected-team-detail">
{selectedTeamSummary ? (
{runtimeTeamSummary ? (
<EmptyStateRoleItem>
<EmptyStateRoleName>Team 摘要</EmptyStateRoleName>
<EmptyStateRoleSummary>{selectedTeamSummary}</EmptyStateRoleSummary>
<EmptyStateRoleName>
{runtimeTeamState ? "本轮摘要" : "Team 摘要"}
</EmptyStateRoleName>
<EmptyStateRoleSummary>{runtimeTeamSummary}</EmptyStateRoleSummary>
</EmptyStateRoleItem>
) : null}
{selectedTeamRoles?.map((role) => (
<EmptyStateRoleItem key={`dock-role-${role.id}`}>
<EmptyStateRoleName>{role.label}</EmptyStateRoleName>
<EmptyStateRoleSummary>{role.summary}</EmptyStateRoleSummary>
{runtimeTeamState?.members.map((member) => {
const memberStatusMeta = resolveRuntimeMemberStatusMeta(
member.status,
);
return (
<EmptyStateRoleItem key={`dock-runtime-role-${member.id}`}>
<div className="flex flex-wrap items-center gap-2">
<EmptyStateRoleName>{member.label}</EmptyStateRoleName>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${memberStatusMeta.badgeClassName}`}
>
{memberStatusMeta.label}
</span>
{member.roleKey ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
{member.roleKey}
</span>
) : null}
{member.profileId ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
{member.profileId}
</span>
) : null}
</div>
<EmptyStateRoleSummary>{member.summary}</EmptyStateRoleSummary>
{member.skillIds.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{member.skillIds.map((skillId) => (
<span
key={`${member.id}-${skillId}`}
className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500"
>
{skillId}
</span>
))}
</div>
) : null}
</EmptyStateRoleItem>
);
})}
{!runtimeTeamState
? selectedTeamRoles?.map((role) => (
<EmptyStateRoleItem key={`dock-role-${role.id}`}>
<EmptyStateRoleName>{role.label}</EmptyStateRoleName>
<EmptyStateRoleSummary>{role.summary}</EmptyStateRoleSummary>
</EmptyStateRoleItem>
))
: null}
{runtimeTeamState?.blueprint?.label ? (
<EmptyStateRoleItem>
<EmptyStateRoleName>
参考蓝图 · {runtimeTeamState.blueprint.label}
</EmptyStateRoleName>
<EmptyStateRoleSummary>
{runtimeTeamState.blueprint.summary ||
"本轮 Team 参考了当前蓝图 Team 的角色偏好。"}
</EmptyStateRoleSummary>
</EmptyStateRoleItem>
))}
) : null}
</EmptyStateRoleList>
) : null}
</EmptyStateDetailCard>
) : null}
<EmptyStateFooter>
{selectedTeamLabel ? (
{runtimeTeamLabel ? (
<EmptyStateBadge data-testid="team-workspace-selected-team">
当前 Team:{selectedTeamLabel}
当前 Team:{runtimeTeamLabel}
</EmptyStateBadge>
) : null}
{selectedTeamSummary ? (
<EmptyStateBadge>{selectedTeamSummary}</EmptyStateBadge>
{runtimeTeamSummary ? (
<EmptyStateBadge>{runtimeTeamSummary}</EmptyStateBadge>
) : null}
{runtimeTeamState?.members.length ? (
<EmptyStateBadge>{runtimeTeamState.members.length} 个成员</EmptyStateBadge>
) : null}
<EmptyStateBadge>不遮挡画布</EmptyStateBadge>
</EmptyStateFooter>
@@ -656,17 +804,38 @@ export function TeamWorkspaceDock({
$withBottomOverlay={withBottomOverlay}
data-testid="team-workspace-dock"
>
{expanded && !shouldPortalPanel ? panelNode : null}
{!launcherOnly && expanded && !shouldPortalPanel ? panelNode : null}
<DockToggle
type="button"
data-testid="team-workspace-dock-toggle"
ref={toggleRef}
aria-expanded={expanded}
aria-label={expanded ? "收起 Team Workspace" : "展开 Team Workspace"}
aria-expanded={launcherOnly ? false : expanded}
aria-label={
launcherOnly
? "打开 Team Workbench"
: expanded
? "收起 Team Workspace"
: "展开 Team Workspace"
}
$active={hasRealTeamGraph}
$expanded={expanded}
$attention={showAttentionCue}
onClick={() => setExpanded((previous) => !previous)}
onClick={() => {
if (launcherOnly) {
onActivateWorkbench?.();
return;
}
setExpanded((previous) => {
const nextExpanded = !previous;
if (previous && !nextExpanded) {
setUserDismissedAutoExpand(true);
}
if (!previous && nextExpanded) {
setUserDismissedAutoExpand(false);
}
return nextExpanded;
});
}}
>
<DockIconShell $attention={showAttentionCue}>
<Workflow className="h-4 w-4" />
@@ -681,7 +850,8 @@ export function TeamWorkspaceDock({
{toggleLabel}
</span>
</DockToggle>
{expanded &&
{!launcherOnly &&
expanded &&
shouldPortalPanel &&
inlinePanelLayout &&
typeof document !== "undefined"
@@ -0,0 +1,118 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeWorkbenchEntryPromptAccessory } from "./ThemeWorkbenchEntryPromptAccessory";
import type { ThemeWorkbenchEntryPromptState } from "../hooks/useThemeWorkbenchEntryPrompt";
interface MountedHarness {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: MountedHarness[] = [];
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
function renderAccessory(
props?: Partial<ComponentProps<typeof ThemeWorkbenchEntryPromptAccessory>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultPrompt: ThemeWorkbenchEntryPromptState = {
kind: "initial_prompt",
signature: "dispatch-1",
title: "已恢复待执行创作意图",
description: "进入页面后不会自动开始生成,确认后再继续。",
actionLabel: "继续生成",
prompt: "请先生成主稿",
};
const defaultProps: ComponentProps<typeof ThemeWorkbenchEntryPromptAccessory> =
{
prompt: defaultPrompt,
onRestart: vi.fn(),
onContinue: vi.fn(async () => undefined),
};
act(() => {
root.render(
<ThemeWorkbenchEntryPromptAccessory {...defaultProps} {...props} />,
);
});
mountedRoots.push({ container, root });
return {
container,
props: {
...defaultProps,
...props,
},
};
}
describe("ThemeWorkbenchEntryPromptAccessory", () => {
it("应渲染提示文案与操作按钮", () => {
const { container } = renderAccessory();
expect(
container.querySelector('[data-testid="theme-workbench-entry-prompt"]')
?.textContent,
).toContain("已恢复待执行创作意图");
expect(container.textContent).toContain("进入页面后不会自动开始生成");
expect(container.textContent).toContain("继续生成");
expect(container.textContent).toContain("重新开始");
});
it("应分发继续与重启动作", async () => {
const onRestart = vi.fn();
const onContinue = vi.fn(async () => undefined);
const { container } = renderAccessory({
onRestart,
onContinue,
});
const restartButton = container.querySelector<HTMLButtonElement>(
'[data-testid="theme-workbench-entry-restart"]',
);
const continueButton = container.querySelector<HTMLButtonElement>(
'[data-testid="theme-workbench-entry-continue"]',
);
if (!restartButton || !continueButton) {
throw new Error("未找到主题工作台入口提示操作按钮");
}
act(() => {
restartButton.click();
});
expect(onRestart).toHaveBeenCalledTimes(1);
await act(async () => {
continueButton.click();
});
expect(onContinue).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,140 @@
import { memo } from "react";
import { Info } from "lucide-react";
import styled from "styled-components";
import type { ThemeWorkbenchEntryPromptState } from "../hooks/useThemeWorkbenchEntryPrompt";
interface ThemeWorkbenchEntryPromptAccessoryProps {
prompt: ThemeWorkbenchEntryPromptState;
onRestart: () => void;
onContinue: () => Promise<void> | void;
}
const ThemeWorkbenchEntryPromptCard = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
min-width: min(360px, calc(100vw - 48px));
max-width: min(420px, calc(100vw - 48px));
padding: 12px 14px;
border-radius: 18px;
border: 1px solid rgba(191, 219, 254, 0.92);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(239, 246, 255, 0.96) 100%
);
color: #0f172a;
box-shadow: 0 18px 34px -28px rgba(15, 23, 42, 0.26);
`;
const ThemeWorkbenchEntryPromptHeader = styled.div`
display: flex;
align-items: flex-start;
gap: 8px;
`;
const ThemeWorkbenchEntryPromptTitleWrap = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
`;
const ThemeWorkbenchEntryPromptTitle = styled.span`
font-size: 13px;
font-weight: 700;
line-height: 1.4;
`;
const ThemeWorkbenchEntryPromptDescription = styled.span`
font-size: 12px;
line-height: 1.5;
color: #475569;
`;
const ThemeWorkbenchEntryPromptActions = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
`;
const ThemeWorkbenchEntryPromptButton = styled.button<{
$variant?: "primary" | "ghost";
}>`
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 88px;
height: 32px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid
${({ $variant }) =>
$variant === "ghost"
? "rgba(191, 219, 254, 0.92)"
: "rgba(59, 130, 246, 0.94)"};
background: ${({ $variant }) =>
$variant === "ghost"
? "rgba(255, 255, 255, 0.92)"
: "linear-gradient(180deg, rgba(59,130,246,0.96) 0%, rgba(37,99,235,0.96) 100%)"};
color: ${({ $variant }) => ($variant === "ghost" ? "#1e293b" : "#eff6ff")};
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.16s ease,
box-shadow 0.2s ease,
background 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 12px 24px -18px rgba(37, 99, 235, 0.46);
background: ${({ $variant }) =>
$variant === "ghost"
? "rgba(239, 246, 255, 0.98)"
: "linear-gradient(180deg, rgba(37,99,235,0.98) 0%, rgba(29,78,216,0.98) 100%)"};
}
`;
export const ThemeWorkbenchEntryPromptAccessory = memo(
function ThemeWorkbenchEntryPromptAccessory({
prompt,
onRestart,
onContinue,
}: ThemeWorkbenchEntryPromptAccessoryProps) {
return (
<ThemeWorkbenchEntryPromptCard data-testid="theme-workbench-entry-prompt">
<ThemeWorkbenchEntryPromptHeader>
<Info className="mt-0.5 h-4 w-4 shrink-0 text-sky-600" />
<ThemeWorkbenchEntryPromptTitleWrap>
<ThemeWorkbenchEntryPromptTitle>
{prompt.title}
</ThemeWorkbenchEntryPromptTitle>
<ThemeWorkbenchEntryPromptDescription>
{prompt.description}
</ThemeWorkbenchEntryPromptDescription>
</ThemeWorkbenchEntryPromptTitleWrap>
</ThemeWorkbenchEntryPromptHeader>
<ThemeWorkbenchEntryPromptActions>
<ThemeWorkbenchEntryPromptButton
type="button"
$variant="ghost"
data-testid="theme-workbench-entry-restart"
onClick={onRestart}
>
重新开始
</ThemeWorkbenchEntryPromptButton>
<ThemeWorkbenchEntryPromptButton
type="button"
data-testid="theme-workbench-entry-continue"
onClick={() => {
void onContinue();
}}
>
{prompt.actionLabel}
</ThemeWorkbenchEntryPromptButton>
</ThemeWorkbenchEntryPromptActions>
</ThemeWorkbenchEntryPromptCard>
);
},
);
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import type { ChatToolPreferences } from "./utils/chatToolPreferences";
import { resolveHomeShellWorkspaceEntry } from "./homeShellEntry";
const defaultToolPreferences: ChatToolPreferences = {
webSearch: false,
thinking: false,
task: false,
subagent: false,
};
describe("homeShellEntry", () => {
it("缺少项目且不是浏览器协助时应拒绝进入", () => {
expect(
resolveHomeShellWorkspaceEntry({
projectId: null,
activeTheme: "general",
creationMode: "guided",
defaultToolPreferences,
payload: {
prompt: "帮我整理方案",
},
now: () => 123,
}),
).toEqual({
ok: false,
reason: "missing_project",
});
});
it("没有 prompt 和图片时应拒绝空进入", () => {
expect(
resolveHomeShellWorkspaceEntry({
projectId: "project-1",
activeTheme: "general",
creationMode: "guided",
defaultToolPreferences,
payload: {},
now: () => 123,
}),
).toEqual({
ok: false,
reason: "empty_payload",
});
});
it("应生成导航参数与工作区 bootstrap", () => {
expect(
resolveHomeShellWorkspaceEntry({
projectId: "project-1",
activeTheme: "general",
creationMode: "guided",
defaultToolPreferences,
payload: {
prompt: "请起草一版首稿",
themeOverride: "social-media",
},
now: () => 456,
}),
).toEqual({
ok: true,
toolPreferences: defaultToolPreferences,
targetTheme: "social-media",
nextNewChatAt: 456,
navigationParams: {
agentEntry: "claw",
immersiveHome: false,
projectId: "project-1",
theme: "social-media",
initialCreationMode: "guided",
initialUserPrompt: "请起草一版首稿",
initialUserImages: undefined,
openBrowserAssistOnMount: undefined,
newChatAt: 456,
lockTheme: false,
},
workspaceBootstrap: {
projectId: "project-1",
initialUserPrompt: "请起草一版首稿",
initialUserImages: undefined,
theme: "social-media",
initialCreationMode: "guided",
openBrowserAssistOnMount: undefined,
newChatAt: 456,
},
});
});
it("浏览器协助入口允许无项目直接进入", () => {
expect(
resolveHomeShellWorkspaceEntry({
projectId: null,
activeTheme: "general",
creationMode: "guided",
defaultToolPreferences,
payload: {
prompt: "",
openBrowserAssistOnMount: true,
},
now: () => 789,
}),
).toEqual({
ok: true,
toolPreferences: defaultToolPreferences,
targetTheme: "general",
nextNewChatAt: 789,
navigationParams: {
agentEntry: "claw",
immersiveHome: false,
projectId: undefined,
theme: "general",
initialCreationMode: "guided",
initialUserPrompt: "",
initialUserImages: undefined,
openBrowserAssistOnMount: true,
newChatAt: 789,
lockTheme: false,
},
workspaceBootstrap: {
projectId: undefined,
initialUserPrompt: "",
initialUserImages: undefined,
theme: "general",
initialCreationMode: "guided",
openBrowserAssistOnMount: true,
newChatAt: 789,
},
});
});
});
+111
View File
@@ -0,0 +1,111 @@
import { buildClawAgentParams } from "@/lib/workspace/navigation";
import type { AgentPageParams } from "@/types/page";
import type { CreationMode } from "./components/types";
import type { MessageImage } from "./types";
import type { ChatToolPreferences } from "./utils/chatToolPreferences";
export interface AgentChatWorkspaceBootstrap {
projectId?: string;
initialUserPrompt?: string;
initialUserImages?: MessageImage[];
theme?: string;
initialCreationMode?: CreationMode;
openBrowserAssistOnMount?: boolean;
newChatAt?: number;
}
export interface HomeShellEnterWorkspacePayload {
prompt?: string;
images?: MessageImage[];
openBrowserAssistOnMount?: boolean;
toolPreferences?: ChatToolPreferences;
themeOverride?: string;
}
export interface ResolveHomeShellWorkspaceEntryInput {
projectId?: string | null;
activeTheme: string;
creationMode: CreationMode;
defaultToolPreferences: ChatToolPreferences;
payload: HomeShellEnterWorkspacePayload;
now?: () => number;
}
export type HomeShellEnterWorkspaceRejectedReason =
| "missing_project"
| "empty_payload";
export type ResolvedHomeShellWorkspaceEntry =
| {
ok: false;
reason: HomeShellEnterWorkspaceRejectedReason;
}
| {
ok: true;
toolPreferences: ChatToolPreferences;
targetTheme: string;
nextNewChatAt: number;
navigationParams: AgentPageParams;
workspaceBootstrap: AgentChatWorkspaceBootstrap;
};
export function resolveHomeShellWorkspaceEntry(
input: ResolveHomeShellWorkspaceEntryInput,
): ResolvedHomeShellWorkspaceEntry {
const {
projectId,
activeTheme,
creationMode,
defaultToolPreferences,
payload,
now = () => Date.now(),
} = input;
const hasPrompt = Boolean(payload.prompt?.trim());
const hasImages = Boolean(payload.images?.length);
const toolPreferences = payload.toolPreferences ?? defaultToolPreferences;
const targetTheme = payload.themeOverride ?? activeTheme;
const openBrowserAssistOnMount = payload.openBrowserAssistOnMount;
if (!openBrowserAssistOnMount && !projectId) {
return {
ok: false,
reason: "missing_project",
};
}
if (!openBrowserAssistOnMount && !hasPrompt && !hasImages) {
return {
ok: false,
reason: "empty_payload",
};
}
const nextNewChatAt = now();
const shared = {
projectId: projectId ?? undefined,
theme: targetTheme,
initialCreationMode: creationMode,
initialUserPrompt: payload.prompt,
initialUserImages: payload.images,
openBrowserAssistOnMount,
newChatAt: nextNewChatAt,
} satisfies AgentPageParams;
return {
ok: true,
toolPreferences,
targetTheme,
nextNewChatAt,
navigationParams: buildClawAgentParams(shared),
workspaceBootstrap: {
projectId: projectId ?? undefined,
initialUserPrompt: payload.prompt,
initialUserImages: payload.images,
theme: targetTheme,
initialCreationMode: creationMode,
openBrowserAssistOnMount,
newChatAt: nextNewChatAt,
},
};
}
@@ -8,6 +8,49 @@ import {
} from "./agentChatHistory";
describe("agentChatHistory", () => {
it("应清理仅用于内部展示的图片占位文本", () => {
const detail: AsterSessionDetail = {
id: "session-image-placeholder",
created_at: 1,
updated_at: 2,
messages: [
{
role: "user",
timestamp: 1710000200,
content: [
{ type: "input_text", text: "[Image #1]" },
{
type: "input_image",
image_url: "data:image/png;base64,aGVsbG8=",
},
],
},
{
role: "assistant",
timestamp: 1710000201,
content: [{ type: "output_text", text: "已收到图片" }],
},
],
};
const messages = hydrateSessionDetailMessages(
detail,
"session-image-placeholder",
);
expect(messages[0]).toMatchObject({
role: "user",
content: "",
images: [
{
mediaType: "image/png",
data: "aGVsbG8=",
},
],
});
expect(messages[1]?.content).toBe("已收到图片");
});
it("应从历史消息的 thinking 字段恢复完整思考过程", () => {
const detail: AsterSessionDetail = {
id: "session-1",
@@ -11,6 +11,10 @@ import {
resolveHistoryUserDataText,
stringifyToolArguments,
} from "./agentChatToolResult";
import {
sanitizeContentPartsForDisplay,
sanitizeMessageTextForDisplay,
} from "../utils/internalImagePlaceholder";
export const normalizeHistoryPartType = (value: unknown): string => {
if (typeof value !== "string") return "";
@@ -569,12 +573,21 @@ export const hydrateSessionDetailMessages = (
contentParts.push({ type: "text", text: resolved });
}
const content = textParts.join("\n").trim();
const rawContent = textParts.join("\n").trim();
let normalizedRole =
msg.role === "tool" ? "assistant" : (msg.role as "user" | "assistant");
const content = sanitizeMessageTextForDisplay(rawContent, {
role: normalizedRole,
hasImages: images.length > 0,
});
const sanitizedContentParts =
sanitizeContentPartsForDisplay(contentParts, {
role: normalizedRole,
hasImages: images.length > 0,
}) || [];
const hasToolMetadata =
toolCalls.length > 0 ||
contentParts.some((part) => part.type === "tool_use");
sanitizedContentParts.some((part) => part.type === "tool_use");
if (normalizedRole === "user" && !content && images.length === 0) {
if (hasToolMetadata) {
@@ -587,7 +600,7 @@ export const hydrateSessionDetailMessages = (
if (
!content &&
images.length === 0 &&
contentParts.length === 0 &&
sanitizedContentParts.length === 0 &&
toolCalls.length === 0
) {
return [];
@@ -599,11 +612,16 @@ export const hydrateSessionDetailMessages = (
role: normalizedRole,
content,
images: images.length > 0 ? images : undefined,
contentParts: contentParts.length > 0 ? contentParts : undefined,
contentParts:
sanitizedContentParts.length > 0
? sanitizedContentParts
: undefined,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
timestamp: messageTimestamp,
isThinking: false,
thinkingContent: extractThinkingContentFromParts(contentParts),
thinkingContent: extractThinkingContentFromParts(
sanitizedContentParts,
),
},
];
});
@@ -134,6 +134,40 @@ describe("agentChatShared", () => {
);
});
it("图片占位符不应直接出现在任务摘要里", () => {
const now = new Date("2026-03-19T00:00:00.000Z");
const messages: Message[] = [
{
id: "msg-user-image",
role: "user",
content: "[Image #1]",
images: [
{
mediaType: "image/png",
data: "aGVsbG8=",
},
],
timestamp: now,
},
];
expect(extractTaskPreviewFromMessages(messages)).toBe("已附加图片");
});
it("助手内部图片标签应转换为自然语言摘要", () => {
const now = new Date("2026-03-19T00:00:00.000Z");
const messages: Message[] = [
{
id: "msg-assistant-image",
role: "assistant",
content: "[Image #1]",
timestamp: now,
},
];
expect(extractTaskPreviewFromMessages(messages)).toBe("图片处理中");
});
it("应按浏览器预检阶段映射不同状态原因", () => {
expect(
deriveTaskLiveState({
@@ -5,6 +5,7 @@ import type {
} from "@/lib/api/agentRuntime";
import type { Message, MessageImage, WriteArtifactContext } from "../types";
import { normalizeExecutionStrategy } from "./agentChatCoreUtils";
import { sanitizeMessageTextForPreview } from "../utils/internalImagePlaceholder";
export type TaskStatus = "draft" | "running" | "waiting" | "done" | "failed";
export type TaskStatusReason =
@@ -120,8 +121,12 @@ function normalizeTaskPreviewText(value: string) {
}
function extractMessageTextContent(message: Message): string {
if (message.content?.trim()) {
return normalizeTaskPreviewText(message.content);
const sanitizedContent = sanitizeMessageTextForPreview(message.content || "", {
role: message.role,
hasImages: Array.isArray(message.images) && message.images.length > 0,
});
if (sanitizedContent) {
return normalizeTaskPreviewText(sanitizedContent);
}
const partText = message.contentParts
@@ -129,7 +134,15 @@ function extractMessageTextContent(message: Message): string {
(part): part is Extract<(typeof message.contentParts)[number], { type: "text" | "thinking" }> =>
part.type === "text" || part.type === "thinking",
)
.map((part) => part.text)
.map((part) =>
part.type === "text"
? sanitizeMessageTextForPreview(part.text, {
role: message.role,
hasImages: Array.isArray(message.images) && message.images.length > 0,
})
: part.text,
)
.filter(Boolean)
.join(" ");
return normalizeTaskPreviewText(partText || "");
@@ -0,0 +1,100 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
LAST_PROJECT_ID_KEY,
loadPersistedProjectId,
savePersistedProjectId,
usePersistedProjectId,
} from "./agentProjectStorage";
interface HookHarness {
getValue: () => ReturnType<typeof usePersistedProjectId>;
rerender: (externalProjectId?: string | null) => void;
unmount: () => void;
}
function mountHook(initialExternalProjectId?: string | null): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof usePersistedProjectId> | null = null;
function TestComponent({
externalProjectId,
}: {
externalProjectId?: string | null;
}) {
hookValue = usePersistedProjectId(externalProjectId);
return null;
}
const render = (externalProjectId?: string | null) => {
act(() => {
root.render(<TestComponent externalProjectId={externalProjectId} />);
});
};
render(initialExternalProjectId);
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
describe("agentProjectStorage", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it("应保存并读取最近项目偏好", () => {
savePersistedProjectId(LAST_PROJECT_ID_KEY, "project-a");
expect(loadPersistedProjectId(LAST_PROJECT_ID_KEY)).toBe("project-a");
});
it("hook 应优先使用 externalProjectId,否则回退最近项目", () => {
savePersistedProjectId(LAST_PROJECT_ID_KEY, "project-local");
const harness = mountHook();
try {
expect(harness.getValue().projectId).toBe("project-local");
harness.rerender("project-external");
expect(harness.getValue().projectId).toBe("project-external");
act(() => {
harness.getValue().setProjectId("project-updated");
harness.getValue().rememberProjectId("project-updated");
});
expect(harness.getValue().projectId).toBe("project-updated");
expect(loadPersistedProjectId(LAST_PROJECT_ID_KEY)).toBe("project-updated");
} finally {
harness.unmount();
}
});
});
@@ -0,0 +1,75 @@
import { useCallback, useEffect, useState } from "react";
import { normalizeProjectId } from "../utils/topicProjectResolution";
export const LAST_PROJECT_ID_KEY = "agent_last_project_id";
export function loadPersistedProjectId(key: string): string | null {
try {
const stored = localStorage.getItem(key);
if (!stored) {
return null;
}
try {
const parsed = JSON.parse(stored);
return normalizeProjectId(typeof parsed === "string" ? parsed : stored);
} catch {
return normalizeProjectId(stored);
}
} catch {
return null;
}
}
export function savePersistedProjectId(key: string, projectId: string): void {
const normalized = normalizeProjectId(projectId);
if (!normalized) {
return;
}
try {
localStorage.setItem(key, JSON.stringify(normalized));
} catch {
// ignore write errors
}
}
export function usePersistedProjectId(
externalProjectId?: string | null,
storageKey = LAST_PROJECT_ID_KEY,
) {
const resolveProjectId = useCallback(
() =>
normalizeProjectId(externalProjectId) ??
loadPersistedProjectId(storageKey),
[externalProjectId, storageKey],
);
const [projectId, setProjectIdState] = useState<string | null>(() =>
resolveProjectId(),
);
useEffect(() => {
setProjectIdState(resolveProjectId());
}, [resolveProjectId]);
const setProjectId = useCallback((nextProjectId?: string | null) => {
setProjectIdState(normalizeProjectId(nextProjectId));
}, []);
const rememberProjectId = useCallback(
(nextProjectId?: string | null) => {
const normalized = normalizeProjectId(nextProjectId);
if (!normalized) {
return;
}
savePersistedProjectId(storageKey, normalized);
},
[storageKey],
);
return {
projectId,
setProjectId,
rememberProjectId,
};
}
@@ -0,0 +1,35 @@
import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { BrowserPreflightState, BrowserTaskRequirement, MessageImage } from "../types";
export interface HandleSendObserver {
onComplete?: (content: string) => void;
onError?: (message: string) => void;
}
export interface HandleSendOptions {
skipThemeSkillPrefix?: boolean;
purpose?: "content_review" | "text_stylize" | "style_rewrite" | "style_audit";
observer?: HandleSendObserver;
requestMetadata?: Record<string, unknown>;
browserPreflightConfirmed?: boolean;
toolPreferencesOverride?: ChatToolPreferences;
}
export interface BrowserTaskPreflight {
requestId: string;
createdAt: number;
sourceText: string;
images: MessageImage[];
webSearch?: boolean;
thinking?: boolean;
sendExecutionStrategy?: "react" | "code_orchestrated" | "auto";
autoContinuePayload?: AutoContinueRequestPayload;
sendOptions?: HandleSendOptions;
requirement: BrowserTaskRequirement;
reason: string;
phase: BrowserPreflightState;
launchUrl: string;
platformLabel?: string;
detail?: string;
}
+1
View File
@@ -32,6 +32,7 @@ export function useAgentChatUnified(options: UseAgentChatUnifiedOptions) {
// 旧 useAgentChat 已删除,避免新代码继续沿 compat 路径扩展。
export { useAsterAgentChat } from "./useAsterAgentChat";
export { useRuntimeTeamFormation } from "./useRuntimeTeamFormation";
export { useTeamWorkspaceRuntime } from "./useTeamWorkspaceRuntime";
export { useCompatSubagentRuntime } from "./useCompatSubagentRuntime";
export { useThemeContextWorkspace } from "./useThemeContextWorkspace";
@@ -0,0 +1,139 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockNotifyProjectRuntimeAgentsGuide,
mockSetSessionExecutionStrategy,
mockToastError,
mockUpdateProject,
} = vi.hoisted(() => ({
mockNotifyProjectRuntimeAgentsGuide: vi.fn(),
mockSetSessionExecutionStrategy: vi.fn(async () => undefined),
mockToastError: vi.fn(),
mockUpdateProject: vi.fn(async () => undefined),
}));
vi.mock("sonner", () => ({
toast: {
error: mockToastError,
},
}));
vi.mock("@/lib/api/project", () => ({
updateProject: mockUpdateProject,
}));
vi.mock("@/components/workspace/services/runtimeAgentsGuideService", () => ({
notifyProjectRuntimeAgentsGuide: mockNotifyProjectRuntimeAgentsGuide,
}));
import { useAgentContext } from "./useAgentContext";
interface HookHarness {
getValue: () => ReturnType<typeof useAgentContext>;
unmount: () => void;
sendMessage: ReturnType<typeof vi.fn>;
}
function mountHook(workspaceId = "workspace-1"): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const sendMessage = vi.fn(async () => undefined);
let hookValue: ReturnType<typeof useAgentContext> | null = null;
function TestComponent() {
hookValue = useAgentContext({
workspaceId,
sessionIdRef: { current: null },
topicsUpdaterRef: { current: null },
sendMessageRef: { current: sendMessage },
runtime: {
setSessionExecutionStrategy: mockSetSessionExecutionStrategy,
},
});
return null;
}
act(() => {
root.render(<TestComponent />);
});
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
sendMessage,
};
}
describe("useAgentContext", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
mockNotifyProjectRuntimeAgentsGuide.mockReset();
mockSetSessionExecutionStrategy.mockClear();
mockToastError.mockReset();
mockUpdateProject.mockReset();
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
document.body.innerHTML = "";
});
it("修复目录并重试时应触发运行时 AGENTS 引导", async () => {
const harness = mountHook();
act(() => {
harness.getValue().setWorkspacePathMissing({
content: "继续上次对话",
images: [],
});
});
await act(async () => {
await harness
.getValue()
.fixWorkspacePathAndRetry("/tmp/workspace-linked");
});
expect(mockUpdateProject).toHaveBeenCalledWith("workspace-1", {
rootPath: "/tmp/workspace-linked",
});
expect(mockNotifyProjectRuntimeAgentsGuide).toHaveBeenCalledWith(
{
id: "workspace-1",
rootPath: "/tmp/workspace-linked",
},
{
successMessage: "工作区目录已重新关联",
showSuccessWhenGuideAlreadySeen: false,
},
);
expect(harness.sendMessage).toHaveBeenCalledWith(
"继续上次对话",
[],
false,
false,
true,
);
harness.unmount();
});
});
@@ -8,6 +8,7 @@ import {
import { toast } from "sonner";
import { updateProject } from "@/lib/api/project";
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import { notifyProjectRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService";
import type {
SendMessageFn,
SessionModelPreference,
@@ -286,6 +287,16 @@ export function useAgentContext(options: UseAgentContextOptions) {
setWorkspacePathMissing(null);
try {
await updateProject(workspaceId, { rootPath: newPath });
notifyProjectRuntimeAgentsGuide(
{
id: workspaceId,
rootPath: newPath,
},
{
successMessage: "工作区目录已重新关联",
showSuccessWhenGuideAlreadySeen: false,
},
);
await sendMessage(retryContent, retryImages, false, false, true);
} catch (err) {
toast.error(
@@ -0,0 +1,156 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
buildInitialDispatchKey,
useBootstrapDispatchPreview,
} from "./useBootstrapDispatchPreview";
interface HookHarness {
getValue: () => ReturnType<typeof useBootstrapDispatchPreview>;
rerender: (
props?: Partial<{
initialUserPrompt?: string;
initialUserImages?: Array<{ data: string; mediaType: string }>;
messagesCount: number;
isSending: boolean;
queuedTurnCount: number;
consumedInitialPromptKey?: string | null;
shouldUseCompactThemeWorkbench?: boolean;
}>,
) => void;
unmount: () => void;
}
function mountHook(
initialProps?: Partial<{
initialUserPrompt?: string;
initialUserImages?: Array<{ data: string; mediaType: string }>;
messagesCount: number;
isSending: boolean;
queuedTurnCount: number;
consumedInitialPromptKey?: string | null;
shouldUseCompactThemeWorkbench?: boolean;
}>,
): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof useBootstrapDispatchPreview> | null = null;
let currentProps = {
initialUserPrompt: "",
initialUserImages: [],
messagesCount: 0,
isSending: false,
queuedTurnCount: 0,
consumedInitialPromptKey: null,
shouldUseCompactThemeWorkbench: false,
...initialProps,
};
function TestComponent() {
hookValue = useBootstrapDispatchPreview(currentProps);
return null;
}
const render = (
nextProps?: Partial<{
initialUserPrompt?: string;
initialUserImages?: Array<{ data: string; mediaType: string }>;
messagesCount: number;
isSending: boolean;
queuedTurnCount: number;
consumedInitialPromptKey?: string | null;
shouldUseCompactThemeWorkbench?: boolean;
}>,
) => {
currentProps = {
...currentProps,
...nextProps,
};
act(() => {
root.render(<TestComponent />);
});
};
render();
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
describe("useBootstrapDispatchPreview", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
// noop
});
it("应生成稳定的 initialDispatchKey", () => {
expect(
buildInitialDispatchKey("写一篇文章", [
{ data: "abcdef1234567890", mediaType: "image/png" },
]),
).toContain("写一篇文章");
});
it("发送中且无消息时应展示 bootstrap 预览消息", () => {
const harness = mountHook({
initialUserPrompt: "请开始处理这个任务",
isSending: true,
});
try {
const value = harness.getValue();
expect(value.initialDispatchKey).toBeTruthy();
expect(value.shouldShowBootstrapDispatchPreview).toBe(true);
expect(value.bootstrapDispatchPreviewMessages).toHaveLength(2);
expect(value.bootstrapDispatchPreviewMessages[0]?.content).toBe(
"请开始处理这个任务",
);
} finally {
harness.unmount();
}
});
it("有真实消息后应清空 bootstrap 预览", () => {
const harness = mountHook({
initialUserPrompt: "请开始处理这个任务",
isSending: true,
});
try {
expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength(2);
harness.rerender({
messagesCount: 1,
isSending: false,
});
expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength(0);
expect(harness.getValue().shouldShowBootstrapDispatchPreview).toBe(false);
} finally {
harness.unmount();
}
});
});
@@ -0,0 +1,159 @@
import { useEffect, useMemo, useState } from "react";
import type { Message, MessageImage } from "../types";
export interface InitialDispatchPreviewSnapshot {
key: string;
prompt?: string;
images: MessageImage[];
}
interface UseBootstrapDispatchPreviewOptions {
initialUserPrompt?: string;
initialUserImages?: MessageImage[];
messagesCount: number;
isSending: boolean;
queuedTurnCount: number;
consumedInitialPromptKey?: string | null;
shouldUseCompactThemeWorkbench?: boolean;
}
export function buildInitialDispatchKey(
prompt?: string,
images?: MessageImage[],
): string | null {
const normalizedPrompt = (prompt || "").trim();
const normalizedImages = images || [];
if (!normalizedPrompt && normalizedImages.length === 0) {
return null;
}
const imageSignature = normalizedImages
.map(
(image, index) =>
`${index}:${image.mediaType}:${image.data.length}:${image.data.slice(0, 16)}`,
)
.join("|");
return `${normalizedPrompt}::${imageSignature}`;
}
export function buildInitialDispatchPreviewMessages(
dispatchKey: string,
prompt?: string,
images?: MessageImage[],
): Message[] {
const normalizedPrompt = (prompt || "").trim();
const normalizedImages = images || [];
if (!normalizedPrompt && normalizedImages.length === 0) {
return [];
}
const timestamp = new Date();
return [
{
id: `initial-dispatch:${dispatchKey}:user`,
role: "user",
content: normalizedPrompt,
images: normalizedImages.length > 0 ? normalizedImages : undefined,
timestamp,
},
{
id: `initial-dispatch:${dispatchKey}:assistant`,
role: "assistant",
content: "正在开始处理任务…",
timestamp: new Date(timestamp.getTime() + 1),
isThinking: true,
},
];
}
export function useBootstrapDispatchPreview({
initialUserPrompt,
initialUserImages,
messagesCount,
isSending,
queuedTurnCount,
consumedInitialPromptKey,
shouldUseCompactThemeWorkbench = false,
}: UseBootstrapDispatchPreviewOptions) {
const initialDispatchKey = useMemo(
() => buildInitialDispatchKey(initialUserPrompt, initialUserImages),
[initialUserImages, initialUserPrompt],
);
const [bootstrapDispatchSnapshot, setBootstrapDispatchSnapshot] =
useState<InitialDispatchPreviewSnapshot | null>(null);
useEffect(() => {
if (!initialDispatchKey) {
return;
}
setBootstrapDispatchSnapshot({
key: initialDispatchKey,
prompt: initialUserPrompt,
images: initialUserImages || [],
});
}, [initialDispatchKey, initialUserImages, initialUserPrompt]);
useEffect(() => {
if (messagesCount > 0) {
setBootstrapDispatchSnapshot(null);
return;
}
if (!initialDispatchKey && !isSending && queuedTurnCount === 0) {
setBootstrapDispatchSnapshot(null);
}
}, [initialDispatchKey, isSending, messagesCount, queuedTurnCount]);
const activeBootstrapDispatch = useMemo(() => {
if (
initialDispatchKey &&
((initialUserPrompt || "").trim() || (initialUserImages || []).length > 0)
) {
return {
key: initialDispatchKey,
prompt: initialUserPrompt,
images: initialUserImages || [],
};
}
return bootstrapDispatchSnapshot;
}, [
bootstrapDispatchSnapshot,
initialDispatchKey,
initialUserImages,
initialUserPrompt,
]);
const isBootstrapDispatchPending =
activeBootstrapDispatch !== null &&
consumedInitialPromptKey !== activeBootstrapDispatch.key;
const shouldShowBootstrapDispatchPreview =
!shouldUseCompactThemeWorkbench &&
Boolean(activeBootstrapDispatch) &&
messagesCount === 0 &&
(isSending || queuedTurnCount > 0);
const bootstrapDispatchPreviewMessages = useMemo(() => {
if (!shouldShowBootstrapDispatchPreview || !activeBootstrapDispatch) {
return [] as Message[];
}
return buildInitialDispatchPreviewMessages(
activeBootstrapDispatch.key,
activeBootstrapDispatch.prompt,
activeBootstrapDispatch.images,
);
}, [activeBootstrapDispatch, shouldShowBootstrapDispatchPreview]);
return {
initialDispatchKey,
activeBootstrapDispatch,
isBootstrapDispatchPending,
shouldShowBootstrapDispatchPreview,
bootstrapDispatchPreviewMessages,
};
}
@@ -0,0 +1,146 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useHomeShellAgentPreferences } from "./useHomeShellAgentPreferences";
interface HookHarness {
getValue: () => ReturnType<typeof useHomeShellAgentPreferences>;
rerender: (projectId?: string | null) => void;
unmount: () => void;
}
function mountHook(initialProjectId?: string | null): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof useHomeShellAgentPreferences> | null = null;
function TestComponent({ projectId }: { projectId?: string | null }) {
hookValue = useHomeShellAgentPreferences(projectId);
return null;
}
const render = (projectId?: string | null) => {
act(() => {
root.render(<TestComponent projectId={projectId} />);
});
};
render(initialProjectId);
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
describe("useHomeShellAgentPreferences", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it("应按项目作用域读取 provider、model 与执行策略", () => {
localStorage.setItem(
"agent_pref_provider_project-home-a",
JSON.stringify("openai"),
);
localStorage.setItem(
"agent_pref_model_project-home-a",
JSON.stringify("gpt-5-mini"),
);
localStorage.setItem(
"aster_execution_strategy_project-home-a",
JSON.stringify("auto"),
);
const harness = mountHook("project-home-a");
try {
expect(harness.getValue().providerType).toBe("openai");
expect(harness.getValue().model).toBe("gpt-5-mini");
expect(harness.getValue().executionStrategy).toBe("auto");
} finally {
harness.unmount();
}
});
it("切换项目时应重载对应偏好,并把变更写回当前作用域", () => {
localStorage.setItem(
"agent_pref_provider_project-home-a",
JSON.stringify("openai"),
);
localStorage.setItem(
"agent_pref_model_project-home-a",
JSON.stringify("gpt-5-mini"),
);
localStorage.setItem(
"aster_execution_strategy_project-home-a",
JSON.stringify("auto"),
);
localStorage.setItem(
"agent_pref_provider_project-home-b",
JSON.stringify("claude"),
);
localStorage.setItem(
"agent_pref_model_project-home-b",
JSON.stringify("claude-sonnet-4-5"),
);
const harness = mountHook("project-home-a");
try {
harness.rerender("project-home-b");
expect(harness.getValue().providerType).toBe("claude");
expect(harness.getValue().model).toBe("claude-sonnet-4-5");
expect(harness.getValue().executionStrategy).toBe("react");
act(() => {
harness.getValue().setProviderType("gemini");
harness.getValue().setModel("gemini-2.5-pro");
harness.getValue().setExecutionStrategy("code_orchestrated");
});
expect(
JSON.parse(
localStorage.getItem("agent_pref_provider_project-home-b") || "null",
),
).toBe("gemini");
expect(
JSON.parse(
localStorage.getItem("agent_pref_model_project-home-b") || "null",
),
).toBe("gemini-2.5-pro");
expect(
JSON.parse(
localStorage.getItem(
"aster_execution_strategy_project-home-b",
) || "null",
),
).toBe("code_orchestrated");
} finally {
harness.unmount();
}
});
});
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useState } from "react";
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import { normalizeProjectId } from "../utils/topicProjectResolution";
import { normalizeExecutionStrategy } from "./agentChatCoreUtils";
import {
getAgentPreferenceKeys,
loadPersisted,
resolveWorkspaceAgentPreferences,
savePersisted,
} from "./agentChatStorage";
function resolveExecutionStrategyStorageKey(
projectId?: string | null,
): string | null {
const normalizedProjectId = normalizeProjectId(projectId);
if (!normalizedProjectId) {
return null;
}
return `aster_execution_strategy_${normalizedProjectId}`;
}
function resolvePersistedExecutionStrategy(
projectId?: string | null,
): AsterExecutionStrategy {
const storageKey = resolveExecutionStrategyStorageKey(projectId);
if (!storageKey) {
return "react";
}
return normalizeExecutionStrategy(
loadPersisted<string | null>(storageKey, "react"),
);
}
export function useHomeShellAgentPreferences(projectId?: string | null) {
const normalizedProjectId = normalizeProjectId(projectId);
const initialPreferences =
resolveWorkspaceAgentPreferences(normalizedProjectId);
const [providerType, setProviderTypeState] = useState(
initialPreferences.providerType,
);
const [model, setModelState] = useState(initialPreferences.model);
const [executionStrategy, setExecutionStrategyState] =
useState<AsterExecutionStrategy>(() =>
resolvePersistedExecutionStrategy(normalizedProjectId),
);
useEffect(() => {
const scopedPreferences =
resolveWorkspaceAgentPreferences(normalizedProjectId);
setProviderTypeState(scopedPreferences.providerType);
setModelState(scopedPreferences.model);
setExecutionStrategyState(
resolvePersistedExecutionStrategy(normalizedProjectId),
);
}, [normalizedProjectId]);
const setProviderType = useCallback(
(nextProviderType: string) => {
setProviderTypeState(nextProviderType);
const { providerKey } = getAgentPreferenceKeys(normalizedProjectId);
savePersisted(providerKey, nextProviderType);
},
[normalizedProjectId],
);
const setModel = useCallback(
(nextModel: string) => {
setModelState(nextModel);
const { modelKey } = getAgentPreferenceKeys(normalizedProjectId);
savePersisted(modelKey, nextModel);
},
[normalizedProjectId],
);
const setExecutionStrategy = useCallback(
(nextExecutionStrategy: AsterExecutionStrategy) => {
const normalized = normalizeExecutionStrategy(nextExecutionStrategy);
setExecutionStrategyState(normalized);
const storageKey = resolveExecutionStrategyStorageKey(normalizedProjectId);
if (!storageKey) {
return;
}
savePersisted(storageKey, normalized);
},
[normalizedProjectId],
);
return {
providerType,
setProviderType,
model,
setModel,
executionStrategy,
setExecutionStrategy,
};
}
@@ -0,0 +1,115 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useHomeShellProjectMemory } from "./useHomeShellProjectMemory";
const mockGetProjectMemory = vi.hoisted(() => vi.fn());
const mockLogAgentDebug = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/memory", () => ({
getProjectMemory: mockGetProjectMemory,
}));
vi.mock("@/lib/agentDebug", () => ({
logAgentDebug: mockLogAgentDebug,
}));
interface HookHarness {
getValue: () => ReturnType<typeof useHomeShellProjectMemory>;
rerender: (projectId?: string | null) => void;
unmount: () => void;
}
function mountHook(initialProjectId?: string | null): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof useHomeShellProjectMemory> | null = null;
function TestComponent({ projectId }: { projectId?: string | null }) {
hookValue = useHomeShellProjectMemory(projectId);
return null;
}
const render = (projectId?: string | null) => {
act(() => {
root.render(<TestComponent projectId={projectId} />);
});
};
render(initialProjectId);
return {
getValue: () => hookValue,
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
async function flushEffects(times = 4) {
for (let index = 0; index < times; index += 1) {
await act(async () => {
await Promise.resolve();
});
}
}
describe("useHomeShellProjectMemory", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
mockGetProjectMemory.mockResolvedValue({
characters: [{ id: "char-1", name: "主角" }],
outline: [],
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("无项目时应直接返回空记忆且不触发加载", async () => {
const harness = mountHook();
try {
await flushEffects();
expect(harness.getValue()).toBeNull();
expect(mockGetProjectMemory).not.toHaveBeenCalled();
} finally {
harness.unmount();
}
});
it("切换项目时应加载对应项目记忆", async () => {
const harness = mountHook("project-memory-a");
try {
await flushEffects();
expect(mockGetProjectMemory).toHaveBeenCalledWith("project-memory-a");
expect(harness.getValue()?.characters[0]?.name).toBe("主角");
mockGetProjectMemory.mockResolvedValueOnce({
characters: [{ id: "char-2", name: "配角" }],
outline: [],
});
harness.rerender("project-memory-b");
await flushEffects();
expect(mockGetProjectMemory).toHaveBeenLastCalledWith("project-memory-b");
expect(harness.getValue()?.characters[0]?.name).toBe("配角");
} finally {
harness.unmount();
}
});
});
@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { getProjectMemory, type ProjectMemory } from "@/lib/api/memory";
import { logAgentDebug } from "@/lib/agentDebug";
import { normalizeProjectId } from "../utils/topicProjectResolution";
export function useHomeShellProjectMemory(projectId?: string | null) {
const normalizedProjectId = normalizeProjectId(projectId);
const [projectMemory, setProjectMemory] = useState<ProjectMemory | null>(
null,
);
useEffect(() => {
if (!normalizedProjectId) {
setProjectMemory(null);
return;
}
let cancelled = false;
const startedAt = Date.now();
logAgentDebug("useHomeShellProjectMemory", "load.start", {
projectId: normalizedProjectId,
});
void getProjectMemory(normalizedProjectId)
.then((memory) => {
if (cancelled) {
return;
}
setProjectMemory(memory);
logAgentDebug("useHomeShellProjectMemory", "load.success", {
durationMs: Date.now() - startedAt,
projectId: normalizedProjectId,
charactersCount: memory.characters.length,
});
})
.catch((error) => {
if (cancelled) {
return;
}
setProjectMemory(null);
logAgentDebug(
"useHomeShellProjectMemory",
"load.error",
{
durationMs: Date.now() - startedAt,
error,
projectId: normalizedProjectId,
},
{ level: "warn" },
);
});
return () => {
cancelled = true;
};
}, [normalizedProjectId]);
return projectMemory;
}
@@ -0,0 +1,137 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useHomeShellSkills } from "./useHomeShellSkills";
const mockGetLocal = vi.hoisted(() => vi.fn());
const mockGetAll = vi.hoisted(() => vi.fn());
const mockLogAgentDebug = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/skills", () => ({
skillsApi: {
getLocal: mockGetLocal,
getAll: mockGetAll,
},
}));
vi.mock("@/lib/agentDebug", () => ({
logAgentDebug: mockLogAgentDebug,
}));
interface HookHarness {
getValue: () => ReturnType<typeof useHomeShellSkills>;
unmount: () => void;
}
function mountHook(): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof useHomeShellSkills> | null = null;
function TestComponent() {
hookValue = useHomeShellSkills();
return null;
}
act(() => {
root.render(<TestComponent />);
});
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
async function flushEffects() {
await act(async () => {
await Promise.resolve();
});
}
describe("useHomeShellSkills", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
vi.useFakeTimers();
mockGetLocal.mockResolvedValue([
{
key: "local-skill",
name: "本地技能",
description: "默认加载",
directory: "local-skill",
installed: true,
sourceKind: "builtin",
},
]);
mockGetAll.mockResolvedValue([
{
key: "remote-skill",
name: "远端技能",
description: "刷新加载",
directory: "remote-skill",
installed: true,
sourceKind: "builtin",
},
]);
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("应在 idle/fallback 阶段加载本地 skills", async () => {
const harness = mountHook();
try {
act(() => {
vi.advanceTimersByTime(200);
});
await flushEffects();
expect(mockGetLocal).toHaveBeenCalledWith("lime");
expect(harness.getValue().skills).toHaveLength(1);
expect(harness.getValue().skills[0]?.key).toBe("local-skill");
expect(harness.getValue().skillsLoading).toBe(false);
} finally {
harness.unmount();
}
});
it("手动刷新时应走远端聚合入口", async () => {
const harness = mountHook();
try {
act(() => {
vi.advanceTimersByTime(200);
});
await flushEffects();
await act(async () => {
await harness.getValue().refreshSkills(true);
});
expect(mockGetAll).toHaveBeenCalledWith("lime");
expect(harness.getValue().skills[0]?.key).toBe("remote-skill");
} finally {
harness.unmount();
}
});
});
@@ -0,0 +1,8 @@
import { useLimeSkills } from "./useLimeSkills";
export function useHomeShellSkills() {
return useLimeSkills({
autoLoad: "deferred",
logScope: "useHomeShellSkills",
});
}
@@ -0,0 +1,143 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useLimeSkills } from "./useLimeSkills";
const mockGetLocal = vi.hoisted(() => vi.fn());
const mockGetAll = vi.hoisted(() => vi.fn());
const mockLogAgentDebug = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/skills", () => ({
skillsApi: {
getLocal: mockGetLocal,
getAll: mockGetAll,
},
}));
vi.mock("@/lib/agentDebug", () => ({
logAgentDebug: mockLogAgentDebug,
}));
interface HookHarness {
getValue: () => ReturnType<typeof useLimeSkills>;
unmount: () => void;
}
function mountHook(
options: Parameters<typeof useLimeSkills>[0] = {
autoLoad: "deferred",
},
): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof useLimeSkills> | null = null;
function TestComponent() {
hookValue = useLimeSkills(options);
return null;
}
act(() => {
root.render(<TestComponent />);
});
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
async function flushEffects() {
await act(async () => {
await Promise.resolve();
});
}
describe("useLimeSkills", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
vi.useFakeTimers();
mockGetLocal.mockResolvedValue([
{
key: "local-skill",
name: "本地技能",
description: "默认加载",
directory: "local-skill",
installed: true,
sourceKind: "builtin",
},
]);
mockGetAll.mockResolvedValue([
{
key: "remote-skill",
name: "远端技能",
description: "刷新加载",
directory: "remote-skill",
installed: true,
sourceKind: "builtin",
},
]);
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("deferred 模式应在 idle/fallback 阶段加载本地 skills", async () => {
const harness = mountHook({ autoLoad: "deferred", logScope: "TestScope" });
try {
act(() => {
vi.advanceTimersByTime(200);
});
await flushEffects();
expect(mockGetLocal).toHaveBeenCalledWith("lime");
expect(harness.getValue().skills[0]?.key).toBe("local-skill");
expect(mockLogAgentDebug).toHaveBeenCalledWith(
"TestScope",
"loadSkills.start",
{ includeRemote: false },
);
} finally {
harness.unmount();
}
});
it("immediate 模式应立即加载,并支持手动刷新远端", async () => {
const harness = mountHook({ autoLoad: "immediate" });
try {
await flushEffects();
expect(mockGetLocal).toHaveBeenCalledWith("lime");
await act(async () => {
await harness.getValue().refreshSkills(true);
});
expect(mockGetAll).toHaveBeenCalledWith("lime");
expect(harness.getValue().skills[0]?.key).toBe("remote-skill");
} finally {
harness.unmount();
}
});
});
@@ -0,0 +1,127 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { logAgentDebug } from "@/lib/agentDebug";
import { skillsApi, type Skill } from "@/lib/api/skills";
const SKILLS_IDLE_TIMEOUT_MS = 1_500;
const SKILLS_FALLBACK_DELAY_MS = 180;
function scheduleDeferredSkillsLoad(task: () => void): () => void {
if (typeof window === "undefined") {
task();
return () => undefined;
}
if (typeof window.requestIdleCallback === "function") {
const idleId = window.requestIdleCallback(() => task(), {
timeout: SKILLS_IDLE_TIMEOUT_MS,
});
return () => {
if (typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(idleId);
}
};
}
const timeoutId = window.setTimeout(task, SKILLS_FALLBACK_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
}
interface UseLimeSkillsOptions {
autoLoad?: "immediate" | "deferred" | false;
logScope?: string;
onError?: (error: unknown) => void;
}
export function useLimeSkills(options: UseLimeSkillsOptions = {}) {
const {
autoLoad = "immediate",
logScope = "useLimeSkills",
onError,
} = options;
const [skills, setSkills] = useState<Skill[]>([]);
const [skillsLoading, setSkillsLoading] = useState(false);
const latestRequestIdRef = useRef(0);
const refreshSkills = useCallback(
async (includeRemote = false): Promise<Skill[]> => {
const requestId = latestRequestIdRef.current + 1;
latestRequestIdRef.current = requestId;
const startedAt = Date.now();
logAgentDebug(logScope, "loadSkills.start", {
includeRemote,
});
setSkillsLoading(true);
try {
const loadedSkills = includeRemote
? await skillsApi.getAll("lime")
: await skillsApi.getLocal("lime");
if (latestRequestIdRef.current !== requestId) {
return loadedSkills;
}
setSkills(loadedSkills);
logAgentDebug(logScope, "loadSkills.success", {
durationMs: Date.now() - startedAt,
includeRemote,
skillsCount: loadedSkills.length,
});
return loadedSkills;
} catch (error) {
if (latestRequestIdRef.current !== requestId) {
return [];
}
setSkills([]);
onError?.(error);
logAgentDebug(
logScope,
"loadSkills.error",
{
durationMs: Date.now() - startedAt,
error,
includeRemote,
},
{ level: "warn" },
);
return [];
} finally {
if (latestRequestIdRef.current === requestId) {
setSkillsLoading(false);
}
}
},
[logScope, onError],
);
useEffect(() => {
if (autoLoad === false) {
return;
}
if (autoLoad === "deferred") {
return scheduleDeferredSkillsLoad(() => {
void refreshSkills(false);
});
}
void refreshSkills(false);
return;
}, [autoLoad, refreshSkills]);
useEffect(() => {
return () => {
latestRequestIdRef.current += 1;
};
}, []);
return {
skills,
skillsLoading,
refreshSkills,
};
}
@@ -0,0 +1,252 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TeamDefinition } from "../utils/teamDefinitions";
import {
shouldGenerateRuntimeTeamAfterSend,
useRuntimeTeamFormation,
} from "./useRuntimeTeamFormation";
type HookProps = Parameters<typeof useRuntimeTeamFormation>[0];
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
function createSelectedTeam(): TeamDefinition {
return {
id: "team-1",
source: "builtin",
label: "研究协作组",
description: "负责拆解调研任务",
roles: [
{
id: "role-1",
label: "研究员",
summary: "收集资料",
},
],
};
}
function renderHook(props?: Partial<HookProps>) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultProps: HookProps = {
activeTheme: "general",
projectId: "project-1",
sessionId: "session-1",
selectedTeam: createSelectedTeam(),
subagentEnabled: true,
hasRealTeamGraph: false,
generateRuntimeTeam: vi.fn(async () => ({
id: "ephemeral-1",
source: "ephemeral",
label: "临时 Team",
description: "自动生成",
roles: [
{
id: "member-1",
label: "执行者",
summary: "执行当前任务",
},
],
})),
createRequestId: () => "request-1",
now: () => 1_710_000_000_000,
};
let latestValue: ReturnType<typeof useRuntimeTeamFormation> | null = null;
function Probe(currentProps: HookProps) {
latestValue = useRuntimeTeamFormation(currentProps);
return null;
}
const render = async (nextProps?: Partial<HookProps>) => {
await act(async () => {
root.render(<Probe {...defaultProps} {...props} {...nextProps} />);
await Promise.resolve();
});
};
mountedRoots.push({ root, container });
return {
render,
getValue: () => {
if (!latestValue) {
throw new Error("hook 尚未初始化");
}
return latestValue;
},
defaultProps: {
...defaultProps,
...props,
},
};
}
describe("useRuntimeTeamFormation", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
it("发送后满足条件时应进入 forming 并在生成完成后切到 formed", async () => {
const generateRuntimeTeam = vi.fn(async () => ({
id: "ephemeral-1",
source: "ephemeral" as const,
label: "自动 Team",
description: "自动生成",
roles: [
{
id: "role-1",
label: "执行者",
summary: "负责执行",
},
],
}));
const { render, getValue } = renderHook({
generateRuntimeTeam,
});
await render();
act(() => {
getValue().handleRuntimeTeamAfterSend({
input: "请拆成两个子任务",
providerType: "openai",
model: "gpt-4.1",
executionStrategy: "react",
});
});
expect(getValue().runtimeTeamState?.status).toBe("forming");
expect(generateRuntimeTeam).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "project-1",
providerType: "openai",
model: "gpt-4.1",
input: "请拆成两个子任务",
activeTheme: "general",
}),
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(getValue().runtimeTeamState?.status).toBe("formed");
expect(getValue().runtimeTeamState?.label).toBe("自动 Team");
});
it("subagent 关闭且没有真实 team 图时,应清空 runtime team 状态", async () => {
const { render, getValue } = renderHook();
await render();
act(() => {
getValue().triggerRuntimeTeamFormation({
input: "请先规划 team",
providerType: "openai",
model: "gpt-4.1",
executionStrategy: "react",
});
});
expect(getValue().runtimeTeamState?.status).toBe("forming");
await render({
subagentEnabled: false,
hasRealTeamGraph: false,
});
expect(getValue().runtimeTeamState).toBeNull();
});
it("session 切换时应重置 runtime team 状态", async () => {
const { render, getValue } = renderHook();
await render();
act(() => {
getValue().triggerRuntimeTeamFormation({
input: "请先规划 team",
providerType: "openai",
model: "gpt-4.1",
executionStrategy: "react",
});
});
expect(getValue().runtimeTeamState?.status).toBe("forming");
await render({
sessionId: "session-2",
});
expect(getValue().runtimeTeamState).toBeNull();
});
});
describe("shouldGenerateRuntimeTeamAfterSend", () => {
it("仅在 subagent 开启、存在项目、非 purpose 且输入非空时返回 true", () => {
expect(
shouldGenerateRuntimeTeamAfterSend({
subagentEnabled: true,
projectId: "project-1",
input: "请拆解任务",
}),
).toBe(true);
expect(
shouldGenerateRuntimeTeamAfterSend({
subagentEnabled: false,
projectId: "project-1",
input: "请拆解任务",
}),
).toBe(false);
expect(
shouldGenerateRuntimeTeamAfterSend({
subagentEnabled: true,
projectId: "",
input: "请拆解任务",
}),
).toBe(false);
expect(
shouldGenerateRuntimeTeamAfterSend({
subagentEnabled: true,
projectId: "project-1",
input: " ",
}),
).toBe(false);
expect(
shouldGenerateRuntimeTeamAfterSend({
subagentEnabled: true,
projectId: "project-1",
input: "请拆解任务",
purpose: "content_review",
}),
).toBe(false);
});
});
@@ -0,0 +1,210 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { HandleSendOptions } from "./handleSendTypes";
import {
createRuntimeFormationStateFromTeam,
type TeamWorkspaceRuntimeFormationState,
} from "../teamWorkspaceRuntime";
import { generateEphemeralTeamWithModel } from "../utils/teamAutoGeneration";
import type { TeamDefinition } from "../utils/teamDefinitions";
interface TriggerRuntimeTeamFormationParams {
input: string;
providerType: string;
model: string;
executionStrategy?: "react" | "code_orchestrated" | "auto";
}
interface HandleRuntimeTeamAfterSendParams
extends TriggerRuntimeTeamFormationParams {
purpose?: HandleSendOptions["purpose"];
}
interface UseRuntimeTeamFormationOptions {
activeTheme: string;
projectId?: string | null;
sessionId?: string | null;
selectedTeam?: TeamDefinition | null;
subagentEnabled: boolean;
hasRealTeamGraph: boolean;
generateRuntimeTeam?: typeof generateEphemeralTeamWithModel;
createRequestId?: () => string;
now?: () => number;
}
export interface UseRuntimeTeamFormationResult {
runtimeTeamState: TeamWorkspaceRuntimeFormationState | null;
clearRuntimeTeamState: () => void;
triggerRuntimeTeamFormation: (
params: TriggerRuntimeTeamFormationParams,
) => void;
handleRuntimeTeamAfterSend: (params: HandleRuntimeTeamAfterSendParams) => void;
}
function defaultCreateRequestId() {
return crypto.randomUUID();
}
export function shouldGenerateRuntimeTeamAfterSend(params: {
subagentEnabled: boolean;
projectId?: string | null;
input: string;
purpose?: HandleSendOptions["purpose"];
}): boolean {
return (
params.subagentEnabled &&
!params.purpose &&
Boolean(params.projectId) &&
params.input.trim().length > 0
);
}
export function useRuntimeTeamFormation({
activeTheme,
projectId,
sessionId,
selectedTeam,
subagentEnabled,
hasRealTeamGraph,
generateRuntimeTeam = generateEphemeralTeamWithModel,
createRequestId = defaultCreateRequestId,
now = () => Date.now(),
}: UseRuntimeTeamFormationOptions): UseRuntimeTeamFormationResult {
const [runtimeTeamState, setRuntimeTeamState] =
useState<TeamWorkspaceRuntimeFormationState | null>(null);
const runtimeTeamRequestIdRef = useRef<string | null>(null);
const clearRuntimeTeamState = useCallback(() => {
runtimeTeamRequestIdRef.current = null;
setRuntimeTeamState(null);
}, []);
const triggerRuntimeTeamFormation = useCallback(
({
input,
providerType,
model,
executionStrategy,
}: TriggerRuntimeTeamFormationParams) => {
const normalizedInput = input.trim();
if (!projectId || !normalizedInput) {
return;
}
const requestId = createRequestId();
runtimeTeamRequestIdRef.current = requestId;
setRuntimeTeamState(
createRuntimeFormationStateFromTeam({
requestId,
status: "forming",
blueprintTeam: selectedTeam ?? null,
updatedAt: now(),
}),
);
void generateRuntimeTeam({
workspaceId: projectId,
providerType,
model,
executionStrategy,
activeTheme,
input: normalizedInput,
blueprintTeam: selectedTeam ?? null,
})
.then((runtimeTeam) => {
if (runtimeTeamRequestIdRef.current !== requestId) {
return;
}
setRuntimeTeamState(
createRuntimeFormationStateFromTeam({
requestId,
status: "formed",
runtimeTeam,
blueprintTeam: selectedTeam ?? null,
updatedAt: now(),
}),
);
})
.catch((error) => {
if (runtimeTeamRequestIdRef.current !== requestId) {
return;
}
const errorMessage =
error instanceof Error ? error.message : "Team 生成失败";
setRuntimeTeamState(
createRuntimeFormationStateFromTeam({
requestId,
status: "failed",
blueprintTeam: selectedTeam ?? null,
errorMessage,
updatedAt: now(),
}),
);
});
},
[
activeTheme,
createRequestId,
generateRuntimeTeam,
now,
projectId,
selectedTeam,
],
);
const handleRuntimeTeamAfterSend = useCallback(
({
input,
providerType,
model,
executionStrategy,
purpose,
}: HandleRuntimeTeamAfterSendParams) => {
if (
shouldGenerateRuntimeTeamAfterSend({
subagentEnabled,
projectId,
input,
purpose,
})
) {
triggerRuntimeTeamFormation({
input,
providerType,
model,
executionStrategy,
});
return;
}
if (!subagentEnabled && !hasRealTeamGraph) {
clearRuntimeTeamState();
}
},
[
clearRuntimeTeamState,
hasRealTeamGraph,
projectId,
subagentEnabled,
triggerRuntimeTeamFormation,
],
);
useEffect(() => {
clearRuntimeTeamState();
}, [clearRuntimeTeamState, sessionId]);
useEffect(() => {
if (!subagentEnabled && !hasRealTeamGraph) {
clearRuntimeTeamState();
}
}, [clearRuntimeTeamState, hasRealTeamGraph, subagentEnabled]);
return {
runtimeTeamState,
clearRuntimeTeamState,
triggerRuntimeTeamFormation,
handleRuntimeTeamAfterSend,
};
}
@@ -1,6 +1,7 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { WorkspaceSettings } from "@/types/workspace";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TeamDefinition } from "../utils/teamDefinitions";
import { createTeamDefinitionFromPreset } from "../utils/teamDefinitions";
import { useSelectedTeamPreference } from "./useSelectedTeamPreference";
@@ -9,31 +10,42 @@ import {
persistSelectedTeam,
} from "../utils/teamStorage";
type HookOptions = Parameters<typeof useSelectedTeamPreference>[1];
interface HookHarness {
getValue: () => ReturnType<typeof useSelectedTeamPreference>;
rerender: (theme?: string | null) => void;
rerender: (theme?: string | null, options?: HookOptions) => void;
unmount: () => void;
}
function mountHook(initialTheme?: string | null): HookHarness {
function mountHook(
initialTheme?: string | null,
initialOptions?: HookOptions,
): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof useSelectedTeamPreference> | null = null;
function TestComponent({ theme }: { theme?: string | null }) {
hookValue = useSelectedTeamPreference(theme);
function TestComponent({
theme,
options,
}: {
theme?: string | null;
options?: HookOptions;
}) {
hookValue = useSelectedTeamPreference(theme, options);
return null;
}
const render = (theme?: string | null) => {
const render = (theme?: string | null, options?: HookOptions) => {
act(() => {
root.render(<TestComponent theme={theme} />);
root.render(<TestComponent theme={theme} options={options} />);
});
};
render(initialTheme);
render(initialTheme, initialOptions);
return {
getValue: () => {
@@ -116,4 +128,170 @@ describe("useSelectedTeamPreference", () => {
harness.unmount();
}
});
it("项目级 Team 偏好应优先于 localStorage,并通过回调持久化", async () => {
const engineeringTeam = createTeamDefinitionFromPreset(
"code-triage-team",
) as TeamDefinition;
const researchTeam = createTeamDefinitionFromPreset(
"research-team",
) as TeamDefinition;
const projectSettings: WorkspaceSettings = {
agentTeam: {
selectedTeam: {
id: engineeringTeam.id,
source: engineeringTeam.source,
},
},
};
const persistSpy = vi.fn().mockResolvedValue(undefined);
persistSelectedTeam(researchTeam, "general");
const harness = mountHook("general", {
projectSettings,
onPersistSelectedTeam: persistSpy,
});
try {
await flushEffects();
expect(harness.getValue().selectedTeam?.id).toBe("code-triage-team");
act(() => {
harness.getValue().setSelectedTeam(null);
});
await flushEffects();
expect(persistSpy).toHaveBeenCalledWith(null);
expect(loadSelectedTeamReference("general")).toEqual({
id: "research-team",
source: "builtin",
});
} finally {
harness.unmount();
}
});
it("项目级自定义 Team 应能直接解析为当前选择", async () => {
const harness = mountHook("general", {
projectSettings: {
agentTeam: {
selectedTeam: {
id: "custom-team-project-1",
source: "custom",
},
customTeams: [
{
id: "custom-team-project-1",
label: "前端联调团队",
description: "分析、实现、验证三段式推进。",
roles: [
{
id: "planner",
label: "分析",
summary: "负责拆解问题与确认边界。",
profileId: "code-explorer",
roleKey: "explorer",
skillIds: ["source-grounding"],
},
],
},
],
},
},
});
try {
await flushEffects();
expect(harness.getValue().selectedTeam?.id).toBe("custom-team-project-1");
expect(harness.getValue().selectedTeam?.label).toBe("前端联调团队");
expect(harness.getValue().selectedTeam?.source).toBe("custom");
expect(harness.getValue().selectedTeam?.roles[0]?.profileId).toBe(
"code-explorer",
);
} finally {
harness.unmount();
}
});
it("项目级持久化失败时应回滚到当前项目 Team", async () => {
const engineeringTeam = createTeamDefinitionFromPreset(
"code-triage-team",
) as TeamDefinition;
const researchTeam = createTeamDefinitionFromPreset(
"research-team",
) as TeamDefinition;
const persistSpy = vi.fn().mockRejectedValue(new Error("save failed"));
const harness = mountHook("general", {
projectSettings: {
agentTeam: {
selectedTeam: {
id: engineeringTeam.id,
source: engineeringTeam.source,
},
},
},
onPersistSelectedTeam: persistSpy,
});
try {
await flushEffects();
expect(harness.getValue().selectedTeam?.id).toBe("code-triage-team");
act(() => {
harness.getValue().setSelectedTeam(researchTeam);
});
await flushEffects();
expect(persistSpy).toHaveBeenCalledWith(researchTeam);
expect(harness.getValue().selectedTeam?.id).toBe("code-triage-team");
} finally {
harness.unmount();
}
});
it("ephemeral Team 不应写入项目级或本地持久化", async () => {
const persistSpy = vi.fn().mockResolvedValue(undefined);
const ephemeralTeam: TeamDefinition = {
id: "ephemeral-team-generated",
source: "ephemeral",
label: "本轮 Team",
description: "仅本轮会话使用",
roles: [
{
id: "planner",
label: "分析",
summary: "负责拆解任务。",
},
],
};
const harness = mountHook("general", {
projectSettings: {
agentTeam: {
selectedTeam: {
id: "code-triage-team",
source: "builtin",
},
},
},
onPersistSelectedTeam: persistSpy,
});
try {
await flushEffects();
act(() => {
harness.getValue().setSelectedTeam(ephemeralTeam);
});
await flushEffects();
expect(harness.getValue().selectedTeam?.id).toBe(
"ephemeral-team-generated",
);
expect(persistSpy).not.toHaveBeenCalled();
expect(loadSelectedTeamReference("general")).toBeNull();
} finally {
harness.unmount();
}
});
});
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { WorkspaceSettings } from "@/types/workspace";
import type { TeamDefinition } from "../utils/teamDefinitions";
import {
buildTeamDefinitionLabel,
@@ -7,24 +8,55 @@ import {
} from "../utils/teamDefinitions";
import {
persistSelectedTeam,
resolvePersistedSelectedTeam,
resolveSelectedTeamPreference,
} from "../utils/teamStorage";
export function useSelectedTeamPreference(theme?: string | null) {
interface UseSelectedTeamPreferenceOptions {
projectSettings?: WorkspaceSettings | null;
onPersistSelectedTeam?: (team: TeamDefinition | null) => void | Promise<void>;
}
export function useSelectedTeamPreference(
theme?: string | null,
options: UseSelectedTeamPreferenceOptions = {},
) {
const { projectSettings, onPersistSelectedTeam } = options;
const resolveCurrentSelection = useCallback(
() =>
resolveSelectedTeamPreference({
theme,
workspaceSettings: projectSettings,
}),
[projectSettings, theme],
);
const [selectedTeam, setSelectedTeamState] = useState<TeamDefinition | null>(
() => resolvePersistedSelectedTeam(theme),
() => resolveCurrentSelection(),
);
useEffect(() => {
setSelectedTeamState(resolvePersistedSelectedTeam(theme));
}, [theme]);
setSelectedTeamState(resolveCurrentSelection());
}, [resolveCurrentSelection]);
const setSelectedTeam = useCallback(
(team: TeamDefinition | null) => {
persistSelectedTeam(team, theme);
setSelectedTeamState(team);
if (onPersistSelectedTeam && team?.source !== "ephemeral") {
const fallbackTeam = resolveCurrentSelection();
void Promise.resolve(onPersistSelectedTeam(team)).catch((error) => {
console.warn("[Team] 持久化项目级 Team 偏好失败:", error);
setSelectedTeamState(fallbackTeam);
});
return;
}
if (onPersistSelectedTeam && team?.source === "ephemeral") {
return;
}
persistSelectedTeam(team, theme);
},
[theme],
[onPersistSelectedTeam, resolveCurrentSelection, theme],
);
const enableSuggestedTeam = useCallback(
@@ -244,6 +244,148 @@ describe("useTeamWorkspaceRuntime", () => {
).toBe(1);
});
it("收到 turn_completed 后,应立即把 live runtime 从 running 回落到 completed", async () => {
const listeners = new Map<
string,
(event: { payload: unknown }) => void
>();
mockSafeListen.mockImplementation(
async (eventName: string, handler: (event: { payload: unknown }) => void) => {
listeners.set(eventName, handler);
return () => {
listeners.delete(eventName);
};
},
);
await renderHookProbe({
childSubagentSessions: [
{
id: "child-1",
name: "研究员",
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
session_type: "sub_agent",
runtime_status: "running",
latest_turn_status: "running",
task_summary: "整理竞品与数据来源",
role_hint: "explorer",
},
],
});
await act(async () => {
listeners.get("agent_subagent_stream:child-1")?.({
payload: {
type: "turn_completed",
turn: {
id: "turn-1",
},
},
});
await Promise.resolve();
});
expect(latestValue?.liveRuntimeBySessionId["child-1"]?.runtimeStatus).toBe(
"completed",
);
expect(
latestValue?.liveRuntimeBySessionId["child-1"]?.latestTurnStatus,
).toBe("completed");
});
it("收到 final_done 且没有后续状态事件时,也应结束 running 状态", async () => {
const listeners = new Map<
string,
(event: { payload: unknown }) => void
>();
mockSafeListen.mockImplementation(
async (eventName: string, handler: (event: { payload: unknown }) => void) => {
listeners.set(eventName, handler);
return () => {
listeners.delete(eventName);
};
},
);
await renderHookProbe({
childSubagentSessions: [
{
id: "child-1",
name: "研究员",
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
session_type: "sub_agent",
runtime_status: "running",
latest_turn_status: "running",
task_summary: "整理竞品与数据来源",
role_hint: "explorer",
},
],
});
await act(async () => {
listeners.get("agent_subagent_stream:child-1")?.({
payload: {
type: "final_done",
},
});
await Promise.resolve();
});
expect(latestValue?.liveRuntimeBySessionId["child-1"]?.runtimeStatus).toBe(
"completed",
);
});
it("收到 error 后,应立即把 live runtime 从 running 回落到 failed", async () => {
const listeners = new Map<
string,
(event: { payload: unknown }) => void
>();
mockSafeListen.mockImplementation(
async (eventName: string, handler: (event: { payload: unknown }) => void) => {
listeners.set(eventName, handler);
return () => {
listeners.delete(eventName);
};
},
);
await renderHookProbe({
childSubagentSessions: [
{
id: "child-1",
name: "研究员",
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
session_type: "sub_agent",
runtime_status: "running",
latest_turn_status: "running",
task_summary: "整理竞品与数据来源",
role_hint: "explorer",
},
],
});
await act(async () => {
listeners.get("agent_subagent_stream:child-1")?.({
payload: {
type: "error",
message: "工具调用失败",
},
});
await Promise.resolve();
});
expect(latestValue?.liveRuntimeBySessionId["child-1"]?.runtimeStatus).toBe(
"failed",
);
expect(
latestValue?.liveRuntimeBySessionId["child-1"]?.latestTurnStatus,
).toBe("failed");
});
it("base snapshot 追平或 session 移除后,应自动清理过期 live 状态", async () => {
const listeners = new Map<
string,
@@ -50,6 +50,7 @@ interface TeamWorkspaceRuntimeStreamProjection {
forgetToolId?: string;
runtimeStatus?: TeamWorkspaceRuntimeStatus;
latestTurnStatus?: TeamWorkspaceRuntimeStatus;
queuedTurnCount?: number;
refreshPreview?: boolean;
}
@@ -272,7 +273,12 @@ function buildLiveRuntimeState(
session: TeamWorkspaceRuntimeSessionSnapshot,
baseFingerprint: string,
current: TeamWorkspaceLiveRuntimeState | undefined,
patch: Pick<TeamWorkspaceLiveRuntimeState, "runtimeStatus" | "latestTurnStatus">,
patch: Partial<
Pick<
TeamWorkspaceLiveRuntimeState,
"runtimeStatus" | "latestTurnStatus" | "queuedTurnCount"
>
>,
): TeamWorkspaceLiveRuntimeState {
return {
runtimeStatus:
@@ -285,17 +291,71 @@ function buildLiveRuntimeState(
current?.latestTurnStatus ??
session.latestTurnStatus ??
session.runtimeStatus,
queuedTurnCount:
patch.queuedTurnCount ??
current?.queuedTurnCount ??
session.queuedTurnCount,
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";
}
function projectRuntimeStreamEvent(params: {
sessionId: string;
session: TeamWorkspaceRuntimeSessionSnapshot;
event: StreamEvent;
currentRuntime?: TeamWorkspaceLiveRuntimeState;
streamState?: SessionLiveStreamState;
toolNameById?: Record<string, string>;
}): TeamWorkspaceRuntimeStreamProjection | null {
const { sessionId, event, streamState, toolNameById } = params;
const { sessionId, session, event, currentRuntime, streamState, toolNameById } =
params;
switch (event.type) {
case "item_started":
@@ -385,6 +445,7 @@ function projectRuntimeStreamEvent(params: {
}),
runtimeStatus: "queued",
latestTurnStatus: "queued",
queuedTurnCount: getQueuedTurnCount(session, currentRuntime) + 1,
};
case "queue_started":
return {
@@ -398,10 +459,29 @@ function projectRuntimeStreamEvent(params: {
}),
runtimeStatus: "running",
latestTurnStatus: "running",
queuedTurnCount: Math.max(getQueuedTurnCount(session, currentRuntime) - 1, 0),
};
case "queue_removed":
case "queue_removed": {
const queuedTurnCount = Math.max(
getQueuedTurnCount(session, currentRuntime) - 1,
0,
);
return {
queuedTurnCount,
runtimeStatus:
queuedTurnCount === 0 && currentRuntime?.runtimeStatus === "queued"
? resolveQueueDrainedRuntimeStatus(session, currentRuntime)
: undefined,
refreshPreview: true,
};
}
case "queue_cleared":
return {
queuedTurnCount: 0,
runtimeStatus:
currentRuntime?.runtimeStatus === "queued"
? resolveQueueDrainedRuntimeStatus(session, currentRuntime)
: undefined,
refreshPreview: true,
};
case "turn_started":
@@ -418,26 +498,37 @@ function projectRuntimeStreamEvent(params: {
latestTurnStatus: "running",
};
case "turn_completed":
return {
entry: buildLifecycleActivityEntry({
sessionId,
key: "turn",
title: "回合完成",
detail: "当前回合已完成,正在等待快照同步。",
statusLabel: "完成",
badgeClassName: COMPLETED_BADGE_CLASS_NAME,
}),
latestTurnStatus: "completed",
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: "当前回合已完成,正在等待快照同步。",
statusLabel: "完成",
badgeClassName: COMPLETED_BADGE_CLASS_NAME,
}),
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,
@@ -448,7 +539,14 @@ function projectRuntimeStreamEvent(params: {
statusLabel: "失败",
badgeClassName: FAILED_BADGE_CLASS_NAME,
}),
runtimeStatus: resolveFinalRuntimeStatus({
session,
current: currentRuntime,
terminalStatus: "failed",
queuedTurnCount,
}),
latestTurnStatus: "failed",
queuedTurnCount,
clearEntryIds: [
`stream-text:${sessionId}`,
`stream-thinking:${sessionId}`,
@@ -458,15 +556,24 @@ function projectRuntimeStreamEvent(params: {
clearThinkingDraft: true,
refreshPreview: true,
};
}
case "warning":
return {
entry: buildWarningActivityEntry(sessionId, event.message, event.code),
refreshPreview: true,
};
case "error":
case "error": {
const queuedTurnCount = getQueuedTurnCount(session, currentRuntime);
return {
entry: buildErrorActivityEntry(sessionId, event.message),
runtimeStatus: resolveFinalRuntimeStatus({
session,
current: currentRuntime,
terminalStatus: "failed",
queuedTurnCount,
}),
latestTurnStatus: "failed",
queuedTurnCount,
clearEntryIds: [
`stream-text:${sessionId}`,
`stream-thinking:${sessionId}`,
@@ -476,10 +583,18 @@ function projectRuntimeStreamEvent(params: {
clearThinkingDraft: true,
refreshPreview: true,
};
}
case "done":
return null;
case "final_done":
case "final_done": {
const queuedTurnCount = getQueuedTurnCount(session, currentRuntime);
return {
runtimeStatus: resolveFinalRuntimeStatus({
session,
current: currentRuntime,
queuedTurnCount,
}),
queuedTurnCount,
clearEntryIds: [
`stream-text:${sessionId}`,
`stream-thinking:${sessionId}`,
@@ -489,6 +604,7 @@ function projectRuntimeStreamEvent(params: {
clearThinkingDraft: true,
refreshPreview: true,
};
}
default:
return null;
}
@@ -551,6 +667,9 @@ export function useTeamWorkspaceRuntime(
const [liveRuntimeBySessionId, setLiveRuntimeBySessionId] = useState<
Record<string, TeamWorkspaceLiveRuntimeState>
>({});
const liveRuntimeBySessionIdRef = useRef<
Record<string, TeamWorkspaceLiveRuntimeState>
>({});
const [liveActivityBySessionId, setLiveActivityBySessionId] = useState<
Record<string, TeamWorkspaceActivityEntry[]>
>({});
@@ -614,6 +733,10 @@ export function useTeamWorkspaceRuntime(
baseFingerprintByIdRef.current = baseFingerprintById;
}, [activeSnapshotById, baseFingerprintById]);
useEffect(() => {
liveRuntimeBySessionIdRef.current = liveRuntimeBySessionId;
}, [liveRuntimeBySessionId]);
const scheduleActivityRefresh = useCallback((sessionId: string) => {
if (refreshTimersRef.current[sessionId] !== undefined) {
return;
@@ -742,9 +865,18 @@ export function useTeamWorkspaceRuntime(
setLiveRuntimeBySessionId((previous) => {
const current = previous[data.session_id];
const nextQueuedTurnCount =
normalizedStatus === "completed" ||
normalizedStatus === "failed" ||
normalizedStatus === "aborted" ||
normalizedStatus === "closed" ||
normalizedStatus === "idle"
? 0
: current?.queuedTurnCount ?? matchingSession.queuedTurnCount;
if (
current?.runtimeStatus === normalizedStatus &&
current.latestTurnStatus === normalizedStatus &&
current.queuedTurnCount === nextQueuedTurnCount &&
current.baseFingerprint === baseFingerprint
) {
return previous;
@@ -752,11 +884,16 @@ export function useTeamWorkspaceRuntime(
return {
...previous,
[data.session_id]: {
runtimeStatus: normalizedStatus,
latestTurnStatus: normalizedStatus,
[data.session_id]: buildLiveRuntimeState(
matchingSession,
baseFingerprint,
},
current,
{
runtimeStatus: normalizedStatus,
latestTurnStatus: normalizedStatus,
queuedTurnCount: nextQueuedTurnCount,
},
),
};
});
@@ -830,7 +967,9 @@ export function useTeamWorkspaceRuntime(
const projection = projectRuntimeStreamEvent({
sessionId,
session: matchingSession,
event: data,
currentRuntime: liveRuntimeBySessionIdRef.current[sessionId],
streamState: liveStreamStateBySessionIdRef.current[sessionId],
toolNameById: toolNameBySessionIdRef.current[sessionId],
});
@@ -891,7 +1030,8 @@ export function useTeamWorkspaceRuntime(
if (
projection.runtimeStatus !== undefined ||
projection.latestTurnStatus !== undefined
projection.latestTurnStatus !== undefined ||
projection.queuedTurnCount !== undefined
) {
setLiveRuntimeBySessionId((previous) => {
const current = previous[sessionId];
@@ -902,6 +1042,7 @@ export function useTeamWorkspaceRuntime(
{
runtimeStatus: projection.runtimeStatus,
latestTurnStatus: projection.latestTurnStatus,
queuedTurnCount: projection.queuedTurnCount,
},
);
@@ -909,6 +1050,7 @@ export function useTeamWorkspaceRuntime(
current &&
current.runtimeStatus === nextState.runtimeStatus &&
current.latestTurnStatus === nextState.latestTurnStatus &&
current.queuedTurnCount === nextState.queuedTurnCount &&
current.baseFingerprint === nextState.baseFingerprint
) {
return previous;
@@ -0,0 +1,130 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { saveChatToolPreferences } from "../utils/chatToolPreferences";
import { useThemeScopedChatToolPreferences } from "./useThemeScopedChatToolPreferences";
interface HookHarness {
getValue: () => ReturnType<typeof useThemeScopedChatToolPreferences>;
rerender: (activeTheme: string) => void;
unmount: () => void;
}
function mountHook(initialTheme: string): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof useThemeScopedChatToolPreferences> | null =
null;
function TestComponent({ activeTheme }: { activeTheme: string }) {
hookValue = useThemeScopedChatToolPreferences(activeTheme);
return null;
}
const render = (activeTheme: string) => {
act(() => {
root.render(<TestComponent activeTheme={activeTheme} />);
});
};
render(initialTheme);
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
describe("useThemeScopedChatToolPreferences", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it("应按当前主题读取工具偏好", () => {
saveChatToolPreferences(
{ webSearch: true, thinking: false, task: true, subagent: false },
"general",
);
const harness = mountHook("general");
try {
expect(harness.getValue().chatToolPreferences).toEqual({
webSearch: true,
thinking: false,
task: true,
subagent: false,
});
} finally {
harness.unmount();
}
});
it("切换主题时应切换作用域,并在更新后持久化当前主题", () => {
saveChatToolPreferences(
{ webSearch: true, thinking: false, task: false, subagent: false },
"general",
);
saveChatToolPreferences(
{ webSearch: false, thinking: true, task: true, subagent: true },
"social-media",
);
const harness = mountHook("general");
try {
harness.rerender("social-media");
expect(harness.getValue().chatToolPreferences).toEqual({
webSearch: false,
thinking: true,
task: true,
subagent: true,
});
act(() => {
harness.getValue().setChatToolPreferences({
webSearch: true,
thinking: true,
task: false,
subagent: true,
});
});
expect(
JSON.parse(
localStorage.getItem("lime.chat.tool_preferences.social-media.v3") ||
"null",
),
).toEqual({
webSearch: true,
thinking: true,
task: false,
subagent: true,
});
} finally {
harness.unmount();
}
});
});
@@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
import {
loadChatToolPreferences,
saveChatToolPreferences,
type ChatToolPreferences,
} from "../utils/chatToolPreferences";
export function useThemeScopedChatToolPreferences(activeTheme: string) {
const [chatToolPreferences, setChatToolPreferences] =
useState<ChatToolPreferences>(() => loadChatToolPreferences(activeTheme));
const [chatToolPreferencesTheme, setChatToolPreferencesTheme] =
useState<string>(activeTheme);
useEffect(() => {
if (chatToolPreferencesTheme === activeTheme) {
return;
}
setChatToolPreferences(loadChatToolPreferences(activeTheme));
setChatToolPreferencesTheme(activeTheme);
}, [activeTheme, chatToolPreferencesTheme]);
useEffect(() => {
if (chatToolPreferencesTheme !== activeTheme) {
return;
}
saveChatToolPreferences(chatToolPreferences, activeTheme);
}, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]);
return {
chatToolPreferences,
setChatToolPreferences,
};
}
@@ -0,0 +1,200 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useThemeWorkbenchEntryPrompt } from "./useThemeWorkbenchEntryPrompt";
interface HookHarness {
getValue: () => ReturnType<typeof useThemeWorkbenchEntryPrompt>;
rerender: (props?: Partial<HookProps>) => void;
unmount: () => void;
onHydrateInitialPrompt: ReturnType<typeof vi.fn>;
}
interface HookProps {
activeTheme: string;
contentId?: string;
sessionId?: string;
isThemeWorkbench: boolean;
shouldUseCompactThemeWorkbench: boolean;
messagesCount: number;
initialDispatchKey: string | null;
initialUserPrompt?: string;
initialUserImages?: Array<{ data: string; mediaType: string }>;
consumedInitialPromptKey?: string | null;
}
function mountHook(initialProps?: Partial<HookProps>): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onHydrateInitialPrompt = vi.fn();
const loadWorkflow = vi.fn(async () => null);
const loadRunState = vi.fn(async () => null);
let hookValue: ReturnType<typeof useThemeWorkbenchEntryPrompt> | null = null;
let currentProps: HookProps = {
activeTheme: "social-media",
contentId: "content-1",
sessionId: "session-1",
isThemeWorkbench: true,
shouldUseCompactThemeWorkbench: false,
messagesCount: 0,
initialDispatchKey: null,
initialUserPrompt: "",
initialUserImages: [],
consumedInitialPromptKey: null,
...initialProps,
};
function TestComponent() {
hookValue = useThemeWorkbenchEntryPrompt({
...currentProps,
onHydrateInitialPrompt,
loadWorkflow,
loadRunState,
});
return null;
}
const render = (nextProps?: Partial<HookProps>) => {
currentProps = {
...currentProps,
...nextProps,
};
act(() => {
root.render(<TestComponent />);
});
};
render();
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
onHydrateInitialPrompt,
};
}
async function flushEffects(times = 4) {
for (let index = 0; index < times; index += 1) {
await act(async () => {
await Promise.resolve();
});
}
}
describe("useThemeWorkbenchEntryPrompt", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("主题工作台初始意图应先进入预填提示态", async () => {
const harness = mountHook({
initialDispatchKey: "initial-dispatch",
initialUserPrompt: "请先生成社媒主稿",
});
try {
await flushEffects();
expect(harness.onHydrateInitialPrompt).toHaveBeenCalledWith(
"请先生成社媒主稿",
"initial-dispatch",
);
expect(harness.getValue().themeWorkbenchEntryPrompt).toMatchObject({
kind: "initial_prompt",
prompt: "请先生成社媒主稿",
});
} finally {
harness.unmount();
}
});
it("无初始意图时应查询 resume prompt", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onHydrateInitialPrompt = vi.fn();
const loadWorkflow = vi.fn(async () => ({
id: "wf-1",
content_id: "content-1",
theme: "social-media",
mode: "guided",
current_step_index: 1,
created_at: Date.now(),
updated_at: Date.now(),
steps: [
{
id: "step-1",
title: "撰写主稿",
status: "completed",
},
{
id: "step-2",
title: "润色结尾",
status: "pending",
},
],
}));
const loadRunState = vi.fn(async () => null);
let hookValue: ReturnType<typeof useThemeWorkbenchEntryPrompt> | null = null;
function TestComponent() {
hookValue = useThemeWorkbenchEntryPrompt({
activeTheme: "social-media",
contentId: "content-1",
sessionId: "session-1",
isThemeWorkbench: true,
shouldUseCompactThemeWorkbench: false,
messagesCount: 0,
initialDispatchKey: null,
initialUserPrompt: "",
initialUserImages: [],
consumedInitialPromptKey: null,
onHydrateInitialPrompt,
loadWorkflow,
loadRunState,
});
return null;
}
act(() => {
root.render(<TestComponent />);
});
try {
await flushEffects();
expect(loadWorkflow).toHaveBeenCalledWith("content-1");
expect(hookValue?.themeWorkbenchEntryPrompt).toMatchObject({
kind: "resume",
title: "发现上次未完成任务",
});
expect(hookValue?.themeWorkbenchEntryCheckPending).toBe(false);
} finally {
act(() => {
root.unmount();
});
container.remove();
}
});
});
@@ -0,0 +1,348 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
contentWorkflowApi,
type WorkflowState as ContentWorkflowState,
} from "@/lib/api/content-workflow";
import {
executionRunGetThemeWorkbenchState,
type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState,
type ThemeWorkbenchRunTerminalItem,
type ThemeWorkbenchRunTodoItem,
} from "@/lib/api/executionRun";
import type { MessageImage } from "../types";
export interface ThemeWorkbenchEntryPromptState {
kind: "initial_prompt" | "resume";
signature: string;
title: string;
description: string;
actionLabel: string;
prompt: string;
}
interface UseThemeWorkbenchEntryPromptOptions {
activeTheme: string;
contentId?: string;
sessionId?: string;
isThemeWorkbench: boolean;
shouldUseCompactThemeWorkbench: boolean;
messagesCount: number;
initialDispatchKey: string | null;
initialUserPrompt?: string;
initialUserImages?: MessageImage[];
consumedInitialPromptKey?: string | null;
onHydrateInitialPrompt: (prompt: string, dispatchKey: string) => void;
loadWorkflow?: (contentId: string) => Promise<ContentWorkflowState | null>;
loadRunState?: (
sessionId: string,
) => Promise<BackendThemeWorkbenchRunState | null>;
}
const defaultLoadThemeWorkbenchWorkflow = (contentId: string) =>
contentWorkflowApi.getByContent(contentId);
const defaultLoadThemeWorkbenchRunState = (sessionId: string) =>
executionRunGetThemeWorkbenchState(sessionId, 3);
function resolveThemeWorkbenchGateLabel(
gateKey?: ThemeWorkbenchRunTodoItem["gate_key"],
): string | null {
switch (gateKey) {
case "topic_select":
return "选题确认";
case "write_mode":
return "写作推进";
case "publish_confirm":
return "发布确认";
case "idle":
case null:
case undefined:
default:
return null;
}
}
function hasWorkflowMeaningfulProgress(
workflow: ContentWorkflowState | null,
): boolean {
if (!workflow) {
return false;
}
if (workflow.current_step_index > 0) {
return true;
}
return workflow.steps.some(
(step) =>
step.status === "completed" ||
step.status === "skipped" ||
step.status === "error" ||
Boolean(step.result),
);
}
export function buildThemeWorkbenchResumePromptFromWorkflow(
workflow: ContentWorkflowState | null,
): ThemeWorkbenchEntryPromptState | null {
if (!workflow || !hasWorkflowMeaningfulProgress(workflow)) {
return null;
}
const hasPendingStep = workflow.steps.some(
(step) => step.status !== "completed" && step.status !== "skipped",
);
if (!hasPendingStep) {
return null;
}
const activeStep =
workflow.steps.find(
(step) =>
step.status === "active" ||
step.status === "pending" ||
step.status === "error",
) || workflow.steps[workflow.current_step_index];
const stepTitle = activeStep?.title?.trim() || "当前创作阶段";
return {
kind: "resume",
signature: `workflow:${workflow.id}:${workflow.updated_at}:${activeStep?.id || ""}`,
title: "发现上次未完成任务",
description: `检测到当前文稿上次停留在“${stepTitle}”,可以直接衔接已有进度继续。`,
actionLabel: "继续上次任务",
prompt: `请基于当前文稿与已有上下文,继续推进上次未完成的任务。优先继续“${stepTitle}”阶段,不要从头重复已经完成的内容。先简要确认当前进度,再继续执行。`,
};
}
function resolveThemeWorkbenchPendingRunCandidate(
state: BackendThemeWorkbenchRunState | null,
): ThemeWorkbenchRunTodoItem | ThemeWorkbenchRunTerminalItem | null {
if (!state) {
return null;
}
const activeQueueItem = (state.queue_items || []).find((item) =>
["queued", "running", "error", "timeout"].includes(item.status),
);
if (activeQueueItem) {
return activeQueueItem;
}
if (
state.latest_terminal &&
["queued", "running", "error", "timeout"].includes(
state.latest_terminal.status,
)
) {
return state.latest_terminal;
}
return null;
}
export function buildThemeWorkbenchResumePromptFromRunState(
state: BackendThemeWorkbenchRunState | null,
): ThemeWorkbenchEntryPromptState | null {
const pendingRun = resolveThemeWorkbenchPendingRunCandidate(state);
if (!pendingRun) {
return null;
}
const runTitle = pendingRun.title?.trim() || "最近一次创作任务";
const gateLabel = resolveThemeWorkbenchGateLabel(pendingRun.gate_key);
const stageSuffix = gateLabel ? `,当前停留在“${gateLabel}”附近` : "";
return {
kind: "resume",
signature: `run:${pendingRun.run_id}:${pendingRun.status}:${pendingRun.started_at}:${"finished_at" in pendingRun ? pendingRun.finished_at || "" : ""}`,
title: "发现上次未完成任务",
description: `最近一次任务“${runTitle}”尚未完成${stageSuffix}。`,
actionLabel: "继续上次任务",
prompt: `请基于当前文稿与最近一次未完成的运行继续推进。任务标题:${runTitle}。${gateLabel ? `优先衔接“${gateLabel}”阶段。` : ""}不要从头开始,先概括已有进度,再继续执行。`,
};
}
export function useThemeWorkbenchEntryPrompt({
activeTheme,
contentId,
sessionId,
isThemeWorkbench,
shouldUseCompactThemeWorkbench,
messagesCount,
initialDispatchKey,
initialUserPrompt,
initialUserImages,
consumedInitialPromptKey,
onHydrateInitialPrompt,
loadWorkflow = defaultLoadThemeWorkbenchWorkflow,
loadRunState = defaultLoadThemeWorkbenchRunState,
}: UseThemeWorkbenchEntryPromptOptions) {
const [themeWorkbenchEntryPrompt, setThemeWorkbenchEntryPrompt] =
useState<ThemeWorkbenchEntryPromptState | null>(null);
const [themeWorkbenchEntryCheckPending, setThemeWorkbenchEntryCheckPending] =
useState(false);
const hydratedPromptSignatureRef = useRef<string | null>(null);
const dismissedPromptSignatureRef = useRef<string | null>(null);
useEffect(() => {
hydratedPromptSignatureRef.current = null;
dismissedPromptSignatureRef.current = null;
setThemeWorkbenchEntryPrompt(null);
setThemeWorkbenchEntryCheckPending(false);
}, [activeTheme, contentId, initialDispatchKey]);
useEffect(() => {
if (shouldUseCompactThemeWorkbench) {
return;
}
const pendingInitialPrompt = (initialUserPrompt || "").trim();
const pendingInitialImages = initialUserImages || [];
if (
!isThemeWorkbench ||
!contentId ||
!initialDispatchKey ||
!pendingInitialPrompt ||
pendingInitialImages.length > 0 ||
messagesCount > 0
) {
return;
}
if (
consumedInitialPromptKey === initialDispatchKey ||
hydratedPromptSignatureRef.current === initialDispatchKey
) {
return;
}
hydratedPromptSignatureRef.current = initialDispatchKey;
onHydrateInitialPrompt(pendingInitialPrompt, initialDispatchKey);
setThemeWorkbenchEntryPrompt({
kind: "initial_prompt",
signature: initialDispatchKey,
title: "已恢复待执行创作意图",
description: "进入页面后不会自动开始生成,确认后再继续。",
actionLabel: "继续生成",
prompt: pendingInitialPrompt,
});
}, [
consumedInitialPromptKey,
contentId,
initialDispatchKey,
initialUserImages,
initialUserPrompt,
isThemeWorkbench,
messagesCount,
onHydrateInitialPrompt,
shouldUseCompactThemeWorkbench,
]);
useEffect(() => {
if (shouldUseCompactThemeWorkbench) {
setThemeWorkbenchEntryCheckPending(false);
return;
}
if (
!isThemeWorkbench ||
!contentId ||
!sessionId ||
messagesCount > 0 ||
Boolean(initialDispatchKey)
) {
setThemeWorkbenchEntryCheckPending(false);
return;
}
let disposed = false;
setThemeWorkbenchEntryCheckPending(true);
void (async () => {
try {
const [workflow, backendState] = await Promise.all([
loadWorkflow(contentId).catch(() => null),
loadRunState(sessionId).catch(() => null),
]);
if (disposed) {
return;
}
const nextPrompt =
buildThemeWorkbenchResumePromptFromWorkflow(workflow) ??
buildThemeWorkbenchResumePromptFromRunState(backendState);
if (!nextPrompt) {
setThemeWorkbenchEntryPrompt((current) =>
current?.kind === "resume" ? null : current,
);
return;
}
if (dismissedPromptSignatureRef.current === nextPrompt.signature) {
return;
}
setThemeWorkbenchEntryPrompt((current) =>
current?.kind === "initial_prompt" ? current : nextPrompt,
);
} finally {
if (!disposed) {
setThemeWorkbenchEntryCheckPending(false);
}
}
})();
return () => {
disposed = true;
};
}, [
contentId,
initialDispatchKey,
isThemeWorkbench,
loadRunState,
loadWorkflow,
messagesCount,
sessionId,
shouldUseCompactThemeWorkbench,
]);
const clearThemeWorkbenchEntryPrompt = useCallback(() => {
setThemeWorkbenchEntryPrompt(null);
}, []);
const dismissThemeWorkbenchEntryPrompt = useCallback(
(options?: {
consumeInitialPrompt?: boolean;
onConsumeInitialPrompt?: () => void;
}) => {
setThemeWorkbenchEntryPrompt((current) => {
if (!current) {
return current;
}
if (
current.kind === "initial_prompt" &&
options?.consumeInitialPrompt &&
initialDispatchKey
) {
options.onConsumeInitialPrompt?.();
} else {
dismissedPromptSignatureRef.current = current.signature;
}
return null;
});
},
[initialDispatchKey],
);
return {
themeWorkbenchEntryPrompt,
themeWorkbenchEntryCheckPending,
clearThemeWorkbenchEntryPrompt,
dismissThemeWorkbenchEntryPrompt,
};
}
@@ -0,0 +1,199 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useThemeWorkbenchEntryPromptActions } from "./useThemeWorkbenchEntryPromptActions";
import type { ThemeWorkbenchEntryPromptState } from "./useThemeWorkbenchEntryPrompt";
interface HookProps {
themeWorkbenchEntryPrompt: ThemeWorkbenchEntryPromptState | null;
input: string;
initialDispatchKey: string | null;
}
interface HookHarness {
getValue: () => ReturnType<typeof useThemeWorkbenchEntryPromptActions>;
rerender: (props?: Partial<HookProps>) => void;
unmount: () => void;
onContinuePrompt: ReturnType<typeof vi.fn>;
dismissThemeWorkbenchEntryPrompt: ReturnType<typeof vi.fn>;
onConsumeInitialPrompt: ReturnType<typeof vi.fn>;
onInputChange: ReturnType<typeof vi.fn>;
onRequirePrompt: ReturnType<typeof vi.fn>;
}
function mountHook(initialProps?: Partial<HookProps>): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onContinuePrompt = vi.fn(async () => undefined);
const dismissThemeWorkbenchEntryPrompt = vi.fn();
const onConsumeInitialPrompt = vi.fn();
const onInputChange = vi.fn();
const onRequirePrompt = vi.fn();
let hookValue: ReturnType<typeof useThemeWorkbenchEntryPromptActions> | null =
null;
let currentProps: HookProps = {
themeWorkbenchEntryPrompt: null,
input: "",
initialDispatchKey: null,
...initialProps,
};
function TestComponent() {
hookValue = useThemeWorkbenchEntryPromptActions({
...currentProps,
onContinuePrompt,
dismissThemeWorkbenchEntryPrompt,
onConsumeInitialPrompt,
onInputChange,
onRequirePrompt,
});
return null;
}
const render = (nextProps?: Partial<HookProps>) => {
currentProps = {
...currentProps,
...nextProps,
};
act(() => {
root.render(<TestComponent />);
});
};
render();
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
onContinuePrompt,
dismissThemeWorkbenchEntryPrompt,
onConsumeInitialPrompt,
onInputChange,
onRequirePrompt,
};
}
describe("useThemeWorkbenchEntryPromptActions", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("继续时应优先发送当前输入,没有输入时回退到提示文案", async () => {
const harness = mountHook({
themeWorkbenchEntryPrompt: {
kind: "initial_prompt",
signature: "dispatch-1",
title: "已恢复待执行创作意图",
description: "desc",
actionLabel: "继续生成",
prompt: "请先生成主稿",
},
input: "",
initialDispatchKey: "dispatch-1",
});
try {
await act(async () => {
await harness
.getValue()
.handleContinueThemeWorkbenchEntryPrompt();
});
expect(harness.onContinuePrompt).toHaveBeenCalledWith("请先生成主稿");
harness.rerender({
input: "我已经补充了额外要求",
});
await act(async () => {
await harness
.getValue()
.handleContinueThemeWorkbenchEntryPrompt();
});
expect(harness.onContinuePrompt).toHaveBeenLastCalledWith(
"我已经补充了额外要求",
);
} finally {
harness.unmount();
}
});
it("继续时没有任何可发送内容应提示补充", async () => {
const harness = mountHook({
themeWorkbenchEntryPrompt: {
kind: "resume",
signature: "resume-1",
title: "发现上次未完成任务",
description: "desc",
actionLabel: "继续任务",
prompt: " ",
},
input: " ",
});
try {
await act(async () => {
await harness
.getValue()
.handleContinueThemeWorkbenchEntryPrompt();
});
expect(harness.onRequirePrompt).toHaveBeenCalledTimes(1);
expect(harness.onContinuePrompt).not.toHaveBeenCalled();
} finally {
harness.unmount();
}
});
it("重新开始初始提示时应消费意图并清空输入", () => {
const harness = mountHook({
themeWorkbenchEntryPrompt: {
kind: "initial_prompt",
signature: "dispatch-1",
title: "已恢复待执行创作意图",
description: "desc",
actionLabel: "继续生成",
prompt: "请先生成主稿",
},
initialDispatchKey: "dispatch-1",
input: "已有内容",
});
try {
act(() => {
harness.getValue().handleRestartThemeWorkbenchEntryPrompt();
});
expect(harness.dismissThemeWorkbenchEntryPrompt).toHaveBeenCalledTimes(1);
const options =
harness.dismissThemeWorkbenchEntryPrompt.mock.calls[0]?.[0];
expect(options?.consumeInitialPrompt).toBe(true);
options?.onConsumeInitialPrompt?.();
expect(harness.onConsumeInitialPrompt).toHaveBeenCalledWith("dispatch-1");
expect(harness.onInputChange).toHaveBeenCalledWith("");
} finally {
harness.unmount();
}
});
});
@@ -0,0 +1,71 @@
import { useCallback } from "react";
import type { ThemeWorkbenchEntryPromptState } from "./useThemeWorkbenchEntryPrompt";
interface DismissThemeWorkbenchEntryPromptOptions {
consumeInitialPrompt?: boolean;
onConsumeInitialPrompt?: () => void;
}
interface UseThemeWorkbenchEntryPromptActionsOptions {
themeWorkbenchEntryPrompt: ThemeWorkbenchEntryPromptState | null;
input: string;
initialDispatchKey: string | null;
onContinuePrompt: (prompt: string) => Promise<void> | void;
dismissThemeWorkbenchEntryPrompt: (
options?: DismissThemeWorkbenchEntryPromptOptions,
) => void;
onConsumeInitialPrompt?: (dispatchKey: string | null) => void;
onInputChange: (value: string) => void;
onRequirePrompt?: () => void;
}
export function useThemeWorkbenchEntryPromptActions({
themeWorkbenchEntryPrompt,
input,
initialDispatchKey,
onContinuePrompt,
dismissThemeWorkbenchEntryPrompt,
onConsumeInitialPrompt,
onInputChange,
onRequirePrompt,
}: UseThemeWorkbenchEntryPromptActionsOptions) {
const handleContinueThemeWorkbenchEntryPrompt = useCallback(async () => {
if (!themeWorkbenchEntryPrompt) {
return;
}
const promptToSend =
input.trim() || themeWorkbenchEntryPrompt.prompt.trim();
if (!promptToSend) {
onRequirePrompt?.();
return;
}
await onContinuePrompt(promptToSend);
}, [input, onContinuePrompt, onRequirePrompt, themeWorkbenchEntryPrompt]);
const handleRestartThemeWorkbenchEntryPrompt = useCallback(() => {
if (!themeWorkbenchEntryPrompt) {
return;
}
dismissThemeWorkbenchEntryPrompt({
consumeInitialPrompt: themeWorkbenchEntryPrompt.kind === "initial_prompt",
onConsumeInitialPrompt: () => {
onConsumeInitialPrompt?.(initialDispatchKey);
},
});
onInputChange("");
}, [
dismissThemeWorkbenchEntryPrompt,
initialDispatchKey,
onConsumeInitialPrompt,
onInputChange,
themeWorkbenchEntryPrompt,
]);
return {
handleContinueThemeWorkbenchEntryPrompt,
handleRestartThemeWorkbenchEntryPrompt,
};
}
@@ -0,0 +1,216 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
useThemeWorkbenchSendBoundary,
} from "./useThemeWorkbenchSendBoundary";
import type { BrowserTaskPreflight } from "./handleSendTypes";
interface HookHarnessProps {
isThemeWorkbench: boolean;
contentId?: string;
initialDispatchKey: string | null;
consumedInitialPromptKey: string | null;
mappedTheme: string;
browserTaskPreflight: BrowserTaskPreflight | null;
isBrowserAssistReady: boolean;
}
interface HookHarness {
getValue: () => ReturnType<typeof useThemeWorkbenchSendBoundary>;
rerender: (props?: Partial<HookHarnessProps>) => void;
unmount: () => void;
onConsumeInitialPrompt: ReturnType<typeof vi.fn>;
onResetConsumedInitialPrompt: ReturnType<typeof vi.fn>;
onClearEntryPrompt: ReturnType<typeof vi.fn>;
onPrepareBrowserTaskPreflight: ReturnType<typeof vi.fn>;
}
function createPreflight(): BrowserTaskPreflight {
return {
requestId: "browser-preflight:existing",
createdAt: 123,
sourceText: "旧任务",
images: [],
requirement: "required_with_user_step",
reason: "需要浏览器",
phase: "awaiting_user",
launchUrl: "https://mp.weixin.qq.com/",
};
}
function mountHook(initialProps?: Partial<HookHarnessProps>): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onConsumeInitialPrompt = vi.fn();
const onResetConsumedInitialPrompt = vi.fn();
const onClearEntryPrompt = vi.fn();
const onPrepareBrowserTaskPreflight = vi.fn();
let hookValue: ReturnType<typeof useThemeWorkbenchSendBoundary> | null = null;
let currentProps: HookHarnessProps = {
isThemeWorkbench: true,
contentId: "content-1",
initialDispatchKey: "dispatch-1",
consumedInitialPromptKey: null,
mappedTheme: "social-media",
browserTaskPreflight: null,
isBrowserAssistReady: true,
...initialProps,
};
function TestComponent() {
hookValue = useThemeWorkbenchSendBoundary({
...currentProps,
initialUserImages: [],
socialArticleSkillKey: "social_post_with_cover",
onConsumeInitialPrompt,
onResetConsumedInitialPrompt,
onClearEntryPrompt,
onPrepareBrowserTaskPreflight,
});
return null;
}
const render = (nextProps?: Partial<HookHarnessProps>) => {
currentProps = {
...currentProps,
...nextProps,
};
act(() => {
root.render(<TestComponent />);
});
};
render();
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
onConsumeInitialPrompt,
onResetConsumedInitialPrompt,
onClearEntryPrompt,
onPrepareBrowserTaskPreflight,
};
}
describe("useThemeWorkbenchSendBoundary", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("应识别社媒主题工作台的首条意图消费与技能前缀", () => {
const harness = mountHook();
try {
const boundary = harness.getValue().resolveSendBoundary({
sourceText: "请生成今天的社媒主稿",
});
expect(boundary.sourceText).toBe(
"/social_post_with_cover 请生成今天的社媒主稿",
);
expect(boundary.shouldConsumePendingThemeWorkbenchInitialPrompt).toBe(
true,
);
expect(boundary.shouldDismissThemeWorkbenchEntryPrompt).toBe(true);
expect(boundary.browserRequirementMatch).toBeNull();
} finally {
harness.unmount();
}
});
it("已有浏览器前置引导时应阻止未确认发送", () => {
const harness = mountHook({
mappedTheme: "general",
browserTaskPreflight: createPreflight(),
});
try {
expect(harness.getValue().isBlockedByBrowserPreflight()).toBe(true);
expect(
harness.getValue().isBlockedByBrowserPreflight({
browserPreflightConfirmed: true,
}),
).toBe(false);
} finally {
harness.unmount();
}
});
it("需要真实浏览器时应创建前置引导并同步消费初始意图", () => {
const harness = mountHook({
mappedTheme: "general",
isBrowserAssistReady: false,
});
try {
const boundary = harness.getValue().resolveSendBoundary({
sourceText: "帮我把这篇文章发布到微信公众号后台",
});
const started = harness.getValue().maybeStartBrowserTaskPreflight({
boundary,
images: [],
webSearch: false,
thinking: false,
sendExecutionStrategy: "auto",
});
expect(started).toBe(true);
expect(harness.onPrepareBrowserTaskPreflight).toHaveBeenCalledWith(
expect.objectContaining({
sourceText: "帮我把这篇文章发布到微信公众号后台",
requirement: "required_with_user_step",
launchUrl: "https://mp.weixin.qq.com/",
phase: "launching",
}),
);
expect(harness.onConsumeInitialPrompt).toHaveBeenCalledWith("dispatch-1");
expect(harness.onClearEntryPrompt).toHaveBeenCalledTimes(1);
} finally {
harness.unmount();
}
});
it("发送失败时应回滚已消费的首条意图", () => {
const harness = mountHook();
try {
const boundary = harness.getValue().resolveSendBoundary({
sourceText: "请生成今天的社媒主稿",
});
act(() => {
harness.getValue().rollbackAfterSendFailure(boundary);
});
expect(harness.onResetConsumedInitialPrompt).toHaveBeenCalledTimes(1);
} finally {
harness.unmount();
}
});
});
@@ -0,0 +1,277 @@
import { useCallback } from "react";
import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime";
import type { BrowserTaskRequirementMatch } from "../utils/browserTaskRequirement";
import { detectBrowserTaskRequirement } from "../utils/browserTaskRequirement";
import type { MessageImage } from "../types";
import type { BrowserTaskPreflight, HandleSendOptions } from "./handleSendTypes";
interface BuildThemeWorkbenchSendBoundaryStateOptions {
isThemeWorkbench: boolean;
contentId?: string;
initialDispatchKey: string | null;
consumedInitialPromptKey: string | null;
initialUserImages?: MessageImage[];
mappedTheme: string;
socialArticleSkillKey: string;
sourceText: string;
sendOptions?: HandleSendOptions;
}
export interface ThemeWorkbenchSendBoundaryState {
sourceText: string;
browserRequirementMatch: BrowserTaskRequirementMatch | null;
shouldConsumePendingThemeWorkbenchInitialPrompt: boolean;
shouldDismissThemeWorkbenchEntryPrompt: boolean;
}
interface CreateBrowserTaskPreflightOptions {
sourceText: string;
images?: MessageImage[];
webSearch?: boolean;
thinking?: boolean;
sendExecutionStrategy?: "react" | "code_orchestrated" | "auto";
autoContinuePayload?: AutoContinueRequestPayload;
sendOptions?: HandleSendOptions;
browserRequirementMatch: BrowserTaskRequirementMatch;
createRequestId?: () => string;
now?: () => number;
}
interface UseThemeWorkbenchSendBoundaryOptions {
isThemeWorkbench: boolean;
contentId?: string;
initialDispatchKey: string | null;
consumedInitialPromptKey: string | null;
initialUserImages?: MessageImage[];
mappedTheme: string;
socialArticleSkillKey: string;
isBrowserAssistReady: boolean;
browserTaskPreflight: BrowserTaskPreflight | null;
onConsumeInitialPrompt: (dispatchKey: string) => void;
onResetConsumedInitialPrompt: () => void;
onClearEntryPrompt: () => void;
onPrepareBrowserTaskPreflight: (preflight: BrowserTaskPreflight) => void;
}
interface StartBrowserTaskPreflightOptions {
boundary: ThemeWorkbenchSendBoundaryState;
images?: MessageImage[];
webSearch?: boolean;
thinking?: boolean;
sendExecutionStrategy?: "react" | "code_orchestrated" | "auto";
autoContinuePayload?: AutoContinueRequestPayload;
sendOptions?: HandleSendOptions;
}
function defaultCreateBrowserTaskPreflightRequestId() {
return `browser-preflight:${crypto.randomUUID()}`;
}
export function buildThemeWorkbenchSendBoundaryState({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey,
initialUserImages,
mappedTheme,
socialArticleSkillKey,
sourceText,
sendOptions,
}: BuildThemeWorkbenchSendBoundaryStateOptions): ThemeWorkbenchSendBoundaryState {
const shouldConsumePendingThemeWorkbenchInitialPrompt =
isThemeWorkbench &&
Boolean(contentId) &&
Boolean(initialDispatchKey) &&
consumedInitialPromptKey !== initialDispatchKey &&
(initialUserImages || []).length === 0 &&
!sendOptions?.purpose;
const shouldDismissThemeWorkbenchEntryPrompt =
isThemeWorkbench && !sendOptions?.purpose;
let nextSourceText = sourceText;
if (
isThemeWorkbench &&
mappedTheme === "social-media" &&
nextSourceText.trim() &&
!nextSourceText.trimStart().startsWith("/") &&
!sendOptions?.skipThemeSkillPrefix
) {
nextSourceText = `/${socialArticleSkillKey} ${nextSourceText}`.trim();
}
const browserRequirementMatch =
mappedTheme === "general" && !sendOptions?.purpose
? detectBrowserTaskRequirement(nextSourceText)
: null;
return {
sourceText: nextSourceText,
browserRequirementMatch,
shouldConsumePendingThemeWorkbenchInitialPrompt,
shouldDismissThemeWorkbenchEntryPrompt,
};
}
export function createBrowserTaskPreflight({
sourceText,
images,
webSearch,
thinking,
sendExecutionStrategy,
autoContinuePayload,
sendOptions,
browserRequirementMatch,
createRequestId = defaultCreateBrowserTaskPreflightRequestId,
now = () => Date.now(),
}: CreateBrowserTaskPreflightOptions): BrowserTaskPreflight {
return {
requestId: createRequestId(),
createdAt: now(),
sourceText,
images: images || [],
webSearch,
thinking,
sendExecutionStrategy,
autoContinuePayload,
sendOptions,
requirement: browserRequirementMatch.requirement,
reason: browserRequirementMatch.reason,
phase: "launching",
launchUrl: browserRequirementMatch.launchUrl,
platformLabel: browserRequirementMatch.platformLabel,
detail: "正在尝试建立浏览器会话,请稍候...",
};
}
export function useThemeWorkbenchSendBoundary({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey,
initialUserImages,
mappedTheme,
socialArticleSkillKey,
isBrowserAssistReady,
browserTaskPreflight,
onConsumeInitialPrompt,
onResetConsumedInitialPrompt,
onClearEntryPrompt,
onPrepareBrowserTaskPreflight,
}: UseThemeWorkbenchSendBoundaryOptions) {
const resolveSendBoundary = useCallback(
({ sourceText, sendOptions }: { sourceText: string; sendOptions?: HandleSendOptions }) =>
buildThemeWorkbenchSendBoundaryState({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey,
initialUserImages,
mappedTheme,
socialArticleSkillKey,
sourceText,
sendOptions,
}),
[
consumedInitialPromptKey,
contentId,
initialDispatchKey,
initialUserImages,
isThemeWorkbench,
mappedTheme,
socialArticleSkillKey,
],
);
const isBlockedByBrowserPreflight = useCallback(
(sendOptions?: HandleSendOptions) =>
Boolean(browserTaskPreflight) && !sendOptions?.browserPreflightConfirmed,
[browserTaskPreflight],
);
const maybeStartBrowserTaskPreflight = useCallback(
({
boundary,
images,
webSearch,
thinking,
sendExecutionStrategy,
autoContinuePayload,
sendOptions,
}: StartBrowserTaskPreflightOptions) => {
if (
!boundary.browserRequirementMatch ||
sendOptions?.browserPreflightConfirmed ||
isBrowserAssistReady
) {
return false;
}
const preflight = createBrowserTaskPreflight({
sourceText: boundary.sourceText,
images,
webSearch,
thinking,
sendExecutionStrategy,
autoContinuePayload,
sendOptions,
browserRequirementMatch: boundary.browserRequirementMatch,
});
onPrepareBrowserTaskPreflight(preflight);
if (
boundary.shouldConsumePendingThemeWorkbenchInitialPrompt &&
initialDispatchKey
) {
onConsumeInitialPrompt(initialDispatchKey);
}
if (boundary.shouldDismissThemeWorkbenchEntryPrompt) {
onClearEntryPrompt();
}
return true;
},
[
initialDispatchKey,
isBrowserAssistReady,
onClearEntryPrompt,
onConsumeInitialPrompt,
onPrepareBrowserTaskPreflight,
],
);
const finalizeAfterSendSuccess = useCallback(
(boundary: ThemeWorkbenchSendBoundaryState) => {
if (
boundary.shouldConsumePendingThemeWorkbenchInitialPrompt &&
initialDispatchKey
) {
onConsumeInitialPrompt(initialDispatchKey);
}
if (boundary.shouldDismissThemeWorkbenchEntryPrompt) {
onClearEntryPrompt();
}
},
[initialDispatchKey, onClearEntryPrompt, onConsumeInitialPrompt],
);
const rollbackAfterSendFailure = useCallback(
(boundary: ThemeWorkbenchSendBoundaryState) => {
if (boundary.shouldConsumePendingThemeWorkbenchInitialPrompt) {
onResetConsumedInitialPrompt();
}
},
[onResetConsumedInitialPrompt],
);
return {
resolveSendBoundary,
isBlockedByBrowserPreflight,
maybeStartBrowserTaskPreflight,
finalizeAfterSendSuccess,
rollbackAfterSendFailure,
};
}
@@ -1,29 +1,19 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
getOverview,
getProviders,
loadConfiguredProviders,
getModelRegistry,
getAllAliasConfigs,
syncTrayModelShortcuts,
} = vi.hoisted(() => ({
getOverview: vi.fn(),
getProviders: vi.fn(),
loadConfiguredProviders: vi.fn(),
getModelRegistry: vi.fn(),
getAllAliasConfigs: vi.fn(),
syncTrayModelShortcuts: vi.fn(),
}));
vi.mock("@/lib/api/providerPool", () => ({
providerPoolApi: {
getOverview,
},
}));
vi.mock("@/lib/api/apiKeyProvider", () => ({
apiKeyProviderApi: {
getProviders,
},
vi.mock("@/hooks/useConfiguredProviders", () => ({
loadConfiguredProviders,
}));
vi.mock("@/lib/api/modelRegistry", () => ({
@@ -68,20 +58,12 @@ describe("buildTrayPayload", () => {
vi.clearAllMocks();
invalidateTrayPayloadCache();
getOverview.mockResolvedValue([
loadConfiguredProviders.mockResolvedValue([
{
provider_type: "deepseek",
credentials: [{ credential_type: "deepseek" }],
},
]);
getProviders.mockResolvedValue([
{
id: "deepseek",
name: "DeepSeek",
key: "deepseek",
label: "DeepSeek",
registryId: "deepseek",
type: "deepseek",
enabled: true,
api_key_count: 1,
custom_models: [],
},
]);
getModelRegistry.mockResolvedValue([
@@ -107,8 +89,7 @@ describe("buildTrayPayload", () => {
);
expect(second).toEqual(first);
expect(getOverview).toHaveBeenCalledTimes(1);
expect(getProviders).toHaveBeenCalledTimes(1);
expect(loadConfiguredProviders).toHaveBeenCalledTimes(1);
expect(getModelRegistry).toHaveBeenCalledTimes(1);
expect(getAllAliasConfigs).toHaveBeenCalledTimes(1);
});
@@ -119,8 +100,7 @@ describe("buildTrayPayload", () => {
forceRefresh: true,
});
expect(getOverview).toHaveBeenCalledTimes(2);
expect(getProviders).toHaveBeenCalledTimes(2);
expect(loadConfiguredProviders).toHaveBeenCalledTimes(2);
expect(getModelRegistry).toHaveBeenCalledTimes(2);
expect(getAllAliasConfigs).toHaveBeenCalledTimes(2);
});
@@ -132,6 +112,49 @@ describe("buildTrayPayload", () => {
expect(syncTrayModelShortcuts).toHaveBeenCalledTimes(1);
});
it("当 provider 仅命中 fallbackRegistryId 时仍应保留托盘候选模型", async () => {
loadConfiguredProviders.mockResolvedValueOnce([
{
key: "custom-openai",
label: "Custom OpenAI",
registryId: "custom-openai",
fallbackRegistryId: "openai",
type: "openai",
},
]);
getModelRegistry.mockResolvedValueOnce([
{
id: "gpt-4.1",
display_name: "GPT-4.1",
provider_id: "openai",
provider_name: "OpenAI",
is_latest: true,
release_date: "2026-02-01",
},
]);
const payload = await buildTrayPayload(
"custom-openai",
"gpt-4.1",
"general",
{ forceRefresh: true },
);
expect(payload.quick_model_groups).toEqual([
{
provider_type: "custom-openai",
provider_label: "Custom OpenAI",
models: [
{
provider_type: "custom-openai",
provider_label: "Custom OpenAI",
model: "gpt-4.1",
},
],
},
]);
});
it("首次同步失败时不应缓存成功指纹,后续重试仍应继续同步", async () => {
syncTrayModelShortcuts
.mockRejectedValueOnce(new Error("tray unavailable"))
@@ -6,26 +6,21 @@ import {
type TrayModelSelectedPayload,
type TrayQuickModelGroup,
} from "@/lib/api/tray";
import {
apiKeyProviderApi,
type ProviderWithKeysDisplay,
} from "@/lib/api/apiKeyProvider";
import {
providerPoolApi,
type ProviderPoolOverview,
} from "@/lib/api/providerPool";
import { modelRegistryApi } from "@/lib/api/modelRegistry";
import { trayApi } from "@/lib/api/tray";
import {
getAliasConfigKey,
getProviderLabel,
getRegistryIdFromType,
isAliasProvider,
} from "@/lib/constants/providerMappings";
import type {
EnhancedModelMetadata,
ProviderAliasConfig,
} from "@/lib/types/modelRegistry";
import {
loadConfiguredProviders,
type ConfiguredProvider,
} from "@/hooks/useConfiguredProviders";
import { buildProviderModelsFromRegistry } from "@/hooks/useProviderModels";
import { filterModelsByTheme } from "../utils/modelThemePolicy";
import { getProviderModelCompatibilityIssue } from "../utils/providerModelCompatibility";
@@ -38,16 +33,6 @@ interface UseTrayModelShortcutsOptions {
deferInitialSync?: boolean;
}
interface ConfiguredProvider {
key: string;
label: string;
registryId: string;
fallbackRegistryId?: string;
type: string;
providerId?: string;
customModels?: string[];
}
const MAX_TRAY_MODELS_PER_PROVIDER = 8;
const TRAY_PAYLOAD_CACHE_TTL_MS = 3_000;
const TRAY_SYNC_IDLE_TIMEOUT_MS = 1_500;
@@ -80,172 +65,6 @@ const THEME_LABEL_MAP: Record<string, string> = {
novel: "小说创作",
};
function sortModels(models: EnhancedModelMetadata[]): EnhancedModelMetadata[] {
return [...models].sort((a, b) => {
if (a.is_latest && !b.is_latest) return -1;
if (!a.is_latest && b.is_latest) return 1;
if (a.release_date && b.release_date) {
return b.release_date.localeCompare(a.release_date);
}
if (a.release_date && !b.release_date) return -1;
if (!a.release_date && b.release_date) return 1;
return a.display_name.localeCompare(b.display_name);
});
}
function convertCustomModelsToMetadata(
models: string[],
providerId: string,
providerName: string,
): EnhancedModelMetadata[] {
const now = Date.now() / 1000;
return models.map((modelName) => ({
id: modelName,
display_name: modelName,
provider_id: providerId,
provider_name: providerName,
family: null,
tier: "pro",
capabilities: {
vision: false,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: modelName.includes("thinking"),
},
pricing: null,
limits: {
context_length: null,
max_output_tokens: null,
requests_per_minute: null,
tokens_per_minute: null,
},
status: "active",
release_date: null,
is_latest: false,
description: `自定义模型: ${modelName}`,
source: "custom",
created_at: now,
updated_at: now,
}));
}
function convertAliasModelsToMetadata(
models: string[],
aliasConfig: ProviderAliasConfig,
providerId: string,
providerName: string,
): EnhancedModelMetadata[] {
const now = Date.now() / 1000;
return models.map((modelName) => {
const aliasInfo = aliasConfig.aliases[modelName];
return {
id: modelName,
display_name: modelName,
provider_id: providerId,
provider_name: providerName,
family: aliasInfo?.provider || null,
tier: "pro" as const,
capabilities: {
vision: false,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: modelName.includes("thinking"),
},
pricing: null,
limits: {
context_length: null,
max_output_tokens: null,
requests_per_minute: null,
tokens_per_minute: null,
},
status: "active" as const,
release_date: null,
is_latest: false,
description: aliasInfo?.description || aliasInfo?.actual || modelName,
source: "custom" as const,
created_at: now,
updated_at: now,
};
});
}
function buildConfiguredProviders(
oauthCredentials: ProviderPoolOverview[],
apiKeyProviders: ProviderWithKeysDisplay[],
): ConfiguredProvider[] {
const providerMap = new Map<string, ConfiguredProvider>();
oauthCredentials.forEach((overview) => {
if (overview.credentials.length === 0) {
return;
}
const key = overview.provider_type;
if (providerMap.has(key)) {
return;
}
providerMap.set(key, {
key,
label: getProviderLabel(key),
registryId: getRegistryIdFromType(key),
type: key,
});
});
apiKeyProviders
.filter((provider) => provider.api_key_count > 0 && provider.enabled)
.forEach((provider) => {
let key = provider.id;
let label = provider.name;
if (providerMap.has(key)) {
key = `${provider.id}_api_key`;
label = `${provider.name} API Key`;
}
if (providerMap.has(key)) {
return;
}
providerMap.set(key, {
key,
label,
registryId: provider.id,
fallbackRegistryId: getRegistryIdFromType(provider.type),
type: provider.type,
providerId: provider.id,
customModels: provider.custom_models,
});
});
return Array.from(providerMap.values());
}
function dedupeModels(
models: EnhancedModelMetadata[],
): EnhancedModelMetadata[] {
const seen = new Set<string>();
const result: EnhancedModelMetadata[] = [];
models.forEach((model) => {
const normalized = model.id.trim().toLowerCase();
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
result.push(model);
});
return result;
}
function getTrayPayloadSignature(
providerType: string,
model: string,
@@ -292,55 +111,14 @@ function scheduleTrayModelSync(task: () => void): () => void {
function resolveProviderModels(
provider: ConfiguredProvider,
registryModels: EnhancedModelMetadata[],
registryModels: Parameters<typeof buildProviderModelsFromRegistry>[1],
aliasConfigs: Record<string, ProviderAliasConfig>,
): EnhancedModelMetadata[] {
const combined: EnhancedModelMetadata[] = [];
if (provider.customModels?.length) {
combined.push(
...convertCustomModelsToMetadata(
provider.customModels,
provider.key,
provider.label,
),
);
}
if (isAliasProvider(provider.key)) {
const aliasConfig = aliasConfigs[getAliasConfigKey(provider.key)];
if (aliasConfig) {
combined.push(
...convertAliasModelsToMetadata(
aliasConfig.models,
aliasConfig,
provider.key,
provider.label,
),
);
}
return dedupeModels(combined);
}
const registryMatches = sortModels(
registryModels.filter((item) => item.provider_id === provider.registryId),
);
if (registryMatches.length > 0) {
combined.push(...registryMatches);
return dedupeModels(combined);
}
if (provider.fallbackRegistryId) {
combined.push(
...sortModels(
registryModels.filter(
(item) => item.provider_id === provider.fallbackRegistryId,
),
),
);
}
return dedupeModels(combined);
) {
return buildProviderModelsFromRegistry(
provider,
registryModels,
aliasConfigs[getAliasConfigKey(provider.key)] ?? null,
).models;
}
function resolveThemeLabel(theme?: string): string {
@@ -476,34 +254,23 @@ export async function buildTrayPayload(
const payloadPromise = (async () => {
const sourceOptions = forceRefresh ? { forceRefresh: true } : undefined;
const [oauthCredentials, apiKeyProviders, registryModels, aliasConfigs] =
await Promise.all([
loadTraySource(
() => providerPoolApi.getOverview(sourceOptions),
[] as ProviderPoolOverview[],
"OAuth Provider 概览",
),
loadTraySource(
() => apiKeyProviderApi.getProviders(sourceOptions),
[] as ProviderWithKeysDisplay[],
"API Key Provider 列表",
),
loadTraySource(
() => modelRegistryApi.getModelRegistry(sourceOptions),
[] as EnhancedModelMetadata[],
"模型注册表",
),
loadTraySource(
() => modelRegistryApi.getAllAliasConfigs(sourceOptions),
{} as Record<string, ProviderAliasConfig>,
"别名模型配置",
),
]);
const providers = buildConfiguredProviders(
oauthCredentials,
apiKeyProviders,
);
const [providers, registryModels, aliasConfigs] = await Promise.all([
loadTraySource(
() => loadConfiguredProviders({ forceRefresh }),
[] as ConfiguredProvider[],
"已配置 Provider 列表",
),
loadTraySource(
() => modelRegistryApi.getModelRegistry(sourceOptions),
[] as EnhancedModelMetadata[],
"模型注册表",
),
loadTraySource(
() => modelRegistryApi.getAllAliasConfigs(sourceOptions),
{} as Record<string, ProviderAliasConfig>,
"别名模型配置",
),
]);
const currentProvider =
providers.find((item) => item.key === providerType) || null;
@@ -585,6 +352,25 @@ export function useTrayModelShortcuts({
}: UseTrayModelShortcutsOptions) {
const lastSyncedSignatureRef = useRef<string>("");
const initialSyncHandledRef = useRef(false);
const latestSelectionRef = useRef({
providerType,
model,
});
const latestMutatorsRef = useRef({
setProviderType,
setModel,
});
useEffect(() => {
latestSelectionRef.current = {
providerType,
model,
};
latestMutatorsRef.current = {
setProviderType,
setModel,
};
}, [model, providerType, setModel, setProviderType]);
useEffect(() => {
const normalizedProviderType = providerType.trim();
@@ -648,17 +434,22 @@ export function useTrayModelShortcuts({
const nextProviderType = event.payload?.providerType?.trim() || "";
const nextModel = event.payload?.model?.trim() || "";
const currentSelection = latestSelectionRef.current;
const currentMutators = latestMutatorsRef.current;
if (!nextModel) {
return;
}
if (nextProviderType && nextProviderType !== providerType) {
setProviderType(nextProviderType);
if (
nextProviderType &&
nextProviderType !== currentSelection.providerType
) {
currentMutators.setProviderType(nextProviderType);
}
if (nextModel !== model) {
setModel(nextModel);
if (nextModel !== currentSelection.model) {
currentMutators.setModel(nextModel);
}
})
.then((unlisten) => {
@@ -678,5 +469,5 @@ export function useTrayModelShortcuts({
dispose();
}
};
}, [model, providerType, setModel, setProviderType]);
}, []);
}
@@ -0,0 +1,166 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { LAST_PROJECT_ID_KEY } from "./agentProjectStorage";
import { useWorkspaceProjectSelection } from "./useWorkspaceProjectSelection";
interface HookHarness {
getValue: () => ReturnType<typeof useWorkspaceProjectSelection>;
rerender: (props?: {
externalProjectId?: string | null;
newChatAt?: number;
}) => void;
unmount: () => void;
}
function mountHook(
initialProps: {
externalProjectId?: string | null;
newChatAt?: number;
} = {},
): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof useWorkspaceProjectSelection> | null = null;
function TestComponent(props: {
externalProjectId?: string | null;
newChatAt?: number;
}) {
hookValue = useWorkspaceProjectSelection(props);
return null;
}
const render = (props?: {
externalProjectId?: string | null;
newChatAt?: number;
}) => {
act(() => {
root.render(<TestComponent {...props} />);
});
};
render(initialProps);
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
rerender: render,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
describe("useWorkspaceProjectSelection", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it("应在新会话请求未处理前屏蔽最近项目恢复", () => {
localStorage.setItem(LAST_PROJECT_ID_KEY, JSON.stringify("project-local"));
const harness = mountHook({
newChatAt: 123,
});
try {
expect(harness.getValue().projectId).toBeUndefined();
expect(harness.getValue().shouldDisableSessionRestore).toBe(true);
act(() => {
harness.getValue().markNewChatRequestHandled("123");
harness.getValue().applyProjectSelection(null);
});
expect(harness.getValue().projectId).toBeUndefined();
} finally {
harness.unmount();
}
});
it("应统一处理项目选择与最近项目记忆", () => {
const harness = mountHook();
try {
act(() => {
harness.getValue().applyProjectSelection("project-a");
});
expect(harness.getValue().projectId).toBe("project-a");
expect(harness.getValue().getRememberedProjectId()).toBe("project-a");
act(() => {
harness.getValue().resetProjectSelection();
});
expect(harness.getValue().projectId).toBeUndefined();
expect(harness.getValue().getRememberedProjectId()).toBe("project-a");
} finally {
harness.unmount();
}
});
it("应在项目切换完成后恢复待执行的任务切换", () => {
const harness = mountHook();
try {
expect(harness.getValue().startTopicProjectResolution()).toBe(true);
expect(harness.getValue().startTopicProjectResolution()).toBe(false);
act(() => {
harness.getValue().deferTopicSwitch("topic-1", "project-target");
});
expect(harness.getValue().projectId).toBe("project-target");
expect(
harness.getValue().consumePendingTopicSwitch("project-other"),
).toBeNull();
expect(harness.getValue().consumePendingTopicSwitch("project-target")).toEqual({
topicId: "topic-1",
targetProjectId: "project-target",
});
harness.getValue().finishTopicProjectResolution();
expect(harness.getValue().startTopicProjectResolution()).toBe(true);
} finally {
harness.unmount();
}
});
it("有外部项目锁定时不应覆盖当前项目选择", () => {
const harness = mountHook({
externalProjectId: "project-external",
});
try {
expect(harness.getValue().projectId).toBe("project-external");
act(() => {
harness.getValue().applyProjectSelection("project-local");
});
expect(harness.getValue().projectId).toBe("project-external");
expect(harness.getValue().getRememberedProjectId()).toBeNull();
} finally {
harness.unmount();
}
});
});
@@ -0,0 +1,163 @@
import { useCallback, useRef, useState } from "react";
import { normalizeProjectId } from "../utils/topicProjectResolution";
import {
LAST_PROJECT_ID_KEY,
loadPersistedProjectId,
savePersistedProjectId,
} from "./agentProjectStorage";
interface PendingTopicSwitchState {
topicId: string;
targetProjectId: string;
}
interface UseWorkspaceProjectSelectionOptions {
externalProjectId?: string | null;
newChatAt?: number;
storageKey?: string;
}
export function useWorkspaceProjectSelection(
options: UseWorkspaceProjectSelectionOptions = {},
) {
const {
externalProjectId,
newChatAt,
storageKey = LAST_PROJECT_ID_KEY,
} = options;
const normalizedExternalProjectId = normalizeProjectId(externalProjectId);
const [internalProjectId, setInternalProjectId] = useState<string | null>(
null,
);
const handledNewChatRequestRef = useRef<string | null>(null);
const pendingTopicSwitchRef = useRef<PendingTopicSwitchState | null>(null);
const isResolvingTopicProjectRef = useRef(false);
const incomingNewChatRequestKey =
typeof newChatAt === "number" ? String(newChatAt) : null;
const shouldDisableSessionRestore = incomingNewChatRequestKey !== null;
const shouldResetToFreshHomeContext =
!normalizedExternalProjectId &&
incomingNewChatRequestKey !== null &&
handledNewChatRequestRef.current !== incomingNewChatRequestKey;
const projectId =
normalizedExternalProjectId ??
(shouldResetToFreshHomeContext ? undefined : internalProjectId) ??
undefined;
const hasHandledNewChatRequest = useCallback(
(requestKey: string) => handledNewChatRequestRef.current === requestKey,
[],
);
const markNewChatRequestHandled = useCallback((requestKey: string) => {
handledNewChatRequestRef.current = requestKey;
}, []);
const clearProjectSelectionRuntime = useCallback(() => {
pendingTopicSwitchRef.current = null;
isResolvingTopicProjectRef.current = false;
}, []);
const rememberProjectId = useCallback(
(nextProjectId?: string | null) => {
const normalizedProjectId = normalizeProjectId(nextProjectId);
if (!normalizedProjectId) {
return;
}
savePersistedProjectId(storageKey, normalizedProjectId);
},
[storageKey],
);
const resetProjectSelection = useCallback(() => {
clearProjectSelectionRuntime();
setInternalProjectId(null);
}, [clearProjectSelectionRuntime]);
const applyProjectSelection = useCallback(
(nextProjectId?: string | null) => {
if (normalizedExternalProjectId) {
return;
}
const normalizedProjectId = normalizeProjectId(nextProjectId);
clearProjectSelectionRuntime();
rememberProjectId(normalizedProjectId);
setInternalProjectId(normalizedProjectId);
},
[clearProjectSelectionRuntime, normalizedExternalProjectId, rememberProjectId],
);
const startTopicProjectResolution = useCallback(() => {
if (isResolvingTopicProjectRef.current) {
return false;
}
isResolvingTopicProjectRef.current = true;
return true;
}, []);
const finishTopicProjectResolution = useCallback(() => {
isResolvingTopicProjectRef.current = false;
}, []);
const deferTopicSwitch = useCallback(
(topicId: string, targetProjectId: string) => {
const normalizedTargetProjectId = normalizeProjectId(targetProjectId);
if (!normalizedTargetProjectId) {
pendingTopicSwitchRef.current = null;
return;
}
rememberProjectId(normalizedTargetProjectId);
pendingTopicSwitchRef.current = {
topicId,
targetProjectId: normalizedTargetProjectId,
};
setInternalProjectId(normalizedTargetProjectId);
},
[rememberProjectId],
);
const consumePendingTopicSwitch = useCallback(
(currentProjectId?: string | null) => {
const pending = pendingTopicSwitchRef.current;
if (!pending) {
return null;
}
const normalizedCurrentProjectId = normalizeProjectId(currentProjectId);
if (normalizedCurrentProjectId !== pending.targetProjectId) {
return null;
}
pendingTopicSwitchRef.current = null;
return pending;
},
[],
);
const getRememberedProjectId = useCallback(
() => loadPersistedProjectId(storageKey),
[storageKey],
);
return {
projectId,
shouldDisableSessionRestore,
hasHandledNewChatRequest,
markNewChatRequestHandled,
rememberProjectId,
getRememberedProjectId,
applyProjectSelection,
resetProjectSelection,
clearProjectSelectionRuntime,
startTopicProjectResolution,
finishTopicProjectResolution,
deferTopicSwitch,
consumePendingTopicSwitch,
};
}
+691 -15
View File
@@ -13,6 +13,7 @@ const {
mockUseTopicBranchBoard,
mockUseTeamWorkspaceRuntime,
mockUseCompatSubagentRuntime,
mockGenerateEphemeralTeamWithModel,
mockGetProject,
mockGetDefaultProject,
mockGetOrCreateDefaultProject,
@@ -36,6 +37,8 @@ const {
mockExecutionRunGetThemeWorkbenchState,
mockExecutionRunListThemeWorkbenchHistory,
mockExecutionRunGet,
mockContentWorkflowCreate,
mockContentWorkflowGetByContent,
mockSkillExecutionGetDetail,
mockSkillsGetAll,
mockSkillsGetLocal,
@@ -50,6 +53,7 @@ const {
mockUseTopicBranchBoard: vi.fn(),
mockUseTeamWorkspaceRuntime: vi.fn(),
mockUseCompatSubagentRuntime: vi.fn(),
mockGenerateEphemeralTeamWithModel: vi.fn(),
mockGetProject: vi.fn(),
mockGetDefaultProject: vi.fn(),
mockGetOrCreateDefaultProject: vi.fn(),
@@ -79,15 +83,21 @@ const {
mockEmptyState: vi.fn((props?: { input?: string }) => (
<div data-testid="empty-state">{props?.input || ""}</div>
)),
mockInputbar: vi.fn((_props?: Record<string, unknown>) => (
<div data-testid="inputbar" />
)),
mockInputbar: vi.fn(
(props?: { overlayAccessory?: ReactNode; input?: string }) => (
<div data-testid="inputbar" data-input={props?.input || ""}>
{props?.overlayAccessory}
</div>
),
),
mockMessageList: vi.fn((_props?: Record<string, unknown>) => (
<div data-testid="message-list" />
)),
mockExecutionRunGetThemeWorkbenchState: vi.fn(),
mockExecutionRunListThemeWorkbenchHistory: vi.fn(),
mockExecutionRunGet: vi.fn(),
mockContentWorkflowCreate: vi.fn(),
mockContentWorkflowGetByContent: vi.fn(),
mockSkillExecutionGetDetail: vi.fn(),
mockSkillsGetAll: vi.fn(),
mockSkillsGetLocal: vi.fn(),
@@ -153,6 +163,10 @@ vi.mock("./hooks", () => ({
useCompatSubagentRuntime: mockUseCompatSubagentRuntime,
}));
vi.mock("./utils/teamAutoGeneration", () => ({
generateEphemeralTeamWithModel: mockGenerateEphemeralTeamWithModel,
}));
vi.mock("./hooks/useSessionFiles", () => ({
useSessionFiles: () => ({
saveFile: vi.fn(async () => undefined),
@@ -405,11 +419,18 @@ vi.mock("./components/TeamWorkspaceDock", () => ({
withBottomOverlay,
shellVisible,
childSubagentSessions,
runtimeTeamState,
onActivateWorkbench,
}: {
placement?: "floating" | "inline";
withBottomOverlay?: boolean;
shellVisible?: boolean;
childSubagentSessions?: Array<{ id: string }>;
runtimeTeamState?: {
status?: string;
members?: Array<{ id: string }>;
} | null;
onActivateWorkbench?: () => void;
}) => (
<div
data-testid="team-workspace-dock"
@@ -417,7 +438,21 @@ vi.mock("./components/TeamWorkspaceDock", () => ({
data-with-bottom-overlay={withBottomOverlay ? "true" : "false"}
data-shell-visible={shellVisible ? "true" : "false"}
data-child-count={String(childSubagentSessions?.length ?? 0)}
/>
data-runtime-status={runtimeTeamState?.status || ""}
data-runtime-member-count={String(runtimeTeamState?.members?.length ?? 0)}
>
{onActivateWorkbench ? (
<button
type="button"
data-testid="team-workspace-dock-activate"
onClick={() => {
onActivateWorkbench();
}}
>
打开 Team Workbench
</button>
) : null}
</div>
),
}));
@@ -459,7 +494,19 @@ vi.mock("@/components/general-chat/bridge", () => ({
vi.mock("@/components/artifact", () => ({
ArtifactList: () => <div data-testid="artifact-list" />,
ArtifactRenderer: () => <div data-testid="artifact-renderer" />,
ArtifactToolbar: () => <div data-testid="artifact-toolbar" />,
ArtifactToolbar: ({ onClose }: { onClose?: () => void }) => (
<div data-testid="artifact-toolbar">
<button
type="button"
data-testid="artifact-toolbar-close"
onClick={() => {
onClose?.();
}}
>
关闭
</button>
</div>
),
}));
vi.mock("@/lib/artifact/store", () => ({
@@ -558,6 +605,13 @@ vi.mock("@/lib/api/executionRun", () => ({
mockExecutionRunListThemeWorkbenchHistory,
}));
vi.mock("@/lib/api/content-workflow", () => ({
contentWorkflowApi: {
create: mockContentWorkflowCreate,
getByContent: mockContentWorkflowGetByContent,
},
}));
vi.mock("@/lib/api/skill-execution", () => ({
skillExecutionApi: {
getSkillDetail: mockSkillExecutionGetDetail,
@@ -721,10 +775,10 @@ function createMockThemeContextWorkspaceState(
async function flushEffects(times = 6) {
for (let i = 0; i < times; i += 1) {
await act(async () => {
await Promise.resolve();
await vi.dynamicImportSettled();
await new Promise<void>((resolve) => {
window.setTimeout(resolve, 20);
});
act(() => {});
}
}
@@ -832,7 +886,7 @@ function mockBrowserAssistCompletedSession() {
);
}
beforeEach(() => {
beforeEach(async () => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
@@ -849,6 +903,17 @@ beforeEach(() => {
);
vi.clearAllMocks();
const originalConsoleError = console.error.bind(console);
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
const message = String(args[0] ?? "");
if (
message.includes("not wrapped in act") ||
message.includes("A suspended resource finished loading inside a test")
) {
return;
}
originalConsoleError(...args);
});
localStorage.clear();
sessionStorage.clear();
observedWorkspaceIds.length = 0;
@@ -888,6 +953,17 @@ beforeEach(() => {
next_offset: null,
});
mockExecutionRunGet.mockResolvedValue(null);
mockContentWorkflowCreate.mockResolvedValue({
id: "wf-default",
content_id: "content-default",
theme: "social-media",
mode: "guided",
steps: [],
current_step_index: 0,
created_at: Date.now(),
updated_at: Date.now(),
});
mockContentWorkflowGetByContent.mockResolvedValue(null);
mockSkillExecutionGetDetail.mockResolvedValue({
name: "social_post_with_cover",
display_name: "社媒主稿与封面",
@@ -1063,6 +1139,8 @@ beforeEach(() => {
};
},
);
await import("./AgentChatWorkspace");
});
afterEach(() => {
@@ -1078,6 +1156,7 @@ afterEach(() => {
}
localStorage.clear();
sessionStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -1470,6 +1549,366 @@ describe("AgentChatPage 通用工作台", () => {
).not.toBeNull();
});
it("Team 组建中与仅完成编队时,不应自动切到 Team 画布", async () => {
let resolveGeneratedTeam:
| ((value: {
id: string;
source: "ephemeral";
label: string;
description: string;
roles: Array<{
id: string;
label: string;
summary: string;
profileId: string;
roleKey: string;
skillIds: string[];
}>;
}) => void)
| null = null;
mockGenerateEphemeralTeamWithModel.mockImplementation(
() =>
new Promise((resolve) => {
resolveGeneratedTeam = resolve;
}),
);
const mounted = mountPage({
projectId: "project-team-runtime",
theme: "general",
lockTheme: true,
});
await flushEffects(10);
let latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
| {
onToolStatesChange?: (
next:
| Record<string, unknown>
| ((prev: Record<string, unknown>) => Record<string, unknown>),
) => void;
}
| undefined;
act(() => {
latestInputbarProps?.onToolStatesChange?.((previous) => ({
...previous,
subagent: true,
}));
});
await flushEffects(8);
latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
| {
onSend?: (
images?: unknown[],
webSearch?: boolean,
thinking?: boolean,
textOverride?: string,
executionStrategy?: "react" | "code_orchestrated" | "auto",
) => Promise<void>;
}
| undefined;
await act(async () => {
await latestInputbarProps?.onSend?.(
[],
false,
false,
"请帮我拆解并推进这个修复任务",
"auto",
);
});
await flushEffects(8);
expect(mockGenerateEphemeralTeamWithModel).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "project-team-runtime",
providerType: "kiro",
model: "mock-model",
input: "请帮我拆解并推进这个修复任务",
}),
);
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat");
expect(
mounted.container
.querySelector('[data-testid="team-workspace-dock"]')
?.getAttribute("data-runtime-status"),
).toBe("forming");
await act(async () => {
resolveGeneratedTeam?.({
id: "ephemeral-team",
source: "ephemeral",
label: "修复 Team",
description: "分析、执行、验证协作闭环。",
roles: [
{
id: "explorer",
label: "分析",
summary: "负责定位问题边界。",
profileId: "code-explorer",
roleKey: "explorer",
skillIds: ["repo-exploration"],
},
{
id: "executor",
label: "执行",
summary: "负责落地修复方案。",
profileId: "code-executor",
roleKey: "executor",
skillIds: ["bounded-implementation"],
},
],
});
await Promise.resolve();
});
await flushEffects(10);
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat");
expect(
mounted.container
.querySelector('[data-testid="team-workspace-dock"]')
?.getAttribute("data-runtime-status"),
).toBe("formed");
expect(
mounted.container
.querySelector('[data-testid="team-workspace-dock"]')
?.getAttribute("data-child-count"),
).toBe("0");
});
it("用户手动点击 Team 入口时,组建中的 Team 也应直接切到 Team 画布", async () => {
let resolveGeneratedTeam:
| ((value: {
id: string;
source: "ephemeral";
label: string;
description: string;
roles: Array<{
id: string;
label: string;
summary: string;
profileId: string;
roleKey: string;
skillIds: string[];
}>;
}) => void)
| null = null;
mockGenerateEphemeralTeamWithModel.mockImplementation(
() =>
new Promise((resolve) => {
resolveGeneratedTeam = resolve;
}),
);
const mounted = mountPage({
projectId: "project-team-manual-open",
theme: "general",
lockTheme: true,
});
await flushEffects(10);
let latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
| {
onToolStatesChange?: (
next:
| Record<string, unknown>
| ((prev: Record<string, unknown>) => Record<string, unknown>),
) => void;
}
| undefined;
act(() => {
latestInputbarProps?.onToolStatesChange?.((previous) => ({
...previous,
subagent: true,
}));
});
await flushEffects(8);
latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
| {
onSend?: (
images?: unknown[],
webSearch?: boolean,
thinking?: boolean,
textOverride?: string,
executionStrategy?: "react" | "code_orchestrated" | "auto",
) => Promise<void>;
}
| undefined;
await act(async () => {
await latestInputbarProps?.onSend?.(
[],
false,
false,
"请组织一个协作团队推进这项修复",
"auto",
);
});
await flushEffects(8);
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat");
expect(
mounted.container
.querySelector('[data-testid="team-workspace-dock"]')
?.getAttribute("data-runtime-status"),
).toBe("forming");
clickButton(mounted.container, "team-workspace-dock-activate");
await flushEffects(8);
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat-canvas");
await act(async () => {
resolveGeneratedTeam?.({
id: "ephemeral-team-manual",
source: "ephemeral",
label: "修复 Team",
description: "分析、执行、验证协作闭环。",
roles: [
{
id: "explorer",
label: "分析",
summary: "负责定位问题边界。",
profileId: "code-explorer",
roleKey: "explorer",
skillIds: ["repo-exploration"],
},
{
id: "executor",
label: "执行",
summary: "负责落地修复方案。",
profileId: "code-executor",
roleKey: "executor",
skillIds: ["bounded-implementation"],
},
],
});
await Promise.resolve();
});
await flushEffects(8);
});
it("同一会话首次出现真实 Team 成员时,应自动切到 Team 画布", async () => {
const runtimeState = {
childSubagentSessions: [] as Array<{
id: string;
name: string;
created_at: number;
updated_at: number;
session_type: "sub_agent";
runtime_status: "running";
task_summary: string;
role_hint: string;
}>,
};
mockUseAgentChatUnified.mockImplementation(
({ workspaceId }: { workspaceId: string }) => {
observedWorkspaceIds.push(workspaceId);
return {
providerType: "kiro",
setProviderType: vi.fn(),
model: "mock-model",
setModel: vi.fn(),
executionStrategy: "auto",
setExecutionStrategy: vi.fn(),
messages: [],
currentTurnId: null,
turns: [],
threadItems: [],
todoItems: [],
childSubagentSessions: runtimeState.childSubagentSessions,
subagentParentContext: null,
queuedTurns: [],
isSending: false,
sendMessage: sharedSendMessageMock,
stopSending: vi.fn(async () => undefined),
promoteQueuedTurn: vi.fn(async () => false),
removeQueuedTurn: vi.fn(async () => false),
clearMessages: vi.fn(),
deleteMessage: vi.fn(),
editMessage: vi.fn(),
handlePermissionResponse: vi.fn(),
pendingActions: [],
triggerAIGuide: sharedTriggerAIGuideMock,
topics: [
{
id: "topic-a",
title: "话题 A",
updatedAt: Date.now(),
},
],
sessionId: "session-1",
createFreshSession: vi.fn(async () => undefined),
switchTopic: sharedSwitchTopicMock,
deleteTopic: vi.fn(),
renameTopic: vi.fn(),
updateTopicSnapshot: vi.fn(),
workspacePathMissing: false,
fixWorkspacePathAndRetry: vi.fn(),
dismissWorkspacePathError: vi.fn(),
};
},
);
const mounted = mountPage({
projectId: "project-team-real-graph",
theme: "general",
lockTheme: true,
});
await flushEffects(10);
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat");
runtimeState.childSubagentSessions = [
{
id: "child-1",
name: "分析成员",
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
session_type: "sub_agent",
runtime_status: "running",
task_summary: "分析问题边界",
role_hint: "explorer",
},
];
mounted.rerender();
await flushEffects(10);
expect(
mounted.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat-canvas");
});
it("已安装 skills 但未显式激活时,通用工作台不应展示技能区块", async () => {
mockSkillsGetAll.mockResolvedValue([
{
@@ -1746,6 +2185,117 @@ describe("AgentChatPage 通用工作台", () => {
);
});
it("手动关闭浏览器协助画布后,同 scope 会话更新不应自动重新打开", async () => {
mockBrowserAssistCompletedSession();
const harness = mountPage({
theme: "general",
lockTheme: true,
});
await flushEffects(10);
expect(
harness.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat-canvas");
clickButton(harness.container, "toggle-canvas");
await flushEffects(6);
expect(
harness.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat");
mockUseAgentChatUnified.mockImplementation(
({ workspaceId }: { workspaceId: string }) => {
observedWorkspaceIds.push(workspaceId);
return {
providerType: "kiro",
setProviderType: vi.fn(),
model: "mock-model",
setModel: vi.fn(),
executionStrategy: "auto",
setExecutionStrategy: vi.fn(),
messages: [
{
id: "msg-browser-user-refresh",
role: "user",
content: "继续浏览器流程",
timestamp: new Date("2026-03-14T03:00:00.000Z"),
},
{
id: "msg-browser-assistant-refresh",
role: "assistant",
content: "",
timestamp: new Date("2026-03-14T03:00:01.000Z"),
toolCalls: [
{
id: "tool-browser-open-refresh",
name: "mcp__lime-browser__browser_navigate",
arguments: JSON.stringify({
url: "https://www.rokid.com/news",
profile_key: "general_browser_assist",
}),
status: "completed",
startTime: new Date("2026-03-14T03:00:01.100Z"),
endTime: new Date("2026-03-14T03:00:02.000Z"),
result: {
success: true,
output: "已刷新页面",
metadata: {
result: {
session_id: "browser-session-1",
profile_key: "general_browser_assist",
page_info: {
title: "Rokid News",
url: "https://www.rokid.com/news",
},
},
},
},
},
],
},
],
isSending: false,
sendMessage: sharedSendMessageMock,
stopSending: vi.fn(async () => undefined),
clearMessages: vi.fn(),
deleteMessage: vi.fn(),
editMessage: vi.fn(),
handlePermissionResponse: vi.fn(),
triggerAIGuide: sharedTriggerAIGuideMock,
topics: [
{
id: "topic-a",
title: "话题 A",
updatedAt: Date.now(),
},
],
sessionId: "session-1",
switchTopic: sharedSwitchTopicMock,
deleteTopic: vi.fn(),
renameTopic: vi.fn(),
workspacePathMissing: false,
fixWorkspacePathAndRetry: vi.fn(),
dismissWorkspacePathError: vi.fn(),
};
},
);
harness.rerender({});
await flushEffects(10);
expect(
harness.container
.querySelector('[data-testid="layout-transition"]')
?.getAttribute("data-mode"),
).toBe("chat");
});
it("显式新 URL 的浏览器请求应复用现有会话并导航到新页面", async () => {
mockBrowserAssistCompletedSession();
mockBrowserExecuteAction.mockResolvedValueOnce({
@@ -2137,12 +2687,17 @@ describe("AgentChatPage 自动引导", () => {
expect(sharedTriggerAIGuideMock).toHaveBeenCalledWith();
});
it("存在 initialUserPrompt 时应优先发送首条意图", async () => {
it("存在 initialUserPrompt 时应先预填并等待确认", async () => {
mockIsContentCreationTheme.mockReturnValue(true);
mockUseThemeContextWorkspace.mockReturnValue(
createMockThemeContextWorkspaceState({
enabled: true,
}),
);
const onInitialUserPromptConsumed = vi.fn();
const initialUserPrompt = "请先帮我写一篇社媒文案提纲。";
renderPage({
const container = renderPage({
projectId: "project-social-intent",
contentId: "content-social-intent",
theme: "social-media",
@@ -2152,8 +2707,20 @@ describe("AgentChatPage 自动引导", () => {
});
await flushEffects(12);
expect(sharedSendMessageMock).not.toHaveBeenCalled();
const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
| { input?: string }
| undefined;
expect(latestInputbarProps?.input || "").toBe(initialUserPrompt);
expect(
container.querySelector('[data-testid="theme-workbench-entry-prompt"]'),
).not.toBeNull();
clickButton(container, "theme-workbench-entry-continue");
await flushEffects(12);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
initialUserPrompt,
`/social_post_with_cover ${initialUserPrompt}`,
[],
false,
false,
@@ -2193,6 +2760,11 @@ describe("AgentChatPage 自动引导", () => {
});
await flushEffects(12);
expect(sharedSendMessageMock).not.toHaveBeenCalled();
const contextContainer = mountedRoots.at(-1)?.container as HTMLDivElement;
clickButton(contextContainer, "theme-workbench-entry-continue");
await flushEffects(12);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
`/social_post_with_cover [生效上下文]\n1. [素材] 品牌手册\n\n${initialUserPrompt}`,
[],
@@ -2215,6 +2787,11 @@ describe("AgentChatPage 自动引导", () => {
it("存在 initialUserPrompt 时应使用当前选中模型发送", async () => {
mockIsContentCreationTheme.mockReturnValue(true);
mockUseThemeContextWorkspace.mockReturnValue(
createMockThemeContextWorkspaceState({
enabled: true,
}),
);
const selectedModel = "gemini-2.5-pro";
const onInitialUserPromptConsumed = vi.fn();
const initialUserPrompt = "请生成面向 CTO 的社媒提纲";
@@ -2263,8 +2840,13 @@ describe("AgentChatPage 自动引导", () => {
});
await flushEffects(12);
const selectedModelContainer = mountedRoots.at(-1)?.container as HTMLDivElement;
expect(sharedSendMessageMock).not.toHaveBeenCalled();
clickButton(selectedModelContainer, "theme-workbench-entry-continue");
await flushEffects(12);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
initialUserPrompt,
`/social_post_with_cover ${initialUserPrompt}`,
[],
false,
false,
@@ -2283,6 +2865,84 @@ describe("AgentChatPage 自动引导", () => {
expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1);
});
it("检测到未完成工作流时应提示继续或重新开始", async () => {
mockIsContentCreationTheme.mockReturnValue(true);
mockUseThemeContextWorkspace.mockReturnValue(
createMockThemeContextWorkspaceState({
enabled: true,
}),
);
mockContentWorkflowGetByContent.mockResolvedValue({
id: "wf-resume-1",
content_id: "content-social-resume",
theme: "social-media",
mode: "guided",
current_step_index: 1,
created_at: Date.now() - 10_000,
updated_at: Date.now(),
steps: [
{
id: "clarify",
type: "clarify",
title: "明确选题",
description: "",
behavior: {
skippable: true,
redoable: true,
auto_advance: false,
},
status: "completed",
},
{
id: "write",
type: "write",
title: "撰写主稿",
description: "",
behavior: {
skippable: false,
redoable: true,
auto_advance: false,
},
status: "pending",
},
],
});
const container = renderPage({
projectId: "project-social-resume",
contentId: "content-social-resume",
theme: "social-media",
lockTheme: true,
});
await flushEffects(14);
expect(sharedSendMessageMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("发现上次未完成任务");
expect(container.textContent).toContain("撰写主稿");
clickButton(container, "theme-workbench-entry-continue");
await flushEffects(12);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
"/social_post_with_cover 请基于当前文稿与已有上下文,继续推进上次未完成的任务。优先继续“撰写主稿”阶段,不要从头重复已经完成的内容。先简要确认当前进度,再继续执行。",
[],
false,
false,
false,
undefined,
expect.any(String),
undefined,
expect.objectContaining({
requestMetadata: expect.objectContaining({
harness: expect.objectContaining({
theme: "social-media",
session_mode: "theme_workbench",
}),
}),
}),
);
});
it("首条意图被父层消费后,发送中仍应保留 bootstrap 预览,避免空白对话框", async () => {
mockUseAgentChatUnified.mockImplementation(
({ workspaceId }: { workspaceId: string }) => {
@@ -2473,7 +3133,7 @@ describe("AgentChatPage 自动引导", () => {
expect(container.querySelector('[data-testid="inputbar"]')).not.toBeNull();
});
it("主题工作台在初始意图稍后注入时应自动发送首条创作请求", async () => {
it("主题工作台在初始意图稍后注入时应改为预填并等待确认", async () => {
mockIsContentCreationTheme.mockReturnValue(true);
mockUseThemeContextWorkspace.mockReturnValue(
createMockThemeContextWorkspaceState({
@@ -2511,6 +3171,17 @@ describe("AgentChatPage 自动引导", () => {
});
await flushEffects(10);
expect(sharedSendMessageMock).not.toHaveBeenCalled();
const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
| { input?: string }
| undefined;
expect(latestInputbarProps?.input || "").toBe(
"请基于当前上下文直接开始生成首版社媒主稿。",
);
clickButton(mounted.container, "theme-workbench-entry-continue");
await flushEffects(10);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
"/social_post_with_cover 请基于当前上下文直接开始生成首版社媒主稿。",
[],
@@ -3994,7 +4665,7 @@ describe("AgentChatPage 小说主题工作台", () => {
expect(sharedSendMessageMock).not.toHaveBeenCalled();
});
it("小说主题工作台带初始意图时仍应自动发送首条请求", async () => {
it("小说主题工作台带初始意图时应改为预填并等待确认", async () => {
mockIsContentCreationTheme.mockReturnValue(true);
mockUseThemeContextWorkspace.mockReturnValue(
createMockThemeContextWorkspaceState({
@@ -4015,6 +4686,11 @@ describe("AgentChatPage 小说主题工作台", () => {
});
await flushEffects(12);
const container = mountedRoots.at(-1)?.container as HTMLDivElement;
expect(sharedSendMessageMock).not.toHaveBeenCalled();
clickButton(container, "theme-workbench-entry-continue");
await flushEffects(12);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
initialUserPrompt,
[],
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { summarizeTeamWorkspaceExecution } from "./teamWorkspaceRuntime";
describe("summarizeTeamWorkspaceExecution", () => {
it("主线程流结束后,只要 live team 仍在运行就应保持活跃", () => {
const summary = summarizeTeamWorkspaceExecution({
currentSessionId: "parent-1",
currentSessionRuntimeStatus: "completed",
childSubagentSessions: [
{
id: "child-1",
name: "研究员",
created_at: 1,
updated_at: 2,
session_type: "sub_agent",
runtime_status: "completed",
latest_turn_status: "completed",
},
],
liveRuntimeBySessionId: {
"child-1": {
runtimeStatus: "running",
latestTurnStatus: "running",
baseFingerprint: "child-1:2:completed:completed:0",
},
},
});
expect(summary.hasActiveRuntime).toBe(true);
expect(summary.runningSessionCount).toBe(1);
expect(summary.statusTitle).toContain("Team 运行中");
});
it("某个子代理失败但仍有其他子代理运行时,不应提前收敛", () => {
const summary = summarizeTeamWorkspaceExecution({
childSubagentSessions: [
{
id: "child-1",
name: "研究员",
created_at: 1,
updated_at: 2,
session_type: "sub_agent",
runtime_status: "failed",
latest_turn_status: "failed",
},
{
id: "child-2",
name: "执行者",
created_at: 3,
updated_at: 4,
session_type: "sub_agent",
runtime_status: "running",
latest_turn_status: "running",
},
],
});
expect(summary.hasActiveRuntime).toBe(true);
expect(summary.activeSessionCount).toBe(1);
expect(summary.runningSessionCount).toBe(1);
expect(summary.statusTitle).toContain("Team 运行中");
});
it("所有 team 会话进入终态后,应返回非活跃状态", () => {
const summary = summarizeTeamWorkspaceExecution({
childSubagentSessions: [
{
id: "child-1",
name: "研究员",
created_at: 1,
updated_at: 2,
session_type: "sub_agent",
runtime_status: "completed",
latest_turn_status: "completed",
},
{
id: "child-2",
name: "执行者",
created_at: 3,
updated_at: 4,
session_type: "sub_agent",
runtime_status: "failed",
latest_turn_status: "failed",
},
],
});
expect(summary.hasActiveRuntime).toBe(false);
expect(summary.activeSessionCount).toBe(0);
expect(summary.statusTitle).toBeNull();
});
});
@@ -1,5 +1,13 @@
import type { AgentThreadItem } from "@/lib/api/agentStream";
import type { AsterSubagentSessionInfo } from "@/lib/api/agentRuntime";
import type {
AsterSubagentParentContext,
AsterSubagentSessionInfo,
} from "@/lib/api/agentRuntime";
import {
buildTeamDefinitionSummary,
type TeamDefinition,
type TeamRoleDefinition,
} from "./utils/teamDefinitions";
export type TeamWorkspaceRuntimeStatus =
AsterSubagentSessionInfo["runtime_status"];
@@ -18,6 +26,7 @@ export interface TeamWorkspaceActivityEntry {
export interface TeamWorkspaceLiveRuntimeState {
runtimeStatus: TeamWorkspaceRuntimeStatus;
latestTurnStatus: TeamWorkspaceRuntimeStatus;
queuedTurnCount?: number;
baseFingerprint: string;
}
@@ -33,6 +42,16 @@ export interface TeamWorkspaceRuntimeCard {
id: string;
runtimeStatus?: TeamWorkspaceRuntimeStatus;
latestTurnStatus?: TeamWorkspaceRuntimeStatus;
queuedTurnCount?: number;
}
export interface TeamWorkspaceExecutionSummary {
totalSessionCount: number;
runningSessionCount: number;
queuedSessionCount: number;
activeSessionCount: number;
hasActiveRuntime: boolean;
statusTitle: string | null;
}
export interface TeamWorkspaceWaitSummary {
@@ -51,6 +70,106 @@ export interface TeamWorkspaceControlSummary {
updatedAt: number;
}
export type TeamWorkspaceRuntimeFormationStatus =
| "forming"
| "formed"
| "failed";
export type TeamWorkspaceRuntimeMemberStatus =
| "planned"
| "spawning"
| "running"
| "waiting"
| "completed"
| "failed";
export interface TeamWorkspaceBlueprintSnapshot {
label?: string | null;
summary?: string | null;
roles: TeamRoleDefinition[];
}
export interface TeamWorkspaceRuntimeMember {
id: string;
label: string;
summary: string;
profileId?: string;
roleKey?: string;
skillIds: string[];
status: TeamWorkspaceRuntimeMemberStatus;
sessionId?: string;
latestSnippet?: string | null;
}
export interface TeamWorkspaceRuntimeFormationState {
requestId: string;
status: TeamWorkspaceRuntimeFormationStatus;
label?: string | null;
summary?: string | null;
members: TeamWorkspaceRuntimeMember[];
blueprint?: TeamWorkspaceBlueprintSnapshot | null;
errorMessage?: string | null;
updatedAt: number;
}
const FORMATION_STATUS_META = {
forming: {
label: "组建中",
title: "正在准备本轮 Team",
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
},
formed: {
label: "已形成",
title: "本轮 Team 已就绪",
badgeClassName: "border border-emerald-200 bg-emerald-50 text-emerald-700",
},
failed: {
label: "失败",
title: "Team 生成失败",
badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700",
},
} satisfies Record<
TeamWorkspaceRuntimeFormationStatus,
{
label: string;
title: string;
badgeClassName: string;
}
>;
const MEMBER_STATUS_META = {
planned: {
label: "计划中",
badgeClassName: "border border-slate-200 bg-slate-50 text-slate-600",
},
spawning: {
label: "拉起中",
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
},
running: {
label: "运行中",
badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700",
},
waiting: {
label: "等待中",
badgeClassName: "border border-amber-200 bg-amber-50 text-amber-700",
},
completed: {
label: "已完成",
badgeClassName: "border border-emerald-200 bg-emerald-50 text-emerald-700",
},
failed: {
label: "失败",
badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700",
},
} satisfies Record<
TeamWorkspaceRuntimeMemberStatus,
{
label: string;
badgeClassName: string;
}
>;
const STATUS_META = {
idle: {
label: "待开始",
@@ -77,7 +196,7 @@ const STATUS_META = {
badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700",
},
closed: {
label: "已关闭",
label: "已停止",
badgeClassName: "border border-slate-200 bg-slate-100 text-slate-600",
},
} satisfies Record<
@@ -145,7 +264,8 @@ function resolveActivityEntryStatusMeta(
case "completed":
return {
label: "完成",
badgeClassName: "border border-emerald-200 bg-emerald-50 text-emerald-700",
badgeClassName:
"border border-emerald-200 bg-emerald-50 text-emerald-700",
};
default:
return {
@@ -198,7 +318,8 @@ function resolveItemActivityDescriptor(item: AgentThreadItem): {
return {
title: item.output ? "检索结果" : "检索查询",
detail:
normalizeActivityText(item.output) || normalizeActivityText(item.query),
normalizeActivityText(item.output) ||
normalizeActivityText(item.query),
};
case "warning":
return {
@@ -237,7 +358,7 @@ export function resolveTeamWorkspaceRuntimeStatusLabel(
case "aborted":
return "已中止";
case "closed":
return "已关闭";
return "已停止";
case "not_found":
return "未找到";
default:
@@ -263,6 +384,147 @@ export function normalizeTeamWorkspaceRuntimeStatus(
return status === "not_found" ? "closed" : status;
}
export function isTeamWorkspaceActiveStatus(
status?: TeamWorkspaceResolvedRuntimeStatus,
): boolean {
return status === "running" || status === "queued";
}
function resolveExecutionSummaryStatusTitle(params: {
totalSessionCount: number;
runningSessionCount: number;
queuedSessionCount: number;
}) {
const { totalSessionCount, runningSessionCount, queuedSessionCount } = params;
if (runningSessionCount > 0) {
if (queuedSessionCount > 0) {
return totalSessionCount > 1
? `Team 运行中 · ${runningSessionCount} 运行 / ${queuedSessionCount} 排队`
: "Team 运行中";
}
return totalSessionCount > 1
? `Team 运行中 · ${runningSessionCount}/${totalSessionCount}`
: "Team 运行中";
}
if (queuedSessionCount > 0) {
return totalSessionCount > 1
? `Team 排队中 · ${queuedSessionCount}/${totalSessionCount}`
: "Team 排队中";
}
return null;
}
function buildExecutionSummarySnapshots(params: {
currentSessionId?: string | null;
currentSessionRuntimeStatus?: TeamWorkspaceRuntimeStatus;
currentSessionLatestTurnStatus?: TeamWorkspaceRuntimeStatus;
currentSessionQueuedTurnCount?: number;
childSubagentSessions?: AsterSubagentSessionInfo[];
subagentParentContext?: AsterSubagentParentContext | null;
}) {
const snapshots = new Map<string, TeamWorkspaceRuntimeSessionSnapshot>();
const currentSessionId = params.currentSessionId?.trim();
if (params.subagentParentContext && currentSessionId) {
snapshots.set(currentSessionId, {
id: currentSessionId,
runtimeStatus: params.currentSessionRuntimeStatus,
latestTurnStatus: params.currentSessionLatestTurnStatus,
queuedTurnCount: params.currentSessionQueuedTurnCount,
});
}
const relatedSessions = params.subagentParentContext
? (params.subagentParentContext.sibling_subagent_sessions ?? [])
: (params.childSubagentSessions ?? []);
relatedSessions.forEach((session) => {
if (snapshots.has(session.id)) {
return;
}
snapshots.set(session.id, {
id: session.id,
runtimeStatus: session.runtime_status,
latestTurnStatus: session.latest_turn_status,
queuedTurnCount: session.queued_turn_count,
updatedAt: session.updated_at,
});
});
return Array.from(snapshots.values());
}
function resolveExecutionSummarySessionStatus(params: {
session: TeamWorkspaceRuntimeSessionSnapshot;
liveState?: TeamWorkspaceLiveRuntimeState;
}): TeamWorkspaceRuntimeStatus | undefined {
if (params.liveState?.runtimeStatus) {
return params.liveState.runtimeStatus;
}
if (params.session.runtimeStatus) {
return params.session.runtimeStatus;
}
if (params.session.latestTurnStatus) {
return params.session.latestTurnStatus;
}
if ((params.session.queuedTurnCount ?? 0) > 0) {
return "queued";
}
return undefined;
}
export function summarizeTeamWorkspaceExecution(params: {
currentSessionId?: string | null;
currentSessionRuntimeStatus?: TeamWorkspaceRuntimeStatus;
currentSessionLatestTurnStatus?: TeamWorkspaceRuntimeStatus;
currentSessionQueuedTurnCount?: number;
childSubagentSessions?: AsterSubagentSessionInfo[];
subagentParentContext?: AsterSubagentParentContext | null;
liveRuntimeBySessionId?: Record<string, TeamWorkspaceLiveRuntimeState>;
}): TeamWorkspaceExecutionSummary {
const snapshots = buildExecutionSummarySnapshots(params);
let runningSessionCount = 0;
let queuedSessionCount = 0;
snapshots.forEach((session) => {
const status = resolveExecutionSummarySessionStatus({
session,
liveState: params.liveRuntimeBySessionId?.[session.id],
});
if (status === "running") {
runningSessionCount += 1;
return;
}
if (status === "queued") {
queuedSessionCount += 1;
}
});
const activeSessionCount = runningSessionCount + queuedSessionCount;
const totalSessionCount = snapshots.length;
return {
totalSessionCount,
runningSessionCount,
queuedSessionCount,
activeSessionCount,
hasActiveRuntime: activeSessionCount > 0,
statusTitle: resolveExecutionSummaryStatusTitle({
totalSessionCount,
runningSessionCount,
queuedSessionCount,
}),
};
}
export function buildTeamWorkspaceSessionFingerprint(
session?: TeamWorkspaceRuntimeSessionSnapshot | null,
) {
@@ -347,5 +609,67 @@ export function applyLiveRuntimeState<T extends TeamWorkspaceRuntimeCard>(
...session,
runtimeStatus: liveState.runtimeStatus,
latestTurnStatus: liveState.latestTurnStatus,
queuedTurnCount: liveState.queuedTurnCount ?? session.queuedTurnCount,
};
}
export function createTeamWorkspaceBlueprintSnapshot(
team?: TeamDefinition | null,
): TeamWorkspaceBlueprintSnapshot | null {
if (!team) {
return null;
}
return {
label: team.label?.trim() || null,
summary: buildTeamDefinitionSummary(team) || null,
roles: team.roles.map((role) => ({
...role,
skillIds: role.skillIds ? [...role.skillIds] : [],
})),
};
}
export function createRuntimeFormationStateFromTeam(params: {
requestId: string;
status: TeamWorkspaceRuntimeFormationStatus;
runtimeTeam?: TeamDefinition | null;
blueprintTeam?: TeamDefinition | null;
errorMessage?: string | null;
updatedAt?: number;
}): TeamWorkspaceRuntimeFormationState {
const runtimeTeam = params.runtimeTeam || null;
return {
requestId: params.requestId,
status: params.status,
label: runtimeTeam?.label?.trim() || null,
summary: runtimeTeam ? buildTeamDefinitionSummary(runtimeTeam) || null : null,
members: (runtimeTeam?.roles ?? []).map((role, index) => ({
id: role.id?.trim() || `runtime-member-${index + 1}`,
label: role.label?.trim() || `角色 ${index + 1}`,
summary: role.summary?.trim() || `${role.label || `角色 ${index + 1}`}负责当前子任务。`,
profileId: role.profileId?.trim() || undefined,
roleKey: role.roleKey?.trim() || undefined,
skillIds: role.skillIds ? [...role.skillIds] : [],
status: "planned",
sessionId: undefined,
latestSnippet: null,
})),
blueprint: createTeamWorkspaceBlueprintSnapshot(params.blueprintTeam),
errorMessage: params.errorMessage?.trim() || null,
updatedAt: params.updatedAt ?? Date.now(),
};
}
export function resolveRuntimeFormationStatusMeta(
status: TeamWorkspaceRuntimeFormationStatus,
) {
return FORMATION_STATUS_META[status];
}
export function resolveRuntimeMemberStatusMeta(
status: TeamWorkspaceRuntimeMemberStatus,
) {
return MEMBER_STATUS_META[status];
}
@@ -1,5 +1,6 @@
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
import type { AgentThreadItem, AgentThreadItemStatus } from "../types";
import { resolveInternalImageTaskDisplayName } from "./internalImagePlaceholder";
export type AgentThreadGroupKind =
| "thinking"
@@ -445,7 +446,11 @@ function summarizeSubagentItem(item: AgentThreadItem): string | null {
if (item.type !== "subagent_activity") {
return null;
}
return shortenText(item.title || item.summary || item.status_label);
return shortenText(
resolveInternalImageTaskDisplayName(item.title) ||
item.summary ||
item.status_label,
);
}
function summarizeAlertItem(item: AgentThreadItem): string | null {
@@ -0,0 +1,128 @@
import {
loadConfiguredProviders,
type ConfiguredProvider,
} from "@/hooks/useConfiguredProviders";
import { loadProviderModels } from "@/hooks/useProviderModels";
import { type EnhancedModelMetadata } from "@/lib/types/modelRegistry";
import { filterModelsByTheme } from "./modelThemePolicy";
import { resolveProviderModelCompatibility } from "./providerModelCompatibility";
export interface ResolveClawWorkspaceProviderSelectionInput {
currentProviderType?: string | null;
currentModel?: string | null;
theme?: string;
}
export interface ClawWorkspaceProviderSelection {
providerType: string;
model: string;
}
function normalizeValue(value?: string | null): string {
return (value || "").trim().toLowerCase();
}
function resolveExactModelId(
models: EnhancedModelMetadata[],
targetModelId?: string | null,
): string | null {
const normalizedTarget = normalizeValue(targetModelId);
if (!normalizedTarget) {
return null;
}
const matchedModel = models.find(
(model) => normalizeValue(model.id) === normalizedTarget,
);
return matchedModel?.id ?? null;
}
function resolvePreferredModelId(
provider: ConfiguredProvider,
models: EnhancedModelMetadata[],
currentModel?: string | null,
theme?: string,
): string | null {
if (models.length === 0) {
return null;
}
const themedModels = filterModelsByTheme(theme, models).models;
const candidateModels = themedModels.length > 0 ? themedModels : models;
if (candidateModels.length === 0) {
return null;
}
const resolveCompatibleModelId = (modelId?: string | null): string | null => {
const exactModelId = resolveExactModelId(candidateModels, modelId);
const compatibilityResult = resolveProviderModelCompatibility({
providerType: provider.key,
configuredProviderType: provider.type,
model: exactModelId ?? modelId ?? "",
});
return (
resolveExactModelId(candidateModels, compatibilityResult.model) ??
exactModelId
);
};
const retainedCurrentModel = resolveCompatibleModelId(currentModel);
if (retainedCurrentModel) {
return retainedCurrentModel;
}
for (const candidateModel of candidateModels) {
const compatibleModelId = resolveCompatibleModelId(candidateModel.id);
if (compatibleModelId) {
return compatibleModelId;
}
}
return candidateModels[0]?.id ?? null;
}
export async function resolveClawWorkspaceProviderSelection(
input: ResolveClawWorkspaceProviderSelectionInput,
): Promise<ClawWorkspaceProviderSelection | null> {
const { currentProviderType, currentModel, theme } = input;
const configuredProviders = await loadConfiguredProviders();
if (configuredProviders.length === 0) {
return null;
}
const currentProvider = configuredProviders.find(
(provider) => provider.key === currentProviderType,
);
const orderedProviders = currentProvider
? [
currentProvider,
...configuredProviders.filter(
(provider) => provider.key !== currentProvider.key,
),
]
: configuredProviders;
for (const provider of orderedProviders) {
const providerModels = await loadProviderModels(provider);
const preferredModel = resolvePreferredModelId(
provider,
providerModels,
provider.key === currentProvider?.key ? currentModel : null,
theme,
);
if (!preferredModel) {
continue;
}
return {
providerType: provider.key,
model: preferredModel,
};
}
return null;
}
@@ -57,6 +57,7 @@ describe("harnessRequestMetadata", () => {
expect.objectContaining({
id: "explorer",
label: "分析",
role_key: undefined,
}),
expect.objectContaining({
id: "executor",
@@ -86,6 +87,40 @@ describe("harnessRequestMetadata", () => {
expect(metadata.gate_key).toBeUndefined();
});
it("应保留 Team 角色的 profileId、roleKey 与 skillIds", () => {
const metadata = buildHarnessRequestMetadata({
theme: "general",
creationMode: "guided",
chatMode: "agent",
webSearchEnabled: false,
thinkingEnabled: true,
taskModeEnabled: true,
subagentModeEnabled: true,
sessionMode: "default",
selectedTeamRoles: [
{
id: "explorer",
label: "分析",
summary: "负责定位问题。",
profileId: "code-explorer",
roleKey: "explorer",
skillIds: ["repo-exploration", "source-grounding"],
},
],
});
expect(metadata.selected_team_roles).toEqual([
{
id: "explorer",
label: "分析",
summary: "负责定位问题。",
profile_id: "code-explorer",
role_key: "explorer",
skill_ids: ["repo-exploration", "source-grounding"],
},
]);
});
it("需要人工确认的浏览器任务应标记 user step", () => {
const metadata = buildHarnessRequestMetadata({
theme: "general",
@@ -0,0 +1,140 @@
import type { ContentPart, Message } from "../types";
const BRACKET_IMAGE_PLACEHOLDER_RE = /\[\s*Image\s*#\d+\s*\]/gi;
const BARE_IMAGE_PLACEHOLDER_RE = /(^|[\s,,;;])Image\s*#\d+(?=$|[\s,,;;])/gi;
const ONLY_IMAGE_PLACEHOLDERS_RE =
/^\s*(?:(?:\[\s*Image\s*#\d+\s*\]|Image\s*#\d+)\s*[,,;;]?\s*)+$/i;
const BRACKET_IMAGE_PLACEHOLDER_TEST_RE = /\[\s*Image\s*#\d+\s*\]/i;
const BARE_IMAGE_PLACEHOLDER_TEST_RE =
/(^|[\s,,;;])Image\s*#\d+(?=$|[\s,,;;])/i;
const EXACT_IMAGE_TASK_LABEL_RE = /^\[?\s*Image\s*#(\d+)\s*\]?$/i;
function collapseDisplayWhitespace(value: string): string {
return value
.replace(/\s+([,。!?、;:,.!?;:])/g, "$1")
.replace(/([(【《“‘([<])\s+/g, "$1")
.replace(/\s+([)】》”’)\]>])/g, "$1")
.replace(/([\u4e00-\u9fff])\s+([\u4e00-\u9fff])/g, "$1$2")
.replace(/\s+/g, " ")
.trim();
}
function replaceImagePlaceholders(text: string, replacement: string): string {
const withBracketPlaceholders = text.replace(
BRACKET_IMAGE_PLACEHOLDER_RE,
` ${replacement} `,
);
return withBracketPlaceholders.replace(
BARE_IMAGE_PLACEHOLDER_RE,
(_match, prefix: string) => `${prefix}${replacement}`,
);
}
export function containsInternalImagePlaceholder(text: string): boolean {
return (
BRACKET_IMAGE_PLACEHOLDER_TEST_RE.test(text) ||
BARE_IMAGE_PLACEHOLDER_TEST_RE.test(text)
);
}
export function isOnlyInternalImagePlaceholderText(text: string): boolean {
return ONLY_IMAGE_PLACEHOLDERS_RE.test(text.trim());
}
export function resolveInternalImageTaskDisplayName(
value: string | null | undefined,
): string | null {
const normalized = value?.trim();
if (!normalized) {
return null;
}
const match = normalized.match(EXACT_IMAGE_TASK_LABEL_RE);
if (!match) {
return normalized;
}
const index = match[1]?.trim();
return index ? `图片任务 ${index}` : "图片任务";
}
interface SanitizeMessageTextOptions {
role: Message["role"];
hasImages?: boolean;
}
export function sanitizeMessageTextForDisplay(
text: string,
options: SanitizeMessageTextOptions,
): string {
const normalized = text.trim();
if (!normalized) {
return "";
}
if (!containsInternalImagePlaceholder(normalized)) {
return normalized;
}
if (
isOnlyInternalImagePlaceholderText(normalized) &&
((options.role === "user" && options.hasImages) ||
options.role === "assistant")
) {
return "";
}
return collapseDisplayWhitespace(replaceImagePlaceholders(normalized, "图片"));
}
export function sanitizeMessageTextForPreview(
text: string,
options: SanitizeMessageTextOptions,
): string {
const sanitized = sanitizeMessageTextForDisplay(text, options);
if (sanitized) {
return sanitized;
}
if (options.role === "user" && options.hasImages) {
return "已附加图片";
}
if (
options.role === "assistant" &&
isOnlyInternalImagePlaceholderText(text)
) {
return "图片处理中";
}
return "";
}
export function sanitizeContentPartsForDisplay(
parts: ContentPart[] | undefined,
options: SanitizeMessageTextOptions,
): ContentPart[] | undefined {
if (!parts || parts.length === 0) {
return parts;
}
const sanitizedParts = parts.flatMap((part) => {
if (part.type !== "text") {
return [part];
}
const sanitizedText = sanitizeMessageTextForDisplay(part.text, options);
if (!sanitizedText) {
return [];
}
return [
{
...part,
text: sanitizedText,
},
];
});
return sanitizedParts.length > 0 ? sanitizedParts : undefined;
}
@@ -0,0 +1,305 @@
import { safeListen } from "@/lib/dev-bridge";
import {
createAgentRuntimeSession,
deleteAgentRuntimeSession,
getAgentRuntimeSession,
initAsterAgent,
submitAgentRuntimeTurn,
type AsterExecutionStrategy,
} from "@/lib/api/agentRuntime";
import { parseStreamEvent } from "@/lib/api/agentStream";
import { mapProviderName } from "../hooks/agentChatCoreUtils";
import {
createTeamDefinitionId,
normalizeTeamDefinition,
type TeamDefinition,
} from "./teamDefinitions";
interface GenerateTeamWithModelOptions {
workspaceId: string;
providerType: string;
model: string;
executionStrategy?: AsterExecutionStrategy;
activeTheme?: string;
input: string;
blueprintTeam?: TeamDefinition | null;
timeoutMs?: number;
}
interface GeneratedTeamPayload {
label?: string;
description?: string;
roles?: Array<{
id?: string;
label?: string;
summary?: string;
profileId?: string;
roleKey?: string;
skillIds?: string[];
}>;
team?: GeneratedTeamPayload;
}
function buildTeamGenerationPrompt(
input: string,
activeTheme?: string,
blueprintTeam?: TeamDefinition | null,
): string {
const blueprintPayload = blueprintTeam
? {
label: blueprintTeam.label,
description: blueprintTeam.description,
roles: blueprintTeam.roles.map((role) => ({
id: role.id,
label: role.label,
summary: role.summary,
profileId: role.profileId,
roleKey: role.roleKey,
skillIds: role.skillIds ?? [],
})),
}
: null;
return [
"请根据下面的任务,为 GUI Team 模式生成一个“本轮 Team 配置”。",
"要求:",
"1. 仅输出 JSON,不要 Markdown,不要代码块,不要额外解释。",
"2. 生成 2~4 个角色,角色名称与职责用中文。",
"3. roleKey 优先从以下集合中选择:explorer、executor、verifier、researcher、planner、reviewer、writer。",
"4. profileId 仅可使用以下值:code-explorer、code-executor、code-verifier、research-analyst、doc-writer、content-ideator、content-reviewer。",
"5. skillIds 仅可使用以下值:repo-exploration、bounded-implementation、verification-report、source-grounding、structured-writing。",
"6. 输出结构必须满足:",
JSON.stringify(
{
label: "本轮 Team 名称",
description: "一句话描述适用场景",
roles: [
{
id: "role-id",
label: "角色名称",
summary: "角色职责",
profileId: "code-explorer",
roleKey: "explorer",
skillIds: ["repo-exploration", "source-grounding"],
},
],
},
null,
2,
),
`当前主题:${activeTheme?.trim() || "general"}`,
blueprintPayload
? "7. 如果下面提供了“参考蓝图 Team”,请把它视为偏好与约束来源;你可以按当前任务动态调整角色结构,但不要完全脱离参考蓝图。"
: "7. 如果没有参考蓝图,请直接围绕当前任务组织最合适的本轮 Team。",
blueprintPayload
? `参考蓝图 Team:${JSON.stringify(blueprintPayload, null, 2)}`
: "参考蓝图 Team:无",
"如果任务本身不复杂,也仍然请输出一个最轻量可用的两角色 Team。",
`任务描述:${input.trim()}`,
].join("\n");
}
function extractAssistantText(detail: Awaited<ReturnType<typeof getAgentRuntimeSession>>): string {
const assistantMessages = [...detail.messages]
.filter((message) => message.role === "assistant")
.sort((left, right) => right.timestamp - left.timestamp);
for (const message of assistantMessages) {
const text = message.content
.map((item) => item.text || item.output || item.error || "")
.join("\n")
.trim();
if (text) {
return text;
}
}
return "";
}
function extractJsonObject(raw: string): string {
const trimmed = raw.trim();
const codeFenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = codeFenceMatch?.[1]?.trim() || trimmed;
const start = candidate.indexOf("{");
const end = candidate.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) {
throw new Error("模型未返回可解析的 JSON");
}
return candidate.slice(start, end + 1);
}
function parseGeneratedTeam(
raw: string,
activeTheme?: string,
): TeamDefinition {
const json = extractJsonObject(raw);
const parsed = JSON.parse(json) as GeneratedTeamPayload;
const payload = parsed.team && typeof parsed.team === "object" ? parsed.team : parsed;
const normalized = normalizeTeamDefinition({
id: createTeamDefinitionId("ephemeral-team"),
source: "ephemeral",
label: payload.label,
description: payload.description,
theme: activeTheme?.trim() || undefined,
roles: payload.roles,
});
if (!normalized) {
throw new Error("模型返回的 Team 结构不完整");
}
return {
...normalized,
source: "ephemeral",
};
}
export async function generateEphemeralTeamWithModel(
options: GenerateTeamWithModelOptions,
): Promise<TeamDefinition> {
const {
workspaceId,
providerType,
model,
executionStrategy = "react",
activeTheme,
input,
blueprintTeam,
timeoutMs = 45_000,
} = options;
const trimmedInput = input.trim();
if (!workspaceId.trim()) {
throw new Error("缺少 workspace,暂时无法生成 Team");
}
if (!providerType.trim() || !model.trim()) {
throw new Error("请先选择可用模型,再生成 Team");
}
if (!trimmedInput) {
throw new Error("请先输入任务描述,再生成 Team");
}
await initAsterAgent();
const resolvedExecutionStrategy =
executionStrategy === "code_orchestrated" ? "react" : executionStrategy;
const sessionId = await createAgentRuntimeSession(
workspaceId,
"Team 规划",
resolvedExecutionStrategy,
);
const eventName = `agent_team_draft:${sessionId}:${Date.now()}`;
let unlisten: (() => void) | null = null;
try {
const completion = new Promise<TeamDefinition>((resolve, reject) => {
let settled = false;
const timer = globalThis.setTimeout(() => {
settle(() => reject(new Error("生成 Team 超时,请稍后重试")));
}, timeoutMs);
const settle = (callback: () => void) => {
if (settled) {
return;
}
settled = true;
globalThis.clearTimeout(timer);
callback();
};
void (async () => {
try {
unlisten = await safeListen(eventName, async (event) => {
const parsed = parseStreamEvent(event.payload);
if (!parsed) {
return;
}
if (parsed.type === "turn_failed" || parsed.type === "error") {
settle(() =>
reject(
new Error(
parsed.type === "turn_failed"
? parsed.turn.error_message?.trim() ||
"Team 生成失败"
: parsed.message.trim() || "Team 生成失败",
),
),
);
return;
}
if (
parsed.type !== "turn_completed" &&
parsed.type !== "final_done"
) {
return;
}
try {
const detail = await getAgentRuntimeSession(sessionId);
const responseText = extractAssistantText(detail);
const generatedTeam = parseGeneratedTeam(responseText, activeTheme);
settle(() => resolve(generatedTeam));
} catch (error) {
settle(() =>
reject(
error instanceof Error
? error
: new Error("解析 Team 结果失败"),
),
);
}
});
await submitAgentRuntimeTurn({
message: buildTeamGenerationPrompt(
trimmedInput,
activeTheme,
blueprintTeam,
),
session_id: sessionId,
event_name: eventName,
workspace_id: workspaceId,
turn_config: {
provider_config: {
provider_id: providerType,
provider_name: mapProviderName(providerType),
model_name: model,
},
execution_strategy: resolvedExecutionStrategy,
web_search: false,
search_mode: "disabled",
system_prompt:
"你是 GUI Team 规划器。当前唯一任务是输出结构化 JSON Team 定义,不要调用工具,不要输出解释。",
metadata: {
source: "team_auto_generation",
auto_team: true,
theme: activeTheme || "general",
},
},
});
} catch (error) {
settle(() =>
reject(
error instanceof Error
? error
: new Error("提交 Team 生成任务失败"),
),
);
}
})();
});
return await completion;
} finally {
try {
unlisten?.();
} catch {
// ignore cleanup failure
}
try {
await deleteAgentRuntimeSession(sessionId);
} catch {
// ignore cleanup failure
}
}
}
@@ -4,7 +4,11 @@ import {
type TeamPresetOption,
} from "./teamPresets";
export type TeamDefinitionSource = "builtin" | "custom";
export type TeamDefinitionSource = "builtin" | "custom" | "ephemeral";
export type PersistableTeamDefinitionSource = Exclude<
TeamDefinitionSource,
"ephemeral"
>;
export interface TeamRoleDefinition {
id: string;
@@ -29,7 +33,7 @@ export interface TeamDefinition {
export interface TeamSelectionReference {
id: string;
source: TeamDefinitionSource;
source: PersistableTeamDefinitionSource;
}
function normalizeText(value?: string | null): string {
@@ -148,7 +152,12 @@ export function normalizeTeamDefinition(
return {
id: normalizeText(value.id) || createTeamDefinitionId("custom-team"),
source: value.source === "builtin" ? "builtin" : "custom",
source:
value.source === "builtin"
? "builtin"
: value.source === "ephemeral"
? "ephemeral"
: "custom",
label,
description: normalizeText(value.description),
theme: normalizeText(value.theme) || undefined,
@@ -185,7 +194,7 @@ export function buildTeamDefinitionLabel(team?: TeamDefinition | null): string {
export function buildTeamSelectionReference(
team?: TeamDefinition | null,
): TeamSelectionReference | null {
if (!team) {
if (!team || team.source === "ephemeral") {
return null;
}
+199 -10
View File
@@ -1,3 +1,8 @@
import type {
WorkspaceAgentCustomTeamSettings,
WorkspaceSettings,
WorkspaceTeamSelectionReference,
} from "@/types/workspace";
import type { TeamDefinition } from "./teamDefinitions";
import {
createTeamDefinitionFromPreset,
@@ -9,6 +14,18 @@ import {
const CUSTOM_TEAM_STORAGE_KEY = "lime.chat.custom_teams.v1";
const TEAM_SELECTION_STORAGE_KEY_PREFIX = "lime.chat.team_selection.v1";
type TeamSelectionLike = Pick<TeamSelectionReference, "id" | "source">;
interface ResolveSelectedTeamPreferenceOptions {
theme?: string | null;
workspaceSettings?: WorkspaceSettings | null;
}
type WorkspaceTeamPreferenceState =
| { kind: "unset" }
| { kind: "disabled" }
| { kind: "selected"; selection: TeamSelectionReference };
function normalizeCustomTeamList(
teams: Array<Partial<TeamDefinition>> | TeamDefinition[],
): TeamDefinition[] {
@@ -43,6 +60,62 @@ function getTeamSelectionStorageKey(theme?: string | null): string {
return `${TEAM_SELECTION_STORAGE_KEY_PREFIX}.${normalizeThemeScope(theme)}`;
}
function normalizeWorkspaceTeamSelectionReference(
value?: Partial<WorkspaceTeamSelectionReference> | null,
): TeamSelectionReference | null {
if (
typeof value?.id !== "string" ||
!value.id.trim() ||
(value.source !== "builtin" && value.source !== "custom")
) {
return null;
}
return {
id: value.id.trim(),
source: value.source,
};
}
function resolveTeamFromSelection(
selection?: TeamSelectionReference | null,
customTeams?: TeamDefinition[],
): TeamDefinition | null {
if (!selection) {
return null;
}
if (selection.source === "builtin") {
return createTeamDefinitionFromPreset(selection.id);
}
return (
(customTeams || loadCustomTeams()).find((team) => team.id === selection.id) ||
null
);
}
function normalizeWorkspaceCustomTeamList(
teams?: Array<Partial<WorkspaceAgentCustomTeamSettings>> | null,
): TeamDefinition[] | null {
if (!Array.isArray(teams)) {
return null;
}
return normalizeCustomTeamList(
teams.map((team) => ({
...team,
source: "custom" as const,
roles: Array.isArray(team.roles)
? team.roles.map((role) => ({
...role,
skillIds: Array.isArray(role.skillIds) ? [...role.skillIds] : [],
}))
: [],
})),
);
}
export function loadCustomTeams(): TeamDefinition[] {
try {
const raw = localStorage.getItem(CUSTOM_TEAM_STORAGE_KEY);
@@ -73,14 +146,12 @@ export function persistSelectedTeam(
): void {
try {
const key = getTeamSelectionStorageKey(theme);
if (!team) {
const selection = buildTeamSelectionReference(team);
if (!team || !selection) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(
key,
JSON.stringify(buildTeamSelectionReference(team)),
);
localStorage.setItem(key, JSON.stringify(selection));
} catch {
// ignore persistence errors
}
@@ -111,17 +182,135 @@ export function loadSelectedTeamReference(
}
}
export function resolveWorkspaceTeamPreferenceState(
settings?: WorkspaceSettings | null,
): WorkspaceTeamPreferenceState {
const agentTeam = settings?.agentTeam;
if (!agentTeam) {
return { kind: "unset" };
}
if (agentTeam.disabled) {
return { kind: "disabled" };
}
const selection = normalizeWorkspaceTeamSelectionReference(
agentTeam.selectedTeam,
);
return selection ? { kind: "selected", selection } : { kind: "unset" };
}
export function loadCustomTeamsFromWorkspaceSettings(
settings?: WorkspaceSettings | null,
): TeamDefinition[] | null {
return normalizeWorkspaceCustomTeamList(settings?.agentTeam?.customTeams);
}
export function resolveCustomTeams(
workspaceSettings?: WorkspaceSettings | null,
): TeamDefinition[] {
return loadCustomTeamsFromWorkspaceSettings(workspaceSettings) || loadCustomTeams();
}
export function loadSelectedTeamReferenceFromWorkspaceSettings(
settings?: WorkspaceSettings | null,
): TeamSelectionReference | null {
const state = resolveWorkspaceTeamPreferenceState(settings);
return state.kind === "selected" ? state.selection : null;
}
export function buildWorkspaceSettingsWithSelectedTeam(
currentSettings: WorkspaceSettings | null | undefined,
team: TeamDefinition | null,
): WorkspaceSettings {
const selection = buildTeamSelectionReference(team);
if (team?.source === "ephemeral") {
return {
...(currentSettings || {}),
agentTeam: {
...(currentSettings?.agentTeam || {}),
disabled: false,
selectedTeam: undefined,
},
};
}
return {
...(currentSettings || {}),
agentTeam: selection
? {
...(currentSettings?.agentTeam || {}),
disabled: false,
selectedTeam: selection,
}
: {
...(currentSettings?.agentTeam || {}),
disabled: true,
},
};
}
export function buildWorkspaceSettingsWithCustomTeams(
currentSettings: WorkspaceSettings | null | undefined,
teams: TeamDefinition[],
): WorkspaceSettings {
return {
...(currentSettings || {}),
agentTeam: {
...(currentSettings?.agentTeam || {}),
customTeams: normalizeCustomTeamList(teams).map((team) => ({
id: team.id,
label: team.label,
description: team.description,
theme: team.theme,
presetId: team.presetId,
roles: team.roles.map((role) => ({
id: role.id,
label: role.label,
summary: role.summary,
profileId: role.profileId,
roleKey: role.roleKey,
skillIds: role.skillIds ? [...role.skillIds] : [],
})),
createdAt: team.createdAt,
updatedAt: team.updatedAt,
})),
},
};
}
export function isSameTeamSelectionReference(
left?: TeamSelectionLike | null,
right?: TeamSelectionLike | null,
): boolean {
return (
(left?.id || "") === (right?.id || "") &&
(left?.source || "") === (right?.source || "")
);
}
export function resolvePersistedSelectedTeam(
theme?: string | null,
): TeamDefinition | null {
const selection = loadSelectedTeamReference(theme);
if (!selection) {
return resolveTeamFromSelection(loadSelectedTeamReference(theme));
}
export function resolveSelectedTeamPreference({
theme,
workspaceSettings,
}: ResolveSelectedTeamPreferenceOptions): TeamDefinition | null {
const workspaceState = resolveWorkspaceTeamPreferenceState(workspaceSettings);
const workspaceCustomTeams = loadCustomTeamsFromWorkspaceSettings(workspaceSettings);
if (workspaceState.kind === "disabled") {
return null;
}
if (selection.source === "builtin") {
return createTeamDefinitionFromPreset(selection.id);
if (workspaceState.kind === "selected") {
return resolveTeamFromSelection(
workspaceState.selection,
workspaceCustomTeams || undefined,
);
}
return loadCustomTeams().find((team) => team.id === selection.id) || null;
return resolvePersistedSelectedTeam(theme);
}
@@ -187,6 +187,12 @@ const PanelSection = styled.div`
gap: 10px;
`;
const SectionHint = styled.span`
font-size: 12px;
line-height: 1.5;
color: hsl(var(--muted-foreground));
`;
const PanelDivider = styled.div`
height: 1px;
background: hsl(var(--border));
@@ -576,6 +582,9 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = memo(
<Bot size={14} />
模型切换
</SectionTitle>
<SectionHint>
这里沿用当前 Claw 会话的 Provider、模型与思考过程设置。
</SectionHint>
<ModelSelector
className="w-full"
providerType={autoContinueProviderType}
@@ -1,5 +1,5 @@
import React from "react";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { LayoutMode } from "../../types";
import { LayoutTransition } from "./LayoutTransition";
import {
@@ -54,9 +54,34 @@ function PlainChatLayoutHarness({ mode }: { mode: LayoutMode }) {
describe("LayoutTransition", () => {
const mountedRoots: MountedRoot[] = [];
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
beforeEach(() => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 1440,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 900,
});
});
afterEach(() => {
cleanupMountedRoots(mountedRoots);
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: originalInnerWidth,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: originalInnerHeight,
});
});
it("chat-canvas 模式应为画布与对话保留分栏间距", () => {
@@ -80,6 +105,38 @@ describe("LayoutTransition", () => {
);
expect(hasGapRule).toBe(true);
expect(root?.getAttribute("data-layout-axis")).toBe("horizontal");
});
it("小屏 chat-canvas 模式应改为上下堆叠,避免右侧区域挤压不可见", () => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 1080,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 720,
});
const { container } = mountHarness(
LayoutHarness,
{ mode: "chat-canvas" },
mountedRoots,
);
const root = container.querySelector<HTMLElement>(
'[data-testid="layout-transition-root"]',
);
expect(root?.getAttribute("data-layout-axis")).toBe("vertical");
expect(
container.querySelector('[data-testid="layout-chat-content"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="layout-canvas-content"]'),
).not.toBeNull();
});
it("画布内容为空时应退回聊天布局,避免保留空白画布列", () => {
@@ -4,36 +4,76 @@
* @module components/content-creator/core/LayoutTransition/LayoutTransition
*/
import React, { memo } from "react";
import React, { memo, useEffect, useState } from "react";
import styled from "styled-components";
import { LayoutMode } from "../../types";
import { useLayoutTransition, TransitionConfig } from "./useLayoutTransition";
const Container = styled.div`
const STACKED_CHAT_CANVAS_BREAKPOINT_WIDTH = 1320;
const STACKED_CHAT_CANVAS_BREAKPOINT_HEIGHT = 820;
const STACKED_CHAT_CANVAS_PANEL_HEIGHT = "clamp(260px, 36%, 360px)";
function shouldUseStackedChatCanvasLayout(mode: LayoutMode): boolean {
if (mode !== "chat-canvas" || typeof window === "undefined") {
return false;
}
return (
window.innerWidth <= STACKED_CHAT_CANVAS_BREAKPOINT_WIDTH ||
window.innerHeight <= STACKED_CHAT_CANVAS_BREAKPOINT_HEIGHT
);
}
const Container = styled.div<{ $stacked: boolean }>`
display: flex;
flex-direction: ${({ $stacked }) => ($stacked ? "column" : "row")};
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
gap: 12px;
gap: ${({ $stacked }) => ($stacked ? "10px" : "12px")};
`;
const ChatPanel = styled.div<{
$width: string;
$duration: number;
$minWidth: string;
$stacked: boolean;
$hidden: boolean;
$chrome: "panel" | "plain";
}>`
height: 100%;
height: ${({ $stacked, $hidden }) =>
$hidden
? "0"
: $stacked
? STACKED_CHAT_CANVAS_PANEL_HEIGHT
: "100%"};
max-height: ${({ $stacked, $hidden }) =>
$hidden
? "0"
: $stacked
? STACKED_CHAT_CANVAS_PANEL_HEIGHT
: "100%"};
overflow: hidden;
transition: width ${({ $duration }) => $duration}ms ease-out;
width: ${({ $width }) => $width};
min-width: ${({ $minWidth }) => $minWidth};
will-change: width;
transition:
width ${({ $duration }) => $duration}ms ease-out,
height ${({ $duration }) => $duration}ms ease-out;
width: ${({ $stacked, $width, $hidden }) =>
$hidden ? "0" : $stacked ? "100%" : $width};
min-width: ${({ $stacked, $minWidth }) => ($stacked ? "0px" : $minWidth)};
min-height: ${({ $stacked, $hidden }) =>
$hidden ? "0" : $stacked ? "220px" : "100%"};
flex: ${({ $stacked, $hidden }) =>
$hidden
? "0 0 0"
: $stacked
? `0 0 ${STACKED_CHAT_CANVAS_PANEL_HEIGHT}`
: "0 0 auto"};
will-change: width, height;
display: ${({ $hidden }) => ($hidden ? "none" : "flex")};
flex-direction: column;
padding: ${({ $chrome }) =>
$chrome === "plain" ? "0" : "16px 16px 16px 0"};
padding: ${({ $stacked, $chrome }) =>
$stacked || $chrome === "plain" ? "0" : "16px 16px 16px 0"};
`;
const ChatPanelInner = styled.div`
@@ -56,14 +96,16 @@ const PlainChatPanelInner = styled.div`
const CanvasPanel = styled.div<{
$visible: boolean;
$stacked: boolean;
$transform: string;
$opacity: number;
$duration: number;
}>`
position: relative;
height: 100%;
height: ${({ $stacked }) => ($stacked ? "auto" : "100%")};
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
transition:
transform ${({ $duration }) => $duration}ms ease-out,
@@ -106,19 +148,43 @@ export const LayoutTransition: React.FC<LayoutTransitionProps> = memo(
effectiveMode,
transitionConfig,
);
const [stackedChatCanvas, setStackedChatCanvas] = useState(() =>
shouldUseStackedChatCanvasLayout(effectiveMode),
);
const chatStyles = getTransitionStyles("chat");
const canvasStyles = getTransitionStyles("canvas");
const shouldRenderCanvas = hasCanvasContent && isCanvasVisible;
useEffect(() => {
const updateLayout = () => {
setStackedChatCanvas(
shouldUseStackedChatCanvasLayout(effectiveMode),
);
};
updateLayout();
if (typeof window === "undefined") {
return;
}
window.addEventListener("resize", updateLayout);
return () => {
window.removeEventListener("resize", updateLayout);
};
}, [effectiveMode]);
return (
<Container
$stacked={stackedChatCanvas}
data-testid="layout-transition-root"
data-effective-mode={effectiveMode}
data-has-canvas={shouldRenderCanvas ? "true" : "false"}
data-layout-axis={stackedChatCanvas ? "vertical" : "horizontal"}
>
<CanvasPanel
$visible={shouldRenderCanvas}
$stacked={stackedChatCanvas}
$transform={canvasStyles.transform as string}
$opacity={canvasStyles.opacity as number}
$duration={parseInt(
@@ -133,7 +199,8 @@ export const LayoutTransition: React.FC<LayoutTransitionProps> = memo(
$duration={parseInt(
chatStyles.transition?.match(/\d+/)?.[0] || "300",
)}
$minWidth={effectiveMode === "canvas" ? "0px" : "460px"}
$minWidth={effectiveMode === "chat-canvas" ? "360px" : "0px"}
$stacked={stackedChatCanvas}
$hidden={effectiveMode === "canvas"}
$chrome={chatPanelChrome}
>
@@ -15,6 +15,7 @@ function HookHarness({ mode }: { mode: LayoutMode }) {
const { transitionState, isCanvasVisible, getTransitionStyles } =
useLayoutTransition(mode);
const canvasStyles = getTransitionStyles("canvas");
const chatStyles = getTransitionStyles("chat");
return (
<div
@@ -23,6 +24,7 @@ function HookHarness({ mode }: { mode: LayoutMode }) {
data-canvas-visible={String(isCanvasVisible)}
data-canvas-transform={String(canvasStyles.transform)}
data-canvas-opacity={String(canvasStyles.opacity)}
data-chat-width={String(chatStyles.width)}
/>
);
}
@@ -66,5 +68,8 @@ describe("useLayoutTransition", () => {
expect(root?.getAttribute("data-canvas-visible")).toBe("true");
expect(root?.getAttribute("data-canvas-transform")).toBe("translateX(0)");
expect(root?.getAttribute("data-canvas-opacity")).toBe("1");
expect(root?.getAttribute("data-chat-width")).toBe(
"min(100%, clamp(480px, 40%, 600px))",
);
});
});
@@ -34,6 +34,8 @@ const DEFAULT_CONFIG: Required<TransitionConfig> = {
exitDelay: 0,
};
const CHAT_CANVAS_PANEL_WIDTH = "min(100%, clamp(480px, 40%, 600px))";
/**
* 布局过渡 Hook
*
@@ -122,7 +124,11 @@ export function useLayoutTransition(
return {
transition: `width ${duration}ms ease-out`,
width:
mode === "chat-canvas" ? "35%" : mode === "canvas" ? "0%" : "100%",
mode === "chat-canvas"
? CHAT_CANVAS_PANEL_WIDTH
: mode === "canvas"
? "0%"
: "100%",
};
},
[transitionState, mergedConfig, mode],
@@ -24,6 +24,7 @@ import { getProjectByRootPath, updateProject } from "@/lib/api/project";
import { cn } from "@/lib/utils";
import { ClipboardPermissionGuideCard } from "@/components/settings-v2/system/shared/ClipboardPermissionGuideCard";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { notifyProjectRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService";
interface CrashRecoveryPanelProps {
error: Error | null;
@@ -207,6 +208,16 @@ export function CrashRecoveryPanel({
return;
}
await updateProject(workspace.id, { rootPath: newPath });
notifyProjectRuntimeAgentsGuide(
{
id: workspace.id,
rootPath: newPath,
},
{
successMessage: "Workspace 路径已更新",
showSuccessWhenGuideAlreadySeen: false,
},
);
setMessage({
type: "success",
text: `Workspace 路径已更新为:${newPath}`,
+3 -2
View File
@@ -31,6 +31,7 @@ import { DeleteProjectDialog } from "./DeleteProjectDialog";
import { ContentListPage } from "./ContentListPage";
import { toast } from "sonner";
import { Page, PageParams } from "@/types/page";
import { notifyProjectCreatedWithRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService";
interface ProjectsPageProps {
onNavigate?: (page: Page, params?: PageParams) => void;
@@ -166,13 +167,13 @@ export function ProjectsPage({ onNavigate }: ProjectsPageProps) {
try {
const projectPath = await resolveProjectRootPath(name);
await createProject({
const createdProject = await createProject({
name,
rootPath: projectPath,
workspaceType: type,
});
toast.success("项目创建成功");
notifyProjectCreatedWithRuntimeAgentsGuide(createdProject, "项目创建成功");
loadProjects();
} catch (error) {
console.error("创建项目失败:", error);
@@ -8,6 +8,8 @@ const {
mockGetMemoryOverview,
mockGetMemoryEffectiveSources,
mockGetMemoryAutoIndex,
mockEnsureWorkspaceLocalAgentsGitignore,
mockScaffoldRuntimeAgentsTemplate,
mockToggleMemoryAuto,
mockUpdateMemoryAutoNote,
mockGetUnifiedMemoryStats,
@@ -18,6 +20,8 @@ const {
mockGetMemoryOverview: vi.fn(),
mockGetMemoryEffectiveSources: vi.fn(),
mockGetMemoryAutoIndex: vi.fn(),
mockEnsureWorkspaceLocalAgentsGitignore: vi.fn(),
mockScaffoldRuntimeAgentsTemplate: vi.fn(),
mockToggleMemoryAuto: vi.fn(),
mockUpdateMemoryAutoNote: vi.fn(),
mockGetUnifiedMemoryStats: vi.fn(),
@@ -33,6 +37,8 @@ vi.mock("@/lib/api/memoryRuntime", () => ({
getContextMemoryOverview: mockGetMemoryOverview,
getContextMemoryEffectiveSources: mockGetMemoryEffectiveSources,
getContextMemoryAutoIndex: mockGetMemoryAutoIndex,
ensureWorkspaceLocalAgentsGitignore: mockEnsureWorkspaceLocalAgentsGitignore,
scaffoldRuntimeAgentsTemplate: mockScaffoldRuntimeAgentsTemplate,
toggleContextMemoryAuto: mockToggleMemoryAuto,
updateContextMemoryAutoNote: mockUpdateMemoryAutoNote,
}));
@@ -183,6 +189,17 @@ beforeEach(() => {
items: [],
});
mockToggleMemoryAuto.mockResolvedValue({ enabled: false });
mockScaffoldRuntimeAgentsTemplate.mockResolvedValue({
target: "workspace",
path: "/tmp/.lime/AGENTS.md",
status: "created",
createdParentDir: true,
});
mockEnsureWorkspaceLocalAgentsGitignore.mockResolvedValue({
path: "/tmp/.gitignore",
entry: ".lime/AGENTS.local.md",
status: "added",
});
mockUpdateMemoryAutoNote.mockResolvedValue({
enabled: true,
root_dir: "/tmp/memory",
@@ -255,4 +272,34 @@ describe("MemorySettings", () => {
expect(mockUpdateMemoryAutoNote).not.toHaveBeenCalled();
expect(container.textContent).toContain("请先输入要保存的自动记忆内容");
});
it("点击生成 Workspace 模板应调用模板生成 API", async () => {
const container = renderComponent();
await flushEffects();
await flushEffects();
await act(async () => {
findButton(container, "生成 Workspace 模板").click();
});
expect(mockScaffoldRuntimeAgentsTemplate).toHaveBeenCalledWith(
"workspace",
"/tmp",
false,
);
});
it("点击加入 .gitignore 应调用 gitignore API", async () => {
const container = renderComponent();
await flushEffects();
await flushEffects();
await act(async () => {
findButton(container, "将本机模板加入 .gitignore").click();
});
expect(mockEnsureWorkspaceLocalAgentsGitignore).toHaveBeenCalledWith(
"/tmp",
);
});
});
@@ -23,6 +23,8 @@ import {
getContextMemoryAutoIndex,
getContextMemoryEffectiveSources,
getContextMemoryOverview,
ensureWorkspaceLocalAgentsGitignore,
scaffoldRuntimeAgentsTemplate,
toggleContextMemoryAuto,
updateContextMemoryAutoNote,
type AutoMemoryIndexResponse,
@@ -32,6 +34,7 @@ import {
type MemoryProfileConfig,
type MemoryResolveConfig,
type MemorySourcesConfig,
type RuntimeAgentsTemplateTarget,
} from "@/lib/api/memoryRuntime";
import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig";
import { getUnifiedMemoryStats } from "@/lib/api/unifiedMemory";
@@ -95,7 +98,7 @@ function normalizeSources(sources?: MemorySourcesConfig): MemorySourcesConfig {
sources?.project_memory_paths?.length &&
sources.project_memory_paths.filter((item) => item.trim().length > 0)
? sources.project_memory_paths
: ["AGENTS.md", ".agents/AGENTS.md"],
: [".lime/AGENTS.md"],
project_rule_dirs:
sources?.project_rule_dirs?.length &&
sources.project_rule_dirs.filter((item) => item.trim().length > 0)
@@ -103,7 +106,7 @@ function normalizeSources(sources?: MemorySourcesConfig): MemorySourcesConfig {
: [".agents/rules"],
user_memory_path: sources?.user_memory_path ?? undefined,
project_local_memory_path:
sources?.project_local_memory_path ?? "AGENTS.local.md",
sources?.project_local_memory_path ?? ".lime/AGENTS.local.md",
};
}
@@ -340,6 +343,9 @@ export function MemorySettings() {
const [loadingLayerMetrics, setLoadingLayerMetrics] = useState(false);
const [loadingSourceState, setLoadingSourceState] = useState(false);
const [savingAutoNote, setSavingAutoNote] = useState(false);
const [scaffoldingTarget, setScaffoldingTarget] =
useState<RuntimeAgentsTemplateTarget | null>(null);
const [ensuringGitignore, setEnsuringGitignore] = useState(false);
const [projectId, setProjectId] = useState<string | null>(() =>
getStoredResourceProjectId({ includeLegacy: true }),
);
@@ -551,6 +557,65 @@ export function MemorySettings() {
}
};
const handleScaffoldRuntimeAgentsTemplate = async (
target: RuntimeAgentsTemplateTarget,
) => {
const workingDir = effectiveSources?.working_dir?.trim() || undefined;
const targetLabelMap: Record<RuntimeAgentsTemplateTarget, string> = {
global: "全局",
workspace: "Workspace",
workspace_local: "本机私有",
};
if (target !== "global" && !workingDir) {
setMessage(`当前未获取到 workspace 路径,暂无法生成${targetLabelMap[target]}模板`);
setTimeout(() => setMessage(null), 2500);
return;
}
setScaffoldingTarget(target);
try {
const result = await scaffoldRuntimeAgentsTemplate(target, workingDir, false);
if (result.status === "exists") {
setMessage(`${targetLabelMap[target]}模板已存在,未覆盖:${result.path}`);
} else {
setMessage(`已生成${targetLabelMap[target]}模板:${result.path}`);
}
setTimeout(() => setMessage(null), 3000);
await Promise.all([loadSourceState(), loadLayerMetrics()]);
} catch (error) {
console.error("生成运行时 AGENTS 模板失败:", error);
setMessage(`生成${targetLabelMap[target]}模板失败`);
setTimeout(() => setMessage(null), 2500);
} finally {
setScaffoldingTarget(null);
}
};
const handleEnsureWorkspaceLocalGitignore = async () => {
const workingDir = effectiveSources?.working_dir?.trim() || undefined;
if (!workingDir) {
setMessage("当前未获取到 workspace 路径,暂无法更新 .gitignore");
setTimeout(() => setMessage(null), 2500);
return;
}
setEnsuringGitignore(true);
try {
const result = await ensureWorkspaceLocalAgentsGitignore(workingDir);
const actionText =
result.status === "exists" ? "已存在,无需重复添加" : "已写入";
setMessage(`${actionText} .gitignore:${result.path}`);
setTimeout(() => setMessage(null), 3000);
} catch (error) {
console.error("更新 .gitignore 失败:", error);
setMessage("更新 .gitignore 失败");
setTimeout(() => setMessage(null), 2500);
} finally {
setEnsuringGitignore(false);
}
};
if (loading) {
return <LoadingSkeleton />;
}
@@ -916,7 +981,7 @@ export function MemorySettings() {
}))
}
className={INPUT_CLASS_NAME}
placeholder="例如 /Library/Application Support/Lime/AGENTS.md"
placeholder="例如 ~/.lime/AGENTS.md"
/>
</label>
@@ -937,7 +1002,7 @@ export function MemorySettings() {
}))
}
className={INPUT_CLASS_NAME}
placeholder="留空时使用应用默认 AGENTS.md 路径"
placeholder="留空时使用应用默认 ~/.lime/AGENTS.md 路径"
/>
</label>
@@ -959,10 +1024,81 @@ export function MemorySettings() {
}))
}
className={INPUT_CLASS_NAME}
placeholder="例如 AGENTS.local.md"
placeholder="例如 .lime/AGENTS.local.md"
/>
</label>
</div>
<div className="mt-4 rounded-[20px] border border-slate-200/80 bg-white/80 p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-sm font-semibold text-slate-900">
显式生成模板
</p>
<p className="mt-1 text-xs leading-5 text-slate-500">
只在你点击时创建模板文件,不会静默生成,也不会默认覆盖已有内容。
</p>
</div>
<span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-[11px] font-medium text-slate-500">
当前 Workspace:{effectiveSources?.working_dir || "未解析"}
</span>
</div>
<div className="mt-4 flex flex-wrap gap-2.5">
<button
type="button"
onClick={() =>
void handleScaffoldRuntimeAgentsTemplate("global")
}
disabled={scaffoldingTarget !== null}
className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:opacity-60"
>
{scaffoldingTarget === "global"
? "生成中..."
: "生成全局模板"}
</button>
<button
type="button"
onClick={() =>
void handleScaffoldRuntimeAgentsTemplate("workspace")
}
disabled={
scaffoldingTarget !== null || !effectiveSources?.working_dir
}
className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:opacity-60"
>
{scaffoldingTarget === "workspace"
? "生成中..."
: "生成 Workspace 模板"}
</button>
<button
type="button"
onClick={() =>
void handleScaffoldRuntimeAgentsTemplate(
"workspace_local",
)
}
disabled={
scaffoldingTarget !== null || !effectiveSources?.working_dir
}
className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:opacity-60"
>
{scaffoldingTarget === "workspace_local"
? "生成中..."
: "生成本机模板"}
</button>
<button
type="button"
onClick={() => void handleEnsureWorkspaceLocalGitignore()}
disabled={ensuringGitignore || !effectiveSources?.working_dir}
className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:opacity-60"
>
{ensuringGitignore
? "写入中..."
: "将本机模板加入 .gitignore"}
</button>
</div>
</div>
</div>
<div className="rounded-[22px] border border-slate-200/80 bg-slate-50/60 p-4">
@@ -1047,7 +1183,7 @@ export function MemorySettings() {
项目记忆文件
</span>
<span className="text-xs leading-5 text-slate-500">
每行一个相对路径,例如 `AGENTS.md`。
每行一个相对路径,例如 `.lime/AGENTS.md`。
</span>
<textarea
value={(sourcesConfig.project_memory_paths || []).join("\n")}
+118 -27
View File
@@ -22,8 +22,7 @@ import {
} from "@/lib/api/appUpdate";
import { cn } from "@/lib/utils";
const FALLBACK_RELEASES_URL =
"https://github.com/aiclientproxy/lime/releases";
const FALLBACK_RELEASES_URL = "https://github.com/aiclientproxy/lime/releases";
const CREATIVE_THEMES = [
"通用对话",
@@ -74,6 +73,23 @@ const RELATED_LINKS = [
},
] as const;
function formatUpdateDate(value?: string) {
if (!value) {
return null;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(parsed);
}
interface AboutPanelProps {
icon: LucideIcon;
title: string;
@@ -150,11 +166,10 @@ export function AboutSection() {
const loadCurrentVersion = async () => {
try {
const result = await checkForUpdates();
setVersionInfo((prev) => ({
...prev,
current: result.current,
setVersionInfo({
...result,
downloadUrl: result.downloadUrl || FALLBACK_RELEASES_URL,
}));
});
} catch (error) {
console.error("Failed to load version:", error);
setVersionInfo((prev) => ({
@@ -200,8 +215,8 @@ export function AboutSection() {
setDownloadResult({
...result,
message: t(
"安装程序已启动,应用将自动关闭以完成更新",
"安装程序已启动,应用将自动关闭以完成更新",
"更新已安装,应用将自动重启以完成升级",
"更新已安装,应用将自动重启以完成升级",
),
});
}, 1000);
@@ -251,6 +266,20 @@ export function AboutSection() {
};
}, [t, versionInfo.error, versionInfo.hasUpdate, versionInfo.latest]);
const releaseNotesPreview = useMemo(() => {
const notes = versionInfo.releaseNotes?.trim();
if (!notes) {
return null;
}
return notes.length > 220 ? `${notes.slice(0, 220)}...` : notes;
}, [versionInfo.releaseNotes]);
const updatePublishedAt = useMemo(
() => formatUpdateDate(versionInfo.pubDate),
[versionInfo.pubDate],
);
return (
<div className="space-y-6 pb-8">
<section className="relative overflow-hidden rounded-[30px] border border-emerald-200/70 bg-[linear-gradient(135deg,rgba(245,250,248,0.98)_0%,rgba(255,255,255,0.98)_52%,rgba(242,247,255,0.96)_100%)] shadow-sm shadow-slate-950/5">
@@ -278,13 +307,22 @@ export function AboutSection() {
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/90 bg-white/88 px-3 py-1.5 text-xs font-medium text-slate-600 shadow-sm">
{t("一句话:从“想到”直接走到“可发布”", "一句话:从“想到”直接走到“可发布”")}
{t(
"一句话:从“想到”直接走到“可发布”",
"一句话:从“想到”直接走到“可发布”",
)}
</span>
<span className="rounded-full border border-white/90 bg-white/88 px-3 py-1.5 text-xs font-medium text-slate-600 shadow-sm">
{t("支持多主题创作与跨媒介工作流", "支持多主题创作与跨媒介工作流")}
{t(
"支持多主题创作与跨媒介工作流",
"支持多主题创作与跨媒介工作流",
)}
</span>
<span className="rounded-full border border-white/90 bg-white/88 px-3 py-1.5 text-xs font-medium text-slate-600 shadow-sm">
{t("适合个人创作者与小团队长期沉淀资产", "适合个人创作者与小团队长期沉淀资产")}
{t(
"适合个人创作者与小团队长期沉淀资产",
"适合个人创作者与小团队长期沉淀资产",
)}
</span>
</div>
</div>
@@ -317,8 +355,7 @@ export function AboutSection() {
</p>
<p className="mt-2 text-sm leading-6 text-slate-600">
{t("当前版本 {{version}}", {
version:
versionInfo.current || t("读取中", "读取中"),
version: versionInfo.current || t("读取中", "读取中"),
defaultValue: "当前版本 {{version}}",
})}
</p>
@@ -375,6 +412,29 @@ export function AboutSection() {
</p>
) : null}
{versionInfo.hasUpdate &&
(releaseNotesPreview || updatePublishedAt) ? (
<div className="mt-4 rounded-[20px] border border-slate-200 bg-slate-50/85 p-4 text-sm text-slate-600 shadow-sm shadow-slate-950/5">
<p className="font-medium text-slate-700">
{t("最新版本 {{version}}", {
version: versionInfo.latest ?? "",
defaultValue: "最新版本 {{version}}",
})}
{updatePublishedAt
? ` · ${t("发布时间 {{date}}", {
date: updatePublishedAt,
defaultValue: "发布时间 {{date}}",
})}`
: ""}
</p>
{releaseNotesPreview ? (
<p className="mt-2 text-xs leading-6 text-slate-500">
{releaseNotesPreview}
</p>
) : null}
</div>
) : null}
{downloadResult ? (
<div
className={cn(
@@ -410,22 +470,34 @@ export function AboutSection() {
<AboutStat
label={t("创作主题", "创作主题")}
value={CREATIVE_THEMES.length.toString()}
description={t("覆盖对话、海报、短视频、小说等常见创作场景。", "覆盖对话、海报、短视频、小说等常见创作场景。")}
description={t(
"覆盖对话、海报、短视频、小说等常见创作场景。",
"覆盖对话、海报、短视频、小说等常见创作场景。",
)}
/>
<AboutStat
label={t("起步步骤", "起步步骤")}
value={QUICK_START_STEPS.length.toString()}
description={t("从选主题到持续迭代,压缩成清晰的三步流程。", "从选主题到持续迭代,压缩成清晰的三步流程。")}
description={t(
"从选主题到持续迭代,压缩成清晰的三步流程。",
"从选主题到持续迭代,压缩成清晰的三步流程。",
)}
/>
<AboutStat
label={t("目标人群", "目标人群")}
value={TARGET_USERS.length.toString()}
description={t("既适合单人创作,也适合需要共用策略的小团队。", "既适合单人创作,也适合需要共用策略的小团队。")}
description={t(
"既适合单人创作,也适合需要共用策略的小团队。",
"既适合单人创作,也适合需要共用策略的小团队。",
)}
/>
<AboutStat
label={t("官方入口", "官方入口")}
value={RELATED_LINKS.length.toString()}
description={t("源码、文档和 issue 入口集中保留,方便反馈和跟进。", "源码、文档和 issue 入口集中保留,方便反馈和跟进。")}
description={t(
"源码、文档和 issue 入口集中保留,方便反馈和跟进。",
"源码、文档和 issue 入口集中保留,方便反馈和跟进。",
)}
/>
</div>
</div>
@@ -436,7 +508,10 @@ export function AboutSection() {
<AboutPanel
icon={Compass}
title={t("产品定位", "产品定位")}
description={t("先说明产品解决什么问题,再说明为什么它和传统聊天工具不同。", "先说明产品解决什么问题,再说明为什么它和传统聊天工具不同。")}
description={t(
"先说明产品解决什么问题,再说明为什么它和传统聊天工具不同。",
"先说明产品解决什么问题,再说明为什么它和传统聊天工具不同。",
)}
>
<div className="space-y-4">
<div className="rounded-[22px] border border-slate-200/80 bg-slate-50/60 p-4 text-sm leading-7 text-slate-600">
@@ -450,7 +525,10 @@ export function AboutSection() {
{t("一句话总结", "一句话总结")}
</p>
<p className="mt-2 text-sm leading-6 text-slate-500">
{t("从“想到”直接走到“可发布”。", "从“想到”直接走到“可发布”。")}
{t(
"从“想到”直接走到“可发布”。",
"从“想到”直接走到“可发布”。",
)}
</p>
</div>
</div>
@@ -459,7 +537,10 @@ export function AboutSection() {
<AboutPanel
icon={Rocket}
title={t("3 步开始创作", "3 步开始创作")}
description={t("把首次上手流程压缩成三步,避免用户在设置里迷路。", "把首次上手流程压缩成三步,避免用户在设置里迷路。")}
description={t(
"把首次上手流程压缩成三步,避免用户在设置里迷路。",
"把首次上手流程压缩成三步,避免用户在设置里迷路。",
)}
>
<div className="grid gap-3 lg:grid-cols-3">
{QUICK_START_STEPS.map((step, index) => (
@@ -481,7 +562,10 @@ export function AboutSection() {
<AboutPanel
icon={Users}
title={t("适合谁", "适合谁")}
description={t("更像是创作工作台而不是纯聊天窗口,因此目标人群也更明确。", "更像是创作工作台而不是纯聊天窗口,因此目标人群也更明确。")}
description={t(
"更像是创作工作台而不是纯聊天窗口,因此目标人群也更明确。",
"更像是创作工作台而不是纯聊天窗口,因此目标人群也更明确。",
)}
>
<div className="grid gap-3 sm:grid-cols-2">
{TARGET_USERS.map((user) => (
@@ -505,7 +589,10 @@ export function AboutSection() {
<AboutPanel
icon={Layers3}
title={t("支持的创作主题", "支持的创作主题")}
description={t("主题不是简单分类,而是预设好的工作上下文与内容目标。", "主题不是简单分类,而是预设好的工作上下文与内容目标。")}
description={t(
"主题不是简单分类,而是预设好的工作上下文与内容目标。",
"主题不是简单分类,而是预设好的工作上下文与内容目标。",
)}
aside={
<span className="rounded-full border border-slate-200 bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600">
{CREATIVE_THEMES.length} {t("项", "项")}
@@ -527,7 +614,10 @@ export function AboutSection() {
<AboutPanel
icon={BookOpen}
title={t("相关链接", "相关链接")}
description={t("源码、文档和问题反馈保持直达,减少寻找成本。", "源码、文档和问题反馈保持直达,减少寻找成本。")}
description={t(
"源码、文档和问题反馈保持直达,减少寻找成本。",
"源码、文档和问题反馈保持直达,减少寻找成本。",
)}
>
<div className="space-y-3">
{RELATED_LINKS.map((link) => (
@@ -555,7 +645,10 @@ export function AboutSection() {
<AboutPanel
icon={Wand2}
title={t("可选能力", "可选能力")}
description={t("这两类说明更偏进阶使用场景,不应和主叙事混在一起。", "这两类说明更偏进阶使用场景,不应和主叙事混在一起。")}
description={t(
"这两类说明更偏进阶使用场景,不应和主叙事混在一起。",
"这两类说明更偏进阶使用场景,不应和主叙事混在一起。",
)}
>
<div className="space-y-3">
{PRODUCT_CAPABILITIES.map((item) => (
@@ -576,9 +669,7 @@ export function AboutSection() {
<Sparkles className="h-4 w-4 text-emerald-600" />
{t("Made for creators & builders", "Made for creators & builders")}
</div>
<p className="mt-2 text-sm leading-6 text-slate-500">
2025-2026 Lime
</p>
<p className="mt-2 text-sm leading-6 text-slate-500">2025-2026 Lime</p>
</section>
</div>
);

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