Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/screenshots/answer-collapse-2026-10-24/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# 长回答展开与收起

2026-10-24,Windows / Bun 1.4.2 / Electron 39。截图来自隔离用户目录中的实际 Electron 渲染器,由仓库安装的 Playwright 驱动;隐藏窗口使用 Electron `capturePage` 截图。

回答、引用与数值均为固定交互验证样例,不代表实时行情、真实模型输出或投资判断。没有发布用户会话、凭据或实际账户数据。

- [收起状态](collapsed.png):正文保留开头预览,展开按钮和来源入口独立可用。
- [展开状态](expanded.png):完整正文恢复,回答身份行显示收起入口。

本次验证覆盖独立展开状态、段落与表格边界、来源弹窗、键盘操作、滚动位置、320px 面板,以及流式事件结束后加载保存记录时的展开状态。流式部分使用固定事件样例,不是实时模型测试;没有重新发布安装程序。
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/en-US/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/zh-CN/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ export const agent = {
},
chat: {
noMessages: '还没有消息,开始对话吧!',
expandFull: '展开全文',
collapseFull: '收起全文',
collapse: '收起',
longAnswer: '内容较长,展开查看完整回答。',
},
suggestions: {
title: '试试问',
Expand Down
13 changes: 13 additions & 0 deletions packages/ui/src/atoms/runAtoms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/atoms/runAtoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
));
Expand Down Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/atoms/sessionAtoms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Run, SessionMeta } from '@finagent/core';
import {
activeMessagesAtom,
activeSessionIdAtom,
answerExpansionAtomFamily,
createSessionAtom,
hydrateSessionsAtom,
loadMessagesAtom,
Expand Down Expand Up @@ -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;
Expand All @@ -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 () => {
Expand Down
19 changes: 19 additions & 0 deletions packages/ui/src/atoms/sessionAtoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Message[]>([]));

/** Presentation only; retained across panel/session switches, never saved in messages. */
export const answerExpansionAtomFamily = atomFamily((_sessionId: string) => atom<Record<string, boolean>>({}));

export const activeMessagesAtom = atom((get) => {
const activeId = get(activeSessionIdAtom);
if (!activeId) return [];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion packages/ui/src/components/agent/AgentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -259,6 +260,11 @@ export const AgentPanel: React.FC = () => {
<div
ref={bodyRef}
onScroll={handleBodyScroll}
onClickCapture={(event) => {
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"
>
<div className="trail-agent-body-content flex min-h-full flex-col gap-3 p-3">
Expand Down Expand Up @@ -299,7 +305,7 @@ export const AgentPanel: React.FC = () => {
)}
</div>
)}
<MessageList messages={messages} isLoading={isRunning} />
<MessageList messages={messages} isLoading={isRunning} sessionId={activeSessionId ?? ''} />
{isRunning && <StreamingBlock answer={runView?.answer ?? ''} />}
<RunFooter
lastRun={lastRun}
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/components/brand/TrailIdentity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ export const TrailLogo: React.FC<{ className?: string }> = ({ 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 (
<div className="trail-answer-identity" data-testid="assistant-identity">
<TrailLogo />
<span className="trail-answer-name">{t('agent.panel.assistantName')}</span>
{timestamp != null && <time dateTime={new Date(timestamp).toISOString()}>{new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</time>}
{streaming && <span className="trail-answer-live">{t('agent.panel.thinking')}</span>}
{action}
</div>
);
};
112 changes: 112 additions & 0 deletions packages/ui/src/components/chat/AnswerCollapse.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof spyOn>;
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(
<Provider store={store}><I18nextProvider i18n={makeTestI18n('zh-CN')}>
{messages.map((item) => <TurnCard key={item.id} message={item} sessionId={sessionId} />)}
</I18nextProvider></Provider>
));
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<HTMLButtonElement>('[data-testid="answer-toggle"]')!;
expect(toggle.textContent).toBe('展开全文');
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(container.querySelector<HTMLElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('[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(
<Provider store={store}><I18nextProvider i18n={makeTestI18n('zh-CN')}>
<TurnCard key={pending.id} message={{ ...pending, toolCalls: message.toolCalls }} sessionId="session" />
</I18nextProvider></Provider>
));
const citation = container.querySelector('[data-citation-id="quote"]');
expect(citation?.tagName).toBe('BUTTON');
expect(citation?.closest('[inert]')).not.toBeNull();
});
});
5 changes: 3 additions & 2 deletions packages/ui/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import { TurnCard } from './TurnCard';
interface MessageListProps {
messages: Message[];
isLoading: boolean;
sessionId?: string;
}

export const MessageList: React.FC<MessageListProps> = ({ messages }) => {
export const MessageList: React.FC<MessageListProps> = ({ messages, sessionId = '' }) => {
return (
<div className="trail-message-list min-w-0 px-0.5 py-0">
{/* Inside the 400px Copilot panel the shell already pads the body; no max-width rail. */}
<div className="max-w-none space-y-3">
{messages.map((message) => (
<TurnCard key={message.id} message={message} />
<TurnCard key={`${sessionId}:${message.id}`} message={message} sessionId={sessionId} />
))}
</div>
</div>
Expand Down
Loading
Loading