diff --git a/web/package-lock.json b/web/package-lock.json index c522d8ba0..d1a7a8d03 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,11 @@ "@observablehq/plot": "^0.6.17", "@react-three/fiber": "^9.6.0", "@tailwindcss/vite": "^4.2.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "gsap": "^3.15.0", @@ -39,6 +44,50 @@ "vite": "^7.3.1" } }, + "../../../../../wterm/packages/@wterm/core": { + "version": "0.1.9", + "extraneous": true, + "license": "Apache-2.0", + "devDependencies": { + "@internal/ts": "workspace:*", + "typescript": "^6.0.2" + } + }, + "../../../../../wterm/packages/@wterm/dom": { + "version": "0.1.9", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "@wterm/core": "workspace:*" + }, + "devDependencies": { + "@internal/ts": "workspace:*", + "jsdom": "^29.0.2", + "typescript": "^6.0.2" + } + }, + "../../../../../wterm/packages/@wterm/react": { + "version": "0.1.9", + "extraneous": true, + "license": "Apache-2.0", + "devDependencies": { + "@internal/ts": "workspace:*", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@wterm/dom": "workspace:*", + "jsdom": "^29.0.2", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^6.0.2" + }, + "peerDependencies": { + "@wterm/dom": "workspace:*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -2861,6 +2910,39 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-unicode11": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", + "integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", diff --git a/web/package.json b/web/package.json index 8882c5c1c..917a53a51 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,11 @@ "@observablehq/plot": "^0.6.17", "@react-three/fiber": "^9.6.0", "@tailwindcss/vite": "^4.2.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "gsap": "^3.15.0", diff --git a/web/public/fonts-terminal/JetBrainsMono-Bold.woff2 b/web/public/fonts-terminal/JetBrainsMono-Bold.woff2 new file mode 100644 index 000000000..81c5a219d Binary files /dev/null and b/web/public/fonts-terminal/JetBrainsMono-Bold.woff2 differ diff --git a/web/public/fonts-terminal/JetBrainsMono-Italic.woff2 b/web/public/fonts-terminal/JetBrainsMono-Italic.woff2 new file mode 100644 index 000000000..4103d3910 Binary files /dev/null and b/web/public/fonts-terminal/JetBrainsMono-Italic.woff2 differ diff --git a/web/public/fonts-terminal/JetBrainsMono-Regular.woff2 b/web/public/fonts-terminal/JetBrainsMono-Regular.woff2 new file mode 100644 index 000000000..66c54672c Binary files /dev/null and b/web/public/fonts-terminal/JetBrainsMono-Regular.woff2 differ diff --git a/web/src/App.tsx b/web/src/App.tsx index c2dc409b3..d6773a35c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import LogsPage from "@/pages/LogsPage"; import AnalyticsPage from "@/pages/AnalyticsPage"; import CronPage from "@/pages/CronPage"; import SkillsPage from "@/pages/SkillsPage"; +import ChatPage from "@/pages/ChatPage"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ThemeSwitcher } from "@/components/ThemeSwitcher"; import { useI18n } from "@/i18n"; @@ -41,6 +42,7 @@ import type { RegisteredPlugin } from "@/plugins"; const BUILTIN_NAV: NavItem[] = [ { path: "/", labelKey: "status", label: "Status", icon: Activity }, + { path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal }, { path: "/sessions", labelKey: "sessions", @@ -230,6 +232,7 @@ export default function App() {
} /> + } /> } /> } /> } /> diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 8941fcda4..5b43354ec 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -54,6 +54,7 @@ export const en: Translations = { }, nav: { status: "Status", + chat: "Chat", sessions: "Sessions", analytics: "Analytics", logs: "Logs", @@ -97,6 +98,7 @@ export const en: Translations = { noMessages: "No messages", untitledSession: "Untitled session", deleteSession: "Delete session", + resumeInChat: "Resume in Chat", previousPage: "Previous page", nextPage: "Next page", roles: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 5ae559c9c..2a6414934 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -56,6 +56,7 @@ export interface Translations { }; nav: { status: string; + chat: string; sessions: string; analytics: string; logs: string; @@ -101,6 +102,7 @@ export interface Translations { noMessages: string; untitledSession: string; deleteSession: string; + resumeInChat: string; previousPage: string; nextPage: string; roles: { diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 34941f616..e29e45d56 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -54,6 +54,7 @@ export const zh: Translations = { }, nav: { status: "状态", + chat: "对话", sessions: "会话", analytics: "分析", logs: "日志", @@ -97,6 +98,7 @@ export const zh: Translations = { noMessages: "暂无消息", untitledSession: "无标题会话", deleteSession: "删除会话", + resumeInChat: "在对话中继续", previousPage: "上一页", nextPage: "下一页", roles: { diff --git a/web/src/index.css b/web/src/index.css index b602361e2..634ac2870 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -5,6 +5,36 @@ Tailwind's JIT purge. */ @source '../node_modules/@nous-research/ui/dist'; +/* ------------------------------------------------------------------ */ +/* JetBrains Mono — bundled for the embedded TUI (/chat tab). */ +/* Gives the terminal a proper monospace font even on systems where */ +/* the user doesn't have one installed locally; xterm.js picks it up */ +/* via ChatPage's `fontFamily` option. */ +/* Apache-2.0. */ +/* ------------------------------------------------------------------ */ + +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts-terminal/JetBrainsMono-Regular.woff2') format('woff2'); +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts-terminal/JetBrainsMono-Bold.woff2') format('woff2'); +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('/fonts-terminal/JetBrainsMono-Italic.woff2') format('woff2'); +} + /* ------------------------------------------------------------------ */ /* Hermes Agent — Nous DS with the LENS_0 (Hermes teal) lens applied */ /* statically. Mirrors nousnet-web/(hermes-agent)/layout.tsx so the */ diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx new file mode 100644 index 000000000..067edc84f --- /dev/null +++ b/web/src/pages/ChatPage.tsx @@ -0,0 +1,444 @@ +/** + * ChatPage — embeds `hermes --tui` inside the dashboard. + * + *
(dashboard chrome) . + * └─
(rounded, dark bg, padded — the "terminal window" . + * look that gives the page a distinct visual identity) . + * └─ @xterm/xterm Terminal (WebGL renderer, Unicode 11 widths) . + * │ onData keystrokes → WebSocket → PTY master . + * │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` . + * │ write(data) PTY output bytes → VT100 parser . + * ▼ . + * WebSocket /api/pty?token= . + * ▼ . + * FastAPI pty_ws (hermes_cli/web_server.py) . + * ▼ . + * POSIX PTY → `node ui-tui/dist/entry.js` → tui_gateway + AIAgent . + */ + +import { useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { WebglAddon } from "@xterm/addon-webgl"; +import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { Copy } from "lucide-react"; +import "@xterm/xterm/css/xterm.css"; + +function buildWsUrl(token: string, resume: string | null): string { + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + const qs = new URLSearchParams({ token }); + if (resume) qs.set("resume", resume); + return `${proto}//${window.location.host}/api/pty?${qs.toString()}`; +} + +// Colors for the terminal body. Matches the dashboard's dark teal canvas +// with cream foreground — we intentionally don't pick monokai or a loud +// theme, because the TUI's skin engine already paints the content; the +// terminal chrome just needs to sit quietly inside the dashboard. +const TERMINAL_THEME = { + background: "#0d2626", + foreground: "#f0e6d2", + cursor: "#f0e6d2", + cursorAccent: "#0d2626", + selectionBackground: "#f0e6d244", +}; + +export default function ChatPage() { + const hostRef = useRef(null); + const termRef = useRef(null); + const fitRef = useRef(null); + const wsRef = useRef(null); + const [searchParams] = useSearchParams(); + const [banner, setBanner] = useState(null); + const [copyState, setCopyState] = useState<"idle" | "copied">("idle"); + const copyResetRef = useRef | null>(null); + + const resumeRef = useRef(searchParams.get("resume")); + + const handleCopyLast = () => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + // Send the slash as a burst, wait long enough for Ink's tokenizer to + // emit a keypress event for each character (not coalesce them into a + // paste), then send Return as its own event. The timing here is + // empirical — 100ms is safely past Node's default stdin coalescing + // window and well inside UI responsiveness. + ws.send("/copy"); + setTimeout(() => { + const s = wsRef.current; + if (s && s.readyState === WebSocket.OPEN) s.send("\r"); + }, 100); + setCopyState("copied"); + if (copyResetRef.current) clearTimeout(copyResetRef.current); + copyResetRef.current = setTimeout(() => setCopyState("idle"), 1500); + termRef.current?.focus(); + }; + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + + const token = window.__HERMES_SESSION_TOKEN__; + if (!token) { + setBanner( + "Session token unavailable. Open this page through `hermes dashboard`, not directly.", + ); + return; + } + + 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, + macOptionIsMeta: true, + scrollback: 0, + theme: TERMINAL_THEME, + }); + termRef.current = term; + + // --- Clipboard integration --------------------------------------- + // + // Three independent paths all route to the system clipboard: + // + // 1. **Selection → Ctrl+C (or Cmd+C on macOS).** Ink's own handler + // in useInputHandlers.ts turns Ctrl+C into a copy when the + // terminal has a selection, then emits an OSC 52 escape. Our + // OSC 52 handler below decodes that escape and writes to the + // browser clipboard — so the flow works just like it does in + // `hermes --tui`. + // + // 2. **Ctrl/Cmd+Shift+C.** Belt-and-suspenders shortcut that + // operates directly on xterm's selection, useful if the TUI + // ever stops listening (e.g. overlays / pickers) or if the user + // has selected with the mouse outside of Ink's selection model. + // + // 3. **Ctrl/Cmd+Shift+V.** Reads the system clipboard and feeds + // it to the terminal as keyboard input. xterm's paste() wraps + // it with bracketed-paste if the host has that mode enabled. + // + // OSC 52 reads (terminal asking to read the clipboard) are not + // supported — that would let any content the TUI renders exfiltrate + // the user's clipboard. + term.parser.registerOscHandler(52, (data) => { + // Format: ";" + const semi = data.indexOf(";"); + if (semi < 0) return false; + const payload = data.slice(semi + 1); + if (payload === "?" || payload === "") return false; // read/clear — ignore + try { + // atob returns a binary string (one byte per char); we need UTF-8 + // decode so multi-byte codepoints (≥, →, emoji, CJK) round-trip + // correctly. Without this step, the three UTF-8 bytes of `≥` + // would land in the clipboard as the three separate Latin-1 + // characters `≥`. + const binary = atob(payload); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + const text = new TextDecoder("utf-8").decode(bytes); + navigator.clipboard.writeText(text).catch(() => {}); + } catch { + // Malformed base64 — silently drop. + } + return true; + }); + + const isMac = + typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); + + term.attachCustomKeyEventHandler((ev) => { + if (ev.type !== "keydown") return true; + + const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; + const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; + + if (copyModifier && ev.key.toLowerCase() === "c") { + const sel = term.getSelection(); + if (sel) { + navigator.clipboard.writeText(sel).catch(() => {}); + ev.preventDefault(); + return false; + } + } + + if (pasteModifier && ev.key.toLowerCase() === "v") { + navigator.clipboard + .readText() + .then((text) => { + if (text) term.paste(text); + }) + .catch(() => {}); + ev.preventDefault(); + return false; + } + + return true; + }); + + const fit = new FitAddon(); + fitRef.current = fit; + term.loadAddon(fit); + + const unicode11 = new Unicode11Addon(); + term.loadAddon(unicode11); + term.unicode.activeVersion = "11"; + + term.loadAddon(new WebLinksAddon()); + + 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, + ); + } + + // Initial fit + resize observer. fit.fit() reads the container's + // current bounding box and resizes the terminal grid to match. + // + // The subtle bit: the dashboard has CSS transitions on the container + // (backdrop fade-in, rounded corners settling as fonts load). If we + // call fit() at mount time, the bounding box we measure is often 1-2 + // cell widths off from the final size. ResizeObserver *does* fire + // when the container settles, but if the pixel delta happens to be + // smaller than one cell's width, fit() computes the same integer + // (cols, rows) as before and doesn't emit onResize — so the PTY + // never learns the final size. Users see truncated long lines until + // they resize the browser window. + // + // We force one extra fit + explicit RESIZE send after two animation + // 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. + } + }); + }; + fit.fit(); + const ro = new ResizeObserver(scheduleFit); + ro.observe(host); + + // 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 + // (even if fit's cols/rows didn't change, so the PTY has the same + // dims registered as our JS state — prevents a drift where Ink + // thinks the terminal is one col bigger than what's on screen). + let settleRaf1 = 0; + let settleRaf2 = 0; + settleRaf1 = requestAnimationFrame(() => { + 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}]`); + } + }); + }); + + // WebSocket + const url = buildWsUrl(token, resumeRef.current); + const ws = new WebSocket(url); + ws.binaryType = "arraybuffer"; + wsRef.current = ws; + + ws.onopen = () => { + setBanner(null); + // Send the initial RESIZE immediately so Ink has *a* size to lay + // out against on its first paint. The double-rAF block above will + // follow up with the authoritative measurement — at worst Ink + // reflows once after the PTY boots, which is imperceptible. + ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`); + }; + + ws.onmessage = (ev) => { + if (typeof ev.data === "string") { + term.write(ev.data); + } else { + term.write(new Uint8Array(ev.data as ArrayBuffer)); + } + }; + + ws.onclose = (ev) => { + wsRef.current = null; + if (ev.code === 4401) { + setBanner( + "Auth failed. Reload the page to refresh the session token.", + ); + return; + } + if (ev.code === 4403) { + setBanner("Chat is only reachable from localhost."); + return; + } + if (ev.code === 1011) { + // Server already wrote an ANSI error frame. + return; + } + term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n"); + }; + + // Keystrokes + mouse events → PTY, with cell-level dedup for motion. + // + // Ink enables `\x1b[?1003h` (any-motion tracking), which asks the + // terminal to report every mouse-move as an SGR mouse event even with + // no button held. xterm.js happily emits one report per pixel of + // mouse motion; without deduping, a casual mouse-over floods Ink with + // hundreds of redraw-triggering reports and the UI goes laggy + // (scrolling stutters, clicks land on stale positions by the time + // Ink finishes processing the motion backlog). + // + // We keep track of the last cell we reported a motion for. Press, + // release, and wheel events always pass through; motion events only + // pass through if the cell changed. Parsing is cheap — SGR reports + // are short literal strings. + const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/; + let lastMotionCell = { col: -1, row: -1 }; + let lastMotionCb = -1; + const onDataDisposable = term.onData((data) => { + if (ws.readyState !== WebSocket.OPEN) return; + + const m = SGR_MOUSE_RE.exec(data); + if (m) { + const cb = parseInt(m[1], 10); + const col = parseInt(m[2], 10); + const row = parseInt(m[3], 10); + const released = m[4] === "m"; + // Motion events have bit 0x20 (32) set in the button code. + // Wheel events have bit 0x40 (64); always forward wheel. + const isMotion = (cb & 0x20) !== 0 && (cb & 0x40) === 0; + const isWheel = (cb & 0x40) !== 0; + if (isMotion && !isWheel && !released) { + if ( + col === lastMotionCell.col && + row === lastMotionCell.row && + cb === lastMotionCb + ) { + return; // same cell + same button state; skip redundant report + } + lastMotionCell = { col, row }; + lastMotionCb = cb; + } else { + // Non-motion event (press, release, wheel) — reset dedup state + // so the next motion after this always reports. + lastMotionCell = { col: -1, row: -1 }; + lastMotionCb = -1; + } + } + + ws.send(data); + }); + + const onResizeDisposable = term.onResize(({ cols, rows }) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(`\x1b[RESIZE:${cols};${rows}]`); + } + }); + + term.focus(); + + return () => { + onDataDisposable.dispose(); + onResizeDisposable.dispose(); + ro.disconnect(); + if (rafId) cancelAnimationFrame(rafId); + if (settleRaf1) cancelAnimationFrame(settleRaf1); + if (settleRaf2) cancelAnimationFrame(settleRaf2); + ws.close(); + wsRef.current = null; + term.dispose(); + termRef.current = null; + fitRef.current = null; + if (copyResetRef.current) { + clearTimeout(copyResetRef.current); + copyResetRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Layout: + // outer flex column — sits inside the dashboard's content area + // terminal wrapper — rounded, dark, padded — the "terminal window" + // floating copy button — bottom-right corner, transparent with a + // subtle border; stays out of the way until hovered. Sends + // `/copy\n` to Ink, which emits OSC 52 → our clipboard handler. + // + // `normal-case` opts out of the dashboard's global `uppercase` rule on + // the root `
` in App.tsx — terminal output must preserve case. + return ( +
+ {banner && ( +
+ {banner} +
+ )} +
+
+ + +
+
+ ); +} + +declare global { + interface Window { + __HERMES_SESSION_TOKEN__?: string; + } +} diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 370b499a8..42f797399 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback, useRef } from "react"; +import { useNavigate } from "react-router-dom"; import { ChevronDown, ChevronLeft, @@ -12,6 +13,7 @@ import { MessageCircle, Hash, X, + Play, } from "lucide-react"; import { H2 } from "@nous-research/ui"; import { api } from "@/lib/api"; @@ -250,6 +252,7 @@ function SessionRow({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { t } = useI18n(); + const navigate = useNavigate(); useEffect(() => { if (isExpanded && messages === null && !loading) { @@ -329,6 +332,19 @@ function SessionRow({ {session.source ?? "local"} +