feat: finalize latest v0.97.0 release payload

This commit is contained in:
coso
2026-03-27 21:46:17 +08:00
parent dedf46c4be
commit 02ed1cd5b9
42 changed files with 1913 additions and 89 deletions
@@ -2075,6 +2075,7 @@ export function AgentChatWorkspace({
normalizedEntryTheme,
shouldBootstrapCanvasOnEntry,
canvasState,
generalCanvasState,
showTeamWorkspaceBoard: teamSessionRuntime.showTeamWorkspaceBoard,
hasCurrentCanvasArtifact: Boolean(currentCanvasArtifact),
currentCanvasArtifactType: currentCanvasArtifact?.type,
@@ -9,8 +9,13 @@ import { A2UITaskCard } from "../../A2UITaskCard";
interface A2UIFloatingFormProps {
response: A2UIResponse;
onSubmit: (formData: A2UIFormData) => void;
submitDisabled?: boolean;
isStale?: boolean;
}
const STALE_STATUS_LABEL = "同步中";
const STALE_FOOTER_TEXT = "正在同步最新上下文,表单暂时不可提交。";
const Card = styled.div`
position: relative;
margin-bottom: 10px;
@@ -34,14 +39,19 @@ const Card = styled.div`
export function A2UIFloatingForm({
response,
onSubmit,
submitDisabled = false,
isStale = false,
}: A2UIFloatingFormProps) {
return (
<Card>
<A2UITaskCard
response={response}
onSubmit={onSubmit}
submitDisabled={submitDisabled}
compact={true}
preset={CHAT_FLOATING_A2UI_TASK_CARD_PRESET}
statusLabel={isStale ? STALE_STATUS_LABEL : undefined}
footerText={isStale ? STALE_FOOTER_TEXT : undefined}
className="m-0"
/>
</Card>
@@ -3,6 +3,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InputbarOverlayShell } from "./InputbarOverlayShell";
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
vi.mock("../../TaskFiles", () => ({
TaskFileList: () => <div data-testid="task-file-list" />,
@@ -66,6 +67,34 @@ function renderShell(
return container;
}
function createPendingA2UIForm(): A2UIResponse {
return {
id: "a2ui-floating-form",
version: "1.0",
root: "root",
data: {},
components: [
{
id: "title",
component: "Text",
text: "请确认开始方式",
variant: "body",
},
{
id: "root",
component: "Column",
children: ["title"],
gap: 12,
align: "stretch",
},
],
submitAction: {
label: "继续处理",
action: { name: "submit" },
},
} as A2UIResponse;
}
describe("InputbarOverlayShell", () => {
it("应把任务文件与额外浮层控件放进同一条透明 overlay row", () => {
const container = renderShell({
@@ -102,4 +131,23 @@ describe("InputbarOverlayShell", () => {
container.querySelector('[data-testid="inputbar-secondary-controls"]'),
).toBeNull();
});
it("A2UI 表单进入粘性保留期时应显示同步提示并禁用提交", () => {
const container = renderShell({
taskFiles: [],
pendingA2UIForm: createPendingA2UIForm(),
pendingA2UIFormStale: true,
onA2UISubmit: vi.fn(),
});
const submitButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("继续处理"),
) as HTMLButtonElement | undefined;
expect(container.textContent).toContain("同步中");
expect(container.textContent).toContain(
"正在同步最新上下文,表单暂时不可提交。",
);
expect(submitButton?.disabled).toBe(true);
});
});
@@ -1,7 +1,10 @@
import React, { type ChangeEvent, type RefObject } from "react";
import styled from "styled-components";
import type { TaskFile } from "../../TaskFiles";
import type { A2UIResponse, A2UIFormData } from "@/components/content-creator/a2ui/types";
import type {
A2UIFormData,
A2UIResponse,
} from "@/components/content-creator/a2ui/types";
import {
A2UISubmissionNotice,
type A2UISubmissionNoticeData,
@@ -25,6 +28,7 @@ interface InputbarOverlayShellProps {
submissionNotice?: A2UISubmissionNoticeData | null;
isSubmissionNoticeVisible: boolean;
pendingA2UIForm?: A2UIResponse | null;
pendingA2UIFormStale?: boolean;
onA2UISubmit?: (formData: A2UIFormData) => void;
fileInputRef: RefObject<HTMLInputElement>;
onFileSelect: (event: ChangeEvent<HTMLInputElement>) => void;
@@ -63,6 +67,7 @@ export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
submissionNotice,
isSubmissionNoticeVisible,
pendingA2UIForm,
pendingA2UIFormStale = false,
onA2UISubmit,
fileInputRef,
onFileSelect,
@@ -94,7 +99,12 @@ export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
/>
) : null}
{pendingA2UIForm && onA2UISubmit ? (
<A2UIFloatingForm response={pendingA2UIForm} onSubmit={onA2UISubmit} />
<A2UIFloatingForm
response={pendingA2UIForm}
onSubmit={onA2UISubmit}
submitDisabled={pendingA2UIFormStale}
isStale={pendingA2UIFormStale}
/>
) : null}
<input
ref={fileInputRef}
@@ -181,7 +181,10 @@ export function useInputbarController({
a2uiSubmissionNotice,
});
const { visibleForm: visiblePendingA2UIForm } = useStickyA2UIForm({
const {
visibleForm: visiblePendingA2UIForm,
isStale: isPendingA2UIFormStale,
} = useStickyA2UIForm({
form: pendingA2UIForm,
clearImmediately: Boolean(a2uiSubmissionNotice),
});
@@ -272,6 +275,7 @@ export function useInputbarController({
themeWorkbenchQueueItems,
renderThemeWorkbenchGeneratingPanel,
visiblePendingA2UIForm,
isPendingA2UIFormStale,
visibleA2UISubmissionNotice,
isA2UISubmissionNoticeVisible,
activeSkill,
@@ -89,21 +89,25 @@ describe("useStickyA2UIForm", () => {
await render();
expect(getValue().visibleForm).toEqual(form);
expect(getValue().isStale).toBe(false);
await render({
form: null,
});
expect(getValue().visibleForm).toEqual(form);
expect(getValue().isStale).toBe(true);
await act(async () => {
vi.advanceTimersByTime(1199);
});
expect(getValue().visibleForm).toEqual(form);
expect(getValue().isStale).toBe(true);
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(getValue().visibleForm).toBeNull();
expect(getValue().isStale).toBe(false);
});
it("进入已提交提示后应立即清空旧表单,避免叠层残留", async () => {
@@ -121,5 +125,6 @@ describe("useStickyA2UIForm", () => {
clearImmediately: true,
});
expect(getValue().visibleForm).toBeNull();
expect(getValue().isStale).toBe(false);
});
});
@@ -15,7 +15,11 @@ export function useStickyA2UIForm({
const [visibleForm, setVisibleForm] = useState<A2UIResponse | null>(
form || null,
);
const [isStale, setIsStale] = useState(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const visibleFormRef = useRef<A2UIResponse | null>(visibleForm);
visibleFormRef.current = visibleForm;
useEffect(() => {
return () => {
@@ -33,16 +37,26 @@ export function useStickyA2UIForm({
if (form) {
setVisibleForm(form);
setIsStale(false);
return;
}
if (clearImmediately) {
setVisibleForm(null);
setIsStale(false);
return;
}
if (!visibleFormRef.current) {
setVisibleForm(null);
setIsStale(false);
return;
}
setIsStale(true);
hideTimerRef.current = setTimeout(() => {
setVisibleForm(null);
setIsStale(false);
hideTimerRef.current = null;
}, holdMs);
@@ -56,5 +70,6 @@ export function useStickyA2UIForm({
return {
visibleForm,
isStale,
};
}
@@ -6,6 +6,7 @@ import { Inputbar } from "./index";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
const mockCharacterMention =
vi.fn<
@@ -202,6 +203,7 @@ afterEach(() => {
});
mounted.container.remove();
}
vi.useRealTimers();
vi.clearAllMocks();
});
@@ -221,17 +223,54 @@ function renderInputbar(
skills: [],
};
act(() => {
root.render(<Inputbar {...defaultProps} {...props} />);
});
const render = (
nextProps?: Partial<React.ComponentProps<typeof Inputbar>>,
) => {
act(() => {
root.render(<Inputbar {...defaultProps} {...props} {...nextProps} />);
});
};
render();
mountedRoots.push({ root, container });
return container;
return {
container,
rerender: render,
};
}
function createPendingA2UIForm(): A2UIResponse {
return {
id: "a2ui-pending",
version: "1.0",
root: "root",
data: {},
components: [
{
id: "title",
component: "Text",
text: "请确认开始方式",
variant: "body",
},
{
id: "root",
component: "Column",
children: ["title"],
gap: 12,
align: "stretch",
},
],
submitAction: {
label: "继续处理",
action: { name: "submit" },
},
} as A2UIResponse;
}
describe("Inputbar", () => {
it("即使角色和技能为空,也应挂载 CharacterMention", async () => {
const container = renderInputbar();
const { container } = renderInputbar();
await act(async () => {
await Promise.resolve();
});
@@ -278,7 +317,7 @@ describe("Inputbar", () => {
});
it("存在 executionRuntime 时应展示最近执行模型与结构化输出提示", async () => {
const container = renderInputbar({
const { container } = renderInputbar({
providerType: "openai",
setProviderType: vi.fn(),
model: "gpt-5.4-mini",
@@ -353,7 +392,7 @@ describe("Inputbar", () => {
it("通用聊天态复杂任务应显示 Team 建议并支持开启多代理", async () => {
const onToolStatesChange = vi.fn();
const container = renderInputbar({
const { container } = renderInputbar({
input: "请拆分这个多代理调试任务,分别分析、实现、验证,再由主线程汇总结论。",
activeTheme: "general",
toolStates: {
@@ -390,7 +429,7 @@ describe("Inputbar", () => {
});
it("仅在 Team mode 开启时显示 TeamSelector", async () => {
const container = renderInputbar({
const { container } = renderInputbar({
activeTheme: "general",
toolStates: {
webSearch: false,
@@ -408,7 +447,7 @@ describe("Inputbar", () => {
container.querySelector('[data-testid="team-selector-stub"]'),
).toBeNull();
const enabledContainer = renderInputbar({
const { container: enabledContainer } = renderInputbar({
activeTheme: "general",
toolStates: {
webSearch: false,
@@ -429,7 +468,7 @@ describe("Inputbar", () => {
it("未开启 Team mode 时应只保留图标开关,并可直接启用", async () => {
const onToolStatesChange = vi.fn();
const container = renderInputbar({
const { container } = renderInputbar({
activeTheme: "general",
toolStates: {
webSearch: false,
@@ -467,7 +506,7 @@ describe("Inputbar", () => {
});
it("点击多代理图标后应自动透传 Team 配置面板打开令牌", async () => {
const container = renderInputbar({
const { container } = renderInputbar({
activeTheme: "general",
});
@@ -500,7 +539,7 @@ describe("Inputbar", () => {
});
it("复杂任务但未开启 Team 时,保留推荐提示但不再渲染重复入口", async () => {
const container = renderInputbar({
const { container } = renderInputbar({
activeTheme: "general",
input: "请把这个跨模块问题拆分成分析、实现、验证三个并行子任务再汇总",
toolStates: {
@@ -531,7 +570,7 @@ describe("Inputbar", () => {
it("社媒主题默认应自动注入 social_post_with_cover skill", async () => {
const onSend = vi.fn();
const container = renderInputbar({
const { container } = renderInputbar({
activeTheme: "social-media",
input: "写一篇春季上新种草文案",
onSend,
@@ -562,7 +601,7 @@ describe("Inputbar", () => {
it("社媒主题输入 slash 命令时不应重复注入默认 skill", async () => {
const onSend = vi.fn();
const container = renderInputbar({
const { container } = renderInputbar({
activeTheme: "social-media",
input: "/custom_skill 写一篇品牌故事",
onSend,
@@ -618,7 +657,7 @@ describe("Inputbar", () => {
});
it("主题工作台在待启动状态下不应显示闸门条", async () => {
const container = renderInputbar({
const { container } = renderInputbar({
variant: "theme_workbench",
themeWorkbenchGate: {
key: "draft_start",
@@ -639,7 +678,7 @@ describe("Inputbar", () => {
it("主题工作台闸门快捷操作应能快速填充输入", async () => {
const setInput = vi.fn();
const container = renderInputbar({
const { container } = renderInputbar({
variant: "theme_workbench",
setInput,
themeWorkbenchGate: {
@@ -677,7 +716,7 @@ describe("Inputbar", () => {
it("主题工作台生成中应展示任务面板并支持停止", async () => {
const onStop = vi.fn();
const container = renderInputbar({
const { container } = renderInputbar({
variant: "theme_workbench",
isLoading: true,
onStop,
@@ -707,7 +746,7 @@ describe("Inputbar", () => {
});
it("主题工作台生成中应支持折叠与展开待办列表", async () => {
const container = renderInputbar({
const { container } = renderInputbar({
variant: "theme_workbench",
isLoading: true,
workflowSteps: [
@@ -747,7 +786,7 @@ describe("Inputbar", () => {
});
it("主题工作台在 auto_running 状态下应展示生成面板(不依赖 isLoading)", async () => {
const container = renderInputbar({
const { container } = renderInputbar({
variant: "theme_workbench",
isLoading: false,
themeWorkbenchRunState: "auto_running",
@@ -768,7 +807,7 @@ describe("Inputbar", () => {
});
it("主题工作台在 await_user_decision 状态下应显示输入框", async () => {
const container = renderInputbar({
const { container } = renderInputbar({
variant: "theme_workbench",
isLoading: true,
themeWorkbenchRunState: "await_user_decision",
@@ -787,4 +826,49 @@ describe("Inputbar", () => {
).toBeTruthy();
expect(container.textContent).not.toContain("正在生成中");
});
it("pending A2UI 表单短暂消失时应保留可见,但进入不可提交的同步态", async () => {
vi.useFakeTimers();
const pendingForm = createPendingA2UIForm();
const { container, rerender } = renderInputbar({
pendingA2UIForm: pendingForm,
onA2UISubmit: vi.fn(),
});
await act(async () => {
await Promise.resolve();
});
let submitButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("继续处理"),
) as HTMLButtonElement | undefined;
expect(submitButton?.disabled).toBe(false);
rerender({
pendingA2UIForm: null,
onA2UISubmit: vi.fn(),
});
await act(async () => {
await Promise.resolve();
});
submitButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("继续处理"),
) as HTMLButtonElement | undefined;
expect(container.textContent).toContain("同步中");
expect(container.textContent).toContain(
"正在同步最新上下文,表单暂时不可提交。",
);
expect(submitButton?.disabled).toBe(true);
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(container.textContent).not.toContain("继续处理");
vi.useRealTimers();
});
});
@@ -188,6 +188,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
themeWorkbenchQueueItems,
renderThemeWorkbenchGeneratingPanel,
visiblePendingA2UIForm,
isPendingA2UIFormStale,
visibleA2UISubmissionNotice,
isA2UISubmissionNoticeVisible,
activeSkill,
@@ -242,6 +243,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
submissionNotice={visibleA2UISubmissionNotice}
isSubmissionNoticeVisible={isA2UISubmissionNoticeVisible}
pendingA2UIForm={visiblePendingA2UIForm}
pendingA2UIFormStale={isPendingA2UIFormStale}
onA2UISubmit={onA2UISubmit}
fileInputRef={fileInputRef}
onFileSelect={handleFileSelect}
@@ -83,6 +83,8 @@ const NON_BLOCKING_RUNTIME_WARNING_CODES = new Set([
"artifact_document_repaired",
]);
type RuntimeIssueThreadItem = Extract<AgentThreadItem, { type: "error" | "warning" }>;
function normalizeText(value?: string | null): string | null {
if (typeof value !== "string") {
return null;
@@ -535,7 +537,7 @@ function deriveFallbackIncidents(
const issueItem = [...threadItems]
.reverse()
.find((item) => {
.find((item): item is RuntimeIssueThreadItem => {
if (item.type === "error") {
return true;
}
@@ -53,6 +53,7 @@ export function WorkspaceMainArea({
canvasContent={canvasContent}
chatPanelWidth={chatPanelWidth}
chatPanelMinWidth={chatPanelMinWidth}
forceOpenChatPanel={hasPendingA2UIForm}
/>
</ThemeWorkbenchLayoutShell>
{generalWorkbenchDialog}
@@ -278,6 +278,7 @@ interface LayoutTransitionRenderGateProps {
canvasContent: ReactNode;
chatPanelWidth?: string;
chatPanelMinWidth?: string;
forceOpenChatPanel?: boolean;
}
export const LayoutTransitionRenderGate = memo(
@@ -287,6 +288,7 @@ export const LayoutTransitionRenderGate = memo(
canvasContent,
chatPanelWidth,
chatPanelMinWidth,
forceOpenChatPanel = false,
}: LayoutTransitionRenderGateProps) => (
<ThemeWorkbenchCanvasHost>
<LayoutTransition
@@ -296,6 +298,7 @@ export const LayoutTransitionRenderGate = memo(
chatPanelChrome="plain"
chatPanelWidth={chatPanelWidth}
chatPanelMinWidth={chatPanelMinWidth}
forceOpenChatPanel={forceOpenChatPanel}
/>
</ThemeWorkbenchCanvasHost>
),
@@ -304,7 +307,8 @@ export const LayoutTransitionRenderGate = memo(
previous.chatContent === next.chatContent &&
previous.canvasContent === next.canvasContent &&
previous.chatPanelWidth === next.chatPanelWidth &&
previous.chatPanelMinWidth === next.chatPanelMinWidth,
previous.chatPanelMinWidth === next.chatPanelMinWidth &&
previous.forceOpenChatPanel === next.forceOpenChatPanel,
);
LayoutTransitionRenderGate.displayName = "LayoutTransitionRenderGate";
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState";
describe("hasRenderableGeneralCanvasPreview", () => {
it("关闭状态或空白内容不应视为真实预览", () => {
expect(
hasRenderableGeneralCanvasPreview({
isOpen: false,
content: "# 草稿",
}),
).toBe(false);
expect(
hasRenderableGeneralCanvasPreview({
isOpen: true,
content: " ",
}),
).toBe(false);
});
it("打开且有正文时应视为真实预览", () => {
expect(
hasRenderableGeneralCanvasPreview({
isOpen: true,
content: "# 草稿\n\n这里有内容",
}),
).toBe(true);
});
});
@@ -0,0 +1,14 @@
import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge";
export function hasRenderableGeneralCanvasPreview(
state:
| Pick<GeneralCanvasState, "isOpen" | "content">
| null
| undefined,
): boolean {
if (!state?.isOpen) {
return false;
}
return state.content.trim().length > 0;
}
@@ -0,0 +1,180 @@
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 { useWorkspaceA2UIRuntime } from "./useWorkspaceA2UIRuntime";
import type { Message } from "../types";
type HookProps = Parameters<typeof useWorkspaceA2UIRuntime>[0];
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
function createAssistantMessage(id: string, content: string): Message {
return {
id,
role: "assistant",
content,
timestamp: new Date("2026-03-27T10:00:00.000Z"),
};
}
function createUserMessage(id: string, content: string): Message {
return {
id,
role: "user",
content,
timestamp: new Date("2026-03-27T10:00:10.000Z"),
};
}
function renderHook(_initialProps: HookProps) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let latestValue: ReturnType<typeof useWorkspaceA2UIRuntime> | null = null;
function Probe(currentProps: HookProps) {
latestValue = useWorkspaceA2UIRuntime(currentProps);
return null;
}
const render = async (nextProps: HookProps) => {
await act(async () => {
root.render(<Probe {...nextProps} />);
await Promise.resolve();
await Promise.resolve();
});
};
mountedRoots.push({ root, container });
return {
render,
getValue: () => {
if (!latestValue) {
throw new Error("hook 尚未初始化");
}
return latestValue;
},
};
}
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.restoreAllMocks();
});
describe("useWorkspaceA2UIRuntime", () => {
it("内联 A2UI 在消息内容短暂变成不完整 JSON 时应保留最后一份有效表单", async () => {
const validA2UIMessage = createAssistantMessage(
"assistant-a2ui",
[
"```a2ui",
'{"type":"form","title":"补充信息","fields":[{"id":"answer","type":"text","label":"你的回答"}],"submitLabel":"继续处理"}',
"```",
].join("\n"),
);
const truncatedA2UIMessage = createAssistantMessage(
"assistant-a2ui",
['```a2ui', '{"type":"form","title":"补充信息"'].join("\n"),
);
const { render, getValue } = renderHook({
messages: [validA2UIMessage],
});
await render({ messages: [validA2UIMessage] });
expect(getValue().pendingA2UIForm?.components.length).toBeGreaterThan(0);
expect(getValue().a2uiSubmissionNotice).toBeNull();
await render({ messages: [truncatedA2UIMessage] });
expect(getValue().pendingA2UIForm?.components.length).toBeGreaterThan(0);
expect(getValue().a2uiSubmissionNotice).toBeNull();
});
it("promoted action_required 在 pending 短暂切到 queued 时应继续保留输入区 A2UI", async () => {
const pendingMessage: Message = {
id: "assistant-action",
role: "assistant",
content: "请补充执行偏好。",
timestamp: new Date("2026-03-27T10:01:00.000Z"),
actionRequests: [
{
requestId: "req-a2ui-1",
actionType: "ask_user",
prompt: "请选择执行模式",
questions: [
{
question: "你希望如何执行?",
header: "执行模式",
options: [{ label: "自动执行" }, { label: "手动确认" }],
},
],
status: "pending",
},
],
};
const queuedMessage: Message = {
...pendingMessage,
actionRequests: [
{
...pendingMessage.actionRequests![0],
status: "queued",
},
],
};
const { render, getValue } = renderHook({
messages: [pendingMessage],
});
await render({ messages: [pendingMessage] });
expect(getValue().pendingA2UIForm?.id).toBe(
"action-request-req-a2ui-1",
);
await render({ messages: [queuedMessage] });
expect(getValue().pendingA2UIForm?.id).toBe(
"action-request-req-a2ui-1",
);
expect(getValue().a2uiSubmissionNotice).toBeNull();
});
it("线程已切走且源消息消失时,应清理保留中的旧 A2UI", async () => {
const validA2UIMessage = createAssistantMessage(
"assistant-old",
[
"```a2ui",
'{"type":"form","title":"补充信息","fields":[{"id":"answer","type":"text","label":"你的回答"}],"submitLabel":"继续处理"}',
"```",
].join("\n"),
);
const { render, getValue } = renderHook({
messages: [validA2UIMessage],
});
await render({ messages: [validA2UIMessage] });
expect(getValue().pendingA2UIForm?.components.length).toBeGreaterThan(0);
await render({
messages: [createUserMessage("user-new-thread", "这是新的会话内容")],
});
expect(getValue().pendingA2UIForm).toBeNull();
expect(getValue().a2uiSubmissionNotice).toBeNull();
});
});
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
import {
@@ -14,6 +14,22 @@ interface A2UISubmissionNotice {
summary: string;
}
type PendingA2UIResolution =
| {
form: A2UIResponse;
source: {
kind: "assistant_message" | "legacy_message";
messageId: string;
};
}
| {
form: A2UIResponse;
source: {
kind: "action_request";
requestId: string;
};
};
interface UseWorkspaceA2UIRuntimeParams {
messages: Message[];
}
@@ -45,7 +61,7 @@ export function useWorkspaceA2UIRuntime({
);
}, [messages]);
const pendingMessageA2UIForm = useMemo<A2UIResponse | null>(() => {
const pendingMessageA2UI = useMemo<PendingA2UIResolution | null>(() => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
@@ -66,7 +82,13 @@ export function useWorkspaceA2UIRuntime({
for (let j = parsed.parts.length - 1; j >= 0; j -= 1) {
const part = parsed.parts[j];
if (part.type === "a2ui" && typeof part.content !== "string") {
return part.content;
return {
form: part.content,
source: {
kind: "assistant_message",
messageId: message.id,
},
};
}
}
} catch {
@@ -76,6 +98,7 @@ export function useWorkspaceA2UIRuntime({
return null;
}, [messages]);
const pendingMessageA2UIForm = pendingMessageA2UI?.form ?? null;
const pendingPromotedA2UIActionRequest = useMemo<ActionRequired | null>(() => {
if (pendingMessageA2UIForm) {
@@ -100,7 +123,7 @@ export function useWorkspaceA2UIRuntime({
return null;
}, [messages, pendingMessageA2UIForm]);
const pendingLegacyQuestionnaireA2UIForm = useMemo<A2UIResponse | null>(() => {
const pendingLegacyQuestionnaireA2UI = useMemo<PendingA2UIResolution | null>(() => {
if (pendingMessageA2UIForm || pendingActionRequest) {
return null;
}
@@ -120,30 +143,49 @@ export function useWorkspaceA2UIRuntime({
return null;
}
return buildLegacyQuestionnaireA2UI(message.content || "");
const form = buildLegacyQuestionnaireA2UI(message.content || "");
if (!form) {
return null;
}
return {
form,
source: {
kind: "legacy_message",
messageId: message.id,
},
};
}
return null;
}, [messages, pendingActionRequest, pendingMessageA2UIForm]);
const pendingLegacyQuestionnaireA2UIForm =
pendingLegacyQuestionnaireA2UI?.form ?? null;
const pendingA2UIForm = useMemo<A2UIResponse | null>(() => {
if (pendingMessageA2UIForm) {
return pendingMessageA2UIForm;
const resolvedPendingA2UI = useMemo<PendingA2UIResolution | null>(() => {
if (pendingMessageA2UI) {
return pendingMessageA2UI;
}
if (pendingPromotedA2UIActionRequest) {
return buildActionRequestA2UI(pendingPromotedA2UIActionRequest);
return {
form: buildActionRequestA2UI(pendingPromotedA2UIActionRequest),
source: {
kind: "action_request",
requestId: pendingPromotedA2UIActionRequest.requestId,
},
};
}
return pendingLegacyQuestionnaireA2UIForm;
return pendingLegacyQuestionnaireA2UI;
}, [
pendingLegacyQuestionnaireA2UIForm,
pendingMessageA2UIForm,
pendingLegacyQuestionnaireA2UI,
pendingMessageA2UI,
pendingPromotedA2UIActionRequest,
]);
const a2uiSubmissionNotice = useMemo<A2UISubmissionNotice | null>(() => {
if (pendingA2UIForm) {
if (resolvedPendingA2UI) {
return null;
}
@@ -194,12 +236,50 @@ export function useWorkspaceA2UIRuntime({
}
return null;
}, [messages, pendingA2UIForm]);
}, [messages, resolvedPendingA2UI]);
const [retainedPendingA2UI, setRetainedPendingA2UI] =
useState<PendingA2UIResolution | null>(resolvedPendingA2UI);
useEffect(() => {
if (resolvedPendingA2UI) {
setRetainedPendingA2UI(resolvedPendingA2UI);
return;
}
if (a2uiSubmissionNotice) {
setRetainedPendingA2UI(null);
return;
}
setRetainedPendingA2UI((previous) => {
if (!previous) {
return null;
}
if (previous.source.kind === "action_request") {
const requestStillExists = messages.some((message) =>
(message.actionRequests || []).some(
(request) => request.requestId === previous.source.requestId,
),
);
return requestStillExists ? previous : null;
}
const sourceMessageStillExists = messages.some(
(message) => message.id === previous.source.messageId,
);
return sourceMessageStillExists ? previous : null;
});
}, [a2uiSubmissionNotice, messages, resolvedPendingA2UI]);
const pendingA2UIForm =
resolvedPendingA2UI?.form ?? retainedPendingA2UI?.form ?? null;
useEffect(() => {
if (
!pendingActionRequest ||
pendingA2UIForm ||
resolvedPendingA2UI ||
!isActionRequestA2UICompatible(pendingActionRequest)
) {
return;
@@ -211,7 +291,7 @@ export function useWorkspaceA2UIRuntime({
prompt: pendingActionRequest.prompt,
scope: pendingActionRequest.scope,
});
}, [pendingA2UIForm, pendingActionRequest]);
}, [pendingActionRequest, resolvedPendingA2UI]);
return {
a2uiSubmissionNotice,
@@ -30,6 +30,12 @@ function renderHook(props?: Partial<HookProps>) {
normalizedEntryTheme: "general",
shouldBootstrapCanvasOnEntry: false,
canvasState: null,
generalCanvasState: {
isOpen: false,
contentType: "empty",
content: "",
isEditing: false,
},
showTeamWorkspaceBoard: false,
hasCurrentCanvasArtifact: false,
currentCanvasArtifactType: null,
@@ -128,4 +134,24 @@ describe("useWorkspaceCanvasLayoutRuntime", () => {
isOpen: false,
});
});
it("general 空白画布没有真实预览目标时应回退到聊天态", async () => {
const setLayoutMode = vi.fn();
const { render } = renderHook({
layoutMode: "chat-canvas",
generalCanvasState: {
isOpen: true,
contentType: "markdown",
content: " ",
isEditing: false,
},
setLayoutMode,
});
await render();
expect(
setLayoutMode.mock.calls.some((call) => call[0] === "chat"),
).toBe(true);
});
});
@@ -5,6 +5,7 @@ import type { CanvasState as GeneralCanvasState } from "@/components/general-cha
import type { LayoutMode, ThemeType } from "@/components/content-creator/types";
import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt";
import type { CanvasWorkbenchLayoutMode } from "../components/CanvasWorkbenchLayout";
import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState";
const FALLBACK_CANVAS_CONTENT = "# 新文档\n\n在这里开始编写内容...";
@@ -22,6 +23,7 @@ interface UseWorkspaceCanvasLayoutRuntimeParams {
normalizedEntryTheme: ThemeType;
shouldBootstrapCanvasOnEntry: boolean;
canvasState: CanvasStateUnion | null;
generalCanvasState: GeneralCanvasState;
showTeamWorkspaceBoard: boolean;
hasCurrentCanvasArtifact: boolean;
currentCanvasArtifactType?: string | null;
@@ -55,6 +57,7 @@ export function useWorkspaceCanvasLayoutRuntime({
normalizedEntryTheme,
shouldBootstrapCanvasOnEntry,
canvasState,
generalCanvasState,
showTeamWorkspaceBoard,
hasCurrentCanvasArtifact,
currentCanvasArtifactType,
@@ -172,6 +175,36 @@ export function useWorkspaceCanvasLayoutRuntime({
setGeneralCanvasState,
]);
useEffect(() => {
if (
activeTheme !== "general" ||
!showChatPanel ||
layoutMode === "chat" ||
showTeamWorkspaceBoard ||
hasCurrentCanvasArtifact ||
currentImageWorkbenchActive ||
isBrowserAssistCanvasVisible
) {
return;
}
if (hasRenderableGeneralCanvasPreview(generalCanvasState)) {
return;
}
setLayoutMode("chat");
}, [
activeTheme,
currentImageWorkbenchActive,
generalCanvasState,
hasCurrentCanvasArtifact,
isBrowserAssistCanvasVisible,
layoutMode,
setLayoutMode,
showChatPanel,
showTeamWorkspaceBoard,
]);
useEffect(() => {
const shouldAutoHideTopicSidebar =
showChatPanel &&
@@ -37,6 +37,7 @@ import {
type TeamWorkbenchSurfaceProps,
type UseTeamWorkbenchPresentationParams,
} from "./teamWorkbenchPresentation";
import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState";
type ArtifactPreviewBaseProps = Omit<
ComponentProps<typeof ArtifactWorkbenchPreview>,
@@ -451,7 +452,7 @@ export function useWorkspaceCanvasPreviewPresentation({
return Boolean(
(artifactPreview.currentCanvasArtifact &&
artifactPreview.displayedCanvasArtifact) ||
defaultPreview.generalCanvasState.isOpen,
hasRenderableGeneralCanvasPreview(defaultPreview.generalCanvasState),
);
}
@@ -463,7 +464,7 @@ export function useWorkspaceCanvasPreviewPresentation({
artifactPreview.currentCanvasArtifact,
artifactPreview.displayedCanvasArtifact,
defaultPreview.canvasRenderTheme,
defaultPreview.generalCanvasState.isOpen,
defaultPreview.generalCanvasState,
defaultPreview.resolvedCanvasState,
imageWorkbench.active,
loading.shouldShowCanvasLoadingState,
@@ -15,6 +15,7 @@ import type { CanvasWorkbenchDefaultPreview } from "../components/CanvasWorkbenc
import { ImageWorkbenchCanvas } from "../components/ImageWorkbenchCanvas";
import { TeamWorkspaceBoard } from "../components/TeamWorkspaceBoard";
import { ArtifactWorkbenchShell } from "./ArtifactWorkbenchShell";
import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState";
import { wrapPreviewWithWorkbenchTrigger } from "./workbenchPreviewHelpers";
import { resolveArtifactProtocolDocumentPayload } from "@/lib/artifact-protocol";
import type { ArtifactDocumentV1 } from "@/lib/artifact-document";
@@ -237,7 +238,10 @@ export function WorkspaceLiveCanvasPreview({
}
if (canvasRenderTheme === "general") {
if (generalCanvasPanelProps?.state.isOpen) {
if (
generalCanvasPanelProps &&
hasRenderableGeneralCanvasPreview(generalCanvasPanelProps.state)
) {
return (
<GeneralCanvasPanel
{...generalCanvasPanelProps}
@@ -18,13 +18,20 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function LayoutHarness({ mode }: { mode: LayoutMode }) {
function LayoutHarness({
mode,
forceOpenChatPanel = false,
}: {
mode: LayoutMode;
forceOpenChatPanel?: boolean;
}) {
return (
<div style={{ width: "1200px", height: "720px" }}>
<LayoutTransition
mode={mode}
chatContent={<div data-testid="layout-chat-content">chat</div>}
canvasContent={<div data-testid="layout-canvas-content">canvas</div>}
forceOpenChatPanel={forceOpenChatPanel}
/>
</div>
);
@@ -212,6 +219,37 @@ describe("LayoutTransition", () => {
).toBe("closed");
});
it("紧凑抽屉态存在待处理 A2UI 时应自动展开聊天区", async () => {
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", forceOpenChatPanel: true },
mountedRoots,
);
await act(async () => {
await Promise.resolve();
});
expect(
container.querySelector<HTMLElement>('[data-testid="layout-chat-panel"]')
?.getAttribute("data-overlay-state"),
).toBe("open");
expect(
container.querySelector('[data-testid="layout-chat-content"]'),
).not.toBeNull();
});
it("画布内容为空时应退回聊天布局,避免保留空白画布列", () => {
const { container } = mountHarness(
EmptyCanvasLayoutHarness,
@@ -181,6 +181,8 @@ interface LayoutTransitionProps {
chatPanelWidth?: string;
/** chat-canvas 模式下聊天面板最小宽度 */
chatPanelMinWidth?: string;
/** 紧凑抽屉态下强制展开聊天区 */
forceOpenChatPanel?: boolean;
}
/**
@@ -197,6 +199,7 @@ export const LayoutTransition: React.FC<LayoutTransitionProps> = memo(
chatPanelChrome = "panel",
chatPanelWidth,
chatPanelMinWidth,
forceOpenChatPanel = false,
}) => {
const hasCanvasContent = React.Children.count(canvasContent) > 0;
const effectiveMode: LayoutMode = hasCanvasContent ? mode : "chat";
@@ -245,6 +248,25 @@ export const LayoutTransition: React.FC<LayoutTransitionProps> = memo(
}
}, [compactChatCanvasOverlay, effectiveMode]);
useEffect(() => {
if (
!forceOpenChatPanel ||
!compactChatCanvasOverlay ||
effectiveMode !== "chat-canvas" ||
compactChatPanelOpen
) {
return;
}
setCompactChatPanelOpen(true);
emitCompactRightPanelOpen({ source: "chat" });
}, [
compactChatCanvasOverlay,
compactChatPanelOpen,
effectiveMode,
forceOpenChatPanel,
]);
useEffect(() => {
if (!compactChatCanvasOverlay || effectiveMode !== "chat-canvas") {
return;
+29 -1
View File
@@ -604,6 +604,30 @@ type MockClawSolutionSummary = {
reasonCode?: string;
};
type MockReviewDecisionRequest = {
session_id?: string;
sessionId?: string;
decision_status?: string;
decisionStatus?: string;
decision_summary?: string;
decisionSummary?: string;
chosen_fix_strategy?: string;
chosenFixStrategy?: string;
risk_level?: string;
riskLevel?: string;
risk_tags?: string[];
riskTags?: string[];
human_reviewer?: string;
humanReviewer?: string;
reviewed_at?: string | null;
reviewedAt?: string | null;
followup_actions?: string[];
followupActions?: string[];
regression_requirements?: string[];
regressionRequirements?: string[];
notes?: string;
};
const mockClawSolutionCatalog: MockClawSolutionSummary[] = [
{
id: "web-research-brief",
@@ -2066,7 +2090,11 @@ const defaultMocks: Record<string, any> = {
},
],
}),
agent_runtime_save_review_decision: ({ request }) => ({
agent_runtime_save_review_decision: ({
request,
}: {
request?: MockReviewDecisionRequest;
}) => ({
session_id: request?.session_id || request?.sessionId || "mock-session",
thread_id: "mock-thread",
workspace_root: "/mock/workspace",