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
5 changes: 5 additions & 0 deletions .changeset/calm-sessions-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge-ui': patch
---

Add an action to restore the recent 30-day session list from a timestamp-pinned session.
21 changes: 18 additions & 3 deletions packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ function entrySourceType(entry: SessionListEntry): 'schedule' | undefined {
return 'sourceType' in entry && Reflect.get(entry, 'sourceType') === 'schedule' ? 'schedule' : undefined;
}

export function AgentSessions({ agentId, startTimestamp, endTimestamp, shareView }: AgentSessionsProps) {
export function AgentSessions({
agentId,
startTimestamp,
endTimestamp,
shareView,
onLoadRecentSessions,
}: AgentSessionsProps) {
const sessionsServer = useAgentSessionsServer();
const chatServer = useServer();
const toaster = useToasterOptional();
Expand Down Expand Up @@ -266,14 +272,16 @@ export function AgentSessions({ agentId, startTimestamp, endTimestamp, shareView

const resumeProps =
resumeHref != null ? { resumeHref, resumeLabel } : shell != null ? { onResume: handleResume, resumeLabel } : {};
const selectedCreatedAt = detailSession?.createdAt ?? selectedEntry?.createdAt;
Comment thread
harshil-2096 marked this conversation as resolved.

// Full empty only when nothing is selected — keep the detail pane for deep-linked sessionIds
// (filters/time range can empty the list while share state still points at a session).
if (
!listLoading &&
!listFailed &&
entries.length === 0 &&
(selectedSessionId == null || selectedSessionId.length === 0)
(selectedSessionId == null || selectedSessionId.length === 0) &&
onLoadRecentSessions == null
) {
return (
<EmptyScreen
Expand All @@ -293,6 +301,13 @@ export function AgentSessions({ agentId, startTimestamp, endTimestamp, shareView
>
<Panel id="agent-sessions-list" defaultSize="35%" minSize="20%" maxSize="50%">
<aside className="flex h-full min-h-0 w-full flex-col bg-sidebar-bg">
{onLoadRecentSessions != null ? (
<div className="flex shrink-0 justify-center border-b border-border p-3">
<Button.Secondary type="button" size="small" onClick={onLoadRecentSessions}>
Load recent sessions
</Button.Secondary>
</div>
) : null}
<div ref={setListEl} className="scrollbar-none min-h-0 flex-1 overflow-y-auto">
{listLoading ? (
<div className="space-y-2 p-3" role="status" aria-label="Loading sessions">
Expand Down Expand Up @@ -379,7 +394,7 @@ export function AgentSessions({ agentId, startTimestamp, endTimestamp, shareView
title={selectedTitle}
sessionId={selectedSessionId}
agentId={agentId}
createdAt={detailSession?.createdAt ?? selectedEntry?.createdAt}
createdAt={selectedCreatedAt}
view={shareView}
onClose={clearSelectedSession}
canResume={canResume}
Expand Down
12 changes: 11 additions & 1 deletion packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { Suspense, useEffect, useMemo, useState } from 'react';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';

import { useSessionShareSearch } from '../../hooks/useSessionShareSearch.js';
import { useOptionalAgentSessionsServer } from '../../server/ServerContext.js';
Expand All @@ -9,6 +9,7 @@ import {
defaultSessionTimeRange,
readSessionShareSearch,
resolveSessionTimeRange,
SESSION_TIME_BUFFER_MS,
type SessionTimeRange,
} from '../../utils/sessionShareUrl.js';
import { PageHeader } from '../PageHeader.js';
Expand Down Expand Up @@ -50,6 +51,14 @@ export function SessionsPage() {
// Resolve relative presets only when the filter changes. Unrelated query
// updates (such as selecting a session) must not shift/refetch the list.
const resolved = useMemo(() => resolveSessionTimeRange(timeRange), [timeRange]);
const timeRangeDurationMs = timeRange.endTs - timeRange.startTs;
const showLoadRecentSessions =
timeRange.timeWindowMs == null && timeRangeDurationMs > 0 && timeRangeDurationMs <= 2 * SESSION_TIME_BUFFER_MS;
const loadRecentSessions = useCallback(() => {
const recentRange = defaultSessionTimeRange();
setTimeRange(recentRange);
updateShareSearch({ timeRange: recentRange, sessionId: null, view: 'sessions' });
}, [updateShareSearch]);

return (
<div className="flex h-full min-h-0 w-full flex-col bg-primary-bg">
Expand Down Expand Up @@ -86,6 +95,7 @@ export function SessionsPage() {
startTimestamp={new Date(resolved.startTs).toISOString()}
endTimestamp={new Date(resolved.endTs).toISOString()}
shareView="sessions"
{...(showLoadRecentSessions ? { onLoadRecentSessions: loadRecentSessions } : {})}
/>
</Suspense>
)}
Expand Down
2 changes: 2 additions & 0 deletions packages/trueforge-ui/src/atoms/agent-details/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type AgentSessionsProps = {
endTimestamp?: string;
/** When `sessions`, selection writes `view=sessions` and pins `s_sts`/`s_ets`. */
shareView?: 'sessions' | null;
/** Restores the rolling recent-session window from a URL-loaded session. */
onLoadRecentSessions?: () => void;
};

export type AgentSessionListRowProps = {
Expand Down
78 changes: 78 additions & 0 deletions packages/trueforge-ui/test/atoms/SessionsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,33 @@ describe('SessionsPage', () => {
expect(screen.queryByRole('separator', { name: 'Resize session list' })).not.toBeInTheDocument();
});

it('keeps the recent-sessions action when a timestamp-pinned range is empty', async () => {
const now = Date.parse('2026-01-31T00:10:00.000Z');
vi.spyOn(Date, 'now').mockReturnValue(now);
const createdAtMs = Date.parse(namedRow.createdAt);
window.history.replaceState(
null,
'',
`/?view=sessions&s_sts=${String(createdAtMs - SESSION_TIME_BUFFER_MS)}&s_ets=${String(createdAtMs + SESSION_TIME_BUFFER_MS)}`,
);
const listSessions = vi.fn(async () => ({ data: [] as SessionListEntry[] }));
renderPage({ listSessions });

await waitFor(() => {
expect(screen.queryByRole('status', { name: 'Loading sessions' })).not.toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Load recent sessions' }));

await waitFor(() => {
expect(listSessions).toHaveBeenLastCalledWith(
expect.objectContaining({
startTimestamp: new Date(now - DEFAULT_SESSION_TIME_WINDOW_MS).toISOString(),
endTimestamp: new Date(now).toISOString(),
}),
);
});
});

it('keeps session detail visible when the list is empty but a sessionId is selected', async () => {
window.history.replaceState(null, '', '/?view=sessions&sessionId=sess-1');
const getSession = vi.fn(async (): Promise<Session> => ({
Expand Down Expand Up @@ -241,6 +268,57 @@ describe('SessionsPage', () => {
expect(resizer).toHaveClass('w-0');
expect(resizer.querySelector('.w-px')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Named session' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Load recent sessions' })).not.toBeInTheDocument();
});

it('loads the recent 30-day window from a timestamp-pinned session and clears its selection', async () => {
const now = Date.parse('2026-01-31T00:10:00.000Z');
vi.spyOn(Date, 'now').mockReturnValue(now);
const createdAtMs = Date.parse(namedRow.createdAt);
window.history.replaceState(
null,
'',
`/?view=sessions&sessionId=sess-1&s_sts=${String(createdAtMs - SESSION_TIME_BUFFER_MS)}&s_ets=${String(createdAtMs + SESSION_TIME_BUFFER_MS)}`,
);
const { listSessions } = renderPage();

const loadRecentButton = await screen.findByRole('button', { name: 'Load recent sessions' });
expect(loadRecentButton.closest('aside')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Close session details' }));
expect(screen.getByRole('button', { name: 'Load recent sessions' })).toBeInTheDocument();
fireEvent.click(loadRecentButton);

await waitFor(() => {
expect(listSessions).toHaveBeenLastCalledWith(
expect.objectContaining({
startTimestamp: new Date(now - DEFAULT_SESSION_TIME_WINDOW_MS).toISOString(),
endTimestamp: new Date(now).toISOString(),
}),
);
});
const params = new URLSearchParams(window.location.search);
expect(params.get('sessionId')).toBeNull();
expect(params.get('s_tw')).toBe(String(DEFAULT_SESSION_TIME_WINDOW_MS));
expect(params.get('s_sts')).toBeNull();
expect(params.get('s_ets')).toBeNull();
expect(screen.getByText('Select a session to view details')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Load recent sessions' })).not.toBeInTheDocument();
});

it('keeps the recent-sessions action while the narrow list range remains active', async () => {
const createdAtMs = Date.parse(namedRow.createdAt);
window.history.replaceState(
null,
'',
`/?view=sessions&sessionId=sess-1&s_sts=${String(createdAtMs - SESSION_TIME_BUFFER_MS)}&s_ets=${String(createdAtMs + SESSION_TIME_BUFFER_MS)}`,
);
renderPage();

expect(await screen.findByRole('button', { name: 'Load recent sessions' })).toBeInTheDocument();
const row = screen.getByText('Draft session');
fireEvent.click(row.closest('button') ?? row);

expect(screen.getByRole('button', { name: 'Load recent sessions' })).toBeInTheDocument();
});

it('deletes only after the confirmation dialog is accepted', async () => {
Expand Down
Loading