fix(web): scope terminal tmux resolution by project (#1551)
* fix(web): scope terminal resolution by project * fix(web): avoid suffix false-positives in project-scoped tmux key match Made-with: Cursor * fix(web): scope fullscreen terminal resize by project Made-with: Cursor
This commit is contained in:
parent
0a9ba4cd7f
commit
b2cdf7adab
|
|
@ -775,6 +775,66 @@ describe("resolveTmuxSession", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("prefers the project-scoped storageKey when session IDs collide", () => {
|
||||
const fs = {
|
||||
readdir: () => [
|
||||
"aaaaaaaaaaaa-alpha",
|
||||
"bbbbbbbbbbbb-beta",
|
||||
],
|
||||
exists: (p: string) =>
|
||||
p.endsWith("/aaaaaaaaaaaa-alpha/sessions/app-1") ||
|
||||
p.endsWith("/bbbbbbbbbbbb-beta/sessions/app-1"),
|
||||
homedir: () => "/home/user",
|
||||
};
|
||||
const mockExec = vi.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("session not found"); // exact match fails
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
return ""; // project-scoped candidate succeeds
|
||||
});
|
||||
|
||||
const result = resolveTmuxSession("app-1", TMUX, mockExec, fs, "beta");
|
||||
|
||||
expect(result).toBe("bbbbbbbbbbbb-beta-app-1");
|
||||
expect(mockExec).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
TMUX,
|
||||
["has-session", "-t", "=bbbbbbbbbbbb-beta-app-1"],
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat suffix project names as exact project matches", () => {
|
||||
const fs = {
|
||||
readdir: () => [
|
||||
"aaaaaaaaaaaa-my-app",
|
||||
"bbbbbbbbbbbb-app",
|
||||
],
|
||||
exists: (p: string) =>
|
||||
p.endsWith("/aaaaaaaaaaaa-my-app/sessions/app-1") ||
|
||||
p.endsWith("/bbbbbbbbbbbb-app/sessions/app-1"),
|
||||
homedir: () => "/home/user",
|
||||
};
|
||||
const mockExec = vi.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("session not found"); // exact match fails
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
return ""; // correctly probes exact project match first
|
||||
});
|
||||
|
||||
const result = resolveTmuxSession("app-1", TMUX, mockExec, fs, "app");
|
||||
|
||||
expect(result).toBe("bbbbbbbbbbbb-app-app-1");
|
||||
expect(mockExec).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
TMUX,
|
||||
["has-session", "-t", "=bbbbbbbbbbbb-app-app-1"],
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts wrapped storageKeys with spaces/unicode in the project name", () => {
|
||||
// Legacy storageKeys use basename(projectPath), which has no character
|
||||
// restrictions on-disk. Regexes that reject spaces or unicode would
|
||||
|
|
|
|||
|
|
@ -17,33 +17,26 @@ import { findTmux, resolveTmuxSession, validateSessionId } from "./tmux-utils.js
|
|||
|
||||
// ── Client → Server ──
|
||||
type ClientMessage =
|
||||
| { ch: "terminal"; id: string; type: "data"; data: string }
|
||||
| { ch: "terminal"; id: string; type: "resize"; cols: number; rows: number }
|
||||
| { ch: "terminal"; id: string; type: "open"; tmuxName?: string }
|
||||
| { ch: "terminal"; id: string; type: "close" }
|
||||
| { ch: "terminal"; id: string; type: "data"; data: string; projectId?: string }
|
||||
| { ch: "terminal"; id: string; type: "resize"; cols: number; rows: number; projectId?: string }
|
||||
| { ch: "terminal"; id: string; type: "open"; projectId?: string; tmuxName?: string }
|
||||
| { ch: "terminal"; id: string; type: "close"; projectId?: string }
|
||||
| { ch: "system"; type: "ping" }
|
||||
| { ch: "subscribe"; topics: ("sessions")[] };
|
||||
| { ch: "subscribe"; topics: "sessions"[] };
|
||||
|
||||
// ── Server → Client ──
|
||||
type ServerMessage =
|
||||
| { ch: "terminal"; id: string; type: "data"; data: string }
|
||||
| { ch: "terminal"; id: string; type: "exited"; code: number }
|
||||
| { ch: "terminal"; id: string; type: "opened" }
|
||||
| { ch: "terminal"; id: string; type: "error"; message: string }
|
||||
| { ch: "terminal"; id: string; type: "data"; data: string; projectId?: string }
|
||||
| { ch: "terminal"; id: string; type: "exited"; code: number; projectId?: string }
|
||||
| { ch: "terminal"; id: string; type: "opened"; projectId?: string }
|
||||
| { ch: "terminal"; id: string; type: "error"; message: string; projectId?: string }
|
||||
| { ch: "sessions"; type: "snapshot"; sessions: SessionPatch[] }
|
||||
| { ch: "sessions"; type: "error"; error: string }
|
||||
| { ch: "system"; type: "pong" }
|
||||
| { ch: "system"; type: "error"; message: string };
|
||||
|
||||
// Mirrors AttentionLevel in src/lib/types.ts — keep in sync.
|
||||
type AttentionLevel =
|
||||
| "merge"
|
||||
| "action"
|
||||
| "respond"
|
||||
| "review"
|
||||
| "pending"
|
||||
| "working"
|
||||
| "done";
|
||||
type AttentionLevel = "merge" | "action" | "respond" | "review" | "pending" | "working" | "done";
|
||||
|
||||
interface SessionPatch {
|
||||
id: string;
|
||||
|
|
@ -211,25 +204,33 @@ class TerminalManager {
|
|||
this.TMUX = tmuxPath ?? findTmux();
|
||||
}
|
||||
|
||||
private terminalKey(id: string, projectId?: string): string {
|
||||
return projectId ? `${projectId}:${id}` : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open/attach to a terminal. If already open, just return.
|
||||
* If has subscribers but PTY crashed, re-attach.
|
||||
*/
|
||||
open(id: string, tmuxName?: string): string {
|
||||
open(id: string, projectId?: string, tmuxName?: string): string {
|
||||
// Validate and resolve
|
||||
if (!validateSessionId(id)) {
|
||||
throw new Error(`Invalid session ID: ${id}`);
|
||||
}
|
||||
|
||||
// Use provided tmuxName, or reuse from existing terminal entry, or resolve
|
||||
const existing = this.terminals.get(id);
|
||||
const tmuxSessionId = tmuxName ?? existing?.tmuxSessionId ?? resolveTmuxSession(id, this.TMUX);
|
||||
const key = this.terminalKey(id, projectId);
|
||||
const existing = this.terminals.get(key);
|
||||
const tmuxSessionId =
|
||||
tmuxName ??
|
||||
existing?.tmuxSessionId ??
|
||||
resolveTmuxSession(id, this.TMUX, undefined, undefined, projectId);
|
||||
if (!tmuxSessionId) {
|
||||
throw new Error(`Session not found: ${id}`);
|
||||
}
|
||||
|
||||
// Get or create terminal entry
|
||||
let terminal = this.terminals.get(id);
|
||||
let terminal = this.terminals.get(key);
|
||||
if (!terminal) {
|
||||
terminal = {
|
||||
id,
|
||||
|
|
@ -241,7 +242,7 @@ class TerminalManager {
|
|||
bufferBytes: 0,
|
||||
reattachAttempts: 0,
|
||||
};
|
||||
this.terminals.set(id, terminal);
|
||||
this.terminals.set(key, terminal);
|
||||
}
|
||||
|
||||
// If PTY is already attached, we're done
|
||||
|
|
@ -322,9 +323,11 @@ class TerminalManager {
|
|||
// after every attach (e.g. resource exhaustion or a broken tmux session).
|
||||
if (terminal.subscribers.size > 0 && terminal.reattachAttempts < MAX_REATTACH_ATTEMPTS) {
|
||||
terminal.reattachAttempts += 1;
|
||||
console.log(`[MuxServer] Re-attaching to ${id} (attempt ${terminal.reattachAttempts}/${MAX_REATTACH_ATTEMPTS})`);
|
||||
console.log(
|
||||
`[MuxServer] Re-attaching to ${id} (attempt ${terminal.reattachAttempts}/${MAX_REATTACH_ATTEMPTS})`,
|
||||
);
|
||||
try {
|
||||
this.open(id);
|
||||
this.open(id, projectId);
|
||||
terminal.reattachAttempts = 0; // reset on successful attach
|
||||
return; // re-attached — don't notify exit
|
||||
} catch (err) {
|
||||
|
|
@ -347,8 +350,8 @@ class TerminalManager {
|
|||
/**
|
||||
* Write data to the PTY if attached
|
||||
*/
|
||||
write(id: string, data: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
write(id: string, data: string, projectId?: string): void {
|
||||
const terminal = this.terminals.get(this.terminalKey(id, projectId));
|
||||
if (terminal?.pty) {
|
||||
terminal.pty.write(data);
|
||||
}
|
||||
|
|
@ -357,8 +360,8 @@ class TerminalManager {
|
|||
/**
|
||||
* Resize the PTY if attached
|
||||
*/
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
resize(id: string, cols: number, rows: number, projectId?: string): void {
|
||||
const terminal = this.terminals.get(this.terminalKey(id, projectId));
|
||||
if (terminal?.pty) {
|
||||
terminal.pty.resize(cols, rows);
|
||||
}
|
||||
|
|
@ -369,10 +372,16 @@ class TerminalManager {
|
|||
* Automatically opens the terminal if needed.
|
||||
* @param onExit - called when the PTY exits and cannot be re-attached
|
||||
*/
|
||||
subscribe(id: string, callback: (data: string) => void, onExit?: (exitCode: number) => void): () => void {
|
||||
subscribe(
|
||||
id: string,
|
||||
projectId: string | undefined,
|
||||
callback: (data: string) => void,
|
||||
onExit?: (exitCode: number) => void,
|
||||
): () => void {
|
||||
// Ensure terminal is open
|
||||
this.open(id);
|
||||
const terminal = this.terminals.get(id);
|
||||
this.open(id, projectId);
|
||||
const key = this.terminalKey(id, projectId);
|
||||
const terminal = this.terminals.get(key);
|
||||
if (!terminal) {
|
||||
throw new Error(`Failed to open terminal: ${id}`);
|
||||
}
|
||||
|
|
@ -391,7 +400,7 @@ class TerminalManager {
|
|||
terminal.pty.kill();
|
||||
terminal.pty = null;
|
||||
}
|
||||
this.terminals.delete(id);
|
||||
this.terminals.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -399,12 +408,11 @@ class TerminalManager {
|
|||
/**
|
||||
* Get buffered data for a terminal
|
||||
*/
|
||||
getBuffer(id: string): string {
|
||||
const terminal = this.terminals.get(id);
|
||||
getBuffer(id: string, projectId?: string): string {
|
||||
const terminal = this.terminals.get(this.terminalKey(id, projectId));
|
||||
if (!terminal) return "";
|
||||
return terminal.buffer.join("");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -455,7 +463,6 @@ export function createMuxWebSocket(tmuxPath?: string): WebSocketServer | null {
|
|||
* Handle incoming messages
|
||||
*/
|
||||
ws.on("message", (data) => {
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(data.toString("utf8")) as ClientMessage;
|
||||
|
||||
|
|
@ -466,64 +473,80 @@ export function createMuxWebSocket(tmuxPath?: string): WebSocketServer | null {
|
|||
}
|
||||
} else if (msg.ch === "terminal") {
|
||||
const { id, type } = msg;
|
||||
const projectId = "projectId" in msg ? msg.projectId : undefined;
|
||||
const subscriptionKey = projectId ? `${projectId}:${id}` : id;
|
||||
|
||||
try {
|
||||
if (type === "open") {
|
||||
// Validate session exists
|
||||
terminalManager.open(id, "tmuxName" in msg ? msg.tmuxName : undefined);
|
||||
terminalManager.open(id, projectId, "tmuxName" in msg ? msg.tmuxName : undefined);
|
||||
|
||||
// Send opened confirmation (idempotent — safe to send on re-open)
|
||||
const openedMsg: ServerMessage = { ch: "terminal", id, type: "opened" };
|
||||
const openedMsg: ServerMessage = {
|
||||
ch: "terminal",
|
||||
id,
|
||||
type: "opened",
|
||||
...(projectId && { projectId }),
|
||||
};
|
||||
ws.send(JSON.stringify(openedMsg));
|
||||
|
||||
// Subscribe and send history buffer only for new subscribers.
|
||||
// Skipping the buffer on re-open prevents duplicate output when
|
||||
// MuxProvider re-sends open for all terminals on reconnect.
|
||||
if (!subscriptions.has(id)) {
|
||||
if (!subscriptions.has(subscriptionKey)) {
|
||||
// Send buffered history to catch up the new subscriber
|
||||
const buffer = terminalManager.getBuffer(id);
|
||||
const buffer = terminalManager.getBuffer(id, projectId);
|
||||
if (buffer) {
|
||||
const bufferMsg: ServerMessage = {
|
||||
ch: "terminal",
|
||||
id,
|
||||
type: "data",
|
||||
data: buffer,
|
||||
...(projectId && { projectId }),
|
||||
};
|
||||
ws.send(JSON.stringify(bufferMsg));
|
||||
}
|
||||
const unsub = terminalManager.subscribe(
|
||||
id,
|
||||
projectId,
|
||||
(data) => {
|
||||
const dataMsg: ServerMessage = {
|
||||
ch: "terminal",
|
||||
id,
|
||||
type: "data",
|
||||
data,
|
||||
...(projectId && { projectId }),
|
||||
};
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(dataMsg));
|
||||
}
|
||||
},
|
||||
(exitCode) => {
|
||||
const exitedMsg: ServerMessage = { ch: "terminal", id, type: "exited", code: exitCode };
|
||||
const exitedMsg: ServerMessage = {
|
||||
ch: "terminal",
|
||||
id,
|
||||
type: "exited",
|
||||
code: exitCode,
|
||||
...(projectId && { projectId }),
|
||||
};
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(exitedMsg));
|
||||
}
|
||||
},
|
||||
);
|
||||
subscriptions.set(id, unsub);
|
||||
subscriptions.set(subscriptionKey, unsub);
|
||||
}
|
||||
} else if (type === "data" && "data" in msg) {
|
||||
terminalManager.write(id, msg.data);
|
||||
terminalManager.write(id, msg.data, projectId);
|
||||
} else if (type === "resize" && "cols" in msg && "rows" in msg) {
|
||||
terminalManager.resize(id, msg.cols, msg.rows);
|
||||
terminalManager.resize(id, msg.cols, msg.rows, projectId);
|
||||
} else if (type === "close") {
|
||||
// Unsubscribe this client only — TerminalManager is shared across
|
||||
// all mux connections so we must not kill the PTY here.
|
||||
const unsub = subscriptions.get(id);
|
||||
const unsub = subscriptions.get(subscriptionKey);
|
||||
if (unsub) {
|
||||
unsub();
|
||||
subscriptions.delete(id);
|
||||
subscriptions.delete(subscriptionKey);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -533,6 +556,7 @@ export function createMuxWebSocket(tmuxPath?: string): WebSocketServer | null {
|
|||
id,
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
...(projectId && { projectId }),
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,11 @@ const defaultFs: FsAdapter = {
|
|||
* it finds a live tmux session — otherwise a stale metadata dir from
|
||||
* one project could shadow the live session of another.
|
||||
*/
|
||||
function findStorageKeysForSession(sessionId: string, fs: FsAdapter): string[] {
|
||||
function findStorageKeysForSession(
|
||||
sessionId: string,
|
||||
fs: FsAdapter,
|
||||
projectId?: string,
|
||||
): string[] {
|
||||
const aoBase = join(fs.homedir(), ".agent-orchestrator");
|
||||
let entries: string[];
|
||||
try {
|
||||
|
|
@ -70,14 +74,20 @@ function findStorageKeysForSession(sessionId: string, fs: FsAdapter): string[] {
|
|||
}
|
||||
|
||||
const matches: string[] = [];
|
||||
const projectMatches: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!STORAGE_KEY_PATTERN.test(entry)) continue;
|
||||
const sessionFile = join(aoBase, entry, "sessions", sessionId);
|
||||
if (fs.exists(sessionFile)) {
|
||||
matches.push(entry);
|
||||
const unwrappedProjectId = entry.slice(13); // Strip "{hash}-" prefix when present.
|
||||
if (projectId && (entry === projectId || unwrappedProjectId === projectId)) {
|
||||
projectMatches.push(entry);
|
||||
} else {
|
||||
matches.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
return [...projectMatches, ...matches];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -146,6 +156,7 @@ export function resolveTmuxSession(
|
|||
tmuxPath: string,
|
||||
execFn: typeof execFileSync = execFileSync,
|
||||
fs: FsAdapter = defaultFs,
|
||||
projectId?: string,
|
||||
): string | null {
|
||||
// Try exact match first using = prefix for exact matching (e.g., "ao-orchestrator")
|
||||
// Without =, tmux uses prefix matching: "ao-1" would match "ao-15"
|
||||
|
|
@ -161,7 +172,7 @@ export function resolveTmuxSession(
|
|||
// even when the storageKey is wrapped (`{hash}-{projectName}`). Walk
|
||||
// every candidate so a stale metadata dir in one project can't shadow
|
||||
// the live session of another project with the same sessionId.
|
||||
for (const storageKey of findStorageKeysForSession(sessionId, fs)) {
|
||||
for (const storageKey of findStorageKeysForSession(sessionId, fs, projectId)) {
|
||||
const tmuxName = `${storageKey}-${sessionId}`;
|
||||
try {
|
||||
execFn(tmuxPath, ["has-session", "-t", `=${tmuxName}`], { timeout: 5000 });
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export { resolveMonoFontFamily } from "./terminal/terminal-font";
|
|||
|
||||
interface DirectTerminalProps {
|
||||
sessionId: string;
|
||||
projectId?: string;
|
||||
/** Actual tmux session name. When provided, the terminal server uses it directly instead of resolving from sessionId. */
|
||||
tmuxName?: string;
|
||||
startFullscreen?: boolean;
|
||||
|
|
@ -38,6 +39,7 @@ interface DirectTerminalProps {
|
|||
*/
|
||||
export function DirectTerminal({
|
||||
sessionId,
|
||||
projectId,
|
||||
tmuxName,
|
||||
startFullscreen = false,
|
||||
variant = "agent",
|
||||
|
|
@ -69,10 +71,11 @@ export function DirectTerminal({
|
|||
variant,
|
||||
fontSize,
|
||||
autoFocus,
|
||||
projectId,
|
||||
tmuxName,
|
||||
});
|
||||
|
||||
useFullscreenResize(fullscreen, sessionId, terminalInstance, fitAddon, terminalRef);
|
||||
useFullscreenResize(fullscreen, sessionId, projectId, terminalInstance, fitAddon, terminalRef);
|
||||
|
||||
// Sync fullscreen to URL query param
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ export function SessionDetail({
|
|||
) : (
|
||||
<DirectTerminal
|
||||
sessionId={session.id}
|
||||
projectId={session.projectId}
|
||||
tmuxName={session.metadata?.tmuxName}
|
||||
startFullscreen={startFullscreen}
|
||||
variant={terminalVariant}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { DirectTerminal } from "../DirectTerminal";
|
|||
|
||||
const replaceMock = vi.fn();
|
||||
let searchParams = new URLSearchParams();
|
||||
const { useFullscreenResizeMock } = vi.hoisted(() => ({
|
||||
useFullscreenResizeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: replaceMock }),
|
||||
|
|
@ -15,6 +18,10 @@ vi.mock("next-themes", () => ({
|
|||
useTheme: () => ({ resolvedTheme: "dark" }),
|
||||
}));
|
||||
|
||||
vi.mock("../terminal/useFullscreenResize", () => ({
|
||||
useFullscreenResize: useFullscreenResizeMock,
|
||||
}));
|
||||
|
||||
class MockTerminal {
|
||||
options: Record<string, unknown>;
|
||||
parser = {
|
||||
|
|
@ -105,6 +112,7 @@ describe("DirectTerminal render", () => {
|
|||
beforeEach(() => {
|
||||
searchParams = new URLSearchParams();
|
||||
replaceMock.mockReset();
|
||||
useFullscreenResizeMock.mockReset();
|
||||
MockWebSocket.instances = [];
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
|
|
@ -186,4 +194,17 @@ describe("DirectTerminal render", () => {
|
|||
expect(terminalShell).toHaveClass("relative");
|
||||
expect(terminalShell).not.toHaveClass("fixed");
|
||||
});
|
||||
|
||||
it("passes projectId to fullscreen resize hook for scoped mux resize", () => {
|
||||
render(<DirectTerminal sessionId="app-1" projectId="project-a" />);
|
||||
|
||||
expect(useFullscreenResizeMock).toHaveBeenCalledWith(
|
||||
false,
|
||||
"app-1",
|
||||
"project-a",
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useMux } from "@/hooks/useMux";
|
|||
export function useFullscreenResize(
|
||||
fullscreen: boolean,
|
||||
sessionId: string,
|
||||
projectId: string | undefined,
|
||||
terminalInstance: RefObject<TerminalType | null>,
|
||||
fitAddon: RefObject<FitAddonType | null>,
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
|
|
@ -56,7 +57,7 @@ export function useFullscreenResize(
|
|||
fit.fit();
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows);
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows, projectId);
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(resizeTerminal);
|
||||
|
|
@ -96,5 +97,5 @@ export function useFullscreenResize(
|
|||
clearTimeout(timer1);
|
||||
clearTimeout(timer2);
|
||||
};
|
||||
}, [fullscreen, sessionId, resizeTerminalMux, containerRef, fitAddon, terminalInstance]);
|
||||
}, [fullscreen, sessionId, projectId, resizeTerminalMux, containerRef, fitAddon, terminalInstance]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface UseXtermTerminalOptions {
|
|||
variant: TerminalVariant;
|
||||
fontSize: number;
|
||||
autoFocus: boolean;
|
||||
projectId?: string;
|
||||
/** Actual tmux session name. When provided, the terminal server uses it directly instead of resolving from sessionId. */
|
||||
tmuxName?: string;
|
||||
}
|
||||
|
|
@ -48,7 +49,7 @@ export function useXtermTerminal(
|
|||
sessionId: string,
|
||||
options: UseXtermTerminalOptions,
|
||||
): UseXtermTerminalResult {
|
||||
const { appearance, variant, fontSize, autoFocus, tmuxName } = options;
|
||||
const { appearance, variant, fontSize, autoFocus, projectId, tmuxName } = options;
|
||||
const { resolvedTheme } = useTheme();
|
||||
const terminalThemes = useMemo(() => buildTerminalThemes(variant), [variant]);
|
||||
const {
|
||||
|
|
@ -131,7 +132,7 @@ export function useXtermTerminal(
|
|||
if (mounted && fitAddon.current) {
|
||||
try {
|
||||
fitAddon.current.fit();
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows);
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows, projectId);
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
|
|
@ -148,14 +149,18 @@ export function useXtermTerminal(
|
|||
terminalInstance.current.options.fontFamily = resolveMonoFontFamily();
|
||||
terminalInstance.current.clearTextureAtlas?.();
|
||||
fitAddon.current.fit();
|
||||
resizeTerminalMux(sessionId, terminalInstance.current.cols, terminalInstance.current.rows);
|
||||
resizeTerminalMux(
|
||||
sessionId,
|
||||
terminalInstance.current.cols,
|
||||
terminalInstance.current.rows,
|
||||
projectId,
|
||||
);
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
};
|
||||
// Feature-detect addEventListener — jsdom's document.fonts mock lacks it.
|
||||
const fontsFace =
|
||||
typeof document !== "undefined" ? document.fonts : undefined;
|
||||
const fontsFace = typeof document !== "undefined" ? document.fonts : undefined;
|
||||
const fontsListenerAttached =
|
||||
!!fontsFace && typeof fontsFace.addEventListener === "function";
|
||||
if (fontsListenerAttached) {
|
||||
|
|
@ -168,12 +173,18 @@ export function useXtermTerminal(
|
|||
// In alternate buffer (tmux) there is no way to detect when the user
|
||||
// returned to the live tail, so the jump-to-latest button stays visible
|
||||
// until clicked.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const cleanupTouchScroll = attachTouchScroll(terminal as any, (data) => {
|
||||
writeTerminal(sessionId, data);
|
||||
}, {
|
||||
onScrollAway: () => { followOutputRef.current = false; setFollowOutput(false); },
|
||||
});
|
||||
const cleanupTouchScroll = attachTouchScroll(
|
||||
terminal,
|
||||
(data) => {
|
||||
writeTerminal(sessionId, data, projectId);
|
||||
},
|
||||
{
|
||||
onScrollAway: () => {
|
||||
followOutputRef.current = false;
|
||||
setFollowOutput(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (terminalRef.current) {
|
||||
|
|
@ -181,7 +192,7 @@ export function useXtermTerminal(
|
|||
if (mounted && fitAddon.current) {
|
||||
try {
|
||||
fitAddon.current.fit();
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows);
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows, projectId);
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
|
|
@ -234,24 +245,28 @@ export function useXtermTerminal(
|
|||
// hazard if subscribeTerminal ever fires synchronously.
|
||||
let programmaticScroll = false;
|
||||
|
||||
openTerminal(sessionId, tmuxName);
|
||||
openTerminal(sessionId, projectId, tmuxName);
|
||||
|
||||
unsubscribe = subscribeTerminal(sessionId, (data) => {
|
||||
if (selectionActive) {
|
||||
writeBuffer.push(data);
|
||||
bufferBytes += data.length;
|
||||
if (bufferBytes > MAX_BUFFER_BYTES) {
|
||||
selectionActive = false;
|
||||
flushWriteBuffer();
|
||||
unsubscribe = subscribeTerminal(
|
||||
sessionId,
|
||||
(data) => {
|
||||
if (selectionActive) {
|
||||
writeBuffer.push(data);
|
||||
bufferBytes += data.length;
|
||||
if (bufferBytes > MAX_BUFFER_BYTES) {
|
||||
selectionActive = false;
|
||||
flushWriteBuffer();
|
||||
}
|
||||
} else {
|
||||
terminal.write(data);
|
||||
if (followOutputRef.current) {
|
||||
programmaticScroll = true;
|
||||
terminal.scrollToBottom();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
terminal.write(data);
|
||||
if (followOutputRef.current) {
|
||||
programmaticScroll = true;
|
||||
terminal.scrollToBottom();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
|
||||
// Use xterm's onScroll event (fires with new viewportY) instead of a DOM
|
||||
// scroll listener — xterm v6 may update scrollTop via RAF, making DOM
|
||||
|
|
@ -275,16 +290,16 @@ export function useXtermTerminal(
|
|||
const handleResize = () => {
|
||||
if (fit) {
|
||||
fit.fit();
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows);
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows, projectId);
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
inputDisposable = terminal.onData((data) => {
|
||||
writeTerminal(sessionId, data);
|
||||
writeTerminal(sessionId, data, projectId);
|
||||
});
|
||||
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows);
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows, projectId);
|
||||
|
||||
cleanup = () => {
|
||||
clearTimeout(deferredFitTimeout);
|
||||
|
|
@ -300,7 +315,7 @@ export function useXtermTerminal(
|
|||
inputDisposable?.dispose();
|
||||
inputDisposable = null;
|
||||
unsubscribe?.();
|
||||
closeTerminal(sessionId);
|
||||
closeTerminal(sessionId, projectId);
|
||||
terminal.dispose();
|
||||
};
|
||||
})
|
||||
|
|
@ -320,6 +335,7 @@ export function useXtermTerminal(
|
|||
}, [
|
||||
appearance,
|
||||
sessionId,
|
||||
projectId,
|
||||
tmuxName,
|
||||
variant,
|
||||
resolvedTheme,
|
||||
|
|
@ -339,8 +355,8 @@ export function useXtermTerminal(
|
|||
const terminal = terminalInstance.current;
|
||||
if (!fit || !terminal) return;
|
||||
fit.fit();
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows);
|
||||
}, [muxStatus, sessionId, resizeTerminalMux]);
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows, projectId);
|
||||
}, [muxStatus, sessionId, projectId, resizeTerminalMux]);
|
||||
|
||||
// Live theme switching without terminal recreation
|
||||
useEffect(() => {
|
||||
|
|
@ -364,8 +380,8 @@ export function useXtermTerminal(
|
|||
// localStorage might be unavailable
|
||||
}
|
||||
fit.fit();
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows);
|
||||
}, [fontSize, sessionId, resizeTerminalMux]);
|
||||
resizeTerminalMux(sessionId, terminal.cols, terminal.rows, projectId);
|
||||
}, [fontSize, sessionId, projectId, resizeTerminalMux]);
|
||||
|
||||
const scrollToLatest = () => {
|
||||
const t = terminalInstance.current;
|
||||
|
|
@ -377,7 +393,7 @@ export function useXtermTerminal(
|
|||
// Alternate buffer (tmux/vim): xterm has no scrollback to scroll
|
||||
// to. The user is in tmux copy-mode (entered by attachTouchScroll
|
||||
// on swipe). Send 'q' to exit copy-mode and return to live tail.
|
||||
writeTerminal(sessionId, "q");
|
||||
writeTerminal(sessionId, "q", projectId);
|
||||
}
|
||||
}
|
||||
followOutputRef.current = true;
|
||||
|
|
|
|||
|
|
@ -3,20 +3,20 @@ import type { AttentionLevel } from "./types";
|
|||
// ── Client → Server ──
|
||||
|
||||
export type ClientMessage =
|
||||
| { ch: "terminal"; id: string; type: "data"; data: string }
|
||||
| { ch: "terminal"; id: string; type: "resize"; cols: number; rows: number }
|
||||
| { ch: "terminal"; id: string; type: "open"; tmuxName?: string }
|
||||
| { ch: "terminal"; id: string; type: "close" }
|
||||
| { ch: "terminal"; id: string; type: "data"; data: string; projectId?: string }
|
||||
| { ch: "terminal"; id: string; type: "resize"; cols: number; rows: number; projectId?: string }
|
||||
| { ch: "terminal"; id: string; type: "open"; projectId?: string; tmuxName?: string }
|
||||
| { ch: "terminal"; id: string; type: "close"; projectId?: string }
|
||||
| { ch: "system"; type: "ping" }
|
||||
| { ch: "subscribe"; topics: ("sessions")[] };
|
||||
| { ch: "subscribe"; topics: "sessions"[] };
|
||||
|
||||
// ── Server → Client ──
|
||||
|
||||
export type ServerMessage =
|
||||
| { ch: "terminal"; id: string; type: "data"; data: string }
|
||||
| { ch: "terminal"; id: string; type: "exited"; code: number }
|
||||
| { ch: "terminal"; id: string; type: "opened" }
|
||||
| { ch: "terminal"; id: string; type: "error"; message: string }
|
||||
| { ch: "terminal"; id: string; type: "data"; data: string; projectId?: string }
|
||||
| { ch: "terminal"; id: string; type: "exited"; code: number; projectId?: string }
|
||||
| { ch: "terminal"; id: string; type: "opened"; projectId?: string }
|
||||
| { ch: "terminal"; id: string; type: "error"; message: string; projectId?: string }
|
||||
| { ch: "sessions"; type: "snapshot"; sessions: SessionPatch[] }
|
||||
| { ch: "sessions"; type: "error"; error: string }
|
||||
| { ch: "system"; type: "pong" }
|
||||
|
|
|
|||
|
|
@ -4,11 +4,15 @@ import React, { useEffect, useRef, useState, useMemo, useCallback, type ReactNod
|
|||
import type { ClientMessage, ServerMessage, SessionPatch } from "@/lib/mux-protocol";
|
||||
|
||||
interface MuxContextValue {
|
||||
subscribeTerminal: (id: string, callback: (data: string) => void) => () => void;
|
||||
writeTerminal: (id: string, data: string) => void;
|
||||
openTerminal: (id: string, tmuxName?: string) => void;
|
||||
closeTerminal: (id: string) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number) => void;
|
||||
subscribeTerminal: (
|
||||
id: string,
|
||||
callback: (data: string) => void,
|
||||
projectId?: string,
|
||||
) => () => void;
|
||||
writeTerminal: (id: string, data: string, projectId?: string) => void;
|
||||
openTerminal: (id: string, projectId?: string, tmuxName?: string) => void;
|
||||
closeTerminal: (id: string, projectId?: string) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number, projectId?: string) => void;
|
||||
status: "connecting" | "connected" | "reconnecting" | "disconnected";
|
||||
sessions: SessionPatch[];
|
||||
/** Last session-fetch error from the server, null when healthy. */
|
||||
|
|
@ -49,7 +53,10 @@ function normalizePathValue(value: unknown): string | undefined {
|
|||
return trimmed;
|
||||
}
|
||||
|
||||
function buildMuxWsUrl(runtimeConfig: { directTerminalPort?: string; proxyWsPath?: string }): string {
|
||||
function buildMuxWsUrl(runtimeConfig: {
|
||||
directTerminalPort?: string;
|
||||
proxyWsPath?: string;
|
||||
}): string {
|
||||
const loc = window.location;
|
||||
const protocol = loc.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
|
|
@ -66,17 +73,24 @@ function buildMuxWsUrl(runtimeConfig: { directTerminalPort?: string; proxyWsPath
|
|||
}
|
||||
|
||||
// Direct port connection — prefer runtime-configured port, fall back to env/default
|
||||
const port = runtimeConfig.directTerminalPort ?? process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT ?? "14801";
|
||||
const port =
|
||||
runtimeConfig.directTerminalPort ?? process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT ?? "14801";
|
||||
return `${protocol}//${loc.hostname}:${port}/mux`;
|
||||
}
|
||||
|
||||
function terminalKey(id: string, projectId?: string): string {
|
||||
return projectId ? `${projectId}:${id}` : id;
|
||||
}
|
||||
|
||||
export function MuxProvider({ children }: { children: ReactNode }) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const subscribersRef = useRef(new Map<string, Set<(data: string) => void>>());
|
||||
const openedTerminalsRef = useRef(new Map<string, string | undefined>());
|
||||
const [status, setStatus] = useState<"connecting" | "connected" | "reconnecting" | "disconnected">(
|
||||
"connecting",
|
||||
const openedTerminalsRef = useRef(
|
||||
new Map<string, { id: string; projectId?: string; tmuxName?: string }>(),
|
||||
);
|
||||
const [status, setStatus] = useState<
|
||||
"connecting" | "connected" | "reconnecting" | "disconnected"
|
||||
>("connecting");
|
||||
const [sessions, setSessions] = useState<SessionPatch[]>([]);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
const reconnectAttempt = useRef(0);
|
||||
|
|
@ -108,12 +122,13 @@ export function MuxProvider({ children }: { children: ReactNode }) {
|
|||
reconnectAttempt.current = 0;
|
||||
|
||||
// Re-open previously opened terminals
|
||||
for (const [terminalId, tmuxName] of openedTerminalsRef.current) {
|
||||
for (const terminal of openedTerminalsRef.current.values()) {
|
||||
const openMsg: ClientMessage = {
|
||||
ch: "terminal",
|
||||
id: terminalId,
|
||||
id: terminal.id,
|
||||
type: "open",
|
||||
...(tmuxName && { tmuxName }),
|
||||
...(terminal.projectId && { projectId: terminal.projectId }),
|
||||
...(terminal.tmuxName && { tmuxName: terminal.tmuxName }),
|
||||
};
|
||||
ws.send(JSON.stringify(openMsg));
|
||||
}
|
||||
|
|
@ -131,9 +146,10 @@ export function MuxProvider({ children }: { children: ReactNode }) {
|
|||
const msg = JSON.parse(event.data as string) as ServerMessage;
|
||||
|
||||
if (msg.ch === "terminal") {
|
||||
const key = terminalKey(msg.id, "projectId" in msg ? msg.projectId : undefined);
|
||||
if (msg.type === "data") {
|
||||
// Push to subscribers
|
||||
const subs = subscribersRef.current.get(msg.id);
|
||||
const subs = subscribersRef.current.get(key);
|
||||
if (subs) {
|
||||
for (const callback of subs) {
|
||||
callback(msg.data);
|
||||
|
|
@ -141,14 +157,17 @@ export function MuxProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
} else if (msg.type === "opened") {
|
||||
// Terminal opened successfully — preserve existing tmuxName
|
||||
if (!openedTerminalsRef.current.has(msg.id)) {
|
||||
openedTerminalsRef.current.set(msg.id, undefined);
|
||||
if (!openedTerminalsRef.current.has(key)) {
|
||||
openedTerminalsRef.current.set(key, {
|
||||
id: msg.id,
|
||||
...("projectId" in msg && msg.projectId ? { projectId: msg.projectId } : {}),
|
||||
});
|
||||
}
|
||||
} else if (msg.type === "exited") {
|
||||
// PTY exited and could not be re-attached — remove so it isn't
|
||||
// re-opened on reconnect, and surface a terminal-level error chunk
|
||||
openedTerminalsRef.current.delete(msg.id);
|
||||
const subs = subscribersRef.current.get(msg.id);
|
||||
openedTerminalsRef.current.delete(key);
|
||||
const subs = subscribersRef.current.get(key);
|
||||
if (subs) {
|
||||
const notice = `\r\n\x1b[31m[Terminal exited with code ${msg.code}]\x1b[0m\r\n`;
|
||||
for (const callback of subs) {
|
||||
|
|
@ -235,32 +254,34 @@ export function MuxProvider({ children }: { children: ReactNode }) {
|
|||
}, [connect]);
|
||||
|
||||
const subscribeTerminal = useCallback(
|
||||
(id: string, callback: (data: string) => void): (() => void) => {
|
||||
(id: string, callback: (data: string) => void, projectId?: string): (() => void) => {
|
||||
const key = terminalKey(id, projectId);
|
||||
// Add to subscribers
|
||||
let subs = subscribersRef.current.get(id);
|
||||
let subs = subscribersRef.current.get(key);
|
||||
if (!subs) {
|
||||
subs = new Set();
|
||||
subscribersRef.current.set(id, subs);
|
||||
subscribersRef.current.set(key, subs);
|
||||
}
|
||||
subs.add(callback);
|
||||
|
||||
// Request open if not already open
|
||||
if (!openedTerminalsRef.current.has(id) && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
if (!openedTerminalsRef.current.has(key) && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const openMsg: ClientMessage = {
|
||||
ch: "terminal",
|
||||
id,
|
||||
type: "open",
|
||||
...(projectId && { projectId }),
|
||||
};
|
||||
wsRef.current.send(JSON.stringify(openMsg));
|
||||
}
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const subs = subscribersRef.current.get(id);
|
||||
const subs = subscribersRef.current.get(key);
|
||||
if (subs) {
|
||||
subs.delete(callback);
|
||||
if (subs.size === 0) {
|
||||
subscribersRef.current.delete(id);
|
||||
subscribersRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -268,55 +289,62 @@ export function MuxProvider({ children }: { children: ReactNode }) {
|
|||
[],
|
||||
);
|
||||
|
||||
const writeTerminal = useCallback((id: string, data: string) => {
|
||||
const writeTerminal = useCallback((id: string, data: string, projectId?: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const msg: ClientMessage = {
|
||||
ch: "terminal",
|
||||
id,
|
||||
type: "data",
|
||||
data,
|
||||
...(projectId && { projectId }),
|
||||
};
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openTerminal = useCallback((id: string, tmuxName?: string) => {
|
||||
openedTerminalsRef.current.set(id, tmuxName);
|
||||
const openTerminal = useCallback((id: string, projectId?: string, tmuxName?: string) => {
|
||||
openedTerminalsRef.current.set(terminalKey(id, projectId), { id, projectId, tmuxName });
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const msg: ClientMessage = {
|
||||
ch: "terminal",
|
||||
id,
|
||||
type: "open",
|
||||
...(projectId && { projectId }),
|
||||
...(tmuxName && { tmuxName }),
|
||||
};
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeTerminal = useCallback((id: string) => {
|
||||
openedTerminalsRef.current.delete(id);
|
||||
const closeTerminal = useCallback((id: string, projectId?: string) => {
|
||||
openedTerminalsRef.current.delete(terminalKey(id, projectId));
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const msg: ClientMessage = {
|
||||
ch: "terminal",
|
||||
id,
|
||||
type: "close",
|
||||
...(projectId && { projectId }),
|
||||
};
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resizeTerminal = useCallback((id: string, cols: number, rows: number) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const msg: ClientMessage = {
|
||||
ch: "terminal",
|
||||
id,
|
||||
type: "resize",
|
||||
cols,
|
||||
rows,
|
||||
};
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
}
|
||||
}, []);
|
||||
const resizeTerminal = useCallback(
|
||||
(id: string, cols: number, rows: number, projectId?: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const msg: ClientMessage = {
|
||||
ch: "terminal",
|
||||
id,
|
||||
type: "resize",
|
||||
cols,
|
||||
rows,
|
||||
...(projectId && { projectId }),
|
||||
};
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const contextValue: MuxContextValue = useMemo(
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -244,7 +244,12 @@ describe("MuxProvider connection lifecycle", () => {
|
|||
});
|
||||
|
||||
it("falls back gracefully when fetch fails", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network"); }));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("network");
|
||||
}),
|
||||
);
|
||||
renderHook(() => useMux(), { wrapper });
|
||||
await flushInit();
|
||||
// Still creates a WebSocket (using defaults)
|
||||
|
|
@ -252,8 +257,16 @@ describe("MuxProvider connection lifecycle", () => {
|
|||
});
|
||||
|
||||
it("sets disconnected status when WebSocket constructor throws", async () => {
|
||||
vi.stubGlobal("WebSocket", vi.fn(() => { throw new Error("unavailable"); }));
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false })));
|
||||
vi.stubGlobal(
|
||||
"WebSocket",
|
||||
vi.fn(() => {
|
||||
throw new Error("unavailable");
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false })),
|
||||
);
|
||||
const { result } = renderHook(() => useMux(), { wrapper });
|
||||
await flushInit();
|
||||
expect(result.current.status).toBe("disconnected");
|
||||
|
|
@ -271,10 +284,12 @@ describe("MuxProvider connection lifecycle", () => {
|
|||
|
||||
act(() => result.current.openTerminal("session-abc"));
|
||||
// Confirm open message sent on ws1
|
||||
expect(ws1.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "open" && p.id === "session-abc";
|
||||
})).toBe(true);
|
||||
expect(
|
||||
ws1.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "open" && p.id === "session-abc";
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
// Disconnect + reconnect
|
||||
act(() => ws1.simulateClose());
|
||||
|
|
@ -286,10 +301,12 @@ describe("MuxProvider connection lifecycle", () => {
|
|||
act(() => ws2.simulateOpen());
|
||||
|
||||
// session-abc should be re-opened on ws2
|
||||
expect(ws2.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "open" && p.id === "session-abc";
|
||||
})).toBe(true);
|
||||
expect(
|
||||
ws2.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "open" && p.id === "session-abc";
|
||||
}),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
|
@ -329,7 +346,9 @@ describe("MuxProvider message handling", () => {
|
|||
const { result, ws } = await setupConnected();
|
||||
|
||||
const received: string[] = [];
|
||||
act(() => { result.current.subscribeTerminal("s1", (d) => received.push(d)); });
|
||||
act(() => {
|
||||
result.current.subscribeTerminal("s1", (d) => received.push(d));
|
||||
});
|
||||
act(() => ws.simulateMessage({ ch: "terminal", id: "s1", type: "data", data: "hello" }));
|
||||
|
||||
expect(received).toContain("hello");
|
||||
|
|
@ -354,10 +373,12 @@ describe("MuxProvider message handling", () => {
|
|||
const ws2 = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||
act(() => ws2.simulateOpen());
|
||||
|
||||
expect(ws2.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.id === "s1";
|
||||
})).toBe(true);
|
||||
expect(
|
||||
ws2.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.id === "s1";
|
||||
}),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
|
@ -367,7 +388,9 @@ describe("MuxProvider message handling", () => {
|
|||
const { result, ws } = await setupConnected();
|
||||
|
||||
const received: string[] = [];
|
||||
act(() => { result.current.subscribeTerminal("s1", (d) => received.push(d)); });
|
||||
act(() => {
|
||||
result.current.subscribeTerminal("s1", (d) => received.push(d));
|
||||
});
|
||||
act(() => ws.simulateMessage({ ch: "terminal", id: "s1", type: "exited", code: 1 }));
|
||||
|
||||
expect(received.some((m) => m.includes("exited"))).toBe(true);
|
||||
|
|
@ -407,7 +430,9 @@ describe("MuxProvider message handling", () => {
|
|||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { result, ws } = await setupConnected();
|
||||
|
||||
act(() => ws.simulateMessage({ ch: "terminal", id: "s1", type: "error", message: "PTY failed" }));
|
||||
act(() =>
|
||||
ws.simulateMessage({ ch: "terminal", id: "s1", type: "error", message: "PTY failed" }),
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining("Terminal error"), expect.any(String));
|
||||
expect(result.current.status).toBe("connected");
|
||||
spy.mockRestore();
|
||||
|
|
@ -416,11 +441,21 @@ describe("MuxProvider message handling", () => {
|
|||
it("updates sessions on snapshot message", async () => {
|
||||
const { result, ws } = await setupConnected();
|
||||
|
||||
act(() => ws.simulateMessage({
|
||||
ch: "sessions",
|
||||
type: "snapshot",
|
||||
sessions: [{ id: "s1", status: "working", activity: null, attentionLevel: "working", lastActivityAt: "" }],
|
||||
}));
|
||||
act(() =>
|
||||
ws.simulateMessage({
|
||||
ch: "sessions",
|
||||
type: "snapshot",
|
||||
sessions: [
|
||||
{
|
||||
id: "s1",
|
||||
status: "working",
|
||||
activity: null,
|
||||
attentionLevel: "working",
|
||||
lastActivityAt: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sessions.length).toBe(1);
|
||||
expect(result.current.sessions[0].id).toBe("s1");
|
||||
|
|
@ -458,10 +493,12 @@ describe("MuxProvider terminal operations", () => {
|
|||
it("writeTerminal sends data message", async () => {
|
||||
const { result, ws } = await setupConnected();
|
||||
act(() => result.current.writeTerminal("s1", "hello\n"));
|
||||
expect(ws.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "data" && p.data === "hello\n";
|
||||
})).toBe(true);
|
||||
expect(
|
||||
ws.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "data" && p.data === "hello\n";
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("writeTerminal is no-op when WebSocket is not open", async () => {
|
||||
|
|
@ -475,38 +512,48 @@ describe("MuxProvider terminal operations", () => {
|
|||
it("openTerminal sends open message when connected", async () => {
|
||||
const { result, ws } = await setupConnected();
|
||||
act(() => result.current.openTerminal("session-abc"));
|
||||
expect(ws.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "open" && p.id === "session-abc";
|
||||
})).toBe(true);
|
||||
expect(
|
||||
ws.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "open" && p.id === "session-abc";
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("closeTerminal sends close message", async () => {
|
||||
const { result, ws } = await setupConnected();
|
||||
act(() => result.current.openTerminal("session-abc"));
|
||||
act(() => result.current.closeTerminal("session-abc"));
|
||||
expect(ws.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "close" && p.id === "session-abc";
|
||||
})).toBe(true);
|
||||
expect(
|
||||
ws.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "close" && p.id === "session-abc";
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("resizeTerminal sends resize message with cols and rows", async () => {
|
||||
const { result, ws } = await setupConnected();
|
||||
act(() => result.current.resizeTerminal("session-abc", 120, 40));
|
||||
expect(ws.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "resize" && p.cols === 120 && p.rows === 40;
|
||||
})).toBe(true);
|
||||
expect(
|
||||
ws.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "resize" && p.cols === 120 && p.rows === 40;
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("subscribeTerminal sends open for untracked terminal", async () => {
|
||||
const { result, ws } = await setupConnected();
|
||||
act(() => { result.current.subscribeTerminal("session-new", () => {}); });
|
||||
expect(ws.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "open" && p.id === "session-new";
|
||||
})).toBe(true);
|
||||
act(() => {
|
||||
result.current.subscribeTerminal("session-new", () => {});
|
||||
});
|
||||
expect(
|
||||
ws.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return p.ch === "terminal" && p.type === "open" && p.id === "session-new";
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("subscribeTerminal unsubscribe stops data delivery", async () => {
|
||||
|
|
@ -514,7 +561,9 @@ describe("MuxProvider terminal operations", () => {
|
|||
const received: string[] = [];
|
||||
let unsub!: () => void;
|
||||
|
||||
act(() => { unsub = result.current.subscribeTerminal("s1", (d) => received.push(d)); });
|
||||
act(() => {
|
||||
unsub = result.current.subscribeTerminal("s1", (d) => received.push(d));
|
||||
});
|
||||
act(() => ws.simulateMessage({ ch: "terminal", id: "s1", type: "data", data: "before" }));
|
||||
act(() => unsub());
|
||||
act(() => ws.simulateMessage({ ch: "terminal", id: "s1", type: "data", data: "after" }));
|
||||
|
|
@ -522,6 +571,55 @@ describe("MuxProvider terminal operations", () => {
|
|||
expect(received).toContain("before");
|
||||
expect(received).not.toContain("after");
|
||||
});
|
||||
|
||||
it("keeps terminal subscribers scoped by project id", async () => {
|
||||
const { result, ws } = await setupConnected();
|
||||
const alpha: string[] = [];
|
||||
const beta: string[] = [];
|
||||
|
||||
act(() => {
|
||||
result.current.subscribeTerminal("app-1", (data) => alpha.push(data), "alpha");
|
||||
result.current.subscribeTerminal("app-1", (data) => beta.push(data), "beta");
|
||||
});
|
||||
|
||||
act(() =>
|
||||
ws.simulateMessage({
|
||||
ch: "terminal",
|
||||
id: "app-1",
|
||||
projectId: "alpha",
|
||||
type: "data",
|
||||
data: "a",
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
ws.simulateMessage({
|
||||
ch: "terminal",
|
||||
id: "app-1",
|
||||
projectId: "beta",
|
||||
type: "data",
|
||||
data: "b",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(alpha).toEqual(["a"]);
|
||||
expect(beta).toEqual(["b"]);
|
||||
expect(
|
||||
ws.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return (
|
||||
p.ch === "terminal" && p.type === "open" && p.id === "app-1" && p.projectId === "alpha"
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
ws.sentMessages.some((m) => {
|
||||
const p = JSON.parse(m) as Record<string, unknown>;
|
||||
return (
|
||||
p.ch === "terminal" && p.type === "open" && p.id === "app-1" && p.projectId === "beta"
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -548,7 +646,10 @@ describe("buildMuxWsUrl", () => {
|
|||
});
|
||||
|
||||
it("uses path-based URL when port is empty (reverse proxy)", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false })));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false })),
|
||||
);
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { protocol: "http:", host: "localhost", hostname: "localhost", port: "" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue