From 54b99a1a58012bc42c56b233c42687be34ff533b Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Mon, 7 Sep 2026 18:50:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=9B=9E=E5=90=88=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E6=94=B9=E7=BB=91=20agent=5Fsettled=EF=BC=8C=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9/=E9=87=8D=E8=AF=95=E9=97=B4=E9=9A=99?= =?UTF-8?q?=E7=9A=84=E8=AF=AF=E6=94=B6=E8=B5=B7=E4=B8=8E=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E9=97=AA=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/renderer/src/App.tsx | 1 + .../src/renderer/src/components/ChatView.tsx | 7 +- .../renderer/src/components/MessageItem.tsx | 84 ++++++++++++------- .../src/renderer/src/hooks/useAgent.ts | 11 ++- 4 files changed, 70 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index ade4d5d..bb32928 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -111,6 +111,7 @@ export default function App(): JSX.Element { messages={state.messages} tools={state.tools} streaming={state.streaming} + turnActive={state.turnActive} error={state.error} retry={state.retry} sessionError={state.sessionError} diff --git a/apps/desktop/src/renderer/src/components/ChatView.tsx b/apps/desktop/src/renderer/src/components/ChatView.tsx index 36798b1..d7c9da6 100644 --- a/apps/desktop/src/renderer/src/components/ChatView.tsx +++ b/apps/desktop/src/renderer/src/components/ChatView.tsx @@ -38,6 +38,7 @@ function renderTurns( messages: UiMessage[], tools: Record, streaming: boolean, + turnActive: boolean, pendingApprovals: ApprovalRequest[] ): ReactNode[] { const items: ReactNode[] = [] @@ -53,6 +54,7 @@ function renderTurns( messages={messages.slice(i, j)} tools={tools} streaming={streaming} + turnActive={turnActive} pendingApprovals={pendingApprovals} /> ) @@ -64,6 +66,7 @@ function renderTurns( message={message} tools={tools} streaming={streaming} + turnActive={turnActive} pendingApprovals={pendingApprovals} /> ) @@ -77,6 +80,7 @@ export function ChatView({ messages, tools, streaming, + turnActive, error, retry, sessionError, @@ -88,6 +92,7 @@ export function ChatView({ messages: UiMessage[] tools: Record streaming: boolean + turnActive: boolean error: string | null retry: { attempt: number; maxAttempts: number; errorMessage: string } | null sessionError: string | null @@ -154,7 +159,7 @@ export function ChatView({
{hasContent ? (
- {renderTurns(messages, tools, streaming, pendingApprovals)} + {renderTurns(messages, tools, streaming, turnActive, pendingApprovals)}
) : (
diff --git a/apps/desktop/src/renderer/src/components/MessageItem.tsx b/apps/desktop/src/renderer/src/components/MessageItem.tsx index 4034459..daccade 100644 --- a/apps/desktop/src/renderer/src/components/MessageItem.tsx +++ b/apps/desktop/src/renderer/src/components/MessageItem.tsx @@ -59,20 +59,25 @@ function useWorkingSeconds(working: boolean): string { } /** 一轮助手回复 = 连续的 assistant 消息(pi 每个工具轮次一条消息)。 - * 划分规则:最后一轮的正文 = 最终答案(始终可见);这轮里的其余一切 —— - * 思考、工具行、以及干活过程中模型穿插写的叙述性正文 —— 全部归入「过程」, - * 样式与普通正文一致,按时序穿插在思考/工具行之间,折叠时随过程一起收起。 - * 流式中:正在流的消息就是答案的雏形,思考/正文按真实到达顺序分段渲染; - * 它结束、下一轮开始后即「沉」入过程区。 */ + * 划分规则:回合结束(turnActive=false,agent_settled 之后)后,最后一轮的正文 + * = 最终答案(始终可见);这轮里的其余一切 —— 思考、工具行、以及干活过程中模型 + * 穿插写的叙述性正文 —— 全部归入「过程」,样式与普通正文一致,按时序穿插在 + * 思考/工具行之间,折叠时随过程一起收起。 + * 回合进行中不做答案/过程划分(包括工具执行与自动压缩的静默间隙——pi 会在一个 + * 回合内发出多次 agent_start/agent_end,streaming 会中途抖动,划分跟着抖就会 + * 出现正文在答案区与过程区之间来回跳的闪现);正在流式的消息始终渲染在底部 + * 作为答案的雏形,它结束、下一轮开始后即「沉」入过程区。 */ export function AssistantTurn({ messages, tools, streaming, + turnActive, pendingApprovals }: { messages: UiMessage[] tools: Record streaming: boolean + turnActive: boolean pendingApprovals: ApprovalRequest[] }): ReactNode { const processNodes: ReactNode[] = [] @@ -137,15 +142,16 @@ export function AssistantTurn({ } // 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。 - // stream 不存在时,最后一条已完成消息的正文是答案。 + // 只有回合结束(settled)且没有流式占位时,最后一条已完成消息的正文才是答案。 const streamPos = messages.findIndex((m) => m.stream) + const settled = !turnActive for (let idx = 0; idx < messages.length; idx++) { const message = messages[idx] if (message.stream) { stream = message.stream continue } - consume(message.raw, streamPos === -1 && idx === messages.length - 1) + consume(message.raw, settled && streamPos === -1 && idx === messages.length - 1) } flushRows() @@ -154,15 +160,16 @@ export function AssistantTurn({ : false const hasProcess = calls > 0 || thinking > 0 || streamHasThinking - // 折叠状态:历史回合默认收起,流式回合默认展开;结束自动收起,可手动切换 - const [collapsed, setCollapsed] = useState(() => !streaming && hasProcess) - const prevStreaming = useRef(streaming) + // 折叠状态:历史回合默认收起,进行中的回合默认展开;回合真正结束 + // (agent_settled → turnActive=false,包含压缩/重试续跑都完成)才自动收起 + const [collapsed, setCollapsed] = useState(() => !turnActive && hasProcess) + const prevTurnActive = useRef(turnActive) useEffect(() => { - if (prevStreaming.current && !streaming) setCollapsed(true) - prevStreaming.current = streaming - }, [streaming]) + if (prevTurnActive.current && !turnActive) setCollapsed(true) + prevTurnActive.current = turnActive + }, [turnActive]) - const workedFor = useWorkingSeconds(streaming) + const workedFor = useWorkingSeconds(turnActive) const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}` // 流式分段:思考段属过程(折叠时隐藏),正文段属答案;均按真实时序渲染 @@ -216,7 +223,7 @@ export function AssistantTurn({ strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" - className="transition-transform" + className="transition-transform duration-500" style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }} > @@ -229,22 +236,31 @@ export function AssistantTurn({
)} - {/* 展开态:过程(思考/工具/叙述正文按时序穿插)+ 最终答案;收起态:只留最终答案 */} - {!collapsed || !hasProcess ? ( - <> + {/* 过程区:grid-rows 0fr/1fr 高度动画 + 透明度渐隐,收起/展开更从容 */} +
+
{processNodes} - {answerNodes} - {streamNodes.length > 0 &&
{streamNodes}
} - {stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && ( - - )} - - ) : ( - <> - {answerNodes} - {streamNodes.length > 0 &&
{streamNodes}
} - +
+
+ + {/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */} + {answerNodes} + {streamNodes.length > 0 &&
{streamNodes}
} + {stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && ( + )} + {/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */} + {turnActive && !stream && }
) } @@ -253,11 +269,13 @@ export function MessageItem({ message, tools, streaming, + turnActive, pendingApprovals = [] }: { message: UiMessage tools: Record streaming: boolean + turnActive: boolean pendingApprovals?: ApprovalRequest[] }): ReactNode | null { const ref = useEntrance() @@ -277,7 +295,13 @@ export function MessageItem({ if (message.role === 'assistant') { return (
- +
) } diff --git a/apps/desktop/src/renderer/src/hooks/useAgent.ts b/apps/desktop/src/renderer/src/hooks/useAgent.ts index d1a0ed8..614bc16 100644 --- a/apps/desktop/src/renderer/src/hooks/useAgent.ts +++ b/apps/desktop/src/renderer/src/hooks/useAgent.ts @@ -33,6 +33,10 @@ interface AgentUiState { messages: UiMessage[] tools: Record streaming: boolean + /** 回合是否仍在进行。与 streaming 的区别:pi 在一个回合内可能因自动压缩/重试 + * 续跑而发出多次 agent_start/agent_end,streaming 会随之中途抖动; + * turnActive 只在 agent_settled(整个回合真正结束)时归位。 */ + turnActive: boolean queue: { steering: string[]; followUp: string[] } error: string | null sessionError: string | null @@ -53,6 +57,7 @@ const initialState: AgentUiState = { messages: [], tools: {}, streaming: false, + turnActive: false, queue: { steering: [], followUp: [] }, error: null, sessionError: null, @@ -147,6 +152,7 @@ function reducer(state: AgentUiState, action: Action): AgentUiState { error: null, retry: null, streaming: false, + turnActive: false, queue: { steering: [], followUp: [] }, stats: null, sessionState: event.state, @@ -154,11 +160,12 @@ function reducer(state: AgentUiState, action: Action): AgentUiState { tools: historyToTools(event.messages) } case 'agent_start': - return { ...state, streaming: true, error: null, retry: null } + return { ...state, streaming: true, turnActive: true, error: null, retry: null } case 'agent_end': + // agent_end 不代表回合结束:自动压缩/重试会以 agent_start 续跑同一回合 return { ...state, streaming: false } case 'agent_settled': - return { ...state, streaming: false, retry: null } + return { ...state, streaming: false, turnActive: false, retry: null } case 'message_start': { // 助手消息开流式占位;用户消息在发送时已乐观插入,此处不重复 if (event.role === 'assistant') {