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:
@@ -0,0 +1,287 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { AlertCircle, ChevronRight, FileText, Loader2, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import { splitFilePath } from "@/components/FileReferenceChip";
|
||||
import { ApiError, fetchFilePreview } from "@/lib/api";
|
||||
import type { FilePreviewPayload } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FilePreviewPanelProps {
|
||||
sessionKey: string;
|
||||
path: string;
|
||||
token: string;
|
||||
desktopWidth?: number;
|
||||
isClosing?: boolean;
|
||||
onResizeStart?: (event: ReactPointerEvent<HTMLButtonElement>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type PreviewState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "ready"; payload: FilePreviewPayload };
|
||||
|
||||
function supportsHoverCloseControl(): boolean {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||
return window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||
}
|
||||
|
||||
export function FilePreviewPanel({
|
||||
sessionKey,
|
||||
path,
|
||||
token,
|
||||
desktopWidth = 544,
|
||||
isClosing = false,
|
||||
onResizeStart,
|
||||
onClose,
|
||||
}: FilePreviewPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<PreviewState>({ status: "loading" });
|
||||
const [entered, setEntered] = useState(false);
|
||||
const [supportsHoverClose, setSupportsHoverClose] = useState(supportsHoverCloseControl);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => setEntered(true));
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") return undefined;
|
||||
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
|
||||
const update = () => setSupportsHoverClose(query.matches);
|
||||
update();
|
||||
if (typeof query.addEventListener === "function") {
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}
|
||||
query.addListener(update);
|
||||
return () => query.removeListener(update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ status: "loading" });
|
||||
fetchFilePreview(token, sessionKey, path)
|
||||
.then((payload) => {
|
||||
if (!cancelled) setState({ status: "ready", payload });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message = error instanceof ApiError
|
||||
? (error.status === 404 && /API route not found/i.test(error.message)
|
||||
? t("filePreview.routeMissing", {
|
||||
defaultValue: "File preview needs the latest gateway. Restart nanobot gateway and try again.",
|
||||
})
|
||||
: error.message)
|
||||
: t("filePreview.failed", { defaultValue: "Could not preview this file." });
|
||||
setState({ status: "error", message });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [path, sessionKey, t, token]);
|
||||
|
||||
const displayPath = state.status === "ready" ? state.payload.display_path : path;
|
||||
const previewPath = state.status === "ready" ? state.payload.path : displayPath;
|
||||
const normalizedPreviewPath = previewPath.replace(/\\/g, "/");
|
||||
const hasRootPrefix = normalizedPreviewPath.startsWith("/");
|
||||
const { name } = splitFilePath(displayPath);
|
||||
const breadcrumbs = useMemo(
|
||||
() => normalizedPreviewPath.split("/").filter(Boolean),
|
||||
[normalizedPreviewPath],
|
||||
);
|
||||
const compactBreadcrumbs = useMemo(
|
||||
() => (breadcrumbs.length > 2 ? breadcrumbs.slice(-2) : breadcrumbs),
|
||||
[breadcrumbs],
|
||||
);
|
||||
const hasCompactPrefix = breadcrumbs.length > compactBreadcrumbs.length;
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={t("filePreview.aria", { defaultValue: "File preview" })}
|
||||
style={{
|
||||
"--file-preview-width": `${desktopWidth}px`,
|
||||
"--file-preview-slot-width": !entered || isClosing ? "0px" : `${desktopWidth}px`,
|
||||
} as CSSProperties}
|
||||
className={cn(
|
||||
"absolute inset-y-0 right-0 z-30 w-[min(92vw,var(--file-preview-slot-width))] overflow-hidden",
|
||||
"transition-[width] duration-300 ease-out will-change-[width]",
|
||||
"md:relative md:z-auto md:w-[var(--file-preview-slot-width)] md:min-w-0 md:shrink-0",
|
||||
isClosing && "pointer-events-none",
|
||||
)}
|
||||
data-testid="file-preview-panel"
|
||||
data-file-preview-panel
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 right-0 flex w-[min(92vw,var(--file-preview-width))] flex-col overflow-hidden md:w-[var(--file-preview-width)]",
|
||||
"border-l border-border/70 bg-background shadow-2xl md:shadow-none",
|
||||
"transition-[opacity,transform] duration-300 ease-out will-change-transform",
|
||||
!entered || isClosing ? "translate-x-full opacity-0" : "translate-x-0 opacity-100",
|
||||
"motion-reduce:translate-x-0",
|
||||
)}
|
||||
>
|
||||
{onResizeStart ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("filePreview.resize", { defaultValue: "Resize file preview" })}
|
||||
className={cn(
|
||||
"group absolute inset-y-0 left-0 z-20 hidden w-3 -translate-x-1/2 cursor-col-resize touch-none md:flex",
|
||||
"items-stretch justify-center focus-visible:outline-none",
|
||||
)}
|
||||
onPointerDown={onResizeStart}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-full w-px bg-foreground/25 opacity-0 transition-opacity",
|
||||
"group-hover:opacity-100 group-focus-visible:bg-ring group-focus-visible:opacity-100",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border/60 px-3">
|
||||
{supportsHoverClose ? (
|
||||
<div
|
||||
className={cn(
|
||||
"group inline-flex max-w-full min-w-0 items-center gap-2 rounded-[12px]",
|
||||
"bg-muted/70 px-2.5 py-1.5 text-sm font-medium",
|
||||
)}
|
||||
title={name || displayPath}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full",
|
||||
"text-muted-foreground/75 transition-[background-color,color,opacity] duration-150 ease-out",
|
||||
"group-hover:bg-foreground group-hover:text-background group-hover:opacity-100",
|
||||
"group-focus-within:bg-foreground group-focus-within:text-background",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
|
||||
>
|
||||
<FileText
|
||||
className={cn(
|
||||
"absolute h-4 w-4 transition-all duration-150 ease-out",
|
||||
"opacity-100 group-hover:scale-75 group-hover:opacity-0",
|
||||
"group-focus-within:scale-75 group-focus-within:opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<X
|
||||
className={cn(
|
||||
"absolute h-3.5 w-3.5 scale-75 opacity-0 transition-all duration-150 ease-out",
|
||||
"group-hover:scale-100 group-hover:opacity-100",
|
||||
"group-focus-within:scale-100 group-focus-within:opacity-100",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
<span className="min-w-0 truncate">{name || displayPath}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
|
||||
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{name || displayPath}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-10 shrink-0 items-center gap-1.5 overflow-hidden",
|
||||
"border-b border-border/45 px-4 text-[13px] text-muted-foreground",
|
||||
)}
|
||||
title={previewPath}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{hasCompactPrefix ? (
|
||||
<span className="shrink-0 text-muted-foreground/55">...</span>
|
||||
) : hasRootPrefix ? (
|
||||
<span className="shrink-0 text-muted-foreground/55">/</span>
|
||||
) : null}
|
||||
{compactBreadcrumbs.length > 0 ? (
|
||||
compactBreadcrumbs.map((part, index) => (
|
||||
<span key={`${part}-${index}`} className="flex min-w-0 items-center gap-1.5">
|
||||
{index > 0 || hasCompactPrefix || hasRootPrefix ? (
|
||||
<ChevronRight
|
||||
className="h-3 w-3 shrink-0 text-muted-foreground/40"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
index === compactBreadcrumbs.length - 1
|
||||
? "font-medium text-foreground"
|
||||
: "max-w-[42vw] shrink text-muted-foreground/76",
|
||||
)}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="truncate">{previewPath}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{state.status === "loading" ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
{t("filePreview.loading", { defaultValue: "Loading preview..." })}
|
||||
</div>
|
||||
) : state.status === "error" ? (
|
||||
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
|
||||
<div className="max-w-sm">
|
||||
<AlertCircle className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70" aria-hidden />
|
||||
<p>{state.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-full">
|
||||
{state.payload.truncated ? (
|
||||
<div className="mx-4 mt-3 rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200">
|
||||
{t("filePreview.truncated", {
|
||||
defaultValue: "Preview is truncated because this file is large.",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<CodeBlock
|
||||
language={state.payload.language}
|
||||
code={state.payload.content}
|
||||
chrome="none"
|
||||
showLineNumbers
|
||||
wrapLongLines={false}
|
||||
className="min-h-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user