feat(webui): add tabbed pane workbench (#5322)

This commit is contained in:
chengyongru
2026-08-12 14:45:12 +08:00
committed by GitHub
parent 1656664a47
commit 4b5319b760
38 changed files with 5706 additions and 527 deletions
@@ -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",
+22 -13
View File
@@ -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>
+63 -23
View File
@@ -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}
+55 -42
View File
@@ -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>