FE portion of seamless clipboard transfer in browser stream (#3799)

This commit is contained in:
Jonathan Dobson
2025-10-23 09:32:10 -04:00
committed by GitHub
parent 2852e1c7cf
commit f66f50fc93

View File

@@ -39,8 +39,31 @@ interface CommandCedeControl {
kind: "cede-control"; kind: "cede-control";
} }
// a "Command" is an fire-n-forget out-message - it does not require a response
type Command = CommandTakeControl | CommandCedeControl; type Command = CommandTakeControl | CommandCedeControl;
const messageInKinds = ["ask-for-clipboard", "copied-text"] as const;
type MessageInKind = (typeof messageInKinds)[number];
interface MessageInAskForClipboard {
kind: "ask-for-clipboard";
}
interface MessageInCopiedText {
kind: "copied-text";
text: string;
}
type MessageIn = MessageInCopiedText | MessageInAskForClipboard;
interface MessageOutAskForClipboardResponse {
kind: "ask-for-clipboard-response";
text: string;
}
type MessageOut = MessageOutAskForClipboardResponse;
type Props = { type Props = {
browserSessionId?: string; browserSessionId?: string;
interactive?: boolean; interactive?: boolean;
@@ -102,6 +125,20 @@ function BrowserStream({
if (!browserSession || browserSession.completed_at) { if (!browserSession || browserSession.completed_at) {
setHasBrowserSession(false); setHasBrowserSession(false);
return false; return false;
// NOTE(jdo:streaming-local-dev): remove above and use this instead
// if (browserSession && browserSession.completed_at) {
// console.warn(
// "Completed at:",
// browserSession.completed_at,
// "continuing anyway!",
// );
// setHasBrowserSession(true);
// return true;
// } else {
// setHasBrowserSession(false);
// return false;
// }
} }
setHasBrowserSession(true); setHasBrowserSession(true);
@@ -117,16 +154,16 @@ function BrowserStream({
const [hasBrowserSession, setHasBrowserSession] = useState(true); // be optimistic const [hasBrowserSession, setHasBrowserSession] = useState(true); // be optimistic
const [userIsControlling, setUserIsControlling] = useState(false); const [userIsControlling, setUserIsControlling] = useState(false);
const [commandSocket, setCommandSocket] = useState<WebSocket | null>(null); const [messageSocket, setMessageSocket] = useState<WebSocket | null>(null);
const [vncDisconnectedTrigger, setVncDisconnectedTrigger] = useState(0); const [vncDisconnectedTrigger, setVncDisconnectedTrigger] = useState(0);
const prevVncConnectedRef = useRef<boolean>(false); const prevVncConnectedRef = useRef<boolean>(false);
const [isVncConnected, setIsVncConnected] = useState<boolean>(false); const [isVncConnected, setIsVncConnected] = useState<boolean>(false);
const [isCanvasReady, setIsCanvasReady] = useState<boolean>(false); const [isCanvasReady, setIsCanvasReady] = useState<boolean>(false);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [commandDisconnectedTrigger, setCommandDisconnectedTrigger] = const [messagesDisconnectedTrigger, setMessagesDisconnectedTrigger] =
useState(0); useState(0);
const prevCommandConnectedRef = useRef<boolean>(false); const prevMessageConnectedRef = useRef<boolean>(false);
const [isCommandConnected, setIsCommandConnected] = useState<boolean>(false); const [isMessageConnected, setIsMessageConnected] = useState<boolean>(false);
const [canvasContainer, setCanvasContainer] = useState<HTMLDivElement | null>( const [canvasContainer, setCanvasContainer] = useState<HTMLDivElement | null>(
null, null,
); );
@@ -168,14 +205,14 @@ function BrowserStream({
prevVncConnectedRef.current = isVncConnected; prevVncConnectedRef.current = isVncConnected;
}, [isVncConnected, onClose]); }, [isVncConnected, onClose]);
// effect for command disconnects only // effect for message disconnects only
useEffect(() => { useEffect(() => {
if (prevCommandConnectedRef.current && !isCommandConnected) { if (prevMessageConnectedRef.current && !isMessageConnected) {
setCommandDisconnectedTrigger((x) => x + 1); setMessagesDisconnectedTrigger((x) => x + 1);
onClose?.(); onClose?.();
} }
prevCommandConnectedRef.current = isCommandConnected; prevMessageConnectedRef.current = isMessageConnected;
}, [isCommandConnected, onClose]); }, [isMessageConnected, onClose]);
// vnc socket // vnc socket
useEffect( useEffect(
@@ -289,7 +326,6 @@ function BrowserStream({
], ],
); );
// command socket
useEffect(() => { useEffect(() => {
if (!showStream || !canvasContainer || !runId) { if (!showStream || !canvasContainer || !runId) {
return; return;
@@ -300,34 +336,52 @@ function BrowserStream({
const connect = async () => { const connect = async () => {
const wsParams = await getWebSocketParams(); const wsParams = await getWebSocketParams();
const commandUrl = const messageUrl =
entity === "browserSession" entity === "browserSession"
? `${newWssBaseUrl}/stream/commands/browser_session/${runId}?${wsParams}` ? `${newWssBaseUrl}/stream/messages/browser_session/${runId}?${wsParams}`
: entity === "task" : entity === "task"
? `${wssBaseUrl}/stream/commands/task/${runId}?${wsParams}` ? `${wssBaseUrl}/stream/messages/task/${runId}?${wsParams}`
: entity === "workflow" : entity === "workflow"
? `${wssBaseUrl}/stream/commands/workflow_run/${runId}?${wsParams}` ? `${wssBaseUrl}/stream/messages/workflow_run/${runId}?${wsParams}`
: null; : null;
if (!commandUrl) { if (!messageUrl) {
throw new Error("No command url"); throw new Error("No message url");
} }
if (!hasBrowserSession) { if (!hasBrowserSession) {
setIsCommandConnected(false); setIsMessageConnected(false);
return; return;
} }
ws = new WebSocket(commandUrl); ws = new WebSocket(messageUrl);
ws.onopen = () => { ws.onopen = () => {
setIsCommandConnected(true); setIsMessageConnected(true);
setCommandSocket(ws); setMessageSocket(ws);
};
ws.onmessage = (event) => {
const data = event.data;
try {
const message = JSON.parse(data);
handleMessage(message, ws);
// handle incoming messages if needed
} catch (e) {
console.error(
"Error parsing message from message channel:",
e,
event,
);
}
}; };
ws.onclose = () => { ws.onclose = () => {
setIsCommandConnected(false); setIsMessageConnected(false);
setCommandSocket(null); setMessageSocket(null);
}; };
}; };
@@ -340,31 +394,33 @@ function BrowserStream({
// pass // pass
} }
}; };
// NOTE: adding getWebSocketParams causes constant reconnects of message channel when,
// for instance, take-control or cede-control is clicked
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
browserSessionId, browserSessionId,
canvasContainer, canvasContainer,
commandDisconnectedTrigger, messagesDisconnectedTrigger,
entity, entity,
getWebSocketParams,
hasBrowserSession, hasBrowserSession,
runId, runId,
showStream, showStream,
]); ]);
// effect to send a command when the user is controlling, vs not controlling // effect to send a message when the user is controlling, vs not controlling
useEffect(() => { useEffect(() => {
if (!isCommandConnected) { if (!isMessageConnected) {
return; return;
} }
const sendCommand = (command: Command) => { const sendCommand = (command: Command) => {
if (!commandSocket) { if (!messageSocket) {
console.warn("Cannot send command, as command socket is closed."); console.warn("Cannot send command, as command socket is closed.");
console.warn(command); console.warn(command);
return; return;
} }
commandSocket.send(JSON.stringify(command)); messageSocket.send(JSON.stringify(command));
}; };
if (interactive || userIsControlling) { if (interactive || userIsControlling) {
@@ -373,7 +429,7 @@ function BrowserStream({
sendCommand({ kind: "cede-control" }); sendCommand({ kind: "cede-control" });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [interactive, isCommandConnected, userIsControlling]); }, [interactive, isMessageConnected, userIsControlling]);
// Effect to handle window resize trigger for NoVNC canvas // Effect to handle window resize trigger for NoVNC canvas
useEffect(() => { useEffect(() => {
@@ -420,6 +476,134 @@ function BrowserStream({
} }
}, [task, workflow]); }, [task, workflow]);
/**
* TODO(jdo): could use zod or smth similar
*/
const getMessage = (data: unknown): MessageIn | undefined => {
if (!data) {
return;
}
if (typeof data !== "object") {
return;
}
if (!("kind" in data)) {
return;
}
const k = data.kind;
if (typeof k !== "string") {
return;
}
if (!messageInKinds.includes(k as MessageInKind)) {
return;
}
const kind = k as MessageInKind;
switch (kind) {
case "ask-for-clipboard": {
return data as MessageInAskForClipboard;
}
case "copied-text": {
if ("text" in data && typeof data.text === "string") {
return {
kind: "copied-text",
text: data.text,
};
}
break;
}
default: {
const _exhaustive: never = kind;
return _exhaustive;
}
}
};
const handleMessage = (data: unknown, ws: WebSocket | null) => {
const message = getMessage(data);
if (!message) {
console.warn("Unknown message received on message channel:", data);
return;
}
const kind = message.kind;
const respond = (message: MessageOut) => {
if (!ws) {
console.warn("Cannot send message, as message socket is null.");
console.warn(message);
return;
}
ws.send(JSON.stringify(message));
};
switch (kind) {
case "ask-for-clipboard": {
if (!navigator.clipboard) {
console.warn("Clipboard API not available.");
return;
}
navigator.clipboard
.readText()
.then((text) => {
toast({
title: "Pasting Into Browser",
description:
"Pasting your current clipboard text into the web page. NOTE: copy-paste only works in the web page - not in the browser (like the address bar).",
});
const response: MessageOutAskForClipboardResponse = {
kind: "ask-for-clipboard-response",
text,
};
respond(response);
})
.catch((err) => {
console.error("Failed to read clipboard contents: ", err);
});
break;
}
case "copied-text": {
const text = message.text;
navigator.clipboard
.writeText(text)
.then(() => {
toast({
title: "Copied to Clipboard",
description:
"The text has been copied to your clipboard. NOTE: copy-paste only works in the web page - not in the browser (like the address bar).",
});
})
.catch((err) => {
console.error("Failed to write to clipboard:", err);
toast({
variant: "destructive",
title: "Failed to write to Clipboard",
description: "The text could not be copied to your clipboard.",
});
});
break;
}
default: {
const _exhaustive: never = kind;
return _exhaustive;
}
}
};
const theUserIsControlling = const theUserIsControlling =
userIsControlling || (interactive && !showControlButtons); userIsControlling || (interactive && !showControlButtons);