Compare commits

..

2 Commits

Author SHA1 Message Date
nntrivi2001 b0bfc66d65 fix(workspace): auto-set cwd when a lane is selected from the strip
Selecting a lane card only updated selectedLaneId, leaving the run-setup
cwd field on whatever it was before — now selecting a lane also syncs
cwd to that lane's own working directory.
2026-08-13 13:55:51 +07:00
nntrivi2001 78fb82b257 fix(run): decode binary WS frames in TerminalView so pty output actually renders
Server streams PTY output as binary WS frames, but the browser's default
binaryType ("blob") handed onmessage a Blob that never matched the
`typeof === "string"` check — every keystroke response was silently
dropped and the terminal stayed blank despite the backend streaming
correctly (verified via a raw ws client against the live tmux session).
2026-08-13 13:54:19 +07:00
3 changed files with 25 additions and 8 deletions
+12 -7
View File
@@ -29,18 +29,23 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
fit.fit(); fit.fit();
const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`); const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
// Server sends PTY bytes as binary frames — default binaryType ("blob")
// would hand onmessage a Blob that the string checks below never match,
// silently dropping all terminal output. "arraybuffer" keeps it sync.
ws.binaryType = "arraybuffer";
const decoder = new TextDecoder();
ws.onopen = () => { ws.onopen = () => {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (typeof event.data === "string") { const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
// Binary PTY output arrives as text here too (the browser WS API const data = isArrayBuffer ? decoder.decode(event.data as ArrayBuffer) : event.data;
// decodes non-Blob/ArrayBuffer frames as strings) — a JSON control if (typeof data === "string") {
// frame is the only thing that starts with `{"type"`. // A JSON control frame is the only thing that starts with `{"type"`.
if (event.data.startsWith('{"type"')) { if (data.startsWith('{"type"')) {
try { try {
const msg = JSON.parse(event.data); const msg = JSON.parse(data);
if (msg.type === "exit") { if (msg.type === "exit") {
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`); term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
} }
@@ -49,7 +54,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
/* not JSON — fall through and render as PTY output */ /* not JSON — fall through and render as PTY output */
} }
} }
term.write(event.data); term.write(data);
} }
}; };
@@ -75,6 +75,15 @@ describe("TerminalView", () => {
expect(writeMock).toHaveBeenCalledWith("hello"); expect(writeMock).toHaveBeenCalledWith("hello");
}); });
it("decodes binary ArrayBuffer frames (server sends PTY output as binary)", () => {
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
const bytes = new TextEncoder().encode("hello-binary").buffer;
ws.onmessage?.({ data: bytes });
expect(writeMock).toHaveBeenCalledWith("hello-binary");
});
it("forwards terminal keystrokes as outgoing WS sends", () => { it("forwards terminal keystrokes as outgoing WS sends", () => {
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />); render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!; const ws = MockWebSocket.instances[0]!;
+4 -1
View File
@@ -894,7 +894,10 @@ export function Workspace() {
key={l.id} key={l.id}
lane={l} lane={l}
selected={selectedLaneId === l.id} selected={selectedLaneId === l.id}
onSelect={() => setSelectedLaneId(l.id)} onSelect={() => {
setSelectedLaneId(l.id);
setCwd(l.cwd);
}}
/> />
))} ))}
{!lanes.length && ( {!lanes.length && (