diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index 1fa583b13..3f17d4775 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -43,6 +43,7 @@ import type { TranscriptExportScope } from '../../utils/dialogTranscriptExport'; import { buildTranscriptExportLabels } from '../../utils/transcriptExportLabels'; import './ModelRoundItem.scss'; import './SubagentItems.scss'; +import { TurnFilesSummary } from './TurnFilesSummary'; const log = createLogger('ModelRoundItem'); @@ -124,10 +125,12 @@ interface ModelRoundItemProps { turnId: string; isLastRound?: boolean; isTurnComplete?: boolean; + isLastTurn?: boolean; turnStartedAt?: number; turnEndedAt?: number; turnDurationMs?: number; turnTokenUsage?: TokenUsage; + turnBackendIndex?: number; } function sortRoundAttempts(attempts: ModelRoundAttempt[]): ModelRoundAttempt[] { @@ -330,10 +333,12 @@ export const ModelRoundItem = React.memo( turnId, isLastRound = false, isTurnComplete = false, + isLastTurn = false, turnStartedAt, turnEndedAt, turnDurationMs, turnTokenUsage, + turnBackendIndex, }) => { const { t } = useTranslation('flow-chat'); const { formatDate, formatNumber } = useI18n('flow-chat'); @@ -762,6 +767,13 @@ export const ModelRoundItem = React.memo( {allowTranscriptExport && } )} + + {isTurnComplete && isLastRound && isLastTurn && !isVisuallyStreaming && typeof turnBackendIndex === 'number' && ( + + )} ); @@ -781,10 +793,12 @@ export const ModelRoundItem = React.memo( prev.round.historyRounds === next.round.historyRounds && prev.isLastRound === next.isLastRound && prev.isTurnComplete === next.isTurnComplete && + prev.isLastTurn === next.isLastTurn && prev.turnStartedAt === next.turnStartedAt && prev.turnEndedAt === next.turnEndedAt && prev.turnDurationMs === next.turnDurationMs && - prev.turnTokenUsage === next.turnTokenUsage + prev.turnTokenUsage === next.turnTokenUsage && + prev.turnBackendIndex === next.turnBackendIndex ); } ); diff --git a/src/web-ui/src/flow_chat/components/modern/TurnFilesSummary.scss b/src/web-ui/src/flow_chat/components/modern/TurnFilesSummary.scss new file mode 100644 index 000000000..a5d91e2e3 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/TurnFilesSummary.scss @@ -0,0 +1,261 @@ +/** + * Turn files summary styles (BEM). + * Compact per-turn file change badge shown in the model round footer. + */ + +@use '../../../component-library/styles/tokens.scss' as *; + +.turn-files-summary { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + margin-top: var(--flowchat-inline-gap); + padding: 0 var(--flowchat-content-inline-pad); + min-height: 0; + + &__button { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 12px; + margin: 10px 0; + background: var(--element-bg-soft); + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + font-size: var(--flowchat-font-size-sm); + line-height: 1; + color: var(--color-text-secondary); + cursor: pointer; + transition: all $motion-fast $easing-standard; + white-space: nowrap; + + span, + svg { + cursor: inherit; + } + + &:hover:not(:disabled) { + background: var(--element-bg-base); + color: var(--color-text-primary); + border-color: var(--border-medium); + } + + &:active:not(:disabled) { + background: var(--element-bg-base); + transform: scale(0.98); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + &__icon { + flex-shrink: 0; + color: var(--color-text-muted); + } + + &__count { + font-weight: $font-weight-medium; + color: var(--color-text-primary); + } + + &__stats { + font-weight: $font-weight-medium; + font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace; + font-size: inherit; + line-height: inherit; + + &--add { + color: var(--color-success); + } + + &--del { + color: var(--color-error); + } + } + + &__arrow { + flex-shrink: 0; + display: block; + color: var(--color-text-muted); + width: 12px; + height: 12px; + transition: transform $motion-fast $easing-standard; + } + + // Expanded state + &--expanded { + .turn-files-summary__arrow { + transform: rotate(180deg); + } + } + + // ==================== Popover ==================== + &__popover { + position: absolute; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + z-index: 100; + min-width: 260px; + max-width: 380px; + background: color-mix(in srgb, var(--color-bg-elevated) 98%, transparent); + backdrop-filter: blur(20px) saturate(1.3); + -webkit-backdrop-filter: blur(20px) saturate(1.3); + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + box-shadow: 0 4px 16px var(--color-overlay-black-30); + animation: turn-files-summary-popover-in 0.2s $easing-standard; + + @keyframes turn-files-summary-popover-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } + } + + &__popover-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + padding: 8px 10px; + border-bottom: 1px solid var(--border-subtle); + font-size: var(--flowchat-font-size-xs); + line-height: var(--flowchat-compact-line-height); + color: var(--color-text-secondary); + } + + &__popover-summary-count { + min-width: 0; + font-weight: $font-weight-medium; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__popover-summary-stats { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace; + } + + // ==================== File list ==================== + &__list { + max-height: 280px; + overflow-y: auto; + padding: 4px 6px 6px; + + scrollbar-width: thin; + scrollbar-color: var(--border-subtle) transparent; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: var(--border-subtle); + border-radius: 2px; + + &:hover { + background-color: var(--border-medium); + } + } + } + + // ==================== File item ==================== + &__file-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: $size-radius-base; + font-size: var(--flowchat-font-size-sm); + line-height: var(--flowchat-compact-line-height); + color: var(--color-text-secondary); + cursor: pointer; + transition: all $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-soft); + color: var(--color-text-primary); + } + + &--error { + cursor: default; + color: var(--color-error); + + &:hover { + background: transparent; + } + } + } + + &__file-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + + .icon-write { + color: var(--color-success); + } + + .icon-edit { + color: var(--color-warning); + } + + .icon-delete { + color: var(--color-error); + } + } + + &__file-name { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__file-stats { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace; + font-size: var(--flowchat-font-size-xs); + } + + &__file-stat { + &--add { + color: var(--color-success); + } + + &--del { + color: var(--color-error); + } + } + + &__file-error { + flex-shrink: 0; + font-size: var(--flowchat-font-size-xs); + color: var(--color-error); + } +} diff --git a/src/web-ui/src/flow_chat/components/modern/TurnFilesSummary.tsx b/src/web-ui/src/flow_chat/components/modern/TurnFilesSummary.tsx new file mode 100644 index 000000000..6303daa9e --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/TurnFilesSummary.tsx @@ -0,0 +1,465 @@ +/** + * Turn files summary. + * Shows a compact button with file count and line changes for a single completed turn. + * Click to expand a popover listing all files modified in that turn. + * Each file opens a diff editor tab when clicked. + */ + +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { + FileEdit, + FilePlus, + Trash2, + ChevronDown, + ChevronUp, + FileCode2, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { snapshotAPI } from '../../../infrastructure/api'; +import { createDiffEditorTab } from '../../../shared/utils/tabUtils'; +import { useWorkspaceContext } from '../../../infrastructure/contexts/WorkspaceContext'; +import { createLogger } from '@/shared/utils/logger'; +import { runWithConcurrencyLimit } from '@/shared/utils/runWithConcurrencyLimit'; +import './TurnFilesSummary.scss'; + +const log = createLogger('TurnFilesSummary'); + +const CACHE_TTL = 60000; +const DIFF_STATS_MAX_CONCURRENCY = 3; + +interface FileStats { + filePath: string; + fileName: string; + additions: number; + deletions: number; + operationType: 'write' | 'edit' | 'delete'; + loading?: boolean; + error?: string; +} + +interface StatsCache { + [filePath: string]: { + stats: FileStats; + timestamp: number; + }; +} + +export interface TurnFilesSummaryProps { + sessionId?: string; + turnIndex: number; +} + +export const TurnFilesSummary: React.FC = ({ + sessionId, + turnIndex, +}) => { + const { t } = useTranslation('flow-chat'); + const { currentWorkspace } = useWorkspaceContext(); + const [isExpanded, setIsExpanded] = useState(false); + const [files, setFiles] = useState([]); + const [fileStats, setFileStats] = useState>(new Map()); + const [loading, setLoading] = useState(false); + const [loadingStats, setLoadingStats] = useState(false); + + const containerRef = useRef(null); + const popoverRef = useRef(null); + const statsCacheRef = useRef({}); + const loadingFilesRef = useRef>(new Set()); + const activeFilePathsRef = useRef>(new Set()); + const loadedTurnRef = useRef(null); + + // Load turn files when the turn completes. + useEffect(() => { + if (!sessionId) { + setFiles([]); + setFileStats(new Map()); + return; + } + + if (loadedTurnRef.current === turnIndex) { + return; + } + + let cancelled = false; + setLoading(true); + setFiles([]); + setFileStats(new Map()); + + snapshotAPI.getTurnFiles(sessionId, turnIndex, currentWorkspace?.rootPath) + .then((result) => { + if (cancelled) return; + loadedTurnRef.current = turnIndex; + setFiles(result); + }) + .catch((error) => { + if (cancelled) return; + log.warn('Failed to load turn files', { sessionId, turnIndex, error }); + setFiles([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [sessionId, turnIndex, currentWorkspace?.rootPath]); + + // Sync active file paths. + useEffect(() => { + const activeFilePaths = new Set(files); + activeFilePathsRef.current = activeFilePaths; + + setFileStats(prev => { + let changed = false; + const next = new Map(); + prev.forEach((stat, filePath) => { + if (activeFilePaths.has(filePath)) { + next.set(filePath, stat); + } else { + changed = true; + } + }); + return changed ? next : prev; + }); + + for (const filePath of Object.keys(statsCacheRef.current)) { + if (!activeFilePaths.has(filePath)) { + delete statsCacheRef.current[filePath]; + } + } + + for (const filePath of Array.from(loadingFilesRef.current)) { + if (!activeFilePaths.has(filePath)) { + loadingFilesRef.current.delete(filePath); + } + } + }, [files]); + + // Fetch per-file diff stats. + const loadFileStats = useCallback(async (filePaths: string[]) => { + if (!sessionId || filePaths.length === 0) { + return; + } + + const now = Date.now(); + + const newFilesToLoad = filePaths.filter(filePath => { + if (loadingFilesRef.current.has(filePath)) { + return false; + } + const cached = statsCacheRef.current[filePath]; + if (cached && now - cached.timestamp < CACHE_TTL) { + return false; + } + return true; + }); + + if (newFilesToLoad.length === 0) { + return; + } + + setLoadingStats(true); + + try { + newFilesToLoad.forEach(filePath => { + loadingFilesRef.current.add(filePath); + }); + + const batchResults = await runWithConcurrencyLimit( + newFilesToLoad, + DIFF_STATS_MAX_CONCURRENCY, + async (filePath) => { + let stats: FileStats | null = null; + + try { + const statsResp = await snapshotAPI.getSessionFileDiffStats( + sessionId, + filePath, + currentWorkspace?.rootPath, + ); + const fileName = filePath.split(/[/\\]/).pop() || filePath; + + const additions = statsResp.linesAdded; + const deletions = statsResp.linesRemoved; + const operationType: 'write' | 'edit' | 'delete' = + statsResp.changeKind === 'create' + ? 'write' + : statsResp.changeKind === 'delete' + ? 'delete' + : 'edit'; + + stats = { + filePath, + fileName, + additions, + deletions, + operationType, + }; + + if (activeFilePathsRef.current.has(filePath)) { + statsCacheRef.current[filePath] = { + stats, + timestamp: now, + }; + } + } catch (error) { + log.warn('Failed to get file stats', { filePath, error }); + const fileName = filePath.split(/[/\\]/).pop() || filePath; + stats = { + filePath, + fileName, + additions: 0, + deletions: 0, + operationType: 'edit', + error: t('sessionFilesBadge.loadFailed'), + }; + } finally { + loadingFilesRef.current.delete(filePath); + } + + return { filePath, stats }; + }, + ); + + setFileStats(prev => { + const newMap = new Map(prev); + for (const { filePath, stats } of batchResults) { + if ( + activeFilePathsRef.current.has(filePath) && + stats && + (stats.additions > 0 || stats.deletions > 0 || stats.error) + ) { + newMap.set(filePath, stats); + } + } + return newMap; + }); + } catch (error) { + log.error('Failed to load file stats', error); + } finally { + setLoadingStats(false); + } + }, [sessionId, t, currentWorkspace?.rootPath]); + + // Reload stats when the file list changes. + useEffect(() => { + const timeoutId = setTimeout(() => { + if (files.length > 0) { + loadFileStats(files); + } else { + setFileStats(new Map()); + statsCacheRef.current = {}; + } + }, 300); + + return () => clearTimeout(timeoutId); + }, [files, loadFileStats]); + + // Close popover when clicking outside. + useEffect(() => { + if (!isExpanded) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + const clickedContainer = !!containerRef.current?.contains(target); + const clickedPopover = !!popoverRef.current?.contains(target); + if (!clickedContainer && !clickedPopover) { + setIsExpanded(false); + } + }; + + const timeoutId = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 0); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isExpanded]); + + // Open diff for the selected file. + const handleFileClick = useCallback(async (filePath: string) => { + if (!sessionId) return; + + try { + const diffData = await snapshotAPI.getOperationDiff(sessionId, filePath); + if ((diffData.originalContent || '') === (diffData.modifiedContent || '')) { + log.debug('Skipping empty diff', { filePath, sessionId }); + setIsExpanded(false); + return; + } + const fileName = filePath.split(/[/\\]/).pop() || filePath; + + window.dispatchEvent(new CustomEvent('expand-right-panel')); + + setTimeout(() => { + createDiffEditorTab( + filePath, + fileName, + diffData.originalContent || '', + diffData.modifiedContent || '', + false, + 'agent', + currentWorkspace?.rootPath, + undefined, + false, + { + titleKind: 'diff', + duplicateKeyPrefix: 'diff' + } + ); + }, 250); + + setIsExpanded(false); + } catch (error) { + log.error('Failed to open diff', { filePath, error }); + } + }, [sessionId, currentWorkspace?.rootPath]); + + const getOperationIcon = (operationType: 'write' | 'edit' | 'delete') => { + switch (operationType) { + case 'write': + return ; + case 'delete': + return ; + default: + return ; + } + }; + + // Compute totals. + const totalStats = useMemo(() => { + let totalAdditions = 0; + let totalDeletions = 0; + + fileStats.forEach((stat) => { + totalAdditions += stat.additions; + totalDeletions += stat.deletions; + }); + + return { totalAdditions, totalDeletions }; + }, [fileStats]); + + const toggleHint = useMemo(() => { + if (fileStats.size === 0) return ''; + const head = t('turnFilesSummary.filesCount', { + count: fileStats.size, + }); + const deltas: string[] = []; + if (totalStats.totalAdditions > 0) deltas.push(`+${totalStats.totalAdditions}`); + if (totalStats.totalDeletions > 0) deltas.push(`-${totalStats.totalDeletions}`); + const cue = t('turnFilesSummary.expandCue'); + return deltas.length > 0 ? `${head} · ${deltas.join(' ')} · ${cue}` : `${head} · ${cue}`; + }, [fileStats.size, totalStats.totalAdditions, totalStats.totalDeletions, t]); + + if (!sessionId || loading || files.length === 0) { + return null; + } + + const showFileStatsSummary = fileStats.size > 0; + + return ( +
+ {showFileStatsSummary ? ( + + ) : null} + + {showFileStatsSummary && isExpanded && ( +
+
+ + {t('turnFilesSummary.filesCount', { + count: fileStats.size, + })} + + {(totalStats.totalAdditions > 0 || totalStats.totalDeletions > 0) && ( + + {totalStats.totalAdditions > 0 && ( + + +{totalStats.totalAdditions} + + )} + {totalStats.totalDeletions > 0 && ( + + -{totalStats.totalDeletions} + + )} + + )} +
+
+ {Array.from(fileStats.values()).map((stat) => ( +
!stat.error && handleFileClick(stat.filePath)} + title={stat.error ? stat.error : t('turnFilesSummary.clickToViewDiff')} + > + + {getOperationIcon(stat.operationType)} + + + {stat.fileName} + + {stat.error ? ( + {stat.error} + ) : ( + + {stat.additions > 0 && ( + + +{stat.additions} + + )} + {stat.deletions > 0 && ( + + -{stat.deletions} + + )} + + )} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx index d3bb38cfd..021edf317 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx @@ -51,10 +51,12 @@ export const VirtualItemRenderer = React.memo( turnId={item.turnId} isLastRound={item.isLastRound} isTurnComplete={item.isTurnComplete} + isLastTurn={item.isLastTurn} turnStartedAt={item.turnStartedAt} turnEndedAt={item.turnEndedAt} turnDurationMs={item.turnDurationMs} turnTokenUsage={item.turnTokenUsage} + turnBackendIndex={item.turnBackendIndex} /> ); diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index e929b43fc..772f45fcb 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -69,10 +69,12 @@ export type VirtualItem = turnId: string; isLastRound: boolean; isTurnComplete: boolean; + isLastTurn: boolean; turnStartedAt?: number; turnEndedAt?: number; turnDurationMs?: number; turnTokenUsage?: TokenUsage; + turnBackendIndex?: number; } | { type: 'explore-group'; data: ExploreGroupData; turnId: string } | { type: 'turn-completion-notice'; data: TurnCompletionNotice; turnId: string } @@ -294,6 +296,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { session.dialogTurns.forEach((turn, turnIndex) => { const hasNewerDialogTurn = turnIndex < session.dialogTurns.length - 1; + const isLastTurn = !hasNewerDialogTurn; const cachedItems = cachedTurnItems.get(turn); if ( cachedItems && @@ -476,12 +479,14 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { turnId: turn.id, isLastRound: roundIndex === rounds.length - 1, isTurnComplete, + isLastTurn, turnStartedAt: turn.startTime, turnEndedAt: turn.endTime, turnDurationMs: typeof turn.endTime === 'number' ? Math.max(0, turn.endTime - turn.startTime) : undefined, turnTokenUsage: turn.tokenUsage, + turnBackendIndex: turn.backendTurnIndex, }); roundIndex++; } diff --git a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts index c9d024eb2..d23de5acf 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts @@ -234,6 +234,26 @@ export class SnapshotAPI { } } + async getTurnFiles( + sessionId: string, + turnIndex: number, + workspacePath?: string, + ): Promise { + try { + const scope = requireSessionSnapshotScope(sessionId, workspacePath); + const key = `get_turn_files:${snapshotScopeKey(scope)}:${sessionId}:${turnIndex}`; + return await this.dedupeInFlight(key, () => api.invoke('get_turn_files', { + request: { + session_id: sessionId, + turn_index: turnIndex, + ...scope, + }, + })); + } catch (error) { + throw createTauriCommandError('get_turn_files', error, { sessionId, turnIndex, workspacePath }); + } + } + async getSessionFileDiffStats( sessionId: string, filePath: string, 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 4efd58db2..299d68ccc 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1283,6 +1283,11 @@ "reviewSource": "Review", "deepReviewSource": "$t(shared:features.deepReview)" }, + "turnFilesSummary": { + "filesCount": "{{count}} files", + "expandCue": "Expand list", + "clickToViewDiff": "Click to view diff" + }, "snapshotSystem": { "errors": { "refreshSessionFailed": "Failed to refresh session state", 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 40669ed69..0f7da4430 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1283,6 +1283,11 @@ "reviewSource": "审核", "deepReviewSource": "严格审核" }, + "turnFilesSummary": { + "filesCount": "{{count}} 个文件", + "expandCue": "展开列表", + "clickToViewDiff": "点击查看 Diff" + }, "snapshotSystem": { "errors": { "refreshSessionFailed": "刷新会话状态失败", 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 c4da9e8ea..ef056ec2f 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1283,6 +1283,11 @@ "reviewSource": "審核", "deepReviewSource": "嚴格審核" }, + "turnFilesSummary": { + "filesCount": "{{count}} 個檔案", + "expandCue": "展開列表", + "clickToViewDiff": "點擊查看 Diff" + }, "snapshotSystem": { "errors": { "refreshSessionFailed": "重新整理會話狀態失敗",