diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index da95acf9d..b0cd1079f 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -1585,6 +1585,7 @@ export const ChatInput: React.FC = ({ const result = await runGoalCommandSafely({ session: effectiveTargetSession, userHint: parsed.userHint, + loadingMessage: t('chatInput.goalGenerating', { defaultValue: 'Generating session goal...' }), failedTitle: t('chatInput.goalFailed', { defaultValue: 'Goal mode activation failed' }), unknownErrorMessage: t('error.unknown'), aiFailedMessage: t('chatInput.goalAiFailed', { diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index a23c6785d..797596cf3 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -21,7 +21,7 @@ import { useFlowChatSync } from './useFlowChatSync'; import { useFlowChatToolActions } from './useFlowChatToolActions'; import { useFlowChatSearch } from './useFlowChatSearch'; import { useVirtualItems, useActiveSession, useVisibleTurnInfo, type VisibleTurnInfo } from '../../store/modernFlowChatStore'; -import type { FlowChatConfig, FlowToolItem, Session } from '../../types/flow-chat'; +import type { FlowChatConfig, FlowToolItem, Session, DialogTurn } from '../../types/flow-chat'; import type { LineRange } from '@/component-library'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { parsePullRequestUrl } from '@/shared/utils/pullRequestLinks'; @@ -276,17 +276,26 @@ export const ModernFlowChatContainer: React.FC = ( searchCurrentMatchVirtualIndex, ]); + const resolveLocalCommandHeaderTitle = useCallback((metadata: DialogTurn['userMessage']['metadata']) => { + if (metadata?.localCommandKind === 'usage_report') { + return t('usage.title'); + } + if (metadata?.localCommandKind === 'goal_pending') { + return t('chatInput.goalGenerating'); + } + return null; + }, [t]); + const turnSummaries = useMemo(() => { return (activeSession?.dialogTurns ?? []) .filter(turn => !!turn.userMessage) .map((turn, index) => ({ turnId: turn.id, turnIndex: index + 1, - title: turn.userMessage?.metadata?.localCommandKind === 'usage_report' - ? t('usage.title') - : turn.userMessage?.content ?? '', + title: resolveLocalCommandHeaderTitle(turn.userMessage?.metadata) + ?? turn.userMessage?.content ?? '', })); - }, [activeSession?.dialogTurns, t]); + }, [activeSession?.dialogTurns, resolveLocalCommandHeaderTitle]); const effectiveVisibleTurnInfo = useMemo(() => { if (!pendingHeaderTurnId) { @@ -312,11 +321,12 @@ export const ModernFlowChatContainer: React.FC = ( return effectiveVisibleTurnInfo?.userMessage ?? ''; } const turn = activeSession?.dialogTurns.find(item => item.id === turnId); - if (turn?.userMessage?.metadata?.localCommandKind === 'usage_report') { - return t('usage.title'); + const localCommandTitle = resolveLocalCommandHeaderTitle(turn?.userMessage?.metadata); + if (localCommandTitle) { + return localCommandTitle; } return effectiveVisibleTurnInfo?.userMessage ?? ''; - }, [activeSession?.dialogTurns, effectiveVisibleTurnInfo?.turnId, effectiveVisibleTurnInfo?.userMessage, t]); + }, [activeSession?.dialogTurns, effectiveVisibleTurnInfo?.turnId, effectiveVisibleTurnInfo?.userMessage, resolveLocalCommandHeaderTitle]); useEffect(() => { if (!pendingHeaderTurnId) return; diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 6aaa6229b..14d3a084c 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -16,7 +16,7 @@ import { snapshotAPI } from '@/infrastructure/api'; import { notificationService } from '@/shared/notification-system'; import { globalEventBus } from '@/infrastructure/event-bus'; import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection'; -import { ReproductionStepsBlock, Tooltip, confirmDanger } from '@/component-library'; +import { ReproductionStepsBlock, Tooltip, confirmDanger, ToolProcessingDots } from '@/component-library'; import { UserMessageEditComposer } from './UserMessageEditComposer'; import { describeUserMessageEditImpact, @@ -69,6 +69,7 @@ export const UserMessageItem = React.memo( const messageContent = typeof message?.content === 'string' ? message.content : String(message?.content || ''); const messageImages = useMemo(() => message?.images ?? [], [message?.images]); const isUsageReportMessage = message?.metadata?.localCommandKind === 'usage_report'; + const isGoalPendingMessage = message?.metadata?.localCommandKind === 'goal_pending'; const isUsageReportLoading = message?.metadata?.usageReportStatus === 'loading'; const usageReport = coerceSessionUsageReport(message?.metadata?.usageReport); const sessionRelationship = useMemo( @@ -356,6 +357,19 @@ export const UserMessageItem = React.memo( /> ); } + + if (isGoalPendingMessage) { + return ( +
+
+ +
+

{messageContent}

+
+
+
+ ); + } return (
{ expect(stored?.lastActiveAt).toBe(4321); }); + it('can append local goal pending turns', () => { + const session = createSession(); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + const turn = flowChatStore.addLocalGoalPendingTurn({ + sessionId: session.sessionId, + message: 'Generating session goal...', + pendingId: 'goal-1', + }); + + const stored = flowChatStore.getState().sessions.get(session.sessionId)?.dialogTurns[0]; + expect(turn).not.toBeNull(); + expect(stored?.kind).toBe('local_command'); + expect(stored?.status).toBe('processing'); + expect(stored?.userMessage.content).toBe('Generating session goal...'); + expect(stored?.userMessage.metadata).toMatchObject({ + localCommandKind: 'goal_pending', + modelVisible: false, + goalPendingId: 'goal-1', + }); + }); + + it('can delete local goal pending turns', () => { + const session = createSession(); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + const turn = flowChatStore.addLocalGoalPendingTurn({ + sessionId: session.sessionId, + message: 'Generating session goal...', + pendingId: 'goal-1', + }); + + expect(turn).not.toBeNull(); + flowChatStore.deleteDialogTurn(session.sessionId, turn!.id); + + const stored = flowChatStore.getState().sessions.get(session.sessionId); + expect(stored?.dialogTurns).toHaveLength(0); + }); + it('appends repeated usage reports as separate snapshots', () => { const session = createSession(); flowChatStore.setState(() => ({ diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index c07bb43ef..102c02ac3 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -1009,6 +1009,66 @@ export class FlowChatStore { return dialogTurn; } + public addLocalGoalPendingTurn(params: { + sessionId: string; + message: string; + pendingId: string; + }): DialogTurn | null { + const session = this.state.sessions.get(params.sessionId); + if (!session) { + log.warn('Session not found, cannot add local goal pending turn', { + sessionId: params.sessionId, + }); + return null; + } + + const generatedAt = Date.now(); + const metadata: LocalCommandMetadata = { + localCommandKind: 'goal_pending', + modelVisible: false, + goalPendingId: params.pendingId, + generatedAt, + }; + const turnIndex = session.dialogTurns.length; + const dialogTurn: DialogTurn = { + id: `local-goal-${params.pendingId}`, + sessionId: params.sessionId, + kind: 'local_command', + userMessage: { + id: `local-goal-user-${params.pendingId}`, + content: params.message, + timestamp: generatedAt, + metadata, + }, + modelRounds: [], + status: 'processing', + startTime: generatedAt, + endTime: generatedAt, + backendTurnIndex: turnIndex, + }; + + this.setState(prev => { + const currentSession = prev.sessions.get(params.sessionId); + if (!currentSession) return prev; + + if (currentSession.dialogTurns.some(turn => turn.id === dialogTurn.id)) { + return prev; + } + + const newSessions = new Map(prev.sessions); + newSessions.set(params.sessionId, { + ...currentSession, + dialogTurns: [...currentSession.dialogTurns, dialogTurn], + }); + + return { + ...prev, + sessions: newSessions, + }; + }); + return dialogTurn; + } + public deleteDialogTurn(sessionId: string, dialogTurnId: string): void { this.setState(prev => { const session = prev.sessions.get(sessionId); @@ -2368,6 +2428,7 @@ export class FlowChatStore { const displayContent = metadata?.localCommandKind === 'usage_report' + || metadata?.localCommandKind === 'goal_pending' ? turn.userMessage.content : metadata?.original_text || this.cleanRemoteUserInput(turn.userMessage.content); const normalizedTurnStatus = normalizeRecoveredTurnStatus(turn.status, { error: undefined }); diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 3d4068ac0..88f662cbc 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -493,6 +493,7 @@ "usageNoWorkspace": "A workspace is required to build a usage report.", "usageFailed": "Usage report failed", "goalAction": "Session goal", + "goalGenerating": "Generating session goal...", "goalNoSession": "No active session for /goal", "goalUsage": "Use /goal with optional focus text, for example /goal fix the login bug.", "goalFailed": "Goal mode activation failed", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 2d8664c45..bae8d3a81 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -487,6 +487,7 @@ "compactUsage": "请直接使用 /compact,不要附加额外参数。", "compactFailed": "会话压缩失败", "goalAction": "会话目标", + "goalGenerating": "正在生成目标…", "goalNoSession": "当前没有可用于 /goal 的会话", "goalUsage": "使用 /goal 并可选择附加目标描述,例如 /goal 修复登录问题。", "goalFailed": "目标模式激活失败", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index e94fd926c..acf5ed419 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -487,6 +487,7 @@ "compactUsage": "請直接使用 /compact,不要附加額外參數。", "compactFailed": "會話壓縮失敗", "goalAction": "會話目標", + "goalGenerating": "正在生成目標…", "goalNoSession": "當前沒有可用於 /goal 的會話", "goalUsage": "使用 /goal 並可選擇附加目標描述,例如 /goal 修復登入問題。", "goalFailed": "目標模式啟用失敗", diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index 2e35f418f..37952433b 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -98,14 +98,17 @@ export interface ReviewActionPersistedState { export type SessionStatus = 'active' | 'archived' | 'completed'; export type DialogTurnKind = 'user_dialog' | 'manual_compaction' | 'local_command'; +export type LocalCommandKind = 'usage_report' | 'goal_pending'; + export interface LocalCommandMetadata { - localCommandKind: 'usage_report'; - reportId: string; - schemaVersion: number; - generatedAt: number; + localCommandKind: LocalCommandKind; + reportId?: string; + schemaVersion?: number; + generatedAt?: number; modelVisible: false; usageReport?: Record; usageReportStatus?: 'loading' | 'completed'; + goalPendingId?: string; } export interface SessionList {