fix: 修复 AI 设计任务栏同步与独立滚动

问题:生成任务未及时出现在右侧栏,任务区与会话区还会共享外层滚动。

实现:补齐 Agent 回合与 Quote 确认后的任务同步,锁定左右滚动容器并增加桌面布局和异步任务回归测试。
This commit is contained in:
2026-08-01 23:12:24 +08:00
parent 592bdfbc70
commit 83e21563dc
3 changed files with 118 additions and 8 deletions

View File

@@ -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" />

View File

@@ -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) });

View File

@@ -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();
});
}); });