mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: finalize latest v0.97.0 release payload
This commit is contained in:
@@ -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>
|
||||
|
||||
+48
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user