mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Remove resume conversation button (#2562)
This commit is contained in:
parent
f677563313
commit
b236b37b7d
26 changed files with 1 additions and 737 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -756,7 +756,6 @@ export const ar: Dict = {
|
|||
'chat.conversationsAria': 'سجل المحادثات',
|
||||
'chat.newConversation': 'محادثة جديدة',
|
||||
'chat.newConversationsTitle': 'محادثة جديدة',
|
||||
'chat.resumeConversation': 'استئناف في محادثة جديدة',
|
||||
'chat.conversationsHeading': 'المحادثات',
|
||||
'chat.new': 'جديد',
|
||||
'chat.emptyConversations': 'لا توجد محادثات بعد.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -778,7 +778,6 @@ export const fa: Dict = {
|
|||
'chat.conversationsAria': 'تاریخچه مکالمات',
|
||||
'chat.newConversation': 'مکالمه جدید',
|
||||
'chat.newConversationsTitle': 'مکالمه جدید',
|
||||
'chat.resumeConversation': 'ادامه در مکالمه جدید',
|
||||
'chat.conversationsHeading': 'مکالمات',
|
||||
'chat.new': 'جدید',
|
||||
'chat.emptyConversations': 'هنوز هیچ مکالمهای وجود ندارد.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -643,7 +643,6 @@ export const ja: Dict = {
|
|||
'chat.conversationsAria': '会話履歴',
|
||||
'chat.newConversation': '新しい会話',
|
||||
'chat.newConversationsTitle': '新しい会話',
|
||||
'chat.resumeConversation': '新しい会話で再開',
|
||||
'chat.conversationsHeading': '会話',
|
||||
'chat.new': '新規',
|
||||
'chat.emptyConversations': 'まだ会話がありません。',
|
||||
|
|
|
|||
|
|
@ -756,7 +756,6 @@ export const ko: Dict = {
|
|||
'chat.conversationsAria': '대화 내역',
|
||||
'chat.newConversation': '새 대화 시작',
|
||||
'chat.newConversationsTitle': '새 대화',
|
||||
'chat.resumeConversation': '새 대화에서 이어가기',
|
||||
'chat.conversationsHeading': '대화 목록',
|
||||
'chat.new': '새로 만들기',
|
||||
'chat.emptyConversations': '아직 대화가 없습니다.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -777,7 +777,6 @@ export const ru: Dict = {
|
|||
'chat.conversationsAria': 'История разговоров',
|
||||
'chat.newConversation': 'Новый разговор',
|
||||
'chat.newConversationsTitle': 'Новый разговор',
|
||||
'chat.resumeConversation': 'Продолжить в новом разговоре',
|
||||
'chat.conversationsHeading': 'Разговоры',
|
||||
'chat.new': 'Новый',
|
||||
'chat.emptyConversations': 'Разговоров пока нет.',
|
||||
|
|
|
|||
|
|
@ -712,7 +712,6 @@ export const th: Dict = {
|
|||
'chat.conversationsAria': 'ประวัติ',
|
||||
'chat.newConversation': 'สนทนาใหม่',
|
||||
'chat.newConversationsTitle': 'เริ่มใหม่',
|
||||
'chat.resumeConversation': 'ดำเนินการต่อในการสนทนาใหม่',
|
||||
'chat.conversationsHeading': 'บทสนทนาทั้งหมด',
|
||||
'chat.new': 'ใหม่',
|
||||
'chat.emptyConversations': 'ยังไม่มีบทสนทนา',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -778,7 +778,6 @@ export const uk: Dict = {
|
|||
'chat.conversationsAria': 'Історія розмов',
|
||||
'chat.newConversation': 'Нова розмова',
|
||||
'chat.newConversationsTitle': 'Нова розмова',
|
||||
'chat.resumeConversation': 'Продовжити в новій розмові',
|
||||
'chat.conversationsHeading': 'Розмови',
|
||||
'chat.new': 'Нова',
|
||||
'chat.emptyConversations': 'Розмов ще немає.',
|
||||
|
|
|
|||
|
|
@ -1241,7 +1241,6 @@ export const zhCN: Dict = {
|
|||
'chat.conversationsAria': '对话历史',
|
||||
'chat.newConversation': '新建对话',
|
||||
'chat.newConversationsTitle': '新建对话',
|
||||
'chat.resumeConversation': '在新对话中继续',
|
||||
'chat.conversationsHeading': '对话',
|
||||
'chat.new': '新建',
|
||||
'chat.emptyConversations': '还没有对话。',
|
||||
|
|
|
|||
|
|
@ -850,7 +850,6 @@ export const zhTW: Dict = {
|
|||
'chat.conversationsAria': '對話紀錄',
|
||||
'chat.newConversation': '新建對話',
|
||||
'chat.newConversationsTitle': '新建對話',
|
||||
'chat.resumeConversation': '在新對話中繼續',
|
||||
'chat.conversationsHeading': '對話',
|
||||
'chat.new': '新建',
|
||||
'chat.emptyConversations': '還沒有對話。',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue