fix(design): separate progress from chat replies

This commit is contained in:
2026-09-04 08:22:32 +08:00
parent 945b40de11
commit c6b0490d7f
5 changed files with 293 additions and 15 deletions

View File

@@ -93,6 +93,39 @@ describe('youth AI Design Canvas page', () => {
expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument();
});
it('presents unfinished AI output as progress instead of a chat reply', () => {
prepareWorkspace();
const unfinishedDraft = '收到,我们要收到,我们要制作一张社团活动海报。';
useImageWorkspaceStore.setState({
assistantStreams: { 'operation-chat-1': unfinishedDraft },
pendingOperations: {
'operation-chat-1': {
id: 'operation-chat-1',
label: '发送创作想法',
command: {
kind: 'apply_input',
workspaceId: 'workspace-1',
sessionId: 'session-1',
expectedDirectionRevision: 4,
clientOperationId: 'operation-chat-1',
input: { kind: 'chat', message: '做一张社团活动海报' },
},
status: 'submitting',
error: null,
clearDraftPaths: [],
clearChatDraft: true,
},
},
});
render(<ImageCanvas />);
const conversation = screen.getByTestId('image-workspace-conversation');
expect(within(conversation).getByRole('status')).toHaveTextContent('AI 正在整理你的想法');
expect(within(conversation).getByText('我已经整理了用途、受众和初步概念,请确认右侧建议。')).toBeInTheDocument();
expect(within(conversation).queryByText(unfinishedDraft)).not.toBeInTheDocument();
});
it('invites a free description and does not turn a legacy decision prompt into a form', () => {
const workspace = designWorkspaceFixture({
form: designFormFixture({

View File

@@ -74,6 +74,14 @@ class FakeEventSource {
}
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function loadedStore(eventSource = new FakeEventSource()) {
openEventsMock.mockResolvedValue(eventSource as unknown as EventSource);
await useImageWorkspaceStore.getState().load();
@@ -110,6 +118,87 @@ describe('V2 Living Form store', () => {
expect(openEventsMock).toHaveBeenCalledWith('workspace-1', 'session-1', undefined);
});
it('closes an obsolete event source when a replacement connection wins the race', async () => {
const workspace = designWorkspaceFixture();
useImageWorkspaceStore.setState({
status: 'ready',
activeWorkspaceId: workspace.workspace.workspaceId,
workspace,
});
const firstOpen = deferred<EventSource>();
const secondOpen = deferred<EventSource>();
const firstSource = new FakeEventSource();
const secondSource = new FakeEventSource();
openEventsMock
.mockReturnValueOnce(firstOpen.promise)
.mockReturnValueOnce(secondOpen.promise);
useImageWorkspaceStore.getState().connectEvents();
useImageWorkspaceStore.getState().disconnectEvents();
useImageWorkspaceStore.getState().connectEvents();
firstOpen.resolve(firstSource as unknown as EventSource);
await Promise.resolve();
secondOpen.resolve(secondSource as unknown as EventSource);
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2));
expect(firstSource.close).toHaveBeenCalledOnce();
expect(secondSource.close).not.toHaveBeenCalled();
});
it('allows a new event connection after opening the previous one failed', async () => {
const workspace = designWorkspaceFixture();
const recoveredSource = new FakeEventSource();
useImageWorkspaceStore.setState({
status: 'ready',
activeWorkspaceId: workspace.workspace.workspaceId,
workspace,
});
openEventsMock
.mockRejectedValueOnce(new Error('temporary event connection failure'))
.mockResolvedValueOnce(recoveredSource as unknown as EventSource);
useImageWorkspaceStore.getState().connectEvents();
await vi.waitFor(() => expect(useImageWorkspaceStore.getState().eventState).toBe('degraded'));
useImageWorkspaceStore.getState().connectEvents();
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2));
expect(recoveredSource.close).not.toHaveBeenCalled();
});
it('uses assistant chunk indexes to ignore replayed deltas', async () => {
const source = await loadedStore();
const baseEvent = {
type: 'design.assistant.delta',
workspaceId: 'workspace-1',
directionId: 'direction-1',
clientOperationId: 'operation-chat-1',
directionRevision: 4,
} as const;
source.emit('design.assistant.delta', {
...baseEvent,
id: 'session-1:7',
chunkIndex: 0,
delta: '收到',
});
source.emit('design.assistant.delta', {
...baseEvent,
id: 'session-1:7-replayed',
chunkIndex: 0,
delta: '收到',
});
source.emit('design.assistant.delta', {
...baseEvent,
id: 'session-1:8',
chunkIndex: 1,
delta: ',正在整理',
});
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({
'operation-chat-1': '收到,正在整理',
});
});
it('keeps a field draft separate until the direct edit succeeds', async () => {
await loadedStore();
useImageWorkspaceStore.getState().setFieldDraft('output.aspect_ratio', '16:9');