mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix(web): queue preview comments during runs
Keep preview comment sends available while an agent run is streaming by queueing notes and forwarding them once the run can accept attachments. Agent-Model: gpt-5 Agent-Family: openai Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197 Agent-Step: 0.0.5
This commit is contained in:
parent
6f532ca35c
commit
01ee877eb0
5 changed files with 190 additions and 11 deletions
|
|
@ -684,10 +684,11 @@ interface Props {
|
|||
isDeck?: boolean;
|
||||
onExportAsPptx?: ((fileName: string) => void) | undefined;
|
||||
streaming?: boolean;
|
||||
commentSendDisabled?: boolean;
|
||||
previewComments?: PreviewComment[];
|
||||
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
|
||||
onRemovePreviewComment?: (commentId: string) => Promise<void>;
|
||||
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<void> | void;
|
||||
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<boolean | void> | boolean | void;
|
||||
onFileSaved?: () => Promise<void> | void;
|
||||
// Open `openName` as a tab (focusing it) and close `closeName` in one
|
||||
// atomic tab-state update. The React module pointer uses this to jump to the
|
||||
|
|
@ -706,6 +707,7 @@ export function FileViewer({
|
|||
isDeck,
|
||||
onExportAsPptx,
|
||||
streaming,
|
||||
commentSendDisabled = false,
|
||||
previewComments = [],
|
||||
onSavePreviewComment,
|
||||
onRemovePreviewComment,
|
||||
|
|
@ -746,6 +748,7 @@ export function FileViewer({
|
|||
isDeck={rendererMatch.renderer.id === 'deck-html'}
|
||||
onExportAsPptx={onExportAsPptx}
|
||||
streaming={Boolean(streaming)}
|
||||
commentSendDisabled={commentSendDisabled}
|
||||
previewComments={previewComments}
|
||||
onSavePreviewComment={onSavePreviewComment}
|
||||
onRemovePreviewComment={onRemovePreviewComment}
|
||||
|
|
@ -3841,6 +3844,7 @@ function HtmlViewer({
|
|||
isDeck,
|
||||
onExportAsPptx,
|
||||
streaming,
|
||||
commentSendDisabled,
|
||||
previewComments = [],
|
||||
onSavePreviewComment,
|
||||
onRemovePreviewComment,
|
||||
|
|
@ -3857,10 +3861,11 @@ function HtmlViewer({
|
|||
isDeck: boolean;
|
||||
onExportAsPptx?: ((fileName: string) => void) | undefined;
|
||||
streaming: boolean;
|
||||
commentSendDisabled: boolean;
|
||||
previewComments?: PreviewComment[];
|
||||
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
|
||||
onRemovePreviewComment?: (commentId: string) => Promise<void>;
|
||||
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<void> | void;
|
||||
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<boolean | void> | boolean | void;
|
||||
onFileSaved?: () => Promise<void> | void;
|
||||
commentPortalId?: string;
|
||||
onCommentModeChange?: (active: boolean) => void;
|
||||
|
|
@ -6129,12 +6134,13 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
if (nextNotes.length === 0) return;
|
||||
setSendingBoardBatch(true);
|
||||
try {
|
||||
await onSendBoardCommentAttachments(
|
||||
const accepted = await onSendBoardCommentAttachments(
|
||||
buildBoardCommentAttachments({
|
||||
target: targetFromSnapshot(activeCommentTarget),
|
||||
notes: nextNotes,
|
||||
}),
|
||||
);
|
||||
if (accepted === false) return;
|
||||
clearBoardComposer();
|
||||
} finally {
|
||||
setSendingBoardBatch(false);
|
||||
|
|
@ -6429,7 +6435,8 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
});
|
||||
setActivePreviewCommentId((current) => (current === commentId ? null : current));
|
||||
} : undefined}
|
||||
sending={sendingBoardBatch || streaming}
|
||||
sending={sendingBoardBatch || commentSendDisabled}
|
||||
|
||||
t={t}
|
||||
scale={overlayPreviewScale}
|
||||
offset={{ x: overlayPreviewTransform.offsetX, y: overlayPreviewTransform.offsetY }}
|
||||
|
|
@ -6500,14 +6507,14 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
fireCommentPopoverClick('send_to_chat');
|
||||
setSendingBoardBatch(true);
|
||||
try {
|
||||
await onSendBoardCommentAttachments(commentsToAttachments(selected));
|
||||
setSelectedSideCommentIds(new Set());
|
||||
const accepted = await onSendBoardCommentAttachments(commentsToAttachments(selected));
|
||||
if (accepted !== false) setSelectedSideCommentIds(new Set());
|
||||
} finally {
|
||||
setSendingBoardBatch(false);
|
||||
}
|
||||
}}
|
||||
onCreateComment={savePanelComment}
|
||||
sending={sendingBoardBatch || streaming}
|
||||
sending={sendingBoardBatch || commentSendDisabled}
|
||||
t={t}
|
||||
composer={null}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ interface Props {
|
|||
isDeck: boolean;
|
||||
onExportAsPptx?: ((fileName: string) => void) | undefined;
|
||||
streaming?: boolean;
|
||||
commentSendDisabled?: boolean;
|
||||
openRequest?: { name: string; nonce: number } | null;
|
||||
liveArtifactEvents?: LiveArtifactEventItem[];
|
||||
designSystemActivityEvents?: AgentEvent[];
|
||||
|
|
@ -79,7 +80,7 @@ interface Props {
|
|||
previewComments?: PreviewComment[];
|
||||
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
|
||||
onRemovePreviewComment?: (commentId: string) => Promise<void>;
|
||||
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<void> | void;
|
||||
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<boolean | void> | boolean | void;
|
||||
onPluginFolderAgentAction?: (
|
||||
relativePath: string,
|
||||
action: PluginFolderAgentAction,
|
||||
|
|
@ -200,6 +201,7 @@ export function FileWorkspace({
|
|||
isDeck,
|
||||
onExportAsPptx,
|
||||
streaming,
|
||||
commentSendDisabled = false,
|
||||
openRequest,
|
||||
liveArtifactEvents = [],
|
||||
designSystemActivityEvents = [],
|
||||
|
|
@ -1063,6 +1065,7 @@ export function FileWorkspace({
|
|||
isDeck={isDeck}
|
||||
onExportAsPptx={onExportAsPptx}
|
||||
streaming={streaming}
|
||||
commentSendDisabled={commentSendDisabled}
|
||||
previewComments={previewComments.filter((comment) => comment.filePath === activeFile.name)}
|
||||
onSavePreviewComment={onSavePreviewComment}
|
||||
onRemovePreviewComment={onRemovePreviewComment}
|
||||
|
|
|
|||
|
|
@ -741,6 +741,8 @@ export function ProjectView({
|
|||
|| failedMessagesConversationId === activeConversationId
|
||||
|| currentConversationAwaitingActiveRunAttach;
|
||||
const currentConversationActionDisabled = currentConversationBusy || currentConversationSendDisabled;
|
||||
const currentConversationQueueDisabled = currentConversationLoading
|
||||
|| failedMessagesConversationId === activeConversationId;
|
||||
const currentConversationQueuedItems = activeConversationId
|
||||
? queuedChatSends
|
||||
.filter((item) => item.conversationId === activeConversationId)
|
||||
|
|
@ -3060,12 +3062,13 @@ export function ProjectView({
|
|||
|
||||
const handleSendBoardCommentAttachments = useCallback(
|
||||
async (commentAttachments: ChatCommentAttachment[]) => {
|
||||
if (currentConversationActionDisabled || commentAttachments.length === 0) return;
|
||||
if (currentConversationQueueDisabled || commentAttachments.length === 0) return false;
|
||||
setWorkspaceFocused(false);
|
||||
setCommentInspectorActive(false);
|
||||
await handleSend('', [], commentAttachments);
|
||||
return true;
|
||||
},
|
||||
[handleSend, currentConversationActionDisabled],
|
||||
[handleSend, currentConversationQueueDisabled],
|
||||
);
|
||||
|
||||
const handleContinueRemainingTasks = useCallback(
|
||||
|
|
@ -4552,6 +4555,7 @@ export function ProjectView({
|
|||
isDeck={isDeck}
|
||||
onExportAsPptx={handleExportAsPptx}
|
||||
streaming={currentConversationActionDisabled}
|
||||
commentSendDisabled={currentConversationQueueDisabled}
|
||||
openRequest={openRequest}
|
||||
liveArtifactEvents={liveArtifactEvents}
|
||||
designSystemActivityEvents={designSystemActivityEvents}
|
||||
|
|
|
|||
|
|
@ -2338,6 +2338,88 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
expect(activeItem?.getAttribute('aria-current')).toBe('true');
|
||||
});
|
||||
|
||||
it('lets element comments queue to chat while a task is running', async () => {
|
||||
const onSendBoardCommentAttachments = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
projectKind="prototype"
|
||||
file={htmlPreviewFile()}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
streaming
|
||||
onSendBoardCommentAttachments={onSendBoardCommentAttachments}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
fireEvent.click(screen.getByTestId('board-mode-toggle'));
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
source: frame.contentWindow,
|
||||
data: {
|
||||
type: 'od:comment-target',
|
||||
elementId: 'hero',
|
||||
selector: '[data-od-id="hero"]',
|
||||
label: 'Hero',
|
||||
text: 'Hero',
|
||||
position: { x: 8, y: 12, width: 120, height: 48 },
|
||||
htmlHint: '<main data-od-id="hero">Hero</main>',
|
||||
},
|
||||
}));
|
||||
|
||||
const input = await screen.findByTestId('comment-popover-input');
|
||||
fireEvent.change(input, { target: { value: '加大字号' } });
|
||||
const send = screen.getByTestId('comment-add-send') as HTMLButtonElement;
|
||||
expect(send.disabled).toBe(false);
|
||||
|
||||
fireEvent.click(send);
|
||||
|
||||
await waitFor(() => expect(onSendBoardCommentAttachments).toHaveBeenCalledTimes(1));
|
||||
expect(onSendBoardCommentAttachments.mock.calls[0]?.[0]?.[0]).toMatchObject({
|
||||
filePath: 'preview.html',
|
||||
elementId: 'hero',
|
||||
comment: '加大字号',
|
||||
source: 'board-batch',
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId('comment-popover')).toBeNull());
|
||||
});
|
||||
|
||||
it('keeps the comment draft when chat queueing declines the send', async () => {
|
||||
const onSendBoardCommentAttachments = vi.fn().mockResolvedValue(false);
|
||||
render(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
projectKind="prototype"
|
||||
file={htmlPreviewFile()}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
onSendBoardCommentAttachments={onSendBoardCommentAttachments}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
fireEvent.click(screen.getByTestId('board-mode-toggle'));
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
source: frame.contentWindow,
|
||||
data: {
|
||||
type: 'od:comment-target',
|
||||
elementId: 'hero',
|
||||
selector: '[data-od-id="hero"]',
|
||||
label: 'Hero',
|
||||
text: 'Hero',
|
||||
position: { x: 8, y: 12, width: 120, height: 48 },
|
||||
htmlHint: '<main data-od-id="hero">Hero</main>',
|
||||
},
|
||||
}));
|
||||
|
||||
const input = await screen.findByTestId('comment-popover-input') as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: '保留这个评论' } });
|
||||
fireEvent.click(screen.getByTestId('comment-add-send'));
|
||||
|
||||
await waitFor(() => expect(onSendBoardCommentAttachments).toHaveBeenCalledTimes(1));
|
||||
expect(screen.getByTestId('comment-popover')).toBeTruthy();
|
||||
expect((screen.getByTestId('comment-popover-input') as HTMLTextAreaElement).value)
|
||||
.toBe('保留这个评论');
|
||||
});
|
||||
|
||||
it('returns to element picking from the Comment button while another annotation tool is active', async () => {
|
||||
render(
|
||||
<FileViewer
|
||||
|
|
|
|||
|
|
@ -153,8 +153,12 @@ vi.mock('../../src/components/ChatPane', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const fileWorkspaceSpy = vi.fn();
|
||||
vi.mock('../../src/components/FileWorkspace', () => ({
|
||||
FileWorkspace: () => null,
|
||||
FileWorkspace: (props: Record<string, unknown>) => {
|
||||
fileWorkspaceSpy(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/components/Loading', () => ({
|
||||
|
|
@ -702,6 +706,85 @@ describe('ProjectView daemon cleanup', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('queues board comment attachments while the current daemon run is still busy', async () => {
|
||||
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
|
||||
listMessages.mockResolvedValue([]);
|
||||
fetchPreviewComments.mockResolvedValue([]);
|
||||
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
|
||||
fetchProjectFiles.mockResolvedValue([]);
|
||||
fetchLiveArtifacts.mockResolvedValue([]);
|
||||
fetchSkill.mockResolvedValue(null);
|
||||
fetchDesignSystem.mockResolvedValue(null);
|
||||
getTemplate.mockResolvedValue(null);
|
||||
listActiveChatRuns.mockResolvedValue([]);
|
||||
streamViaDaemon.mockImplementation(async () => new Promise<void>(() => {}));
|
||||
|
||||
render(
|
||||
<ProjectView
|
||||
project={{
|
||||
id: 'project-comments',
|
||||
name: 'Project',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
} as never}
|
||||
routeFileName={null}
|
||||
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
|
||||
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
|
||||
skills={[]}
|
||||
designTemplates={[]}
|
||||
designSystems={[]}
|
||||
daemonLive
|
||||
onModeChange={() => {}}
|
||||
onAgentChange={() => {}}
|
||||
onAgentModelChange={() => {}}
|
||||
onRefreshAgents={() => {}}
|
||||
onOpenSettings={() => {}}
|
||||
onBack={() => {}}
|
||||
onClearPendingPrompt={() => {}}
|
||||
onTouchProject={() => {}}
|
||||
onProjectChange={() => {}}
|
||||
onProjectsRefresh={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const chatProps = await waitForReadyChatPaneProps();
|
||||
await chatProps.onSend!('keep running', [], []);
|
||||
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => {
|
||||
expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.streaming).toBe(true);
|
||||
});
|
||||
|
||||
const workspaceProps = fileWorkspaceSpy.mock.calls.at(-1)?.[0] as {
|
||||
onSendBoardCommentAttachments?: (attachments: unknown[]) => Promise<boolean | void>;
|
||||
};
|
||||
expect(workspaceProps.onSendBoardCommentAttachments).toBeTruthy();
|
||||
|
||||
await workspaceProps.onSendBoardCommentAttachments!([
|
||||
{
|
||||
id: 'hero-board-1',
|
||||
order: 1,
|
||||
filePath: 'preview.html',
|
||||
elementId: 'hero',
|
||||
selector: '[data-od-id="hero"]',
|
||||
label: 'Hero',
|
||||
comment: 'Use a warmer accent',
|
||||
currentText: 'Hero',
|
||||
pagePosition: { x: 10, y: 20, width: 100, height: 40 },
|
||||
htmlHint: '<main data-od-id="hero">',
|
||||
source: 'board-batch',
|
||||
},
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
const latest = chatPaneSpy.mock.calls.at(-1)?.[0] as {
|
||||
queuedItems?: Array<{ commentAttachments?: Array<{ comment: string }> }>;
|
||||
};
|
||||
expect(latest.queuedItems).toHaveLength(1);
|
||||
expect(latest.queuedItems?.[0]?.commentAttachments?.[0]?.comment).toBe('Use a warmer accent');
|
||||
});
|
||||
expect(streamViaDaemon).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('audits design-system workspace output after first auto-send and seeds a bounded repair prompt', async () => {
|
||||
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
|
||||
listMessages.mockResolvedValue([]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue