feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)
* feat(desktop): add native host scaffold * feat(webui): track turns and usage in gateway * feat(webui): polish desktop chat experience * feat(apps): add ArcGIS and Joplin logos * feat(desktop): polish shell and shared surfaces * fix(webui): avoid preview chips for glob references * test: align CI expectations for token fallback * feat(webui): preview prompt rail entries * feat(webui): add prompt navigator drawer * style(webui): refine prompt navigator placement * style(webui): align prompt navigator with header actions * style(webui): simplify prompt navigator header * refactor(webui): clean thread resource refresh * feat(desktop): add native reply notifications * fix(webui): preserve desktop restart and replay state * fix(desktop): harden gateway proxy startup * fix(web): fall back when readability is unavailable * fix(desktop): hide window instead of closing on macos * fix(webui): unify desktop header actions * fix(webui): simplify prompt history rows * fix(desktop): log notification delivery failures * chore(desktop): clean source package artifacts * fix(cron): support one-time relative reminders * fix(webui): reveal scroll button in place * Revert "fix(cron): support one-time relative reminders" This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b. * refactor(webui): extract token usage heatmap * docs(desktop): clarify contributor guides --------- Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
||||
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
|
||||
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
|
||||
import { useSessionHistory } from "@/hooks/useSessions";
|
||||
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
|
||||
@@ -21,8 +25,6 @@ import {
|
||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||
import type {
|
||||
ChatSummary,
|
||||
CliAppInfo,
|
||||
McpPresetInfo,
|
||||
SettingsPayload,
|
||||
SlashCommand,
|
||||
UIMessage,
|
||||
@@ -51,6 +53,23 @@ function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boo
|
||||
return snapshot.every((message, index) => sameMessageShape(current[index], message));
|
||||
}
|
||||
|
||||
const FILE_PREVIEW_DEFAULT_WIDTH = 544;
|
||||
const FILE_PREVIEW_MIN_WIDTH = 360;
|
||||
const FILE_PREVIEW_MAX_WIDTH = 860;
|
||||
const FILE_PREVIEW_MIN_MAIN_WIDTH = 420;
|
||||
const FILE_PREVIEW_CLOSE_ANIMATION_MS = 320;
|
||||
|
||||
function clampFilePreviewWidth(width: number, maxWidth: number): number {
|
||||
return Math.min(Math.max(width, FILE_PREVIEW_MIN_WIDTH), maxWidth);
|
||||
}
|
||||
|
||||
function maxFilePreviewWidth(containerWidth: number): number {
|
||||
return Math.max(
|
||||
FILE_PREVIEW_MIN_WIDTH,
|
||||
Math.min(FILE_PREVIEW_MAX_WIDTH, containerWidth - FILE_PREVIEW_MIN_MAIN_WIDTH),
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadShellProps {
|
||||
session: ChatSummary | null;
|
||||
title: string;
|
||||
@@ -62,6 +81,7 @@ interface ThreadShellProps {
|
||||
theme?: "light" | "dark";
|
||||
onToggleTheme?: () => void;
|
||||
hideSidebarToggleForHostChrome?: boolean;
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
hideHeader?: boolean;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
@@ -71,6 +91,7 @@ interface ThreadShellProps {
|
||||
workspaceError?: string | null;
|
||||
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
||||
settingsSnapshot?: SettingsPayload | null;
|
||||
onOpenModelSettings?: () => void;
|
||||
}
|
||||
|
||||
function toModelBadgeLabel(modelName: string | null): string | null {
|
||||
@@ -85,6 +106,7 @@ interface ModelBadgeInfo {
|
||||
label: string | null;
|
||||
provider: string | null;
|
||||
providerLabel: string | null;
|
||||
needsSetup: boolean;
|
||||
}
|
||||
|
||||
function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["model_presets"][number] | null {
|
||||
@@ -107,12 +129,20 @@ function resolvedModelProvider(settings: SettingsPayload | null, modelName: stri
|
||||
}
|
||||
|
||||
function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload | null): ModelBadgeInfo {
|
||||
const label = toModelBadgeLabel(modelName || settings?.agent.model || null);
|
||||
const provider = resolvedModelProvider(settings, modelName || settings?.agent.model || null);
|
||||
const model = modelName || settings?.agent.model || null;
|
||||
const label = toModelBadgeLabel(model);
|
||||
const provider = resolvedModelProvider(settings, model);
|
||||
const providerRow = provider
|
||||
? settings?.providers.find((item) => item.name === provider)
|
||||
: null;
|
||||
const needsSetup = Boolean(
|
||||
settings && (!model || !provider || !providerRow || !providerRow.configured),
|
||||
);
|
||||
return {
|
||||
label,
|
||||
provider,
|
||||
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
|
||||
needsSetup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,6 +164,63 @@ interface PendingFirstMessage {
|
||||
options?: SendOptions;
|
||||
}
|
||||
|
||||
interface InstalledSettingItemsOptions<Payload, Item> {
|
||||
token: string;
|
||||
eventName: string;
|
||||
fetchPayload: (token: string) => Promise<Payload>;
|
||||
isPayload: (value: unknown) => value is Payload;
|
||||
selectItems: (payload: Payload) => Item[];
|
||||
}
|
||||
|
||||
function useInstalledSettingItems<Payload, Item>({
|
||||
token,
|
||||
eventName,
|
||||
fetchPayload,
|
||||
isPayload,
|
||||
selectItems,
|
||||
}: InstalledSettingItemsOptions<Payload, Item>): Item[] {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
|
||||
const refresh = useCallback(async (isCancelled?: () => boolean) => {
|
||||
try {
|
||||
const payload = await fetchPayload(token);
|
||||
if (!isCancelled?.()) setItems(selectItems(payload));
|
||||
} catch {
|
||||
if (!isCancelled?.()) setItems([]);
|
||||
}
|
||||
}, [fetchPayload, selectItems, token]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void refresh(() => cancelled);
|
||||
|
||||
const refreshOnFocus = () => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void refresh();
|
||||
};
|
||||
const refreshOnChanged = (event: Event) => {
|
||||
const payload = (event as CustomEvent<unknown>).detail;
|
||||
if (isPayload(payload)) {
|
||||
setItems(selectItems(payload));
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
document.addEventListener("visibilitychange", refreshOnFocus);
|
||||
window.addEventListener(eventName, refreshOnChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
||||
window.removeEventListener(eventName, refreshOnChanged);
|
||||
};
|
||||
}, [eventName, isPayload, refresh, selectItems]);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function ThreadShell({
|
||||
session,
|
||||
title,
|
||||
@@ -143,6 +230,7 @@ export function ThreadShell({
|
||||
theme = "light",
|
||||
onToggleTheme = () => {},
|
||||
hideSidebarToggleForHostChrome = false,
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
hideHeader = false,
|
||||
workspaceScope = null,
|
||||
@@ -152,6 +240,7 @@ export function ThreadShell({
|
||||
workspaceError = null,
|
||||
onWorkspaceScopeChange,
|
||||
settingsSnapshot = null,
|
||||
onOpenModelSettings,
|
||||
}: ThreadShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const chatId = session?.chatId ?? null;
|
||||
@@ -166,12 +255,31 @@ export function ThreadShell({
|
||||
const { client, modelName, token } = useClient();
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
|
||||
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
|
||||
const cliApps = useInstalledSettingItems({
|
||||
token,
|
||||
eventName: CLI_APPS_CHANGED_EVENT,
|
||||
fetchPayload: fetchCliApps,
|
||||
isPayload: isCliAppsPayload,
|
||||
selectItems: installedCliAppsFromPayload,
|
||||
});
|
||||
const mcpPresets = useInstalledSettingItems({
|
||||
token,
|
||||
eventName: MCP_PRESETS_CHANGED_EVENT,
|
||||
fetchPayload: fetchMcpPresets,
|
||||
isPayload: isMcpPresetsPayload,
|
||||
selectItems: installedMcpPresetsFromPayload,
|
||||
});
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
|
||||
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
|
||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
|
||||
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
|
||||
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||
const shellRef = useRef<HTMLElement | null>(null);
|
||||
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||
const filePreviewCloseTimerRef = useRef<number | null>(null);
|
||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||
@@ -204,6 +312,27 @@ export function ThreadShell({
|
||||
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
|
||||
}, [chatId, historyKey]);
|
||||
|
||||
useEffect(() => {
|
||||
filePreviewWidthRef.current = filePreviewWidth;
|
||||
}, [filePreviewWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filePreviewCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(filePreviewCloseTimerRef.current);
|
||||
filePreviewCloseTimerRef.current = null;
|
||||
}
|
||||
setFilePreviewClosing(false);
|
||||
setFilePreviewPath(null);
|
||||
}, [historyKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (filePreviewCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(filePreviewCloseTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
||||
|
||||
const showHeroComposer = messages.length === 0 && !loading;
|
||||
@@ -212,6 +341,9 @@ export function ThreadShell({
|
||||
() => toModelBadgeInfo(modelName, settings),
|
||||
[modelName, settings],
|
||||
);
|
||||
const modelBadgeLabel = modelBadge.needsSetup
|
||||
? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" })
|
||||
: modelBadge.label;
|
||||
useEffect(() => {
|
||||
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
|
||||
setHeroGreetingKey(randomHeroGreetingKey());
|
||||
@@ -372,94 +504,6 @@ export function ThreadShell({
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const refreshCliApps = useCallback(async () => {
|
||||
try {
|
||||
const payload = await fetchCliApps(token);
|
||||
setCliApps(installedCliAppsFromPayload(payload));
|
||||
} catch {
|
||||
setCliApps([]);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const refreshMcpPresets = useCallback(async () => {
|
||||
try {
|
||||
const payload = await fetchMcpPresets(token);
|
||||
setMcpPresets(installedMcpPresetsFromPayload(payload));
|
||||
} catch {
|
||||
setMcpPresets([]);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const payload = await fetchCliApps(token);
|
||||
if (!cancelled) setCliApps(installedCliAppsFromPayload(payload));
|
||||
} catch {
|
||||
if (!cancelled) setCliApps([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
|
||||
const refreshOnFocus = () => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void refreshCliApps();
|
||||
};
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
document.addEventListener("visibilitychange", refreshOnFocus);
|
||||
const refreshOnCliAppsChanged = (event: Event) => {
|
||||
const payload = (event as CustomEvent<unknown>).detail;
|
||||
if (isCliAppsPayload(payload)) {
|
||||
setCliApps(installedCliAppsFromPayload(payload));
|
||||
return;
|
||||
}
|
||||
void refreshCliApps();
|
||||
};
|
||||
window.addEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
||||
window.removeEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
|
||||
};
|
||||
}, [refreshCliApps, token]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const payload = await fetchMcpPresets(token);
|
||||
if (!cancelled) setMcpPresets(installedMcpPresetsFromPayload(payload));
|
||||
} catch {
|
||||
if (!cancelled) setMcpPresets([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
|
||||
const refreshOnFocus = () => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void refreshMcpPresets();
|
||||
};
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
document.addEventListener("visibilitychange", refreshOnFocus);
|
||||
const refreshOnMcpPresetsChanged = (event: Event) => {
|
||||
const payload = (event as CustomEvent<unknown>).detail;
|
||||
if (isMcpPresetsPayload(payload)) {
|
||||
setMcpPresets(installedMcpPresetsFromPayload(payload));
|
||||
return;
|
||||
}
|
||||
void refreshMcpPresets();
|
||||
};
|
||||
window.addEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
||||
window.removeEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
|
||||
};
|
||||
}, [refreshMcpPresets, token]);
|
||||
|
||||
const handleWelcomeSend = useCallback(
|
||||
async (content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
if (booting) return;
|
||||
@@ -482,6 +526,94 @@ export function ThreadShell({
|
||||
[send, withWorkspaceScope],
|
||||
);
|
||||
|
||||
const handleOpenFilePreview = useCallback((path: string) => {
|
||||
if (filePreviewCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(filePreviewCloseTimerRef.current);
|
||||
filePreviewCloseTimerRef.current = null;
|
||||
}
|
||||
setFilePreviewClosing(false);
|
||||
setFilePreviewPath(path);
|
||||
}, []);
|
||||
|
||||
const handleCloseFilePreview = useCallback(() => {
|
||||
if (!filePreviewPath || filePreviewClosing) return;
|
||||
setFilePreviewClosing(true);
|
||||
filePreviewCloseTimerRef.current = window.setTimeout(() => {
|
||||
filePreviewCloseTimerRef.current = null;
|
||||
setFilePreviewPath(null);
|
||||
setFilePreviewClosing(false);
|
||||
}, FILE_PREVIEW_CLOSE_ANIMATION_MS);
|
||||
}, [filePreviewClosing, filePreviewPath]);
|
||||
|
||||
const handleFilePreviewResizeStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const panel = event.currentTarget.closest<HTMLElement>("[data-file-preview-panel]");
|
||||
const shellRect = shellRef.current?.getBoundingClientRect();
|
||||
const rightEdge = shellRect?.right ?? window.innerWidth;
|
||||
const maxWidth = maxFilePreviewWidth(shellRect?.width ?? window.innerWidth);
|
||||
const originalBodyCursor = document.body.style.cursor;
|
||||
const originalBodyUserSelect = document.body.style.userSelect;
|
||||
const originalPanelTransition = panel?.style.transition ?? "";
|
||||
let nextWidth = filePreviewWidthRef.current;
|
||||
let frame: number | null = null;
|
||||
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
if (panel) panel.style.transition = "none";
|
||||
|
||||
const applyWidth = (clientX: number) => {
|
||||
nextWidth = clampFilePreviewWidth(rightEdge - clientX, maxWidth);
|
||||
filePreviewWidthRef.current = nextWidth;
|
||||
if (frame !== null) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
panel?.style.setProperty("--file-preview-width", `${nextWidth}px`);
|
||||
panel?.style.setProperty("--file-preview-slot-width", `${nextWidth}px`);
|
||||
});
|
||||
};
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
moveEvent.preventDefault();
|
||||
applyWidth(moveEvent.clientX);
|
||||
};
|
||||
const handlePointerUp = () => {
|
||||
if (frame !== null) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = null;
|
||||
}
|
||||
panel?.style.setProperty("--file-preview-width", `${nextWidth}px`);
|
||||
panel?.style.setProperty("--file-preview-slot-width", `${nextWidth}px`);
|
||||
if (panel) panel.style.transition = originalPanelTransition;
|
||||
setFilePreviewWidth(nextWidth);
|
||||
document.body.style.cursor = originalBodyCursor;
|
||||
document.body.style.userSelect = originalBodyUserSelect;
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", handlePointerUp);
|
||||
};
|
||||
|
||||
applyWidth(event.clientX);
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filePreviewPath) return;
|
||||
const clampToShell = () => {
|
||||
const shellWidth = shellRef.current?.getBoundingClientRect().width ?? window.innerWidth;
|
||||
const maxWidth = maxFilePreviewWidth(shellWidth);
|
||||
const nextWidth = clampFilePreviewWidth(filePreviewWidthRef.current, maxWidth);
|
||||
filePreviewWidthRef.current = nextWidth;
|
||||
setFilePreviewWidth(nextWidth);
|
||||
};
|
||||
clampToShell();
|
||||
window.addEventListener("resize", clampToShell);
|
||||
return () => {
|
||||
window.removeEventListener("resize", clampToShell);
|
||||
};
|
||||
}, [filePreviewPath]);
|
||||
|
||||
const composer = (
|
||||
<>
|
||||
{streamError ? (
|
||||
@@ -500,9 +632,11 @@ export function ThreadShell({
|
||||
? t("thread.composer.placeholderHero")
|
||||
: t("thread.composer.placeholderThread")
|
||||
}
|
||||
modelLabel={modelBadge.label}
|
||||
modelLabel={modelBadgeLabel}
|
||||
modelProvider={modelBadge.provider}
|
||||
modelProviderLabel={modelBadge.providerLabel}
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
@@ -528,9 +662,11 @@ export function ThreadShell({
|
||||
? t("thread.composer.placeholderOpening")
|
||||
: t("thread.composer.placeholderHero")
|
||||
}
|
||||
modelLabel={modelBadge.label}
|
||||
modelLabel={modelBadgeLabel}
|
||||
modelProvider={modelBadge.provider}
|
||||
modelProviderLabel={modelBadge.providerLabel}
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant="hero"
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
@@ -559,31 +695,58 @@ export function ThreadShell({
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
const sessionInfoAction = historyKey ? (
|
||||
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
|
||||
) : undefined;
|
||||
const promptNavigatorAction = historyKey ? (
|
||||
<PromptNavigator
|
||||
messages={displayMessages}
|
||||
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{!hideHeader ? (
|
||||
<ThreadHeader
|
||||
title={title}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||
hideThemeButton={hideThemeButton}
|
||||
minimal={!session && !loading}
|
||||
<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}
|
||||
/>
|
||||
) : null}
|
||||
<ThreadViewport
|
||||
ref={viewportRef}
|
||||
messages={displayMessages}
|
||||
isStreaming={isStreaming}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
conversationKey={historyKey}
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
||||
/>
|
||||
</div>
|
||||
{filePreviewPath && historyKey ? (
|
||||
<FilePreviewPanel
|
||||
sessionKey={historyKey}
|
||||
path={filePreviewPath}
|
||||
token={token}
|
||||
desktopWidth={filePreviewWidth}
|
||||
isClosing={filePreviewClosing}
|
||||
onResizeStart={handleFilePreviewResizeStart}
|
||||
onClose={handleCloseFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
<ThreadViewport
|
||||
messages={displayMessages}
|
||||
isStreaming={isStreaming}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
conversationKey={historyKey}
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user