diff --git a/docs/screenshots/answer-collapse-2026-10-24/README.md b/docs/screenshots/answer-collapse-2026-10-24/README.md new file mode 100644 index 0000000..a149256 --- /dev/null +++ b/docs/screenshots/answer-collapse-2026-10-24/README.md @@ -0,0 +1,10 @@ +# 长回答展开与收起 + +2026-10-24,Windows / Bun 1.4.2 / Electron 39。截图来自隔离用户目录中的实际 Electron 渲染器,由仓库安装的 Playwright 驱动;隐藏窗口使用 Electron `capturePage` 截图。 + +回答、引用与数值均为固定交互验证样例,不代表实时行情、真实模型输出或投资判断。没有发布用户会话、凭据或实际账户数据。 + +- [收起状态](collapsed.png):正文保留开头预览,展开按钮和来源入口独立可用。 +- [展开状态](expanded.png):完整正文恢复,回答身份行显示收起入口。 + +本次验证覆盖独立展开状态、段落与表格边界、来源弹窗、键盘操作、滚动位置、320px 面板,以及流式事件结束后加载保存记录时的展开状态。流式部分使用固定事件样例,不是实时模型测试;没有重新发布安装程序。 diff --git a/docs/screenshots/answer-collapse-2026-10-24/collapsed.png b/docs/screenshots/answer-collapse-2026-10-24/collapsed.png new file mode 100644 index 0000000..924f5f9 Binary files /dev/null and b/docs/screenshots/answer-collapse-2026-10-24/collapsed.png differ diff --git a/docs/screenshots/answer-collapse-2026-10-24/expanded.png b/docs/screenshots/answer-collapse-2026-10-24/expanded.png new file mode 100644 index 0000000..e2afabe Binary files /dev/null and b/docs/screenshots/answer-collapse-2026-10-24/expanded.png differ diff --git a/packages/i18n/src/locales/en-US/agent.ts b/packages/i18n/src/locales/en-US/agent.ts index d9b8ca0..8a695d2 100644 --- a/packages/i18n/src/locales/en-US/agent.ts +++ b/packages/i18n/src/locales/en-US/agent.ts @@ -130,6 +130,10 @@ export const agent = { }, chat: { noMessages: 'No messages yet. Start the conversation!', + expandFull: 'Expand full answer', + collapseFull: 'Collapse answer', + collapse: 'Collapse', + longAnswer: 'Expand to view the full answer.', }, suggestions: { title: 'Try asking', diff --git a/packages/i18n/src/locales/zh-CN/agent.ts b/packages/i18n/src/locales/zh-CN/agent.ts index c4658a1..fc0c35d 100644 --- a/packages/i18n/src/locales/zh-CN/agent.ts +++ b/packages/i18n/src/locales/zh-CN/agent.ts @@ -130,6 +130,10 @@ export const agent = { }, chat: { noMessages: '还没有消息,开始对话吧!', + expandFull: '展开全文', + collapseFull: '收起全文', + collapse: '收起', + longAnswer: '内容较长,展开查看完整回答。', }, suggestions: { title: '试试问', diff --git a/packages/ui/src/atoms/runAtoms.test.ts b/packages/ui/src/atoms/runAtoms.test.ts index 2877aaa..38cc9cc 100644 --- a/packages/ui/src/atoms/runAtoms.test.ts +++ b/packages/ui/src/atoms/runAtoms.test.ts @@ -3,6 +3,7 @@ import { createStore } from 'jotai'; import type { AgentEvent } from '@finagent/core'; import { activeSessionIdAtom, + answerExpansionAtomFamily, messagesAtomFamily, } from './sessionAtoms'; import { applyAgentEventAtom, runViewAtom } from './runAtoms'; @@ -34,6 +35,18 @@ function runStarted(sessionId: string): AgentEvent { } describe('agent event projection', () => { + it('keeps a just-completed answer expanded instead of folding when streaming ends', () => { + const store = createStore(); + store.set(activeSessionIdAtom, 'visible-session'); + store.set(applyAgentEventAtom, runStarted('visible-session')); + store.set(applyAgentEventAtom, { + id: 'completion', sessionId: 'visible-session', runId: 'run-visible-session', + type: 'run_completed', timestamp: 2, sequence: 2, + payload: { answer: 'A long answer.', toolCalls: [] }, + } as AgentEvent); + expect(store.get(answerExpansionAtomFamily('visible-session'))['assistant-run-visible-session']).toBe(true); + expect(store.get(answerExpansionAtomFamily('other-session'))).toEqual({}); + }); it('keeps internal sessions out of the visible copilot', () => { const store = createStore(); store.set(activeSessionIdAtom, 'visible-session'); diff --git a/packages/ui/src/atoms/runAtoms.ts b/packages/ui/src/atoms/runAtoms.ts index 856e142..8586cfd 100644 --- a/packages/ui/src/atoms/runAtoms.ts +++ b/packages/ui/src/atoms/runAtoms.ts @@ -2,7 +2,7 @@ import { atom } from 'jotai'; import type { AgentEvent, ApiError, Message, ToolCall, WorkspaceContext } from '@finagent/core'; import { isRuntimeInfraCode } from '@finagent/core'; import type { FinagentClient } from '../client'; -import { activeSessionIdAtom, messagesAtomFamily, sessionsAtom } from './sessionAtoms'; +import { activeSessionIdAtom, answerExpansionAtomFamily, messagesAtomFamily, sessionsAtom } from './sessionAtoms'; /** Live view of the currently executing run, streamed from kernel events. */ export interface RunView { @@ -177,6 +177,7 @@ export const applyAgentEventAtom = atom( })), }; set(messages, [...get(messages), assistantMessage]); + set(answerExpansionAtomFamily(sessionId), (choices) => ({ ...choices, [assistantMessage.id]: true })); set(sessionsAtom, (sessions) => sessions.map((session) => session.id === sessionId ? { ...session, status: 'idle' as const, messageCount: session.messageCount + 1 } : session )); @@ -240,6 +241,7 @@ export const applyAgentEventAtom = atom( })), }; set(messages, [...get(messages), assistantMessage]); + set(answerExpansionAtomFamily(sessionId), (choices) => ({ ...choices, [assistantMessage.id]: true })); set(sessionsAtom, (sessions) => sessions.map((session) => session.id === sessionId ? { ...session, status: 'idle' as const, messageCount: session.messageCount + 1 } diff --git a/packages/ui/src/atoms/sessionAtoms.test.ts b/packages/ui/src/atoms/sessionAtoms.test.ts index 5d031f4..ddddaab 100644 --- a/packages/ui/src/atoms/sessionAtoms.test.ts +++ b/packages/ui/src/atoms/sessionAtoms.test.ts @@ -5,6 +5,7 @@ import type { Run, SessionMeta } from '@finagent/core'; import { activeMessagesAtom, activeSessionIdAtom, + answerExpansionAtomFamily, createSessionAtom, hydrateSessionsAtom, loadMessagesAtom, @@ -105,6 +106,7 @@ describe('session atoms', () => { const live = { id: 'live', role: 'assistant' as const, content: 'answer', timestamp: 2 }; const saved = { ...live, id: 'saved' }; store.set(messagesAtomFamily('completion'), [user, live]); + store.set(answerExpansionAtomFamily('completion'), { live: false }); let reads = 0; client.kernel.getMessages = async () => { reads += 1; @@ -114,6 +116,7 @@ describe('session atoms', () => { await store.set(loadMessagesAtom, client, 'completion', 2); expect(reads).toBe(3); expect(store.get(messagesAtomFamily('completion'))).toEqual([user, saved]); + expect(store.get(answerExpansionAtomFamily('completion'))).toEqual({ saved: false }); }); it('does not erase a complete answer if persistence stays behind the refresh deadline', async () => { diff --git a/packages/ui/src/atoms/sessionAtoms.ts b/packages/ui/src/atoms/sessionAtoms.ts index 38a73a6..734afc8 100644 --- a/packages/ui/src/atoms/sessionAtoms.ts +++ b/packages/ui/src/atoms/sessionAtoms.ts @@ -19,6 +19,9 @@ export const activeSessionAtom = atom((get) => { // Per-session message cache, loaded lazily from the kernel. export const messagesAtomFamily = atomFamily((_sessionId: string) => atom([])); +/** Presentation only; retained across panel/session switches, never saved in messages. */ +export const answerExpansionAtomFamily = atomFamily((_sessionId: string) => atom>({})); + export const activeMessagesAtom = atom((get) => { const activeId = get(activeSessionIdAtom); if (!activeId) return []; @@ -58,6 +61,21 @@ export const loadMessagesAtom = atom( ? Math.max(minimumCount, get(messagesAtomFamily(sessionId)).length) : 0; if (result.data.length >= requiredCount) { + // Transfer choices from temporary live ids to persisted message ids. + const previous = get(messagesAtomFamily(sessionId)); + const expansion = get(answerExpansionAtomFamily(sessionId)); + const nextExpansion = { ...expansion }; + let changed = false; + result.data.forEach((message, index) => { + const old = previous[index]; + if (old && old.id !== message.id && old.role === 'assistant' && message.role === 'assistant' + && old.content === message.content && Object.hasOwn(expansion, old.id)) { + nextExpansion[message.id] = expansion[old.id]; + delete nextExpansion[old.id]; + changed = true; + } + }); + if (changed) set(answerExpansionAtomFamily(sessionId), nextExpansion); set(messagesAtomFamily(sessionId), result.data); set(loadedSessionIdsAtom, (loaded) => new Set([...loaded, sessionId])); return; @@ -86,6 +104,7 @@ export const deleteSessionAtom = atom( if (!result.ok) return; set(sessionsAtom, (sessions) => sessions.filter((session) => session.id !== sessionId)); messagesAtomFamily.remove(sessionId); + answerExpansionAtomFamily.remove(sessionId); if (_get(activeSessionIdAtom) === sessionId) { const remaining = _get(sessionsAtom); set(activeSessionIdAtom, remaining.length > 0 ? remaining[0].id : null); diff --git a/packages/ui/src/components/agent/AgentPanel.tsx b/packages/ui/src/components/agent/AgentPanel.tsx index 096b25d..e457d28 100644 --- a/packages/ui/src/components/agent/AgentPanel.tsx +++ b/packages/ui/src/components/agent/AgentPanel.tsx @@ -148,6 +148,7 @@ export const AgentPanel: React.FC = () => { setInput(''); setSendError(null); + shouldAutoScrollRef.current = true; // V9.1 §2: capture the ACTUAL context this live run starts with so the // run footer's trace can show it as 'Live' — never guessed later. setLastRun((previous) => ({ @@ -259,6 +260,11 @@ export const AgentPanel: React.FC = () => {
{ + if (event.target instanceof Element && event.target.closest('[data-answer-toggle]')) { + shouldAutoScrollRef.current = false; + } + }} className="trail-agent-body min-h-0 flex-1 overflow-y-auto scrollbar-hover" >
@@ -299,7 +305,7 @@ export const AgentPanel: React.FC = () => { )}
)} - + {isRunning && } = ({ className = '' }) ); /** Shared by live and persisted answers: one identity per assistant turn. */ -export const AssistantIdentity: React.FC<{ timestamp?: number; streaming?: boolean }> = ({ timestamp, streaming }) => { +export const AssistantIdentity: React.FC<{ timestamp?: number; streaming?: boolean; action?: React.ReactNode }> = ({ timestamp, streaming, action }) => { const { t } = useTranslation(); return (
@@ -16,6 +16,7 @@ export const AssistantIdentity: React.FC<{ timestamp?: number; streaming?: boole {t('agent.panel.assistantName')} {timestamp != null && } {streaming && {t('agent.panel.thinking')}} + {action}
); }; diff --git a/packages/ui/src/components/chat/AnswerCollapse.test.tsx b/packages/ui/src/components/chat/AnswerCollapse.test.tsx new file mode 100644 index 0000000..61d1c82 --- /dev/null +++ b/packages/ui/src/components/chat/AnswerCollapse.test.tsx @@ -0,0 +1,112 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { createStore, Provider } from 'jotai'; +import type { Message } from '@finagent/core'; +import { installHappyDom } from '../../test/setupHappyDom'; +import { I18nextProvider, makeTestI18n } from '../../test/i18nTest'; +import { TurnCard } from './TurnCard'; +import { answerExpansionAtomFamily } from '../../atoms/sessionAtoms'; + +const content = '## Title\n\nIntroduction.\n\n## Details\n\n| Metric | Value |\n| --- | --- |\n| Revenue | 10 |\n\nTail [Hidden link](https://example.com).'; +const message: Message = { id: 'long', role: 'assistant', content, timestamp: 0, + toolCalls: [{ id: 'quote', toolName: 'get_quote', args: {}, status: 'success', startedAt: 0, completedAt: 1 }] }; +let restoreDom: (() => void) | undefined; +let root: Root | undefined; +let rectSpy: ReturnType; +let short = false; + +function rect(top: number, height: number): DOMRect { + return { top, bottom: top + height, height, width: 400, left: 0, right: 400, x: 0, y: top, toJSON: () => ({}) }; +} + +beforeAll(() => { restoreDom = installHappyDom().restore; }); +afterAll(() => restoreDom?.()); +beforeEach(() => { + short = false; + // Happy DOM has no layout. These boxes represent a title, complete paragraph, + // new section, table and link; real sizes/resize/scroll are checked in Electron. + rectSpy = spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.classList.contains('trail-answer-content') || this.classList.contains('markdown-content')) return rect(0, short ? 100 : 650); + if (this.tagName === 'H2') return rect(this.textContent === 'Title' ? 0 : 220, 28); + if (this.tagName === 'P') return this.textContent?.startsWith('Introduction') ? rect(44, 144) : rect(540, 80); + if (this.hasAttribute('data-citation-id')) return rect(630, 12); + if (this.querySelector('table')) return rect(270, 230); + return rect(0, 0); + }); +}); +afterEach(async () => { await act(async () => root?.unmount()); root = undefined; rectSpy.mockRestore(); }); + +async function render(messages: Message[] = [message], store = createStore(), sessionId = 'session') { + const container = document.createElement('div'); + root = createRoot(container); + await act(async () => root!.render( + + {messages.map((item) => )} + + )); + return { container, store }; +} + +describe('answer collapse', () => { + it('previews complete paragraphs, moves the entire table below the fold and keeps sources accessible', async () => { + const { container } = await render(); + const toggle = container.querySelector('[data-testid="answer-toggle"]')!; + expect(toggle.textContent).toBe('展开全文'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(container.querySelector('.trail-answer-preview')?.style.maxHeight).toBe('188px'); + expect(container.querySelector('table')?.closest('[inert]')).not.toBeNull(); + expect(container.querySelector('a')?.closest('[inert]')).not.toBeNull(); + expect(container.querySelector('[data-testid="open-source-inspector"]')?.closest('[inert]')).toBeNull(); + await act(async () => toggle.click()); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(container.querySelector('[inert]')).toBeNull(); + expect(container.querySelector('.trail-answer-collapse-top')).not.toBeNull(); + await act(async () => container.querySelector('.trail-answer-collapse-top')!.click()); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + }); + + it('remembers each message independently and restores its choice after the panel remounts', async () => { + const { container, store } = await render([message, { ...message, id: 'second' }]); + const toggles = container.querySelectorAll('[data-testid="answer-toggle"]'); + await act(async () => toggles[0].click()); + expect(toggles[1].getAttribute('aria-expanded')).toBe('false'); + expect(store.get(answerExpansionAtomFamily('session'))).toEqual({ long: true }); + await act(async () => root!.unmount()); + root = undefined; + const again = await render([message], store); + expect(again.container.querySelector('[data-testid="answer-toggle"]')?.getAttribute('aria-expanded')).toBe('true'); + expect(store.get(answerExpansionAtomFamily('other-session'))).toEqual({}); + }); + + it('leaves short answers and user messages without collapse controls', async () => { + short = true; + const { container } = await render([message, { ...message, id: 'user', role: 'user' }]); + expect(container.querySelector('[data-testid="answer-toggle"]')).toBeNull(); + expect(container.querySelector('[inert]')).toBeNull(); + }); + + it('remeasures when the panel changes size without losing the reader choice', async () => { + const { container } = await render(); + short = true; + await act(async () => window.dispatchEvent(new Event('resize'))); + expect(container.querySelector('[data-testid="answer-toggle"]')).toBeNull(); + short = false; + await act(async () => window.dispatchEvent(new Event('resize'))); + expect(container.querySelector('[data-testid="answer-toggle"]')?.getAttribute('aria-expanded')).toBe('false'); + }); + + it('keeps a hidden citation out of keyboard order when late evidence turns it into a button', async () => { + const pending = { ...message, content: `${content}⟦cite:quote⟧`, toolCalls: undefined }; + const { container, store } = await render([pending]); + expect(container.querySelector('[data-citation-id="quote"]')?.tagName).toBe('SUP'); + await act(async () => root!.render( + + + + )); + const citation = container.querySelector('[data-citation-id="quote"]'); + expect(citation?.tagName).toBe('BUTTON'); + expect(citation?.closest('[inert]')).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 65f04c7..3000e2d 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -5,15 +5,16 @@ import { TurnCard } from './TurnCard'; interface MessageListProps { messages: Message[]; isLoading: boolean; + sessionId?: string; } -export const MessageList: React.FC = ({ messages }) => { +export const MessageList: React.FC = ({ messages, sessionId = '' }) => { return (
{/* Inside the 400px Copilot panel the shell already pads the body; no max-width rail. */}
{messages.map((message) => ( - + ))}
diff --git a/packages/ui/src/components/chat/TurnCard.tsx b/packages/ui/src/components/chat/TurnCard.tsx index a546aa6..57de3bd 100644 --- a/packages/ui/src/components/chat/TurnCard.tsx +++ b/packages/ui/src/components/chat/TurnCard.tsx @@ -1,23 +1,41 @@ import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { BookOpen } from 'lucide-react'; +import { BookOpen, ChevronDown, ChevronUp } from 'lucide-react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { selectAtom } from 'jotai/utils'; import type { Message } from '@finagent/core'; import { AnswerContent } from './AnswerContent'; import { SourceInspector } from './SourceInspector'; import { ToolActivity } from '../agent/ToolActivity'; import { collectCitationSources } from '../../lib/citations'; import { AssistantIdentity } from '../brand/TrailIdentity'; +import { answerExpansionAtomFamily } from '../../atoms/sessionAtoms'; +import { useAnswerCollapse } from './useAnswerCollapse'; interface TurnCardProps { message: Message; + sessionId?: string; } -export const TurnCard: React.FC = ({ message }) => { +export const TurnCard: React.FC = ({ message, sessionId = '' }) => { const { t } = useTranslation(); const isUser = message.role === 'user'; const isTool = message.role === 'tool'; const toolCalls = message.toolCalls ?? []; const isAssistant = !isUser && !isTool; + const expansionAtom = answerExpansionAtomFamily(sessionId); + const selectedExpansionAtom = useMemo( + () => selectAtom(expansionAtom, (choices) => choices[message.id] ?? false), + [expansionAtom, message.id] + ); + const expanded = useAtomValue(selectedExpansionAtom); + const setExpansion = useSetAtom(expansionAtom); + const collapse = useAnswerCollapse({ + message, + enabled: isAssistant, + expanded, + onExpandedChange: (value) => setExpansion((choices) => ({ ...choices, [message.id]: value })), + }); // provenance surface for this assistant turn. const citationIndex = useMemo( @@ -33,6 +51,8 @@ export const TurnCard: React.FC = ({ message }) => { return (
= ({ message }) => { : 'mac-message-assistant rounded-bl-[4px] text-foreground' }`} > - {isAssistant && } + {isAssistant && +