diff --git a/.changeset/layout-mode-parity.md b/.changeset/layout-mode-parity.md new file mode 100644 index 000000000..0db8295c3 --- /dev/null +++ b/.changeset/layout-mode-parity.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Bring Agents, Sessions, and Schedules chrome to drawer/dock/widget, keep overlay back/close controls (including widget Close), reopen compact Agent Config after close, and stack sessions list/detail on compact and mobile widths. diff --git a/.changeset/withrouter-default-true.md b/.changeset/withrouter-default-true.md new file mode 100644 index 000000000..a5f8d7db3 --- /dev/null +++ b/.changeset/withrouter-default-true.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Default `TrueForgeUI` `withRouter` to `true`; pass `withRouter={false}` for embeds that must not own the URL. diff --git a/packages/trueforge-ui/README.md b/packages/trueforge-ui/README.md index d74ca22ca..70e72ec4d 100644 --- a/packages/trueforge-ui/README.md +++ b/packages/trueforge-ui/README.md @@ -398,12 +398,12 @@ show the title text (see [Custom layouts](#custom-layouts)). `agentConfig` controls library chrome, draft composer, and how New Chat / Clear Chat behave. -| Mode | Layout chrome | Agent selection / New Chat | -| -------------------------------------- | ------------------------------- | --------------------------------------------------------- | -| `AgentLibraryWithComposer` _(default)_ | Agents + draft builder | New Chat opens draft; library picks a named agent | -| `SingleAgent` | Named-only, plain composer | Locked to `name`; New Chat / Clear Chat = new thread | -| `AgentLibrary` | Agents only (no draft) | Empty until pick; no New Chat; Clear Chat after selection | -| `AgentComposer` | Draft builder only (no library) | Always draft; New Chat / Clear Chat = fresh draft | +| Mode | Layout chrome | Agent selection / New Chat | +| -------------------------------------- | ------------------------------- | ------------------------------------------------------------------------ | +| `AgentLibraryWithComposer` _(default)_ | Agents + draft builder | New Chat opens draft; library picks a named agent | +| `SingleAgent` | Named-only, plain composer | Locked to `name`; New Chat / Clear Chat = new thread | +| `AgentLibrary` | Agents only (no draft) | Opens Agents Library by default; no New Chat; Clear Chat after selection | +| `AgentComposer` | Draft builder only (no library) | Always draft; New Chat / Clear Chat = fresh draft | In library modes, picking an agent from Agents switches to a named chat for that agent **and remounts the runtime** so the new agent starts from a clean conversation. Draft chats can be promoted via **Save agent** (`server.saveAgent` on the resolved `AgentUIServer`). **Clear Chat** (thread header) resets the current named or draft session. @@ -446,12 +446,14 @@ Mutable composers expose **Agent Config** for live model parameters, instruction Built-in `layout` values: -| Value | Description | -| --------- | ---------------------------------------------------- | -| `sidebar` | Icon rail + recent session history + active thread | -| `drawer` | Full-bleed thread; sessions open in a slide-over | -| `dock` | Fixed-width right panel; list XOR thread stack | -| `widget` | Same stack as `dock`, opened from a bottom-right FAB | +| Value | Description | +| --------- | -------------------------------------------------------------------------------------------------- | +| `sidebar` | Icon rail (New Chat / Build Agent / Agents / Sessions / Schedules) + recent chats + active thread | +| `drawer` | Full-bleed thread; Recents top tab opens Chat History on the right; overlays replace the main pane | +| `dock` | Fixed-width right panel; thread stack with toolbar nav and overlay back navigation | +| `widget` | Same stack as `dock`, opened from a bottom-right FAB; Close stays available on overlays | + +In every layout, Agents / Sessions / Schedules entry points appear when the host `agentConfig` mode and server ports enable them. Compact and mobile sessions use list → detail stack navigation; desktop sidebar/drawer keep the resizable split. --- diff --git a/packages/trueforge-ui/docs/customization.md b/packages/trueforge-ui/docs/customization.md index 2bfba623f..1e9fd7dbc 100644 --- a/packages/trueforge-ui/docs/customization.md +++ b/packages/trueforge-ui/docs/customization.md @@ -84,13 +84,16 @@ Public override surface (primitives stay theme/CSS — not slots): ## URL routing (`withRouter`) -Opt in to browser-URL sync for shell navigation. Requires `react-router-dom` -(v6 or v7) in the host; it is an optional peer and stays out of the bundle -unless `withRouter` is set, so dock/widget embeds and hosts that own their own -router should leave it off (the default). +Browser-URL sync for shell navigation is on by default. Requires `react-router-dom` +(v6 or v7) in the host; it is an optional peer. Pass `withRouter={false}` for +dock/widget embeds and hosts that own their own router (keeps `react-router` out +of the bundle for that mount). ```tsx - +; +{ + /* or explicitly: withRouter={false} for embeds */ +} ``` Places mirrored to the URL: diff --git a/packages/trueforge-ui/src/atoms/AgentsLibraryButton.tsx b/packages/trueforge-ui/src/atoms/AgentsLibraryButton.tsx index d5349a4dd..0aeaa6eef 100644 --- a/packages/trueforge-ui/src/atoms/AgentsLibraryButton.tsx +++ b/packages/trueforge-ui/src/atoms/AgentsLibraryButton.tsx @@ -11,10 +11,13 @@ import { SEARCH_AGENTS_PAGE_SIZE } from './lib/useSearchAgentsList.js'; export type AgentsLibraryButtonProps = { className?: string; + /** Sidebar rail: icon + label stacked. */ compact?: boolean; + /** Header/footer chrome: icon-only control. */ + toolbar?: boolean; }; -export function AgentsLibraryButton({ className, compact = false }: AgentsLibraryButtonProps) { +export function AgentsLibraryButton({ className, compact = false, toolbar = false }: AgentsLibraryButtonProps) { const server = useOptionalServer(); const shell = useOptionalShellMode(); const [countLabel, setCountLabel] = useState(null); @@ -22,10 +25,11 @@ export function AgentsLibraryButton({ className, compact = false }: AgentsLibrar const enabled = shell?.isLibraryEnabled === true && server != null; const libraryOpen = shell?.libraryOpen === true; const agentsListEpoch = shell?.agentsListEpoch ?? 0; + const skipCount = compact || toolbar; useEffect(() => { - // Compact rail has no count badge; skip the catalog request. - if (!enabled || !server || compact) return; + // Rail / toolbar have no count badge; skip the catalog request. + if (!enabled || !server || skipCount) return; let cancelled = false; void server .searchAgents({ limit: SEARCH_AGENTS_PAGE_SIZE }) @@ -38,10 +42,33 @@ export function AgentsLibraryButton({ className, compact = false }: AgentsLibrar return () => { cancelled = true; }; - }, [enabled, server, agentsListEpoch, compact]); + }, [enabled, server, agentsListEpoch, skipCount]); if (!enabled) return null; + if (toolbar) { + return ( + + ); + } + return (
+ ); + } + return (
+ ); + } + return (
); } diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx index b951d5bb5..042604d83 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessions.tsx @@ -17,7 +17,9 @@ import { drainListPages } from '../../utils/drainListPages.js'; import { sessionTimeRangeFromCreatedAt } from '../../utils/sessionShareUrl.js'; import { EmptyScreen } from '../EmptyScreen.js'; import { cn } from '../lib/cn.js'; +import { useCompactLayout } from '../lib/CompactLayoutContext.js'; import { sessionIsCreateAgent } from '../lib/sessionCreateAgent.js'; +import { useIsMobile } from '../lib/useIsMobile.js'; import { Button } from '../primitives/Button.js'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../primitives/Dialog.js'; import { Skeleton } from '../primitives/Skeleton.js'; @@ -47,6 +49,10 @@ export function AgentSessions({ agentId, startTimestamp, endTimestamp, shareView const toaster = useToasterOptional(); const shell = useOptionalShellMode(); const routes = useOptionalResolvedRoutes(); + const compactLayout = useCompactLayout(); + const isMobile = useIsMobile(); + // dock/widget and mobile: list XOR detail. Desktop sidebar/drawer: resizable split. + const stacked = compactLayout || isMobile; const { sessionId: selectedSessionId, updateShareSearch } = useSessionShareSearch(); const AgentSessionListRow = useSlot('AgentSessionListRow'); @@ -284,6 +290,139 @@ export function AgentSessions({ agentId, startTimestamp, endTimestamp, shareView ); } + const showDetail = selectedSessionId != null && selectedSessionId.length > 0; + + const listPane = ( + + ); + + const detailPane = ( +
+ {selectedSessionId == null || selectedSessionId.length === 0 ? ( +
+ Select a session to view details +
+ ) : detailFailed ? ( +
+ Session details could not be loaded. +
+ ) : ( + <> + + {detailLoading || detailEvents === undefined ? ( +
+ +
+ ) : ( + + )} + + )} +
+ ); + + const deleteDialog = + pendingDelete != null ? ( + { + if (!open) setPendingDelete(null); + }} + aria-label="Delete session" + className="max-w-md" + > + + + Delete session +

+ “{sessionTitle(pendingDelete)}” will be permanently deleted. This cannot be undone. +

+
+
+ + setPendingDelete(null)}> + Cancel + + void handleDelete(pendingDelete)} + > + Delete + + +
+ ) : null; + + if (stacked) { + return ( +
+ {showDetail ? detailPane : listPane} + {deleteDialog} +
+ ); + } + return ( - + {listPane} -
- {selectedSessionId == null ? ( -
- Select a session to view details -
- ) : detailFailed ? ( -
- Session details could not be loaded. -
- ) : ( - <> - - {detailLoading || detailEvents === undefined ? ( -
- -
- ) : ( - - )} - - )} -
+ {detailPane}
- {pendingDelete != null ? ( - { - if (!open) setPendingDelete(null); - }} - aria-label="Delete session" - className="max-w-md" - > - - - Delete session -

- “{sessionTitle(pendingDelete)}” will be permanently deleted. This cannot be undone. -

-
-
- - setPendingDelete(null)}> - Cancel - - void handleDelete(pendingDelete)} - > - Delete - - -
- ) : null} + {deleteDialog}
); } diff --git a/packages/trueforge-ui/src/containers/TrueForgeUI.tsx b/packages/trueforge-ui/src/containers/TrueForgeUI.tsx index dd43e7c84..07104a619 100644 --- a/packages/trueforge-ui/src/containers/TrueForgeUI.tsx +++ b/packages/trueforge-ui/src/containers/TrueForgeUI.tsx @@ -12,7 +12,7 @@ export type { TrueForgeBuiltInServerConfig, TrueForgeServerConfig } from '../ser export type { LayoutProp } from '../theme/types.js'; export type { ChatLayout, TrueForgeUIProps } from './TrueForgeUIShell.js'; -// Lazy so `react-router` stays out of the base bundle unless `withRouter` is set. +// Lazy so `react-router` stays out of the base bundle when `withRouter={false}`. const TrueForgeUIWithRouter = lazy>>(() => import('./TrueForgeUIWithRouter.js').then(m => ({ default: m.TrueForgeUIWithRouter })), ); @@ -34,7 +34,7 @@ export function TrueForgeUI(props: TrueForgeUIProps) { ); } - const { withRouter, ...rest } = props; + const { withRouter = true, ...rest } = props; if (withRouter) { return ( }> diff --git a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx index ca8309399..b62e0515c 100644 --- a/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx +++ b/packages/trueforge-ui/src/containers/TrueForgeUIShell.tsx @@ -62,9 +62,9 @@ export type TrueForgeUIProps = { */ customActionRenderers?: CustomActionRenderers; /** - * Sync shell navigation to the browser URL via react-router (opt-in). - * Requires `react-router-dom` in the host. Leave off for dock/widget embeds - * and hosts that own their own router. Defaults to `false`. + * Sync shell navigation to the browser URL via react-router. + * Requires `react-router-dom` in the host. Defaults to `true`. + * Set `false` for dock/widget embeds and hosts that own their own router. */ withRouter?: boolean; /** URL path customization; only honored when `withRouter`. */ diff --git a/packages/trueforge-ui/src/layouts/DrawerLayout.tsx b/packages/trueforge-ui/src/layouts/DrawerLayout.tsx index 0eed6f105..bc5802106 100644 --- a/packages/trueforge-ui/src/layouts/DrawerLayout.tsx +++ b/packages/trueforge-ui/src/layouts/DrawerLayout.tsx @@ -1,6 +1,6 @@ 'use client'; -import { lazy, Suspense, useRef } from 'react'; +import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { useAui } from '../assistant-ui.js'; import { NamedAgentHeaderLabel } from '../atoms/NamedAgentHeaderLabel.js'; @@ -12,8 +12,9 @@ import { useIsMobile } from '../atoms/lib/useIsMobile.js'; import { Spinner } from '../atoms/primitives/Spinner.js'; import { AgentConfigDrawerContainer } from '../containers/AgentConfigDrawerContainer.js'; import { Thread } from '../containers/Thread.js'; +import { ThreadListContainer } from '../containers/ThreadListContainer.js'; import { Icon } from '../icons/Icon.js'; -import { shellIsCreateAgent, useOptionalShellMode } from '../server/ShellModeContext.js'; +import { shellIsCreateAgent, useOptionalShellMode, type ShellMode } from '../server/ShellModeContext.js'; import { useSlot } from '../theme/SlotsProvider.js'; const TruefoundrySettingsBuilder = lazy(() => import('../containers/SettingsBuilder/index.js')); @@ -21,6 +22,10 @@ const SchedulesPage = lazy(() => import('../atoms/schedules/SchedulesPage.js').then(m => ({ default: m.SchedulesPage })), ); +function isRecentHistoryAllowed({ overlayOpen, mode }: { overlayOpen: boolean; mode?: ShellMode }): boolean { + return !overlayOpen && mode?.status === 'active' && !mode.isCreateAgent; +} + export function DrawerLayout({ className }: { className?: string }) { const aui = useAui(); const shell = useOptionalShellMode(); @@ -28,9 +33,13 @@ export function DrawerLayout({ className }: { className?: string }) { const ClearChatButton = useSlot('ClearChatButton'); const AgentDetailsPage = useSlot('AgentDetailsPage'); const AgentsLibrary = useSlot('AgentsLibrary'); + const AgentsLibraryButton = useSlot('AgentsLibraryButton'); + const SessionsBrowserButton = useSlot('SessionsBrowserButton'); + const SchedulesButton = useSlot('SchedulesButton'); const SessionsPage = useSlot('SessionsPage'); const SaveAgentButton = useSlot('SaveAgentButton'); const SelectAgentEmptyState = useSlot('SelectAgentEmptyState'); + const DraftAgentConfigTrigger = useSlot('DraftAgentConfigTrigger'); const UserAvatar = useSlot('UserAvatar'); const mainRef = useRef(null); const isIdle = shell?.mode.status === 'idle'; @@ -41,28 +50,48 @@ export function DrawerLayout({ className }: { className?: string }) { const overlayOpen = settingsOpen || libraryOpen || sessionsOpen || schedulesOpen; const showAgentConfig = shell != null && shellIsCreateAgent(shell.mode) && !overlayOpen && (!isMobile || shell.agentConfigOpen); + const showConfigReopen = + shell != null && shellIsCreateAgent(shell.mode) && !overlayOpen && isMobile && !shell.agentConfigOpen; + const recentsAllowed = isRecentHistoryAllowed({ overlayOpen, mode: shell?.mode }); const showNewActions = shell?.isNewChatEnabled !== false; + // Recents starts open in New Chat; users can collapse it from the top tab. + const [recentsOpen, setRecentsOpen] = useState(true); + + useEffect(() => { + if (!recentsAllowed) setRecentsOpen(false); + }, [recentsAllowed]); const handleNewChat = () => { shell?.setLibraryOpen(false); shell?.setSessionsOpen(false); if (shell?.isComposerEnabled) { shell.openDraft(); + setRecentsOpen(true); return; } shell?.setSettingsOpen(false); shell?.setSchedulesOpen(false); void Promise.resolve(aui.threads().switchToNewThread()).catch(() => undefined); + setRecentsOpen(true); }; const handleNewAgent = () => { shell?.setLibraryOpen(false); shell?.setSessionsOpen(false); + setRecentsOpen(false); if (shell?.isComposerEnabled) { shell.openAgentBuilder(); } }; + const handleBackToChat = () => { + shell?.setLibraryOpen(false); + shell?.setSessionsOpen(false); + shell?.setSchedulesOpen(false); + }; + + const showRecentsPane = recentsAllowed && recentsOpen; + return (
{showAgentConfig ? ( @@ -82,14 +111,11 @@ export function DrawerLayout({ className }: { className?: string }) { title={ !overlayOpen ? ( - ) : libraryOpen || schedulesOpen ? ( + ) : libraryOpen || sessionsOpen || schedulesOpen ? ( ) : null} + {recentsAllowed ? ( + + ) : null} {showNewActions && shell?.isComposerEnabled ? (
- } +
+
+ {settingsOpen ? ( + + + Loading +
+ } + > + + + ) : sessionsOpen ? ( + + ) : libraryOpen && shell?.libraryAgentId != null ? ( + + ) : libraryOpen ? ( + + ) : schedulesOpen ? ( + + + Loading +
+ } + > + +
+ ) : isIdle ? ( + + ) : ( + + )} +
+ {showRecentsPane ? ( + + ) : null}
diff --git a/packages/trueforge-ui/src/layouts/StackChatPanel.tsx b/packages/trueforge-ui/src/layouts/StackChatPanel.tsx index 86a4276e5..e723f3add 100644 --- a/packages/trueforge-ui/src/layouts/StackChatPanel.tsx +++ b/packages/trueforge-ui/src/layouts/StackChatPanel.tsx @@ -12,7 +12,7 @@ import { Spinner } from '../atoms/primitives/Spinner.js'; import { AgentConfigDrawerContainer } from '../containers/AgentConfigDrawerContainer.js'; import { Thread } from '../containers/Thread.js'; import { Icon } from '../icons/Icon.js'; -import { useOptionalShellMode } from '../server/ShellModeContext.js'; +import { shellIsCreateAgent, useOptionalShellMode } from '../server/ShellModeContext.js'; import { useSlot } from '../theme/SlotsProvider.js'; const TruefoundrySettingsBuilder = lazy(() => import('../containers/SettingsBuilder/index.js')); @@ -36,16 +36,23 @@ export function StackChatPanel({ className, threadHeaderEnd }: StackChatPanelPro const ClearChatButton = useSlot('ClearChatButton'); const AgentDetailsPage = useSlot('AgentDetailsPage'); const AgentsLibrary = useSlot('AgentsLibrary'); + const AgentsLibraryButton = useSlot('AgentsLibraryButton'); + const SessionsBrowserButton = useSlot('SessionsBrowserButton'); + const SchedulesButton = useSlot('SchedulesButton'); const SessionsPage = useSlot('SessionsPage'); const SaveAgentButton = useSlot('SaveAgentButton'); const SelectAgentEmptyState = useSlot('SelectAgentEmptyState'); + const DraftAgentConfigTrigger = useSlot('DraftAgentConfigTrigger'); const UserAvatar = useSlot('UserAvatar'); const isIdle = shell?.mode.status === 'idle'; const settingsOpen = shell?.settingsOpen === true; const libraryOpen = shell?.libraryOpen === true; const sessionsOpen = shell?.sessionsOpen === true; const schedulesOpen = shell?.schedulesOpen === true; + const overlayOpen = settingsOpen || libraryOpen || sessionsOpen || schedulesOpen; const showNewActions = shell?.isNewChatEnabled !== false; + const isCreateAgent = shell != null && shellIsCreateAgent(shell.mode); + const showConfigReopen = isCreateAgent && !overlayOpen && shell?.agentConfigOpen !== true; useEffect(() => { if (isIdle) return; @@ -72,6 +79,13 @@ export function StackChatPanel({ className, threadHeaderEnd }: StackChatPanelPro } }; + const handleBackToChat = () => { + shell?.setLibraryOpen(false); + shell?.setSessionsOpen(false); + shell?.setSchedulesOpen(false); + shell?.setSettingsOpen(false); + }; + return (
{settingsOpen ? ( @@ -156,6 +170,7 @@ export function StackChatPanel({ className, threadHeaderEnd }: StackChatPanelPro <> + {showConfigReopen ? : null} {threadHeaderEnd} } @@ -163,25 +178,32 @@ export function StackChatPanel({ className, threadHeaderEnd }: StackChatPanelPro
{isIdle ? : }
)} - {/* Stable mount: only ShellActions needs to survive Settings / list / thread; host end chrome stays in the thread header. */} + {/* Stable mount: ShellActions + nav survive Settings / list / thread; widget close stays visible on overlays. */}
- {libraryOpen || schedulesOpen ? ( + {overlayOpen ? ( ) : null} + {!overlayOpen ? ( + <> + + + + + ) : null}
- +
+ + {overlayOpen ? threadHeaderEnd : null} +
{shell?.agentConfigOpen ? (