fix: mobile chat in new layout
This commit is contained in:
@@ -65,15 +65,22 @@ import { useI18n } from "@/i18n";
|
||||
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
|
||||
import type { PluginManifest } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
||||
|
||||
function RootRedirect() {
|
||||
return <Navigate to="/sessions" replace />;
|
||||
}
|
||||
|
||||
/** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */
|
||||
const BUILTIN_ROUTES: Record<string, ComponentType> = {
|
||||
const CHAT_NAV_ITEM: NavItem = {
|
||||
path: "/chat",
|
||||
labelKey: "chat",
|
||||
label: "Chat",
|
||||
icon: Terminal,
|
||||
};
|
||||
|
||||
/** Built-in routes except /chat (only with `hermes dashboard --tui`). */
|
||||
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
||||
"/": RootRedirect,
|
||||
"/chat": ChatPage,
|
||||
"/sessions": SessionsPage,
|
||||
"/analytics": AnalyticsPage,
|
||||
"/logs": LogsPage,
|
||||
@@ -84,8 +91,7 @@ const BUILTIN_ROUTES: Record<string, ComponentType> = {
|
||||
"/docs": DocsPage,
|
||||
};
|
||||
|
||||
const BUILTIN_NAV: NavItem[] = [
|
||||
{ path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal },
|
||||
const BUILTIN_NAV_REST: NavItem[] = [
|
||||
{
|
||||
path: "/sessions",
|
||||
labelKey: "sessions",
|
||||
@@ -170,7 +176,10 @@ function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildRoutes(manifests: PluginManifest[]): Array<{
|
||||
function buildRoutes(
|
||||
builtinRoutes: Record<string, ComponentType>,
|
||||
manifests: PluginManifest[],
|
||||
): Array<{
|
||||
key: string;
|
||||
path: string;
|
||||
element: ReactNode;
|
||||
@@ -192,7 +201,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{
|
||||
element: ReactNode;
|
||||
}> = [];
|
||||
|
||||
for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) {
|
||||
for (const [path, Component] of Object.entries(builtinRoutes)) {
|
||||
const om = byOverride.get(path);
|
||||
if (om) {
|
||||
routes.push({
|
||||
@@ -207,7 +216,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{
|
||||
|
||||
for (const m of addons) {
|
||||
if (m.tab.hidden) continue;
|
||||
if (BUILTIN_ROUTES[m.tab.path]) continue;
|
||||
if (builtinRoutes[m.tab.path]) continue;
|
||||
routes.push({
|
||||
key: `plugin:${m.name}`,
|
||||
path: m.tab.path,
|
||||
@@ -217,7 +226,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{
|
||||
|
||||
for (const m of manifests) {
|
||||
if (!m.tab.hidden) continue;
|
||||
if (BUILTIN_ROUTES[m.tab.path] || m.tab.override) continue;
|
||||
if (builtinRoutes[m.tab.path] || m.tab.override) continue;
|
||||
routes.push({
|
||||
key: `plugin:hidden:${m.name}`,
|
||||
path: m.tab.path,
|
||||
@@ -236,12 +245,32 @@ export default function App() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
|
||||
const normalizedPath = pathname.replace(/\/$/, "") || "/";
|
||||
const isChatRoute = normalizedPath === "/chat";
|
||||
const embeddedChat = isDashboardEmbeddedChatEnabled();
|
||||
|
||||
const builtinRoutes = useMemo(
|
||||
() => ({
|
||||
...BUILTIN_ROUTES_CORE,
|
||||
...(embeddedChat ? { "/chat": ChatPage } : {}),
|
||||
}),
|
||||
[embeddedChat],
|
||||
);
|
||||
|
||||
const builtinNav = useMemo(
|
||||
() =>
|
||||
embeddedChat ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] : BUILTIN_NAV_REST,
|
||||
[embeddedChat],
|
||||
);
|
||||
|
||||
const navItems = useMemo(
|
||||
() => buildNavItems(BUILTIN_NAV, manifests),
|
||||
[manifests],
|
||||
() => buildNavItems(builtinNav, manifests),
|
||||
[builtinNav, manifests],
|
||||
);
|
||||
const routes = useMemo(
|
||||
() => buildRoutes(builtinRoutes, manifests),
|
||||
[builtinRoutes, manifests],
|
||||
);
|
||||
const routes = useMemo(() => buildRoutes(manifests), [manifests]);
|
||||
const pluginTabMeta = useMemo(
|
||||
() =>
|
||||
manifests
|
||||
@@ -468,8 +497,9 @@ export default function App() {
|
||||
className={cn(
|
||||
"relative z-2 flex min-w-0 min-h-0 flex-1 flex-col",
|
||||
"px-3 sm:px-6",
|
||||
"pt-2 sm:pt-4 lg:pt-6",
|
||||
"pb-4 sm:pb-8",
|
||||
isChatRoute
|
||||
? "pb-3 pt-1 sm:pb-4 sm:pt-2 lg:pt-4"
|
||||
: "pt-2 sm:pt-4 lg:pt-6 pb-4 sm:pb-8",
|
||||
isDocsRoute && "min-h-0 flex-1",
|
||||
)}
|
||||
>
|
||||
@@ -477,7 +507,7 @@ export default function App() {
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-w-0",
|
||||
isDocsRoute && "min-h-0 flex flex-1 flex-col",
|
||||
(isDocsRoute || isChatRoute) && "min-h-0 flex flex-1 flex-col",
|
||||
)}
|
||||
>
|
||||
<Routes>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ModelPickerDialog } from "@/components/ModelPickerDialog";
|
||||
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
|
||||
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -66,9 +67,10 @@ const STATE_TONE: Record<ConnectionState, string> = {
|
||||
|
||||
interface ChatSidebarProps {
|
||||
channel: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatSidebar({ channel }: ChatSidebarProps) {
|
||||
export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
||||
// `version` bumps on reconnect; gw is derived so we never call setState
|
||||
// for it inside an effect (React 19's set-state-in-effect rule). The
|
||||
// counter is the dependency on purpose — it's not read in the memo body,
|
||||
@@ -284,7 +286,12 @@ export function ChatSidebar({ channel }: ChatSidebarProps) {
|
||||
const banner = error ?? info.credential_warning ?? null;
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-80 shrink-0 flex-col gap-3 normal-case">
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 normal-case lg:w-80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Card className="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
|
||||
@@ -34,6 +34,8 @@ export function PageHeaderProvider({
|
||||
);
|
||||
const displayTitle = titleOverride ?? defaultTitle;
|
||||
|
||||
const isChatRoute = pathname === "/chat" || pathname === "/chat/";
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
setAfterTitle,
|
||||
@@ -59,8 +61,10 @@ export function PageHeaderProvider({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full min-w-0 flex-1 flex-col justify-center gap-2",
|
||||
"px-3 py-2 sm:flex-row sm:items-center sm:gap-3 sm:px-6 sm:py-0",
|
||||
"flex h-full w-full min-w-0 flex-1 gap-2 px-3 py-2 sm:gap-3 sm:px-6 sm:py-0",
|
||||
isChatRoute
|
||||
? "flex-row items-center"
|
||||
: "flex-col justify-center sm:flex-row sm:items-center",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
||||
@@ -74,7 +78,12 @@ export function PageHeaderProvider({
|
||||
</div>
|
||||
|
||||
{end ? (
|
||||
<div className="flex w-full min-w-0 justify-end sm:max-w-md sm:flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 justify-end sm:max-w-md sm:flex-1",
|
||||
isChatRoute ? "w-auto shrink-0" : "w-full",
|
||||
)}
|
||||
>
|
||||
{end}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -84,7 +93,9 @@ export function PageHeaderProvider({
|
||||
<main
|
||||
className={cn(
|
||||
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
|
||||
"overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
|
||||
isChatRoute
|
||||
? "overflow-hidden"
|
||||
: "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -53,6 +53,7 @@ export const en: Translations = {
|
||||
brand: "Hermes Agent",
|
||||
brandShort: "HA",
|
||||
closeNavigation: "Close navigation",
|
||||
closeModelTools: "Close model and tools",
|
||||
footer: {
|
||||
org: "Nous Research",
|
||||
},
|
||||
@@ -76,6 +77,8 @@ export const en: Translations = {
|
||||
sessions: "Sessions",
|
||||
skills: "Skills",
|
||||
},
|
||||
modelToolsSheetSubtitle: "& tools",
|
||||
modelToolsSheetTitle: "Model",
|
||||
navigation: "Navigation",
|
||||
openDocumentation: "Open documentation in a new tab",
|
||||
openNavigation: "Open navigation",
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface Translations {
|
||||
brand: string;
|
||||
brandShort: string;
|
||||
closeNavigation: string;
|
||||
closeModelTools: string;
|
||||
footer: {
|
||||
org: string;
|
||||
};
|
||||
@@ -76,6 +77,8 @@ export interface Translations {
|
||||
sessions: string;
|
||||
skills: string;
|
||||
};
|
||||
modelToolsSheetSubtitle: string;
|
||||
modelToolsSheetTitle: string;
|
||||
navigation: string;
|
||||
openDocumentation: string;
|
||||
openNavigation: string;
|
||||
|
||||
@@ -52,6 +52,7 @@ export const zh: Translations = {
|
||||
brand: "Hermes Agent",
|
||||
brandShort: "HA",
|
||||
closeNavigation: "关闭导航",
|
||||
closeModelTools: "关闭模型与工具",
|
||||
footer: {
|
||||
org: "Nous Research",
|
||||
},
|
||||
@@ -75,6 +76,8 @@ export const zh: Translations = {
|
||||
sessions: "会话",
|
||||
skills: "技能",
|
||||
},
|
||||
modelToolsSheetSubtitle: "与工具",
|
||||
modelToolsSheetTitle: "模型",
|
||||
navigation: "导航",
|
||||
openDocumentation: "在新标签页中打开文档",
|
||||
openNavigation: "打开导航",
|
||||
|
||||
15
web/src/lib/dashboard-flags.ts
Normal file
15
web/src/lib/dashboard-flags.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Set true by the server only for `hermes dashboard --tui` (or HERMES_DASHBOARD_TUI=1). */
|
||||
__HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
|
||||
/** @deprecated Older injected name; treated as on when true. */
|
||||
__HERMES_DASHBOARD_TUI__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
/** True only when the dashboard was started with embedded TUI Chat (`hermes dashboard --tui`). */
|
||||
export function isDashboardEmbeddedChatEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
if (window.__HERMES_DASHBOARD_EMBEDDED_CHAT__ === true) return true;
|
||||
return window.__HERMES_DASHBOARD_TUI__ === true;
|
||||
}
|
||||
@@ -22,11 +22,16 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Copy } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Typography } from "@nous-research/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy, PanelRight, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { ChatSidebar } from "@/components/ChatSidebar";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
function buildWsUrl(
|
||||
token: string,
|
||||
@@ -62,6 +67,39 @@ const TERMINAL_THEME = {
|
||||
selectionBackground: "#f0e6d244",
|
||||
};
|
||||
|
||||
/**
|
||||
* CSS width for xterm font tiers.
|
||||
*
|
||||
* Prefer the terminal host's `clientWidth` — Chrome DevTools device mode often
|
||||
* keeps `window.innerWidth` at the full desktop value while the *drawn* layout
|
||||
* is phone-sized, which made us pick desktop font sizes (~14px) and look huge.
|
||||
*/
|
||||
function terminalTierWidthPx(host: HTMLElement | null): number {
|
||||
if (typeof window === "undefined") return 1280;
|
||||
const fromHost = host?.clientWidth ?? 0;
|
||||
if (fromHost > 2) return Math.round(fromHost);
|
||||
const doc = document.documentElement?.clientWidth ?? 0;
|
||||
const vv = window.visualViewport;
|
||||
const inner = window.innerWidth;
|
||||
const vvw = vv?.width ?? inner;
|
||||
const layout = Math.min(inner, vvw, doc > 0 ? doc : inner);
|
||||
return Math.max(1, Math.round(layout));
|
||||
}
|
||||
|
||||
function terminalFontSizeForWidth(layoutWidthPx: number): number {
|
||||
if (layoutWidthPx < 300) return 7;
|
||||
if (layoutWidthPx < 360) return 8;
|
||||
if (layoutWidthPx < 420) return 9;
|
||||
if (layoutWidthPx < 520) return 10;
|
||||
if (layoutWidthPx < 720) return 11;
|
||||
if (layoutWidthPx < 1024) return 12;
|
||||
return 14;
|
||||
}
|
||||
|
||||
function terminalLineHeightForWidth(layoutWidthPx: number): number {
|
||||
return layoutWidthPx < 1024 ? 1.02 : 1.15;
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
@@ -77,10 +115,83 @@ export default function ChatPage() {
|
||||
);
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
|
||||
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [mobilePanelOpen, setMobilePanelOpen] = useState(false);
|
||||
const { setEnd } = usePageHeader();
|
||||
const { t } = useI18n();
|
||||
const closeMobilePanel = useCallback(() => setMobilePanelOpen(false), []);
|
||||
const modelToolsLabel = useMemo(
|
||||
() => `${t.app.modelToolsSheetTitle} ${t.app.modelToolsSheetSubtitle}`,
|
||||
[t.app.modelToolsSheetSubtitle, t.app.modelToolsSheetTitle],
|
||||
);
|
||||
const [portalRoot] = useState<HTMLElement | null>(() =>
|
||||
typeof document !== "undefined" ? document.body : null,
|
||||
);
|
||||
const [narrow, setNarrow] = useState(() =>
|
||||
typeof window !== "undefined"
|
||||
? window.matchMedia("(max-width: 1023px)").matches
|
||||
: false,
|
||||
);
|
||||
|
||||
const resumeRef = useRef<string | null>(searchParams.get("resume"));
|
||||
const channel = useMemo(() => generateChannelId(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(max-width: 1023px)");
|
||||
const sync = () => setNarrow(mql.matches);
|
||||
sync();
|
||||
mql.addEventListener("change", sync);
|
||||
return () => mql.removeEventListener("change", sync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobilePanelOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeMobilePanel();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [mobilePanelOpen, closeMobilePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(min-width: 1024px)");
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
if (e.matches) setMobilePanelOpen(false);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!narrow) {
|
||||
setEnd(null);
|
||||
return;
|
||||
}
|
||||
setEnd(
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobilePanelOpen(true)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded border border-current/20",
|
||||
"px-2 py-1 text-[0.65rem] font-medium tracking-wide normal-case",
|
||||
"text-midground/80 hover:text-midground hover:bg-midground/5",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
"shrink-0 cursor-pointer",
|
||||
)}
|
||||
aria-expanded={mobilePanelOpen}
|
||||
aria-controls="chat-side-panel"
|
||||
>
|
||||
<PanelRight className="h-3 w-3 shrink-0" />
|
||||
{modelToolsLabel}
|
||||
</button>,
|
||||
);
|
||||
return () => setEnd(null);
|
||||
}, [narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
|
||||
|
||||
const handleCopyLast = () => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
@@ -110,13 +221,17 @@ export default function ChatPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const tierW0 = terminalTierWidthPx(host);
|
||||
const term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
cursorBlink: true,
|
||||
fontFamily:
|
||||
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.2,
|
||||
fontSize: terminalFontSizeForWidth(tierW0),
|
||||
lineHeight: terminalLineHeightForWidth(tierW0),
|
||||
letterSpacing: 0,
|
||||
fontWeight: "400",
|
||||
fontWeightBold: "700",
|
||||
macOptionIsMeta: true,
|
||||
scrollback: 0,
|
||||
theme: TERMINAL_THEME,
|
||||
@@ -212,19 +327,23 @@ export default function ChatPage() {
|
||||
|
||||
term.open(host);
|
||||
|
||||
// WebGL renderer: rasterizes glyphs to a GPU texture atlas, paints
|
||||
// each cell at an integer-pixel position. Box-drawing glyphs connect
|
||||
// cleanly between rows (no DOM baseline / line-height math). Falls
|
||||
// back to the default DOM renderer if WebGL is unavailable.
|
||||
try {
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => webgl.dispose());
|
||||
term.loadAddon(webgl);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[hermes-chat] WebGL renderer unavailable; falling back to default",
|
||||
err,
|
||||
);
|
||||
// WebGL draws from a texture atlas sized with device pixels. On phones and
|
||||
// in DevTools device mode that often produces *visually* much larger cells
|
||||
// than `fontSize` suggests — users see "huge" text even at 7–9px settings.
|
||||
// The canvas/DOM renderer tracks `fontSize` faithfully; use it for narrow
|
||||
// hosts. Wide layouts still get WebGL for crisp box-drawing.
|
||||
const useWebgl = terminalTierWidthPx(host) >= 768;
|
||||
if (useWebgl) {
|
||||
try {
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => webgl.dispose());
|
||||
term.loadAddon(webgl);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[hermes-chat] WebGL renderer unavailable; falling back to default",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial fit + resize observer. fit.fit() reads the container's
|
||||
@@ -244,22 +363,65 @@ export default function ChatPage() {
|
||||
// frames. rAF→rAF guarantees one layout commit between the two
|
||||
// callbacks, giving CSS transitions and font metrics time to finalize
|
||||
// before we take the authoritative measurement.
|
||||
let rafId = 0;
|
||||
const scheduleFit = () => {
|
||||
if (rafId) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = 0;
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
// Element was removed mid-resize; cleanup will handle it.
|
||||
}
|
||||
let hostSyncRaf = 0;
|
||||
const scheduleHostSync = () => {
|
||||
if (hostSyncRaf) return;
|
||||
hostSyncRaf = requestAnimationFrame(() => {
|
||||
hostSyncRaf = 0;
|
||||
syncTerminalMetrics();
|
||||
});
|
||||
};
|
||||
fit.fit();
|
||||
const ro = new ResizeObserver(scheduleFit);
|
||||
|
||||
let metricsDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
const syncTerminalMetrics = () => {
|
||||
const w = terminalTierWidthPx(host);
|
||||
const nextSize = terminalFontSizeForWidth(w);
|
||||
const nextLh = terminalLineHeightForWidth(w);
|
||||
const fontChanged =
|
||||
term.options.fontSize !== nextSize ||
|
||||
term.options.lineHeight !== nextLh;
|
||||
if (fontChanged) {
|
||||
term.options.fontSize = nextSize;
|
||||
term.options.lineHeight = nextLh;
|
||||
}
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (fontChanged && term.rows > 0) {
|
||||
try {
|
||||
term.refresh(0, term.rows - 1);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (
|
||||
fontChanged &&
|
||||
wsRef.current &&
|
||||
wsRef.current.readyState === WebSocket.OPEN
|
||||
) {
|
||||
wsRef.current.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSyncTerminalMetrics = () => {
|
||||
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||
metricsDebounce = setTimeout(() => {
|
||||
metricsDebounce = null;
|
||||
syncTerminalMetrics();
|
||||
}, 60);
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(() => scheduleHostSync());
|
||||
ro.observe(host);
|
||||
|
||||
window.addEventListener("resize", scheduleSyncTerminalMetrics);
|
||||
window.visualViewport?.addEventListener("resize", scheduleSyncTerminalMetrics);
|
||||
window.visualViewport?.addEventListener("scroll", scheduleSyncTerminalMetrics);
|
||||
scheduleHostSync();
|
||||
requestAnimationFrame(() => scheduleHostSync());
|
||||
|
||||
// Double-rAF authoritative fit. On the second frame the layout has
|
||||
// committed at least once since mount; fit.fit() then reads the
|
||||
// stable container size. We always send a RESIZE escape afterwards
|
||||
@@ -272,15 +434,7 @@ export default function ChatPage() {
|
||||
settleRaf1 = 0;
|
||||
settleRaf2 = requestAnimationFrame(() => {
|
||||
settleRaf2 = 0;
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const sock = wsRef.current;
|
||||
if (sock && sock.readyState === WebSocket.OPEN) {
|
||||
sock.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
||||
}
|
||||
syncTerminalMetrics();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -387,8 +541,18 @@ export default function ChatPage() {
|
||||
return () => {
|
||||
onDataDisposable.dispose();
|
||||
onResizeDisposable.dispose();
|
||||
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||
window.removeEventListener("resize", scheduleSyncTerminalMetrics);
|
||||
window.visualViewport?.removeEventListener(
|
||||
"resize",
|
||||
scheduleSyncTerminalMetrics,
|
||||
);
|
||||
window.visualViewport?.removeEventListener(
|
||||
"scroll",
|
||||
scheduleSyncTerminalMetrics,
|
||||
);
|
||||
ro.disconnect();
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf);
|
||||
if (settleRaf1) cancelAnimationFrame(settleRaf1);
|
||||
if (settleRaf2) cancelAnimationFrame(settleRaf2);
|
||||
ws.close();
|
||||
@@ -416,52 +580,149 @@ export default function ChatPage() {
|
||||
//
|
||||
// `normal-case` opts out of the dashboard's global `uppercase` rule on
|
||||
// the root `<div>` in App.tsx — terminal output must preserve case.
|
||||
//
|
||||
// Mobile model/tools sheet is portaled to `document.body` so it stacks
|
||||
// above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main
|
||||
// dashboard column uses `relative z-2`, which traps `position:fixed`
|
||||
// descendants below those layers (see Toast.tsx).
|
||||
const mobileModelToolsPortal =
|
||||
narrow &&
|
||||
portalRoot &&
|
||||
createPortal(
|
||||
<>
|
||||
{mobilePanelOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t.app.closeModelTools}
|
||||
onClick={closeMobilePanel}
|
||||
className={cn(
|
||||
"fixed inset-0 z-[55]",
|
||||
"bg-black/60 backdrop-blur-sm cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
id="chat-side-panel"
|
||||
role="complementary"
|
||||
aria-label={modelToolsLabel}
|
||||
className={cn(
|
||||
"font-mondwest fixed top-0 right-0 z-[60] flex h-dvh max-h-dvh w-64 min-w-0 flex-col antialiased",
|
||||
"border-l border-current/20 text-midground",
|
||||
"bg-background-base/95 backdrop-blur-sm",
|
||||
"transition-transform duration-200 ease-out",
|
||||
"[background:var(--component-sidebar-background)]",
|
||||
"[clip-path:var(--component-sidebar-clip-path)]",
|
||||
"[border-image:var(--component-sidebar-border-image)]",
|
||||
mobilePanelOpen
|
||||
? "translate-x-0"
|
||||
: "pointer-events-none translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 shrink-0 items-center justify-between gap-2 border-b border-current/20 px-5",
|
||||
)}
|
||||
>
|
||||
<Typography
|
||||
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.modelToolsSheetTitle}
|
||||
<br />
|
||||
{t.app.modelToolsSheetSubtitle}
|
||||
</Typography>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeMobilePanel}
|
||||
aria-label={t.app.closeModelTools}
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center",
|
||||
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
|
||||
"border-t border-current/10",
|
||||
)}
|
||||
>
|
||||
<ChatSidebar channel={channel} />
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
portalRoot,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-10rem)] flex-col gap-2 normal-case">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 normal-case">
|
||||
{mobileModelToolsPortal}
|
||||
|
||||
{banner && (
|
||||
<div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide">
|
||||
{banner}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 gap-3">
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 lg:flex-row lg:gap-3">
|
||||
<div
|
||||
className="relative min-w-0 flex-1 overflow-hidden rounded-lg"
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg",
|
||||
"p-2 sm:p-3",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: TERMINAL_THEME.background,
|
||||
padding: "12px",
|
||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
||||
}}
|
||||
>
|
||||
<div ref={hostRef} className="h-full w-full" />
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLast}
|
||||
title="Copy last assistant response as raw markdown"
|
||||
aria-label="Copy last assistant response"
|
||||
className={[
|
||||
"absolute bottom-4 right-4 z-10",
|
||||
"flex items-center gap-1.5",
|
||||
className={cn(
|
||||
"absolute z-10 flex items-center gap-1.5",
|
||||
"rounded border border-current/30",
|
||||
"bg-black/20 backdrop-blur-sm",
|
||||
"px-2.5 py-1.5 text-xs",
|
||||
"opacity-60 hover:opacity-100 hover:border-current/60",
|
||||
"transition-opacity duration-150",
|
||||
"focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current",
|
||||
"cursor-pointer",
|
||||
].join(" ")}
|
||||
"bottom-2 right-2 px-2 py-1 text-[0.65rem] sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5 sm:text-xs",
|
||||
"lg:bottom-4 lg:right-4",
|
||||
)}
|
||||
style={{ color: TERMINAL_THEME.foreground }}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
<span className="tracking-wide">
|
||||
<Copy className="h-3 w-3 shrink-0" />
|
||||
<span className="hidden min-[400px]:inline tracking-wide">
|
||||
{copyState === "copied" ? "copied" : "copy last response"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="hidden min-h-0 lg:block">
|
||||
<ChatSidebar channel={channel} />
|
||||
</div>
|
||||
{!narrow && (
|
||||
<div
|
||||
id="chat-side-panel"
|
||||
role="complementary"
|
||||
aria-label={modelToolsLabel}
|
||||
className="flex min-h-0 shrink-0 flex-col lg:h-full lg:w-80"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<ChatSidebar channel={channel} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function DocsPage() {
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 w-full min-w-0 flex-1 flex-col",
|
||||
"-mx-3 sm:-mx-6",
|
||||
"pt-1 sm:pt-2",
|
||||
)}
|
||||
>
|
||||
<iframe
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
||||
|
||||
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
|
||||
{
|
||||
@@ -258,6 +259,7 @@ function SessionRow({
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onDelete,
|
||||
resumeInChatEnabled,
|
||||
}: {
|
||||
session: SessionInfo;
|
||||
snippet?: string;
|
||||
@@ -265,6 +267,7 @@ function SessionRow({
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
resumeInChatEnabled: boolean;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -350,19 +353,21 @@ function SessionRow({
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{session.source ?? "local"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-success"
|
||||
aria-label={t.sessions.resumeInChat}
|
||||
title={t.sessions.resumeInChat}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
||||
}}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{resumeInChatEnabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-success"
|
||||
aria-label={t.sessions.resumeInChat}
|
||||
title={t.sessions.resumeInChat}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
||||
}}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -422,6 +427,7 @@ export default function SessionsPage() {
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
const { activeAction, actionStatus, dismissLog } = useSystemActions();
|
||||
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (loading) {
|
||||
@@ -786,6 +792,7 @@ export default function SessionsPage() {
|
||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||
}
|
||||
onDelete={() => sessionDelete.requestDelete(s.id)}
|
||||
resumeInChatEnabled={resumeInChatEnabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user