diff --git a/.changeset/session-rename-ui.md b/.changeset/session-rename-ui.md new file mode 100644 index 000000000..921ac5967 --- /dev/null +++ b/.changeset/session-rename-ui.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge-ui": patch +--- + +Add chat-history rename for servers that implement `renameSession`, including TrueForge harness title updates. diff --git a/docs/ui-sdk/setup-custom-servers/server-contract.mdx b/docs/ui-sdk/setup-custom-servers/server-contract.mdx index 252943266..621a90eb3 100644 --- a/docs/ui-sdk/setup-custom-servers/server-contract.mdx +++ b/docs/ui-sdk/setup-custom-servers/server-contract.mdx @@ -65,6 +65,7 @@ interface AgentChatServer { cancelSession(req: { sessionId: string }): Promise; deleteSession?(req: { sessionId: string }): Promise; + renameSession?(req: { sessionId: string; title: string }): Promise; listTurns(req: { sessionId: string; @@ -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. `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`. @@ -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 diff --git a/packages/trueforge-ui/src/atoms/ThreadListRow.tsx b/packages/trueforge-ui/src/atoms/ThreadListRow.tsx index 1af8c1c61..c5a1e4682 100644 --- a/packages/trueforge-ui/src/atoms/ThreadListRow.tsx +++ b/packages/trueforge-ui/src/atoms/ThreadListRow.tsx @@ -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; @@ -15,11 +15,82 @@ 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 ( + + + {agentName} + + ); +} + +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(null); + + useEffect(() => { + const input = inputRef.current; + if (input == null) return; + input.focus(); + input.select(); + }, []); + + return ( +
+ onRenameValueChange?.(event.target.value)} + onBlur={() => onRenameBlur?.()} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault(); + onRenameCommit?.(); + } + if (event.key === 'Escape') { + event.preventDefault(); + onRenameCancel?.(); + } + }} + /> + {agentName != null ? : null} +
+ ); +} + export function ThreadListRow({ title, active, @@ -27,46 +98,63 @@ export function ThreadListRow({ 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 (
- + {renaming ? ( + + ) : ( + + )} {hasTrailing ? (
{relative != null ? ( diff --git a/packages/trueforge-ui/src/atoms/lib/threadListMeta.ts b/packages/trueforge-ui/src/atoms/lib/threadListMeta.ts index 50d8329ef..b567335e1 100644 --- a/packages/trueforge-ui/src/atoms/lib/threadListMeta.ts +++ b/packages/trueforge-ui/src/atoms/lib/threadListMeta.ts @@ -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()); diff --git a/packages/trueforge-ui/src/containers/ThreadListContainer.tsx b/packages/trueforge-ui/src/containers/ThreadListContainer.tsx index a96b7da03..e1f80e74d 100644 --- a/packages/trueforge-ui/src/containers/ThreadListContainer.tsx +++ b/packages/trueforge-ui/src/containers/ThreadListContainer.tsx @@ -16,6 +16,7 @@ import { cn } from '../atoms/lib/cn.js'; import { useCompactLayout } from '../atoms/lib/CompactLayoutContext.js'; import { canReuseMutableShell, + MAX_SESSION_TITLE_LENGTH, readThreadAgentName, threadListIndicesByRecency, threadListItemIsMutable, @@ -27,13 +28,16 @@ import { Icon } from '../icons/Icon.js'; import { useOptionalServer } from '../server/ServerContext.js'; import { useOptionalShellMode } from '../server/ShellModeContext.js'; import { useSlot } from '../theme/SlotsProvider.js'; +import { useToasterOptional } from './ToasterContainer.js'; /** * Simplified relative to the reference: renders threads in a single flat list * rather than grouping them by Today/Yesterday/Earlier. * * Delete uses assistant-ui ThreadListItemPrimitive.Delete / ThreadListItemMorePrimitive - * (adapter.delete → server.deleteSession). Mobile/compact keeps a BottomSheet chrome. + * (adapter.delete → server.deleteSession). Rename uses aui.threadListItem().rename + * (adapter.rename → server.renameSession) when `renameSession` is implemented. + * Mobile/compact keeps a BottomSheet chrome. */ export type ThreadListContainerProps = { /** Called after New chat or selecting a row — used by stack/drawer chrome. */ @@ -42,10 +46,25 @@ export type ThreadListContainerProps = { variant?: 'default' | 'recent-history'; }; +const actionItemClass = + 'flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium outline-none transition-colors hover:bg-ghost-button-hover focus:bg-ghost-button-hover data-[highlighted]:bg-ghost-button-hover'; + const deleteItemClass = 'flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-failure-bg outline-none transition-colors hover:bg-failure-bg/12 hover:text-failure-bg focus:bg-failure-bg/12 focus:text-failure-bg data-[highlighted]:bg-failure-bg/12 data-[highlighted]:text-failure-bg'; -function ThreadListItemDeleteMenu({ disabled }: { disabled: boolean }) { +function ThreadListItemActionsMenu({ + canRename, + renameDisabled, + canDelete, + deleteDisabled, + onRename, +}: { + canRename: boolean; + renameDisabled: boolean; + canDelete: boolean; + deleteDisabled: boolean; + onRename: () => void; +}) { const PermissionGuard = useSlot('PermissionGuard'); const compact = useCompactLayout(); const isMobile = useIsMobile(); @@ -63,6 +82,38 @@ function ThreadListItemDeleteMenu({ disabled }: { disabled: boolean }) { className: 'size-7 shrink-0 text-text-secondary hover:bg-transparent hover:text-text-primary', }); + const renameButton = canRename ? ( + + + + ) : null; + + const deleteButton = canDelete ? ( + + { + if (!deleteDisabled) setSheetOpen(false); + }} + > + + Delete + + + ) : null; + if (useSheet) { return ( <> @@ -80,17 +131,8 @@ function ThreadListItemDeleteMenu({ disabled }: { disabled: boolean }) { {sheetOpen ? (
- - { - if (!disabled) setSheetOpen(false); - }} - > - - Delete - - + {renameButton} + {deleteButton}
) : null} @@ -112,14 +154,10 @@ function ThreadListItemDeleteMenu({ disabled }: { disabled: boolean }) { portalProps={{ container: portalContainer }} align="end" sideOffset={4} - className="aui-popup-enter font-sans-flex z-50 min-w-[8rem] rounded-md border border-border bg-card-bg p-1 text-text-primary shadow-md *:w-full" + className="aui-popup-enter font-sans-flex z-50 min-w-32 rounded-md border border-border bg-card-bg p-1 text-text-primary shadow-md *:w-full" > - - - - Delete - - + {renameButton} + {deleteButton} ); @@ -127,15 +165,20 @@ function ThreadListItemDeleteMenu({ disabled }: { disabled: boolean }) { function ThreadListItemRow({ onThreadOpen, + canRenameSession, canDeleteSession, + canManageResource, canDeleteResource, }: { onThreadOpen?: () => void; + canRenameSession: boolean; canDeleteSession: boolean; + canManageResource: (sessionId: string) => boolean; canDeleteResource: (sessionId: string) => boolean; }) { const aui = useAui(); const shell = useOptionalShellMode(); + const toaster = useToasterOptional(); const ThreadListRow = useSlot('ThreadListRow'); const id = useAuiState(s => s.threadListItem.id); const remoteId = useAuiState(s => s.threadListItem.remoteId); @@ -144,17 +187,75 @@ function ThreadListItemRow({ const custom = useAuiState(s => s.threadListItem.custom); const mainThreadId = useAuiState(s => s.threads.mainThreadId); const agentName = readThreadAgentName(custom); + const showRename = canRenameSession && remoteId != null; const showDelete = canDeleteSession && remoteId != null; + const showActions = showRename || showDelete; + const renameDisabled = remoteId != null && !canManageResource(remoteId); const deleteDisabled = remoteId != null && !canDeleteResource(remoteId); const sidebarNavOpen = shell?.libraryOpen === true || shell?.sessionsOpen === true || shell?.schedulesOpen === true; + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(''); + const [renameSaving, setRenameSaving] = useState(false); + const skipRenameBlurRef = useRef(false); + + const displayTitle = title ?? 'New Chat'; + const isValidRename = (() => { + const trimmed = renameValue.trim(); + return trimmed.length > 0 && trimmed.length <= MAX_SESSION_TITLE_LENGTH; + })(); + + const persistRename = async () => { + const trimmed = renameValue.trim(); + if (trimmed === (title ?? '').trim()) { + skipRenameBlurRef.current = true; + setRenaming(false); + return; + } + setRenameSaving(true); + try { + await aui.threadListItem().rename(trimmed); + skipRenameBlurRef.current = true; + setRenaming(false); + } catch (caught) { + skipRenameBlurRef.current = true; + setRenaming(false); + toaster?.showError(caught); + } finally { + setRenameSaving(false); + } + }; return ( { + if (!isValidRename || renameSaving) return; + void persistRename(); + }} + onRenameCancel={() => { + if (renameSaving) return; + skipRenameBlurRef.current = true; + setRenaming(false); + }} + onRenameBlur={() => { + if (skipRenameBlurRef.current || renameSaving) { + skipRenameBlurRef.current = false; + return; + } + if (!isValidRename) { + setRenaming(false); + return; + } + void persistRename(); + }} onSelect={() => { onThreadOpen?.(); shell?.setSettingsOpen(false); @@ -195,7 +296,21 @@ function ThreadListItemRow({ } void Promise.resolve(aui.threads().switchToThread(id)).catch(() => undefined); }} - actions={showDelete ? : undefined} + actions={ + showActions ? ( + { + skipRenameBlurRef.current = false; + setRenameValue(title ?? ''); + setRenaming(true); + }} + /> + ) : undefined + } /> ); @@ -227,12 +342,16 @@ function useThreadListIndicesByRecency(variant: NonNullable void; + canRenameSession: boolean; canDeleteSession: boolean; + canManageResource: (sessionId: string) => boolean; canDeleteResource: (sessionId: string) => boolean; }) { // Newest-first: remount/switchToThread can append the active session to threadIds. @@ -246,7 +365,9 @@ function ThreadListItemsByRecency({ @@ -328,6 +449,7 @@ export function ThreadListContainer({ onThreadOpen, variant = 'default' }: Threa const showNewChat = shell?.isNewChatEnabled !== false; const isIdle = shell?.mode.status === 'idle'; const isRecentHistory = variant === 'recent-history'; + const canRenameSession = typeof server?.renameSession === 'function'; const canDeleteSession = typeof server?.deleteSession === 'function'; const remoteSessionIds = useMemo( () => threadItems.flatMap(item => (item.remoteId == null ? [] : [item.remoteId])), @@ -401,7 +523,9 @@ export function ThreadListContainer({ onThreadOpen, variant = 'default' }: Threa allows(sessionId, 'MANAGE')} canDeleteResource={sessionId => allows(sessionId, 'DELETE')} /> diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts index f2839e3e6..efae83936 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts @@ -218,10 +218,15 @@ export function createHarnessChatServer( await client.sessions.delete(sessionId); }, - async updateSession({ sessionId, agentSpec }) { + async renameSession({ sessionId, title }) { + await client.sessions.update(sessionId, { title }); + }, + + async updateSession({ sessionId, agentSpec, title }) { // Named (reference) sessions reject agent updates server-side. const response = await client.sessions.update(sessionId, { ...(agentSpec === undefined ? {} : { agent: { spec: toHarnessAgentSpec(agentSpec) } }), + ...(title === undefined ? {} : { title }), }); return toUiSession(response.data); }, diff --git a/packages/trueforge-ui/test/atoms/ThreadListRow.test.tsx b/packages/trueforge-ui/test/atoms/ThreadListRow.test.tsx index 6d6433c63..2e6322c0e 100644 --- a/packages/trueforge-ui/test/atoms/ThreadListRow.test.tsx +++ b/packages/trueforge-ui/test/atoms/ThreadListRow.test.tsx @@ -7,6 +7,31 @@ import { DropdownMenuItem } from '@/atoms/primitives/DropdownMenu.js'; import { SlotsProvider } from '@/theme/SlotsProvider.js'; describe('ThreadListRow', () => { + it('renders an inline title input when renaming', () => { + const { rerender } = render( + + {}} renaming renameValue="Session A" /> + , + ); + const input = screen.getByRole('textbox', { name: 'Session title' }); + expect(input).toHaveValue('Session A'); + expect(input.className).toMatch(/bg-transparent/); + expect(input.className).toMatch(/border-none/); + const row = input.closest('[data-slot="aui_thread-list-item"]'); + expect(row).not.toHaveAttribute('data-active'); + expect(row?.className).not.toMatch(/bg-dropdown-selected-item-bg/); + expect(screen.queryByRole('button', { name: 'Session A' })).not.toBeInTheDocument(); + + rerender( + + {}} renaming renameValue="Session A" renameSaving /> + , + ); + expect(input).toHaveAttribute('readonly'); + expect(input).not.toBeDisabled(); + expect(input).toHaveFocus(); + }); + it('hides actions when omitted', () => { render( diff --git a/packages/trueforge-ui/test/containers/ThreadListContainer.test.tsx b/packages/trueforge-ui/test/containers/ThreadListContainer.test.tsx index 7cb6a7e31..f2d3b9b28 100644 --- a/packages/trueforge-ui/test/containers/ThreadListContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/ThreadListContainer.test.tsx @@ -12,8 +12,10 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; import type { ThreadListRowProps } from '@/atoms/ThreadListRow.js'; import { CompactLayoutProvider } from '@/atoms/lib/CompactLayoutContext.js'; import { ThreadListContainer, type ThreadListContainerProps } from '@/containers/ThreadListContainer.js'; +import { ToasterProvider } from '@/containers/ToasterContainer.js'; import { ServerProvider } from '@/server/ServerContext.js'; import { ShellModeProvider, useShellMode } from '@/server/ShellModeContext.js'; +import type { ListPermissionsResponse } from '@/server/types.js'; import { SlotsProvider } from '@/theme/SlotsProvider.js'; import { createMockAgentUIServer } from '../server/mockServer.js'; @@ -44,13 +46,39 @@ function ThreadListRuntimeHarness({ return {children}; } -function ThreadListRowOverride({ title, active, onSelect, actions }: ThreadListRowProps) { +function ThreadListRowOverride({ + title, + active, + onSelect, + actions, + renaming, + renameValue, + renameSaving, + onRenameValueChange, + onRenameCommit, + onRenameCancel, + onRenameBlur, +}: ThreadListRowProps) { return (
- - {actions} + {renaming ? ( + onRenameValueChange?.(event.target.value)} + onBlur={() => onRenameBlur?.()} + onKeyDown={event => { + if (event.key === 'Enter') onRenameCommit?.(); + if (event.key === 'Escape') onRenameCancel?.(); + }} + /> + ) : ( + + )} + {renaming ? null : actions}
); } @@ -108,29 +136,45 @@ function renderThreadList({ adapter, onThreadOpen, canDelete = false, + canRename = false, + permissions, variant, }: { adapter: ExternalStoreThreadListAdapter; onThreadOpen?: () => void; canDelete?: boolean; + canRename?: boolean; + permissions?: { + listPermissions: (req: { resourceType: string; resourceIds: string[] }) => Promise; + }; variant?: ThreadListContainerProps['variant']; }) { const list = ( - - - - - + + + + + + + ); - if (!canDelete) { + if (!canDelete && !canRename) { return render(list); } return render( - {} })}>{list}, + {} } : {}), + ...(canRename ? { renameSession: async () => {} } : {}), + ...(permissions === undefined ? {} : { permissions }), + })} + > + {list} + , ); } @@ -367,6 +411,208 @@ describe('ThreadListContainer', () => { }); }); + it('hides rename when the server does not opt in', () => { + renderThreadList({ + adapter: { + threads: [ + { + status: 'regular', + id: 'thread-1', + remoteId: 'session-1', + title: 'Remote session', + }, + ], + }, + canDelete: true, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Session actions' })); + expect(screen.queryByRole('button', { name: 'Rename' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + }); + + it('renames a remote session inline after validating the title', async () => { + const onRename = vi.fn(async () => {}); + + renderThreadList({ + adapter: { + threadId: 'thread-1', + threads: [ + { + status: 'regular', + id: 'thread-1', + remoteId: 'session-1', + title: 'Remote session', + }, + { + status: 'regular', + id: 'thread-local', + title: 'Local draft', + }, + ], + onRename, + }, + canRename: true, + }); + + const actionButtons = screen.getAllByRole('button', { name: 'Session actions' }); + expect(actionButtons).toHaveLength(1); + fireEvent.click(actionButtons[0]!); + fireEvent.click(screen.getByRole('button', { name: 'Rename' })); + + const titleInput = screen.getByRole('textbox', { name: 'Session title' }); + expect(titleInput).toHaveValue('Remote session'); + + fireEvent.change(titleInput, { target: { value: ' ' } }); + fireEvent.keyDown(titleInput, { key: 'Enter' }); + expect(onRename).not.toHaveBeenCalled(); + expect(titleInput).toBeInTheDocument(); + + fireEvent.change(titleInput, { target: { value: ' Acme onboarding ' } }); + fireEvent.keyDown(titleInput, { key: 'Enter' }); + + await waitFor(() => { + expect(onRename).toHaveBeenCalledWith('thread-1', 'Acme onboarding'); + }); + }); + + it('cancels inline rename on Escape without persisting', async () => { + const onRename = vi.fn(async () => {}); + + renderThreadList({ + adapter: { + threadId: 'thread-1', + threads: [ + { + status: 'regular', + id: 'thread-1', + remoteId: 'session-1', + title: 'Remote session', + }, + ], + onRename, + }, + canRename: true, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Session actions' })); + fireEvent.click(screen.getByRole('button', { name: 'Rename' })); + const titleInput = screen.getByRole('textbox', { name: 'Session title' }); + fireEvent.change(titleInput, { target: { value: 'Scratch' } }); + fireEvent.keyDown(titleInput, { key: 'Escape' }); + + await waitFor(() => { + expect(screen.queryByRole('textbox', { name: 'Session title' })).not.toBeInTheDocument(); + }); + expect(onRename).not.toHaveBeenCalled(); + expect(screen.getByText('Remote session')).toBeInTheDocument(); + }); + + it('ignores Escape while rename is saving', async () => { + let resolveRename: (() => void) | undefined; + const onRename = vi.fn( + () => + new Promise(resolve => { + resolveRename = resolve; + }), + ); + + renderThreadList({ + adapter: { + threadId: 'thread-1', + threads: [ + { + status: 'regular', + id: 'thread-1', + remoteId: 'session-1', + title: 'Remote session', + }, + ], + onRename, + }, + canRename: true, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Session actions' })); + fireEvent.click(screen.getByRole('button', { name: 'Rename' })); + const titleInput = screen.getByRole('textbox', { name: 'Session title' }); + fireEvent.change(titleInput, { target: { value: 'Updated title' } }); + fireEvent.keyDown(titleInput, { key: 'Enter' }); + + await waitFor(() => { + expect(titleInput).toHaveAttribute('readonly'); + }); + fireEvent.keyDown(titleInput, { key: 'Escape' }); + expect(titleInput).toBeInTheDocument(); + + resolveRename?.(); + await waitFor(() => { + expect(screen.queryByRole('textbox', { name: 'Session title' })).not.toBeInTheDocument(); + }); + }); + + it('toasts when rename fails and closes the inline editor', async () => { + const onRename = vi.fn(async () => { + throw new Error('rename failed'); + }); + + renderThreadList({ + adapter: { + threadId: 'thread-1', + threads: [ + { + status: 'regular', + id: 'thread-1', + remoteId: 'session-1', + title: 'Remote session', + }, + ], + onRename, + }, + canRename: true, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Session actions' })); + fireEvent.click(screen.getByRole('button', { name: 'Rename' })); + const titleInput = screen.getByRole('textbox', { name: 'Session title' }); + fireEvent.change(titleInput, { target: { value: 'Updated title' } }); + fireEvent.keyDown(titleInput, { key: 'Enter' }); + + await waitFor(() => { + expect(screen.getByText('rename failed')).toBeInTheDocument(); + }); + expect(screen.queryByRole('textbox', { name: 'Session title' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remote session' })); + expect(onRename).toHaveBeenCalledOnce(); + }); + + it('disables rename when the caller lacks MANAGE', async () => { + renderThreadList({ + adapter: { + threads: [ + { + status: 'regular', + id: 'thread-1', + remoteId: 'session-1', + title: 'Remote session', + }, + ], + }, + canRename: true, + canDelete: true, + permissions: { + listPermissions: vi.fn(async (): Promise => ({ + data: { type: 'session', permissions: { 'session-1': ['DELETE'] } }, + })), + }, + }); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Session actions' })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: 'Session actions' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Rename' })).toBeDisabled()); + expect(screen.getByRole('button', { name: 'Delete' })).not.toBeDisabled(); + }); + it('clears chat selection highlight while a sidebar top nav tab is open', () => { function OpenSchedulesButton() { const shell = useShellMode(); diff --git a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts index 81ca6582b..8b4f950db 100644 --- a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts +++ b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts @@ -42,6 +42,15 @@ const fetchMock: typeof fetch = async (input, init) => { } return Response.json({ data: session }); } + if (url.endsWith('/api/v1/sessions/ses_1') && method === 'PATCH') { + const body: unknown = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + sessionRequests.push(body); + const title = + body !== null && typeof body === 'object' && 'title' in body && typeof body.title === 'string' + ? body.title + : session.title; + return Response.json({ data: { ...session, title } }); + } if (url.endsWith('/api/v1/sessions/ses_1') && method === 'DELETE') { deletedSessions.push('ses_1'); return new Response(null, { status: 204 }); @@ -224,6 +233,17 @@ describe('createHarnessChatServer', () => { assert.equal(new URL(listUrl, 'http://test.local').searchParams.get('created_by_me'), 'true'); }); + it('PATCHes session title and returns the updated UI session', async () => { + sessionRequests.length = 0; + const server = createHarnessChatServer({ fetch: fetchMock }); + assert.equal(typeof server.renameSession, 'function'); + await server.renameSession?.({ sessionId: 'ses_1', title: 'Acme onboarding' }); + const updated = await server.updateSession({ sessionId: 'ses_1', title: 'Acme onboarding' }); + + assert.deepEqual(sessionRequests.at(-1), { title: 'Acme onboarding' }); + assert.equal(updated.title, 'Acme onboarding'); + }); + it('listSessions forwards an unknown agentId to the API (empty page from the server)', async () => { let listUrl: string | undefined; const fetchNamed: typeof fetch = async input => { diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c8ad9525b..40134d3e8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,7 +17,7 @@ allowBuilds: # Silence peer mismatches between trueforge-ui peers and the versions we pin. peerDependencyRules: allowedVersions: - '@truefoundry/assistant-ui-runtime': '0.1.39' + '@truefoundry/assistant-ui-runtime': '0.1.42' # One Context module for assistant-ui (avoids "requires an AuiProvider"). overrides: