Remove resume conversation button (#2562)

This commit is contained in:
Siri-Ray 2026-05-21 17:55:03 +08:00 committed by GitHub
parent f677563313
commit b236b37b7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1 additions and 737 deletions

View file

@ -256,10 +256,6 @@ interface Props {
// Header "+" button — kicks off ProjectView's create-conversation flow.
onNewConversation?: () => void;
newConversationDisabled?: boolean;
// Header "resume" button — synthesizes a handoff prompt from the
// current transcript and opens a fresh conversation seeded with it.
onResumeConversation?: () => void;
resumeConversationDisabled?: boolean;
// Conversation list that used to live in the topbar. The chat tab now
// owns the list so users can browse + switch conversations without
// leaving the pane.
@ -330,8 +326,6 @@ export function ChatPane({
onAssistantFeedback,
onNewConversation,
newConversationDisabled = false,
onResumeConversation,
resumeConversationDisabled = false,
conversations,
activeConversationId,
onSelectConversation,
@ -762,19 +756,6 @@ export function ChatPane({
>
<Icon name="plus" size={16} />
</button>
{onResumeConversation ? (
<button
type="button"
className="icon-only"
data-testid="resume-conversation"
title={t('chat.resumeConversation')}
aria-label={t('chat.resumeConversation')}
onClick={onResumeConversation}
disabled={resumeConversationDisabled}
>
<Icon name="reload" size={16} />
</button>
) : null}
{onCollapse ? (
<button
type="button"

View file

@ -83,7 +83,6 @@ import {
patchProject,
saveMessage,
saveTabs,
synthesizeHandoff,
type SaveMessageOptions,
} from '../state/projects';
import type { AppliedPluginSnapshot } from '@open-design/contracts';
@ -611,13 +610,6 @@ export function ProjectView({
// correctly gate new-conversation creation even during async loads.
const messagesConversationIdRef = useRef<string | null>(null);
const creatingConversationRef = useRef(false);
// Resume-conversation handoff (#462): once the new conversation is
// created we cannot call `handleSend` synchronously — its guards
// reject until that conversation's message DB read settles. We stash
// the synthesized prompt + target conversation id here and let a
// dedicated effect fire the auto-send once the conversation is ready,
// mirroring the PluginLoopHome auto-send pattern below.
const pendingResumeRef = useRef<{ conversationId: string; prompt: string } | null>(null);
// Last conversation id this view pushed into the URL. Lets the
// route -> active-conversation sync tell a genuine external navigation
// apart from the URL merely lagging a local conversation switch.
@ -645,7 +637,6 @@ export function ProjectView({
// allowed to apply its result.
const conversationsRefreshTokenRef = useRef(0);
const [creatingConversation, setCreatingConversation] = useState(false);
const [resumingConversation, setResumingConversation] = useState(false);
const currentConversationHasActiveRun = useMemo(
() => messages.some((m) => m.role === 'assistant' && isActiveRunStatus(m.runStatus)),
[messages],
@ -663,17 +654,7 @@ export function ProjectView({
|| currentConversationHasActiveRun
|| failedMessagesConversationId === activeConversationId;
const currentConversationActionDisabled = currentConversationBusy || currentConversationSendDisabled;
// Disabled during a resume too: an in-flight handoff synthesis ends in
// its own createConversation, so a concurrent "New conversation" click
// would spawn a second conversation behind the resumed one.
const newConversationDisabled = creatingConversation || resumingConversation;
// Resume needs a transcript to summarize, and must not race a busy
// conversation or a synthesis already in flight.
const resumeConversationDisabled =
resumingConversation
|| creatingConversation
|| currentConversationBusy
|| messages.length === 0;
const newConversationDisabled = creatingConversation;
const activeCompletionNotificationRunsRef = useRef<Set<string>>(new Set());
const completedNotificationRunsRef = useRef<Set<string>>(new Set());
@ -2847,89 +2828,6 @@ export function ProjectView({
}
}, [project.id, activeConversationId, messages.length]);
// #462 — "Resume conversation in new chat". Synthesizes a handoff
// prompt from the current transcript via the daemon, opens a fresh
// conversation in the same project, and auto-sends the prompt as that
// conversation's first user message. The old conversation is kept.
const handleResumeConversation = useCallback(async () => {
if (resumingConversation || creatingConversationRef.current) return;
if (currentConversationBusy) return;
// Nothing to hand off without an active conversation that has messages.
if (!activeConversationId) return;
if (messages.length === 0) return;
const resumedConversationId = activeConversationId;
setResumingConversation(true);
setConversationLoadError(null);
try {
// Only forward baseUrl when the user set a custom one. The default
// Anthropic path normalizes config.baseUrl to '', and the handoff
// route rejects an explicit empty baseUrl with 400 — forwarding it
// would break Resume for every default-config user before synthesis.
const customBaseUrl = config.baseUrl.trim();
const outcome = await synthesizeHandoff(project.id, {
// Scope the handoff to the conversation being resumed — the
// endpoint synthesizes from this conversation's transcript only.
conversationId: resumedConversationId,
apiKey: config.apiKey,
model: config.model,
maxTokens: effectiveMaxTokens(config),
...(customBaseUrl ? { baseUrl: customBaseUrl } : {}),
});
if (!outcome) {
// Transport failure / unparseable response — the daemon never gave
// us a classified reason.
setProjectActionsToast({
message: 'Could not reach the daemon to synthesize a handoff prompt. Try again.',
details: null,
});
return;
}
if ('error' in outcome) {
// Surface the daemon's classified error verbatim (rate limit,
// empty transcript, upstream provider detail, ...) rather than
// collapsing every case into one generic message.
setProjectActionsToast({
message: outcome.error.message,
details: typeof outcome.error.details === 'string' ? outcome.error.details : null,
});
return;
}
const fresh = await createConversation(project.id);
if (!fresh) {
setProjectActionsToast({
message: 'Could not create a conversation to resume into.',
details: null,
});
return;
}
// Hand the prompt to the auto-send effect, then switch to the new
// conversation — mirrors handleNewConversation's eager state reset
// so rapid clicks cannot double-create.
pendingResumeRef.current = { conversationId: fresh.id, prompt: outcome.prompt };
setMessages([]);
setStreaming(false);
streamingConversationIdRef.current = null;
setStreamingConversationId(null);
setMessagesConversationId(null);
messagesConversationIdRef.current = fresh.id;
setConversations((curr) => [fresh, ...curr]);
setActiveConversationId(fresh.id);
setError(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Could not resume this conversation.';
setProjectActionsToast({ message, details: null });
} finally {
setResumingConversation(false);
}
}, [
resumingConversation,
currentConversationBusy,
activeConversationId,
messages.length,
project.id,
config,
]);
const handleSelectConversation = useCallback((id: string) => {
if (id === activeConversationId && failedMessagesConversationId !== id) return;
setMessages([]);
@ -3515,28 +3413,6 @@ export function ProjectView({
handleSend,
]);
// Resume-conversation auto-send (#462). When handleResumeConversation
// has stashed a pending prompt, fire it as the first user message of
// the freshly created conversation — but only once that conversation's
// message DB read has settled (`messagesConversationId` matches its
// id). Gating on the settled id rather than `messagesInitialized`
// matters here: resuming switches away from an already-loaded
// conversation, so `messagesInitialized` has a stale-true window the
// PluginLoopHome auto-send (fresh project mount) never sees. The ref
// is cleared before dispatch so React 18 strict-mode's double-invoke
// cannot fire the send twice.
useEffect(() => {
const pending = pendingResumeRef.current;
if (!pending) return;
if (activeConversationId !== pending.conversationId) return;
if (messagesConversationId !== pending.conversationId) return;
if (messages.length > 0) return;
if (streaming) return;
const prompt = pending.prompt;
pendingResumeRef.current = null;
void handleSend(prompt, [], []);
}, [activeConversationId, messagesConversationId, messages.length, streaming, handleSend]);
// Wire the Critique Theater drop-in mount into the project workspace.
// The hook reads the M1 Settings toggle out of the existing
// `open-design:config` localStorage blob and stays in sync with the
@ -3746,8 +3622,6 @@ export function ProjectView({
onAssistantFeedback={handleAssistantFeedback}
onNewConversation={handleNewConversation}
newConversationDisabled={newConversationDisabled}
onResumeConversation={handleResumeConversation}
resumeConversationDisabled={resumeConversationDisabled}
conversations={conversations}
activeConversationId={activeConversationId}
onSelectConversation={handleSelectConversation}

View file

@ -756,7 +756,6 @@ export const ar: Dict = {
'chat.conversationsAria': 'سجل المحادثات',
'chat.newConversation': 'محادثة جديدة',
'chat.newConversationsTitle': 'محادثة جديدة',
'chat.resumeConversation': 'استئناف في محادثة جديدة',
'chat.conversationsHeading': 'المحادثات',
'chat.new': 'جديد',
'chat.emptyConversations': 'لا توجد محادثات بعد.',

View file

@ -644,7 +644,6 @@ export const de: Dict = {
'chat.conversationsAria': 'Konversationsverlauf',
'chat.newConversation': 'Neue Konversation',
'chat.newConversationsTitle': 'Neue Konversation',
'chat.resumeConversation': 'In neuer Konversation fortsetzen',
'chat.conversationsHeading': 'Konversationen',
'chat.new': 'Neu',
'chat.emptyConversations': 'Noch keine Konversationen.',

View file

@ -1246,7 +1246,6 @@ export const en: Dict = {
'chat.conversationsAria': 'Conversation history',
'chat.newConversation': 'New conversation',
'chat.newConversationsTitle': 'New conversation',
'chat.resumeConversation': 'Resume in new conversation',
'chat.conversationsHeading': 'Conversations',
'chat.new': 'New',
'chat.emptyConversations': 'No conversations yet.',

View file

@ -645,7 +645,6 @@ export const esES: Dict = {
'chat.conversationsAria': 'Historial de conversaciones',
'chat.newConversation': 'Nueva conversación',
'chat.newConversationsTitle': 'Nueva conversación',
'chat.resumeConversation': 'Reanudar en una conversación nueva',
'chat.conversationsHeading': 'Conversaciones',
'chat.new': 'Nueva',
'chat.emptyConversations': 'Aún no hay conversaciones.',

View file

@ -778,7 +778,6 @@ export const fa: Dict = {
'chat.conversationsAria': 'تاریخچه مکالمات',
'chat.newConversation': 'مکالمه جدید',
'chat.newConversationsTitle': 'مکالمه جدید',
'chat.resumeConversation': 'ادامه در مکالمه جدید',
'chat.conversationsHeading': 'مکالمات',
'chat.new': 'جدید',
'chat.emptyConversations': 'هنوز هیچ مکالمه‌ای وجود ندارد.',

View file

@ -772,7 +772,6 @@ export const fr: Dict = {
'chat.conversationsAria': 'Historique des conversations',
'chat.newConversation': 'Nouvelle conversation',
'chat.newConversationsTitle': 'Nouvelle conversation',
'chat.resumeConversation': 'Reprendre dans une nouvelle conversation',
'chat.conversationsHeading': 'Conversations',
'chat.new': 'Nouvelle',
'chat.emptyConversations': 'Aucune conversation pour l\'instant.',

View file

@ -756,7 +756,6 @@ export const hu: Dict = {
'chat.conversationsAria': 'Beszélgetések előzménye',
'chat.newConversation': 'Új beszélgetés',
'chat.newConversationsTitle': 'Új beszélgetés',
'chat.resumeConversation': 'Folytatás új beszélgetésben',
'chat.conversationsHeading': 'Beszélgetések',
'chat.new': 'Új',
'chat.emptyConversations': 'Még nincs beszélgetés.',

View file

@ -870,7 +870,6 @@ export const id: Dict = {
'chat.conversationsAria': 'Buka percakapan',
'chat.newConversation': 'Percakapan baru',
'chat.newConversationsTitle': 'Mulai percakapan baru',
'chat.resumeConversation': 'Lanjutkan di percakapan baru',
'chat.conversationsHeading': 'Percakapan',
'chat.new': 'Baru',
'chat.emptyConversations': 'Belum ada percakapan.',

View file

@ -676,7 +676,6 @@ export const it: Dict = {
'chat.conversationsAria': 'Cronologia delle conversazioni',
'chat.newConversation': 'Nuova conversazione',
'chat.newConversationsTitle': 'Nuova conversazione',
'chat.resumeConversation': 'Riprendi in una nuova conversazione',
'chat.conversationsHeading': 'Conversazioni',
'chat.new': 'Nuova',
'chat.emptyConversations': 'Nessuna conversazione per ora.',

View file

@ -643,7 +643,6 @@ export const ja: Dict = {
'chat.conversationsAria': '会話履歴',
'chat.newConversation': '新しい会話',
'chat.newConversationsTitle': '新しい会話',
'chat.resumeConversation': '新しい会話で再開',
'chat.conversationsHeading': '会話',
'chat.new': '新規',
'chat.emptyConversations': 'まだ会話がありません。',

View file

@ -756,7 +756,6 @@ export const ko: Dict = {
'chat.conversationsAria': '대화 내역',
'chat.newConversation': '새 대화 시작',
'chat.newConversationsTitle': '새 대화',
'chat.resumeConversation': '새 대화에서 이어가기',
'chat.conversationsHeading': '대화 목록',
'chat.new': '새로 만들기',
'chat.emptyConversations': '아직 대화가 없습니다.',

View file

@ -756,7 +756,6 @@ export const pl: Dict = {
'chat.conversationsAria': 'Historia rozmów',
'chat.newConversation': 'Nowa rozmowa',
'chat.newConversationsTitle': 'Nowa rozmowa',
'chat.resumeConversation': 'Wznów w nowej rozmowie',
'chat.conversationsHeading': 'Rozmowy',
'chat.new': 'Nowa',
'chat.emptyConversations': 'Brak rozmów.',

View file

@ -777,7 +777,6 @@ export const ptBR: Dict = {
'chat.conversationsAria': 'Histórico de conversas',
'chat.newConversation': 'Nova conversa',
'chat.newConversationsTitle': 'Nova conversa',
'chat.resumeConversation': 'Retomar em nova conversa',
'chat.conversationsHeading': 'Conversas',
'chat.new': 'Nova',
'chat.emptyConversations': 'Ainda não há conversas.',

View file

@ -777,7 +777,6 @@ export const ru: Dict = {
'chat.conversationsAria': 'История разговоров',
'chat.newConversation': 'Новый разговор',
'chat.newConversationsTitle': 'Новый разговор',
'chat.resumeConversation': 'Продолжить в новом разговоре',
'chat.conversationsHeading': 'Разговоры',
'chat.new': 'Новый',
'chat.emptyConversations': 'Разговоров пока нет.',

View file

@ -712,7 +712,6 @@ export const th: Dict = {
'chat.conversationsAria': 'ประวัติ',
'chat.newConversation': 'สนทนาใหม่',
'chat.newConversationsTitle': 'เริ่มใหม่',
'chat.resumeConversation': 'ดำเนินการต่อในการสนทนาใหม่',
'chat.conversationsHeading': 'บทสนทนาทั้งหมด',
'chat.new': 'ใหม่',
'chat.emptyConversations': 'ยังไม่มีบทสนทนา',

View file

@ -745,7 +745,6 @@ export const tr: Dict = {
'chat.conversationsAria': 'Konuşma geçmişi',
'chat.newConversation': 'Yeni konuşma',
'chat.newConversationsTitle': 'Yeni Konuşma',
'chat.resumeConversation': 'Yeni konuşmada devam et',
'chat.conversationsHeading': 'Konuşmalar',
'chat.new': 'Yeni',
'chat.emptyConversations': 'Henüz konuşma yok.',

View file

@ -778,7 +778,6 @@ export const uk: Dict = {
'chat.conversationsAria': 'Історія розмов',
'chat.newConversation': 'Нова розмова',
'chat.newConversationsTitle': 'Нова розмова',
'chat.resumeConversation': 'Продовжити в новій розмові',
'chat.conversationsHeading': 'Розмови',
'chat.new': 'Нова',
'chat.emptyConversations': 'Розмов ще немає.',

View file

@ -1241,7 +1241,6 @@ export const zhCN: Dict = {
'chat.conversationsAria': '对话历史',
'chat.newConversation': '新建对话',
'chat.newConversationsTitle': '新建对话',
'chat.resumeConversation': '在新对话中继续',
'chat.conversationsHeading': '对话',
'chat.new': '新建',
'chat.emptyConversations': '还没有对话。',

View file

@ -850,7 +850,6 @@ export const zhTW: Dict = {
'chat.conversationsAria': '對話紀錄',
'chat.newConversation': '新建對話',
'chat.newConversationsTitle': '新建對話',
'chat.resumeConversation': '在新對話中繼續',
'chat.conversationsHeading': '對話',
'chat.new': '新建',
'chat.emptyConversations': '還沒有對話。',

View file

@ -1557,7 +1557,6 @@ export interface Dict {
'chat.conversationsAria': string;
'chat.newConversation': string;
'chat.newConversationsTitle': string;
'chat.resumeConversation': string;
'chat.conversationsHeading': string;
'chat.new': string;
'chat.emptyConversations': string;

View file

@ -6,12 +6,9 @@
// the UI can stay rendered when the daemon is briefly unreachable.
import type {
ApiError,
AppliedPluginSnapshot,
ApplyResult,
CreatePluginShareProjectResponse,
HandoffRequest,
HandoffResponse,
ImportFolderRequest,
ImportFolderResponse,
InstalledPluginRecord,
@ -257,44 +254,6 @@ export async function createConversation(
}
}
// Outcome of a handoff synthesis call. The daemon route classifies its
// failures (RATE_LIMITED, EMPTY_TRANSCRIPT, an upstream 400 with provider
// detail, ...); `{ error }` carries that structured error through so the
// caller can show the real reason instead of a generic message. `null`
// is reserved for a transport failure or an unparseable error body.
export type HandoffOutcome = HandoffResponse | { error: ApiError } | null;
// Synthesizes a self-contained "first user message" from the project's
// chat transcript so a fresh conversation can resume work without the
// user replaying context by hand. A transport failure returns null; a
// daemon-classified failure returns `{ error }` so the caller keeps the
// daemon's message/details rather than collapsing every case into one
// generic toast.
export async function synthesizeHandoff(
projectId: string,
body: HandoffRequest,
): Promise<HandoffOutcome> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/handoff`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
if (!resp.ok) {
const payload = (await resp.json().catch(() => null)) as
| { error?: ApiError }
| null;
return payload?.error ? { error: payload.error } : null;
}
return (await resp.json()) as HandoffResponse;
} catch {
return null;
}
}
export async function patchConversation(
projectId: string,
conversationId: string,

View file

@ -1,98 +0,0 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import { forwardRef } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../src/components/ChatPane';
import type { Conversation, ProjectMetadata } from '../../src/types';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));
vi.mock('../../src/components/ChatComposer', () => ({
ChatComposer: forwardRef((_props: Record<string, unknown>, _ref) => (
<output data-testid="composer" />
)),
}));
afterEach(() => {
cleanup();
});
const conversations: Conversation[] = [
{ id: 'conv-1', projectId: 'project-1', title: 'C1', createdAt: 1, updatedAt: 1 },
];
const projectMetadata: ProjectMetadata = { kind: 'prototype' };
function renderChatPane(
props: Partial<Parameters<typeof ChatPane>[0]> = {},
) {
return render(
<ChatPane
messages={[]}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
onNewConversation={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
{...props}
/>,
);
}
describe('ChatPane resume-conversation control', () => {
it('renders the resume button inside the header actions, next to new-conversation', () => {
// The control must sit in the same action cluster as "New conversation"
// so users discover it where they already manage conversations.
renderChatPane({ onResumeConversation: vi.fn() });
const resume = screen.getByTestId('resume-conversation');
const newConv = screen.getByTestId('new-conversation');
expect(resume.closest('.chat-header-actions')).not.toBeNull();
expect(newConv.closest('.chat-header-actions')).toBe(
resume.closest('.chat-header-actions'),
);
});
it('omits the resume button when no handler is wired', () => {
// Without an onResumeConversation handler the feature is unavailable;
// a dead button would read as broken.
renderChatPane({ onResumeConversation: undefined });
expect(screen.queryByTestId('resume-conversation')).toBeNull();
});
it('invokes onResumeConversation when clicked', () => {
const onResumeConversation = vi.fn();
renderChatPane({ onResumeConversation });
screen.getByTestId('resume-conversation').click();
expect(onResumeConversation).toHaveBeenCalledTimes(1);
});
it('disables the button — and ignores clicks — while resumeConversationDisabled is set', () => {
// Disabled covers mid-stream / empty-transcript: a click then must be
// a no-op, not a stray handoff request.
const onResumeConversation = vi.fn();
renderChatPane({ onResumeConversation, resumeConversationDisabled: true });
const resume = screen.getByTestId('resume-conversation') as HTMLButtonElement;
expect(resume.disabled).toBe(true);
resume.click();
expect(onResumeConversation).not.toHaveBeenCalled();
});
});

View file

@ -1,356 +0,0 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectView } from '../../src/components/ProjectView';
import type {
AgentInfo,
AppConfig,
ChatMessage,
Conversation,
DesignSystemSummary,
Project,
SkillSummary,
} from '../../src/types';
import {
createConversation,
listConversations,
listMessages,
synthesizeHandoff,
} from '../../src/state/projects';
import { fetchPreviewComments } from '../../src/providers/registry';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));
vi.mock('../../src/router', () => ({
navigate: vi.fn(),
}));
vi.mock('../../src/providers/anthropic', () => ({
streamMessage: vi.fn(),
}));
vi.mock('../../src/providers/daemon', () => ({
fetchChatRunStatus: vi.fn(),
listActiveChatRuns: vi.fn().mockResolvedValue([]),
listProjectRuns: vi.fn().mockResolvedValue([]),
reattachDaemonRun: vi.fn(),
streamViaDaemon: vi.fn(),
}));
vi.mock('../../src/providers/project-events', () => ({
useProjectFileEvents: vi.fn(),
}));
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
deletePreviewComment: vi.fn(),
fetchDesignSystem: vi.fn(),
fetchLiveArtifacts: vi.fn().mockResolvedValue([]),
fetchPreviewComments: vi.fn(),
fetchProjectFiles: vi.fn().mockResolvedValue([]),
fetchSkill: vi.fn(),
getTemplate: vi.fn(),
patchPreviewCommentStatus: vi.fn(),
upsertPreviewComment: vi.fn(),
writeProjectTextFile: vi.fn(),
};
});
vi.mock('../../src/state/projects', async () => {
const actual = await vi.importActual<typeof import('../../src/state/projects')>(
'../../src/state/projects',
);
return {
...actual,
createConversation: vi.fn(),
listConversations: vi.fn(),
listMessages: vi.fn(),
loadTabs: vi.fn().mockResolvedValue({ tabs: [], active: null }),
patchConversation: vi.fn(),
patchProject: vi.fn(),
saveMessage: vi.fn(),
saveTabs: vi.fn(),
synthesizeHandoff: vi.fn(),
};
});
vi.mock('../../src/components/AppChromeHeader', () => ({
AppChromeHeader: ({ children }: { children: React.ReactNode }) => <header>{children}</header>,
}));
vi.mock('../../src/components/AvatarMenu', () => ({ AvatarMenu: () => null }));
vi.mock('../../src/components/FileWorkspace', () => ({
FileWorkspace: () => <div data-testid="file-workspace" />,
}));
vi.mock('../../src/components/Loading', () => ({
CenteredLoader: () => <div data-testid="loader" />,
}));
// A thin ChatPane stand-in: exposes the resume control + the live message
// list + the composer draft so the test can prove the synthesized prompt
// is auto-sent (lands as a user message) rather than seeded into the draft.
vi.mock('../../src/components/ChatPane', () => ({
ChatPane: ({
messages,
onResumeConversation,
resumeConversationDisabled,
initialDraft,
}: {
messages: ChatMessage[];
onResumeConversation?: () => void;
resumeConversationDisabled?: boolean;
initialDraft?: string;
}) => (
<div>
<button
type="button"
data-testid="resume"
disabled={resumeConversationDisabled}
onClick={() => onResumeConversation?.()}
/>
<div data-testid="messages">
{messages.map((m) => `${m.role}:${m.content}`).join('|')}
</div>
<textarea data-testid="draft" readOnly value={initialDraft ?? ''} />
</div>
),
}));
const mockedListConversations = vi.mocked(listConversations);
const mockedCreateConversation = vi.mocked(createConversation);
const mockedListMessages = vi.mocked(listMessages);
const mockedSynthesizeHandoff = vi.mocked(synthesizeHandoff);
const mockedFetchPreviewComments = vi.mocked(fetchPreviewComments);
const config: AppConfig = {
mode: 'api',
apiKey: 'sk-test',
baseUrl: '',
model: 'claude-opus-4-7',
agentId: null,
skillId: null,
designSystemId: null,
};
const project: Project = {
id: 'p1',
name: 'Project p1',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 1,
};
const origConversation: Conversation = {
id: 'conv-orig',
projectId: 'p1',
title: 'Original',
createdAt: 1,
updatedAt: 1,
};
const freshConversation: Conversation = {
id: 'conv-new',
projectId: 'p1',
title: null,
createdAt: 2,
updatedAt: 2,
};
const origMessage: ChatMessage = {
id: 'm1',
role: 'user',
content: 'first turn',
createdAt: 1,
};
function renderProjectView(configOverride?: Partial<AppConfig>) {
return render(
<ProjectView
project={project}
routeFileName={null}
config={configOverride ? { ...config, ...configOverride } : config}
agents={[] as AgentInfo[]}
skills={[] as SkillSummary[]}
designTemplates={[] as SkillSummary[]}
designSystems={[] as DesignSystemSummary[]}
daemonLive
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={vi.fn()}
onRefreshAgents={vi.fn()}
onOpenSettings={vi.fn()}
onBack={vi.fn()}
onClearPendingPrompt={vi.fn()}
onTouchProject={vi.fn()}
onProjectChange={vi.fn()}
onProjectsRefresh={vi.fn()}
/>,
);
}
function messagesText(): string {
return screen.getByTestId('messages').textContent ?? '';
}
describe('ProjectView resume conversation', () => {
beforeEach(() => {
mockedListConversations.mockResolvedValue([origConversation]);
mockedCreateConversation.mockResolvedValue(freshConversation);
// The original conversation carries a transcript; the freshly created
// one is empty (its DB read settles before the auto-send fires).
mockedListMessages.mockImplementation(async (_projectId, conversationId) =>
conversationId === origConversation.id ? [origMessage] : [],
);
mockedFetchPreviewComments.mockResolvedValue([]);
// handleSend's best-effort memory/extract POST hits fetch; keep it benign.
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('{}', { status: 200 })),
);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it('synthesizes a handoff prompt and auto-sends it as the first message of a new conversation', async () => {
mockedSynthesizeHandoff.mockResolvedValue({
prompt: 'SYNTHESIZED HANDOFF',
model: 'claude-opus-4-7',
inputTokens: 10,
outputTokens: 5,
transcriptMessageCount: 1,
});
renderProjectView();
// Wait for the original transcript to hydrate so the resume control
// is enabled (it is disabled when there is nothing to hand off).
await waitFor(() => {
expect(messagesText()).toContain('user:first turn');
});
expect((screen.getByTestId('resume') as HTMLButtonElement).disabled).toBe(false);
screen.getByTestId('resume').click();
await waitFor(() => {
expect(mockedSynthesizeHandoff).toHaveBeenCalledWith('p1', {
// Scoped to the conversation being resumed, not the whole project.
conversationId: origConversation.id,
apiKey: 'sk-test',
model: 'claude-opus-4-7',
maxTokens: expect.any(Number),
});
});
// Default Anthropic config has baseUrl '' — it must be omitted, not
// forwarded as an empty string the handoff route would 400.
expect(mockedSynthesizeHandoff.mock.calls[0]![1]).not.toHaveProperty('baseUrl');
await waitFor(() => {
expect(mockedCreateConversation).toHaveBeenCalledWith('p1');
});
// The synthesized prompt must land as a real user message in the new
// conversation — proving auto-send, not a composer seed.
await waitFor(() => {
expect(messagesText()).toContain('user:SYNTHESIZED HANDOFF');
});
expect(messagesText()).not.toContain('user:first turn');
expect((screen.getByTestId('draft') as HTMLTextAreaElement).value).toBe('');
});
it('forwards baseUrl when the user has set a custom one', async () => {
mockedSynthesizeHandoff.mockResolvedValue({
prompt: 'SYNTHESIZED HANDOFF',
model: 'claude-opus-4-7',
inputTokens: 10,
outputTokens: 5,
transcriptMessageCount: 1,
});
renderProjectView({ baseUrl: 'https://proxy.example' });
await waitFor(() => {
expect(messagesText()).toContain('user:first turn');
});
screen.getByTestId('resume').click();
await waitFor(() => {
expect(mockedSynthesizeHandoff).toHaveBeenCalledWith(
'p1',
expect.objectContaining({ baseUrl: 'https://proxy.example' }),
);
});
});
it('disables the resume control while the conversation has no transcript to hand off', async () => {
// Guards the `messages.length === 0` arm of resumeConversationDisabled:
// a fresh/empty conversation has nothing to synthesize a handoff from.
mockedListMessages.mockResolvedValue([]);
renderProjectView();
await waitFor(() => {
expect(screen.getByTestId('resume')).toBeTruthy();
});
expect((screen.getByTestId('resume') as HTMLButtonElement).disabled).toBe(true);
expect(messagesText()).toBe('');
});
it('shows a toast and creates no conversation when synthesis fails', async () => {
mockedSynthesizeHandoff.mockResolvedValue(null);
renderProjectView();
await waitFor(() => {
expect(messagesText()).toContain('user:first turn');
});
screen.getByTestId('resume').click();
await waitFor(() => {
expect(mockedSynthesizeHandoff).toHaveBeenCalledTimes(1);
});
await screen.findByText(/handoff prompt/i);
expect(mockedCreateConversation).not.toHaveBeenCalled();
expect(messagesText()).toContain('user:first turn');
});
it('surfaces the daemon-classified error message in the toast', async () => {
// A structured daemon error (rate limit, empty transcript, ...) must
// reach the toast verbatim, not collapse into a generic message.
mockedSynthesizeHandoff.mockResolvedValue({
error: {
code: 'RATE_LIMITED',
message: 'This request would exceed your rate limit of 30,000 input tokens per minute.',
},
});
renderProjectView();
await waitFor(() => {
expect(messagesText()).toContain('user:first turn');
});
screen.getByTestId('resume').click();
await screen.findByText(/exceed your rate limit/i);
expect(mockedCreateConversation).not.toHaveBeenCalled();
});
});

View file

@ -1,76 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { synthesizeHandoff } from '../../src/state/projects';
// A realistic request shape — synthesizeHandoff forwards the body
// verbatim, so the fixture uses a valid non-empty baseUrl rather than the
// empty string the handoff route rejects (caller-side omission of an
// unset baseUrl is ProjectView's job, covered in its own test).
const request = {
conversationId: 'conv-1',
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
maxTokens: 4096,
};
describe('synthesizeHandoff', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('POSTs the handoff request to the project handoff endpoint and returns the synthesized prompt', async () => {
const response = {
prompt: '## Context\nResume here.',
model: 'claude-opus-4-7',
inputTokens: 1200,
outputTokens: 340,
transcriptMessageCount: 12,
};
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify(response),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
const result = await synthesizeHandoff('proj 1', request);
expect(result).toEqual(response);
const [url, init] = fetchMock.mock.calls[0]!;
// The project id is path-encoded so ids with spaces/slashes stay safe.
expect(url).toBe('/api/projects/proj%201/handoff');
expect(init?.method).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual(request);
});
it('returns null when the daemon rejects the request', async () => {
// A 4xx/5xx must not throw — the caller surfaces a toast, not a crash.
const fetchMock = vi.fn<typeof fetch>(async () => new Response('bad request', { status: 400 }));
vi.stubGlobal('fetch', fetchMock);
expect(await synthesizeHandoff('p1', request)).toBeNull();
});
it('returns null when the daemon is unreachable', async () => {
// Transport failure fails soft so the chat header stays interactive.
const fetchMock = vi.fn<typeof fetch>(async () => {
throw new Error('network down');
});
vi.stubGlobal('fetch', fetchMock);
expect(await synthesizeHandoff('p1', request)).toBeNull();
});
it('returns the daemon error structure when the response carries a classified error', async () => {
// A classified daemon failure (rate limit, empty transcript, upstream
// detail) must survive to the caller, not collapse into a bare null.
const error = { code: 'EMPTY_TRANSCRIPT', message: 'conversation has no messages' };
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ error }),
{ status: 400, headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
expect(await synthesizeHandoff('p1', request)).toEqual({ error });
});
});