Files
nanobot/webui/src/components/thread/ThreadShell.tsx
T

866 lines
31 KiB
TypeScript
Raw Normal View History

2026-05-06 15:54:15 +00:00
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, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import {
fetchInstalledCliApps,
fetchMcpPresets,
fetchSettings,
listSlashCommands,
} from "@/lib/api";
import {
CLI_APPS_CHANGED_EVENT,
installedCliAppsFromPayload,
isCliAppsPayload,
} from "@/lib/cli-app-events";
import {
MCP_PRESETS_CHANGED_EVENT,
installedMcpPresetsFromPayload,
isMcpPresetsPayload,
} from "@/lib/mcp-preset-events";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type {
ChatSummary,
SettingsPayload,
SlashCommand,
2026-07-01 15:52:34 +08:00
SkillSummary,
UIMessage,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider";
function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
}
type MessageShape = Pick<UIMessage, "role" | "kind" | "content">;
function sameMessageShape(a: MessageShape, b: MessageShape): boolean {
2026-05-24 20:56:07 +08:00
return (
a.role === b.role
&& (a.kind ?? "") === (b.kind ?? "")
&& a.content === b.content
);
}
function durableMessageShape(message: UIMessage): MessageShape | null {
if (message.kind === "trace") return null;
if (message.role !== "user" && message.role !== "assistant") return null;
if (message.role === "assistant" && !message.content.trim() && !message.media?.length) {
return null;
}
return {
role: message.role,
kind: message.kind,
content: message.content,
};
}
function preservesDurableMessages(current: UIMessage[], snapshot: UIMessage[]): boolean {
// Canonical history refreshes can race with live websocket messages after fork/send.
// Never accept a refreshed snapshot that drops a user/assistant message already shown.
const expected = current
.map(durableMessageShape)
.filter((message): message is MessageShape => message !== null);
if (expected.length === 0) return true;
const candidates = snapshot
.map(durableMessageShape)
.filter((message): message is MessageShape => message !== null);
let cursor = 0;
for (const message of expected) {
let found = false;
while (cursor < candidates.length) {
const candidate = candidates[cursor];
cursor += 1;
if (sameMessageShape(message, candidate)) {
found = true;
break;
}
}
if (!found) return false;
}
return true;
}
2026-05-24 20:56:07 +08:00
function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boolean {
if (current.length === 0) return false;
2026-05-24 20:56:07 +08:00
if (snapshot.length === 0) return true;
if (!preservesDurableMessages(current, snapshot)) return true;
if (snapshot.length >= current.length) return false;
2026-05-24 20:56:07 +08:00
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;
onToggleSidebar: () => void;
2026-05-06 14:15:36 +00:00
onGoHome?: () => void;
onNewChat?: () => void;
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise<string | null>;
2026-05-06 14:15:36 +00:00
onTurnEnd?: () => void;
theme?: "light" | "dark";
onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean;
hostChromeTitleInset?: boolean;
2026-05-31 17:00:42 +08:00
hideThemeButton?: boolean;
hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean;
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
2026-07-01 15:52:34 +08:00
skills?: SkillSummary[];
}
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;
}
interface ModelBadgeInfo {
label: string | null;
provider: string | null;
providerLabel: string | null;
needsSetup: boolean;
}
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 {
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,
};
}
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;
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];
}
interface PendingFirstMessage {
content: string;
images?: SendImage[];
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,
onToggleSidebar,
2026-05-06 14:15:36 +00:00
onCreateChat,
onForkChat,
2026-05-06 14:15:36 +00:00
onTurnEnd,
theme = "light",
onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false,
hostChromeTitleInset = false,
2026-05-31 17:00:42 +08:00
hideThemeButton = false,
hideHeader = false,
workspaceScope = null,
workspaceDefaultScope = null,
workspaceControls = null,
workspaceScopeDisabled = false,
workspaceError = null,
onWorkspaceScopeChange,
settingsSnapshot = null,
onOpenModelSettings,
2026-07-01 15:52:34 +08:00
skills = [],
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const {
messages: historical,
loading,
2026-06-10 18:02:27 +08:00
loadingOlder,
loadOlder,
hasMoreBefore,
userMessageOffset,
hasPendingToolCalls,
refresh: refreshHistory,
version: historyVersion,
2026-06-10 02:07:47 +08:00
forkBoundaryMessageCount,
} = useSessionHistory(historyKey);
const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
2026-05-06 15:54:15 +00:00
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const cliApps = useInstalledSettingItems({
token,
eventName: CLI_APPS_CHANGED_EVENT,
fetchPayload: fetchInstalledCliApps,
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 [scrollToLatestUserPromptSignal, setScrollToLatestUserPromptSignal] = 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 [pendingFirstTargetChatId, setPendingFirstTargetChatId] = useState<string | 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);
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
const skipLayoutCacheRef = useRef(false);
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
const bottomScrolledChatIdRef = useRef<string | null>(null);
const initial = useMemo(() => {
if (!chatId) return historical;
return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical]);
const handleTurnEnd = useCallback(() => {
onTurnEnd?.();
}, [onTurnEnd]);
const {
messages,
isStreaming,
runStartedAt,
goalState,
send,
transcribeAudio,
stop,
setMessages,
streamError,
dismissStreamError,
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
useEffect(() => {
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;
const wasShowingHeroComposerRef = useRef(showHeroComposer);
const modelBadge = useMemo(
() => 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());
}
wasShowingHeroComposerRef.current = showHeroComposer;
}, [showHeroComposer]);
const withWorkspaceScope = useCallback(
(options?: SendOptions): SendOptions | undefined => {
if (!workspaceScope) return options;
return {
...(options ?? {}),
workspaceScope,
};
},
[workspaceScope],
);
const refreshModelSettings = useCallback(async () => {
try {
setSettings(await fetchSettings(token));
} catch {
if (!settingsSnapshot) setSettings(null);
}
}, [settingsSnapshot, token]);
useEffect(() => {
if (settingsSnapshot) {
setSettings(settingsSnapshot);
return;
}
void refreshModelSettings();
}, [refreshModelSettings, settingsSnapshot]);
useEffect(() => {
return client.onRuntimeModelUpdate(() => {
void refreshModelSettings();
});
}, [client, refreshModelSettings]);
useEffect(() => {
if (!chatId || loading) return;
const cached = messageCacheRef.current.get(chatId);
const appliedVersion = appliedHistoryVersionRef.current.get(chatId) ?? 0;
const hasPendingCanonicalHydrate = pendingCanonicalHydrateRef.current.has(chatId);
const hasNewCanonicalHistory = hasPendingCanonicalHydrate && historyVersion > appliedVersion;
// When the user switches away and back, keep the local in-memory thread
// state (including not-yet-persisted messages) instead of replacing it with
// whatever the history endpoint currently knows about. Once a fresh
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
// so rendering converges to the same shape as a manual refresh.
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;
};
if (hasNewCanonicalHistory && historical.length > 0) {
2026-05-24 20:56:07 +08:00
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
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-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);
appliedHistoryVersionRef.current.set(chatId, historyVersion);
2026-05-24 20:56:07 +08:00
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
return normalizedHistory;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, chatId, historical, historyVersion]);
useEffect(() => {
if (!chatId) return;
return client.onSessionUpdate((updatedChatId, scope) => {
if (updatedChatId !== chatId) return;
if (scope === "metadata") return;
2026-06-26 11:28:49 +08:00
viewportRef.current?.cancelAutoScroll();
pendingCanonicalHydrateRef.current.add(chatId);
refreshHistory();
});
}, [chatId, client, refreshHistory]);
useEffect(() => {
if (!chatId) {
bottomScrolledChatIdRef.current = null;
return;
}
if (loading || bottomScrolledChatIdRef.current === chatId) return;
bottomScrolledChatIdRef.current = chatId;
setScrollToBottomSignal((value) => value + 1);
}, [chatId, loading]);
useEffect(() => {
if (chatId) return;
setMessages(projectWebuiThreadMessages(historical));
}, [chatId, historical, setMessages]);
2026-05-06 15:54:15 +00:00
useLayoutEffect(() => {
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
}
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) {
return;
}
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]);
// The landing composer queues the first message while `new_chat` is in flight.
// Only the chat created for that send may consume it; selecting another chat
// while creation is pending must not leak the message there.
useEffect(() => {
if (!chatId || pendingFirstTargetChatId !== chatId) return;
const pending = pendingFirstRef.current;
if (!pending) {
setPendingFirstTargetChatId(null);
return;
}
pendingFirstRef.current = null;
setPendingFirstTargetChatId(null);
setScrollToLatestUserPromptSignal((value) => value + 1);
send(pending.content, pending.images, pending.options);
setBooting(false);
}, [chatId, pendingFirstTargetChatId, send]);
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]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
setPendingFirstTargetChatId(null);
const newId = await onCreateChat?.(workspaceScope);
if (!newId) {
pendingFirstRef.current = null;
setPendingFirstTargetChatId(null);
setBooting(false);
return;
}
setPendingFirstTargetChatId(newId);
},
[booting, onCreateChat, withWorkspaceScope, workspaceScope],
2026-05-06 14:15:36 +00:00
);
const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
setScrollToLatestUserPromptSignal((value) => value + 1);
send(content, images, withWorkspaceScope(options));
},
[send, withWorkspaceScope],
2026-05-06 14:15:36 +00: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]);
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;
messageCacheRef.current.delete(forkedChatId);
appliedHistoryVersionRef.current.delete(forkedChatId);
pendingCanonicalHydrateRef.current.add(forkedChatId);
},
2026-06-10 02:54:19 +08:00
[chatId, onForkChat],
);
2026-05-06 14:15:36 +00:00
const composer = (
<>
{streamError ? (
<StreamErrorNotice
error={streamError}
onDismiss={dismissStreamError}
/>
) : null}
{session ? (
<ThreadComposer
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")
}
modelLabel={modelBadgeLabel}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
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}
mcpPresets={mcpPresets}
2026-07-01 15:52:34 +08:00
skills={skills}
onStop={stop}
onTranscribeAudio={transcribeAudio}
runStartedAt={runStartedAt}
goalState={goalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
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")
}
modelLabel={modelBadgeLabel}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
2026-05-06 14:15:36 +00:00
variant="hero"
slashCommands={slashCommands}
2026-05-22 22:25:12 +08:00
cliApps={cliApps}
mcpPresets={mcpPresets}
2026-07-01 15:52:34 +08:00
skills={skills}
runStartedAt={runStartedAt}
onTranscribeAudio={transcribeAudio}
goalState={goalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
2026-05-06 14:15:36 +00:00
/>
)}
</>
);
const emptyState = loading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t("thread.loadingConversation")}
</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="max-w-[44rem] text-balance text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight">
{t(heroGreetingKey)}
2026-05-06 14:15:36 +00:00
</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 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}
scrollToLatestUserPromptSignal={scrollToLatestUserPromptSignal}
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}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
/>
</div>
{filePreviewPath && historyKey ? (
<FilePreviewPanel
sessionKey={historyKey}
path={filePreviewPath}
token={token}
desktopWidth={filePreviewWidth}
isClosing={filePreviewClosing}
onResizeStart={handleFilePreviewResizeStart}
onClose={handleCloseFilePreview}
/>
) : null}
</section>
);
}