fix: 修复 AI 设计任务栏同步与独立滚动
问题:生成任务未及时出现在右侧栏,任务区与会话区还会共享外层滚动。 实现:补齐 Agent 回合与 Quote 确认后的任务同步,锁定左右滚动容器并增加桌面布局和异步任务回归测试。
This commit is contained in:
@@ -326,7 +326,7 @@ export function ImageCanvas() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="image-canvas-page" className="-m-5 flex min-h-full min-w-0 flex-col overflow-hidden bg-background text-foreground sm:-m-6">
|
<div data-testid="image-canvas-page" className="-m-5 flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground sm:-m-6">
|
||||||
<header className="glass-surface flex shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-4 py-3 sm:px-6">
|
<header className="glass-surface flex shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-4 py-3 sm:px-6">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand-soft text-brand">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand-soft text-brand">
|
||||||
@@ -361,9 +361,9 @@ export function ImageCanvas() {
|
|||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col lg:flex-row">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:flex-row">
|
||||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col">
|
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:h-full">
|
||||||
<main data-testid="image-workspace-conversation" className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
|
<main data-testid="image-workspace-conversation" className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5 sm:px-6">
|
||||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col pb-4">
|
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col pb-4">
|
||||||
{workspace.messages.length === 0 ? (
|
{workspace.messages.length === 0 ? (
|
||||||
<div data-testid="image-workspace-empty-state" className="flex flex-1 items-center justify-center py-10 text-center">
|
<div data-testid="image-workspace-empty-state" className="flex flex-1 items-center justify-center py-10 text-center">
|
||||||
@@ -469,7 +469,9 @@ export function ImageCanvas() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside data-testid="design-task-list" className="min-h-0 w-full shrink-0 border-t border-border/70 bg-surface-subtle/45 lg:w-80 lg:border-l lg:border-t-0">
|
<div data-testid="design-pane-divider" aria-hidden="true" className="hidden w-px shrink-0 bg-border/70 lg:block" />
|
||||||
|
|
||||||
|
<aside data-testid="design-task-list" className="min-h-0 w-full shrink-0 overflow-hidden border-t border-border/70 bg-surface-subtle/45 lg:h-full lg:w-80 lg:border-t-0">
|
||||||
<div className="flex items-center justify-between border-b border-border/70 px-4 py-3">
|
<div className="flex items-center justify-between border-b border-border/70 px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold">生成任务</h2>
|
<h2 className="text-sm font-semibold">生成任务</h2>
|
||||||
@@ -477,7 +479,7 @@ export function ImageCanvas() {
|
|||||||
</div>
|
</div>
|
||||||
{hasActiveTasks ? <Clock3 className="h-4 w-4 animate-pulse text-brand" /> : null}
|
{hasActiveTasks ? <Clock3 className="h-4 w-4 animate-pulse text-brand" /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-72 space-y-3 overflow-y-auto p-3 lg:max-h-none lg:h-[calc(100%-57px)]">
|
<div data-testid="design-task-scroll" className="h-full max-h-72 space-y-3 overflow-y-auto overscroll-contain p-3 lg:h-[calc(100%-57px)] lg:max-h-none">
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-dashed border-border/80 bg-background/70 px-4 py-7 text-center">
|
<div className="rounded-2xl border border-dashed border-border/80 bg-background/70 px-4 py-7 text-center">
|
||||||
<WandSparkles className="mx-auto h-5 w-5 text-muted-foreground" />
|
<WandSparkles className="mx-auto h-5 w-5 text-muted-foreground" />
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ type ImageWorkspaceState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let inFlightLoad: Promise<DesignWorkspaceBootstrap | null> | null = null;
|
let inFlightLoad: Promise<DesignWorkspaceBootstrap | null> | null = null;
|
||||||
|
const GENERATION_TASK_SYNC_ATTEMPTS = 20;
|
||||||
|
const GENERATION_TASK_SYNC_DELAY_MS = 500;
|
||||||
|
|
||||||
|
function wait(milliseconds: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
function unavailable(error: unknown): boolean {
|
function unavailable(error: unknown): boolean {
|
||||||
return error instanceof ImageWorkspaceApiError
|
return error instanceof ImageWorkspaceApiError
|
||||||
@@ -107,6 +113,21 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
|||||||
return workspace;
|
return workspace;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const waitForGenerationTask = async (
|
||||||
|
workspaceId: string,
|
||||||
|
quoteId: string,
|
||||||
|
): Promise<DesignGenerationTask[]> => {
|
||||||
|
for (let attempt = 0; attempt < GENERATION_TASK_SYNC_ATTEMPTS; attempt += 1) {
|
||||||
|
if (get().activeWorkspaceId !== workspaceId) return [];
|
||||||
|
const tasks = await get().refreshTasks().catch(() => []);
|
||||||
|
if (tasks.some((task) => task.quoteId === quoteId)) return tasks;
|
||||||
|
if (attempt < GENERATION_TASK_SYNC_ATTEMPTS - 1) {
|
||||||
|
await wait(GENERATION_TASK_SYNC_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('生成请求已确认,但任务列表尚未同步,请点击刷新重试');
|
||||||
|
};
|
||||||
|
|
||||||
const loadWorkspace = async (workspaceId: string): Promise<void> => {
|
const loadWorkspace = async (workspaceId: string): Promise<void> => {
|
||||||
const [workspace, tasks] = await Promise.all([
|
const [workspace, tasks] = await Promise.all([
|
||||||
fetchImageWorkspaceProject(workspaceId),
|
fetchImageWorkspaceProject(workspaceId),
|
||||||
@@ -251,11 +272,13 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
|||||||
const workspace = get().workspace;
|
const workspace = get().workspace;
|
||||||
if (!workspace) throw new Error('请先选择设计项目');
|
if (!workspace) throw new Error('请先选择设计项目');
|
||||||
try {
|
try {
|
||||||
return applyWorkspace(await sendImageWorkspaceMessage(
|
const updated = applyWorkspace(await sendImageWorkspaceMessage(
|
||||||
workspace.workspaceId,
|
workspace.workspaceId,
|
||||||
workspace.turnRevision,
|
workspace.turnRevision,
|
||||||
message,
|
message,
|
||||||
));
|
));
|
||||||
|
await get().refreshTasks().catch(() => []);
|
||||||
|
return updated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({ error: handleRequestError(error) });
|
set({ error: handleRequestError(error) });
|
||||||
return await recoverRevisionConflict(error);
|
return await recoverRevisionConflict(error);
|
||||||
@@ -271,7 +294,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
|||||||
workspace.turnRevision,
|
workspace.turnRevision,
|
||||||
quoteId,
|
quoteId,
|
||||||
));
|
));
|
||||||
await get().refreshTasks().catch(() => []);
|
await waitForGenerationTask(workspace.workspaceId, quoteId);
|
||||||
return updated;
|
return updated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({ error: handleRequestError(error) });
|
set({ error: handleRequestError(error) });
|
||||||
|
|||||||
@@ -220,6 +220,38 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
|||||||
expect(screen.queryByText(/当前 Agent/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/当前 Agent/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the task list visible in a 1404px desktop workspace', async () => {
|
||||||
|
const previousWidth = window.innerWidth;
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1404 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||||
|
await screen.findByTestId('image-workspace-conversation');
|
||||||
|
|
||||||
|
expect(screen.getByTestId('design-task-list')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('design-pane-divider')).toHaveClass('lg:block');
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(window, 'innerWidth', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousWidth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps conversation and task scrolling isolated', async () => {
|
||||||
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||||
|
await screen.findByTestId('image-workspace-conversation');
|
||||||
|
|
||||||
|
expect(screen.getByTestId('image-canvas-page'))
|
||||||
|
.toHaveClass('h-full', 'overflow-hidden');
|
||||||
|
expect(screen.getByTestId('image-workspace-conversation'))
|
||||||
|
.toHaveClass('overflow-y-auto', 'overscroll-contain');
|
||||||
|
expect(screen.getByTestId('design-task-list'))
|
||||||
|
.toHaveClass('lg:h-full', 'overflow-hidden');
|
||||||
|
expect(screen.getByTestId('design-task-scroll'))
|
||||||
|
.toHaveClass('lg:h-[calc(100%-57px)]', 'overflow-y-auto', 'overscroll-contain');
|
||||||
|
});
|
||||||
|
|
||||||
it('explains the project model before the first Workspace is created', async () => {
|
it('explains the project model before the first Workspace is created', async () => {
|
||||||
fetchImageWorkspaceMock.mockResolvedValueOnce({
|
fetchImageWorkspaceMock.mockResolvedValueOnce({
|
||||||
...bootstrapFixture,
|
...bootstrapFixture,
|
||||||
@@ -250,6 +282,35 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
|||||||
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
|
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshes tasks after an Agent turn creates a generation task', async () => {
|
||||||
|
const queuedTask = {
|
||||||
|
...taskFixture,
|
||||||
|
taskId: 'task-two',
|
||||||
|
status: 'queued' as const,
|
||||||
|
resultAssets: [],
|
||||||
|
};
|
||||||
|
fetchImageWorkspaceTasksMock
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([queuedTask]);
|
||||||
|
|
||||||
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||||
|
await screen.findByTestId('design-quote-quote-one');
|
||||||
|
|
||||||
|
const confirmationReply = workspaceFixture().messages[1].quickReplies[0];
|
||||||
|
fireEvent.change(screen.getByLabelText('设计需求'), {
|
||||||
|
target: { value: confirmationReply },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
||||||
|
'workspace-cloud',
|
||||||
|
1,
|
||||||
|
confirmationReply,
|
||||||
|
));
|
||||||
|
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
||||||
|
expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('creates a generation task only through explicit Quote confirmation', async () => {
|
it('creates a generation task only through explicit Quote confirmation', async () => {
|
||||||
fetchImageWorkspaceTasksMock
|
fetchImageWorkspaceTasksMock
|
||||||
.mockResolvedValueOnce([taskFixture])
|
.mockResolvedValueOnce([taskFixture])
|
||||||
@@ -268,4 +329,28 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
|||||||
.toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one'));
|
.toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one'));
|
||||||
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps checking after Quote confirmation until the new task becomes visible', async () => {
|
||||||
|
const queuedTask = {
|
||||||
|
...taskFixture,
|
||||||
|
taskId: 'task-delayed',
|
||||||
|
status: 'queued' as const,
|
||||||
|
resultAssets: [],
|
||||||
|
};
|
||||||
|
fetchImageWorkspaceTasksMock
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([queuedTask]);
|
||||||
|
|
||||||
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||||
|
await screen.findByTestId('design-quote-quote-one');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
|
||||||
|
.toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one'));
|
||||||
|
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(3));
|
||||||
|
expect(await screen.findByTestId('design-task-task-delayed'))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user