fix(dashboard): stabilize embedded chat resume and scrollback

This commit is contained in:
nouseman666 2026-05-03 13:12:36 +08:00 committed by Teknium
parent fdb9e0f6a6
commit a0758cd1e9
3 changed files with 67 additions and 50 deletions

View file

@ -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

View file

@ -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>