mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
fix(dashboard): stabilize embedded chat resume and scrollback
This commit is contained in:
parent
fdb9e0f6a6
commit
a0758cd1e9
3 changed files with 67 additions and 50 deletions
|
|
@ -303,7 +303,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
|||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 normal-case lg:w-80",
|
||||
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1 normal-case lg:w-80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
@ -355,12 +355,12 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="flex min-h-0 flex-1 flex-col px-2 py-2">
|
||||
<Card className="flex min-h-0 flex-none flex-col px-2 py-2">
|
||||
<div className="px-1 pb-2 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
tools
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-1">
|
||||
<div className="flex min-h-0 flex-col gap-1.5">
|
||||
{tools.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
no tool calls yet
|
||||
|
|
|
|||
|
|
@ -147,8 +147,14 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
: false,
|
||||
);
|
||||
|
||||
const resumeRef = useRef<string | null>(searchParams.get("resume"));
|
||||
const channel = useMemo(() => generateChannelId(), []);
|
||||
// The dashboard keeps ChatPage mounted persistently so the PTY survives tab
|
||||
// switches. That is great for ordinary /chat navigation, but it means query
|
||||
// param changes do NOT remount the component. Resume-in-chat from the
|
||||
// Sessions page relies on `/chat?resume=<id>` changing at runtime, so we must
|
||||
// treat the current resume target as part of the PTY identity and rebuild the
|
||||
// terminal session when it changes.
|
||||
const resumeId = searchParams.get("resume");
|
||||
const channel = useMemo(() => generateChannelId(), [resumeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(max-width: 1023px)");
|
||||
|
|
@ -254,7 +260,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
fontWeight: "400",
|
||||
fontWeightBold: "700",
|
||||
macOptionIsMeta: true,
|
||||
scrollback: 0,
|
||||
// Keep a reasonable terminal history in the browser so users can
|
||||
// scroll back through earlier conversation/tool output. A zero
|
||||
// scrollback makes wheel scrolling feel broken once the visible
|
||||
// viewport fills.
|
||||
scrollback: 5000,
|
||||
theme: TERMINAL_THEME,
|
||||
});
|
||||
termRef.current = term;
|
||||
|
|
@ -357,6 +367,25 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
fitRef.current = fit;
|
||||
term.loadAddon(fit);
|
||||
|
||||
// Force a browser-native-feeling wheel path for the embedded chat.
|
||||
// The default xterm.js / terminal-app interaction can be ambiguous in
|
||||
// our PTY setup: wheel events may be interpreted as terminal mouse
|
||||
// input, ignored by the app, or otherwise fail to move the browser-side
|
||||
// scrollback even when history exists. Intercept the wheel gesture at
|
||||
// the terminal boundary and map it directly onto xterm's own scrollback.
|
||||
term.attachCustomWheelEventHandler((ev) => {
|
||||
const delta = ev.deltaY;
|
||||
if (!delta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const step = Math.max(1, Math.round(Math.abs(delta) / 40));
|
||||
term.scrollLines(delta > 0 ? step : -step);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
|
||||
const unicode11 = new Unicode11Addon();
|
||||
term.loadAddon(unicode11);
|
||||
term.unicode.activeVersion = "11";
|
||||
|
|
@ -484,7 +513,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
});
|
||||
|
||||
// WebSocket
|
||||
const url = buildWsUrl(token, resumeRef.current, channel);
|
||||
const url = buildWsUrl(token, resumeId, channel);
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
|
|
@ -530,53 +559,27 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n");
|
||||
};
|
||||
|
||||
// Keystrokes + mouse events → PTY, with cell-level dedup for motion.
|
||||
// Keystrokes → PTY.
|
||||
//
|
||||
// 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).
|
||||
// IMPORTANT:
|
||||
// The embedded web chat has occasionally surfaced stray letters/digits
|
||||
// in the input line after a turn completes. The most likely culprit is
|
||||
// browser-side terminal control traffic being forwarded back into the
|
||||
// PTY as if it were user text. SGR mouse tracking is the highest-risk
|
||||
// path here: xterm.js emits raw CSI reports (`\x1b[<...`) that look like
|
||||
// ordinary bytes to the backend.
|
||||
//
|
||||
// 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.
|
||||
// For the browser embed we prefer input stability over terminal-style
|
||||
// mouse reporting, so we drop SGR mouse reports entirely instead of
|
||||
// forwarding them into Hermes. Keyboard input, paste, and resize still
|
||||
// behave normally.
|
||||
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
|
||||
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;
|
||||
}
|
||||
if (SGR_MOUSE_RE.test(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(data);
|
||||
|
|
@ -619,7 +622,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
copyResetRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [channel]);
|
||||
}, [channel, resumeId]);
|
||||
|
||||
// When the user returns to the chat tab (isActive: false → true), the
|
||||
// terminal host just transitioned from display:none to display:flex.
|
||||
|
|
@ -814,9 +817,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
id="chat-side-panel"
|
||||
role="complementary"
|
||||
aria-label={modelToolsLabel}
|
||||
className="flex min-h-0 shrink-0 flex-col lg:h-full lg:w-80"
|
||||
className="flex min-h-0 shrink-0 flex-col overflow-hidden lg:h-full lg:w-80"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ChatSidebar channel={channel} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue