open-design/apps/web/tests/components/ProjectView.run-cleanup.test.tsx
Eli-tangerine 8193981511
Keep PR 2400 changes without folder pickers (#2462)
* feat(daemon): add project working directory management and editor hand-off functionality

- Introduced new flags for project commands to manage working directories, including `--working-dir` and `--dir`.
- Implemented API routes for listing available editors and opening projects in selected editors.
- Added a hand-off button in the ChatPane header to facilitate opening project folders in local applications.
- Enhanced the HomeHero component to include working directory and design system settings, improving user experience in project creation.
- Created HomeHeroSettingsChips component for inline management of working directory and design system selection.

* feat(chat): implement voice transcription proxy and enhance UI components

- Added a new API route for voice transcription using OpenAI's `/audio/transcriptions` endpoint, allowing users to send audio blobs directly for transcription.
- Integrated multer for handling audio file uploads in memory, ensuring efficient processing without disk storage.
- Updated the HomeHero component to include example prompt suggestions for plugins, enhancing user interaction.
- Introduced the EditorIcon component to visually represent different editors in the hand-off menu, improving the user experience.
- Refined the HandoffButton component to utilize the new EditorIcon, providing a more cohesive interface for selecting editors.
- Enhanced CSS styles for various components to improve layout and responsiveness, including adjustments to tab and button sizes for better usability.

* style(workspace-shell): enhance layout and overflow handling

- Updated CSS for .workspace-shell to ensure full viewport width and height, with proper overflow management.
- Adjusted grid layout to prevent content overflow and maintain responsiveness.
- Modified styles for .workspace-tabs-chrome to improve width handling and prevent overflow issues.

* refactor(chat): remove voice transcription proxy and related components

- Deleted the voice transcription proxy implementation, including the associated API route and multer configuration.
- Removed the MicButton component from the ChatComposer and HomeHero components to streamline the UI.
- Updated HomeHero to include example suggestions without the voice input functionality.
- Adjusted CSS styles for various components to maintain layout consistency after the removal of the MicButton.

* feat(daemon): implement minting of HMAC tokens for working directory management

- Added a new function `mintImportTokenFromCurrentSecret` to generate HMAC tokens bound to a specified base directory, enhancing security for working directory operations.
- Updated the `desktop-auth.ts` file to include the new token minting functionality, which returns structured errors when the desktop auth secret is cleared.
- Introduced new IPC message types for minting import tokens in the sidecar protocol, allowing seamless integration with the daemon's working directory management.
- Enhanced the `WorkingDirPill` component to utilize the new token minting flow for secure directory selection in desktop builds.
- Updated CSS styles for the HomeHero component to accommodate new example suggestion features and maintain layout consistency.

* fix(HomeView): import HOME_HERO_CHIPS constant for improved chip management

- Updated the HomeView component to import the HOME_HERO_CHIPS constant from the chips module, enhancing the management of hero chips within the component.

* feat(daemon): implement mintImportTokenViaSidecar for secure working directory management

- Introduced the `mintImportTokenViaSidecar` function to facilitate the minting of HMAC tokens for desktop-import operations via the daemon's sidecar IPC. This allows CLI commands to bypass authentication when the desktop-auth gate is active.
- Updated the CLI to utilize the new token minting function when setting the working directory, ensuring secure access to trust-gated API endpoints.
- Enhanced the sidecar server to handle minting requests and return structured error messages for improved user feedback.
- Added tests to validate the new token minting functionality and its integration with the working directory management process.
- Refactored related components to support the new token flow, improving overall security and user experience.

* feat(HomeHero): enhance UI components and styles for improved user experience

- Updated HomeHero component to replace active dot indicators with Plug icons for better visual representation of active plugins.
- Adjusted CSS styles for various elements, including padding and dimensions, to enhance layout consistency and responsiveness.
- Introduced new styles for active type icons and improved hover effects for buttons.
- Updated HomeHeroSettingsChips to change button titles and icons for clarity.
- Added tests to ensure proper rendering and functionality of updated components.

* feat(ProjectDesignSystemPicker): enhance design system selection with preview functionality

- Updated the ProjectDesignSystemPicker component to include a preview feature for design systems, allowing users to see a preview of the selected design system.
- Implemented hover functionality to update the preview based on the hovered design system.
- Added fullscreen preview capability for a more immersive experience.
- Enhanced CSS styles for the design system picker to improve layout and responsiveness.
- Introduced tests to validate the new preview functionality and ensure proper interaction within the component.

* feat: refactor project metadata handling and enhance design system picker

- Updated the default scenario plugin ID retrieval to use project metadata, improving the logic for determining the appropriate plugin based on project intent.
- Enhanced the ProjectDesignSystemPicker and related components to support localized design system summaries and categories, improving user experience.
- Introduced new translations for working directory and design system picker components, ensuring better accessibility and usability across different locales.
- Added a new 'live-artifact' project type to the HomeHero chips, expanding the functionality for users creating refreshable artifacts.
- Updated tests to validate the new project metadata handling and design system picker functionalities.

* feat: enhance localization and styling for design system components

- Added French translations for working directory and design system picker components, improving accessibility for French-speaking users.
- Updated CSS styles for the pet task item to ensure consistent padding and layout.
- Introduced a new test suite for HomeHeroSettingsChips to validate localization and design system selection functionality.
- Enhanced ProjectDesignSystemPicker tests to ensure proper localization and interaction with design system categories.

* fix: update .gitignore to include all claude-sessions directories and remove specific session files

- Modified .gitignore to ensure all claude-sessions directories are ignored by using a wildcard pattern.
- Deleted two specific claude-sessions markdown files to clean up unnecessary session data.

* fix: repair home automation ci regressions

* fix: stabilize artifact consistency e2e

* Remove folder picker changes from PR 2400

---------

Co-authored-by: pftom <1043269994@qq.com>
Co-authored-by: qiongyu1999 <2694684348@qq.com>
2026-05-20 22:07:30 +08:00

1099 lines
40 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ProjectView,
clearStreamingConversationMarker,
finalizeActiveAssistantMessagesOnStop,
findExistingArtifactProjectFile,
resolveSucceededRunStatus,
shouldClearActiveRunRefs,
} from '../../src/components/ProjectView';
import type { Artifact, ChatMessage, ProjectFile } from '../../src/types';
const listConversations = vi.fn();
const listMessages = vi.fn();
const fetchPreviewComments = vi.fn();
const loadTabs = vi.fn();
const fetchProjectFiles = vi.fn();
const fetchProjectDesignSystemPackageAudit = vi.fn();
const fetchLiveArtifacts = vi.fn();
const fetchSkill = vi.fn();
const fetchDesignSystem = vi.fn();
const getTemplate = vi.fn();
const fetchChatRunStatus = vi.fn();
const listActiveChatRuns = vi.fn();
const listProjectRuns = vi.fn();
const reattachDaemonRun = vi.fn();
const streamViaDaemon = vi.fn();
const saveMessage = vi.fn();
const createConversation = vi.fn();
const patchConversation = vi.fn();
const patchProject = vi.fn();
const saveTabs = vi.fn();
const writeProjectTextFile = vi.fn();
const replayArtifact: Artifact = {
identifier: 'real-daemon-smoke',
artifactType: 'text/html',
title: 'Real Daemon Smoke',
html: '<!doctype html><html><body><h1>Real Daemon Smoke</h1></body></html>',
};
function artifactProjectFile(name: string, mtime: number): ProjectFile {
return {
artifactManifest: {
entry: name,
exports: ['html'],
kind: 'html',
metadata: {
artifactType: 'text/html',
identifier: 'real-daemon-smoke',
inferred: false,
},
renderer: 'html',
title: 'Real Daemon Smoke',
version: 1,
},
kind: 'html',
mime: 'text/html',
mtime,
name,
size: 100,
};
}
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => ((value: string) => value),
}));
vi.mock('../../src/providers/anthropic', () => ({
streamMessage: vi.fn(),
}));
vi.mock('../../src/providers/daemon', () => ({
fetchChatRunStatus: (...args: unknown[]) => fetchChatRunStatus(...args),
listActiveChatRuns: (...args: unknown[]) => listActiveChatRuns(...args),
listProjectRuns: (...args: unknown[]) => listProjectRuns(...args),
reattachDaemonRun: (...args: unknown[]) => reattachDaemonRun(...args),
streamViaDaemon: (...args: unknown[]) => streamViaDaemon(...args),
}));
vi.mock('../../src/providers/registry', () => ({
deletePreviewComment: vi.fn(),
fetchPreviewComments: (...args: unknown[]) => fetchPreviewComments(...args),
fetchDesignSystem: (...args: unknown[]) => fetchDesignSystem(...args),
fetchProjectDesignSystemPackageAudit: (...args: unknown[]) => fetchProjectDesignSystemPackageAudit(...args),
fetchLiveArtifacts: (...args: unknown[]) => fetchLiveArtifacts(...args),
fetchProjectFiles: (...args: unknown[]) => fetchProjectFiles(...args),
fetchSkill: (...args: unknown[]) => fetchSkill(...args),
patchPreviewCommentStatus: vi.fn(),
upsertPreviewComment: vi.fn(),
writeProjectTextFile: (...args: unknown[]) => writeProjectTextFile(...args),
}));
vi.mock('../../src/providers/project-events', () => ({
useProjectFileEvents: vi.fn(),
}));
vi.mock('../../src/router', () => ({
navigate: vi.fn(),
}));
vi.mock('../../src/state/projects', () => ({
createConversation: (...args: unknown[]) => createConversation(...args),
deleteConversation: vi.fn(),
getTemplate: (...args: unknown[]) => getTemplate(...args),
listConversations: (...args: unknown[]) => listConversations(...args),
listMessages: (...args: unknown[]) => listMessages(...args),
loadTabs: (...args: unknown[]) => loadTabs(...args),
patchConversation: (...args: unknown[]) => patchConversation(...args),
patchProject: (...args: unknown[]) => patchProject(...args),
saveMessage: (...args: unknown[]) => saveMessage(...args),
saveTabs: (...args: unknown[]) => saveTabs(...args),
}));
vi.mock('../../src/components/AppChromeHeader', () => ({
AppChromeHeader: () => null,
}));
vi.mock('../../src/components/AvatarMenu', () => ({
AvatarMenu: () => null,
}));
const chatPaneSpy = vi.fn();
vi.mock('../../src/components/ChatPane', () => ({
ChatPane: (props: Record<string, unknown>) => {
chatPaneSpy(props);
return null;
},
}));
vi.mock('../../src/components/FileWorkspace', () => ({
FileWorkspace: () => null,
}));
vi.mock('../../src/components/Loading', () => ({
CenteredLoader: () => null,
}));
async function waitForReadyChatPaneProps() {
await waitFor(() => {
expect(chatPaneSpy).toHaveBeenCalled();
expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.sendDisabled).toBe(false);
});
return chatPaneSpy.mock.calls.at(-1)?.[0] as {
onSend?: (prompt: string, attachments: unknown[], comments: unknown[]) => Promise<void>;
initialDraft?: string;
};
}
describe('terminal replay artifact recovery', () => {
it('only reuses existing artifacts created at or after the current run started', () => {
const runCreatedAt = 1_000;
const stale = artifactProjectFile('real-daemon-smoke.html', runCreatedAt - 1);
const current = artifactProjectFile('real-daemon-smoke-2.html', runCreatedAt + 1);
expect(findExistingArtifactProjectFile(replayArtifact, [stale], { minMtime: runCreatedAt }))
.toBeNull();
expect(findExistingArtifactProjectFile(replayArtifact, [stale, current], { minMtime: runCreatedAt }))
.toBe(current);
});
});
describe('ProjectView daemon cleanup', () => {
beforeEach(() => {
listProjectRuns.mockResolvedValue([]);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
window.sessionStorage.clear();
});
it('does not abort daemon cancel reattach controllers during unmount cleanup', async () => {
let seenCancelSignal: { aborted: boolean } | null = null;
let seenSignal: { aborted: boolean } | null = null;
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-1',
role: 'assistant',
content: 'working',
createdAt: Date.now(),
runId: 'run-1',
runStatus: 'running',
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-1',
status: 'running',
createdAt: Date.now(),
updatedAt: Date.now(),
exitCode: null,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
reattachDaemonRun.mockImplementation(async (options: { signal: { aborted: boolean }; cancelSignal?: { aborted: boolean } }) => {
seenSignal = options.signal;
seenCancelSignal = options.cancelSignal ?? null;
return new Promise<void>(() => {});
});
const view = render(
<ProjectView
project={{ id: 'project-1', 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={() => {}}
/>,
);
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1));
expect(seenSignal).not.toBeNull();
expect(seenCancelSignal).not.toBeNull();
view.unmount();
if (!seenSignal || !seenCancelSignal) throw new Error('Expected reattach signals to be captured');
expect((seenSignal as any).aborted).toBe(true);
expect((seenCancelSignal as any).aborted).toBe(false);
});
it('marks successful daemon completion as succeeded even before runId reaches message state', () => {
expect(resolveSucceededRunStatus('running')).toBe('succeeded');
expect(resolveSucceededRunStatus('queued')).toBe('succeeded');
expect(resolveSucceededRunStatus(undefined)).toBe('succeeded');
expect(resolveSucceededRunStatus('failed')).toBe('failed');
expect(resolveSucceededRunStatus('canceled')).toBe('canceled');
});
// Regression: a phantom 'running' row in DB (no runId, no matching active
// daemon run) used to stick the UI on "Waiting for first output —
// Working 24m+" forever. The reattach loop now self-heals by marking
// such a message as failed so the composer is interactive again.
//
// TODO(reconcile): re-add the three unit tests for
// finalizeActiveAssistantMessagesOnStop / clearStreamingConversationMarker /
// shouldClearActiveRunRefs that landed on main alongside this hunk —
// they were dropped at merge because their bodies sat on top of HEAD's
// self-heals fixture and the test body that follows uses the
// `startedAt` variable declared only in this `it()` opener.
it('self-heals running messages with no runId when daemon has no active run', async () => {
const startedAt = Date.now();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-phantom',
role: 'assistant',
content: '',
createdAt: startedAt,
startedAt,
runStatus: 'running',
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
render(
<ProjectView
project={{ id: 'project-1', 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={() => {}}
/>,
);
await waitFor(() => expect(listActiveChatRuns).toHaveBeenCalled());
await waitFor(() => {
const failedCall = saveMessage.mock.calls.find(
(call) =>
call[2]?.id === 'msg-phantom' && call[2]?.runStatus === 'failed',
);
expect(failedCall).toBeTruthy();
});
expect(reattachDaemonRun).not.toHaveBeenCalled();
});
it('persists a delayed daemon run id after switching projects so returning can reattach', async () => {
const projectOne = { id: 'project-1', name: 'Project One', skillId: null, designSystemId: null };
const projectTwo = { id: 'project-2', name: 'Project Two', skillId: null, designSystemId: null };
const messagesByConversation = new Map<string, ChatMessage[]>([
['conv-1', []],
['conv-2', []],
]);
listConversations.mockImplementation(async (projectId: string) => [
projectId === 'project-1'
? { id: 'conv-1', title: 'Conversation 1' }
: { id: 'conv-2', title: 'Conversation 2' },
]);
listMessages.mockImplementation(async (_projectId: string, conversationId: string) =>
messagesByConversation.get(conversationId) ?? [],
);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-delayed',
status: 'running',
createdAt: 1,
updatedAt: 1,
exitCode: null,
signal: null,
});
saveMessage.mockImplementation(async (_projectId: string, conversationId: string, message: ChatMessage) => {
const existing = messagesByConversation.get(conversationId) ?? [];
const next = existing.filter((item) => item.id !== message.id);
next.push(message);
messagesByConversation.set(conversationId, next);
return message;
});
reattachDaemonRun.mockImplementation(async () => new Promise<void>(() => {}));
let capturedRunCreated: ((runId: string) => void) | null = null;
let capturedStreamSignal: AbortSignal | null = null;
let capturedCancelSignal: AbortSignal | null = null;
let capturedAssistantMessageId: string | null = null;
streamViaDaemon.mockImplementation(async (options: {
assistantMessageId?: string;
signal: AbortSignal;
cancelSignal?: AbortSignal;
onRunCreated?: (runId: string) => void;
}) => {
capturedRunCreated = options.onRunCreated ?? null;
capturedStreamSignal = options.signal;
capturedCancelSignal = options.cancelSignal ?? null;
capturedAssistantMessageId = options.assistantMessageId ?? null;
return new Promise<void>(() => {});
});
const view = render(
<ProjectView
project={projectOne 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 sendProps = await waitForReadyChatPaneProps();
await sendProps.onSend!('keep running', [], []);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
expect(capturedRunCreated).not.toBeNull();
view.rerender(
<ProjectView
project={projectTwo 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={() => {}}
/>,
);
await waitFor(() => expect((capturedStreamSignal as AbortSignal | null)?.aborted).toBe(true));
expect((capturedCancelSignal as AbortSignal | null)?.aborted).toBe(false);
capturedRunCreated!('run-delayed');
await waitFor(() => {
const persistedAssistant = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-1' &&
call[1] === 'conv-1' &&
call[2]?.id === capturedAssistantMessageId &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-delayed' &&
call[2]?.runStatus === 'queued',
);
expect(persistedAssistant).toBeTruthy();
});
view.rerender(
<ProjectView
project={projectOne 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={() => {}}
/>,
);
await waitFor(() => {
expect(reattachDaemonRun).toHaveBeenCalledWith(
expect.objectContaining({ runId: 'run-delayed' }),
);
});
});
// Regression: when a project is created via PluginLoopHome with the
// auto-send sessionStorage flag set, ProjectView used to seed
// ChatComposer.initialDraft with project.pendingPrompt. The composer
// latched that seed into local state, then auto-send fired the same
// text as a real user message — leaving the textarea populated while
// the run streamed. The user reported "好像发送了输入框的 query 还
// 没有清除". With the fix, auto-send projects must hand the composer
// an undefined initialDraft so the textarea stays empty; the seed
// still flows through autoSendSeedRef so the prompt is delivered.
it('does not seed composer initialDraft when auto-send sessionStorage flag is set', 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.mockResolvedValue(undefined);
chatPaneSpy.mockClear();
window.sessionStorage.setItem('od:auto-send-first:project-2', '1');
try {
render(
<ProjectView
project={{
id: 'project-2',
name: 'Project',
skillId: null,
designSystemId: null,
pendingPrompt: 'design a landing page for a coffee shop',
} 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={() => {}}
/>,
);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
const seededCall = chatPaneSpy.mock.calls.find(
(call) => call[0]?.initialDraft === 'design a landing page for a coffee shop',
);
expect(seededCall).toBeUndefined();
} finally {
window.sessionStorage.removeItem('od:auto-send-first:project-2');
}
});
it('auto-sends Home-staged design files as first-turn daemon attachments', 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.mockResolvedValue(undefined);
chatPaneSpy.mockClear();
window.sessionStorage.setItem('od:auto-send-first:project-files', '1');
window.sessionStorage.setItem(
'od:auto-send-attachments:project-files',
JSON.stringify([
{ path: 'brief.pdf', name: 'brief.pdf', kind: 'file', size: 5 },
{ path: 'logo.png', name: 'logo.png', kind: 'image', size: 7 },
]),
);
try {
render(
<ProjectView
project={{
id: 'project-files',
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={() => {}}
/>,
);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
expect(streamViaDaemon.mock.calls[0]?.[0]).toMatchObject({
attachments: ['brief.pdf', 'logo.png'],
history: [
expect.objectContaining({
role: 'user',
content: '',
attachments: [
{ path: 'brief.pdf', name: 'brief.pdf', kind: 'file', size: 5 },
{ path: 'logo.png', name: 'logo.png', kind: 'image', size: 7 },
],
}),
],
});
expect(window.sessionStorage.getItem('od:auto-send-first:project-files')).toBeNull();
expect(window.sessionStorage.getItem('od:auto-send-attachments:project-files')).toBeNull();
} finally {
window.sessionStorage.removeItem('od:auto-send-first:project-files');
window.sessionStorage.removeItem('od:auto-send-attachments:project-files');
}
});
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([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue({
ok: false,
projectPath: '/tmp/ds',
filesInspected: 12,
errors: [{
severity: 'error',
code: 'ui_kit_index_missing_runtime_bootstrap',
message: 'ui_kits/app/index.html must mount the kit.',
path: 'ui_kits/app/index.html',
}],
warnings: [],
});
let streamCallCount = 0;
streamViaDaemon.mockImplementation(async (options: {
handlers: { onDone: () => void };
onRunCreated?: (runId: string) => void;
}) => {
streamCallCount += 1;
options.onRunCreated?.(`run-ds-${streamCallCount}`);
if (streamCallCount === 1) {
options.handlers.onDone();
}
});
chatPaneSpy.mockClear();
window.sessionStorage.setItem('od:auto-send-first:project-ds', '1');
render(
<ProjectView
project={{
id: 'project-ds',
name: 'Cherry Studio Design System',
skillId: null,
designSystemId: 'user:cherry-studio',
pendingPrompt: 'Create this project as a design system.',
metadata: {
importedFrom: 'design-system',
entryFile: 'DESIGN.md',
sourceFileName: 'user:cherry-studio',
},
} 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={() => {}}
/>,
);
await waitFor(() => expect(fetchProjectDesignSystemPackageAudit).toHaveBeenCalledWith('project-ds'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalled());
expect(window.sessionStorage.getItem('od:design-system-audit-auto-repair:project-ds')).toBe('1');
await waitFor(() => {
const repairSeed = chatPaneSpy.mock.calls.find(
(call) => typeof call[0]?.initialDraft === 'string'
&& call[0].initialDraft.includes('Fix the design-system package audit findings below.')
&& call[0].initialDraft.includes('ui_kit_index_missing_runtime_bootstrap'),
);
expect(repairSeed).toBeTruthy();
});
expect(saveMessage.mock.calls.some((call) =>
call[2]?.role === 'assistant'
&& call[2]?.events?.some((event: { kind?: string; label?: string; detail?: string }) =>
event.kind === 'status'
&& event.label === 'audit'
&& event.detail?.includes('Package audit found 1 error'),
),
)).toBe(true);
});
it('clears design-system auto-repair budget when the first audit passes', 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([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue({
ok: true,
projectPath: '/tmp/ds',
filesInspected: 24,
errors: [],
warnings: [],
});
streamViaDaemon.mockImplementation(async (options: {
handlers: { onDone: () => void };
onRunCreated?: (runId: string) => void;
}) => {
options.onRunCreated?.('run-ds-pass');
options.handlers.onDone();
});
chatPaneSpy.mockClear();
window.sessionStorage.setItem('od:auto-send-first:project-ds-pass', '1');
render(
<ProjectView
project={{
id: 'project-ds-pass',
name: 'Passing Design System',
skillId: null,
designSystemId: 'user:passing-ds',
pendingPrompt: 'Create this project as a design system.',
metadata: {
importedFrom: 'design-system',
entryFile: 'DESIGN.md',
sourceFileName: 'user:passing-ds',
},
} 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={() => {}}
/>,
);
await waitFor(() => expect(fetchProjectDesignSystemPackageAudit).toHaveBeenCalledWith('project-ds-pass'));
expect(streamViaDaemon).toHaveBeenCalledTimes(1);
expect(window.sessionStorage.getItem('od:design-system-audit-auto-repair:project-ds-pass')).toBeNull();
});
// Sister check: without the auto-send flag, the composer should still
// seed from pendingPrompt so the user can edit before manually sending.
it('seeds composer initialDraft with pendingPrompt when auto-send flag is absent', 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([]);
chatPaneSpy.mockClear();
window.sessionStorage.removeItem('od:auto-send-first:project-3');
render(
<ProjectView
project={{
id: 'project-3',
name: 'Project',
skillId: null,
designSystemId: null,
pendingPrompt: 'design a landing page for a coffee shop',
} 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={() => {}}
/>,
);
await waitFor(() => expect(chatPaneSpy).toHaveBeenCalled());
// The first render — before activeConversationId resolves — must
// pass the seed through so ChatComposer can populate its draft.
const seedingCall = chatPaneSpy.mock.calls.find(
(call) => call[0]?.initialDraft === 'design a landing page for a coffee shop',
);
expect(seedingCall).toBeTruthy();
});
// Root-cause regression for the "Working 24m+ / Waiting for first output"
// stuck UI. The phantom was created at line `persistMessage(assistantMsg)`
// in handleSend: a daemon assistant row was written to DB with
// runStatus='running' BEFORE POST /api/runs returned a runId. If that POST
// never returned (slow daemon, network blip, component unmount mid-flight),
// the row was orphaned forever with no runId for the reattach loop to
// recover. The fix: persistMessage / persistMessageById / updateMessageById
// all refuse to write a daemon assistant row that is still active without
// a runId. The first DB write for that row only happens once onRunCreated
// pins the daemon's runId onto the message.
it('does not persist an assistant message before POST /api/runs returns a runId', 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: capture onRunCreated but never resolve, so the POST
// looks "in-flight" for the rest of the test. This is the exact window
// in which phantom rows used to be written.
let capturedOnRunCreated: ((runId: string) => void) | null = null;
streamViaDaemon.mockImplementation(async (options: { onRunCreated?: (runId: string) => void }) => {
capturedOnRunCreated = options.onRunCreated ?? null;
return new Promise<void>(() => {});
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-phantom', 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 sendProps = await waitForReadyChatPaneProps();
expect(sendProps?.onSend).toBeTypeOf('function');
await sendProps!.onSend!('hello world', [], []);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
// The user message must be persisted immediately — it is committed
// user intent and has no runId concept.
const userSave = saveMessage.mock.calls.find((call) => call[2]?.role === 'user');
expect(userSave?.[2]?.content).toBe('hello world');
// The assistant placeholder must NOT be persisted yet: runStatus
// is 'running' and the daemon has not returned a runId.
const phantomSave = saveMessage.mock.calls.find(
(call) =>
call[2]?.role === 'assistant' &&
call[2]?.runStatus === 'running' &&
!call[2]?.runId,
);
expect(phantomSave).toBeUndefined();
// Now simulate POST /api/runs returning a runId. The assistant row
// transitions to 'queued' with a runId — that's a non-phantom write
// that the guard lets through.
expect(capturedOnRunCreated).not.toBeNull();
capturedOnRunCreated!('run-pinned-xyz');
await waitFor(() => {
const pinnedSave = saveMessage.mock.calls.find(
(call) =>
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-pinned-xyz' &&
call[2]?.runStatus === 'queued',
);
expect(pinnedSave).toBeTruthy();
});
});
// Companion regression: if the user navigates away (component unmounts)
// BEFORE onRunCreated ever fires, the assistant placeholder must never
// appear in DB. This is the exact failure mode the user reported — the
// PluginLoopHome auto-send fired, the user moved on, and a phantom row
// sat forever in the project's conversation.
it('never persists a phantom assistant row when send aborts before runId', 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 () => {
// Simulate a POST that never returns (network blip, daemon timeout).
return new Promise<void>(() => {});
});
chatPaneSpy.mockClear();
const view = render(
<ProjectView
project={{ id: 'project-aborted', 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 sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('quick send', [], []);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
view.unmount();
const phantomSave = saveMessage.mock.calls.find(
(call) =>
call[2]?.role === 'assistant' &&
call[2]?.runStatus === 'running' &&
!call[2]?.runId,
);
expect(phantomSave).toBeUndefined();
});
it('relinks terminal replay to an existing artifact without writing a duplicate file', async () => {
const runCreatedAt = Date.now();
const existingArtifact = {
artifactManifest: {
entry: 'real-daemon-smoke.html',
exports: ['html'],
kind: 'html',
metadata: {
artifactType: 'text/html',
identifier: 'real-daemon-smoke',
inferred: false,
},
renderer: 'html',
title: 'Real Daemon Smoke',
version: 1,
},
kind: 'html',
mime: 'text/html',
mtime: runCreatedAt + 1,
name: 'real-daemon-smoke.html',
size: 100,
};
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-replay',
role: 'assistant',
content: '',
createdAt: Date.now(),
runId: 'run-replay',
runStatus: 'succeeded',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([existingArtifact]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-replay',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 0,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onDelta: (delta: string) => void;
onDone: () => void;
};
}) => {
options.handlers.onDelta(
'<artifact identifier="real-daemon-smoke" type="text/html" title="Real Daemon Smoke"><h1>Real Daemon Smoke</h1></artifact>',
);
options.handlers.onDone();
});
render(
<ProjectView
project={{ id: 'project-1', 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={() => {}}
/>,
);
await waitFor(() => {
expect(saveMessage.mock.calls).toEqual(
expect.arrayContaining([
expect.arrayContaining([
'project-1',
'conv-1',
expect.objectContaining({
id: 'msg-replay',
producedFiles: [existingArtifact],
}),
]),
]),
);
});
expect(writeProjectTextFile).not.toHaveBeenCalled();
});
});