Skip to content
Draft
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/session-share-route.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge-ui": minor
---

Add detail-only session sharing routes, chat-header share links, and rename the active-chat reset action to New Chat.
7 changes: 6 additions & 1 deletion packages/trueforge-ui/docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Public override surface (primitives stay theme/CSS — not slots):
- **Thread list:** `ThreadListShell`, `ThreadListNewButton`, `ThreadListRow`,
`ThreadListRowSkeleton`, `ThreadListEmptyState`, `HistoryLoader`,
`AgentsLibrary`, `AgentsLibraryButton`, `SessionsBrowserButton`,
`SaveAgentButton`, `SelectAgentEmptyState`, `ClearChatButton`
`SaveAgentButton`, `SelectAgentEmptyState`, `ClearChatButton`, `ShareChatButton`
- **Agent details / sessions:** `AgentDetailsPage`, `AgentDetailsHeader`,
`AgentDetailsTabs`, `AgentDetailsUnavailable`, `AgentOverview`,
`AgentOverviewCard`, `AgentSessions`, `AgentSessionsFilters`, `SessionsPage`,
Expand Down Expand Up @@ -100,6 +100,7 @@ Places mirrored to the URL:
- `/agents/:agentName` — immutable "Try" of a library agent
- `/sessions` — all-user Sessions browser (named agents and drafts)
- `/sessions/:sessionId` — a specific chat session
- `/sessions/share/:sessionId` — detail-only session view without the Sessions list or filters
- `/settings` — settings overlay (closing navigates to the chat place below it)
- `/library` — Agents
- `/library/:agentId` — agent details. `?tab=overview|sessions|code|metrics` selects the tab (default Overview);
Expand All @@ -117,6 +118,7 @@ to keep that place overlay-only with no URL:
paths: {
buildAgent: '/new-agent',
session: '/chats/:sessionId',
sharedSession: '/sessions/share/:sessionId',
libraryAgent: '/library/:agentId',
settings: false,
},
Expand Down Expand Up @@ -146,6 +148,9 @@ Notes on behaviour:
(`agentId`, `s_tw` for a relative window, or `s_sts`/`s_ets` for an absolute
range). Opening a session pins `s_sts`/`s_ets` around `created_at` (±5 min)
so a refresh still finds that row on page 1 without scrolling the list.
- A detail-only shared session is `/sessions/share/:sessionId` when
`withRouter` is on. Without SDK routing, the same view uses
`?view=shared-session&sessionId=:sessionId` on the host page.
- A `/sessions/:sessionId` link is resolved through `getSession` so the chat
opens with its own agent binding and mutability rather than as a new draft.
- `/build-agent` is used for a fresh builder; after its draft session persists,
Expand Down
8 changes: 4 additions & 4 deletions packages/trueforge-ui/src/atoms/ClearChatButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useOptionalShellMode } from '../server/ShellModeContext.js';
import { useSlot } from '../theme/SlotsProvider.js';
import { auiButtonClass } from './lib/buttonClasses.js';

// Resets the current chat / draft (Try Agent, New Chat, New Agent, Edit).
// Starts a fresh chat / draft (Try Agent, New Chat, New Agent, Edit).
// Hidden while idle and on a fresh chat.
export function ClearChatButton() {
const shell = useOptionalShellMode();
Expand All @@ -21,14 +21,14 @@ export function ClearChatButton() {
<PermissionGuard allowed={canManageSession}>
<button
type="button"
title="Clear chat"
title="New Chat"
className={auiButtonClass({ variant: 'ghost', size: 'large' })}
onClick={() => {
if (canManageSession) shell.clearChat();
}}
>
<Icon name="broom" size="0.875rem" />
Clear chat
<Icon name="square-pen" size="0.875rem" />
New Chat
</button>
</PermissionGuard>
);
Expand Down
31 changes: 31 additions & 0 deletions packages/trueforge-ui/src/atoms/ShareChatButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';

import { useAuiState } from '../assistant-ui.js';
import { useCopySharedSessionLink } from '../hooks/useCopySharedSessionLink.js';
import { Icon } from '../icons/Icon.js';
import { auiButtonClass } from './lib/buttonClasses.js';

export function ShareChatButton() {
const sessionId = useAuiState(state => state.threadListItem.remoteId);
const { copied, copySharedSessionLink } = useCopySharedSessionLink(sessionId);

if (sessionId == null) return null;

return (
<button
type="button"
title="Copy shared link"
className={auiButtonClass({ variant: 'ghost', size: 'large' })}
onClick={() => void copySharedSessionLink()}
>
<Icon name="link" size="0.875rem" />
{copied ? 'Copied' : 'Share'}
</button>
);
}

declare module '../theme/SlotsProvider.js' {
interface AtomSlots {
ShareChatButton: typeof ShareChatButton;
}
}
17 changes: 13 additions & 4 deletions packages/trueforge-ui/src/atoms/ThreadShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { forwardRef, type ComponentPropsWithRef, type CSSProperties } from 'reac

import { cn } from './lib/cn.js';

const THREAD_MAX_WIDTH = '44rem';
const THREAD_CONTENT_MAX_WIDTH = `var(--thread-max-width, ${THREAD_MAX_WIDTH})`;

const THREAD_CSS_VARS: CSSProperties = {
['--thread-max-width' as string]: '44rem',
['--thread-max-width' as string]: THREAD_MAX_WIDTH,
['--composer-padding' as string]: '8px',
};

Expand All @@ -26,24 +29,30 @@ ThreadRootShell.displayName = 'ThreadRootShell';

export type ThreadViewportShellProps = ComponentPropsWithRef<'div'> & {
isEmpty?: boolean;
/** Disable the inner scroll root when an ancestor owns the combined surface scroll. */
scrollable?: boolean;
/** Override the conversation content width without changing the scroll root width. */
contentMaxWidth?: string;
};

export const ThreadViewportShell = forwardRef<HTMLDivElement, ThreadViewportShellProps>(
({ className, isEmpty, children, ...rest }, ref) => (
({ className, isEmpty, scrollable = true, contentMaxWidth, children, ...rest }, ref) => (
<div
ref={ref}
data-slot="aui_thread-viewport"
className={cn(
'relative flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overflow-y-auto overscroll-contain',
'relative flex min-h-0 min-w-0 flex-col',
scrollable ? 'flex-1 overflow-x-hidden overflow-y-auto overscroll-contain' : 'shrink-0 overflow-visible',
className,
)}
{...rest}
>
<div
className={cn(
'mx-auto flex w-full min-w-0 max-w-(--thread-max-width) flex-col px-3 pt-3',
'mx-auto flex w-full min-w-0 flex-col px-3 pt-3',
isEmpty ? 'min-h-full justify-center pb-4' : 'pb-32',
)}
style={{ maxWidth: contentMaxWidth ?? THREAD_CONTENT_MAX_WIDTH }}
>
{children}
</div>
Expand Down
101 changes: 64 additions & 37 deletions packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,18 @@ export function AgentSessions({
startTimestamp,
endTimestamp,
shareView,
detailOnly = false,
detailSessionId,
onCloseDetail,
onLoadRecentSessions,
}: AgentSessionsProps) {
const sessionsServer = useAgentSessionsServer();
const chatServer = useServer();
const toaster = useToasterOptional();
const shell = useOptionalShellMode();
const routes = useOptionalResolvedRoutes();
const { sessionId: selectedSessionId, updateShareSearch } = useSessionShareSearch();
const { sessionId: querySessionId, updateShareSearch } = useSessionShareSearch();
const selectedSessionId = detailOnly ? (detailSessionId ?? null) : querySessionId;

const AgentSessionListRow = useSlot('AgentSessionListRow');
const AgentSessionDetailHeader = useSlot('AgentSessionDetailHeader');
Expand Down Expand Up @@ -103,6 +107,16 @@ export function AgentSessions({
let cancelled = false;
loadMoreInflightRef.current = false;
setNextPageToken(undefined);
if (detailOnly) {
setEntries([]);
setListLoading(false);
setListLoadingMore(false);
setListLoadMoreFailed(false);
setListFailed(false);
return () => {
cancelled = true;
};
}
setListLoading(true);
setListLoadingMore(false);
setListLoadMoreFailed(false);
Expand All @@ -127,7 +141,7 @@ export function AgentSessions({
return () => {
cancelled = true;
};
}, [listRequest, sessionsServer]);
}, [detailOnly, listRequest, sessionsServer]);

const loadMore = useCallback(async () => {
// A ref, not `listLoadingMore`: the observer can fire twice before a re-render.
Expand Down Expand Up @@ -220,6 +234,10 @@ export function AgentSessions({

const clearSelectedSession = () => {
if (selectedSessionId == null) return;
if (detailOnly) {
onCloseDetail?.();
return;
}
updateShareSearch({ sessionId: null });
};

Expand Down Expand Up @@ -274,6 +292,49 @@ export function AgentSessions({
resumeHref != null ? { resumeHref, resumeLabel } : shell != null ? { onResume: handleResume, resumeLabel } : {};
const selectedCreatedAt = detailSession?.createdAt ?? selectedEntry?.createdAt;

const detailPanel = (
<section className="flex h-full min-w-0 flex-col bg-primary-bg">
{selectedSessionId == null ? (
<div className="flex flex-1 items-center justify-center px-6 text-sm text-text-secondary">
Select a session to view details
</div>
) : detailFailed ? (
<div className="flex flex-1 items-center justify-center px-6 text-sm text-text-secondary">
Session details could not be loaded.
</div>
) : (
<>
<AgentSessionDetailHeader
title={selectedTitle}
sessionId={selectedSessionId}
agentId={agentId}
createdAt={selectedCreatedAt}
view={shareView}
onClose={clearSelectedSession}
canResume={canResume}
{...resumeProps}
/>
{detailLoading || detailEvents === undefined ? (
<div className="flex flex-1 flex-col p-4" role="status" aria-label="Loading session details">
<Skeleton className="min-h-64 flex-1 rounded-lg" />
</div>
) : (
<AgentSessionTimelineContainer
sessionId={selectedSessionId}
events={detailEvents}
listMetrics={selectedEntry?.metrics}
{...(detailOnly ? { contentMaxWidth: '60rem' } : {})}
/>
)}
</>
)}
</section>
);

if (detailOnly) {
return <div className="h-full min-h-0 w-full">{detailPanel}</div>;
}

// 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 (
Expand Down Expand Up @@ -379,41 +440,7 @@ export function AgentSessions({
</Separator>

<Panel id="agent-session-detail" defaultSize="65%" minSize="30%">
<section className="flex h-full min-w-0 flex-col bg-primary-bg">
{selectedSessionId == null ? (
<div className="flex flex-1 items-center justify-center px-6 text-sm text-text-secondary">
Select a session to view details
</div>
) : detailFailed ? (
<div className="flex flex-1 items-center justify-center px-6 text-sm text-text-secondary">
Session details could not be loaded.
</div>
) : (
<>
<AgentSessionDetailHeader
title={selectedTitle}
sessionId={selectedSessionId}
agentId={agentId}
createdAt={selectedCreatedAt}
view={shareView}
onClose={clearSelectedSession}
canResume={canResume}
{...resumeProps}
/>
{detailLoading || detailEvents === undefined ? (
<div className="flex flex-1 flex-col p-4" role="status" aria-label="Loading session details">
<Skeleton className="min-h-64 flex-1 rounded-lg" />
</div>
) : (
<AgentSessionTimelineContainer
sessionId={selectedSessionId}
events={detailEvents}
listMetrics={selectedEntry?.metrics}
/>
)}
</>
)}
</section>
{detailPanel}
</Panel>

{pendingDelete != null ? (
Expand Down
Loading
Loading