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
7 changes: 4 additions & 3 deletions src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ use bitfun_core::service::session::{DialogTurnData, SessionRelationship};

const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024;
const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024;
const SESSION_VIEW_TRUNCATED_MARKER: &str = "\n...[truncated for session view]";
const SESSION_VIEW_OMITTED_MARKER: &str = "[truncated for session view]";
const SESSION_VIEW_TRUNCATED_MARKER: &str = "\n... Output truncated for session preview";
const SESSION_VIEW_OMITTED_MARKER: &str = "Output omitted from session preview";

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -1863,7 +1863,8 @@ mod tests {
.as_str()
.expect("output should remain a visible string preview");
assert!(output.len() < 80 * 1024);
assert!(output.contains("truncated for session view"));
assert!(!output.contains("[truncated for session view]"));
assert!(output.contains("Output truncated for session preview"));
assert_eq!(tool_result.result["exit_code"], 0);
assert_eq!(tool_result.result_for_assistant, None);
}
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/flow_chat/components/CopyOutputButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createMarkdownEditorTab } from '@/shared/utils/tabUtils';
import { Tooltip } from '@/component-library';
import { i18nService } from '@/infrastructure/i18n';
import { createLogger } from '@/shared/utils/logger';
import { formatSessionViewPreviewText } from '../utils/sessionViewPreview';
import './CopyOutputButton.css';

const log = createLogger('CopyOutputButton');
Expand Down Expand Up @@ -65,7 +66,7 @@ export const CopyOutputButton: React.FC<CopyOutputButtonProps> = ({
const resultStr = typeof toolItem.toolResult.result === 'string'
? toolItem.toolResult.result
: JSON.stringify(toolItem.toolResult.result, null, 2);
toolContent += `\n[Result]\n\`\`\`\n${resultStr}\n\`\`\`\n`;
toolContent += `\n[Result]\n\`\`\`\n${formatSessionViewPreviewText(resultStr)}\n\`\`\`\n`;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@
margin-top: var(--flowchat-inline-gap, 0.35rem);
}

.model-round-item__history-loader {
color: var(--color-text-muted, #999);
font-size: 12px;
line-height: 18px;
padding: var(--flowchat-inline-gap, 0.35rem) 0;
}

.model-round-item__action-btn {
display: flex;
align-items: center;
Expand Down
84 changes: 81 additions & 3 deletions src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,16 @@ import { taskCollapseStateManager } from '../../store/TaskCollapseStateManager';
import { ExportImageButton } from './ExportImageButton';
import { ForkSessionButton } from './ForkSessionButton';
import { buildModelRoundItemGroups, COMPLETED_TOOL_TRANSIENT_MS } from './modelRoundItemGrouping';
import {
MODEL_ROUND_GROUP_RENDER_CHUNK_DELAY_MS,
getInitialModelRoundGroupRenderCount,
getNextModelRoundGroupRenderCount,
getSynchronizedModelRoundGroupRenderCount,
} from './modelRoundProgressiveRender';
import { Tooltip } from '@/component-library';
import { createLogger } from '@/shared/utils/logger';
import { SubagentProjectionView } from '../subagent/SubagentProjectionView';
import { formatSessionViewPreviewText } from '../../utils/sessionViewPreview';
import './ModelRoundItem.scss';
import './SubagentItems.scss';

Expand Down Expand Up @@ -169,6 +176,71 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
});
}, [round.isStreaming, round.renderHints?.disableExploreGrouping, sortedItems, transientNowMs]);

const initialGroupRenderCount = useMemo(() => (
getInitialModelRoundGroupRenderCount({
groupCount: groupedItems.length,
isStreaming: round.isStreaming,
})
), [groupedItems.length, round.isStreaming]);

const [renderedGroupState, setRenderedGroupState] = useState(() => ({
roundId: round.id,
count: initialGroupRenderCount,
}));

useEffect(() => {
setRenderedGroupState((current) => {
if (current.roundId !== round.id) {
return { roundId: round.id, count: initialGroupRenderCount };
}

const nextCount = getSynchronizedModelRoundGroupRenderCount({
currentCount: current.count,
groupCount: groupedItems.length,
initialCount: initialGroupRenderCount,
isStreaming: round.isStreaming,
});

return current.count === nextCount
? current
: { roundId: round.id, count: nextCount };
});
}, [groupedItems.length, initialGroupRenderCount, round.id, round.isStreaming]);

const renderedGroupCount = renderedGroupState.roundId === round.id
? renderedGroupState.count
: initialGroupRenderCount;

useEffect(() => {
if (round.isStreaming || renderedGroupCount >= groupedItems.length) {
return;
}

const timeoutId = window.setTimeout(() => {
setRenderedGroupState((current) => {
if (current.roundId !== round.id) {
return current;
}

return {
roundId: round.id,
count: getNextModelRoundGroupRenderCount({
currentCount: current.count,
groupCount: groupedItems.length,
}),
};
});
}, MODEL_ROUND_GROUP_RENDER_CHUNK_DELAY_MS);

return () => window.clearTimeout(timeoutId);
}, [groupedItems.length, renderedGroupCount, round.id, round.isStreaming]);

const visibleGroupedItems = useMemo(
() => groupedItems.slice(0, renderedGroupCount),
[groupedItems, renderedGroupCount],
);
const hasDeferredGroups = renderedGroupCount < groupedItems.length;

const extractDialogTurnContent = useCallback(() => {
const flowChatStore = FlowChatStore.getInstance();
const state = flowChatStore.getState();
Expand Down Expand Up @@ -218,7 +290,7 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
const resultStr = typeof item.toolResult.result === 'string'
? item.toolResult.result
: JSON.stringify(item.toolResult.result, null, 2);
toolContent += `\n[Result]\n\`\`\`\n${resultStr}\n\`\`\`\n`;
toolContent += `\n[Result]\n\`\`\`\n${formatSessionViewPreviewText(resultStr)}\n\`\`\`\n`;
}
}

Expand Down Expand Up @@ -260,8 +332,8 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
<div
className={`model-round-item model-round-item--${round.isStreaming ? 'streaming' : 'complete'}`}
>
{groupedItems.map((group, groupIndex) => {
const isLastGroup = groupIndex === groupedItems.length - 1;
{visibleGroupedItems.map((group, groupIndex) => {
const isLastGroup = !hasDeferredGroups && groupIndex === groupedItems.length - 1;
const isLast = isLastRound && isLastGroup;
switch (group.type) {
case 'explore':
Expand Down Expand Up @@ -304,6 +376,12 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
return null;
}
})}

{hasDeferredGroups && (
<div className="model-round-item__history-loader">
{t('modelRound.loadingMoreHistory', { defaultValue: 'Loading more history...' })}
</div>
)}

{isTurnComplete && isLastRound && hasContent && !round.isStreaming && (
<div className="model-round-item__footer">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';

import {
MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE,
MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT,
getInitialModelRoundGroupRenderCount,
getNextModelRoundGroupRenderCount,
getSynchronizedModelRoundGroupRenderCount,
} from './modelRoundProgressiveRender';

describe('modelRoundProgressiveRender', () => {
it('renders completed large historical rounds in bounded initial chunks', () => {
expect(getInitialModelRoundGroupRenderCount({
groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 25,
isStreaming: false,
})).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT);
});

it('keeps streaming rounds fully rendered', () => {
expect(getInitialModelRoundGroupRenderCount({
groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 25,
isStreaming: true,
})).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 25);
});

it('advances chunked historical rendering without overshooting the group count', () => {
expect(getNextModelRoundGroupRenderCount({
currentCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT,
groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE + 5,
})).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE);

expect(getNextModelRoundGroupRenderCount({
currentCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE,
groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE + 5,
})).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE + 5);
});

it('does not shrink a fully rendered round after streaming completes', () => {
expect(getSynchronizedModelRoundGroupRenderCount({
currentCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 120,
groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 120,
initialCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT,
isStreaming: false,
})).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 120);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export const MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT = 80;
export const MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE = 80;
export const MODEL_ROUND_GROUP_RENDER_CHUNK_DELAY_MS = 16;

export function getInitialModelRoundGroupRenderCount(params: {
groupCount: number;
isStreaming: boolean;
}): number {
const { groupCount, isStreaming } = params;
if (isStreaming || groupCount <= MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT) {
return groupCount;
}

return MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT;
}

export function getNextModelRoundGroupRenderCount(params: {
currentCount: number;
groupCount: number;
}): number {
const { currentCount, groupCount } = params;
return Math.min(groupCount, currentCount + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE);
}

export function getSynchronizedModelRoundGroupRenderCount(params: {
currentCount: number;
groupCount: number;
initialCount: number;
isStreaming: boolean;
}): number {
const { currentCount, groupCount, initialCount, isStreaming } = params;
if (isStreaming) {
return groupCount;
}

return Math.min(groupCount, Math.max(currentCount, initialCount));
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getElementText, copyTextToClipboard } from '@/shared/utils/textSelectio
import { createLogger } from '@/shared/utils/logger';
import { FlowChatStore } from '../../store/FlowChatStore';
import { i18nService } from '@/infrastructure/i18n';
import { formatSessionViewPreviewText } from '../../utils/sessionViewPreview';

const log = createLogger('useFlowChatCopyDialog');

Expand Down Expand Up @@ -61,7 +62,7 @@ function extractDialogTurnContent(turnId: string): string {
const resultStr = typeof item.toolResult.result === 'string'
? item.toolResult.result
: JSON.stringify(item.toolResult.result, null, 2);
toolContent += `\n[Result]\n\`\`\`\n${resultStr}\n\`\`\`\n`;
toolContent += `\n[Result]\n\`\`\`\n${formatSessionViewPreviewText(resultStr)}\n\`\`\`\n`;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,20 +213,37 @@ vi.mock('react-i18next', () => ({
}));

vi.mock('@/component-library', () => ({
IconButton: ({
IconButton: React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string; size?: string }
>(function MockIconButton({
children,
variant: _variant,
size: _size,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string; size?: string }) => (
<button type="button" {...props}>
{children}
</button>
),
}, ref) {
return (
<button ref={ref} type="button" {...props}>
{children}
</button>
);
}),
MarkdownRenderer: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>,
Tooltip: ({ children, content }: { children: React.ReactNode; content?: React.ReactNode }) => (
<span data-tooltip={typeof content === 'string' ? content : undefined}>{children}</span>
),
Tooltip: ({ children, content }: { children: React.ReactNode; content?: React.ReactNode }) => {
const tooltipContent = typeof content === 'string' ? content : undefined;
let trigger = children;
if (React.isValidElement(children)) {
trigger = React.cloneElement(
children as React.ReactElement<{
ref?: React.Ref<HTMLElement>;
}>,
{
ref: () => undefined,
}
);
}
return <span data-tooltip={tooltipContent}>{trigger}</span>;
},
ToolProcessingDots: ({ className }: { className?: string }) => <span className={className}>...</span>,
}));

Expand Down Expand Up @@ -659,6 +676,7 @@ describe('Session usage report UI components', () => {

it('keeps chat card file names visible and labels model tokens', () => {
dom.window.localStorage.setItem(USAGE_EXPORT_REDACT_PATHS_STORAGE_KEY, 'false');
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const longPath = 'src/features/session-usage/reports/components/very/deeply/nested/UsageReportCardFilePathThatWouldNormallyOverflow.tsx';
const fileName = 'UsageReportCardFilePathThatWouldNormallyOverflow.tsx';
const report = usageReport({
Expand Down Expand Up @@ -689,19 +707,28 @@ describe('Session usage report UI components', () => {
},
});

render(
<SessionUsageReportCard
report={report}
markdown="## Session Usage"
/>
);
let refWarnings: unknown[][] = [];
try {
render(
<SessionUsageReportCard
report={report}
markdown="## Session Usage"
/>
);
refWarnings = consoleError.mock.calls.filter(([message]) =>
String(message).includes('Function components cannot be given refs')
);
} finally {
consoleError.mockRestore();
}

const fileNameLabel = container.querySelector('.session-usage-report-card__mini-list-file-name');
expect(fileNameLabel?.textContent).toBe(fileName);
expect(container.textContent).not.toContain('src/features');
expect(container.textContent).not.toContain('/.../');
expect(container.querySelector(`[data-tooltip="${longPath}"]`)).not.toBeNull();
expect(container.textContent).toContain('1,500 tokens');
expect(refWarnings).toEqual([]);
});

it('syncs path redaction between the chat card and detail panel', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ interface SessionUsageReportCardProps {
onOpenDetails?: (report: SessionUsageReport, initialTab?: SessionUsagePanelTab) => void;
}

const UsageMiniListFilePathLabel = React.forwardRef<HTMLSpanElement, { pathLabel: string }>(
function UsageMiniListFilePathLabel({ pathLabel }, ref) {
return (
<span ref={ref} className="session-usage-report-card__mini-list-file-name">
{getUsageFileNameFromPath(pathLabel)}
</span>
);
}
);

export const SessionUsageReportCard: React.FC<SessionUsageReportCardProps> = ({
report,
markdown = '',
Expand Down Expand Up @@ -465,14 +475,6 @@ function UsageMiniListLabelView({ label }: { label: UsageMiniListLabel }) {
: node;
}

function UsageMiniListFilePathLabel({ pathLabel }: { pathLabel: string }) {
return (
<span className="session-usage-report-card__mini-list-file-name">
{getUsageFileNameFromPath(pathLabel)}
</span>
);
}

function UsageFileChangeDetail({
addedLines,
deletedLines,
Expand Down
Loading
Loading