feat(ui): terminal, charts and low-contrast text follow the selected theme

- The lane terminal (xterm) takes its palette from the selected DARK theme —
  it stays dark in light mode, as a terminal should — and repaints live when
  the theme changes (`client/src/lib/terminalTheme.ts`).
- Charts on the Dashboard and Workflows pages draw their grid, axes, labels
  and tooltips from theme tokens instead of hardcoded near-black/white, and
  the activity heatmap ramps from the page surface to the accent, so it reads
  on every theme instead of going near-black on light ones.
- Status/brand text that only worked on dark backgrounds
  (`text-*-300/400`) gets a darker light-mode shade (`text-*-700
  dark:text-*-300`) across sessions, conversations, workflows, config and
  settings. Categorical chart colours are kept.
This commit is contained in:
2026-09-24 09:50:44 +07:00
parent 6353e88c9e
commit 8ea40260ad
36 changed files with 919 additions and 635 deletions
+2 -2
View File
@@ -58,7 +58,7 @@ function StatusPill({
const colors: Record<string, string> = {
running: "bg-status-success/10 text-status-success border-status-success/30",
gone: "bg-surface-3 text-fg-secondary border-border",
completed: "bg-sky-500/10 text-sky-300 border-sky-500/30",
completed: "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30",
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
killed: "bg-surface-3 text-fg-secondary border-border",
abandoned: "bg-surface-3 text-fg-secondary border-border",
@@ -574,7 +574,7 @@ function UnifiedRunRowView({
)}
{row.external && (
<span
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
className="text-[10px] font-semibold text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
title={t("runs.externalHint")}
>
{t("runs.externalBadge")}
+1 -1
View File
@@ -691,7 +691,7 @@ function SessionPicker({
s.status === "active"
? "bg-status-success/10 text-status-success border-status-success/30"
: s.status === "completed"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
: s.status === "error"
? "bg-status-danger/10 text-status-danger border-status-danger/30"
: "bg-surface-3 text-fg-secondary border-border"
+28 -2
View File
@@ -9,13 +9,17 @@
* The socket auto-reconnects: a server restart kills the attach PTY, and
* without a retry the pane silently freezes on its last painted frame — it
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { CanvasAddon } from "@xterm/addon-canvas";
import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
interface TerminalViewProps {
runId: string;
@@ -33,6 +37,14 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
// re-attach on each one.
const onExitRef = useRef(onExit);
onExitRef.current = onExit;
// Always the DARK theme's palette, even in light mode: Claude Code's TUI
// assumes a dark background. Read through a ref at construction so a theme
// change never re-runs the attach effect; the effect below recolours live.
const darkTheme = useTheme().themeFor.dark;
const palette = useMemo(() => terminalThemeFor(darkTheme), [darkTheme]);
const paletteRef = useRef(palette);
paletteRef.current = palette;
const termRef = useRef<Terminal | null>(null);
useEffect(() => {
// An explicit fontFamily pins every glyph to one bundled webfont. Left
@@ -56,7 +68,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
fontSize: 13,
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
theme: paletteRef.current,
});
termRef.current = term;
const fit = new FitAddon();
term.loadAddon(fit);
if (containerRef.current) term.open(containerRef.current);
@@ -225,8 +239,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
ws?.close();
canvas?.dispose();
term.dispose();
termRef.current = null;
};
}, [runId, wsBaseUrl]);
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
useEffect(() => {
if (termRef.current) termRef.current.options.theme = palette;
}, [palette]);
return (
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: palette.background }}
data-testid="terminal-view"
/>
);
}
@@ -3,11 +3,12 @@
* @description Tests for the xterm.js-backed terminal view: verifies it opens
* a WS connection to the right URL, writes incoming binary frames to the
* mocked terminal, forwards typed input as outgoing binary frames, and
* re-attaches after the socket drops (server restart) but not after `exit`.
* re-attaches after the socket drops (server restart) but not after `exit`,
* and recolours live on a dark-theme change without re-attaching.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { render, cleanup, act } from "@testing-library/react";
import { TerminalView } from "../TerminalView";
const writeMock = vi.fn();
@@ -26,9 +27,13 @@ const termState = {
rows: 28,
};
const onResizeHandlers: Array<() => void> = [];
const termOptions: { theme?: unknown } = {};
const ctorArgs: Array<{ theme?: unknown }> = [];
vi.mock("@xterm/xterm", () => ({
Terminal: vi.fn().mockImplementation(() => ({
Terminal: vi.fn().mockImplementation((opts: { theme?: unknown }) => ({
...(ctorArgs.push(opts), {}),
options: termOptions,
get modes() {
return termState.modes;
},
@@ -58,6 +63,10 @@ vi.mock("@xterm/xterm", () => ({
focus: focusMock,
})),
}));
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
vi.mock("../../../lib/terminalTheme", () => ({
terminalThemeFor: (id: string) => ({ background: `bg-${id}` }),
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
@@ -103,6 +112,10 @@ describe("TerminalView", () => {
loadAddonMock.mockClear();
openMock.mockClear();
focusMock.mockClear();
disposeMock.mockClear();
ctorArgs.length = 0;
delete termOptions.theme;
localStorage.clear();
});
afterEach(cleanup);
@@ -126,6 +139,21 @@ describe("TerminalView", () => {
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
});
it("paints the chosen DARK theme and recolours live without re-attaching", () => {
localStorage.setItem("theme", "light"); // app in light mode: terminal stays dark
localStorage.setItem("agenthub.theme.dark", "apple-dark");
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(ctorArgs[0]!.theme).toEqual({ background: "bg-apple-dark" });
act(() => {
localStorage.setItem("agenthub.theme.dark", "darcula");
window.dispatchEvent(new Event("agenthub-theme"));
});
expect(termOptions.theme).toEqual({ background: "bg-darcula" });
expect(MockWebSocket.instances).toHaveLength(1);
expect(disposeMock).not.toHaveBeenCalled();
});
it("writes incoming WS data to the terminal", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;