feat(webui): add tabbed pane workbench (#5322)
This commit is contained in:
@@ -186,6 +186,7 @@ interface ThreadComposerProps {
|
||||
) => boolean | void | Promise<boolean | void>;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
inputAriaLabel?: string;
|
||||
isStreaming?: boolean;
|
||||
modelLabel?: string | null;
|
||||
modelDetail?: string | null;
|
||||
@@ -940,6 +941,7 @@ export function ThreadComposer({
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
inputAriaLabel,
|
||||
isStreaming = false,
|
||||
modelLabel = null,
|
||||
modelDetail = null,
|
||||
@@ -2400,7 +2402,7 @@ export function ThreadComposer({
|
||||
rows={1}
|
||||
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
||||
disabled={interactionDisabled}
|
||||
aria-label={t("thread.composer.inputAria")}
|
||||
aria-label={inputAriaLabel ?? t("thread.composer.inputAria")}
|
||||
className={cn(
|
||||
inputTextClasses,
|
||||
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
||||
|
||||
@@ -17,8 +17,11 @@ interface ThreadHeaderProps {
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
hideSidebarToggleForHostChrome?: boolean;
|
||||
hideSidebarToggle?: boolean;
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
hideTitle?: boolean;
|
||||
actions?: ReactNode;
|
||||
minimal?: boolean;
|
||||
promptNavigatorAction?: ReactNode;
|
||||
sessionInfoAction?: ReactNode;
|
||||
@@ -33,8 +36,11 @@ export function ThreadHeader({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
hideSidebarToggleForHostChrome = false,
|
||||
hideSidebarToggle = false,
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
hideTitle = false,
|
||||
actions,
|
||||
minimal = false,
|
||||
promptNavigatorAction,
|
||||
sessionInfoAction,
|
||||
@@ -54,19 +60,21 @@ export function ThreadHeader({
|
||||
)}
|
||||
>
|
||||
<div className="relative flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("thread.header.toggleSidebar")}
|
||||
onClick={onToggleSidebar}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
||||
hideSidebarToggleForHostChrome && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<Menu className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{!minimal ? (
|
||||
{!hideSidebarToggle ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("thread.header.toggleSidebar")}
|
||||
onClick={onToggleSidebar}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
||||
hideSidebarToggleForHostChrome && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<Menu className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{!minimal && !hideTitle ? (
|
||||
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
||||
</div>
|
||||
@@ -76,6 +84,7 @@ export function ThreadHeader({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{sessionInfoAction}
|
||||
{promptNavigatorAction}
|
||||
{actions}
|
||||
{onTemporaryChatEnabledChange ? (
|
||||
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
|
||||
<Tooltip>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||
@@ -311,9 +312,18 @@ interface ThreadShellProps {
|
||||
theme?: "light" | "dark";
|
||||
onToggleTheme?: () => void;
|
||||
hideSidebarToggleForHostChrome?: boolean;
|
||||
hideSidebarToggle?: boolean;
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
hideHeaderTitle?: boolean;
|
||||
hideHeader?: boolean;
|
||||
headerActions?: ReactNode;
|
||||
headerPortalTarget?: HTMLElement | null;
|
||||
headerActive?: boolean;
|
||||
composerPortalTarget?: HTMLElement | null;
|
||||
composerActive?: boolean;
|
||||
composerInputAriaLabel?: string;
|
||||
emptyComposerVariant?: "hero" | "thread";
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||
@@ -598,9 +608,18 @@ export function ThreadShell({
|
||||
theme = "light",
|
||||
onToggleTheme = () => {},
|
||||
hideSidebarToggleForHostChrome = false,
|
||||
hideSidebarToggle = false,
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
hideHeaderTitle = false,
|
||||
hideHeader = false,
|
||||
headerActions,
|
||||
headerPortalTarget,
|
||||
headerActive = true,
|
||||
composerPortalTarget,
|
||||
composerActive = true,
|
||||
composerInputAriaLabel,
|
||||
emptyComposerVariant = "hero",
|
||||
workspaceScope = null,
|
||||
workspaceDefaultScope = null,
|
||||
workspaceControls = null,
|
||||
@@ -843,6 +862,7 @@ export function ThreadShell({
|
||||
]);
|
||||
|
||||
const showHeroComposer = displayMessages.length === 0 && !loading;
|
||||
const composerVariant = showHeroComposer ? emptyComposerVariant : "thread";
|
||||
const wasShowingHeroComposerRef = useRef(showHeroComposer);
|
||||
const sessionModelPreset = session?.modelPreset?.trim() || null;
|
||||
const [localModelPreset, setLocalModelPreset] = useState<string | null>(null);
|
||||
@@ -1405,9 +1425,10 @@ export function ThreadShell({
|
||||
<ThreadComposer
|
||||
onSend={handleThreadSend}
|
||||
disabled={!chatId}
|
||||
inputAriaLabel={composerInputAriaLabel}
|
||||
isStreaming={turnActive}
|
||||
placeholder={
|
||||
showHeroComposer
|
||||
composerVariant === "hero"
|
||||
? t("thread.composer.placeholderHero")
|
||||
: t("thread.composer.placeholderThread")
|
||||
}
|
||||
@@ -1421,7 +1442,7 @@ export function ThreadShell({
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
variant={composerVariant}
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
@@ -1449,6 +1470,7 @@ export function ThreadShell({
|
||||
<ThreadComposer
|
||||
onSend={handleWelcomeSend}
|
||||
disabled={booting}
|
||||
inputAriaLabel={composerInputAriaLabel}
|
||||
isStreaming={turnActive}
|
||||
placeholder={
|
||||
booting
|
||||
@@ -1508,28 +1530,33 @@ export function ThreadShell({
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
const threadHeader = !hideHeader ? (
|
||||
<ThreadHeader
|
||||
title={title}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||
hideSidebarToggle={hideSidebarToggle}
|
||||
hostChromeTitleInset={hostChromeTitleInset}
|
||||
hideThemeButton={hideThemeButton}
|
||||
hideTitle={hideHeaderTitle}
|
||||
actions={headerActions}
|
||||
minimal={!session && !loading}
|
||||
promptNavigatorAction={promptNavigatorAction}
|
||||
sessionInfoAction={sessionInfoAction}
|
||||
temporaryChatEnabled={temporaryChatEnabled}
|
||||
temporaryChatDisabled={booting || turnActive}
|
||||
onTemporaryChatEnabledChange={
|
||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{!hideHeader ? (
|
||||
<ThreadHeader
|
||||
title={title}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||
hostChromeTitleInset={hostChromeTitleInset}
|
||||
hideThemeButton={hideThemeButton}
|
||||
minimal={!session && !loading}
|
||||
promptNavigatorAction={promptNavigatorAction}
|
||||
sessionInfoAction={sessionInfoAction}
|
||||
temporaryChatEnabled={temporaryChatEnabled}
|
||||
temporaryChatDisabled={booting || turnActive}
|
||||
onTemporaryChatEnabledChange={
|
||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{headerPortalTarget === undefined ? threadHeader : null}
|
||||
<FilePreviewAvailabilityProvider
|
||||
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
||||
>
|
||||
@@ -1539,7 +1566,7 @@ export function ThreadShell({
|
||||
temporary={temporary}
|
||||
isStreaming={turnActive}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
composer={composerPortalTarget === undefined ? composer : null}
|
||||
activeTurnId={viewportTurnId}
|
||||
activeTurnStartedHere={activeTurnStartedHere}
|
||||
conversationKey={historyKey}
|
||||
@@ -1559,6 +1586,19 @@ export function ThreadShell({
|
||||
/>
|
||||
</FilePreviewAvailabilityProvider>
|
||||
</div>
|
||||
{headerPortalTarget && headerActive
|
||||
? createPortal(threadHeader, headerPortalTarget)
|
||||
: null}
|
||||
{composerPortalTarget ? createPortal(
|
||||
<div
|
||||
hidden={!composerActive}
|
||||
aria-hidden={!composerActive}
|
||||
data-testid={composerActive ? "active-pane-composer" : undefined}
|
||||
>
|
||||
{composer}
|
||||
</div>,
|
||||
composerPortalTarget,
|
||||
) : null}
|
||||
{filePreviewPath && historyKey ? (
|
||||
<FilePreviewPanel
|
||||
sessionKey={historyKey}
|
||||
|
||||
@@ -37,7 +37,7 @@ interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
temporary?: boolean;
|
||||
isStreaming: boolean;
|
||||
composer: ReactNode;
|
||||
composer?: ReactNode;
|
||||
emptyState?: ReactNode;
|
||||
scrollToBottomSignal?: number;
|
||||
activeTurnId?: string | null;
|
||||
@@ -61,6 +61,7 @@ interface ThreadViewportProps {
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
const NEAR_TOP_PX = 96;
|
||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
|
||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||
export const INITIAL_HISTORY_WINDOW = 160;
|
||||
@@ -266,11 +267,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||
? forkBoundaryMessageCount - hiddenMessageCount
|
||||
: null;
|
||||
const hasComposer = composer !== null && composer !== undefined;
|
||||
const scrollButtonBottom =
|
||||
keyboardInsetBottom
|
||||
+ (composerDockHeight > 0
|
||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
|
||||
: hasComposer
|
||||
? DEFAULT_SCROLL_BUTTON_BOTTOM_PX
|
||||
: EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX);
|
||||
const scrollViewportStyle =
|
||||
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
||||
|
||||
@@ -661,7 +665,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
|
||||
data-layout={hasMessages ? "thread" : "hero"}
|
||||
data-layout={hasComposer ? (hasMessages ? "thread" : "hero") : "external"}
|
||||
className={cn(
|
||||
"thread-layout mx-auto grid min-h-full w-full",
|
||||
hasMessages
|
||||
@@ -699,58 +703,67 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
|
||||
<div
|
||||
className={cn(
|
||||
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
|
||||
hasComposer && "sm:items-end sm:pb-8",
|
||||
)}
|
||||
>
|
||||
{emptyState}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
data-testid="thread-composer-dock"
|
||||
onInputCapture={(event) => {
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null;
|
||||
threadMotionRef.current?.handleComposerInput();
|
||||
}
|
||||
}}
|
||||
onInput={(event) => {
|
||||
if (!(event.target instanceof HTMLTextAreaElement)) return;
|
||||
const previousScrollTop = composerInputScrollTopRef.current;
|
||||
composerInputScrollTopRef.current = null;
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl && previousScrollTop !== null) {
|
||||
// Textarea autosizing briefly collapses to `height: auto` while
|
||||
// measuring. Chrome can clamp the sibling thread scrollport in
|
||||
// that intermediate layout; restore it before paint, then let
|
||||
// ResizeObserver handle any real final composer height change.
|
||||
scrollEl.scrollTop = previousScrollTop;
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"row-start-2 z-10 w-full",
|
||||
hasMessages ? "relative bg-background" : "relative self-center",
|
||||
)}
|
||||
>
|
||||
{hasComposer ? (
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
data-testid="thread-composer-dock"
|
||||
onInputCapture={(event) => {
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null;
|
||||
threadMotionRef.current?.handleComposerInput();
|
||||
}
|
||||
}}
|
||||
onInput={(event) => {
|
||||
if (!(event.target instanceof HTMLTextAreaElement)) return;
|
||||
const previousScrollTop = composerInputScrollTopRef.current;
|
||||
composerInputScrollTopRef.current = null;
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl && previousScrollTop !== null) {
|
||||
// Textarea autosizing briefly collapses to `height: auto` while
|
||||
// measuring. Chrome can clamp the sibling thread scrollport in
|
||||
// that intermediate layout; restore it before paint, then let
|
||||
// ResizeObserver handle any real final composer height change.
|
||||
scrollEl.scrollTop = previousScrollTop;
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
hasMessages
|
||||
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
|
||||
: "",
|
||||
"row-start-2 z-10 w-full",
|
||||
hasMessages ? "relative bg-background" : "relative self-center",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="thread-composer-motion"
|
||||
className="mx-auto w-full max-w-[58rem]"
|
||||
className={cn(
|
||||
hasMessages
|
||||
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{composer}
|
||||
<div
|
||||
data-testid="thread-composer-motion"
|
||||
className="mx-auto w-full max-w-[58rem]"
|
||||
>
|
||||
{composer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
||||
/>
|
||||
{hasComposer ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user