From 692cf5a3bd64b7bdbf3021469aa65c1a6fc31431 Mon Sep 17 00:00:00 2001 From: shaowei <466995322@qq.com> Date: Mon, 3 Aug 2026 02:08:20 +0800 Subject: [PATCH 1/4] feat: add persistent plan and thread goals --- scripts/verify-frontend-normalizers.mjs | 76 +- src/App.vue | 93 ++- src/api/codexGateway.ts | 26 + src/api/normalizers/v2.ts | 24 +- .../content/ComposerRegressionFixture.vue | 81 ++- .../content/ConversationRegressionFixture.vue | 80 ++- src/components/content/ThreadComposer.vue | 39 +- src/components/content/ThreadConversation.vue | 433 ++++++++++++ src/components/content/ThreadGoalBar.vue | 650 ++++++++++++++++++ src/composables/conversationProjection.ts | 41 ++ src/composables/threadGoal.ts | 35 + src/composables/useDesktopState.ts | 442 +++++++++++- src/types/codex.ts | 35 + tests.md | 45 +- 14 files changed, 2056 insertions(+), 44 deletions(-) create mode 100644 src/components/content/ThreadGoalBar.vue create mode 100644 src/composables/threadGoal.ts diff --git a/scripts/verify-frontend-normalizers.mjs b/scripts/verify-frontend-normalizers.mjs index 6e8ae2c..79429fd 100644 --- a/scripts/verify-frontend-normalizers.mjs +++ b/scripts/verify-frontend-normalizers.mjs @@ -31,6 +31,7 @@ const latestReplyImport = toImportPath(relative(outputRoot, join(repoRoot, 'src' const taskPetReadPolicyImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'mobile', 'taskPetReadPolicy.ts'))) const sessionFileChangeImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'sessionFileChange.ts'))) const composerEnterBehaviorImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'composerEnterBehavior.ts'))) +const threadGoalImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'threadGoal.ts'))) try { writeFileSync(entryPath, ` @@ -64,7 +65,9 @@ import { } from '${messageOutboxPersistenceImport}' import { areMessageFieldsEqual, + hasPlanImplementationConfirmation, mergeMessages, + PLAN_IMPLEMENTATION_CONFIRMATION, removeRedundantLiveAgentMessages, removeStaleHistoryNoticeAfterOlderMerge, sortMessagesByTurnIndex, @@ -105,6 +108,7 @@ import { readCxSessionFileChangeSource, } from '${sessionFileChangeImport}' import { resolveSendWithEnterPreference } from '${composerEnterBehaviorImport}' +import { normalizeThreadGoal } from '${threadGoalImport}' assert.equal(CONVERSATION_BOTTOM_THRESHOLD_PX, 24) assert.equal(CX_SESSION_FILES_CHANGED_METHOD, 'cx/session-files/changed') @@ -131,6 +135,27 @@ assert.equal(resolveSendWithEnterPreference(null, true), false) assert.equal(resolveSendWithEnterPreference('1', true), true) assert.equal(resolveSendWithEnterPreference('0', false), false) assert.equal(resolveSendWithEnterPreference('invalid', true), false) +assert.deepEqual(normalizeThreadGoal({ + threadId: 'thread-goal', + objective: 'Keep improving', + status: 'active', + tokenBudget: 1000, + tokensUsed: 120, + timeUsedSeconds: 30, + createdAt: 1, + updatedAt: 2, +}), { + threadId: 'thread-goal', + objective: 'Keep improving', + status: 'active', + tokenBudget: 1000, + tokensUsed: 120, + timeUsedSeconds: 30, + createdAt: 1, + updatedAt: 2, +}) +assert.equal(normalizeThreadGoal({ threadId: 'thread-goal', objective: 'x', status: 'unknown' }), null) +assert.equal(normalizeThreadGoal({ threadId: '', objective: 'x', status: 'active' }), null) assert.equal(conversationDistanceFromBottom({ scrollHeight: 1000, scrollTop: 676, clientHeight: 300 }), 24) assert.equal(isConversationViewportAtBottom({ scrollHeight: 1000, scrollTop: 676, clientHeight: 300 }), true) assert.equal(isConversationViewportAtBottom({ scrollHeight: 1000, scrollTop: 675, clientHeight: 300 }), false) @@ -562,6 +587,37 @@ assert.equal(areMessageFieldsEqual(projectedCommand, { ...projectedCommand, commandExecution: { ...projectedCommand.commandExecution, command: 'npm run test' }, }), false) +const projectedPlan = { + id: 'plan:turn-projection', + role: 'system', + text: '', + messageType: 'plan', + plan: { + turnId: 'turn-projection', + explanation: 'Plan safely', + steps: [{ step: 'Inspect', status: 'pending' }], + rawText: '', + isStreaming: false, + }, +} +assert.equal(areMessageFieldsEqual(projectedPlan, { ...projectedPlan, plan: { ...projectedPlan.plan } }), true) +assert.equal(areMessageFieldsEqual(projectedPlan, { + ...projectedPlan, + plan: { ...projectedPlan.plan, steps: [{ step: 'Inspect', status: 'completed' }] }, +}), false) +assert.equal(hasPlanImplementationConfirmation([ + projectedPlan, + { id: 'plan-confirmation', role: 'user', text: PLAN_IMPLEMENTATION_CONFIRMATION }, +], projectedPlan.id), true) +assert.equal(hasPlanImplementationConfirmation([ + projectedPlan, + { id: 'ordinary-follow-up', role: 'user', text: '继续检查,但先不要执行' }, +], projectedPlan.id), false) +assert.equal(hasPlanImplementationConfirmation([ + projectedPlan, + { ...projectedPlan, id: 'plan:newer-turn' }, + { id: 'late-confirmation', role: 'user', text: PLAN_IMPLEMENTATION_CONFIRMATION }, +], projectedPlan.id), false) assert.equal(areMessageFieldsEqual(projectedCommand, { ...projectedCommand, commandExecution: { ...projectedCommand.commandExecution, cwd: 'E:/other' }, @@ -717,6 +773,7 @@ const messages = normalizeThreadMessagesV2({ status: 'completed', items: [ { id: 'item-known', type: 'agentMessage', text: 'Known message' }, + { id: 'item-plan', type: 'plan', text: '1. Inspect\\n2. Implement' }, { id: 'item-mcp', type: 'mcpToolCall', @@ -756,16 +813,21 @@ const messages = normalizeThreadMessagesV2({ }, }) -assert.equal(messages.length, 3) +assert.equal(messages.length, 4) assert.equal(messages[0]?.messageType, 'agentMessage') assert.equal(messages[1]?.role, 'system') -assert.equal(messages[1]?.messageType, 'unhandled.threadShellCommandOutput') -assert.equal(messages[1]?.text, 'Unhandled App Server item: threadShellCommandOutput') -assert.equal(messages[1]?.isUnhandled, true) -assert.equal(messages[1]?.turnIndex, 0) -assert.equal(messages[1]?.rawPayload?.includes('secret command'), true) -assert.equal(messages[2]?.messageType, 'unhandled.invalidItem') +assert.equal(messages[1]?.id, 'plan:turn-a') +assert.equal(messages[1]?.messageType, 'plan') +assert.equal(messages[1]?.plan?.turnId, 'turn-a') +assert.equal(messages[1]?.plan?.rawText, '1. Inspect\\n2. Implement') +assert.equal(messages[1]?.plan?.isStreaming, false) +assert.equal(messages[2]?.messageType, 'unhandled.threadShellCommandOutput') +assert.equal(messages[2]?.text, 'Unhandled App Server item: threadShellCommandOutput') assert.equal(messages[2]?.isUnhandled, true) +assert.equal(messages[2]?.turnIndex, 0) +assert.equal(messages[2]?.rawPayload?.includes('secret command'), true) +assert.equal(messages[3]?.messageType, 'unhandled.invalidItem') +assert.equal(messages[3]?.isUnhandled, true) assert.equal(messages.some((message) => message.messageType === 'unhandled.fileChange'), false) assert.equal(messages.some((message) => message.messageType === 'unhandled.webSearch'), false) assert.equal(messages.some((message) => message.rawPayload?.includes('large internal patch details')), false) diff --git a/src/App.vue b/src/App.vue index fedda60..dcc6f46 100644 --- a/src/App.vue +++ b/src/App.vue @@ -813,6 +813,8 @@ :show-empty-thread-actions="isRouteOnlyEmptyThread" :is-turn-in-progress="isSelectedThreadInProgress" :is-rolling-back="isRollingBack" + :implementing-plan-id="implementingPlanId" + :implemented-plan-ids="implementedPlanIds" @update-scroll-state="onUpdateThreadScrollState" @respond-server-request="onRespondServerRequest" @toggle-favorite="onToggleFavoriteMessage" @@ -823,6 +825,7 @@ @dismiss-empty-thread="onDismissEmptyThread" @copy-status="onConversationCopyStatus" @retry-failed-message="retryFailedUserMessage" + @implement-plan="onImplementPlan" @rollback="onRollback" /> @@ -846,6 +849,20 @@ @retry="retryQueuedMessage" @delete="deleteQueuedMessage" /> + import('./components/content/ThreadGoalBar.vue')) const QueuedMessages = defineAsyncComponent(() => import('./components/content/QueuedMessages.vue')) const RateLimitStatus = defineAsyncComponent(() => import('./components/content/RateLimitStatus.vue')) const FavoritesModal = defineAsyncComponent(() => import('./components/content/FavoritesModal.vue')) @@ -1442,6 +1460,10 @@ const { selectedLiveOverlay, selectedThreadRuntimeStatus, selectedThreadTokenUsage, + selectedThreadGoal, + isSelectedThreadGoalLoading, + isSelectedThreadGoalUpdating, + selectedThreadGoalError, selectedThreadLoadError, selectedThreadId, availableModels, @@ -1508,6 +1530,10 @@ const { setWorktreeGitAutomationEnabled, setSelectedReasoningEffort, setSelectedCollaborationMode, + refreshSelectedThreadGoal, + saveSelectedThreadGoal, + updateSelectedThreadGoalStatus, + clearSelectedThreadGoal, updateSelectedSpeedMode, respondToPendingServerRequest, renameProject, @@ -1530,6 +1556,8 @@ const isSettingsSheetMode = computed(() => isMobile.value || isDualPaneMobile.va const { favorites, toggleFavorite, removeFavorite, refreshFavorites } = useFavorites() const homeThreadComposerRef = ref(null) const threadComposerRef = ref(null) +const implementingPlanId = ref('') +const implementedPlanIds = ref([]) const threadConversationRef = ref(null) const sidebarThreadTreeRef = ref<{ revealSelectedThread: () => Promise } | null>(null) const sidebarScrollableRef = ref(null) @@ -2333,6 +2361,13 @@ const displayFavorites = computed(() => ( )) const isSelectedThreadInProgress = computed(() => !isHomeRoute.value && selectedThreadExecutionActive.value) const isSelectedThreadInterruptible = computed(() => !isHomeRoute.value && selectedThreadCanStop.value) +const selectedThreadGoalExecutionHint = computed(() => { + if (selectedThreadGoal.value?.status !== 'active') return '' + if (selectedThreadServerRequests.value.length > 0) return '等待确认' + if (selectedThreadQueuedMessages.value.length > 0) return '等待消息队列' + if (isSelectedThreadInProgress.value) return '正在推进' + return '等待继续' +}) const shouldShowSelectedThreadProcessing = computed(() => ( selectedThreadServerRequests.value.length > 0 || selectedLiveOverlay.value !== null || @@ -4581,8 +4616,62 @@ function onSelectCollaborationMode(mode: CollaborationMode): void { setSelectedCollaborationMode(mode) } +function onSaveThreadGoal(objective: string): void { + void saveSelectedThreadGoal(objective).catch(() => { + // The desktop state exposes the actionable RPC error in the shared error banner. + }) +} + +function onSetThreadGoalStatus(status: 'active' | 'paused'): void { + void updateSelectedThreadGoalStatus(status).catch(() => { + // Keep the existing goal visible so the user can retry without re-entering it. + }) +} + +function onClearThreadGoal(): void { + void clearSelectedThreadGoal().catch(() => { + // The goal remains visible when the authoritative clear fails. + }) +} + +async function onImplementPlan(message: UiMessage): Promise { + const threadId = selectedThreadId.value + if ( + !threadId + || isSelectedThreadInProgress.value + || implementingPlanId.value + || implementedPlanIds.value.includes(message.id) + ) return + const previousMode = selectedCollaborationMode.value + implementingPlanId.value = message.id + setSelectedCollaborationMode('execute') + try { + await sendMessageToSelectedThread( + PLAN_IMPLEMENTATION_CONFIRMATION, + [], + [], + 'steer', + [], + undefined, + 'execute', + ) + implementedPlanIds.value = [...implementedPlanIds.value, message.id].slice(-12) + markDesktopSyncPending(threadId) + } catch { + if (selectedThreadId.value === threadId) setSelectedCollaborationMode(previousMode) + showProductToast('计划提交失败,计划卡已保留,可直接重试。', 'danger') + } finally { + if (implementingPlanId.value === message.id) implementingPlanId.value = '' + } +} + function onInterruptTurn(source: 'composer-stop' | 'runtime-status-stop' | 'unknown' = 'unknown'): void { - showProductToast('已请求停止,正在确认任务状态。', 'warning') + showProductToast( + selectedThreadGoal.value?.status === 'active' + ? '已请求停止,持续目标已同时暂停。' + : '已请求停止,正在确认任务状态。', + 'warning', + ) void interruptSelectedThreadTurn(source) } diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index c312020..9746cbf 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -35,11 +35,14 @@ import type { SpeedMode, UiMessage, UiProjectGroup, + UiThreadGoal, + UiThreadGoalStatus, UiThreadTokenUsage, UiTokenUsageBreakdown, } from '../types/codex' import { normalizePathForUi } from '../pathUtils.js' import { shouldAutoLoginForResponse, tryMobileShellAutoLogin } from '../mobile/mobileAuth' +import { normalizeThreadGoal } from '../composables/threadGoal' type CurrentModelConfig = { model: string @@ -1038,6 +1041,29 @@ export async function renameThread(threadId: string, threadName: string): Promis await callRpc('thread/name/set', { threadId, name: threadName }) } +export async function getThreadGoal(threadId: string, options: RpcCallOptions = {}): Promise { + const payload = await callRpc<{ goal?: unknown }>('thread/goal/get', { threadId }, options) + return normalizeThreadGoal(payload?.goal) +} + +export async function setThreadGoal( + threadId: string, + input: { objective?: string; status?: Extract }, +): Promise { + const params: Record = { threadId } + const objective = input.objective?.trim() + if (objective) params.objective = objective + if (input.status) params.status = input.status + const payload = await callRpc<{ goal?: unknown }>('thread/goal/set', params) + const goal = normalizeThreadGoal(payload?.goal) + if (!goal) throw new Error('thread/goal/set did not return a valid goal') + return goal +} + +export async function clearThreadGoal(threadId: string): Promise { + await callRpc('thread/goal/clear', { threadId }) +} + export async function rollbackThread(threadId: string, numTurns: number): Promise { const payload = await callRpc('thread/rollback', { threadId, numTurns }) return normalizeThreadMessagesV2(payload) diff --git a/src/api/normalizers/v2.ts b/src/api/normalizers/v2.ts index 7c5c360..d306a4b 100644 --- a/src/api/normalizers/v2.ts +++ b/src/api/normalizers/v2.ts @@ -223,7 +223,7 @@ function extractAssistantImages(item: ThreadItem): string[] { return images } -function toUiMessages(item: ThreadItem): UiMessage[] { +function toUiMessages(item: ThreadItem, turnId = ''): UiMessage[] { const rawItem = item as Record const itemId = readTrimmedString(rawItem.id) || `unhandled:${readTrimmedString(rawItem.type) || 'item'}` const itemType = readTrimmedString(rawItem.type) @@ -254,6 +254,26 @@ function toUiMessages(item: ThreadItem): UiMessage[] { ] } + if (item.type === 'plan') { + const text = typeof item.text === 'string' ? item.text : '' + const normalizedTurnId = turnId.trim() + return [ + { + id: normalizedTurnId ? `plan:${normalizedTurnId}` : item.id, + role: 'system', + text, + messageType: 'plan', + plan: { + turnId: normalizedTurnId, + explanation: '', + steps: [], + rawText: text, + isStreaming: false, + }, + }, + ] + } + if (item.type === 'imageView') { const images: string[] = [] pushImageCandidate(images, rawItem.path) @@ -507,7 +527,7 @@ export function normalizeThreadMessagesV2(payload: ThreadReadResponse): UiMessag ? item : { id: `turn-${String(turnIndex)}:item-${String(messages.length)}`, type: 'invalidItem', content: item } ) as ThreadItem - for (const msg of toUiMessages(threadItem)) { + for (const msg of toUiMessages(threadItem, readTrimmedString(rawTurn.id))) { messages.push({ ...msg, turnIndex: absoluteTurnIndex }) } } diff --git a/src/components/content/ComposerRegressionFixture.vue b/src/components/content/ComposerRegressionFixture.vue index 95f58f9..f179cfc 100644 --- a/src/components/content/ComposerRegressionFixture.vue +++ b/src/components/content/ComposerRegressionFixture.vue @@ -14,8 +14,30 @@ > 模拟语音转文字 + + {{ fixtureThreadId }} {{ submitCount }} + import { computed, onBeforeUnmount, ref } from 'vue' import ThreadComposer, { type SubmitPayload, type ThreadComposerExposed } from './ThreadComposer.vue' -import type { ComposerModelInfo, ComposerPluginInfo, ReasoningEffort } from '../../types/codex' +import ThreadGoalBar from './ThreadGoalBar.vue' +import type { CollaborationMode, ComposerModelInfo, ComposerPluginInfo, ReasoningEffort, UiThreadGoal } from '../../types/codex' import { useMobile } from '../../composables/useMobile' import { resolveSendWithEnterPreference } from '../../composables/composerEnterBehavior' @@ -94,6 +117,24 @@ const availableModels: ComposerModelInfo[] = [ ] const composerRef = ref(null) const submitCount = ref(0) +const fixtureParams = typeof window !== 'undefined' + ? new URLSearchParams(window.location.hash.split('?')[1] ?? '') + : new URLSearchParams() +const showGoalFixture = fixtureParams.get('goal') === '1' +const isGoalSwitchFixture = fixtureParams.get('goalSwitch') === '1' +const fixtureThreadId = ref(isGoalSwitchFixture ? 'fixture-thread-a' : 'fixture-thread-composer') +const selectedCollaborationMode = ref(fixtureParams.get('planMode') === '1' ? 'plan' : 'execute') +const fixtureGoalError = ref(fixtureParams.get('goalError') === '1' ? '持续目标同步失败,请重试。' : '') +const fixtureGoal = ref(fixtureParams.get('goalEmpty') === '1' ? null : { + threadId: fixtureThreadId.value, + objective: '持续完善 7420 的稳定性与细节体验,并以可复现回归作为完成标准。', + status: 'active', + tokenBudget: 120_000, + tokensUsed: 36_800, + timeUsedSeconds: 4_260, + createdAt: Date.now() - 4_260_000, + updatedAt: Date.now(), +}) const { isMobile } = useMobile() const sendWithEnter = computed(() => resolveSendWithEnterPreference(null, isMobile.value)) const originalFetch = window.fetch @@ -154,6 +195,40 @@ function noop(): void { // Fixture route only needs rendered output for browser assertions. } +function updateFixtureGoal(objective: string): void { + fixtureGoal.value = fixtureGoal.value + ? { ...fixtureGoal.value, objective, updatedAt: Date.now() } + : { + threadId: fixtureThreadId.value, + objective, + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + } +} + +function updateFixtureGoalStatus(status: 'active' | 'paused'): void { + if (!fixtureGoal.value) return + fixtureGoal.value = { ...fixtureGoal.value, status, updatedAt: Date.now() } +} + +function switchFixtureGoalThread(): void { + fixtureThreadId.value = fixtureThreadId.value === 'fixture-thread-a' ? 'fixture-thread-b' : 'fixture-thread-a' + fixtureGoal.value = { + threadId: fixtureThreadId.value, + objective: `持续目标:${fixtureThreadId.value}`, + status: 'active', + tokenBudget: 120_000, + tokensUsed: 36_800, + timeUsedSeconds: 4_260, + createdAt: Date.now() - 4_260_000, + updatedAt: Date.now(), + } +} + function onSubmit(_payload: SubmitPayload): void { submitCount.value += 1 } diff --git a/src/components/content/ConversationRegressionFixture.vue b/src/components/content/ConversationRegressionFixture.vue index bba1462..68194b8 100644 --- a/src/components/content/ConversationRegressionFixture.vue +++ b/src/components/content/ConversationRegressionFixture.vue @@ -53,7 +53,7 @@ :pending-requests="pendingRequests" :live-overlay="liveOverlay" :is-loading="false" - :is-turn-in-progress="!isLoadFailureFixture && !isScrollSwitchRaceFixture" + :is-turn-in-progress="!isLoadFailureFixture && !isScrollSwitchRaceFixture && !isPlanFixture" :load-error="isLoadFailureFixture ? '连接不到桌面端,会话内容暂时未加载。页面会自动重试,也可以检查或修改连接地址。' : ''" :show-connection-settings-action="isLoadFailureFixture" compact-runtime-chrome @@ -61,6 +61,8 @@ cwd="E:/javaword/CXCodex/codexui" :scroll-state="activeScrollState" :favorite-message-ids="favoriteMessageIds" + :implementing-plan-id="fixtureImplementingPlanId" + :implemented-plan-ids="fixtureImplementedPlanIds" @update-scroll-state="onUpdateScrollState" @respond-server-request="noop" @rollback="noop" @@ -71,6 +73,7 @@ @return-to-new-thread="noop" @dismiss-empty-thread="noop" @retry-failed-message="noop" + @implement-plan="implementFixturePlan" @copy-status="copyStatus = $event" />

(isPlanSubmittedFixture ? ['plan:fixture-plan-turn'] : []) const activeThreadId = ref(isScrollSwitchRaceFixture ? 'regression-scroll-a' : 'regression-conversation-blocks') const scrollStateByThreadId = ref>({}) const activeScrollState = computed(() => scrollStateByThreadId.value[activeThreadId.value] ?? null) @@ -393,6 +402,54 @@ const markdownImageMessages: UiMessage[] = [ turnIndex: 9, }, ] +const planMessages: UiMessage[] = [ + { + id: 'plan:fixture-older-plan-turn', + role: 'system', + text: '', + messageType: 'plan', + plan: { + turnId: 'fixture-older-plan-turn', + explanation: '这是较早的计划,默认应收起以降低长会话噪声。', + steps: [ + { step: '读取现有实现', status: 'completed' }, + { step: '列出体验问题', status: 'completed' }, + ], + rawText: '', + isStreaming: false, + }, + turnIndex: 9, + }, + { + id: 'fixture-plan-request', + role: 'user', + text: '请先规划如何稳定实现持续目标,不要修改文件。', + turnIndex: 10, + }, + { + id: 'plan:fixture-plan-turn', + role: 'system', + text: '', + messageType: 'plan', + plan: { + turnId: 'fixture-plan-turn', + explanation: '先确认桌面端协议,再以最小改动补齐状态、交互和验证闭环。', + steps: [ + { step: '核对 thread/goal 与 turn/plan 事件结构', status: 'completed' }, + { step: '接入线程级目标生命周期和持续计划模式', status: 'completed' }, + { step: '补充计划增量合并,避免频繁重绘', status: 'completed' }, + { step: '为目标读取增加请求去重与旧响应保护', status: 'completed' }, + { step: '优化移动端目标操作区', status: 'inProgress' }, + { step: '验证清除确认和错误重试', status: 'pending' }, + { step: '完成浏览器回归并记录兼容边界', status: 'pending' }, + { step: '整理发布前验收结论', status: 'pending' }, + ], + rawText: '', + isStreaming: false, + }, + turnIndex: 10, + }, +] const scrollRaceMessagesByThreadId = ref>(Object.fromEntries( ['regression-scroll-a', 'regression-scroll-b'].map((threadId) => [ threadId, @@ -411,6 +468,16 @@ const fixtureMessages = computed(() => { } if (isImagePreviewFixture) return [...messages, imagePreviewMessage] if (isMarkdownImageFixture) return [...messages, ...markdownImageMessages] + if (isPlanFixture) { + return isPlanHistoryImplementedFixture + ? [...planMessages, { + id: 'fixture-plan-implementation-confirmation', + role: 'user' as const, + text: PLAN_IMPLEMENTATION_CONFIRMATION, + turnIndex: 11, + }] + : planMessages + } return messages }) const pendingRequests: UiServerRequest[] = isTailStatusFixture || isLoadFailureFixture ? [] : allPendingRequests @@ -428,7 +495,7 @@ const liveOverlay = ref({ errorText: '', }) -if (isLoadFailureFixture || isScrollSwitchRaceFixture) { +if (isLoadFailureFixture || isScrollSwitchRaceFixture || isPlanFixture) { liveOverlay.value = null } @@ -498,6 +565,15 @@ function noop(): void { // Fixture route only needs rendered output for browser assertions. } +function implementFixturePlan(message: UiMessage): void { + if (fixtureImplementingPlanId.value || fixtureImplementedPlanIds.value.includes(message.id)) return + fixtureImplementingPlanId.value = message.id + window.setTimeout(() => { + fixtureImplementedPlanIds.value = [...fixtureImplementedPlanIds.value, message.id] + fixtureImplementingPlanId.value = '' + }, 400) +} + function appendResumeOutput(): void { const threadId = activeThreadId.value const currentMessages = scrollRaceMessagesByThreadId.value[threadId] ?? [] diff --git a/src/components/content/ThreadComposer.vue b/src/components/content/ThreadComposer.vue index 373a580..15c8cd6 100644 --- a/src/components/content/ThreadComposer.vue +++ b/src/components/content/ThreadComposer.vue @@ -221,7 +221,10 @@ -

+
× + +
@@ -409,9 +428,9 @@ > - 仅生成计划 + 计划模式 - {{ selectedCollaborationMode === 'plan' ? '本次只生成计划,发送后回到执行' : '仅规划一次,不执行文件和命令' }} + {{ selectedCollaborationMode === 'plan' ? '持续开启,仅规划,不执行修改' : '开启后持续使用计划模式' }}