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:
Xubin Ren
2026-06-06 19:49:33 +08:00
committed by GitHub
co-authored by chengyongru
parent a1b9577224
commit ab9f49970d
103 changed files with 10483 additions and 1003 deletions
+281 -118
View File
@@ -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>
);
}