feat(chat): switch to the conversation on send with a startup placeholder (TODO 08)

This commit is contained in:
xiaohan.yuan committed 2026-10-17 07:38:49 +08:00
1 parent 0b549ca794
commit 6173a77918
9 files changed
+404 -22

No files matched your search

+16 -4
View File
@@ -221,10 +221,22 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise<void> => {
if ((!text && !attachments.length && !quotes.length) || running || sending) return
followBottom.current = true
await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode })
const destination = draftMoves.current.get(draftKey) ?? draftKey
draftMoves.current.delete(draftKey)
setDrafts((all) => ({ ...all, [draftKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } }))
// 原版发送即清空输入框并立即切到对话页:先清空草稿,失败时再恢复(TODO 08)。
const submitKey = draftKey
const restore = { ...entry, text, attachments, resources, quotes }
setDrafts((all) => ({ ...all, [submitKey]: { text: '', attachments: [] } }))
try {
await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode })
// 会话创建后草稿所有权已迁移到会话 id;两处都清空,避免留下旧文本。
const destination = draftMoves.current.get(submitKey) ?? submitKey
draftMoves.current.delete(submitKey)
setDrafts((all) => ({ ...all, [submitKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } }))
} catch (error) {
const destination = draftMoves.current.get(submitKey)
draftMoves.current.delete(submitKey)
setDrafts((all) => ({ ...all, [submitKey]: restore, ...(destination && destination !== submitKey ? { [destination]: restore } : {}) }))
throw error
}
}
/** 就地编辑:进入、取消、提交(提交走 send 的 action='edit' 分支,与原版 message.edit.submit 对齐)。 */
@@ -23,6 +23,7 @@ import type { InteractionResponse, MessageQuote, PlanContent } from '@shared/con
import MarkdownContent from './MarkdownContent'
import StreamingFooter from '../workbuddy/conversation/StreamingFooter'
import StreamingTurnDuration from '../workbuddy/conversation/StreamingTurnDuration'
import { resolvePrepareText } from '../workbuddy/conversation/prepare-phases'
import { getMessageParts } from '@shared/message-parts'
import { projectWorkBuddyHistoryTool } from '@shared/workbuddy-history-projection'
import { MessageQuoteTag } from '../workbuddy/conversation/MessageQuoteTag'
@@ -265,7 +266,7 @@ export default function MessageBubble({ message, onQuote, quotedSourceIds, darkM
return option ? onRespond?.(id, { kind: 'approval', optionId: option.optionId }) : undefined
}} /> : interaction.answer && !message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer) ? <QuestionAnswerTranscript key={interaction.id} questionAnswer={interaction.answer} /> : null)}
{streaming && <StreamingFooter />}
{streaming && <StreamingFooter prepareText={resolvePrepareText(message.prepareStage)} />}
{message.status === 'error' && message.error ? (
<div className="msg__status msg__status--error">{message.error}</div>
+44 -10
View File
@@ -8,10 +8,30 @@ import type { ThinkingLevel } from '@shared/workspaces'
import type { AgentMode } from '@shared/conversation'
import { expertText, type ExpertActivation } from '@shared/capabilities'
import { ChatStreamCommitter, type ChatStreamKey } from './chat-stream-committer'
import { PENDING_START_STAGE } from '../workbuddy/conversation/prepare-phases'
interface SummonExpertOptions { newTask?: boolean; initPrompt?: string }
interface RunningState { runId: string; assistantId: string; sessionId?: string }
/**
* 新任务发送后、主进程回合就绪前渲染的乐观占位(用户消息 + 助手占位)。
* id 以 local- 开头,只在渲染进程存在;真实消息到达或被替换/清理。
*/
function optimisticStartMessages(draftOwner: string, text: string, options: Omit<SendPayload, 'sessionId' | 'text'>): ChatMessage[] {
const startedAt = Date.now()
return [
{ id: `local-user-${draftOwner}`, role: 'user', content: text, createdAt: startedAt, status: 'done',
attachments: options.attachments, resources: options.resources, quotes: options.quotes },
{ id: `local-assistant-${draftOwner}`, role: 'assistant', content: '', tools: [], parts: [],
createdAt: startedAt, turnStartedAt: startedAt, status: 'streaming', prepareStage: PENDING_START_STAGE }
]
}
function isOptimisticStartMessage(message: ChatMessage): boolean {
return message.id.startsWith('local-user-') || message.id.startsWith('local-assistant-')
}
interface ChatState {
sessions: SessionMeta[]
currentSessionId: string | null
@@ -27,7 +47,7 @@ interface ChatState {
draftId: number
init(): Promise<void>
selectSession(id: string): Promise<void>
newSession(context?: SessionContext): Promise<SessionMeta>
newSession(context?: SessionContext, seedMessages?: ChatMessage[]): Promise<SessionMeta>
startTask(context?: SessionContext): void
selectModel(id: string): Promise<void>
selectExpert(id: string | null): Promise<void>
@@ -225,14 +245,14 @@ export const useChatStore = create<ChatState>((set, get) => {
window.dispatchEvent(new CustomEvent('ycode:expert-input', { detail: { owner: targetOwner, clear: true } }))
}
},
async newSession(context) {
async newSession(context, seedMessages: ChatMessage[] = []) {
const previousSessionId = get().currentSessionId
if (previousSessionId) streamCommitter.flushSession(previousSessionId)
const version = ++selectionVersion
const created = await window.ycode.session.create(undefined, context)
set((state) => ({ sessions: [created, ...state.sessions],
sessionMessages: { ...state.sessionMessages, [created.id]: [] },
...(version === selectionVersion ? { currentSessionId: created.id, messages: [], running: null,
sessionMessages: { ...state.sessionMessages, [created.id]: seedMessages },
...(version === selectionVersion ? { currentSessionId: created.id, messages: seedMessages, running: null,
sending: false, loading: false, error: null } : {}) }))
return created
},
@@ -286,13 +306,20 @@ export const useChatStore = create<ChatState>((set, get) => {
let sessionId = get().currentSessionId
if (!sessionId) {
if (get().sending) return
// 原版在新任务发送后立即切到对话页:先渲染乐观的「用户消息 + 助手占位」,
// 等主进程建好会话与回合(可能包含运行环境启动)后再用真实消息替换(TODO 08)。
const draftOwner = `new-${get().draftId}`
set({ sending: true })
const optimistic = optimisticStartMessages(draftOwner, trimmed, options)
set({ sending: true, messages: optimistic })
try {
sessionId = (await get().newSession({ ...get().draftContext })).id
sessionId = (await get().newSession({ ...get().draftContext }, optimistic)).id
if (typeof window.dispatchEvent === 'function') window.dispatchEvent(new CustomEvent('ycode:composer-owner-migrated', { detail: { from: draftOwner, to: sessionId } }))
}
catch (error) { set({ sending: false }); throw error }
catch (error) {
set((state) => ({ sending: false, messages: state.currentSessionId === null ? [] : state.messages,
error: error instanceof Error ? error.message : String(error) }))
throw error
}
}
if (!sessionId || get().sendingSessions.includes(sessionId) || currentRun(get().runs, sessionId)) return
const owner = sessionId
@@ -306,7 +333,8 @@ export const useChatStore = create<ChatState>((set, get) => {
assistantDisplay: result.assistantDisplay, createdAt: Date.now(),
turnStartedAt: result.userMessage.createdAt, status: 'streaming' }
set((state) => {
const history = state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? []
const history = (state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? [])
.filter((item) => !isOptimisticStartMessage(item))
const messages = result.messages ?? [...history, result.userMessage, placeholder]
const running = { runId: result.runId, assistantId: result.assistantMessageId, sessionId: owner }
return { sessionMessages: { ...state.sessionMessages, [owner]: messages },
@@ -320,8 +348,14 @@ export const useChatStore = create<ChatState>((set, get) => {
void window.ycode.session.list().then((sessions) => set({ sessions })).catch(() => undefined)
} catch (error) {
pendingEvents.delete(owner)
set((state) => ({ sendingSessions: state.sendingSessions.filter((id) => id !== owner),
...(state.currentSessionId === owner ? { sending: false, error: error instanceof Error ? error.message : String(error) } : {}) }))
// 首条消息发送失败:移除乐观占位(用户草稿由输入框恢复),不留下未持久化的消息。
set((state) => {
const base = state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? []
const cleaned = base.filter((item) => !isOptimisticStartMessage(item))
return { sessionMessages: { ...state.sessionMessages, [owner]: cleaned },
sendingSessions: state.sendingSessions.filter((id) => id !== owner),
...(state.currentSessionId === owner ? { messages: cleaned, sending: false, error: error instanceof Error ? error.message : String(error) } : {}) }
})
throw error
}
},
@@ -97,9 +97,11 @@ function activeBrandName(): string {
export interface StreamingFooterProps {
locale?: string
/** 新任务启动阶段文案(原版 chat.prepare.<stage>);提供时替换「思考中」。 */
prepareText?: string | null
}
export function StreamingFooter({ locale }: StreamingFooterProps): React.JSX.Element {
export function StreamingFooter({ locale, prepareText }: StreamingFooterProps): React.JSX.Element {
const resolvedLocale = locale ?? activeLocale()
const brandName = activeBrandName()
const tips = useMemo(() => getSourceLoadingTips(resolvedLocale, brandName), [brandName, resolvedLocale])
@@ -112,7 +114,7 @@ export function StreamingFooter({ locale }: StreamingFooterProps): React.JSX.Ele
isPaused: () => isPaused.current
}), [tips])
const thinkingLabel = conversationLabel('message.loading.thinking', {}, resolveLoadingTipLocale(resolvedLocale) === 'en' ? 'en' : 'zh-cn')
const thinkingLabel = prepareText ?? conversationLabel('message.loading.thinking', {}, resolveLoadingTipLocale(resolvedLocale) === 'en' ? 'en' : 'zh-cn')
return <div className="wb-cr-streaming-footer">
<div className="_loadingWithCredit_ttf8p_463" style={{ '--cb-assistant-inline-padding': '0px' } as CSSProperties}>
@@ -0,0 +1,29 @@
// Recovered from REA ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb
// (WorkBuddy 5.8.2 renderer/assets/contexts-BMeWSUHk.js, SHA-256
// 5f35b7489b107e21ef452c8ee88a95265756f6fc444841eef869228bbc44768b);
// 文案表与显示逻辑见 docs/对话启动提示文案-2026-10-16.md。
// 原版按会话 prepareStatus.stage 选择 chat.prepare.<stage>;YCode 的 chat:send 在
// 主进程启动运行环境期间是单个阻塞阶段,因此发送后的占位只使用 acquiring_runtime,
// 不按字符串表臆造原版阶段顺序与持续时长。
export const CONVERSATION_PREPARE_TEXT = {
expert: '正在准备专家能力…',
reading_conversation_settings: '正在读取对话设置…',
resolving_system_prompt: '正在生成系统提示词…',
configuring_model_and_tools: '正在配置模型与工具…',
resolving_skill_requirements: '正在整理技能配置…',
resolving_plugin_requirements: '正在整理插件配置…',
preparing_environment: '正在准备运行环境…',
mcp: '正在整理连接器配置…',
checking_launch_config: '正在检查启动配置…',
acquiring_runtime: '正在启动对话运行环境…'
} as const
export type ConversationPrepareStage = keyof typeof CONVERSATION_PREPARE_TEXT
/** 新任务发送后、主进程回合就绪前的占位阶段(对应运行环境启动)。 */
export const PENDING_START_STAGE: ConversationPrepareStage = 'acquiring_runtime'
export function resolvePrepareText(stage: string | null | undefined): string | null {
if (!stage) return null
return stage in CONVERSATION_PREPARE_TEXT ? CONVERSATION_PREPARE_TEXT[stage as ConversationPrepareStage] : null
}
+2
View File
@@ -43,6 +43,8 @@ export interface ChatMessage {
/** 本回合的终态 */
status?: 'streaming' | 'done' | 'error' | 'cancelled'
error?: string
/** 渲染进程专用的启动占位阶段(新任务发送后、主进程回合就绪前);不参与持久化。 */
prepareStage?: string
}
export interface ToolCallRecord {