diff --git a/packages/web/src/__tests__/components.test.tsx b/packages/web/src/__tests__/components.test.tsx index 94141fdd1..e747cf6d1 100644 --- a/packages/web/src/__tests__/components.test.tsx +++ b/packages/web/src/__tests__/components.test.tsx @@ -520,11 +520,13 @@ describe("SessionCard", () => { expect(onSend).toHaveBeenCalledTimes(1); }); - expect(screen.queryByRole("button", { name: "Sent" })).not.toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).toHaveValue( - "please continue", - ); - expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).not.toBeDisabled(); + await waitFor(() => { + expect(screen.queryByRole("button", { name: "Sent" })).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).toHaveValue( + "please continue", + ); + expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).not.toBeDisabled(); + }); }); it("shows a temporary failed state when an alert action send is rejected", async () => { diff --git a/packages/web/src/app/dev/terminal-test/page.test.tsx b/packages/web/src/app/dev/terminal-test/page.test.tsx new file mode 100644 index 000000000..fb8886904 --- /dev/null +++ b/packages/web/src/app/dev/terminal-test/page.test.tsx @@ -0,0 +1,70 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +let searchParams = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => searchParams, +})); + +vi.mock("@/components/DirectTerminal", () => ({ + DirectTerminal: ({ sessionId }: { sessionId: string }) => ( +
\x1b]52;c;<base64>\x07
+ \x1b]52;c;<base64>\x07
CSI > q (also
+ XDA query: CSI > q (also
called XTVERSION)
xterm.js does NOT implement XDA (Extended Device Attributes)
+
test.skip('CSI > Ps q - Report xterm name and version (XTVERSION)')
"iTerm2 "
+ "iTerm2 "
+
{`terminal.parser.registerCsiHandler(
{ prefix: ">", final: "q" }, // CSI > q is XDA query
@@ -211,7 +211,7 @@ function TerminalTestPageContent() {
- Intercepts XDA queries from tmux
-
- Responds with
XTerm(370){" "}
+ Responds with XTerm(370){" "}
identification
- tmux detects "XTerm(" in response and enables TTYC_MS capability
@@ -245,12 +245,12 @@ function TerminalTestPageContent() {
CRITICAL: This implementation requires Node 20.x (currently 20.20.0)
@@ -264,7 +264,7 @@ function TerminalTestPageContent() {posix_spawnp failed
+ posix_spawnp failed
+
node -e "const pty = require('node-pty'); pty.spawn('/bin/bash', [],
{})"
posix_spawnp failed{" "}
+ If no posix_spawnp failed{" "}
error, proceed
+
?old_session=ao-orchestrator&new_session=ao-20
Total Time Wasted: 12+ hours across Feb 15-16, 2026
@@ -446,7 +446,7 @@ function TerminalTestPageContent() {
+
set-option -s set-clipboard on
{" "}
in tmux.conf. Didn't help - tmux needs the terminal to declare support.
@@ -462,11 +462,11 @@ function TerminalTestPageContent() {
What finally worked: Registering an XDA (Extended Device
Attributes) handler in xterm.js using{" "}
-
+
terminal.parser.registerCsiHandler()
-
+
{`terminal.parser.registerCsiHandler(
{ prefix: ">", final: "q" },
@@ -492,12 +492,12 @@ function TerminalTestPageContent() {
-
Deep-dive into tmux source code - Used DeepWiki.com to analyze
tmux's terminal capability detection logic in{" "}
-
tty-keys.c and{" "}
- tty.c
+ tty-keys.c and{" "}
+ tty.c
-
Discovered XDA queries - Found that tmux sends{" "}
-
CSI > q (XTVERSION) to
+ CSI > q (XTVERSION) to
detect terminal type
-
@@ -508,7 +508,7 @@ function TerminalTestPageContent() {
-
Checked xterm.js implementation - Found that XDA is marked as
TODO in xterm.js tests:{" "}
-
+
test.skip('CSI > Ps q - Report xterm name and version (XTVERSION)')
@@ -519,7 +519,7 @@ function TerminalTestPageContent() {
tmux -vvv or using a
+ tmux -vvv or using a
terminal protocol analyzer would have revealed the XDA queries being sent
tty-keys.c: Terminal
+ tmux tty-keys.c: Terminal
type detection logic
+
packages/web/src/components/DirectTerminal.tsx
{" "}
- Main component with XDA handler
+
packages/web/server/direct-terminal-ws.ts
{" "}
- WebSocket server using node-pty
+
packages/web/src/app/dev/terminal-test/page.tsx
{" "}
- This test page
diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css
index 9b8bb5b68..113d6251a 100644
--- a/packages/web/src/app/globals.css
+++ b/packages/web/src/app/globals.css
@@ -26,12 +26,12 @@
/* โโ Light mode (default) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
:root {
- /* Base surfaces โ Linear-standard light palette */
- --color-bg-base: #ffffff;
+ /* Base surfaces โ design-system light palette */
+ --color-bg-base: #f5f5f7;
--color-bg-surface: #ffffff;
--color-bg-elevated: #ffffff;
--color-bg-elevated-hover: #f7f7f8;
- --color-bg-subtle: #f2f2f2;
+ --color-bg-subtle: #f0f0f2;
/* Backward-compat aliases */
--color-bg-primary: var(--color-bg-base);
@@ -39,9 +39,9 @@
--color-bg-tertiary: var(--color-bg-elevated);
/* Borders */
- --color-border-subtle: #e8e8ec;
+ --color-border-subtle: #e7e7ec;
--color-border-default: #d9d9de;
- --color-border-strong: #c1c1c6;
+ --color-border-strong: #c9c9d2;
/* Backward-compat aliases */
--color-border-muted: var(--color-border-subtle);
@@ -50,8 +50,8 @@
/* Text */
--color-text-primary: #1b1b1f;
--color-text-secondary: #5e5e66;
- --color-text-tertiary: #8b8b93;
- --color-text-muted: #8b8b93;
+ --color-text-tertiary: #8e8e96;
+ --color-text-muted: #8e8e96;
--color-text-inverse: #ffffff;
/* Interactive accent */
@@ -75,15 +75,15 @@
--color-accent-yellow: #9a6700;
--color-accent-orange: #bc4c00;
--color-accent-red: #dc2626;
- --color-accent-violet: #8250df;
- --color-accent-purple: #8250df;
+ --color-accent-violet: #5B7EF8;
+ --color-accent-purple: #5B7EF8;
/* Theme-adaptive tints (bg tints behind status pills) */
--color-tint-blue: rgba(91, 126, 248, 0.08);
--color-tint-green: rgba(22, 163, 74, 0.08);
--color-tint-yellow: rgba(154, 103, 0, 0.08);
--color-tint-red: rgba(220, 38, 38, 0.08);
- --color-tint-violet: rgba(130, 80, 223, 0.08);
+ --color-tint-violet: rgba(91, 126, 248, 0.08);
--color-tint-orange: rgba(188, 76, 0, 0.08);
--color-tint-neutral: rgba(0, 0, 0, 0.04);
@@ -151,16 +151,13 @@
--color-alert-ci-unknown: #9a6700;
--color-alert-review: #1a7f37;
--color-alert-review-bg: #1a7f37;
- --color-alert-changes: #8250df;
- --color-alert-changes-bg: #7c3aed;
+ --color-alert-changes: #5B7EF8;
+ --color-alert-changes-bg: #5B7EF8;
--color-alert-conflict: #9a6700;
--color-alert-conflict-bg: #92400e;
--color-alert-comment: #bc4c00;
--color-alert-comment-bg: #c2410c;
- /* Activity pulse glow */
- --color-activity-pulse: rgba(22, 163, 74, 0.45);
- --color-activity-pulse-end: rgba(22, 163, 74, 0);
}
/* โโ Dark mode โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
@@ -199,7 +196,7 @@
--color-status-review: #06b6d4;
--color-status-attention: #f1be64;
--color-status-idle: #293142;
- --color-status-done: #202838;
+ --color-status-done: #3a4252;
--color-status-error: #ef4444;
/* Semantic aliases */
@@ -208,15 +205,15 @@
--color-accent-yellow: #f1be64;
--color-accent-orange: #ff9d57;
--color-accent-red: #ef4444;
- --color-accent-violet: #b59cff;
- --color-accent-purple: #b59cff;
+ --color-accent-violet: #5B7EF8;
+ --color-accent-purple: #5B7EF8;
/* Theme-adaptive tints */
--color-tint-blue: rgba(91, 126, 248, 0.12);
--color-tint-green: rgba(34, 197, 94, 0.12);
--color-tint-yellow: rgba(241, 190, 100, 0.12);
--color-tint-red: rgba(239, 68, 68, 0.12);
- --color-tint-violet: rgba(181, 156, 255, 0.12);
+ --color-tint-violet: rgba(91, 126, 248, 0.12);
--color-tint-orange: rgba(255, 157, 87, 0.12);
--color-tint-neutral: rgba(255, 255, 255, 0.05);
@@ -233,7 +230,7 @@
/* Body */
--color-body-gradient-blue: rgba(110, 143, 255, 0.2);
- --color-body-gradient-violet: rgba(91, 208, 177, 0.08);
+ --color-body-gradient-violet: rgba(91, 126, 248, 0.08);
/* Nav glass */
--color-nav-bg: rgba(10, 13, 18, 0.82);
@@ -288,16 +285,13 @@
--color-alert-ci-unknown: #d4a72c;
--color-alert-review: #4dab6e;
--color-alert-review-bg: rgba(77, 171, 110, 0.25);
- --color-alert-changes: #9b8afb;
- --color-alert-changes-bg: rgba(155, 138, 251, 0.25);
+ --color-alert-changes: #5B7EF8;
+ --color-alert-changes-bg: rgba(91, 126, 248, 0.25);
--color-alert-conflict: #d4a72c;
--color-alert-conflict-bg: rgba(212, 167, 44, 0.25);
--color-alert-comment: #c88a2e;
--color-alert-comment-bg: rgba(200, 138, 46, 0.25);
- /* Activity pulse glow */
- --color-activity-pulse: rgba(34, 197, 94, 0.45);
- --color-activity-pulse-end: rgba(34, 197, 94, 0);
}
@theme {
@@ -412,7 +406,7 @@ body {
/* Light mode: solid white, NO gradient */
html:not(.dark) body {
- background: #ffffff;
+ background: var(--color-bg-base);
}
/* Radial gradient only for dark mode */
@@ -515,10 +509,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
@keyframes activity-pulse {
0%,
100% {
- box-shadow: 0 0 0 0 var(--color-activity-pulse);
+ transform: scale(1);
+ opacity: 1;
}
50% {
- box-shadow: 0 0 0 4px var(--color-activity-pulse-end);
+ transform: scale(1.16);
+ opacity: 0.72;
}
}
@@ -617,10 +613,6 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
border-color 0.12s ease;
}
-.orchestrator-btn:active {
- transform: scale(0.97);
-}
-
.orchestrator-btn:hover {
transform: translateY(-1px);
background: linear-gradient(
@@ -632,6 +624,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
box-shadow: var(--btn-shadow-hover);
}
+.orchestrator-btn:active,
+.orchestrator-btn:hover:active {
+ transform: scale(0.97);
+}
+
/* โโ Glass nav โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.nav-glass {
@@ -663,9 +660,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
position: relative;
overflow: visible;
border: 1px solid var(--color-border-default);
- border-radius: 0;
+ border-radius: 2px;
background: var(--color-bg-surface);
- box-shadow: 0 10px 24px rgba(2, 6, 12, 0.06);
+ box-shadow: var(--card-shadow);
}
.dashboard-hero__backdrop {
@@ -718,7 +715,6 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
height: 6px;
border-radius: 999px;
background: currentColor;
- box-shadow: 0 0 18px currentColor;
}
.dashboard-title {
@@ -796,8 +792,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
}
.dashboard-alert {
- border-radius: 0;
- box-shadow: 0 16px 34px rgba(0, 0, 0, 0.08);
+ border-radius: 2px;
+ box-shadow: var(--detail-card-shadow);
}
/* โโ Detail page cards โ subtle depth, no hover lift โโโโโโโโโโโโโโโโโโโ */
@@ -805,7 +801,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.detail-card {
background: var(--detail-card-bg);
box-shadow: var(--detail-card-shadow);
- border-radius: 0;
+ border-radius: 2px;
}
.session-detail-page {
@@ -826,6 +822,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
padding: 14px 16px;
background: var(--detail-card-bg);
box-shadow: var(--detail-card-shadow);
+ border-radius: 2px;
}
@media (min-width: 900px) {
@@ -893,7 +890,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
min-height: 28px;
padding: 0 10px;
border: 1px solid var(--color-border-subtle);
- border-radius: 0;
+ border-radius: 2px;
background: color-mix(in srgb, var(--color-bg-elevated) 88%, transparent);
color: var(--color-text-secondary);
font-size: 11px;
@@ -948,6 +945,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
position: relative;
overflow: hidden;
box-shadow: inset 0 1px 0 color-mix(in srgb, white 16%, transparent);
+ transition:
+ transform 0.16s ease-out,
+ opacity 0.16s ease-out,
+ background 0.16s ease-out,
+ border-color 0.16s ease-out;
}
.session-detail-status-pill__dot {
@@ -992,40 +994,34 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
@keyframes session-status-active-breathe {
0%, 100% {
- box-shadow:
- inset 0 1px 0 color-mix(in srgb, white 16%, transparent),
- 0 0 0 0 color-mix(in srgb, var(--color-status-working) 0%, transparent);
+ transform: translateY(0);
+ opacity: 0.96;
}
50% {
- box-shadow:
- inset 0 1px 0 color-mix(in srgb, white 18%, transparent),
- 0 0 0 3px color-mix(in srgb, var(--color-status-working) 10%, transparent);
+ transform: translateY(-1px);
+ opacity: 1;
}
}
@keyframes session-status-ready-breathe {
0%, 100% {
- box-shadow:
- inset 0 1px 0 color-mix(in srgb, white 16%, transparent),
- 0 0 0 0 color-mix(in srgb, var(--color-status-ready) 0%, transparent);
+ transform: translateY(0);
+ opacity: 0.96;
}
50% {
- box-shadow:
- inset 0 1px 0 color-mix(in srgb, white 20%, transparent),
- 0 0 0 3px color-mix(in srgb, var(--color-status-ready) 12%, transparent);
+ transform: translateY(-1px);
+ opacity: 1;
}
}
@keyframes session-status-waiting-breathe {
0%, 100% {
- box-shadow:
- inset 0 1px 0 color-mix(in srgb, white 16%, transparent),
- 0 0 0 0 color-mix(in srgb, var(--color-status-attention) 0%, transparent);
+ transform: translateY(0);
+ opacity: 0.95;
}
50% {
- box-shadow:
- inset 0 1px 0 color-mix(in srgb, white 18%, transparent),
- 0 0 0 3px color-mix(in srgb, var(--color-status-attention) 10%, transparent);
+ transform: translateY(-1px);
+ opacity: 1;
}
}
@@ -3129,7 +3125,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
}
.toast--success .toast__icon {
- background: var(--color-status-ready);
+ background: var(--color-status-working);
}
.toast--error .toast__icon {
diff --git a/packages/web/src/app/test-direct/page.test.tsx b/packages/web/src/app/test-direct/page.test.tsx
new file mode 100644
index 000000000..d4ed45dff
--- /dev/null
+++ b/packages/web/src/app/test-direct/page.test.tsx
@@ -0,0 +1,50 @@
+import { render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+let searchParams = new URLSearchParams();
+
+vi.mock("next/navigation", () => ({
+ useSearchParams: () => searchParams,
+}));
+
+vi.mock("@/components/DirectTerminal", () => ({
+ DirectTerminal: ({
+ sessionId,
+ startFullscreen,
+ }: {
+ sessionId: string;
+ startFullscreen: boolean;
+ }) => (
+
+ {sessionId}:{String(startFullscreen)}
+
+ ),
+}));
+
+describe("TestDirectPage", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("renders the default direct terminal clipboard instructions", async () => {
+ searchParams = new URLSearchParams();
+ const { default: TestDirectPage } = await import("./page");
+
+ render( );
+
+ expect(screen.getByText("DirectTerminal Test - XDA Clipboard Support")).toBeInTheDocument();
+ expect(screen.getByText("Testing:")).toBeInTheDocument();
+ expect(screen.getByText("ao-orchestrator")).toBeInTheDocument();
+ expect(screen.getByTestId("direct-terminal")).toHaveTextContent("ao-orchestrator:false");
+ });
+
+ it("passes session and fullscreen params to the terminal", async () => {
+ searchParams = new URLSearchParams("session=ao-20&fullscreen=true");
+ const { default: TestDirectPage } = await import("./page");
+
+ render( );
+
+ expect(screen.getByTestId("direct-terminal")).toHaveTextContent("ao-20:true");
+ expect(screen.getByText(/clipboard works without iTerm2 attachment/i)).toBeInTheDocument();
+ });
+});
diff --git a/packages/web/src/app/test-direct/page.tsx b/packages/web/src/app/test-direct/page.tsx
index 3d6c43672..9a8b14911 100644
--- a/packages/web/src/app/test-direct/page.tsx
+++ b/packages/web/src/app/test-direct/page.tsx
@@ -35,7 +35,7 @@ function TestDirectPageContent() {
tmux should recognize it as XTerm and enable clipboard support (OSC 52).
-
+
Testing: {sessionId}
@@ -56,7 +56,7 @@ function TestDirectPageContent() {
-
+
Technical Details:
diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx
index 90c15e65c..ebf79852a 100644
--- a/packages/web/src/components/DirectTerminal.tsx
+++ b/packages/web/src/components/DirectTerminal.tsx
@@ -15,7 +15,7 @@ import type { FitAddon as FitAddonType } from "@xterm/addon-fit";
interface DirectTerminalProps {
sessionId: string;
startFullscreen?: boolean;
- /** Visual variant. "orchestrator" uses violet accent; "agent" (default) uses blue. */
+ /** Visual variant. Orchestrator keeps the same design-system blue accent as the rest of the app. */
variant?: "agent" | "orchestrator";
/** CSS height for the terminal container in normal (non-fullscreen) mode.
* Defaults to "max(440px, calc(100vh - 440px))". */
@@ -78,11 +78,7 @@ export function buildTerminalThemes(variant: TerminalVariant): { dark: ITheme; l
selDark: "rgba(91, 126, 248, 0.30)",
selLight: "rgba(91, 126, 248, 0.25)",
};
- const orchAccent = {
- cursor: "#a371f7",
- selDark: "rgba(163, 113, 247, 0.25)",
- selLight: "rgba(130, 80, 223, 0.20)",
- };
+ const orchAccent = agentAccent;
const accent = variant === "orchestrator" ? orchAccent : agentAccent;
const dark: ITheme = {
@@ -682,8 +678,7 @@ export function DirectTerminal({
};
}, [fullscreen]);
- const accentColor =
- variant === "orchestrator" ? "var(--color-accent-violet)" : "var(--color-accent)";
+ const accentColor = "var(--color-accent)";
const statusDotClass =
status === "connected"
diff --git a/packages/web/src/components/PRStatus.tsx b/packages/web/src/components/PRStatus.tsx
index bbe33c792..26242c32a 100644
--- a/packages/web/src/components/PRStatus.tsx
+++ b/packages/web/src/components/PRStatus.tsx
@@ -38,7 +38,7 @@ export function PRStatus({ pr }: PRStatusProps) {
{/* Merged badge */}
{pr.state === "merged" && (
-
+
merged
)}
diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx
index 9947bd08e..7960b7899 100644
--- a/packages/web/src/components/SessionDetail.tsx
+++ b/packages/web/src/components/SessionDetail.tsx
@@ -538,7 +538,10 @@ function SessionDetailPRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: st
·
Merged
@@ -631,7 +634,7 @@ function SessionDetailPRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: st
onClick={() => handleAskAgentToFix(c)}
disabled={sendingComments.has(c.url)}
className={cn(
- "mt-1.5 px-3 py-1 text-[11px] font-semibold transition-all",
+ "mt-1.5 px-3 py-1 text-[11px] font-semibold transition-colors duration-150",
sentComments.has(c.url)
? "bg-[var(--color-status-ready)] text-white"
: errorComments.has(c.url)
diff --git a/packages/web/src/components/Terminal.tsx b/packages/web/src/components/Terminal.tsx
index 3a9a130a6..cffc58aa4 100644
--- a/packages/web/src/components/Terminal.tsx
+++ b/packages/web/src/components/Terminal.tsx
@@ -40,7 +40,7 @@ export function Terminal({ sessionId }: TerminalProps) {
return (
@@ -68,7 +68,7 @@ export function Terminal({ sessionId }: TerminalProps) {
diff --git a/packages/web/src/components/__tests__/DirectTerminal.render.test.tsx b/packages/web/src/components/__tests__/DirectTerminal.render.test.tsx
new file mode 100644
index 000000000..b79f9d802
--- /dev/null
+++ b/packages/web/src/components/__tests__/DirectTerminal.render.test.tsx
@@ -0,0 +1,129 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { DirectTerminal } from "../DirectTerminal";
+
+const replaceMock = vi.fn();
+let searchParams = new URLSearchParams();
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ replace: replaceMock }),
+ usePathname: () => "/test-direct",
+ useSearchParams: () => searchParams,
+}));
+
+vi.mock("next-themes", () => ({
+ useTheme: () => ({ resolvedTheme: "dark" }),
+}));
+
+class MockTerminal {
+ options: Record;
+ parser = {
+ registerCsiHandler: vi.fn(),
+ registerOscHandler: vi.fn(),
+ };
+ cols = 80;
+ rows = 24;
+
+ constructor(options: Record) {
+ this.options = options;
+ }
+
+ loadAddon() {}
+ open() {}
+ write() {}
+ refresh() {}
+ dispose() {}
+ hasSelection() {
+ return false;
+ }
+ getSelection() {
+ return "";
+ }
+ clearSelection() {}
+ onSelectionChange() {
+ return { dispose() {} };
+ }
+ attachCustomKeyEventHandler() {}
+ onData() {
+ return { dispose() {} };
+ }
+}
+
+class MockFitAddon {
+ fit() {}
+}
+
+function MockWebLinksAddon() {
+ return undefined;
+}
+
+class MockWebSocket {
+ static OPEN = 1;
+ static instances: MockWebSocket[] = [];
+ readyState = MockWebSocket.OPEN;
+ binaryType = "arraybuffer";
+ onopen: (() => void) | null = null;
+ onmessage: ((event: { data: string }) => void) | null = null;
+ onerror: ((event: unknown) => void) | null = null;
+ onclose: ((event: { code: number; reason: string }) => void) | null = null;
+
+ constructor(public url: string) {
+ MockWebSocket.instances.push(this);
+ setTimeout(() => this.onopen?.(), 0);
+ }
+
+ send() {}
+ close() {}
+}
+
+vi.mock("xterm", () => ({
+ Terminal: MockTerminal,
+}));
+
+vi.mock("@xterm/addon-fit", () => ({
+ FitAddon: MockFitAddon,
+}));
+
+vi.mock("@xterm/addon-web-links", () => ({
+ WebLinksAddon: MockWebLinksAddon,
+}));
+
+describe("DirectTerminal render", () => {
+ beforeEach(() => {
+ searchParams = new URLSearchParams();
+ replaceMock.mockReset();
+ MockWebSocket.instances = [];
+ Object.defineProperty(document, "fonts", {
+ configurable: true,
+ value: { ready: Promise.resolve() },
+ });
+ vi.stubGlobal("WebSocket", MockWebSocket);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({
+ ok: true,
+ json: async () => ({
+ proxyWsPath: "/ao-terminal-ws",
+ }),
+ })),
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it("renders the shared accent chrome for orchestrator terminals", async () => {
+ render( );
+
+ await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/runtime/terminal", expect.any(Object)));
+ await waitFor(() =>
+ expect(screen.getByText("Connected")).toBeInTheDocument(),
+ );
+
+ expect(screen.getByText("ao-orchestrator")).toHaveStyle({ color: "var(--color-accent)" });
+ expect(screen.getByText("XDA")).toHaveStyle({ color: "var(--color-accent)" });
+ expect(MockWebSocket.instances[0]?.url).toContain("/ao-terminal-ws?session=ao-orchestrator");
+ });
+});
diff --git a/packages/web/src/components/__tests__/DirectTerminal.test.ts b/packages/web/src/components/__tests__/DirectTerminal.test.ts
index 9b3f63027..cc1d6f690 100644
--- a/packages/web/src/components/__tests__/DirectTerminal.test.ts
+++ b/packages/web/src/components/__tests__/DirectTerminal.test.ts
@@ -125,11 +125,21 @@ describe("buildTerminalThemes", () => {
expect(dark.background).toBe("#0a0a0f");
});
- it("variant changes cursor color between agent and orchestrator", () => {
+ it("orchestrator variant reuses the shared design-system accent", () => {
const agent = buildTerminalThemes("agent");
const orch = buildTerminalThemes("orchestrator");
- expect(agent.dark.cursor).not.toBe(orch.dark.cursor);
- expect(agent.light.cursor).not.toBe(orch.light.cursor);
+ expect(agent.dark.cursor).toBe(orch.dark.cursor);
+ expect(agent.light.cursor).toBe(orch.light.cursor);
+ expect(agent.dark.selectionBackground).toBe(orch.dark.selectionBackground);
+ expect(agent.light.selectionBackground).toBe(orch.light.selectionBackground);
+ });
+
+ it("keeps ANSI magenta distinct from ANSI blue", () => {
+ const { dark, light } = buildTerminalThemes("agent");
+ expect(dark.magenta).not.toBe(dark.blue);
+ expect(dark.brightMagenta).not.toBe(dark.brightBlue);
+ expect(light.magenta).not.toBe(light.blue);
+ expect(light.brightMagenta).not.toBe(light.brightBlue);
});
it("selection colors differ between dark and light themes", () => {
diff --git a/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx b/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx
index 2627b73b7..4875f9841 100644
--- a/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx
+++ b/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx
@@ -180,4 +180,29 @@ describe("SessionDetail mobile navbar", () => {
expect(screen.getByText("lint")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ask Agent to Fix" })).toBeInTheDocument();
});
+
+ it("shows the merged badge styling for merged PR sessions", () => {
+ render(
+ ,
+ );
+
+ const mergedBadge = screen.getByText("Merged");
+ expect(mergedBadge).toBeInTheDocument();
+ expect(mergedBadge).toHaveStyle({
+ color: "var(--color-text-secondary)",
+ background: "var(--color-chip-bg)",
+ });
+ });
});
diff --git a/packages/web/src/components/__tests__/Terminal.test.tsx b/packages/web/src/components/__tests__/Terminal.test.tsx
new file mode 100644
index 000000000..75a3c83e7
--- /dev/null
+++ b/packages/web/src/components/__tests__/Terminal.test.tsx
@@ -0,0 +1,39 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Terminal } from "../Terminal";
+
+describe("Terminal", () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({
+ ok: true,
+ json: async () => ({ url: "http://localhost:14800/session/demo" }),
+ })),
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it("loads the iframe terminal URL and supports fullscreen toggling", async () => {
+ const { container } = render( );
+
+ await waitFor(() =>
+ expect(screen.getByTitle("Terminal: ao-77")).toHaveAttribute(
+ "src",
+ "http://localhost:14800/session/demo",
+ ),
+ );
+
+ expect(fetch).toHaveBeenCalledWith(
+ `${window.location.protocol}//${window.location.hostname}:14800/terminal?session=ao-77`,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "fullscreen" }));
+ expect(container.firstChild).toHaveClass("fixed", "inset-0");
+ expect(screen.getByRole("button", { name: "exit fullscreen" })).toBeInTheDocument();
+ });
+});