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:
@@ -9,15 +9,33 @@ interface CodeBlockProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
className?: string;
|
||||
chrome?: "default" | "none";
|
||||
highlight?: boolean;
|
||||
showLineNumbers?: boolean;
|
||||
wrapLongLines?: boolean;
|
||||
}
|
||||
|
||||
interface HighlightedCodeProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
isDark: boolean;
|
||||
chrome: "default" | "none";
|
||||
showLineNumbers: boolean;
|
||||
wrapLongLines: boolean;
|
||||
}
|
||||
|
||||
const CODE_FONT_STACK = [
|
||||
'"JetBrains Mono"',
|
||||
'"SFMono-Regular"',
|
||||
'"SF Mono"',
|
||||
'"Fira Code"',
|
||||
'"Cascadia Code"',
|
||||
'"Source Code Pro"',
|
||||
"Menlo",
|
||||
"Consolas",
|
||||
"monospace",
|
||||
].join(", ");
|
||||
|
||||
const LazyHighlightedCode = lazy(async () => {
|
||||
const [
|
||||
{ default: SyntaxHighlighter },
|
||||
@@ -30,19 +48,56 @@ const LazyHighlightedCode = lazy(async () => {
|
||||
]);
|
||||
|
||||
return {
|
||||
default({ language, code, isDark }: HighlightedCodeProps) {
|
||||
default({
|
||||
language,
|
||||
code,
|
||||
isDark,
|
||||
chrome,
|
||||
showLineNumbers,
|
||||
wrapLongLines,
|
||||
}: HighlightedCodeProps) {
|
||||
const theme = isDark ? oneDark : oneLight;
|
||||
const transparentTheme = chrome === "none" ? {
|
||||
...theme,
|
||||
'pre[class*="language-"]': {
|
||||
...theme['pre[class*="language-"]'],
|
||||
background: "transparent",
|
||||
},
|
||||
'code[class*="language-"]': {
|
||||
...theme['code[class*="language-"]'],
|
||||
background: "transparent",
|
||||
},
|
||||
} : theme;
|
||||
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
language={language || "text"}
|
||||
style={isDark ? oneDark : oneLight}
|
||||
style={transparentTheme}
|
||||
customStyle={{
|
||||
background: chrome === "none" ? "transparent" : undefined,
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.6,
|
||||
padding: chrome === "none" ? "0.75rem 1rem" : "1rem",
|
||||
fontFamily: CODE_FONT_STACK,
|
||||
fontSize: chrome === "none" ? "13px" : "0.875rem",
|
||||
lineHeight: chrome === "none" ? 1.55 : 1.6,
|
||||
tabSize: 2,
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: chrome === "none" ? {
|
||||
background: "transparent",
|
||||
fontFamily: CODE_FONT_STACK,
|
||||
} : undefined,
|
||||
}}
|
||||
lineNumberStyle={{
|
||||
minWidth: "2.6em",
|
||||
paddingRight: "1.15rem",
|
||||
color: isDark ? "rgba(212, 212, 216, 0.45)" : "rgba(63, 63, 70, 0.68)",
|
||||
fontFamily: CODE_FONT_STACK,
|
||||
userSelect: "none",
|
||||
}}
|
||||
PreTag="pre"
|
||||
wrapLongLines
|
||||
showLineNumbers={showLineNumbers}
|
||||
wrapLongLines={wrapLongLines}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
@@ -51,13 +106,39 @@ const LazyHighlightedCode = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function PlainCodeFallback({ code }: { code: string }) {
|
||||
function PlainCodeFallback({
|
||||
code,
|
||||
chrome,
|
||||
showLineNumbers,
|
||||
}: {
|
||||
code: string;
|
||||
chrome: "default" | "none";
|
||||
showLineNumbers: boolean;
|
||||
}) {
|
||||
const lines = code.split("\n");
|
||||
return (
|
||||
<pre
|
||||
className="m-0 overflow-x-auto whitespace-pre-wrap bg-background p-4 font-mono text-sm leading-[1.6] text-foreground/90"
|
||||
className={cn(
|
||||
"m-0 overflow-x-auto p-4 font-mono text-sm leading-[1.6] text-foreground/90",
|
||||
showLineNumbers ? "whitespace-pre" : "whitespace-pre-wrap",
|
||||
chrome === "default" ? "bg-background" : "bg-transparent",
|
||||
chrome === "none" && "p-3 text-[13px] leading-[1.55]",
|
||||
)}
|
||||
data-testid="plain-code-fallback"
|
||||
>
|
||||
<code className="text-inherit">{code}</code>
|
||||
<code className="text-inherit">
|
||||
{showLineNumbers ? (
|
||||
lines.map((line, index) => (
|
||||
<span key={index} className="flex min-w-max">
|
||||
<span className="w-10 shrink-0 select-none pr-4 text-right text-muted-foreground/60">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="whitespace-pre">{line || " "}</span>
|
||||
{index < lines.length - 1 ? "\n" : null}
|
||||
</span>
|
||||
))
|
||||
) : code}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -66,11 +147,15 @@ export function CodeBlock({
|
||||
language,
|
||||
code,
|
||||
className,
|
||||
chrome = "default",
|
||||
highlight = true,
|
||||
showLineNumbers = false,
|
||||
wrapLongLines = true,
|
||||
}: CodeBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isDark = useThemeValue() === "dark";
|
||||
const hasChrome = chrome === "default";
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (!navigator.clipboard) return;
|
||||
@@ -83,47 +168,69 @@ export function CodeBlock({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border",
|
||||
isDark ? "border-white/10" : "border-black/10",
|
||||
"overflow-hidden",
|
||||
hasChrome && "rounded-lg border",
|
||||
hasChrome && (isDark ? "border-white/10" : "border-black/10"),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-1.5 text-xs font-medium",
|
||||
isDark
|
||||
? "bg-zinc-800 text-zinc-300"
|
||||
: "bg-zinc-100 text-zinc-600",
|
||||
)}
|
||||
>
|
||||
<span className="lowercase font-mono">
|
||||
{language || t("code.fallbackLanguage")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
{hasChrome ? (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors",
|
||||
"flex items-center justify-between px-4 py-1.5 text-xs font-medium",
|
||||
isDark
|
||||
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700",
|
||||
? "bg-zinc-800 text-zinc-300"
|
||||
: "bg-zinc-100 text-zinc-600",
|
||||
)}
|
||||
aria-label={t("code.copyAria")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="lowercase font-mono">
|
||||
{language || t("code.fallbackLanguage")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors",
|
||||
isDark
|
||||
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700",
|
||||
)}
|
||||
aria-label={t("code.copyAria")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{highlight ? (
|
||||
<Suspense fallback={<PlainCodeFallback code={code} />}>
|
||||
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<PlainCodeFallback
|
||||
code={code}
|
||||
chrome={chrome}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LazyHighlightedCode
|
||||
language={language}
|
||||
code={code}
|
||||
isDark={isDark}
|
||||
chrome={chrome}
|
||||
showLineNumbers={showLineNumbers}
|
||||
wrapLongLines={wrapLongLines}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<PlainCodeFallback code={code} />
|
||||
<PlainCodeFallback
|
||||
code={code}
|
||||
chrome={chrome}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { KeyboardEvent, MouseEvent } from "react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -6,10 +8,11 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FileReferenceKind =
|
||||
export type FileReferenceKind =
|
||||
| "default"
|
||||
| "css"
|
||||
| "html"
|
||||
| "javascript"
|
||||
| "json"
|
||||
| "markdown"
|
||||
| "notebook"
|
||||
@@ -24,6 +27,8 @@ interface FileReferenceChipProps {
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
previewPath?: string;
|
||||
onOpen?: (path: string) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
@@ -34,12 +39,26 @@ export function FileReferenceChip({
|
||||
active = false,
|
||||
className,
|
||||
textClassName,
|
||||
previewPath,
|
||||
onOpen,
|
||||
testId = "inline-file-path",
|
||||
}: FileReferenceChipProps) {
|
||||
const { directory, name } = splitFilePath(path);
|
||||
const kind = fileKindForPath(path);
|
||||
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
|
||||
const fullPath = tooltipPath || path;
|
||||
const targetPath = previewPath || tooltipPath || path;
|
||||
const interactive = Boolean(onOpen);
|
||||
const openPreview = (event: MouseEvent | KeyboardEvent) => {
|
||||
if (!onOpen) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpen(targetPath);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
openPreview(event);
|
||||
};
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||
<Tooltip>
|
||||
@@ -50,10 +69,18 @@ export function FileReferenceChip({
|
||||
<span
|
||||
data-testid={testId}
|
||||
aria-label={fullPath}
|
||||
role={interactive ? "button" : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
onClick={interactive ? openPreview : undefined}
|
||||
onKeyDown={interactive ? onKeyDown : undefined}
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-baseline gap-[0.28em] font-medium leading-[inherit]",
|
||||
"text-sky-600 transition-colors hover:text-sky-700",
|
||||
"dark:text-sky-300 dark:hover:text-sky-200",
|
||||
interactive && [
|
||||
"cursor-pointer rounded-[5px]",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45",
|
||||
],
|
||||
)}
|
||||
>
|
||||
<FileReferenceIcon kind={kind} />
|
||||
@@ -100,6 +127,7 @@ export function isLikelyFilePath(value: string): boolean {
|
||||
const raw = value.trim();
|
||||
if (!raw || raw.includes("\n")) return false;
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false;
|
||||
if (isFilePatternReference(raw)) return false;
|
||||
if (!/[\\/]/.test(raw) && !/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(raw)) {
|
||||
return false;
|
||||
}
|
||||
@@ -110,7 +138,11 @@ export function isLikelyFilePath(value: string): boolean {
|
||||
return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name);
|
||||
}
|
||||
|
||||
function splitFilePath(path: string): { directory: string; name: string } {
|
||||
export function isFilePatternReference(value: string): boolean {
|
||||
return /[*?[\]{}]/.test(value.trim());
|
||||
}
|
||||
|
||||
export function splitFilePath(path: string): { directory: string; name: string } {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const slash = normalized.lastIndexOf("/");
|
||||
if (slash < 0) return { directory: "", name: path };
|
||||
@@ -120,7 +152,7 @@ function splitFilePath(path: string): { directory: string; name: string } {
|
||||
};
|
||||
}
|
||||
|
||||
function fileKindForPath(path: string): FileReferenceKind {
|
||||
export function fileKindForPath(path: string): FileReferenceKind {
|
||||
const normalized = path.toLowerCase();
|
||||
const name = normalized.split(/[\\/]/).pop() ?? normalized;
|
||||
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
||||
@@ -134,7 +166,13 @@ function fileKindForPath(path: string): FileReferenceKind {
|
||||
case "jsx":
|
||||
case "tsx":
|
||||
return "react";
|
||||
case "js":
|
||||
case "mjs":
|
||||
case "cjs":
|
||||
return "javascript";
|
||||
case "ts":
|
||||
case "mts":
|
||||
case "cts":
|
||||
return "typescript";
|
||||
case "html":
|
||||
case "htm":
|
||||
@@ -156,7 +194,27 @@ function fileKindForPath(path: string): FileReferenceKind {
|
||||
}
|
||||
}
|
||||
|
||||
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
||||
export function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
||||
if (kind === "python") {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="h-[1em] w-[1em] shrink-0 translate-y-[0.12em]"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M11.9 2.3c-3 0-4.5.8-4.5 2.3v2.1h4.8v.8H5.5C4 7.5 3 8.8 3 10.8v2.1c0 1.8 1.1 3 2.7 3h1.6v-2.3c0-1.7 1.4-3.1 3.1-3.1h4.2c1.3 0 2.3-1 2.3-2.3V4.6c0-1.4-1.5-2.3-4.6-2.3h-.4Z"
|
||||
fill="#3776AB"
|
||||
/>
|
||||
<path
|
||||
d="M12.1 21.7c3 0 4.5-.8 4.5-2.3v-2.1h-4.8v-.8h6.7c1.5 0 2.5-1.3 2.5-3.3v-2.1c0-1.8-1.1-3-2.7-3h-1.6v2.3c0 1.7-1.4 3.1-3.1 3.1H9.4c-1.3 0-2.3 1-2.3 2.3v3.6c0 1.4 1.5 2.3 4.6 2.3h.4Z"
|
||||
fill="#FFD43B"
|
||||
/>
|
||||
<circle cx="9" cy="5.1" r="0.8" fill="#fff" />
|
||||
<circle cx="15" cy="18.9" r="0.8" fill="#5C3B00" opacity="0.85" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (kind === "react") {
|
||||
return (
|
||||
<svg
|
||||
@@ -234,6 +292,8 @@ function fileKindLabel(kind: FileReferenceKind): string {
|
||||
return "#";
|
||||
case "html":
|
||||
return "H";
|
||||
case "javascript":
|
||||
return "JS";
|
||||
case "json":
|
||||
return "{}";
|
||||
case "markdown":
|
||||
|
||||
@@ -16,6 +16,7 @@ interface MarkdownTextProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
streaming?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
|
||||
@@ -25,13 +26,19 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
|
||||
source,
|
||||
className,
|
||||
highlightCode,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
source: string;
|
||||
className?: string;
|
||||
highlightCode: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<LazyMarkdownRenderer className={className} highlightCode={highlightCode}>
|
||||
<LazyMarkdownRenderer
|
||||
className={className}
|
||||
highlightCode={highlightCode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
>
|
||||
{source}
|
||||
</LazyMarkdownRenderer>
|
||||
);
|
||||
@@ -55,6 +62,7 @@ export function MarkdownText({
|
||||
children,
|
||||
className,
|
||||
streaming = false,
|
||||
onOpenFilePreview,
|
||||
}: MarkdownTextProps) {
|
||||
const renderedSource = useStreamingMarkdownSource(children, streaming);
|
||||
const highlightCode = streaming
|
||||
@@ -82,6 +90,7 @@ export function MarkdownText({
|
||||
source={renderedSource}
|
||||
className={className}
|
||||
highlightCode={highlightCode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { Children, isValidElement, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import { Check } from "lucide-react";
|
||||
import { Check, Globe2 } from "lucide-react";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
|
||||
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
|
||||
import {
|
||||
FileReferenceChip,
|
||||
isFilePatternReference,
|
||||
isLikelyFilePath,
|
||||
} from "@/components/FileReferenceChip";
|
||||
import { inferMediaKind } from "@/lib/media";
|
||||
import { faviconUrls } from "@/lib/provider-brand";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import "katex/dist/katex.min.css";
|
||||
@@ -19,6 +32,7 @@ interface MarkdownTextRendererProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
highlightCode?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
type MarkdownAstNode = {
|
||||
@@ -32,10 +46,9 @@ type MarkdownAstNode = {
|
||||
|
||||
type InlineLinkPreview = {
|
||||
href: string;
|
||||
origin: string;
|
||||
host: string;
|
||||
prefix?: string;
|
||||
title: string;
|
||||
initials: string;
|
||||
};
|
||||
|
||||
const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
|
||||
@@ -187,6 +200,45 @@ function nodeText(value: ReactNode): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function cleanFileReferenceTarget(value: string): string {
|
||||
let target = value.trim();
|
||||
if (!target) return "";
|
||||
try {
|
||||
if (/^file:\/\//i.test(target)) {
|
||||
target = decodeURIComponent(new URL(target).pathname);
|
||||
} else {
|
||||
target = decodeURIComponent(target);
|
||||
}
|
||||
} catch {
|
||||
// Keep the raw value when URL/path decoding is not possible.
|
||||
}
|
||||
target = target.split("?", 1)[0]?.split("#", 1)[0]?.trim() ?? "";
|
||||
if (!/^[A-Za-z]:[\\/]/.test(target)) {
|
||||
target = target.replace(/:\d+(?::\d+)?$/, "");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function isPreviewableFileTarget(value: string): boolean {
|
||||
if (isFilePatternReference(value)) return false;
|
||||
if (isLikelyFilePath(value)) return true;
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
|
||||
if (/[\\/]/.test(value)) return false;
|
||||
return /^[^?#]+\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(value);
|
||||
}
|
||||
|
||||
function isNonNavigableFilePatternLink(href: string | undefined): boolean {
|
||||
if (!href || /^https?:\/\//i.test(href) || href.startsWith("#")) return false;
|
||||
const target = cleanFileReferenceTarget(href);
|
||||
return Boolean(target && isFilePatternReference(target));
|
||||
}
|
||||
|
||||
function fileReferenceFromLink(href: string | undefined): string | null {
|
||||
if (!href || /^https?:\/\//i.test(href) || href.startsWith("#")) return null;
|
||||
const target = cleanFileReferenceTarget(href);
|
||||
return isPreviewableFileTarget(target) ? target : null;
|
||||
}
|
||||
|
||||
function linkPreviewParts(value: ReactNode): { text: string; href?: string } {
|
||||
let text = "";
|
||||
let href: string | undefined;
|
||||
@@ -216,16 +268,6 @@ function cleanLinkPreviewText(value: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function linkPreviewInitials(value: string): string {
|
||||
const clean = value
|
||||
.replace(/^https?:\/\//i, "")
|
||||
.replace(/^www\./i, "")
|
||||
.replace(/\.[a-z]{2,}$/i, "");
|
||||
const parts = clean.split(/[\s.-]+/).filter(Boolean);
|
||||
return (parts.length > 1 ? parts.slice(0, 2).map((part) => part[0]).join("") : clean.slice(0, 2))
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview | null {
|
||||
const { text: rawText, href } = linkPreviewParts(children);
|
||||
if (!href) return null;
|
||||
@@ -253,17 +295,18 @@ function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview |
|
||||
|
||||
return {
|
||||
href,
|
||||
origin: url.origin,
|
||||
host: url.hostname,
|
||||
prefix,
|
||||
title,
|
||||
initials: linkPreviewInitials(prefix || url.hostname),
|
||||
};
|
||||
}
|
||||
|
||||
function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
const { favicon, onFaviconError } = useFaviconFallback(link.host);
|
||||
const label = link.prefix
|
||||
? `${link.prefix} — ${link.title}`
|
||||
: link.title;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
@@ -278,20 +321,21 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
<span
|
||||
className={cn(
|
||||
"relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px]",
|
||||
"border border-border/65 bg-background text-[0.5rem] font-semibold text-muted-foreground",
|
||||
"border border-border/65 bg-background text-muted-foreground",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{link.initials}
|
||||
<img
|
||||
src={`${link.origin}/favicon.ico`}
|
||||
alt=""
|
||||
className="absolute h-3 w-3 rounded-[2px] object-contain"
|
||||
loading="lazy"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
{favicon ? (
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
className="h-3 w-3 rounded-[2px] object-contain"
|
||||
loading="lazy"
|
||||
onError={onFaviconError}
|
||||
/>
|
||||
) : (
|
||||
<Globe2 className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate leading-normal">
|
||||
{label}
|
||||
@@ -300,6 +344,24 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
);
|
||||
}
|
||||
|
||||
function useFaviconFallback(host: string) {
|
||||
const faviconCandidates = useMemo(() => faviconUrls(host), [host]);
|
||||
const [faviconIndex, setFaviconIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFaviconIndex(0);
|
||||
}, [host]);
|
||||
|
||||
const onFaviconError = useCallback(() => {
|
||||
setFaviconIndex((index) => Math.min(index + 1, faviconCandidates.length));
|
||||
}, [faviconCandidates.length]);
|
||||
|
||||
return {
|
||||
favicon: faviconCandidates[faviconIndex] ?? null,
|
||||
onFaviconError,
|
||||
};
|
||||
}
|
||||
|
||||
function isRenderedCodeBlock(value: ReactNode): boolean {
|
||||
if (!isValidElement(value)) return false;
|
||||
const props = value.props as { code?: unknown };
|
||||
@@ -326,6 +388,7 @@ export default function MarkdownTextRenderer({
|
||||
children,
|
||||
className,
|
||||
highlightCode = true,
|
||||
onOpenFilePreview,
|
||||
}: MarkdownTextRendererProps) {
|
||||
const components = useMemo<Components>(
|
||||
() => ({
|
||||
@@ -344,7 +407,7 @@ export default function MarkdownTextRenderer({
|
||||
}
|
||||
const raw = String(kids).replace(/\n$/, "");
|
||||
if (isLikelyFilePath(raw)) {
|
||||
return <FileReferenceChip path={raw} />;
|
||||
return <FileReferenceChip path={raw} onOpen={onOpenFilePreview} />;
|
||||
}
|
||||
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
|
||||
const widePlainBlock = raw.includes("\n") || raw.length > 120;
|
||||
@@ -405,6 +468,21 @@ export default function MarkdownTextRenderer({
|
||||
);
|
||||
},
|
||||
a({ href, children: markdownChildren, ...props }) {
|
||||
const filePath = fileReferenceFromLink(href);
|
||||
if (filePath) {
|
||||
const label = nodeText(markdownChildren).trim();
|
||||
return (
|
||||
<FileReferenceChip
|
||||
path={label || filePath}
|
||||
tooltipPath={filePath}
|
||||
previewPath={filePath}
|
||||
onOpen={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isNonNavigableFilePatternLink(href)) {
|
||||
return <>{markdownChildren}</>;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
@@ -495,7 +573,7 @@ export default function MarkdownTextRenderer({
|
||||
);
|
||||
},
|
||||
}),
|
||||
[highlightCode],
|
||||
[highlightCode, onOpenFilePreview],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Check, ChevronRight, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react";
|
||||
import { Check, ChevronRight, Clock3, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||
@@ -33,6 +33,7 @@ interface MessageBubbleProps {
|
||||
showAssistantCopyAction?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +50,7 @@ export function MessageBubble({
|
||||
showAssistantCopyAction = true,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onOpenFilePreview,
|
||||
}: MessageBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -129,6 +131,10 @@ export function MessageBubble({
|
||||
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
|
||||
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
|
||||
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
|
||||
const automationSourceLabel = message.source?.kind === "cron"
|
||||
? (message.source.label?.trim() || t("message.automationSourceFallback"))
|
||||
: "";
|
||||
const automationTriggeredLabel = t("message.automationTriggered");
|
||||
|
||||
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
||||
const showCopyButton = showAssistantCopyAction && showAssistantActions;
|
||||
@@ -142,13 +148,29 @@ export function MessageBubble({
|
||||
return (
|
||||
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||
{hasReasoning ? (
|
||||
<ReasoningBubble text={reasoning} streaming={reasoningStreaming} hasBodyBelow={!empty} />
|
||||
<ReasoningBubble
|
||||
text={reasoning}
|
||||
streaming={reasoningStreaming}
|
||||
hasBodyBelow={!empty}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
{empty && message.isStreaming && !hasReasoning ? (
|
||||
<TypingDots />
|
||||
) : empty && message.isStreaming ? null : (
|
||||
<>
|
||||
<MarkdownText streaming={!!message.isStreaming}>{message.content}</MarkdownText>
|
||||
{automationSourceLabel ? (
|
||||
<AutomationSourceBadge
|
||||
label={automationSourceLabel}
|
||||
triggerLabel={automationTriggeredLabel}
|
||||
/>
|
||||
) : null}
|
||||
<MarkdownText
|
||||
streaming={!!message.isStreaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownText>
|
||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||
{showAssistantFooterRow ? (
|
||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
||||
@@ -187,6 +209,25 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 inline-flex max-w-full items-center gap-1.5 rounded-full px-2 py-1",
|
||||
"border border-sky-500/15 bg-sky-500/[0.06]",
|
||||
"text-[11px] font-medium leading-none text-sky-700",
|
||||
"dark:border-sky-300/15 dark:bg-sky-300/[0.08] dark:text-sky-200/80",
|
||||
)}
|
||||
title={triggerLabel}
|
||||
>
|
||||
<Clock3 className="h-3 w-3 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
<span className="text-current/45" aria-hidden>·</span>
|
||||
<span className="shrink-0">{triggerLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeMcpMentionPresets(
|
||||
presets: McpPresetInfo[],
|
||||
attachments: UIMcpPresetAttachment[] | undefined,
|
||||
@@ -488,6 +529,7 @@ interface ReasoningBubbleProps {
|
||||
hasBodyBelow: boolean;
|
||||
/** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */
|
||||
embeddedInCluster?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,6 +551,7 @@ export function ReasoningBubble({
|
||||
streaming,
|
||||
hasBodyBelow,
|
||||
embeddedInCluster = false,
|
||||
onOpenFilePreview,
|
||||
}: ReasoningBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
@@ -567,6 +610,7 @@ export function ReasoningBubble({
|
||||
>
|
||||
<MarkdownText
|
||||
streaming={streaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
className={cn(
|
||||
"text-[12.5px] italic text-muted-foreground/88",
|
||||
"prose-p:my-1.5 prose-li:my-0.5",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
Archive,
|
||||
Brain,
|
||||
Menu,
|
||||
Search,
|
||||
Settings,
|
||||
@@ -34,8 +35,9 @@ interface SidebarProps {
|
||||
onNewChatInProject: (projectPath: string, projectName: string) => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenApps: () => void;
|
||||
onOpenSkills: () => void;
|
||||
onOpenSearch: () => void;
|
||||
activeUtility?: "apps" | null;
|
||||
activeUtility?: "apps" | "skills" | null;
|
||||
onToggleArchived: () => void;
|
||||
onCollapse: () => void;
|
||||
onExpand?: () => void;
|
||||
@@ -157,6 +159,13 @@ export function Sidebar(props: SidebarProps) {
|
||||
active={props.activeUtility === "apps"}
|
||||
icon={<Blocks className="h-4 w-4" />}
|
||||
/>
|
||||
<SidebarActionButton
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.skills.title")}
|
||||
onClick={props.onOpenSkills}
|
||||
active={props.activeUtility === "skills"}
|
||||
icon={<Brain className="h-4 w-4" />}
|
||||
/>
|
||||
{props.archivedCount ? (
|
||||
<SidebarActionButton
|
||||
collapsed={collapsed}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Bot,
|
||||
Brain,
|
||||
Check,
|
||||
CircleAlert,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -52,6 +53,8 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
|
||||
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -73,6 +76,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createModelConfiguration,
|
||||
fetchSettings,
|
||||
fetchSettingsUsage,
|
||||
fetchCliApps,
|
||||
fetchMcpPresets,
|
||||
fetchProviderModels,
|
||||
@@ -99,6 +103,7 @@ import {
|
||||
providerDisplayLabel,
|
||||
} from "@/lib/provider-brand";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { shortWorkspacePath } from "@/lib/workspace";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
@@ -109,6 +114,7 @@ import type {
|
||||
NetworkSafetySettingsUpdate,
|
||||
ProviderModelsPayload,
|
||||
SettingsPayload,
|
||||
SkillSummary,
|
||||
WebSearchSettingsUpdate,
|
||||
WebuiDefaultAccessMode,
|
||||
} from "@/lib/types";
|
||||
@@ -120,6 +126,7 @@ export type SettingsSectionKey =
|
||||
| "image"
|
||||
| "browser"
|
||||
| "apps"
|
||||
| "skills"
|
||||
| "runtime"
|
||||
| "advanced";
|
||||
|
||||
@@ -167,7 +174,6 @@ type ProviderApiType = "auto" | "chat_completions" | "responses";
|
||||
type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType };
|
||||
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
|
||||
|
||||
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
|
||||
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
|
||||
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
|
||||
"aihubmix",
|
||||
@@ -265,15 +271,18 @@ const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
|
||||
interface SettingsViewProps {
|
||||
theme: "light" | "dark";
|
||||
initialSection?: SettingsSectionKey;
|
||||
initialSettings?: SettingsPayload | null;
|
||||
showSidebar?: boolean;
|
||||
onToggleTheme: () => void;
|
||||
onBackToChat: () => void;
|
||||
onModelNameChange: (modelName: string | null) => void;
|
||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
||||
skills?: SkillSummary[];
|
||||
onWorkspaceSettingsChange?: () => void | Promise<void>;
|
||||
onSectionChange?: (section: SettingsSectionKey) => void;
|
||||
onLogout?: () => void;
|
||||
onRestart?: () => void;
|
||||
onNativeEngineRestart?: () => Promise<string>;
|
||||
isRestarting?: boolean;
|
||||
hostChromeInset?: boolean;
|
||||
}
|
||||
@@ -311,27 +320,150 @@ function editableDefaultProvider(payload: SettingsPayload): string {
|
||||
return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "";
|
||||
}
|
||||
|
||||
function settingsProviderRow(
|
||||
payload: SettingsPayload,
|
||||
provider: string | null | undefined,
|
||||
): SettingsPayload["providers"][number] | null {
|
||||
if (!provider) return null;
|
||||
return payload.providers.find((row) => row.name === provider) ?? null;
|
||||
}
|
||||
|
||||
function settingsProviderConfigured(
|
||||
payload: SettingsPayload,
|
||||
provider: string | null | undefined,
|
||||
): boolean {
|
||||
const row = settingsProviderRow(payload, provider);
|
||||
if (row) return row.configured;
|
||||
return payload.agent.has_api_key;
|
||||
}
|
||||
|
||||
const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
|
||||
model: "",
|
||||
provider: "",
|
||||
modelPreset: "default",
|
||||
presetLabel: "Default",
|
||||
contextWindowTokens: 65_536,
|
||||
timezone: "UTC",
|
||||
botName: "nanobot",
|
||||
botIcon: "",
|
||||
toolHintMaxLength: 40,
|
||||
};
|
||||
|
||||
const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
|
||||
provider: "duckduckgo",
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
maxResults: 5,
|
||||
timeout: 30,
|
||||
useJinaReader: true,
|
||||
};
|
||||
|
||||
const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
|
||||
enabled: false,
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
defaultAspectRatio: "1:1",
|
||||
defaultImageSize: "1K",
|
||||
maxImagesPerTurn: 4,
|
||||
};
|
||||
|
||||
const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
|
||||
webuiAllowLocalServiceAccess: true,
|
||||
webuiDefaultAccessMode: "default",
|
||||
};
|
||||
|
||||
function agentDraftFromPayload(payload: SettingsPayload): AgentSettingsDraft {
|
||||
const fallbackDefault = defaultPreset(payload);
|
||||
const activePresetName = modelPresetValue(payload);
|
||||
const activePreset =
|
||||
payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault;
|
||||
return {
|
||||
model: activePreset?.model ?? payload.agent.model,
|
||||
provider: activePreset?.is_default
|
||||
? editableDefaultProvider(payload)
|
||||
: activePreset?.provider ?? editableDefaultProvider(payload),
|
||||
modelPreset: activePresetName,
|
||||
presetLabel: activePreset?.label ?? activePresetName,
|
||||
contextWindowTokens: normalizeContextWindowTokens(
|
||||
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
|
||||
),
|
||||
timezone: payload.agent.timezone,
|
||||
botName: payload.agent.bot_name,
|
||||
botIcon: payload.agent.bot_icon,
|
||||
toolHintMaxLength: payload.agent.tool_hint_max_length,
|
||||
};
|
||||
}
|
||||
|
||||
function webSearchFormFromPayload(
|
||||
payload: SettingsPayload,
|
||||
previous?: WebSearchSettingsUpdate,
|
||||
): WebSearchSettingsUpdate {
|
||||
return {
|
||||
provider: payload.web_search.provider,
|
||||
apiKey: previous?.provider === payload.web_search.provider ? previous.apiKey ?? "" : "",
|
||||
baseUrl: payload.web_search.base_url ?? "",
|
||||
maxResults: payload.web_search.max_results,
|
||||
timeout: payload.web_search.timeout,
|
||||
useJinaReader: payload.web.fetch.use_jina_reader,
|
||||
};
|
||||
}
|
||||
|
||||
function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate {
|
||||
return {
|
||||
enabled: payload.image_generation.enabled,
|
||||
provider: payload.image_generation.provider,
|
||||
model: payload.image_generation.model,
|
||||
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
|
||||
defaultImageSize: payload.image_generation.default_image_size,
|
||||
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
|
||||
};
|
||||
}
|
||||
|
||||
function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
|
||||
return {
|
||||
webuiAllowLocalServiceAccess:
|
||||
payload.advanced.webui_allow_local_service_access ??
|
||||
payload.advanced.allow_local_preview_access ??
|
||||
true,
|
||||
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(
|
||||
payload.advanced.webui_default_access_mode,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function pendingRestartSectionsFromPayload(payload: SettingsPayload): PendingRestartSections {
|
||||
const sections = payload.restart_required_sections ?? [];
|
||||
return {
|
||||
runtime: sections.includes("runtime"),
|
||||
browser: sections.includes("browser"),
|
||||
image: sections.includes("image"),
|
||||
};
|
||||
}
|
||||
|
||||
export function SettingsView({
|
||||
theme,
|
||||
initialSection = "overview",
|
||||
initialSettings = null,
|
||||
showSidebar = true,
|
||||
onToggleTheme,
|
||||
onBackToChat,
|
||||
onModelNameChange,
|
||||
onSettingsChange,
|
||||
skills = [],
|
||||
onWorkspaceSettingsChange,
|
||||
onSectionChange,
|
||||
onLogout,
|
||||
onRestart,
|
||||
onNativeEngineRestart,
|
||||
isRestarting = false,
|
||||
hostChromeInset = false,
|
||||
}: SettingsViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useClient();
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
|
||||
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
|
||||
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(() => initialSettings === null);
|
||||
const [cliAppsLoading, setCliAppsLoading] = useState(true);
|
||||
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -370,26 +502,18 @@ export function SettingsView({
|
||||
EMPTY_PENDING_RESTART_SECTIONS,
|
||||
);
|
||||
const [localPrefs, setLocalPrefs] = useState<LocalPreferences>(() => readLocalPreferences());
|
||||
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>({
|
||||
provider: "duckduckgo",
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
maxResults: 5,
|
||||
timeout: 30,
|
||||
useJinaReader: true,
|
||||
});
|
||||
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>({
|
||||
enabled: false,
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
defaultAspectRatio: "1:1",
|
||||
defaultImageSize: "1K",
|
||||
maxImagesPerTurn: 4,
|
||||
});
|
||||
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>({
|
||||
webuiAllowLocalServiceAccess: true,
|
||||
webuiDefaultAccessMode: "default",
|
||||
});
|
||||
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>(() =>
|
||||
initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM,
|
||||
);
|
||||
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>(
|
||||
() =>
|
||||
initialSettings
|
||||
? imageGenerationFormFromPayload(initialSettings)
|
||||
: DEFAULT_IMAGE_GENERATION_FORM,
|
||||
);
|
||||
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
|
||||
initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSection(initialSection);
|
||||
@@ -404,17 +528,9 @@ export function SettingsView({
|
||||
);
|
||||
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
|
||||
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
|
||||
const [form, setForm] = useState<AgentSettingsDraft>({
|
||||
model: "",
|
||||
provider: "",
|
||||
modelPreset: "default",
|
||||
presetLabel: "Default",
|
||||
contextWindowTokens: 65_536,
|
||||
timezone: "UTC",
|
||||
botName: "nanobot",
|
||||
botIcon: "",
|
||||
toolHintMaxLength: 40,
|
||||
});
|
||||
const [form, setForm] = useState<AgentSettingsDraft>(() =>
|
||||
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
|
||||
);
|
||||
|
||||
const text = useCallback(
|
||||
(key: string, fallback: string, options?: Record<string, unknown>) =>
|
||||
@@ -423,59 +539,27 @@ export function SettingsView({
|
||||
);
|
||||
|
||||
const applyPayload = useCallback((payload: SettingsPayload) => {
|
||||
const fallbackDefault = defaultPreset(payload);
|
||||
const activePresetName = modelPresetValue(payload);
|
||||
const activePreset =
|
||||
payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault;
|
||||
setSettings(payload);
|
||||
setForm({
|
||||
model: activePreset?.model ?? payload.agent.model,
|
||||
provider: activePreset?.is_default
|
||||
? editableDefaultProvider(payload)
|
||||
: activePreset?.provider ?? editableDefaultProvider(payload),
|
||||
modelPreset: activePresetName,
|
||||
presetLabel: activePreset?.label ?? activePresetName,
|
||||
contextWindowTokens: normalizeContextWindowTokens(
|
||||
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
|
||||
),
|
||||
timezone: payload.agent.timezone,
|
||||
botName: payload.agent.bot_name,
|
||||
botIcon: payload.agent.bot_icon,
|
||||
toolHintMaxLength: payload.agent.tool_hint_max_length,
|
||||
});
|
||||
setWebSearchForm((prev) => ({
|
||||
provider: payload.web_search.provider,
|
||||
apiKey: prev.provider === payload.web_search.provider ? prev.apiKey ?? "" : "",
|
||||
baseUrl: payload.web_search.base_url ?? "",
|
||||
maxResults: payload.web_search.max_results,
|
||||
timeout: payload.web_search.timeout,
|
||||
useJinaReader: payload.web.fetch.use_jina_reader,
|
||||
}));
|
||||
setImageGenerationForm({
|
||||
enabled: payload.image_generation.enabled,
|
||||
provider: payload.image_generation.provider,
|
||||
model: payload.image_generation.model,
|
||||
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
|
||||
defaultImageSize: payload.image_generation.default_image_size,
|
||||
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
|
||||
});
|
||||
setNetworkSafetyForm({
|
||||
webuiAllowLocalServiceAccess: payload.advanced.webui_allow_local_service_access ?? payload.advanced.allow_local_preview_access ?? true,
|
||||
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(payload.advanced.webui_default_access_mode),
|
||||
});
|
||||
setForm(agentDraftFromPayload(payload));
|
||||
setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev));
|
||||
setImageGenerationForm(imageGenerationFormFromPayload(payload));
|
||||
setNetworkSafetyForm(networkSafetyFormFromPayload(payload));
|
||||
if (payload.restart_required_sections) {
|
||||
setPendingRestartSections({
|
||||
runtime: payload.restart_required_sections.includes("runtime"),
|
||||
browser: payload.restart_required_sections.includes("browser"),
|
||||
image: payload.restart_required_sections.includes("image"),
|
||||
});
|
||||
setPendingRestartSections(pendingRestartSectionsFromPayload(payload));
|
||||
}
|
||||
onSettingsChange?.(payload);
|
||||
}, [onSettingsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSettings || settings !== null) return;
|
||||
applyPayload(initialSettings);
|
||||
setLoading(false);
|
||||
}, [applyPayload, initialSettings, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const showLoading = settings === null;
|
||||
if (showLoading) setLoading(true);
|
||||
fetchSettings(token)
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
@@ -484,7 +568,7 @@ export function SettingsView({
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError((err as Error).message);
|
||||
if (!cancelled && showLoading) setError((err as Error).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
@@ -494,6 +578,34 @@ export function SettingsView({
|
||||
};
|
||||
}, [applyPayload, token]);
|
||||
|
||||
const hasSettings = settings !== null;
|
||||
useEffect(() => {
|
||||
if (activeSection !== "overview" || !hasSettings) return;
|
||||
let cancelled = false;
|
||||
const refresh = () => {
|
||||
fetchSettingsUsage(token)
|
||||
.then((usage) => {
|
||||
if (cancelled) return;
|
||||
setSettings((current) => (current ? { ...current, usage } : current));
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
void refresh();
|
||||
const interval = window.setInterval(refresh, 5000);
|
||||
const onFocus = () => refresh();
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") refresh();
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [activeSection, hasSettings, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "apps") return;
|
||||
let cancelled = false;
|
||||
@@ -629,12 +741,15 @@ export function SettingsView({
|
||||
|
||||
const restartViaSettingsSurface = useCallback(async () => {
|
||||
const isNativeHost = (settings?.surface ?? settings?.runtime_surface) === "native";
|
||||
const hostApi = getHostApi();
|
||||
if (isNativeHost && settings?.runtime_capabilities?.can_restart_engine && hostApi) {
|
||||
if (
|
||||
isNativeHost &&
|
||||
settings?.runtime_capabilities?.can_restart_engine &&
|
||||
onNativeEngineRestart
|
||||
) {
|
||||
setHostEngineApplying(true);
|
||||
try {
|
||||
await hostApi.restartEngine();
|
||||
const payload = await fetchSettings(token);
|
||||
const nextToken = await onNativeEngineRestart();
|
||||
const payload = await fetchSettings(nextToken);
|
||||
applyPayload(payload);
|
||||
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
|
||||
setError(null);
|
||||
@@ -646,21 +761,25 @@ export function SettingsView({
|
||||
return;
|
||||
}
|
||||
onRestart?.();
|
||||
}, [applyPayload, onRestart, settings, token]);
|
||||
}, [applyPayload, onNativeEngineRestart, onRestart, settings]);
|
||||
|
||||
const maybeRestartHostEngine = useCallback(
|
||||
async (payload: RestartAwarePayload) => {
|
||||
const surface = payload.surface ?? payload.runtime_surface ?? settings?.surface ?? settings?.runtime_surface;
|
||||
const capabilities = payload.runtime_capabilities ?? settings?.runtime_capabilities;
|
||||
const isNativeHost = surface === "native";
|
||||
const hostApi = getHostApi();
|
||||
if (!payload.requires_restart || !isNativeHost || !capabilities?.can_restart_engine || !hostApi) {
|
||||
if (
|
||||
!payload.requires_restart ||
|
||||
!isNativeHost ||
|
||||
!capabilities?.can_restart_engine ||
|
||||
!onNativeEngineRestart
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setHostEngineApplying(true);
|
||||
try {
|
||||
await hostApi.restartEngine();
|
||||
const refreshed = await fetchSettings(token);
|
||||
const nextToken = await onNativeEngineRestart();
|
||||
const refreshed = await fetchSettings(nextToken);
|
||||
applyPayload(refreshed);
|
||||
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
|
||||
setError(null);
|
||||
@@ -670,7 +789,7 @@ export function SettingsView({
|
||||
setHostEngineApplying(false);
|
||||
}
|
||||
},
|
||||
[applyPayload, settings, token],
|
||||
[applyPayload, onNativeEngineRestart, settings],
|
||||
);
|
||||
|
||||
const saveModelSettings = async () => {
|
||||
@@ -1135,8 +1254,6 @@ export function SettingsView({
|
||||
<OverviewSettings
|
||||
settings={settings}
|
||||
requiresRestart={hasPendingRestart}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
onSelectSection={selectSection}
|
||||
/>
|
||||
@@ -1290,6 +1407,8 @@ export function SettingsView({
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
/>
|
||||
);
|
||||
case "skills":
|
||||
return <SkillsCatalogSettings skills={skills} />;
|
||||
case "runtime":
|
||||
return (
|
||||
<RuntimeSettings
|
||||
@@ -1354,10 +1473,20 @@ export function SettingsView({
|
||||
)}
|
||||
>
|
||||
<div className="mb-7">
|
||||
<p className="mb-2 text-[13px] font-medium text-muted-foreground">
|
||||
{!showSidebar ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToChat}
|
||||
className="mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("settings.backToChat")}
|
||||
</button>
|
||||
) : null}
|
||||
<p className="mb-2 text-[12px] font-normal text-muted-foreground">
|
||||
{t("settings.sidebar.title")}
|
||||
</p>
|
||||
<h1 className="text-[28px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[34px]">
|
||||
<h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
|
||||
{text(`settings.nav.${activeSection}`, titleForSection(activeSection))}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -1437,7 +1566,7 @@ function SettingsSidebar({
|
||||
{t("settings.backToChat")}
|
||||
</button>
|
||||
<div className="mb-3 px-1 md:mb-4 md:px-2">
|
||||
<h2 className="text-[21px] font-semibold tracking-[-0.02em] text-foreground">
|
||||
<h2 className="text-[18px] font-normal tracking-normal text-foreground">
|
||||
{t("settings.sidebar.title")}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -1488,15 +1617,11 @@ function SettingsSidebar({
|
||||
function OverviewSettings({
|
||||
settings,
|
||||
requiresRestart,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
onSelectSection,
|
||||
showBrandLogos,
|
||||
}: {
|
||||
settings: SettingsPayload;
|
||||
requiresRestart: boolean;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
onSelectSection: (section: SettingsSectionKey) => void;
|
||||
showBrandLogos: boolean;
|
||||
}) {
|
||||
@@ -1504,6 +1629,16 @@ function OverviewSettings({
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const activePreset = settings.agent.model_preset || "default";
|
||||
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
|
||||
const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider);
|
||||
const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider);
|
||||
const activeModelValue = activeProviderConfigured
|
||||
? settings.agent.model
|
||||
: tx("settings.values.notConfigured", "Not configured");
|
||||
const activeModelCaption = activeProviderConfigured
|
||||
? `${activeProvider} · ${activePreset}`
|
||||
: activeProviderLabel || settings.agent.model
|
||||
? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ")
|
||||
: tx("settings.byok.noConfiguredProviders", "No configured providers");
|
||||
const webStatus = settings.web.enable
|
||||
? tx("settings.values.enabled", "Enabled")
|
||||
: tx("settings.values.disabled", "Disabled");
|
||||
@@ -1515,48 +1650,23 @@ function OverviewSettings({
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")
|
||||
}`;
|
||||
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
|
||||
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
|
||||
const runtimeTitle = isNativeHost
|
||||
? tx("settings.rows.engine", "Engine")
|
||||
: tx("settings.rows.gateway", "Gateway");
|
||||
const runtimeValue = isNativeHost
|
||||
? tx("settings.values.privateEngine", "Private engine")
|
||||
: `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`;
|
||||
const runtimeCaption = isNativeHost
|
||||
? tx("settings.values.unixSocket", "Unix socket")
|
||||
: requiresRestart
|
||||
? tx("settings.values.restartPending", "Restart pending")
|
||||
: tx("settings.values.ready", "Ready");
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.075)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.24)]">
|
||||
<div className="flex flex-col gap-4 px-5 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<NanobotBrandLogo size="lg" testId="overview-nanobot-logo" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-medium text-muted-foreground">nanobot</div>
|
||||
<div className="mt-0.5 truncate text-[18px] font-semibold leading-6 text-foreground">
|
||||
{settings.agent.model}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[13px] leading-5 text-muted-foreground">
|
||||
{activeProvider} · {activePreset}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
<StatusPill tone={requiresRestart ? "neutral" : "success"}>
|
||||
{requiresRestart
|
||||
? tx("settings.values.restartPending", "Restart pending")
|
||||
: tx("settings.values.ready", "Ready")}
|
||||
</StatusPill>
|
||||
{requiresRestart && onRestart ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRestart}
|
||||
disabled={isRestarting}
|
||||
className="rounded-full"
|
||||
>
|
||||
{isRestarting ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TokenUsageHeatmap usage={settings.usage} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -1566,8 +1676,8 @@ function OverviewSettings({
|
||||
icon={Bot}
|
||||
valueLogoProvider={activeProvider}
|
||||
title={tx("settings.overview.model", "Current model")}
|
||||
value={settings.agent.model}
|
||||
caption={`${activeProvider} · ${activePreset}`}
|
||||
value={activeModelValue}
|
||||
caption={activeModelCaption}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("models")}
|
||||
/>
|
||||
@@ -1603,20 +1713,16 @@ function OverviewSettings({
|
||||
<SettingsGroup>
|
||||
<OverviewListRow
|
||||
icon={Server}
|
||||
title={tx("settings.rows.gateway", "Gateway")}
|
||||
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
|
||||
caption={
|
||||
requiresRestart
|
||||
? tx("settings.values.restartPending", "Restart pending")
|
||||
: tx("settings.values.ready", "Ready")
|
||||
}
|
||||
title={runtimeTitle}
|
||||
value={runtimeValue}
|
||||
caption={runtimeCaption}
|
||||
onClick={() => onSelectSection("runtime")}
|
||||
/>
|
||||
<OverviewListRow
|
||||
icon={HardDrive}
|
||||
title={tx("settings.overview.workspace", "Workspace")}
|
||||
value={settings.runtime.workspace_path}
|
||||
caption={settings.runtime.config_path}
|
||||
value={tx("settings.values.defaultWorkspace", "Default workspace")}
|
||||
caption={workspaceCaption}
|
||||
onClick={() => onSelectSection("runtime")}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
@@ -1885,9 +1991,8 @@ function ModelsSettings({
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
||||
const oauthProviders = settings.providers.filter((provider) => provider.auth_type === "oauth");
|
||||
const showAutoProvider = defaultPreset(settings)?.provider === "auto" || form.provider === "auto";
|
||||
const selectableProviders = uniqueProviders([...configuredProviders, ...oauthProviders]);
|
||||
const selectableProviders = uniqueProviders(configuredProviders);
|
||||
const providerOptions = showAutoProvider
|
||||
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
|
||||
: selectableProviders;
|
||||
@@ -1900,6 +2005,7 @@ function ModelsSettings({
|
||||
const selectedProviderNeedsSignIn =
|
||||
selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
|
||||
const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
|
||||
const selectedProviderConfigured = settingsProviderConfigured(settings, form.provider);
|
||||
const modelFieldsMissing =
|
||||
!form.model.trim() ||
|
||||
!form.provider.trim() ||
|
||||
@@ -1918,6 +2024,7 @@ function ModelsSettings({
|
||||
settings={settings}
|
||||
draftModel={form.model}
|
||||
draftProvider={form.provider}
|
||||
providerConfigured={selectedProviderConfigured}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(modelPreset) => {
|
||||
const nextPreset = settings.model_presets.find((preset) => preset.name === modelPreset);
|
||||
@@ -2871,9 +2978,11 @@ function AppsCatalogSettings({
|
||||
const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets;
|
||||
const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null);
|
||||
const statusIsError = Boolean(cliError || mcpError);
|
||||
const caption = tx("settings.apps.caption", "{{cli}} CLI · {{mcp}} MCP")
|
||||
.replace("{{cli}}", String(cliApps?.installed_count ?? 0))
|
||||
.replace("{{mcp}}", String(mcpPresets?.installed_count ?? 0));
|
||||
const caption = t("settings.apps.caption", {
|
||||
cli: cliApps?.installed_count ?? 0,
|
||||
mcp: mcpPresets?.installed_count ?? 0,
|
||||
defaultValue: "{{cli}} CLI · {{mcp}} MCP",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
@@ -3255,7 +3364,10 @@ function McpAppsCatalogRow({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12.5px] font-semibold text-foreground">
|
||||
{tx("settings.mcp.connectTitle", "Connect {{name}}").replace("{{name}}", preset.display_name)}
|
||||
{t("settings.mcp.connectTitle", {
|
||||
name: preset.display_name,
|
||||
defaultValue: "Connect {{name}}",
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||
{tx("settings.mcp.connectHint", "Add the key from your account settings.")}
|
||||
@@ -4060,10 +4172,12 @@ function RuntimeSettings({
|
||||
<section>
|
||||
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<ReadOnlyRow
|
||||
title={tx("settings.rows.gateway", "Gateway")}
|
||||
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
|
||||
/>
|
||||
{!isNativeHost ? (
|
||||
<ReadOnlyRow
|
||||
title={tx("settings.rows.gateway", "Gateway")}
|
||||
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
|
||||
/>
|
||||
) : null}
|
||||
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
|
||||
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
|
||||
{onRestart && !requiresRestartPending ? (
|
||||
@@ -4369,7 +4483,14 @@ function ModelIdPicker({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const effectiveProvider =
|
||||
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
|
||||
const canFetchModels = Boolean(effectiveProvider && effectiveProvider !== "auto");
|
||||
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
|
||||
const providerRow = settingsProviderRow(settings, effectiveProvider);
|
||||
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
|
||||
const providerRequiresConfiguration = hasConcreteProvider && !providerConfigured;
|
||||
const providerUsesManualModelIds =
|
||||
hasConcreteProvider && providerConfigured && providerRow?.auth_type === "oauth";
|
||||
const canFetchModels =
|
||||
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const providerModels = payload?.models ?? [];
|
||||
const visibleModels = providerModels
|
||||
@@ -4390,13 +4511,15 @@ function ModelIdPicker({
|
||||
const hasModelList = payload?.status === "available";
|
||||
const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery));
|
||||
const customCandidate = query.trim();
|
||||
const allowCustomModel = !providerRequiresConfiguration;
|
||||
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
|
||||
const providerModelCount = payload?.model_count ?? providerModels.length;
|
||||
const modelUnconfigured = !value.trim() || !providerConfigured;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery("");
|
||||
}, [open, effectiveProvider]);
|
||||
setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
|
||||
}, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !shouldFetchModels) {
|
||||
@@ -4443,7 +4566,11 @@ function ModelIdPicker({
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
|
||||
<ProviderPickerIcon
|
||||
provider={effectiveProvider}
|
||||
showBrandLogos={showProviderLogos}
|
||||
unconfigured={!providerConfigured}
|
||||
/>
|
||||
<span className="min-w-0 truncate font-medium text-foreground">
|
||||
{model.label ?? model.id}
|
||||
</span>
|
||||
@@ -4467,7 +4594,11 @@ function ModelIdPicker({
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
|
||||
<ProviderPickerIcon
|
||||
provider={effectiveProvider}
|
||||
showBrandLogos={showProviderLogos}
|
||||
unconfigured={modelUnconfigured}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate font-medium",
|
||||
@@ -4500,7 +4631,15 @@ function ModelIdPicker({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canFetchModels ? (
|
||||
{providerRequiresConfiguration ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
|
||||
</div>
|
||||
) : providerUsesManualModelIds ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
||||
</div>
|
||||
) : !canFetchModels ? (
|
||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
|
||||
</div>
|
||||
@@ -4544,7 +4683,7 @@ function ModelIdPicker({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{customCandidate && !exactQueryMatch && customCandidate !== value ? (
|
||||
{allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value ? (
|
||||
<>
|
||||
{showModels ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
@@ -4581,17 +4720,31 @@ function formatContextWindow(tokens: number): string {
|
||||
function ProviderPickerIcon({
|
||||
provider,
|
||||
showBrandLogos,
|
||||
unconfigured = false,
|
||||
}: {
|
||||
provider: string;
|
||||
showBrandLogos: boolean;
|
||||
unconfigured?: boolean;
|
||||
}) {
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const brand = providerBrand(provider);
|
||||
const Icon = PROVIDER_ICONS[provider] ?? Sparkles;
|
||||
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
|
||||
const logoUrl = brand?.logoUrls[logoIndex];
|
||||
|
||||
useEffect(() => setLogoIndex(0), [provider]);
|
||||
|
||||
if (unconfigured) {
|
||||
return (
|
||||
<span
|
||||
data-testid="provider-picker-unconfigured-icon"
|
||||
className="grid h-5 w-5 shrink-0 place-items-center text-amber-700 dark:text-amber-200"
|
||||
aria-hidden
|
||||
>
|
||||
<CircleAlert className="h-4 w-4" strokeWidth={1.8} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (showBrandLogos && logoUrl) {
|
||||
return (
|
||||
<span
|
||||
@@ -4901,32 +5054,6 @@ function ProviderIcon({
|
||||
);
|
||||
}
|
||||
|
||||
function NanobotBrandLogo({
|
||||
size = "sm",
|
||||
testId,
|
||||
}: {
|
||||
size?: "sm" | "lg";
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center overflow-hidden border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]",
|
||||
size === "lg" ? "h-12 w-12 rounded-[16px]" : "h-9 w-9 rounded-[12px]",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
src={NANOBOT_ICON_SRC}
|
||||
alt=""
|
||||
className={cn("select-none object-contain", size === "lg" ? "h-10 w-10" : "h-7 w-7")}
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewRowIcon({
|
||||
icon: Icon,
|
||||
}: {
|
||||
@@ -5090,6 +5217,7 @@ function ModelPresetPicker({
|
||||
settings,
|
||||
draftModel,
|
||||
draftProvider,
|
||||
providerConfigured,
|
||||
showProviderLogos,
|
||||
onChange,
|
||||
onCreateConfiguration,
|
||||
@@ -5099,6 +5227,7 @@ function ModelPresetPicker({
|
||||
settings: SettingsPayload;
|
||||
draftModel: string;
|
||||
draftProvider: string;
|
||||
providerConfigured: boolean;
|
||||
showProviderLogos: boolean;
|
||||
onChange: (preset: string) => void;
|
||||
onCreateConfiguration: () => void;
|
||||
@@ -5126,6 +5255,7 @@ function ModelPresetPicker({
|
||||
settings={settings}
|
||||
draftModel={draftModel}
|
||||
draftProvider={draftProvider}
|
||||
forceUnconfigured={selectedPreset?.is_default ? !providerConfigured : undefined}
|
||||
showProviderLogos={showProviderLogos}
|
||||
compact
|
||||
/>
|
||||
@@ -5190,6 +5320,7 @@ function ModelPresetOptionContent({
|
||||
settings,
|
||||
draftModel,
|
||||
draftProvider,
|
||||
forceUnconfigured,
|
||||
showProviderLogos,
|
||||
compact = false,
|
||||
}: {
|
||||
@@ -5197,27 +5328,50 @@ function ModelPresetOptionContent({
|
||||
settings: SettingsPayload;
|
||||
draftModel: string;
|
||||
draftProvider: string;
|
||||
forceUnconfigured?: boolean;
|
||||
showProviderLogos: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const provider = modelPresetProviderKey(preset, settings, {
|
||||
draftProvider: preset.is_default ? draftProvider : undefined,
|
||||
});
|
||||
const model = preset.is_default ? draftModel : preset.model;
|
||||
const providerName = providerDisplayLabel(settings.providers, provider);
|
||||
const providerConfigured =
|
||||
forceUnconfigured === undefined
|
||||
? settingsProviderConfigured(settings, provider)
|
||||
: !forceUnconfigured;
|
||||
const title = providerConfigured ? model || preset.label : tx("settings.values.notConfigured", "Not configured");
|
||||
const caption = providerConfigured
|
||||
? `${providerName}${preset.label ? ` · ${preset.label}` : ""}`
|
||||
: providerName || model || preset.label
|
||||
? [providerName, model || preset.label].filter(Boolean).join(" · ")
|
||||
: tx("settings.byok.noConfiguredProviders", "No configured providers");
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<ProviderPickerIcon provider={provider} showBrandLogos={showProviderLogos} />
|
||||
<ProviderPickerIcon
|
||||
provider={provider}
|
||||
showBrandLogos={showProviderLogos}
|
||||
unconfigured={!providerConfigured}
|
||||
/>
|
||||
<span className="min-w-0 text-left leading-tight">
|
||||
<span className="block truncate font-medium text-foreground">{model || preset.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate font-medium",
|
||||
providerConfigured ? "text-foreground" : "text-amber-800 dark:text-amber-200",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 block truncate text-muted-foreground",
|
||||
compact ? "text-[11.5px]" : "text-[12px]",
|
||||
)}
|
||||
>
|
||||
{providerName}
|
||||
{preset.label ? ` · ${preset.label}` : ""}
|
||||
{caption}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Brain, Check, CircleAlert, KeyRound, Loader2, Terminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
|
||||
import { fetchSkillDetail } from "@/lib/api";
|
||||
import type { SkillDetail, SkillSummary } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
||||
const { t } = useTranslation();
|
||||
const availableCount = skills.filter((skill) => skill.available).length;
|
||||
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
|
||||
{t("settings.skills.description", {
|
||||
defaultValue: "Review the instruction skills this agent can load during a conversation.",
|
||||
})}
|
||||
</p>
|
||||
<span className="text-[12px] font-medium text-muted-foreground">
|
||||
{t("settings.skills.caption", {
|
||||
available: availableCount,
|
||||
total: skills.length,
|
||||
defaultValue: "{{available}} available · {{total}} total",
|
||||
})}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between border-b border-border/45 pb-3">
|
||||
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
||||
{t("settings.skills.featured", { defaultValue: "Agent skills" })}
|
||||
</h2>
|
||||
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
{skills.length}
|
||||
</span>
|
||||
</div>
|
||||
{skills.length ? (
|
||||
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
|
||||
{skills.map((skill) => (
|
||||
<SkillCatalogRow
|
||||
key={`${skill.source}:${skill.name}`}
|
||||
skill={skill}
|
||||
onSelect={setSelectedSkill}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
|
||||
{t("settings.skills.empty", { defaultValue: "No skills are available." })}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<SkillDetailSheet
|
||||
skill={selectedSkill}
|
||||
open={selectedSkill !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedSkill(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillCatalogRow({
|
||||
skill,
|
||||
onSelect,
|
||||
}: {
|
||||
skill: SkillSummary;
|
||||
onSelect: (skill: SkillSummary) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const sourceLabel = skillSourceLabel(skill.source, t);
|
||||
const StatusIcon = skill.available ? Check : CircleAlert;
|
||||
const statusLabel = skill.available
|
||||
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
|
||||
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("settings.skills.openDetails", {
|
||||
name: skill.name,
|
||||
defaultValue: "Open details for {{name}}",
|
||||
})}
|
||||
onClick={() => onSelect(skill)}
|
||||
className={cn(
|
||||
"group flex min-w-0 items-center gap-3 rounded-[16px] px-3 py-3 text-left transition-colors",
|
||||
"hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
!skill.available && "opacity-65",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[14px] bg-muted/70 text-muted-foreground">
|
||||
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="truncate text-[15px] font-semibold leading-5 text-foreground">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground">
|
||||
{sourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
|
||||
{skill.description}
|
||||
</p>
|
||||
{!skill.available && skill.unavailable_reason ? (
|
||||
<p className="mt-1 truncate text-[12px] leading-4 text-muted-foreground/80">
|
||||
{t("settings.skills.unavailableReason", {
|
||||
reason: skill.unavailable_reason,
|
||||
defaultValue: "Missing: {{reason}}",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
title={!skill.available && skill.unavailable_reason ? skill.unavailable_reason : undefined}
|
||||
className={cn(
|
||||
"hidden shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-medium sm:inline-flex",
|
||||
skill.available
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<StatusIcon className="h-3.5 w-3.5" aria-hidden />
|
||||
{statusLabel}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillDetailSheet({
|
||||
skill,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
skill: SkillSummary | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { token } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !skill) return;
|
||||
let cancelled = false;
|
||||
setDetail(null);
|
||||
setLoading(true);
|
||||
setLoadFailed(false);
|
||||
fetchSkillDetail(token, skill.name)
|
||||
.then((payload) => {
|
||||
if (!cancelled) setDetail(payload);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, skill, token]);
|
||||
|
||||
if (!skill) return null;
|
||||
|
||||
const activeSkill = detail ?? skill;
|
||||
const sourceLabel = skillSourceLabel(activeSkill.source, t);
|
||||
const statusLabel = activeSkill.available
|
||||
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
|
||||
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[15px] bg-muted/70 text-muted-foreground">
|
||||
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="truncate text-[20px] font-semibold">
|
||||
{activeSkill.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("settings.skills.detailDescription", {
|
||||
name: activeSkill.name,
|
||||
defaultValue: "Details for {{name}}.",
|
||||
})}
|
||||
</SheetDescription>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
|
||||
<Pill>{sourceLabel}</Pill>
|
||||
<Pill tone={activeSkill.available ? "success" : "muted"}>{statusLabel}</Pill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
|
||||
</div>
|
||||
) : loadFailed ? (
|
||||
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
|
||||
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-7 space-y-6">
|
||||
<DetailSection title={t("settings.skills.descriptionTitle", { defaultValue: "Description" })}>
|
||||
<p className="text-[14px] leading-6 text-muted-foreground">{activeSkill.description}</p>
|
||||
</DetailSection>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MetaItem
|
||||
label={t("settings.skills.source", { defaultValue: "Source" })}
|
||||
value={sourceLabel}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("settings.skills.status", { defaultValue: "Status" })}
|
||||
value={statusLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!activeSkill.available && activeSkill.unavailable_reason ? (
|
||||
<DetailSection
|
||||
title={t("settings.skills.unavailableReasonLabel", {
|
||||
defaultValue: "Unavailable reason",
|
||||
})}
|
||||
>
|
||||
<p className="text-[13px] leading-5 text-destructive/85">
|
||||
{activeSkill.unavailable_reason}
|
||||
</p>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{detail ? <RequirementsSection detail={detail} /> : null}
|
||||
|
||||
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function RawInstructionsBlock({ markdown }: { markdown: string }) {
|
||||
const { t } = useTranslation();
|
||||
const content =
|
||||
markdown ||
|
||||
t("settings.skills.rawInstructionsEmpty", {
|
||||
defaultValue: "No raw instructions.",
|
||||
});
|
||||
|
||||
return (
|
||||
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
|
||||
<summary className="cursor-pointer select-none text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
|
||||
{t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })}
|
||||
</summary>
|
||||
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70">
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-[min(42vh,32rem)] overflow-auto overscroll-contain px-3.5 py-3 pr-4",
|
||||
"whitespace-pre-wrap break-words font-mono text-[12px] leading-[1.7] text-foreground/62",
|
||||
"scrollbar-thin scrollbar-track-transparent",
|
||||
"[&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar]:w-1.5",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/25",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-[16px] bg-muted/35 px-3 py-2.5">
|
||||
<div className="text-[11px] text-muted-foreground">{label}</div>
|
||||
<div className="mt-0.5 truncate text-[13px] font-medium text-foreground">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementsSection({ detail }: { detail: SkillDetail }) {
|
||||
const { t } = useTranslation();
|
||||
const { bins, env, missing_bins, missing_env } = detail.requirements;
|
||||
const hasRequirements = bins.length > 0 || env.length > 0;
|
||||
|
||||
return (
|
||||
<DetailSection title={t("settings.skills.requirements", { defaultValue: "Requirements" })}>
|
||||
{hasRequirements ? (
|
||||
<div className="space-y-3">
|
||||
{missing_bins.length ? (
|
||||
<RequirementLine
|
||||
title={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
|
||||
items={missing_bins}
|
||||
tone="danger"
|
||||
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
|
||||
/>
|
||||
) : null}
|
||||
{missing_env.length ? (
|
||||
<RequirementLine
|
||||
title={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
|
||||
items={missing_env}
|
||||
tone="danger"
|
||||
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
|
||||
/>
|
||||
) : null}
|
||||
{bins.length ? (
|
||||
<RequirementLine
|
||||
title={t("settings.skills.commands", { defaultValue: "Commands" })}
|
||||
items={bins}
|
||||
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
|
||||
/>
|
||||
) : null}
|
||||
{env.length ? (
|
||||
<RequirementLine
|
||||
title={t("settings.skills.environment", { defaultValue: "Environment variables" })}
|
||||
items={env}
|
||||
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })}
|
||||
</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementLine({
|
||||
title,
|
||||
items,
|
||||
icon,
|
||||
tone = "muted",
|
||||
}: {
|
||||
title: string;
|
||||
items: string[];
|
||||
icon: ReactNode;
|
||||
tone?: "muted" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-[12px]",
|
||||
tone === "danger" ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((item) => (
|
||||
<Pill key={item}>{item}</Pill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({
|
||||
children,
|
||||
tone = "muted",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "muted" | "success";
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
tone === "success"
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function skillSourceLabel(source: string, t: TFunction): string {
|
||||
if (source === "workspace") {
|
||||
return t("settings.skills.sourceWorkspace", { defaultValue: "Custom" });
|
||||
}
|
||||
if (source === "builtin") {
|
||||
return t("settings.skills.sourceBuiltin", { defaultValue: "Built-in" });
|
||||
}
|
||||
return source;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SettingsPayload } from "@/lib/types";
|
||||
|
||||
type TokenUsagePayload = NonNullable<SettingsPayload["usage"]>;
|
||||
type TokenUsageDay = TokenUsagePayload["days"][number];
|
||||
type TokenUsageCell = {
|
||||
date: string;
|
||||
total: number;
|
||||
estimated: number;
|
||||
requests: number;
|
||||
sources: NonNullable<TokenUsageDay["sources"]>;
|
||||
future: boolean;
|
||||
};
|
||||
type TokenUsageMonthLabel = {
|
||||
label: string;
|
||||
column: number;
|
||||
};
|
||||
|
||||
const TOKEN_HEATMAP_CELLS = 371;
|
||||
const TOKEN_HEATMAP_COLUMNS = Math.ceil(TOKEN_HEATMAP_CELLS / 7);
|
||||
const TOKEN_USAGE_SOURCE_ORDER = ["user", "api", "cron", "dream", "system"] as const;
|
||||
|
||||
function startOfUtcDay(date: Date): Date {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
}
|
||||
|
||||
function addUtcDays(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setUTCDate(next.getUTCDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function isoDay(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildTokenUsageCalendar(
|
||||
days: TokenUsageDay[] | undefined,
|
||||
monthFormatter: Intl.DateTimeFormat,
|
||||
): { cells: TokenUsageCell[]; monthLabels: TokenUsageMonthLabel[] } {
|
||||
const byDate = new Map((days ?? []).map((day) => [day.date, day]));
|
||||
const today = startOfUtcDay(new Date());
|
||||
const end = addUtcDays(today, 6 - today.getUTCDay());
|
||||
const start = addUtcDays(end, -(TOKEN_HEATMAP_CELLS - 1));
|
||||
const seenMonths = new Set<string>();
|
||||
const monthLabels: TokenUsageMonthLabel[] = [];
|
||||
|
||||
const cells = Array.from({ length: TOKEN_HEATMAP_CELLS }, (_, index) => {
|
||||
const date = addUtcDays(start, index);
|
||||
const key = isoDay(date);
|
||||
const row = byDate.get(key);
|
||||
const monthKey = key.slice(0, 7);
|
||||
if (!seenMonths.has(monthKey)) {
|
||||
seenMonths.add(monthKey);
|
||||
monthLabels.push({
|
||||
label: monthFormatter.format(date),
|
||||
column: Math.floor(index / 7) + 1,
|
||||
});
|
||||
}
|
||||
return {
|
||||
date: key,
|
||||
total: row?.total_tokens ?? 0,
|
||||
estimated: row?.estimated_tokens ?? 0,
|
||||
requests: row?.requests ?? 0,
|
||||
sources: row?.sources ?? {},
|
||||
future: date > today,
|
||||
};
|
||||
});
|
||||
return { cells, monthLabels };
|
||||
}
|
||||
|
||||
function tokenUsageSourceLabel(
|
||||
source: string,
|
||||
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (source === "user") return tx("settings.usage.sources.user", "Chat");
|
||||
if (source === "api") return tx("settings.usage.sources.api", "API");
|
||||
if (source === "cron") return tx("settings.usage.sources.cron", "Automations");
|
||||
if (source === "dream") return tx("settings.usage.sources.dream", "Memory");
|
||||
return tx("settings.usage.sources.system", "System");
|
||||
}
|
||||
|
||||
function tokenUsageSourceBreakdown(
|
||||
cell: TokenUsageCell,
|
||||
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
const known = TOKEN_USAGE_SOURCE_ORDER.filter((source) => cell.sources[source]?.total_tokens > 0);
|
||||
const extra = Object.keys(cell.sources)
|
||||
.filter((source) => !TOKEN_USAGE_SOURCE_ORDER.includes(source as typeof TOKEN_USAGE_SOURCE_ORDER[number]))
|
||||
.filter((source) => cell.sources[source]?.total_tokens > 0)
|
||||
.sort();
|
||||
return [...known, ...extra]
|
||||
.map((source) => {
|
||||
const label = tokenUsageSourceLabel(source, tx);
|
||||
const tokens = formatCompactTokens(cell.sources[source]?.total_tokens ?? 0);
|
||||
return `${label} ${tokens}`;
|
||||
})
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function formatCompactTokens(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens >= 10_000_000 ? 0 : 1)}M`;
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens >= 10_000 ? 0 : 1)}K`;
|
||||
return String(tokens);
|
||||
}
|
||||
|
||||
function tokenUsageLevel(tokens: number, max: number): number {
|
||||
if (tokens <= 0 || max <= 0) return 0;
|
||||
const ratio = tokens / max;
|
||||
if (ratio >= 0.75) return 4;
|
||||
if (ratio >= 0.45) return 3;
|
||||
if (ratio >= 0.2) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function tokenUsageCellClass(level: number, future: boolean): string {
|
||||
if (future) return "bg-transparent ring-1 ring-neutral-200/70 dark:ring-white/[0.045]";
|
||||
if (level === 4) return "bg-sky-300 dark:bg-sky-300";
|
||||
if (level === 3) return "bg-sky-400/85 dark:bg-sky-500/80";
|
||||
if (level === 2) return "bg-sky-500/60 dark:bg-sky-700/85";
|
||||
if (level === 1) return "bg-sky-500/30 dark:bg-sky-900/80";
|
||||
return "bg-neutral-200/70 ring-1 ring-black/[0.025] dark:bg-white/[0.08] dark:ring-white/[0.035]";
|
||||
}
|
||||
|
||||
export function TokenUsageHeatmap({ usage }: { usage?: TokenUsagePayload }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||
const monthFormatter = useMemo(
|
||||
() => new Intl.DateTimeFormat(i18n.language, { month: "short", timeZone: "UTC" }),
|
||||
[i18n.language],
|
||||
);
|
||||
const { cells, monthLabels } = useMemo(
|
||||
() => buildTokenUsageCalendar(usage?.days, monthFormatter),
|
||||
[monthFormatter, usage?.days],
|
||||
);
|
||||
const maxTokens = Math.max(0, ...cells.map((cell) => cell.total));
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="mx-auto w-full min-w-[760px] max-w-[1054px] px-0.5">
|
||||
<div className="mb-2 flex justify-end">
|
||||
<span className="text-[11px] font-normal leading-none text-muted-foreground/64">
|
||||
{tx("settings.usage.shortTitle", "Token Usage")}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mb-2 grid h-4 gap-1.5 text-[10px] font-normal leading-4 text-muted-foreground/62"
|
||||
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
|
||||
aria-hidden
|
||||
>
|
||||
{monthLabels.map((month) => (
|
||||
<span
|
||||
key={`${month.label}-${month.column}`}
|
||||
className="truncate"
|
||||
style={{ gridColumnStart: month.column, gridColumnEnd: "span 4" }}
|
||||
>
|
||||
{month.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="grid grid-flow-col grid-rows-7 gap-1.5"
|
||||
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
|
||||
aria-label={tx("settings.usage.title", "Token activity")}
|
||||
>
|
||||
<TooltipProvider delayDuration={120} skipDelayDuration={80}>
|
||||
{cells.map((cell) => {
|
||||
const level = tokenUsageLevel(cell.total, maxTokens);
|
||||
const baseLabel = cell.future
|
||||
? cell.date
|
||||
: tx("settings.usage.cellTitle", "{{date}}: {{tokens}} tokens, {{requests}} requests", {
|
||||
date: cell.date,
|
||||
tokens: formatCompactTokens(cell.total),
|
||||
requests: cell.requests,
|
||||
});
|
||||
const label = cell.future || cell.estimated <= 0
|
||||
? baseLabel
|
||||
: `${baseLabel} · ${
|
||||
cell.estimated >= cell.total
|
||||
? tx("settings.usage.estimated", "estimated")
|
||||
: tx("settings.usage.includesEstimates", "includes estimates")
|
||||
}`;
|
||||
const breakdown = cell.future ? "" : tokenUsageSourceBreakdown(cell, tx);
|
||||
const ariaLabel = breakdown ? `${label} · ${breakdown}` : label;
|
||||
return (
|
||||
<Tooltip key={cell.date}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"aspect-square w-full rounded-[4px] transition-transform hover:scale-110",
|
||||
tokenUsageCellClass(level, cell.future),
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="rounded-[10px] border-border/45 bg-popover px-2.5 py-1.5 text-[11px] font-normal text-popover-foreground shadow-lg"
|
||||
>
|
||||
<span className="block">{label}</span>
|
||||
{breakdown ? (
|
||||
<span className="mt-1 block text-muted-foreground">{breakdown}</span>
|
||||
) : null}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -173,6 +173,7 @@ interface AgentActivityClusterProps {
|
||||
turnLatencyMs?: number;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +187,7 @@ export function AgentActivityCluster({
|
||||
turnLatencyMs,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onOpenFilePreview,
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileEdits = useMemo(
|
||||
@@ -423,6 +425,7 @@ export function AgentActivityCluster({
|
||||
added={added}
|
||||
deleted={deleted}
|
||||
hasDiffStats={hasDiffStats}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -449,6 +452,8 @@ export function AgentActivityCluster({
|
||||
<FileReferenceChip
|
||||
path={singleFilePath}
|
||||
tooltipPath={singleFileTooltipPath}
|
||||
previewPath={singleFileTooltipPath || singleFilePath}
|
||||
onOpen={onOpenFilePreview}
|
||||
active={hasLiveEditingFiles}
|
||||
className="-my-0.5 min-w-0"
|
||||
textClassName="text-xs"
|
||||
@@ -494,6 +499,7 @@ export function AgentActivityCluster({
|
||||
key={m.id}
|
||||
text={m.reasoning ?? ""}
|
||||
streaming={isTurnStreaming && !!m.reasoningStreaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -510,7 +516,12 @@ export function AgentActivityCluster({
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
|
||||
{fileEdits.length ? (
|
||||
<FileEditGroup
|
||||
edits={fileEdits}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -537,6 +548,7 @@ function FileEditFlatActivity({
|
||||
added,
|
||||
deleted,
|
||||
hasDiffStats,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edits: FileEditSummary[];
|
||||
active: boolean;
|
||||
@@ -550,6 +562,7 @@ function FileEditFlatActivity({
|
||||
added: number;
|
||||
deleted: number;
|
||||
hasDiffStats: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending);
|
||||
return (
|
||||
@@ -569,6 +582,8 @@ function FileEditFlatActivity({
|
||||
<FileReferenceChip
|
||||
path={singleFilePath}
|
||||
tooltipPath={singleFileTooltipPath}
|
||||
previewPath={singleFileTooltipPath || singleFilePath}
|
||||
onOpen={onOpenFilePreview}
|
||||
active={hasLiveEditingFiles}
|
||||
className="-my-0.5 min-w-0"
|
||||
textClassName="text-xs"
|
||||
@@ -583,7 +598,7 @@ function FileEditFlatActivity({
|
||||
</div>
|
||||
{showRows ? (
|
||||
<div className="mt-0.5 pl-4">
|
||||
<FileEditGroup edits={edits} />
|
||||
<FileEditGroup edits={edits} onOpenFilePreview={onOpenFilePreview} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ListTree, Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
type PromptAnchor,
|
||||
userPromptAnchors,
|
||||
} from "@/components/thread/promptNavigation";
|
||||
import { fmtDateTime } from "@/lib/format";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PromptNavigatorProps {
|
||||
messages: UIMessage[];
|
||||
onJumpToPrompt: (promptId: string) => void;
|
||||
}
|
||||
|
||||
export function PromptNavigator({
|
||||
messages,
|
||||
onJumpToPrompt,
|
||||
}: PromptNavigatorProps) {
|
||||
const { i18n, t } = useTranslation();
|
||||
const prompts = useMemo(() => userPromptAnchors(messages), [messages]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filteredPrompts = useMemo(() => {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
if (!needle) return prompts;
|
||||
return prompts.filter((prompt) =>
|
||||
`${prompt.label}\n${prompt.preview}`.toLocaleLowerCase().includes(needle),
|
||||
);
|
||||
}, [prompts, query]);
|
||||
|
||||
if (prompts.length === 0) return null;
|
||||
|
||||
const jump = (promptId: string) => {
|
||||
setOpen(false);
|
||||
onJumpToPrompt(promptId);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/80",
|
||||
"hover:bg-accent/40 hover:text-foreground",
|
||||
)}
|
||||
aria-label={t("thread.promptNavigator.open")}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<ListTree className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
aria-describedby={undefined}
|
||||
className="w-[min(92vw,24rem)] gap-0 p-0 sm:max-w-[24rem]"
|
||||
>
|
||||
<div className="border-b px-5 pb-4 pt-5">
|
||||
<SheetTitle className="text-base font-medium">
|
||||
{t("thread.promptNavigator.title")}
|
||||
</SheetTitle>
|
||||
<div className="relative mt-4">
|
||||
<Search
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
aria-label={t("thread.promptNavigator.search")}
|
||||
placeholder={t("thread.promptNavigator.search")}
|
||||
className={cn(
|
||||
"h-10 w-full rounded-full border border-border bg-background pl-9 pr-3 text-sm",
|
||||
"outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/20",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||
{filteredPrompts.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{filteredPrompts.map((prompt) => (
|
||||
<PromptNavigatorRow
|
||||
key={prompt.id}
|
||||
locale={i18n.resolvedLanguage || i18n.language}
|
||||
prompt={prompt}
|
||||
onJump={jump}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
|
||||
{t("thread.promptNavigator.noResults")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface PromptNavigatorRowProps {
|
||||
locale: string;
|
||||
onJump: (promptId: string) => void;
|
||||
prompt: PromptAnchor;
|
||||
}
|
||||
|
||||
function PromptNavigatorRow({
|
||||
locale,
|
||||
onJump,
|
||||
prompt,
|
||||
}: PromptNavigatorRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const timestamp = fmtDateTime(prompt.createdAt, locale);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full rounded-xl px-3 py-3 text-left transition",
|
||||
"hover:bg-accent focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30",
|
||||
)}
|
||||
aria-label={t("thread.promptNavigator.jumpTo", { label: prompt.label })}
|
||||
onClick={() => onJump(prompt.id)}
|
||||
>
|
||||
<div className="max-h-20 overflow-hidden whitespace-pre-wrap break-words text-sm leading-5 text-foreground">
|
||||
{prompt.preview}
|
||||
</div>
|
||||
{timestamp ? (
|
||||
<div className="mt-1 text-[10px] leading-4 text-muted-foreground/75">
|
||||
{timestamp}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,13 @@ import {
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import {
|
||||
findPromptElement,
|
||||
jumpToPrompt,
|
||||
type PromptAnchor,
|
||||
promptTop,
|
||||
userPromptAnchors,
|
||||
} from "@/components/thread/promptNavigation";
|
||||
|
||||
interface PromptRailProps {
|
||||
bottomOffset: number;
|
||||
@@ -16,11 +23,6 @@ interface PromptRailProps {
|
||||
scrollRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
interface PromptAnchor {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MeasuredPrompt extends PromptAnchor {
|
||||
top: number;
|
||||
topPercent: number;
|
||||
@@ -30,18 +32,21 @@ interface PromptMarker {
|
||||
count: number;
|
||||
ids: string[];
|
||||
label: string;
|
||||
preview: string;
|
||||
topPercent: number;
|
||||
}
|
||||
|
||||
const MIN_PROMPTS_FOR_RAIL = 3;
|
||||
const RAIL_MIN_SCROLL_RANGE_PX = 240;
|
||||
const RAIL_MIN_SCROLL_RANGE_PX = 80;
|
||||
const DENSE_PROMPT_THRESHOLD = 30;
|
||||
const DENSE_BUCKET_HEIGHT_PX = 12;
|
||||
const DENSE_BUCKET_FALLBACK_COUNT = 32;
|
||||
const DENSE_BUCKET_MAX_COUNT = 42;
|
||||
const MARKER_MIN_GAP_PX = 9;
|
||||
const MARKER_BASE_WIDTH_PX = 26;
|
||||
const MARKER_MAX_WIDTH_PX = 42;
|
||||
const MARKER_BASE_WIDTH_PX = 16;
|
||||
const MARKER_MAX_WIDTH_PX = 28;
|
||||
const MEASURE_RETRY_FRAMES = 4;
|
||||
const RAIL_REVEAL_MS = 1400;
|
||||
|
||||
export function PromptRail({
|
||||
bottomOffset,
|
||||
@@ -52,6 +57,19 @@ export function PromptRail({
|
||||
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
|
||||
const [markers, setMarkers] = useState<PromptMarker[]>([]);
|
||||
const [activePromptId, setActivePromptId] = useState<string | null>(null);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const revealTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const revealTemporarily = useCallback(() => {
|
||||
setRevealed(true);
|
||||
if (revealTimeoutRef.current !== null) {
|
||||
window.clearTimeout(revealTimeoutRef.current);
|
||||
}
|
||||
revealTimeoutRef.current = window.setTimeout(() => {
|
||||
setRevealed(false);
|
||||
revealTimeoutRef.current = null;
|
||||
}, RAIL_REVEAL_MS);
|
||||
}, []);
|
||||
|
||||
const updateMarkers = useCallback(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
@@ -74,8 +92,18 @@ export function PromptRail({
|
||||
}, [promptAnchors, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
updateMarkers();
|
||||
}, [updateMarkers]);
|
||||
let frame = 0;
|
||||
let remainingFrames = MEASURE_RETRY_FRAMES;
|
||||
const measure = () => {
|
||||
updateMarkers();
|
||||
remainingFrames -= 1;
|
||||
if (remainingFrames > 0) {
|
||||
frame = window.requestAnimationFrame(measure);
|
||||
}
|
||||
};
|
||||
measure();
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [bottomOffset, updateMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
@@ -84,6 +112,7 @@ export function PromptRail({
|
||||
let frame = 0;
|
||||
const schedule = () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
revealTemporarily();
|
||||
frame = window.requestAnimationFrame(updateMarkers);
|
||||
};
|
||||
|
||||
@@ -94,7 +123,7 @@ export function PromptRail({
|
||||
scrollEl.removeEventListener("scroll", schedule);
|
||||
window.removeEventListener("resize", schedule);
|
||||
};
|
||||
}, [scrollRef, updateMarkers]);
|
||||
}, [revealTemporarily, scrollRef, updateMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
@@ -105,63 +134,85 @@ export function PromptRail({
|
||||
return () => observer.disconnect();
|
||||
}, [scrollRef, updateMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (revealTimeoutRef.current !== null) {
|
||||
window.clearTimeout(revealTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (markers.length === 0) return null;
|
||||
|
||||
const maxMarkerCount = Math.max(...markers.map((marker) => marker.count));
|
||||
const activeMarkerIndex = markers.findIndex((marker) =>
|
||||
marker.ids.includes(activePromptId ?? ""),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={railRef}
|
||||
aria-label="User prompt navigation"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-6 top-12 z-20 hidden w-12 md:block",
|
||||
"group pointer-events-auto absolute right-4 top-14 z-20 hidden w-8 opacity-70 md:block",
|
||||
"transition-opacity duration-200 hover:opacity-100",
|
||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200",
|
||||
)}
|
||||
style={{ bottom: Math.max(80, bottomOffset) }}
|
||||
>
|
||||
{markers.map((marker) => {
|
||||
{markers.map((marker, index) => {
|
||||
const active = marker.ids.includes(activePromptId ?? "");
|
||||
const nearActive = activeMarkerIndex < 0 || Math.abs(index - activeMarkerIndex) <= 1;
|
||||
return (
|
||||
<button
|
||||
key={marker.ids.join("|")}
|
||||
type="button"
|
||||
title={marker.label}
|
||||
aria-label={`Jump to prompt: ${marker.label}`}
|
||||
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute right-0 h-1.5 -translate-y-1/2 rounded-full",
|
||||
"bg-muted-foreground/30 transition-all duration-150",
|
||||
"hover:bg-blue-500/80 focus-visible:bg-blue-500",
|
||||
"group/marker absolute right-0 h-5 -translate-y-1/2 overflow-visible rounded-full",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
|
||||
marker.count > 1 && "bg-muted-foreground/45",
|
||||
active && "bg-foreground shadow-sm",
|
||||
)}
|
||||
style={{
|
||||
top: `${marker.topPercent}%`,
|
||||
width: markerWidth(marker.count, maxMarkerCount, active),
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute right-0 top-1/2 h-[3px] w-full -translate-y-1/2 rounded-full",
|
||||
"bg-foreground/20 transition-[background-color,opacity,transform,height] duration-200",
|
||||
"group-hover/marker:bg-blue-500/70 group-hover/marker:opacity-100 group-hover/marker:scale-x-110",
|
||||
"group-focus-visible/marker:bg-blue-500 group-focus-visible/marker:opacity-100 group-focus-visible/marker:scale-x-110",
|
||||
marker.count > 1 && "bg-foreground/30",
|
||||
active && "h-1 bg-foreground/65 opacity-80 shadow-sm",
|
||||
!active && nearActive && "opacity-25 group-hover:opacity-55",
|
||||
!active && !nearActive && !revealed && "opacity-0 group-hover:opacity-40",
|
||||
!active && !nearActive && revealed && "opacity-35",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-9 top-1/2 z-30 w-64 -translate-y-1/2 rounded-lg px-3 py-2 text-left",
|
||||
"bg-background/95 text-xs leading-5 text-foreground shadow-lg ring-1 ring-border/80 backdrop-blur",
|
||||
"opacity-0 translate-x-1 transition-[opacity,transform] duration-150",
|
||||
"group-hover/marker:opacity-100 group-hover/marker:translate-x-0",
|
||||
"group-focus-visible/marker:opacity-100 group-focus-visible/marker:translate-x-0",
|
||||
)}
|
||||
>
|
||||
<span className="block max-h-24 overflow-hidden whitespace-pre-wrap break-words">
|
||||
{marker.preview}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
|
||||
return messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message, index) => ({
|
||||
id: message.id,
|
||||
label: promptLabel(message.content, index),
|
||||
}));
|
||||
}
|
||||
|
||||
function promptLabel(content: string, index: number): string {
|
||||
const text = content.replace(/\s+/g, " ").trim();
|
||||
if (!text) return `Prompt ${index + 1}`;
|
||||
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
|
||||
}
|
||||
|
||||
function measurePrompts(
|
||||
scrollEl: HTMLElement,
|
||||
anchors: PromptAnchor[],
|
||||
@@ -199,12 +250,14 @@ function groupPromptMarkers(
|
||||
last.count += 1;
|
||||
last.ids.push(prompt.id);
|
||||
last.label = groupedPromptLabel(last.count, prompt.label);
|
||||
last.preview = groupedPromptPreview(last.count, prompt.preview);
|
||||
continue;
|
||||
}
|
||||
groups.push({
|
||||
count: 1,
|
||||
ids: [prompt.id],
|
||||
label: prompt.label,
|
||||
preview: prompt.preview,
|
||||
topPercent: prompt.topPercent,
|
||||
});
|
||||
}
|
||||
@@ -245,6 +298,9 @@ function bucketPromptMarkers(
|
||||
label: bucket.length === 1
|
||||
? latest.label
|
||||
: groupedPromptLabel(bucket.length, latest.label),
|
||||
preview: bucket.length === 1
|
||||
? latest.preview
|
||||
: groupedPromptPreview(bucket.length, latest.preview),
|
||||
topPercent,
|
||||
}];
|
||||
});
|
||||
@@ -271,6 +327,10 @@ function groupedPromptLabel(count: number, latestLabel: string): string {
|
||||
return `${count} prompts, latest: ${latestLabel}`;
|
||||
}
|
||||
|
||||
function groupedPromptPreview(count: number, latestPreview: string): string {
|
||||
return `${count} prompts\n\n${latestPreview}`;
|
||||
}
|
||||
|
||||
function markerWidth(count: number, maxCount: number, active: boolean): number {
|
||||
if (maxCount <= 1) return active ? 34 : MARKER_BASE_WIDTH_PX;
|
||||
const density = Math.log2(count + 1) / Math.log2(maxCount + 1);
|
||||
@@ -279,33 +339,6 @@ function markerWidth(count: number, maxCount: number, active: boolean): number {
|
||||
return Math.round(active ? width + 4 : width);
|
||||
}
|
||||
|
||||
function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
|
||||
if (!scrollEl || !promptId) return;
|
||||
const target = findPromptElement(scrollEl, promptId);
|
||||
if (!target) return;
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, promptTop(scrollEl, target) - 16),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
|
||||
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
|
||||
return Array.from(candidates).find(
|
||||
(candidate) => candidate.dataset.userPromptId === promptId,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
|
||||
if (hasLayoutRect) {
|
||||
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
|
||||
}
|
||||
return target.offsetTop;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CalendarClock,
|
||||
CircleAlert,
|
||||
ListTodo,
|
||||
RefreshCcw,
|
||||
} from "lucide-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useSessionAutomationJobs } from "@/hooks/useSessionAutomationJobs";
|
||||
import { currentLocale } from "@/i18n";
|
||||
import { fmtDateTime } from "@/lib/format";
|
||||
import type { SessionAutomationJob } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||
[60, "second"],
|
||||
[60, "minute"],
|
||||
[24, "hour"],
|
||||
[7, "day"],
|
||||
[4.345, "week"],
|
||||
[12, "month"],
|
||||
[Number.POSITIVE_INFINITY, "year"],
|
||||
];
|
||||
|
||||
interface SessionInfoPopoverProps {
|
||||
sessionKey: string;
|
||||
token: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopoverProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const { jobs, loading, loadFailed, now } = useSessionAutomationJobs(open, token, sessionKey);
|
||||
const automationContent = loading ? (
|
||||
<div className="flex items-center gap-2 rounded-[16px] bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground">
|
||||
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
|
||||
{t("thread.sessionInfo.loading")}
|
||||
</div>
|
||||
) : loadFailed ? (
|
||||
<div className="flex items-center gap-2 rounded-[16px] bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive">
|
||||
<CircleAlert className="h-3.5 w-3.5" />
|
||||
{t("thread.sessionInfo.loadFailed")}
|
||||
</div>
|
||||
) : jobs.length ? (
|
||||
<div className="space-y-1.5">
|
||||
{jobs.map((job) => (
|
||||
<AutomationRow key={job.id} job={job} now={now} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[16px] bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
{t("thread.sessionInfo.empty")}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("thread.header.sessionInfo")}
|
||||
className={cn(
|
||||
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/85",
|
||||
"hover:bg-accent/40 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<ListTodo className="h-4 w-4 stroke-[1.75]" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-[min(23rem,calc(100vw-1.5rem))] rounded-[24px] p-0"
|
||||
>
|
||||
<div className="space-y-3 px-4 py-3.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-normal text-muted-foreground/75">
|
||||
{t("thread.sessionInfo.title")}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[14px] font-medium text-foreground">
|
||||
{title || t("thread.sessionInfo.untitled")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/45" />
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
|
||||
<span className="truncate text-[13px] font-medium text-foreground">
|
||||
{t("thread.sessionInfo.automations")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-muted/70 px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{t("thread.sessionInfo.count", { count: jobs.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{automationContent}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function AutomationRow({ job, now }: { job: SessionAutomationJob; now: number }) {
|
||||
const { t } = useTranslation("common");
|
||||
const schedule = formatSchedule(job, t);
|
||||
const nextRun = formatNextRun(job, t, now);
|
||||
const statusClass = job.enabled
|
||||
? job.state.last_status === "error"
|
||||
? "bg-destructive"
|
||||
: "bg-emerald-500"
|
||||
: "bg-muted-foreground/35";
|
||||
|
||||
return (
|
||||
<div className="rounded-[16px] px-3 py-2.5 transition-colors hover:bg-muted/40">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", statusClass)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[13px] font-medium text-foreground">{job.name}</span>
|
||||
{!job.enabled ? (
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10.5px] text-muted-foreground">
|
||||
{t("thread.sessionInfo.disabled")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-[12px] leading-snug text-muted-foreground">
|
||||
{job.payload.message}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-muted-foreground/80">
|
||||
<span>{schedule}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span title={nextRun.title}>{nextRun.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSchedule(job: SessionAutomationJob, t: TFunction) {
|
||||
const locale = currentLocale();
|
||||
if (job.schedule.kind === "at" && job.schedule.at_ms) {
|
||||
return t("thread.sessionInfo.schedule.at", { time: fmtDateTime(job.schedule.at_ms, locale) });
|
||||
}
|
||||
if (job.schedule.kind === "every" && job.schedule.every_ms) {
|
||||
return t("thread.sessionInfo.schedule.every", {
|
||||
duration: formatDuration(job.schedule.every_ms, locale),
|
||||
});
|
||||
}
|
||||
if (job.schedule.kind === "cron" && job.schedule.expr) {
|
||||
return job.schedule.tz
|
||||
? t("thread.sessionInfo.schedule.cronWithTz", {
|
||||
expr: job.schedule.expr,
|
||||
tz: job.schedule.tz,
|
||||
})
|
||||
: t("thread.sessionInfo.schedule.cron", { expr: job.schedule.expr });
|
||||
}
|
||||
return t("thread.sessionInfo.schedule.unknown");
|
||||
}
|
||||
|
||||
function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
|
||||
const locale = currentLocale();
|
||||
if (!job.enabled) {
|
||||
return { label: t("thread.sessionInfo.next.disabled"), title: "" };
|
||||
}
|
||||
const next = job.state.next_run_at_ms;
|
||||
if (!next) {
|
||||
return { label: t("thread.sessionInfo.next.none"), title: "" };
|
||||
}
|
||||
return {
|
||||
label: t("thread.sessionInfo.next.label", { time: relativeTimeFrom(next, now, locale) }),
|
||||
title: fmtDateTime(next, locale),
|
||||
};
|
||||
}
|
||||
|
||||
function relativeTimeFrom(value: number, now: number, locale: string): string {
|
||||
let delta = (value - now) / 1000;
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
for (const [step, unit] of RELATIVE_THRESHOLDS) {
|
||||
if (Math.abs(delta) < step) {
|
||||
return formatter.format(Math.round(delta), unit);
|
||||
}
|
||||
delta /= step;
|
||||
}
|
||||
return formatter.format(Math.round(delta), "year");
|
||||
}
|
||||
|
||||
function formatDuration(ms: number, locale: string): string {
|
||||
const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
|
||||
["day", 86_400_000],
|
||||
["hour", 3_600_000],
|
||||
["minute", 60_000],
|
||||
["second", 1000],
|
||||
];
|
||||
for (const [unit, size] of units) {
|
||||
if (ms >= size && ms % size === 0) {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "unit",
|
||||
unit,
|
||||
unitDisplay: "long",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(ms / size);
|
||||
}
|
||||
}
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "unit",
|
||||
unit: "minute",
|
||||
unitDisplay: "long",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(ms / 60_000);
|
||||
}
|
||||
@@ -94,6 +94,8 @@ interface ThreadComposerProps {
|
||||
modelLabel?: string | null;
|
||||
modelProvider?: string | null;
|
||||
modelProviderLabel?: string | null;
|
||||
modelNeedsSetup?: boolean;
|
||||
onModelBadgeClick?: () => void;
|
||||
variant?: "thread" | "hero";
|
||||
slashCommands?: SlashCommand[];
|
||||
cliApps?: CliAppInfo[];
|
||||
@@ -647,6 +649,8 @@ export function ThreadComposer({
|
||||
modelLabel = null,
|
||||
modelProvider = null,
|
||||
modelProviderLabel = null,
|
||||
modelNeedsSetup = false,
|
||||
onModelBadgeClick,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
cliApps = [],
|
||||
@@ -759,17 +763,21 @@ export function ThreadComposer({
|
||||
);
|
||||
const hasErrors = images.some((img) => img.status === "error");
|
||||
|
||||
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
|
||||
const canSend =
|
||||
!disabled
|
||||
&& !modelNeedsSetup
|
||||
&& !encoding
|
||||
&& !hasErrors
|
||||
&& (value.trim().length > 0 || readyImages.length > 0);
|
||||
&& hasComposerContent;
|
||||
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
|
||||
const canQueueGuidance =
|
||||
isStreaming
|
||||
&& !disabled
|
||||
&& !modelNeedsSetup
|
||||
&& !encoding
|
||||
&& !hasErrors
|
||||
&& (value.trim().length > 0 || readyImages.length > 0)
|
||||
&& hasComposerContent
|
||||
&& !value.trimStart().startsWith("/");
|
||||
|
||||
const slashQuery = useMemo(() => {
|
||||
@@ -1181,6 +1189,10 @@ export function ThreadComposer({
|
||||
}, [onStop, queuedPrompts.length]);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (modelNeedsSetup) {
|
||||
onModelBadgeClick?.();
|
||||
return;
|
||||
}
|
||||
if (!canSend) return;
|
||||
const trimmed = value.trim();
|
||||
const content = trimmed;
|
||||
@@ -1219,6 +1231,8 @@ export function ThreadComposer({
|
||||
canSend,
|
||||
clear,
|
||||
clearComposerText,
|
||||
modelNeedsSetup,
|
||||
onModelBadgeClick,
|
||||
onSend,
|
||||
readyImages,
|
||||
value,
|
||||
@@ -1533,24 +1547,32 @@ export function ThreadComposer({
|
||||
label={modelLabel}
|
||||
provider={modelProvider}
|
||||
providerLabel={modelProviderLabel}
|
||||
needsSetup={modelNeedsSetup}
|
||||
isHero={isHero}
|
||||
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
type={showStopButton ? "button" : "submit"}
|
||||
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
|
||||
size="icon"
|
||||
disabled={showStopButton ? disabled : !canSend}
|
||||
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
|
||||
onClick={showStopButton ? handleStop : undefined}
|
||||
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
|
||||
aria-label={
|
||||
showStopButton
|
||||
? t("thread.composer.stop")
|
||||
: modelNeedsSetup
|
||||
? t("thread.composer.configureModel", { defaultValue: "Configure model" })
|
||||
: t("thread.composer.send")
|
||||
}
|
||||
onClick={showStopButton ? handleStop : modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||
className={cn(
|
||||
"rounded-full transition-transform",
|
||||
showStopButton
|
||||
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
|
||||
: isHero
|
||||
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
|
||||
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
|
||||
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground disabled:bg-foreground disabled:text-background"
|
||||
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground disabled:bg-foreground disabled:text-background",
|
||||
isHero ? "h-8 w-8" : "h-9 w-9",
|
||||
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
|
||||
(canSend || canOpenModelSettings || showStopButton) && "hover:scale-[1.03] active:scale-95",
|
||||
)}
|
||||
>
|
||||
{showStopButton ? (
|
||||
@@ -1766,44 +1788,59 @@ function ComposerModelBadge({
|
||||
label,
|
||||
provider,
|
||||
providerLabel,
|
||||
needsSetup,
|
||||
isHero,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
isHero: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const inferredProvider = provider || inferProviderFromModelName(label);
|
||||
const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(label);
|
||||
const brand = providerBrand(inferredProvider);
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const logoUrl = brand?.logoUrls[logoIndex];
|
||||
const showLogo = !!logoUrl;
|
||||
const title = providerLabel ? `${label} · ${providerLabel}` : label;
|
||||
const interactive = Boolean(onClick);
|
||||
const Container = interactive ? "button" : "span";
|
||||
|
||||
useEffect(() => setLogoIndex(0), [inferredProvider]);
|
||||
|
||||
return (
|
||||
<span
|
||||
<Container
|
||||
title={title}
|
||||
type={interactive ? "button" : undefined}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
|
||||
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
||||
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
|
||||
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
||||
isHero ? "h-8 max-w-[12.5rem] gap-1.5 px-2 text-[11.5px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
|
||||
data-testid={needsSetup ? "composer-model-setup-icon" : inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background",
|
||||
"grid shrink-0 place-items-center overflow-hidden",
|
||||
needsSetup
|
||||
? "text-amber-800 dark:text-amber-200"
|
||||
: "rounded-full border bg-background",
|
||||
isHero ? "h-[18px] w-[18px]" : "h-5 w-5",
|
||||
)}
|
||||
style={{
|
||||
borderColor: brand ? `${brand.color}28` : undefined,
|
||||
boxShadow: brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
|
||||
borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
|
||||
boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{showLogo ? (
|
||||
{needsSetup ? (
|
||||
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
|
||||
) : showLogo ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
@@ -1825,7 +1862,7 @@ function ComposerModelBadge({
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Menu, Moon, Sun } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -10,8 +11,11 @@ interface ThreadHeaderProps {
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
hideSidebarToggleForHostChrome?: boolean;
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
minimal?: boolean;
|
||||
promptNavigatorAction?: ReactNode;
|
||||
sessionInfoAction?: ReactNode;
|
||||
}
|
||||
|
||||
export function ThreadHeader({
|
||||
@@ -20,39 +24,22 @@ export function ThreadHeader({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
hideSidebarToggleForHostChrome = false,
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
minimal = false,
|
||||
promptNavigatorAction,
|
||||
sessionInfoAction,
|
||||
}: ThreadHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
if (minimal) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-11 items-center justify-between gap-3 px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("thread.header.toggleSidebar")}
|
||||
onClick={onToggleSidebar}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
||||
hideSidebarToggleForHostChrome && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<Menu className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{!hideThemeButton ? (
|
||||
<ThemeButton
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
label={t("thread.header.toggleTheme")}
|
||||
className="ml-auto"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex items-center justify-between gap-3 px-3 py-2",
|
||||
minimal && "h-11",
|
||||
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
|
||||
)}
|
||||
>
|
||||
<div className="relative flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -66,21 +53,28 @@ export function ThreadHeader({
|
||||
>
|
||||
<Menu className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
||||
</div>
|
||||
{!minimal ? (
|
||||
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!hideThemeButton ? (
|
||||
<ThemeButton
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
label={t("thread.header.toggleTheme")}
|
||||
className="ml-auto shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{sessionInfoAction}
|
||||
{promptNavigatorAction}
|
||||
{!hideThemeButton ? (
|
||||
<ThemeButton
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
label={t("thread.header.toggleTheme")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
|
||||
{!minimal ? (
|
||||
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ThreadMessagesProps {
|
||||
onLoadEarlier?: () => void;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
export type DisplayUnit = TurnUnit;
|
||||
@@ -33,8 +34,13 @@ export function isFinalAssistantSliceBeforeNextUser(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
return normalizeActivityTimeline(messages);
|
||||
export function buildDisplayUnits(
|
||||
messages: UIMessage[],
|
||||
isStreaming = false,
|
||||
): DisplayUnit[] {
|
||||
return normalizeActivityTimeline(messages, {
|
||||
preserveTrailingActivity: isStreaming,
|
||||
});
|
||||
}
|
||||
|
||||
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
@@ -61,9 +67,10 @@ export function ThreadMessages({
|
||||
onLoadEarlier,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onOpenFilePreview,
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
|
||||
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
||||
const liveActivityClusterIndices = useMemo(
|
||||
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
||||
@@ -117,6 +124,7 @@ export function ThreadMessages({
|
||||
turnLatencyMs={unit.turnLatencyMs}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : (
|
||||
<MessageBubble
|
||||
@@ -128,6 +136,7 @@ export function ThreadMessages({
|
||||
}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -14,9 +16,17 @@ import { PromptRail } from "@/components/thread/PromptRail";
|
||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
findPromptElement,
|
||||
jumpToPrompt,
|
||||
} from "@/components/thread/promptNavigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
export interface ThreadViewportHandle {
|
||||
jumpToUserPrompt: (promptId: string) => void;
|
||||
}
|
||||
|
||||
interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
isStreaming: boolean;
|
||||
@@ -27,6 +37,7 @@ interface ThreadViewportProps {
|
||||
showScrollToBottomButton?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
@@ -48,7 +59,7 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
|
||||
return messages.slice(start);
|
||||
}
|
||||
|
||||
export function ThreadViewport({
|
||||
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
|
||||
messages,
|
||||
isStreaming,
|
||||
composer,
|
||||
@@ -58,7 +69,8 @@ export function ThreadViewport({
|
||||
showScrollToBottomButton = true,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
}: ThreadViewportProps) {
|
||||
onOpenFilePreview,
|
||||
}, ref) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -66,6 +78,7 @@ export function ThreadViewport({
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||
const pendingConversationScrollRef = useRef(true);
|
||||
const pendingPromptJumpRef = useRef<string | null>(null);
|
||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
||||
const restoreScrollAfterPrependRef =
|
||||
useRef<{ height: number; top: number } | null>(null);
|
||||
@@ -139,6 +152,22 @@ export function ThreadViewport({
|
||||
);
|
||||
}, [messages.length]);
|
||||
|
||||
const jumpToUserPrompt = useCallback((promptId: string) => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl && findPromptElement(scrollEl, promptId)) {
|
||||
jumpToPrompt(scrollEl, promptId);
|
||||
return;
|
||||
}
|
||||
const index = messages.findIndex((message) => message.id === promptId);
|
||||
if (index < 0) return;
|
||||
pendingPromptJumpRef.current = promptId;
|
||||
userReadingHistoryRef.current = true;
|
||||
setAtBottom(false);
|
||||
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
|
||||
}, [messages]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ jumpToUserPrompt }), [jumpToUserPrompt]);
|
||||
|
||||
const measureComposerDock = useCallback(() => {
|
||||
const el = composerDockRef.current;
|
||||
if (!el) return;
|
||||
@@ -180,6 +209,15 @@ export function ThreadViewport({
|
||||
el.scrollTop = pending.top + delta;
|
||||
}, [visibleMessages.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const promptId = pendingPromptJumpRef.current;
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!promptId || !scrollEl || !findPromptElement(scrollEl, promptId)) return;
|
||||
pendingPromptJumpRef.current = null;
|
||||
const frame = window.requestAnimationFrame(() => jumpToPrompt(scrollEl, promptId));
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [visibleMessages.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pendingConversationScrollRef.current) return;
|
||||
if (!conversationKey) {
|
||||
@@ -256,6 +294,7 @@ export function ThreadViewport({
|
||||
onLoadEarlier={loadEarlierMessages}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,22 +338,25 @@ export function ThreadViewport({
|
||||
) : null}
|
||||
|
||||
{showScrollToBottomButton && !atBottom && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => scrollToBottom(true, 1, { force: true })}
|
||||
className={cn(
|
||||
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
|
||||
"absolute left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
|
||||
"bg-background/90 backdrop-blur",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
)}
|
||||
<div
|
||||
className="absolute left-1/2 z-20 -translate-x-1/2"
|
||||
style={{ bottom: scrollButtonBottom }}
|
||||
aria-label={t("thread.scrollToBottom")}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => scrollToBottom(true, 1, { force: true })}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full shadow-md",
|
||||
"bg-background/90 backdrop-blur",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
)}
|
||||
aria-label={t("thread.scrollToBottom")}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ export function WorkspaceProjectPicker({
|
||||
|
||||
if (nativeProjectPicker) {
|
||||
return (
|
||||
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
|
||||
<div className="flex items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || pickingFolder}
|
||||
@@ -133,7 +133,7 @@ export function WorkspaceProjectPicker({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
|
||||
<div className="flex items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -22,18 +22,34 @@ export interface FileEditSummary {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
|
||||
export function FileEditGroup({
|
||||
edits,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edits: FileEditSummary[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
if (edits.length === 0) return null;
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{edits.map((edit) => (
|
||||
<FileEditRow key={edit.key} edit={edit} />
|
||||
<FileEditRow
|
||||
key={edit.key}
|
||||
edit={edit}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function FileEditRow({ edit }: { edit: FileEditSummary }) {
|
||||
function FileEditRow({
|
||||
edit,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edit: FileEditSummary;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const editing = edit.status === "editing";
|
||||
const failed = edit.status === "error";
|
||||
@@ -76,6 +92,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
|
||||
<FileReferenceChip
|
||||
path={edit.path}
|
||||
tooltipPath={edit.absolute_path}
|
||||
previewPath={edit.absolute_path || edit.path}
|
||||
onOpen={onOpenFilePreview}
|
||||
display="path"
|
||||
active={editing}
|
||||
className="min-w-0"
|
||||
|
||||
@@ -10,9 +10,11 @@ import { ActivityStep } from "./ActivityStep";
|
||||
export function ReasoningRow({
|
||||
text,
|
||||
streaming,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
@@ -30,6 +32,7 @@ export function ReasoningRow({
|
||||
{text.trim() ? (
|
||||
<MarkdownText
|
||||
streaming={streaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
className={cn(
|
||||
"min-w-0 text-[12.5px] italic text-muted-foreground/78",
|
||||
"prose-p:my-1 prose-li:my-0.5",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
export interface PromptAnchor {
|
||||
id: string;
|
||||
label: string;
|
||||
preview: string;
|
||||
createdAt: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
|
||||
let index = 0;
|
||||
return messages.flatMap((message) => {
|
||||
if (message.role !== "user") return [];
|
||||
const anchor: PromptAnchor = {
|
||||
id: message.id,
|
||||
label: promptLabel(message.content, index),
|
||||
preview: promptPreview(message.content, index),
|
||||
createdAt: message.createdAt,
|
||||
index,
|
||||
};
|
||||
index += 1;
|
||||
return [anchor];
|
||||
});
|
||||
}
|
||||
|
||||
export function promptLabel(content: string, index: number): string {
|
||||
const text = content.replace(/\s+/g, " ").trim();
|
||||
if (!text) return `Prompt ${index + 1}`;
|
||||
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
|
||||
}
|
||||
|
||||
export function promptPreview(content: string, index: number): string {
|
||||
const text = content.replace(/\n{3,}/g, "\n\n").trim();
|
||||
if (!text) return `Prompt ${index + 1}`;
|
||||
return text.length > 320 ? `${text.slice(0, 317)}...` : text;
|
||||
}
|
||||
|
||||
export function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
|
||||
if (!scrollEl || !promptId) return;
|
||||
const target = findPromptElement(scrollEl, promptId);
|
||||
if (!target) return;
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, promptTop(scrollEl, target) - 16),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
export function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
|
||||
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
|
||||
return Array.from(candidates).find(
|
||||
(candidate) => candidate.dataset.userPromptId === promptId,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
|
||||
if (hasLayoutRect) {
|
||||
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
|
||||
}
|
||||
return target.offsetTop;
|
||||
}
|
||||
@@ -100,4 +100,16 @@ const SheetTitle = React.forwardRef<
|
||||
));
|
||||
SheetTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
export { Sheet, SheetContent, SheetTitle };
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export { Sheet, SheetContent, SheetDescription, SheetTitle };
|
||||
|
||||
Reference in New Issue
Block a user