refactor(web): render the chat column outside App

The workbench shell still owns session and modal state. Message stream, filters, and the composer now live in ChatColumn so App stops assembling that column inline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-19 13:57:10 +00:00
co-authored by Cursor
parent b3a1305e46
commit 8a9599fbb0
3 changed files with 294 additions and 162 deletions
+38 -162
View File
@@ -1,8 +1,7 @@
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { AppShell } from './components/layout/AppShell'; import { AppShell } from './components/layout/AppShell';
import { DocumentPane } from './components/doc/DocumentPane'; import { DocumentPane } from './components/doc/DocumentPane';
import { MessageStream } from './components/chat/MessageStream'; import { ChatColumn } from './components/chat/ChatColumn';
import { InputArea } from './components/chat/InputArea';
import { fetchFreeformDoc } from './api/freeformDoc'; import { fetchFreeformDoc } from './api/freeformDoc';
import type { DiffResult, OverlayMode } from './features/documentRevision/types'; import type { DiffResult, OverlayMode } from './features/documentRevision/types';
import { fetchProjectLineage, getProject } from './api/projectWorkflow'; import { fetchProjectLineage, getProject } from './api/projectWorkflow';
@@ -35,7 +34,6 @@ import {
import { DocumentLineage } from './types/projectWorkflow'; import { DocumentLineage } from './types/projectWorkflow';
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner'; import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard';
import { UserProjectRightsModal } from './components/user/UserProjectRightsModal'; import { UserProjectRightsModal } from './components/user/UserProjectRightsModal';
import { UserSettingsModal } from './components/user/UserSettingsModal'; import { UserSettingsModal } from './components/user/UserSettingsModal';
import { OnDemand } from './components/lazy/OnDemand'; import { OnDemand } from './components/lazy/OnDemand';
@@ -114,13 +112,8 @@ import { RequestEpoch, shareInflight } from './data/initFanout';
import { import {
resolveSessionProjectId, resolveSessionProjectId,
} from './lib/sessionAdapter'; } from './lib/sessionAdapter';
import { CategoryFilterBar } from './components/history/CategoryFilterBar'; import { navigateToAnchor, parseAnchorHash } from './engine/anchorNavigator';
import { HistoryCalendar } from './components/history/HistoryCalendar';
import { ChatSearchModal } from './components/history/ChatSearchModal';
import { DigestTimelineModal } from './components/history/DigestTimelineModal';
import { navigateToAnchor, parseAnchorHash, resolveTurnAnchorId } from './engine/anchorNavigator';
import { useUnreadMarks } from './hooks/useUnreadMarks'; import { useUnreadMarks } from './hooks/useUnreadMarks';
import { CategoryDock } from './components/chat/CategoryDock';
import { fetchChatCategories, fetchChatDates } from './api/chatWindow'; import { fetchChatCategories, fetchChatDates } from './api/chatWindow';
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench'; import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
import { saveFile } from './data/fileStore'; import { saveFile } from './data/fileStore';
@@ -134,9 +127,6 @@ export {
const loadAdminConsoleView = () => const loadAdminConsoleView = () =>
import('./components/admin/console/AdminConsoleView').then((m) => ({ default: m.AdminConsoleView })); import('./components/admin/console/AdminConsoleView').then((m) => ({ default: m.AdminConsoleView }));
const loadChatExportModal = () =>
import('./components/history/ChatExportModal').then((m) => ({ default: m.ChatExportModal }));
export default function App() { export default function App() {
// Wave 2:启动时幂等剔除 localStorage 中的 demo seed 残留(须先于 load*) // Wave 2:启动时幂等剔除 localStorage 中的 demo seed 残留(须先于 load*)
purgeDemoSeedsFromLocalStorage(); purgeDemoSeedsFromLocalStorage();
@@ -1553,156 +1543,42 @@ export default function App() {
}} }}
workflowBanner={workflowBanner} workflowBanner={workflowBanner}
chatContent={ chatContent={
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <ChatColumn
{/* P1, P2, P3: 顶部分类统计过滤栏、历史日历、三模搜索、智能摘要、导出与未读角标 */} activeSessionId={activeSessionId}
<CategoryFilterBar categoryStats={categoryStats}
categories={categoryStats} chatWindow={chatWindow}
selectedCategory={chatWindow.selectedCategory} isCalendarOpen={isCalendarOpen}
onSelectCategory={chatWindow.setSelectedCategory} setIsCalendarOpen={setIsCalendarOpen}
onNavigateToCategory={(_cat, lastMessageId) => { isSearchOpen={isSearchOpen}
if (lastMessageId && activeSessionId) { setIsSearchOpen={setIsSearchOpen}
const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages); isDigestOpen={isDigestOpen}
navigateToAnchor( setIsDigestOpen={setIsDigestOpen}
{ sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 }, isExportOpen={isExportOpen}
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } setIsExportOpen={setIsExportOpen}
); unreadTotal={unreadTotal}
} dateStats={dateStats}
}} unreadMarks={unreadMarks}
onToggleCalendar={() => setIsCalendarOpen((prev) => !prev)} unreadMessageIds={unreadMessageIds}
isCalendarOpen={isCalendarOpen} isAllReadCompleted={isAllReadCompleted}
onOpenSearch={() => setIsSearchOpen(true)} consumeNextUnread={consumeNextUnread}
onOpenDigest={() => setIsDigestOpen(true)} handleSelectDoc={handleSelectDoc}
onOpenExport={() => setIsExportOpen(true)} handleCardAction={handleCardAction}
unreadTotal={unreadTotal} handleErrorAction={handleErrorAction}
/> handleQuoteMessage={handleQuoteMessage}
{isCalendarOpen && ( handleSaveMessageToPrivate={handleSaveMessageToPrivate}
<HistoryCalendar handleExportReplyDocx={handleExportReplyDocx}
dateStats={dateStats} pendingProposal={pendingProposal}
unreadMarks={unreadMarks} proposalError={proposalError}
onSelectDate={(date) => { handleAcceptProposal={handleAcceptProposal}
chatWindow.jumpToDate(date); handleRejectProposal={handleRejectProposal}
setIsCalendarOpen(false); handleSendMessage={handleSendMessage}
}} isThinking={isThinking}
onClose={() => setIsCalendarOpen(false)} isStreamingActive={isStreamingActive}
/> handleStopStreaming={handleStopStreaming}
)} activeQuotes={activeQuotes}
{isSearchOpen && ( handleRemoveQuote={handleRemoveQuote}
<ChatSearchModal isCurrentProjectExited={isCurrentProjectExited}
sessionId={activeSessionId} />
onSelectHit={(hit) => {
navigateToAnchor(
{ sessionId: activeSessionId, messageId: hit.message_id },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
setIsSearchOpen(false);
}}
onClose={() => setIsSearchOpen(false)}
/>
)}
{isDigestOpen && (
<DigestTimelineModal
sessionId={activeSessionId}
onJumpToMessage={(messageId) => {
navigateToAnchor(
{ sessionId: activeSessionId, messageId },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
setIsDigestOpen(false);
}}
onClose={() => setIsDigestOpen(false)}
/>
)}
{isExportOpen && (
<OnDemand
label="聊天导出"
load={loadChatExportModal}
componentProps={{
isOpen: isExportOpen,
onClose: () => setIsExportOpen(false),
sessionId: activeSessionId,
dayStack: chatWindow.displayDayStack,
}}
/>
)}
<div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<MessageStream
sessionId={activeSessionId}
dayStack={chatWindow.displayDayStack}
isLoadingOlder={chatWindow.phase === 'loading_older'}
hasMoreBefore={chatWindow.hasMoreBefore}
onLoadOlder={chatWindow.loadOlder}
onLoadFullDay={chatWindow.loadFullDay}
onOpenCase={handleSelectDoc}
onCardAction={handleCardAction}
onErrorAction={handleErrorAction}
onQuote={handleQuoteMessage}
onNavigateToAnchor={(targetAnchor) => {
navigateToAnchor(
{ sessionId: activeSessionId, messageId: targetAnchor },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
}}
selectedCategory={chatWindow.selectedCategory}
unreadMessageIds={unreadMessageIds}
onSaveToPrivate={handleSaveMessageToPrivate}
onExportDocx={handleExportReplyDocx}
onOpenDoc={handleSelectDoc}
/>
<CategoryDock
categories={categoryStats}
activeCategory={chatWindow.selectedCategory}
messages={chatWindow.allMessages}
unreadTotal={unreadTotal}
unreadMessageIds={unreadMessageIds}
isAllReadCompleted={isAllReadCompleted}
onConsumeNextUnread={async () => {
const targetId = await consumeNextUnread();
if (targetId && activeSessionId) {
navigateToAnchor(
{ sessionId: activeSessionId, messageId: targetId, highlightMs: 2000 },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
}
}}
onNavigateToCategory={(_cat, lastMessageId) => {
if (lastMessageId && activeSessionId) {
const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages);
navigateToAnchor(
{ sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
}
}}
onSelectCategory={chatWindow.setSelectedCategory}
/>
</div>
{pendingProposal && (
<div style={{ padding: '0 16px' }}>
{proposalError && (
<div style={{ color: 'var(--danger, #b91c1c)', fontSize: 12, marginBottom: 8 }}>
{proposalError}
</div>
)}
<ProposalConfirmCard
proposedChanges={pendingProposal.changes}
impactAnalysis={pendingProposal.impact}
potentialRisk={pendingProposal.risk}
onAccept={handleAcceptProposal}
onReject={handleRejectProposal}
/>
</div>
)}
<InputArea
onSendMessage={handleSendMessage}
isThinking={isThinking}
isStreaming={isStreamingActive}
onStopStreaming={handleStopStreaming}
quotes={activeQuotes}
onRemoveQuote={handleRemoveQuote}
disabled={isCurrentProjectExited}
disabledMessage="您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。"
/>
</div>
} }
docContent={ docContent={
<DocumentPane <DocumentPane
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
test('chat column lives outside the workbench shell', () => {
const app = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
const column = readFileSync(new URL('./ChatColumn.tsx', import.meta.url), 'utf8');
assert.equal((app.match(/<ChatColumn\b/g) || []).length, 1);
assert.equal(app.includes('<MessageStream'), false);
assert.equal(app.includes('<InputArea'), false);
assert.equal(column.includes('<MessageStream'), true);
assert.equal(column.includes('<InputArea'), true);
assert.equal(column.includes('ProposalConfirmCard'), true);
});
+242
View File
@@ -0,0 +1,242 @@
import type React from 'react';
import { OnDemand } from '../lazy/OnDemand';
import { CategoryFilterBar } from '../history/CategoryFilterBar';
import { HistoryCalendar } from '../history/HistoryCalendar';
import { ChatSearchModal } from '../history/ChatSearchModal';
import { DigestTimelineModal } from '../history/DigestTimelineModal';
import { navigateToAnchor, resolveTurnAnchorId } from '../../engine/anchorNavigator';
import { CategoryDock } from './CategoryDock';
import { MessageStream } from './MessageStream';
import { InputArea } from './InputArea';
import { ProposalConfirmCard } from '../doc/ProposalConfirmCard';
import type { UseChatWindowReturn } from '../../hooks/useChatWindow';
import type { ChatCategoryStat, ChatDateStat, ChatMessage, QuotedMessage, UnreadMark } from '../../types/workbench';
const loadChatExportModal = () =>
import('../history/ChatExportModal').then((m) => ({ default: m.ChatExportModal }));
export interface ChatColumnProps {
activeSessionId: string;
categoryStats: ChatCategoryStat[];
chatWindow: UseChatWindowReturn;
isCalendarOpen: boolean;
setIsCalendarOpen: (next: boolean | ((prev: boolean) => boolean)) => void;
isSearchOpen: boolean;
setIsSearchOpen: (open: boolean) => void;
isDigestOpen: boolean;
setIsDigestOpen: (open: boolean) => void;
isExportOpen: boolean;
setIsExportOpen: (open: boolean) => void;
unreadTotal: number;
dateStats: ChatDateStat[];
unreadMarks: UnreadMark[];
unreadMessageIds: number[];
isAllReadCompleted: boolean;
consumeNextUnread: () => Promise<number | null | undefined>;
handleSelectDoc: (id: string) => void;
handleCardAction: (cardId: string, actionId: string, payload?: unknown) => void;
handleErrorAction: (messageId: string, action: 'retry' | 'continue' | 'restart') => void;
handleQuoteMessage: (message: ChatMessage) => void;
handleSaveMessageToPrivate: (message: ChatMessage) => void;
handleExportReplyDocx: (message: ChatMessage) => void | Promise<void>;
pendingProposal: { changes: string; impact: string; risk: string } | null;
proposalError: string | null;
handleAcceptProposal: () => void;
handleRejectProposal: () => void;
handleSendMessage: React.ComponentProps<typeof InputArea>['onSendMessage'];
isThinking: boolean;
isStreamingActive: boolean;
handleStopStreaming: () => void;
activeQuotes: QuotedMessage[];
handleRemoveQuote: (index: number) => void;
isCurrentProjectExited: boolean;
}
export function ChatColumn({
activeSessionId,
categoryStats,
chatWindow,
isCalendarOpen,
setIsCalendarOpen,
isSearchOpen,
setIsSearchOpen,
isDigestOpen,
setIsDigestOpen,
isExportOpen,
setIsExportOpen,
unreadTotal,
dateStats,
unreadMarks,
unreadMessageIds,
isAllReadCompleted,
consumeNextUnread,
handleSelectDoc,
handleCardAction,
handleErrorAction,
handleQuoteMessage,
handleSaveMessageToPrivate,
handleExportReplyDocx,
pendingProposal,
proposalError,
handleAcceptProposal,
handleRejectProposal,
handleSendMessage,
isThinking,
isStreamingActive,
handleStopStreaming,
activeQuotes,
handleRemoveQuote,
isCurrentProjectExited,
}: ChatColumnProps) {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<CategoryFilterBar
categories={categoryStats}
selectedCategory={chatWindow.selectedCategory}
onSelectCategory={chatWindow.setSelectedCategory}
onNavigateToCategory={(_cat, lastMessageId) => {
if (lastMessageId && activeSessionId) {
const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages);
navigateToAnchor(
{ sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
}
}}
onToggleCalendar={() => setIsCalendarOpen((prev) => !prev)}
isCalendarOpen={isCalendarOpen}
onOpenSearch={() => setIsSearchOpen(true)}
onOpenDigest={() => setIsDigestOpen(true)}
onOpenExport={() => setIsExportOpen(true)}
unreadTotal={unreadTotal}
/>
{isCalendarOpen && (
<HistoryCalendar
dateStats={dateStats}
unreadMarks={unreadMarks}
onSelectDate={(date) => {
chatWindow.jumpToDate(date);
setIsCalendarOpen(false);
}}
onClose={() => setIsCalendarOpen(false)}
/>
)}
{isSearchOpen && (
<ChatSearchModal
sessionId={activeSessionId}
onSelectHit={(hit) => {
navigateToAnchor(
{ sessionId: activeSessionId, messageId: hit.message_id },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
setIsSearchOpen(false);
}}
onClose={() => setIsSearchOpen(false)}
/>
)}
{isDigestOpen && (
<DigestTimelineModal
sessionId={activeSessionId}
onJumpToMessage={(messageId) => {
navigateToAnchor(
{ sessionId: activeSessionId, messageId },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
setIsDigestOpen(false);
}}
onClose={() => setIsDigestOpen(false)}
/>
)}
{isExportOpen && (
<OnDemand
label="聊天导出"
load={loadChatExportModal}
componentProps={{
isOpen: isExportOpen,
onClose: () => setIsExportOpen(false),
sessionId: activeSessionId,
dayStack: chatWindow.displayDayStack,
}}
/>
)}
<div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<MessageStream
sessionId={activeSessionId}
dayStack={chatWindow.displayDayStack}
isLoadingOlder={chatWindow.phase === 'loading_older'}
hasMoreBefore={chatWindow.hasMoreBefore}
onLoadOlder={chatWindow.loadOlder}
onLoadFullDay={chatWindow.loadFullDay}
onOpenCase={handleSelectDoc}
onCardAction={handleCardAction}
onErrorAction={handleErrorAction}
onQuote={handleQuoteMessage}
onNavigateToAnchor={(targetAnchor) => {
navigateToAnchor(
{ sessionId: activeSessionId, messageId: targetAnchor },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
}}
selectedCategory={chatWindow.selectedCategory}
unreadMessageIds={unreadMessageIds}
onSaveToPrivate={handleSaveMessageToPrivate}
onExportDocx={handleExportReplyDocx}
onOpenDoc={handleSelectDoc}
/>
<CategoryDock
categories={categoryStats}
activeCategory={chatWindow.selectedCategory}
messages={chatWindow.allMessages}
unreadTotal={unreadTotal}
unreadMessageIds={unreadMessageIds}
isAllReadCompleted={isAllReadCompleted}
onConsumeNextUnread={async () => {
const targetId = await consumeNextUnread();
if (targetId && activeSessionId) {
navigateToAnchor(
{ sessionId: activeSessionId, messageId: targetId, highlightMs: 2000 },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
}
}}
onNavigateToCategory={(_cat, lastMessageId) => {
if (lastMessageId && activeSessionId) {
const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages);
navigateToAnchor(
{ sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
}
}}
onSelectCategory={chatWindow.setSelectedCategory}
/>
</div>
{pendingProposal && (
<div style={{ padding: '0 16px' }}>
{proposalError && (
<div style={{ color: 'var(--danger, #b91c1c)', fontSize: 12, marginBottom: 8 }}>
{proposalError}
</div>
)}
<ProposalConfirmCard
proposedChanges={pendingProposal.changes}
impactAnalysis={pendingProposal.impact}
potentialRisk={pendingProposal.risk}
onAccept={handleAcceptProposal}
onReject={handleRejectProposal}
/>
</div>
)}
<InputArea
onSendMessage={handleSendMessage}
isThinking={isThinking}
isStreaming={isStreamingActive}
onStopStreaming={handleStopStreaming}
quotes={activeQuotes}
onRemoveQuote={handleRemoveQuote}
disabled={isCurrentProjectExited}
disabledMessage="您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。"
/>
</div>
);
}