2026-05-06 15:54:15 +00:00
|
|
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
2026-06-06 19:49:33 +08:00
|
|
|
import type { PointerEvent as ReactPointerEvent } from "react";
|
2026-04-19 06:39:06 +00:00
|
|
|
import { useTranslation } from "react-i18next";
|
2026-04-18 18:51:53 +00:00
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
|
|
|
|
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
|
|
|
|
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
2026-04-18 18:51:53 +00:00
|
|
|
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
|
|
|
|
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
2026-04-21 10:29:47 +00:00
|
|
|
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
2026-06-06 19:49:33 +08:00
|
|
|
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
|
2026-05-08 09:40:15 +00:00
|
|
|
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
|
2026-04-18 18:51:53 +00:00
|
|
|
import { useSessionHistory } from "@/hooks/useSessions";
|
2026-06-13 13:26:49 +08:00
|
|
|
import {
|
|
|
|
|
fetchInstalledCliApps,
|
|
|
|
|
fetchMcpPresets,
|
|
|
|
|
fetchSettings,
|
|
|
|
|
listSlashCommands,
|
|
|
|
|
} from "@/lib/api";
|
2026-05-23 00:59:54 +08:00
|
|
|
import {
|
|
|
|
|
CLI_APPS_CHANGED_EVENT,
|
|
|
|
|
installedCliAppsFromPayload,
|
|
|
|
|
isCliAppsPayload,
|
|
|
|
|
} from "@/lib/cli-app-events";
|
2026-05-24 13:38:37 +08:00
|
|
|
import {
|
|
|
|
|
MCP_PRESETS_CHANGED_EVENT,
|
|
|
|
|
installedMcpPresetsFromPayload,
|
|
|
|
|
isMcpPresetsPayload,
|
|
|
|
|
} from "@/lib/mcp-preset-events";
|
|
|
|
|
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
2026-05-29 03:42:53 +08:00
|
|
|
import type {
|
|
|
|
|
ChatSummary,
|
|
|
|
|
SettingsPayload,
|
|
|
|
|
SlashCommand,
|
|
|
|
|
UIMessage,
|
|
|
|
|
WorkspaceScopePayload,
|
|
|
|
|
WorkspacesPayload,
|
|
|
|
|
} from "@/lib/types";
|
2026-05-16 01:14:11 +08:00
|
|
|
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
|
|
|
|
|
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
|
2026-04-18 18:51:53 +00:00
|
|
|
import { useClient } from "@/providers/ClientProvider";
|
|
|
|
|
|
2026-05-16 01:14:11 +08:00
|
|
|
function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
|
|
|
|
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 20:56:07 +08:00
|
|
|
function sameMessageShape(a: UIMessage, b: UIMessage): boolean {
|
|
|
|
|
return (
|
|
|
|
|
a.role === b.role
|
|
|
|
|
&& (a.kind ?? "") === (b.kind ?? "")
|
|
|
|
|
&& a.content === b.content
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boolean {
|
|
|
|
|
if (current.length === 0 || snapshot.length >= current.length) return false;
|
|
|
|
|
if (snapshot.length === 0) return true;
|
|
|
|
|
return snapshot.every((message, index) => sameMessageShape(current[index], message));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
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),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 18:51:53 +00:00
|
|
|
interface ThreadShellProps {
|
|
|
|
|
session: ChatSummary | null;
|
|
|
|
|
title: string;
|
|
|
|
|
onToggleSidebar: () => void;
|
2026-05-06 14:15:36 +00:00
|
|
|
onGoHome?: () => void;
|
|
|
|
|
onNewChat?: () => void;
|
2026-05-29 03:42:53 +08:00
|
|
|
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
|
2026-06-05 19:49:34 +08:00
|
|
|
onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise<string | null>;
|
2026-05-06 14:15:36 +00:00
|
|
|
onTurnEnd?: () => void;
|
|
|
|
|
theme?: "light" | "dark";
|
|
|
|
|
onToggleTheme?: () => void;
|
2026-05-29 03:42:53 +08:00
|
|
|
hideSidebarToggleForHostChrome?: boolean;
|
2026-06-06 19:49:33 +08:00
|
|
|
hostChromeTitleInset?: boolean;
|
2026-05-31 17:00:42 +08:00
|
|
|
hideThemeButton?: boolean;
|
2026-05-29 03:42:53 +08:00
|
|
|
hideHeader?: boolean;
|
|
|
|
|
workspaceScope?: WorkspaceScopePayload | null;
|
|
|
|
|
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
|
|
|
|
workspaceControls?: WorkspacesPayload["controls"] | null;
|
|
|
|
|
workspaceScopeDisabled?: boolean;
|
|
|
|
|
workspaceError?: string | null;
|
|
|
|
|
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
|
|
|
|
settingsSnapshot?: SettingsPayload | null;
|
2026-06-06 19:49:33 +08:00
|
|
|
onOpenModelSettings?: () => void;
|
2026-04-18 18:51:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toModelBadgeLabel(modelName: string | null): string | null {
|
|
|
|
|
if (!modelName) return null;
|
|
|
|
|
const trimmed = modelName.trim();
|
|
|
|
|
if (!trimmed) return null;
|
|
|
|
|
const leaf = trimmed.split("/").pop() ?? trimmed;
|
|
|
|
|
return leaf || trimmed;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
interface ModelBadgeInfo {
|
|
|
|
|
label: string | null;
|
|
|
|
|
provider: string | null;
|
|
|
|
|
providerLabel: string | null;
|
2026-06-06 19:49:33 +08:00
|
|
|
needsSetup: boolean;
|
2026-05-24 13:38:37 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["model_presets"][number] | null {
|
|
|
|
|
if (!settings) return null;
|
|
|
|
|
const configured = settings.agent.model_preset || "default";
|
|
|
|
|
return (
|
|
|
|
|
settings.model_presets.find((preset) => preset.name === configured)
|
|
|
|
|
?? settings.model_presets.find((preset) => preset.active)
|
|
|
|
|
?? null
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolvedModelProvider(settings: SettingsPayload | null, modelName: string | null): string | null {
|
|
|
|
|
const preset = activeModelPreset(settings);
|
|
|
|
|
const rawProvider = preset?.provider || settings?.agent.provider || null;
|
|
|
|
|
if (rawProvider === "auto") {
|
|
|
|
|
return settings?.agent.resolved_provider || inferProviderFromModelName(modelName) || null;
|
|
|
|
|
}
|
|
|
|
|
return rawProvider || inferProviderFromModelName(modelName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload | null): ModelBadgeInfo {
|
2026-06-06 19:49:33 +08:00
|
|
|
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),
|
|
|
|
|
);
|
2026-05-24 13:38:37 +08:00
|
|
|
return {
|
|
|
|
|
label,
|
|
|
|
|
provider,
|
|
|
|
|
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
|
2026-06-06 19:49:33 +08:00
|
|
|
needsSetup,
|
2026-05-24 13:38:37 +08:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
const HERO_GREETING_KEYS = [
|
|
|
|
|
"thread.empty.greetings.workOn",
|
|
|
|
|
"thread.empty.greetings.start",
|
|
|
|
|
"thread.empty.greetings.build",
|
|
|
|
|
"thread.empty.greetings.tackle",
|
2026-05-06 14:15:36 +00:00
|
|
|
] as const;
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
function randomHeroGreetingKey(): (typeof HERO_GREETING_KEYS)[number] {
|
|
|
|
|
const index = Math.floor(Math.random() * HERO_GREETING_KEYS.length);
|
|
|
|
|
return HERO_GREETING_KEYS[index] ?? HERO_GREETING_KEYS[0];
|
|
|
|
|
}
|
2026-05-08 09:40:15 +00:00
|
|
|
|
|
|
|
|
interface PendingFirstMessage {
|
|
|
|
|
content: string;
|
|
|
|
|
images?: SendImage[];
|
|
|
|
|
options?: SendOptions;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 18:51:53 +00:00
|
|
|
export function ThreadShell({
|
|
|
|
|
session,
|
|
|
|
|
title,
|
|
|
|
|
onToggleSidebar,
|
2026-05-06 14:15:36 +00:00
|
|
|
onCreateChat,
|
2026-06-05 19:49:34 +08:00
|
|
|
onForkChat,
|
2026-05-06 14:15:36 +00:00
|
|
|
onTurnEnd,
|
|
|
|
|
theme = "light",
|
|
|
|
|
onToggleTheme = () => {},
|
2026-05-29 03:42:53 +08:00
|
|
|
hideSidebarToggleForHostChrome = false,
|
2026-06-06 19:49:33 +08:00
|
|
|
hostChromeTitleInset = false,
|
2026-05-31 17:00:42 +08:00
|
|
|
hideThemeButton = false,
|
2026-05-29 03:42:53 +08:00
|
|
|
hideHeader = false,
|
|
|
|
|
workspaceScope = null,
|
|
|
|
|
workspaceDefaultScope = null,
|
|
|
|
|
workspaceControls = null,
|
|
|
|
|
workspaceScopeDisabled = false,
|
|
|
|
|
workspaceError = null,
|
|
|
|
|
onWorkspaceScopeChange,
|
|
|
|
|
settingsSnapshot = null,
|
2026-06-06 19:49:33 +08:00
|
|
|
onOpenModelSettings,
|
2026-04-18 18:51:53 +00:00
|
|
|
}: ThreadShellProps) {
|
2026-04-19 06:39:06 +00:00
|
|
|
const { t } = useTranslation();
|
2026-04-18 18:51:53 +00:00
|
|
|
const chatId = session?.chatId ?? null;
|
|
|
|
|
const historyKey = session?.key ?? null;
|
2026-05-13 16:39:07 +00:00
|
|
|
const {
|
|
|
|
|
messages: historical,
|
|
|
|
|
loading,
|
2026-06-10 18:02:27 +08:00
|
|
|
loadingOlder,
|
|
|
|
|
loadOlder,
|
|
|
|
|
hasMoreBefore,
|
|
|
|
|
userMessageOffset,
|
2026-05-13 16:39:07 +00:00
|
|
|
hasPendingToolCalls,
|
|
|
|
|
refresh: refreshHistory,
|
|
|
|
|
version: historyVersion,
|
2026-06-10 02:07:47 +08:00
|
|
|
forkBoundaryMessageCount,
|
2026-05-13 16:39:07 +00:00
|
|
|
} = useSessionHistory(historyKey);
|
|
|
|
|
const { client, modelName, token } = useClient();
|
2026-04-18 18:51:53 +00:00
|
|
|
const [booting, setBooting] = useState(false);
|
2026-05-06 15:54:15 +00:00
|
|
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
2026-06-06 19:49:33 +08:00
|
|
|
const cliApps = useInstalledSettingItems({
|
|
|
|
|
token,
|
|
|
|
|
eventName: CLI_APPS_CHANGED_EVENT,
|
2026-06-13 13:26:49 +08:00
|
|
|
fetchPayload: fetchInstalledCliApps,
|
2026-06-06 19:49:33 +08:00
|
|
|
isPayload: isCliAppsPayload,
|
|
|
|
|
selectItems: installedCliAppsFromPayload,
|
|
|
|
|
});
|
|
|
|
|
const mcpPresets = useInstalledSettingItems({
|
|
|
|
|
token,
|
|
|
|
|
eventName: MCP_PRESETS_CHANGED_EVENT,
|
|
|
|
|
fetchPayload: fetchMcpPresets,
|
|
|
|
|
isPayload: isMcpPresetsPayload,
|
|
|
|
|
selectItems: installedMcpPresetsFromPayload,
|
|
|
|
|
});
|
2026-05-29 03:42:53 +08:00
|
|
|
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
|
|
|
|
|
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
|
2026-05-13 16:39:07 +00:00
|
|
|
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
2026-06-06 19:49:33 +08:00
|
|
|
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);
|
2026-05-08 09:40:15 +00:00
|
|
|
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
2026-06-06 19:49:33 +08:00
|
|
|
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
2026-04-18 18:51:53 +00:00
|
|
|
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
2026-05-16 01:14:11 +08:00
|
|
|
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
|
|
|
|
const prevChatIdForCacheRef = useRef<string | null>(null);
|
|
|
|
|
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
|
|
|
|
const skipLayoutCacheRef = useRef(false);
|
2026-05-13 16:39:07 +00:00
|
|
|
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
|
|
|
|
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
2026-05-16 01:14:11 +08:00
|
|
|
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
2026-04-18 18:51:53 +00:00
|
|
|
|
|
|
|
|
const initial = useMemo(() => {
|
|
|
|
|
if (!chatId) return historical;
|
|
|
|
|
return messageCacheRef.current.get(chatId) ?? historical;
|
|
|
|
|
}, [chatId, historical]);
|
2026-05-13 16:39:07 +00:00
|
|
|
const handleTurnEnd = useCallback(() => {
|
|
|
|
|
onTurnEnd?.();
|
2026-05-16 08:33:15 +00:00
|
|
|
}, [onTurnEnd]);
|
2026-04-21 10:29:47 +00:00
|
|
|
const {
|
|
|
|
|
messages,
|
|
|
|
|
isStreaming,
|
2026-05-16 01:14:11 +08:00
|
|
|
runStartedAt,
|
|
|
|
|
goalState,
|
2026-04-21 10:29:47 +00:00
|
|
|
send,
|
2026-06-09 01:08:49 +08:00
|
|
|
transcribeAudio,
|
2026-05-08 09:40:15 +00:00
|
|
|
stop,
|
2026-04-21 10:29:47 +00:00
|
|
|
setMessages,
|
|
|
|
|
streamError,
|
|
|
|
|
dismissStreamError,
|
2026-05-13 16:39:07 +00:00
|
|
|
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
|
2026-05-16 01:14:11 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
|
|
|
|
|
}, [chatId, historyKey]);
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-16 01:14:11 +08:00
|
|
|
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
|
|
|
|
|
2026-04-18 18:51:53 +00:00
|
|
|
const showHeroComposer = messages.length === 0 && !loading;
|
2026-05-29 03:42:53 +08:00
|
|
|
const wasShowingHeroComposerRef = useRef(showHeroComposer);
|
2026-05-24 13:38:37 +08:00
|
|
|
const modelBadge = useMemo(
|
|
|
|
|
() => toModelBadgeInfo(modelName, settings),
|
|
|
|
|
[modelName, settings],
|
|
|
|
|
);
|
2026-06-06 19:49:33 +08:00
|
|
|
const modelBadgeLabel = modelBadge.needsSetup
|
|
|
|
|
? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" })
|
|
|
|
|
: modelBadge.label;
|
2026-05-29 03:42:53 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
|
|
|
|
|
setHeroGreetingKey(randomHeroGreetingKey());
|
|
|
|
|
}
|
|
|
|
|
wasShowingHeroComposerRef.current = showHeroComposer;
|
|
|
|
|
}, [showHeroComposer]);
|
|
|
|
|
|
|
|
|
|
const withWorkspaceScope = useCallback(
|
|
|
|
|
(options?: SendOptions): SendOptions | undefined => {
|
|
|
|
|
if (!workspaceScope) return options;
|
|
|
|
|
return {
|
|
|
|
|
...(options ?? {}),
|
|
|
|
|
workspaceScope,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
[workspaceScope],
|
|
|
|
|
);
|
2026-05-24 13:38:37 +08:00
|
|
|
|
|
|
|
|
const refreshModelSettings = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
setSettings(await fetchSettings(token));
|
|
|
|
|
} catch {
|
2026-05-29 03:42:53 +08:00
|
|
|
if (!settingsSnapshot) setSettings(null);
|
2026-05-24 13:38:37 +08:00
|
|
|
}
|
2026-05-29 03:42:53 +08:00
|
|
|
}, [settingsSnapshot, token]);
|
2026-05-24 13:38:37 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-29 03:42:53 +08:00
|
|
|
if (settingsSnapshot) {
|
|
|
|
|
setSettings(settingsSnapshot);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-24 13:38:37 +08:00
|
|
|
void refreshModelSettings();
|
2026-05-29 03:42:53 +08:00
|
|
|
}, [refreshModelSettings, settingsSnapshot]);
|
2026-05-24 13:38:37 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return client.onRuntimeModelUpdate(() => {
|
|
|
|
|
void refreshModelSettings();
|
|
|
|
|
});
|
|
|
|
|
}, [client, refreshModelSettings]);
|
2026-04-18 18:51:53 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!chatId || loading) return;
|
|
|
|
|
const cached = messageCacheRef.current.get(chatId);
|
2026-05-13 16:39:07 +00:00
|
|
|
const appliedVersion = appliedHistoryVersionRef.current.get(chatId) ?? 0;
|
|
|
|
|
const hasPendingCanonicalHydrate = pendingCanonicalHydrateRef.current.has(chatId);
|
|
|
|
|
const hasNewCanonicalHistory = hasPendingCanonicalHydrate && historyVersion > appliedVersion;
|
2026-04-18 18:51:53 +00:00
|
|
|
// When the user switches away and back, keep the local in-memory thread
|
|
|
|
|
// state (including not-yet-persisted messages) instead of replacing it with
|
2026-05-13 16:39:07 +00:00
|
|
|
// whatever the history endpoint currently knows about. Once a fresh
|
2026-05-16 08:33:15 +00:00
|
|
|
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
|
|
|
|
// so rendering converges to the same shape as a manual refresh.
|
2026-05-08 09:40:15 +00:00
|
|
|
setMessages((prev) => {
|
2026-05-24 20:56:07 +08:00
|
|
|
const normalizedHistory = projectWebuiThreadMessages(historical);
|
|
|
|
|
const keepLiveMessages = (messagesToKeep: UIMessage[]) => {
|
|
|
|
|
const projected = projectWebuiThreadMessages(messagesToKeep);
|
|
|
|
|
messageCacheRef.current.set(chatId, projected);
|
|
|
|
|
return projected;
|
|
|
|
|
};
|
2026-05-13 16:39:07 +00:00
|
|
|
if (hasNewCanonicalHistory && historical.length > 0) {
|
2026-05-24 20:56:07 +08:00
|
|
|
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
2026-05-13 16:39:07 +00:00
|
|
|
pendingCanonicalHydrateRef.current.delete(chatId);
|
|
|
|
|
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
2026-05-24 20:56:07 +08:00
|
|
|
messageCacheRef.current.set(chatId, normalizedHistory);
|
|
|
|
|
return normalizedHistory;
|
2026-05-13 16:39:07 +00:00
|
|
|
}
|
2026-05-24 20:56:07 +08:00
|
|
|
if (cached && cached.length > 0) {
|
|
|
|
|
const normalizedCached = projectWebuiThreadMessages(cached);
|
2026-06-10 18:02:27 +08:00
|
|
|
if (
|
|
|
|
|
normalizedHistory.length > normalizedCached.length
|
|
|
|
|
&& !isStaleThreadSnapshot(prev, normalizedHistory)
|
|
|
|
|
) {
|
|
|
|
|
messageCacheRef.current.set(chatId, normalizedHistory);
|
|
|
|
|
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
|
|
|
|
return normalizedHistory;
|
|
|
|
|
}
|
2026-05-24 20:56:07 +08:00
|
|
|
if (isStaleThreadSnapshot(prev, normalizedCached)) return keepLiveMessages(prev);
|
|
|
|
|
return normalizedCached;
|
|
|
|
|
}
|
|
|
|
|
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
2026-05-13 16:39:07 +00:00
|
|
|
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
2026-05-24 20:56:07 +08:00
|
|
|
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
|
|
|
|
|
return normalizedHistory;
|
2026-05-08 09:40:15 +00:00
|
|
|
});
|
2026-04-18 18:51:53 +00:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-05-13 16:39:07 +00:00
|
|
|
}, [loading, chatId, historical, historyVersion]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!chatId) return;
|
2026-05-17 23:52:50 +08:00
|
|
|
return client.onSessionUpdate((updatedChatId, scope) => {
|
2026-05-13 16:39:07 +00:00
|
|
|
if (updatedChatId !== chatId) return;
|
2026-05-17 23:52:50 +08:00
|
|
|
if (scope === "metadata") return;
|
2026-05-13 16:39:07 +00:00
|
|
|
pendingCanonicalHydrateRef.current.add(chatId);
|
|
|
|
|
refreshHistory();
|
|
|
|
|
});
|
|
|
|
|
}, [chatId, client, refreshHistory]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!chatId || loading) return;
|
|
|
|
|
setScrollToBottomSignal((value) => value + 1);
|
|
|
|
|
}, [chatId, loading, historical]);
|
2026-04-18 18:51:53 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (chatId) return;
|
2026-05-16 01:14:11 +08:00
|
|
|
setMessages(projectWebuiThreadMessages(historical));
|
2026-04-18 18:51:53 +00:00
|
|
|
}, [chatId, historical, setMessages]);
|
|
|
|
|
|
2026-05-06 15:54:15 +00:00
|
|
|
useLayoutEffect(() => {
|
2026-05-16 01:14:11 +08:00
|
|
|
if (chatId) {
|
|
|
|
|
const prev = prevChatIdForCacheRef.current;
|
|
|
|
|
if (prev && prev !== chatId) {
|
|
|
|
|
messageCacheRef.current.set(prev, projectWebuiThreadMessages(messages));
|
|
|
|
|
skipLayoutCacheRef.current = true;
|
2026-05-06 15:54:15 +00:00
|
|
|
}
|
2026-05-16 01:14:11 +08:00
|
|
|
prevChatIdForCacheRef.current = chatId;
|
|
|
|
|
} else {
|
|
|
|
|
if (prevChatIdForCacheRef.current) {
|
|
|
|
|
messageCacheRef.current.set(
|
|
|
|
|
prevChatIdForCacheRef.current,
|
|
|
|
|
projectWebuiThreadMessages(messages),
|
|
|
|
|
);
|
|
|
|
|
skipLayoutCacheRef.current = true;
|
|
|
|
|
}
|
|
|
|
|
prevChatIdForCacheRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}, [chatId, messages]);
|
|
|
|
|
|
|
|
|
|
// Persist thread to in-memory cache after paint so ``useNanobotStream``'s chat switch
|
|
|
|
|
// ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still
|
|
|
|
|
// sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!chatId) {
|
2026-05-01 13:16:33 -03:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-16 01:14:11 +08:00
|
|
|
if (skipLayoutCacheRef.current) {
|
|
|
|
|
skipLayoutCacheRef.current = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (loading) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
messageCacheRef.current.set(chatId, projectWebuiThreadMessages(messages));
|
2026-05-06 15:54:15 +00:00
|
|
|
}, [chatId, loading, messages]);
|
2026-04-18 18:51:53 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!chatId) return;
|
|
|
|
|
const pending = pendingFirstRef.current;
|
|
|
|
|
if (!pending) return;
|
|
|
|
|
pendingFirstRef.current = null;
|
2026-05-13 16:39:07 +00:00
|
|
|
setScrollToBottomSignal((value) => value + 1);
|
2026-05-08 09:40:15 +00:00
|
|
|
send(pending.content, pending.images, pending.options);
|
2026-04-18 18:51:53 +00:00
|
|
|
setBooting(false);
|
2026-05-08 09:40:15 +00:00
|
|
|
}, [chatId, send]);
|
2026-04-18 18:51:53 +00:00
|
|
|
|
2026-05-06 15:54:15 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const commands = await listSlashCommands(token);
|
|
|
|
|
if (!cancelled) setSlashCommands(commands);
|
|
|
|
|
} catch {
|
|
|
|
|
if (!cancelled) setSlashCommands([]);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [token]);
|
|
|
|
|
|
2026-04-18 18:51:53 +00:00
|
|
|
const handleWelcomeSend = useCallback(
|
2026-05-08 09:40:15 +00:00
|
|
|
async (content: string, images?: SendImage[], options?: SendOptions) => {
|
2026-04-18 18:51:53 +00:00
|
|
|
if (booting) return;
|
|
|
|
|
setBooting(true);
|
2026-05-29 03:42:53 +08:00
|
|
|
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
|
|
|
|
const newId = await onCreateChat?.(workspaceScope);
|
2026-04-18 18:51:53 +00:00
|
|
|
if (!newId) {
|
|
|
|
|
pendingFirstRef.current = null;
|
|
|
|
|
setBooting(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-05-29 03:42:53 +08:00
|
|
|
[booting, onCreateChat, withWorkspaceScope, workspaceScope],
|
2026-05-06 14:15:36 +00:00
|
|
|
);
|
|
|
|
|
|
2026-05-13 16:39:07 +00:00
|
|
|
const handleThreadSend = useCallback(
|
|
|
|
|
(content: string, images?: SendImage[], options?: SendOptions) => {
|
|
|
|
|
setScrollToBottomSignal((value) => value + 1);
|
2026-05-29 03:42:53 +08:00
|
|
|
send(content, images, withWorkspaceScope(options));
|
2026-05-13 16:39:07 +00:00
|
|
|
},
|
2026-05-29 03:42:53 +08:00
|
|
|
[send, withWorkspaceScope],
|
2026-05-06 14:15:36 +00:00
|
|
|
);
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
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]);
|
|
|
|
|
|
2026-06-05 19:49:34 +08:00
|
|
|
const handleForkFromMessage = useCallback(
|
|
|
|
|
async (beforeUserIndex: number) => {
|
|
|
|
|
if (!chatId || !onForkChat) return;
|
|
|
|
|
const forkedChatId = await onForkChat(chatId, beforeUserIndex);
|
2026-06-10 02:54:19 +08:00
|
|
|
if (!forkedChatId) return;
|
2026-06-05 19:49:34 +08:00
|
|
|
messageCacheRef.current.delete(forkedChatId);
|
|
|
|
|
appliedHistoryVersionRef.current.delete(forkedChatId);
|
|
|
|
|
pendingCanonicalHydrateRef.current.add(forkedChatId);
|
|
|
|
|
},
|
2026-06-10 02:54:19 +08:00
|
|
|
[chatId, onForkChat],
|
2026-06-05 19:49:34 +08:00
|
|
|
);
|
|
|
|
|
|
2026-05-06 14:15:36 +00:00
|
|
|
const composer = (
|
|
|
|
|
<>
|
|
|
|
|
{streamError ? (
|
|
|
|
|
<StreamErrorNotice
|
|
|
|
|
error={streamError}
|
|
|
|
|
onDismiss={dismissStreamError}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
{session ? (
|
|
|
|
|
<ThreadComposer
|
2026-05-13 16:39:07 +00:00
|
|
|
onSend={handleThreadSend}
|
2026-06-10 02:54:19 +08:00
|
|
|
disabled={!chatId}
|
2026-05-06 14:15:36 +00:00
|
|
|
isStreaming={isStreaming}
|
|
|
|
|
placeholder={
|
|
|
|
|
showHeroComposer
|
|
|
|
|
? t("thread.composer.placeholderHero")
|
|
|
|
|
: t("thread.composer.placeholderThread")
|
|
|
|
|
}
|
2026-06-06 19:49:33 +08:00
|
|
|
modelLabel={modelBadgeLabel}
|
2026-05-24 13:38:37 +08:00
|
|
|
modelProvider={modelBadge.provider}
|
|
|
|
|
modelProviderLabel={modelBadge.providerLabel}
|
2026-06-06 19:49:33 +08:00
|
|
|
modelNeedsSetup={modelBadge.needsSetup}
|
|
|
|
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
2026-05-06 14:15:36 +00:00
|
|
|
variant={showHeroComposer ? "hero" : "thread"}
|
2026-05-06 15:54:15 +00:00
|
|
|
slashCommands={slashCommands}
|
2026-05-22 22:25:12 +08:00
|
|
|
cliApps={cliApps}
|
2026-05-24 13:38:37 +08:00
|
|
|
mcpPresets={mcpPresets}
|
2026-05-08 09:40:15 +00:00
|
|
|
onStop={stop}
|
2026-06-09 01:08:49 +08:00
|
|
|
onTranscribeAudio={transcribeAudio}
|
2026-05-16 01:14:11 +08:00
|
|
|
runStartedAt={runStartedAt}
|
|
|
|
|
goalState={goalState}
|
2026-05-29 03:42:53 +08:00
|
|
|
workspaceScope={workspaceScope}
|
|
|
|
|
workspaceDefaultScope={workspaceDefaultScope}
|
|
|
|
|
workspaceControls={workspaceControls}
|
|
|
|
|
workspaceScopeDisabled={workspaceScopeDisabled}
|
|
|
|
|
workspaceError={workspaceError}
|
|
|
|
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
2026-05-30 23:45:26 +08:00
|
|
|
pendingQueueKey={chatId}
|
2026-05-06 14:15:36 +00:00
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<ThreadComposer
|
|
|
|
|
onSend={handleWelcomeSend}
|
|
|
|
|
disabled={booting}
|
|
|
|
|
isStreaming={isStreaming}
|
|
|
|
|
placeholder={
|
|
|
|
|
booting
|
|
|
|
|
? t("thread.composer.placeholderOpening")
|
|
|
|
|
: t("thread.composer.placeholderHero")
|
|
|
|
|
}
|
2026-06-06 19:49:33 +08:00
|
|
|
modelLabel={modelBadgeLabel}
|
2026-05-24 13:38:37 +08:00
|
|
|
modelProvider={modelBadge.provider}
|
|
|
|
|
modelProviderLabel={modelBadge.providerLabel}
|
2026-06-06 19:49:33 +08:00
|
|
|
modelNeedsSetup={modelBadge.needsSetup}
|
|
|
|
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
2026-05-06 14:15:36 +00:00
|
|
|
variant="hero"
|
2026-05-13 08:47:34 +00:00
|
|
|
slashCommands={slashCommands}
|
2026-05-22 22:25:12 +08:00
|
|
|
cliApps={cliApps}
|
2026-05-24 13:38:37 +08:00
|
|
|
mcpPresets={mcpPresets}
|
2026-05-16 01:14:11 +08:00
|
|
|
runStartedAt={runStartedAt}
|
2026-06-09 01:08:49 +08:00
|
|
|
onTranscribeAudio={transcribeAudio}
|
2026-05-16 01:14:11 +08:00
|
|
|
goalState={goalState}
|
2026-05-29 03:42:53 +08:00
|
|
|
workspaceScope={workspaceScope}
|
|
|
|
|
workspaceDefaultScope={workspaceDefaultScope}
|
|
|
|
|
workspaceControls={workspaceControls}
|
|
|
|
|
workspaceScopeDisabled={workspaceScopeDisabled}
|
|
|
|
|
workspaceError={workspaceError}
|
|
|
|
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
2026-05-06 14:15:36 +00:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
2026-04-18 18:51:53 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const emptyState = loading ? (
|
|
|
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
2026-04-19 06:39:06 +00:00
|
|
|
{t("thread.loadingConversation")}
|
2026-04-18 18:51:53 +00:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-05-06 14:15:36 +00:00
|
|
|
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
|
|
|
|
<h1 className="text-balance text-[40px] font-normal leading-tight tracking-[-0.045em] text-foreground sm:text-[48px]">
|
2026-05-29 03:42:53 +08:00
|
|
|
{t(heroGreetingKey)}
|
2026-05-06 14:15:36 +00:00
|
|
|
</h1>
|
2026-04-18 18:51:53 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
2026-06-06 19:49:33 +08:00
|
|
|
const sessionInfoAction = historyKey ? (
|
|
|
|
|
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
|
|
|
|
|
) : undefined;
|
|
|
|
|
const promptNavigatorAction = historyKey ? (
|
|
|
|
|
<PromptNavigator
|
|
|
|
|
messages={displayMessages}
|
|
|
|
|
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
|
|
|
|
|
/>
|
|
|
|
|
) : undefined;
|
2026-04-18 18:51:53 +00:00
|
|
|
|
|
|
|
|
return (
|
2026-06-06 19:49:33 +08:00
|
|
|
<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}
|
2026-06-10 02:07:47 +08:00
|
|
|
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
2026-06-10 18:02:27 +08:00
|
|
|
hasMoreBefore={hasMoreBefore}
|
|
|
|
|
loadingOlder={loadingOlder}
|
|
|
|
|
userMessageOffset={userMessageOffset}
|
|
|
|
|
onLoadOlder={loadOlder}
|
2026-06-06 19:49:33 +08:00
|
|
|
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
2026-06-05 19:49:34 +08:00
|
|
|
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
2026-06-06 19:49:33 +08:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{filePreviewPath && historyKey ? (
|
|
|
|
|
<FilePreviewPanel
|
|
|
|
|
sessionKey={historyKey}
|
|
|
|
|
path={filePreviewPath}
|
|
|
|
|
token={token}
|
|
|
|
|
desktopWidth={filePreviewWidth}
|
|
|
|
|
isClosing={filePreviewClosing}
|
|
|
|
|
onResizeStart={handleFilePreviewResizeStart}
|
|
|
|
|
onClose={handleCloseFilePreview}
|
2026-05-29 03:42:53 +08:00
|
|
|
/>
|
|
|
|
|
) : null}
|
2026-04-18 18:51:53 +00:00
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|