Skip to content
Open
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-rename-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge-ui": patch
---

Add chat-history rename for servers that implement `renameSession`, including TrueForge harness title updates.
Comment thread
harshil-2096 marked this conversation as resolved.
7 changes: 4 additions & 3 deletions docs/ui-sdk/setup-custom-servers/server-contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ interface AgentChatServer {

cancelSession(req: { sessionId: string }): Promise<void>;
deleteSession?(req: { sessionId: string }): Promise<void>;
renameSession?(req: { sessionId: string; title: string }): Promise<void>;

listTurns(req: {
sessionId: string;
Expand Down Expand Up @@ -105,7 +106,7 @@ interface AgentChatServer {
}
```

Four methods are optional. `deleteSession` gates the delete control in the session list. `listTurnEvents` hydrates the events of a single in-flight turn when your backend can serve them per turn. `subscribeToTurn` lets a reconnecting client resume a stream from `afterSequenceNumber` rather than replaying it — pass the same `abortSignal` you use on `createTurn` to tear it down. `downloadSandboxFile` enables artifact downloads.
`deleteSession` and `renameSession` are optional. Presence gates Delete and Rename in the session list; `renameSession` persists `{ sessionId, title }`. TrueForge implements both. TrueFoundry Gateway does not persist titles and should omit `renameSession`. `listTurnEvents` hydrates the events of a single in-flight turn when your backend can serve them per turn. `subscribeToTurn` lets a reconnecting client resume a stream from `afterSequenceNumber` rather than replaying it — pass the same `abortSignal` you use on `createTurn` to tear it down. `downloadSandboxFile` enables artifact downloads.

<Note>
`downloadSandboxFile` receives both `turnId` and `sandboxId` because backends address sandboxes differently. If your download route is scoped to a turn, resolve the sandbox from `turnId` and ignore `sandboxId`; if you address sandboxes directly, use `sandboxId`.
Expand All @@ -127,9 +128,9 @@ interface Session {
}
```

`id`, `isMutable`, `createdAt`, and `updatedAt` are required; timestamps are ISO strings. Set `isMutable: true` while the agent spec may still be edited — that is what permits `updateSession`.
`id`, `isMutable`, `createdAt`, and `updatedAt` are required; timestamps are ISO strings. Set `isMutable: true` while the agent spec may still be edited. `renameSession` must work for named and inline sessions.

`CreateSessionRequest` is `{ agentName?, agentSpec?, title? }`; `UpdateSessionRequest` is the same plus a required `sessionId`.
`CreateSessionRequest` is `{ agentName?, agentSpec?, title? }`; `UpdateSessionRequest` is `{ sessionId, agentSpec?, title? }`.

## Pagination

Expand Down
150 changes: 119 additions & 31 deletions packages/trueforge-ui/src/atoms/ThreadListRow.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
'use client';

import type { ReactNode } from 'react';
import { useEffect, useRef, type ReactNode } from 'react';

import { Icon } from '../icons/Icon.js';
import { auiButtonClass } from './lib/buttonClasses.js';
import { cn } from './lib/cn.js';
import { formatRelativeShort } from './lib/threadListMeta.js';
import { formatRelativeShort, MAX_SESSION_TITLE_LENGTH } from './lib/threadListMeta.js';

export type ThreadListRowProps = {
title: string;
Expand All @@ -15,58 +15,146 @@ export type ThreadListRowProps = {
agentName?: string;
/** Shown as compact relative time on the right. */
lastMessageAt?: Date;
/** Overflow actions (e.g. delete menu) — rendered as a sibling of the title button. */
/** Overflow actions (e.g. rename / delete menu) — rendered as a sibling of the title button. */
actions?: ReactNode;
renaming?: boolean;
renameValue?: string;
renameSaving?: boolean;
onRenameValueChange?: (value: string) => void;
onRenameCommit?: () => void;
onRenameCancel?: () => void;
onRenameBlur?: () => void;
className?: string;
};

function ThreadListAgentName({ agentName }: { agentName: string }) {
return (
<span className="mt-0.5 flex min-w-0 items-center gap-1 text-[0.75rem] text-text-secondary">
<Icon name="bot" className="shrink-0" />
<span className="truncate">{agentName}</span>
</span>
);
}

function ThreadListRenameField({
title,
renameValue,
renameSaving,
agentName,
onRenameValueChange,
onRenameCommit,
onRenameCancel,
onRenameBlur,
}: {
title: string;
renameValue?: string;
renameSaving: boolean;
agentName?: string;
onRenameValueChange?: (value: string) => void;
onRenameCommit?: () => void;
onRenameCancel?: () => void;
onRenameBlur?: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
const input = inputRef.current;
if (input == null) return;
input.focus();
input.select();
}, []);

return (
<div className="min-h-8 min-w-0 flex-1 overflow-hidden px-2.5 py-1.5">
<input
ref={inputRef}
aria-label="Session title"
value={renameValue ?? title}
readOnly={renameSaving}
maxLength={MAX_SESSION_TITLE_LENGTH}
className="h-7 w-full cursor-text border-none bg-transparent text-sm text-text-primary outline-none focus:ring-0 focus-visible:ring-0"
onChange={event => onRenameValueChange?.(event.target.value)}
onBlur={() => onRenameBlur?.()}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault();
onRenameCommit?.();
}
if (event.key === 'Escape') {
event.preventDefault();
onRenameCancel?.();
}
}}
/>
{agentName != null ? <ThreadListAgentName agentName={agentName} /> : null}
</div>
);
}

export function ThreadListRow({
title,
active,
onSelect,
agentName,
lastMessageAt,
actions,
renaming = false,
renameValue,
renameSaving = false,
onRenameValueChange,
onRenameCommit,
onRenameCancel,
onRenameBlur,
className,
}: ThreadListRowProps) {
const relative = lastMessageAt != null ? formatRelativeShort(lastMessageAt) : undefined;
const hasTrailing = relative != null || actions != null;
const hasTrailing = !renaming && (relative != null || actions != null);

return (
<div
data-slot="aui_thread-list-item"
data-active={active || undefined}
data-active={(!renaming && active) || undefined}
className={cn(
'group flex min-w-0 items-center gap-0.5 rounded-[0.5rem] transition-colors',
active
? 'bg-dropdown-selected-item-bg text-dropdown-selected-item-text'
: 'text-text-secondary hover:bg-ghost-button-hover hover:text-text-primary',
renaming
? 'text-text-secondary'
: active
? 'bg-dropdown-selected-item-bg text-dropdown-selected-item-text'
: 'text-text-secondary hover:bg-ghost-button-hover hover:text-text-primary',
className,
)}
>
<button
type="button"
onClick={onSelect}
style={{ borderRadius: 'var(--thread-list-item-radius, 0.75rem)' }}
className={auiButtonClass({
variant: 'ghost',
className: cn(
'!justify-start h-auto min-h-8 min-w-0 flex-1 overflow-hidden rounded-[0.75rem] px-2.5 py-1.5 text-left font-normal shadow-none',
'bg-transparent hover:bg-transparent hover:text-inherit',
active ? 'text-dropdown-selected-item-text' : 'text-inherit',
),
})}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-normal text-text-primary">{title}</span>
{agentName != null ? (
<span className="mt-0.5 flex min-w-0 items-center gap-1 text-[0.75rem] text-text-secondary">
<Icon name="bot" className="shrink-0" />
<span className="truncate">{agentName}</span>
</span>
) : null}
</span>
</button>
{renaming ? (
<ThreadListRenameField
title={title}
renameValue={renameValue}
renameSaving={renameSaving}
agentName={agentName}
onRenameValueChange={onRenameValueChange}
onRenameCommit={onRenameCommit}
onRenameCancel={onRenameCancel}
onRenameBlur={onRenameBlur}
/>
) : (
<button
type="button"
onClick={onSelect}
style={{ borderRadius: 'var(--thread-list-item-radius, 0.75rem)' }}
className={auiButtonClass({
variant: 'ghost',
className: cn(
'!justify-start h-auto min-h-8 min-w-0 flex-1 overflow-hidden rounded-[0.75rem] px-2.5 py-1.5 text-left font-normal shadow-none',
'bg-transparent hover:bg-transparent hover:text-inherit',
active ? 'text-dropdown-selected-item-text' : 'text-inherit',
),
})}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-normal text-text-primary">{title}</span>
{agentName != null ? <ThreadListAgentName agentName={agentName} /> : null}
</span>
</button>
)}
{hasTrailing ? (
<div className="relative mr-1 flex size-7 shrink-0 items-center justify-center">
{relative != null ? (
Expand Down
3 changes: 3 additions & 0 deletions packages/trueforge-ui/src/atoms/lib/threadListMeta.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/** Matches TrueForge `PATCH /sessions/{id}` title max. */
export const MAX_SESSION_TITLE_LENGTH = 50;

/** Compact relative age for sidebar session rows (e.g. 30m, 22h, 1d). */
export function formatRelativeShort(date: Date, now: Date = new Date()): string {
const diffMs = Math.max(0, now.getTime() - date.getTime());
Expand Down
Loading
Loading