diff --git a/src/backend/default_canvas.json b/src/backend/default_canvas.json
index 3ecb6dc..e902312 100644
--- a/src/backend/default_canvas.json
+++ b/src/backend/default_canvas.json
@@ -2176,7 +2176,8 @@
"boundElements": [],
"backgroundColor": "#e9ecef",
"customData": {
- "showHyperlinkIcon": false
+ "showHyperlinkIcon": false,
+ "showClickableHint": false
}
}
]
diff --git a/src/frontend/src/CustomEmbeddableRenderer.tsx b/src/frontend/src/CustomEmbeddableRenderer.tsx
index 0fcb1c4..20792d7 100644
--- a/src/frontend/src/CustomEmbeddableRenderer.tsx
+++ b/src/frontend/src/CustomEmbeddableRenderer.tsx
@@ -9,6 +9,7 @@ import {
ControlButton,
HtmlEditor,
Editor,
+ Terminal,
} from './pad';
import { ActionButton } from './pad/buttons';
import "./CustomEmbeddableRenderer.scss";
@@ -20,6 +21,7 @@ export const renderCustomEmbeddable = (
) => {
if (element.link && element.link.startsWith('!')) {
+
let path = element.link.split('!')[1];
let content;
let title;
@@ -28,10 +30,15 @@ export const renderCustomEmbeddable = (
case 'html':
content = ;
title = "HTML Editor";
+ break;
case 'editor':
content = ;
title = "Code Editor";
break;
+ case 'terminal':
+ content = ;
+ title = "Terminal";
+ break;
case 'state':
content = ;
title = "State Indicator";
diff --git a/src/frontend/src/pad/buttons/ActionButton.tsx b/src/frontend/src/pad/buttons/ActionButton.tsx
index 8d4f104..91118f5 100644
--- a/src/frontend/src/pad/buttons/ActionButton.tsx
+++ b/src/frontend/src/pad/buttons/ActionButton.tsx
@@ -280,35 +280,16 @@ const ActionButton: React.FC = ({
};
- const getUrl = () => {
+ const getCodeUrl = () => {
if (!workspaceState) {
- if (selectedTarget === 'terminal') {
- return 'https://terminal.example.dev';
- } else {
- return 'https://vscode.example.dev';
- }
+ return '';
}
- if (selectedTarget === 'terminal') {
- return `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/terminal`;
- } else {
- return `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/apps/code-server`;
- }
+ return `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/apps/code-server`;
};
// Placement logic has been moved to ExcalidrawElementFactory.placeInScene
- const createEmbeddableElement = (link: string, buttonElement: HTMLElement | null = null) => {
- return ExcalidrawElementFactory.createEmbeddableElement({
- link,
- width: 600,
- height: 400,
- strokeColor: "#1e1e1e",
- backgroundColor: "#ffffff",
- roughness: 1
- });
- };
-
const executeAction = () => {
capture('action_button_clicked', {
@@ -324,36 +305,60 @@ const ActionButton: React.FC = ({
return;
}
- const baseUrl = getUrl();
+ // Determine the link to use
+ let link: string;
- if (!baseUrl) {
- console.error('Could not determine URL for embedding');
- return;
+ if (selectedTarget === 'terminal') {
+ // For terminal, use the !terminal embed link
+ link = '!terminal';
+ } else {
+ // For code, use the code URL
+ const codeUrl = getCodeUrl();
+ if (!codeUrl) {
+ console.error('Could not determine URL for embedding');
+ return;
+ }
+ link = codeUrl;
}
- // Create element with our factory
- const buttonElement = wrapperRef.current;
- const newElement = createEmbeddableElement(baseUrl, buttonElement);
+ // Create element directly with ExcalidrawElementFactory
+ const newElement = ExcalidrawElementFactory.createEmbeddableElement({
+ link,
+ width: 600,
+ height: 400,
+ });
- // Place the element in the scene using our new placement logic
+ // Place the element in the scene
ExcalidrawElementFactory.placeInScene(newElement, excalidrawAPI, {
mode: PlacementMode.NEAR_VIEWPORT_CENTER,
bufferPercentage: 10,
scrollToView: true
});
- console.debug(`[pad.ws] Embedded ${selectedTarget} at URL: ${baseUrl}`);
+ console.debug(`[pad.ws] Embedded ${selectedTarget}`);
} else if (selectedAction === 'open-tab') {
- const baseUrl = getUrl();
- if (!baseUrl) {
- console.error('Could not determine URL for opening in tab');
- return;
+ if (selectedTarget === 'terminal') {
+ // For terminal, open the terminal URL in a new tab
+ if (!workspaceState) {
+ console.error('Workspace state not available for opening terminal in tab');
+ return;
+ }
+
+ const terminalUrl = `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/terminal`;
+ console.debug(`[pad.ws] Opening terminal in new tab: ${terminalUrl}`);
+ window.open(terminalUrl, '_blank');
+ } else {
+ // For code, open the code URL in a new tab
+ const codeUrl = getCodeUrl();
+ if (!codeUrl) {
+ console.error('Could not determine URL for opening in tab');
+ return;
+ }
+
+ console.debug(`[pad.ws] Opening ${selectedTarget} in new tab: ${codeUrl}`);
+ window.open(codeUrl, '_blank');
}
-
- console.debug(`[pad.ws] Opening ${selectedTarget} in new tab from ${baseUrl}`);
- window.open(baseUrl, '_blank');
-
} else if (selectedAction === 'magnet') {
if (!workspaceState) {
console.error('Workspace state not available for magnet link');
@@ -365,14 +370,12 @@ const ActionButton: React.FC = ({
const url = workspaceState.base_url;
const agent = workspaceState.agent;
- let magnetLink = '';
-
if (selectedTarget === 'terminal') {
console.error('Terminal magnet links are not supported');
return;
} else if (selectedTarget === 'code') {
const prefix = selectedCodeVariant === 'cursor' ? 'cursor' : 'vscode';
- magnetLink = `${prefix}://coder.coder-remote/open?owner=${owner}&workspace=${workspace}&url=${url}&token=&openRecent=true&agent=${agent}`;
+ const magnetLink = `${prefix}://coder.coder-remote/open?owner=${owner}&workspace=${workspace}&url=${url}&token=&openRecent=true&agent=${agent}`;
console.debug(`[pad.ws] Opening ${selectedCodeVariant} desktop app with magnet link: ${magnetLink}`);
window.open(magnetLink, '_blank');
}
diff --git a/src/frontend/src/pad/containers/Terminal.scss b/src/frontend/src/pad/containers/Terminal.scss
new file mode 100644
index 0000000..31a9462
--- /dev/null
+++ b/src/frontend/src/pad/containers/Terminal.scss
@@ -0,0 +1,18 @@
+.terminal-container {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.terminal-iframe {
+ flex: 1;
+ background-color: #1e1e1e;
+ height: 100%;
+ width: 100%;
+ border: 0px !important;
+ overflow: hidden;
+ border-bottom-left-radius: var(--embeddable-radius);
+ border-bottom-right-radius: var(--embeddable-radius);
+}
diff --git a/src/frontend/src/pad/containers/Terminal.tsx b/src/frontend/src/pad/containers/Terminal.tsx
new file mode 100644
index 0000000..768e09c
--- /dev/null
+++ b/src/frontend/src/pad/containers/Terminal.tsx
@@ -0,0 +1,186 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { useWorkspaceState } from '../../api/hooks';
+import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types';
+import type { AppState } from '@atyrode/excalidraw/types';
+import './Terminal.scss';
+
+interface TerminalProps {
+ element: NonDeleted;
+ appState: AppState;
+ excalidrawAPI?: any;
+}
+
+// Interface for terminal connection info stored in customData
+interface TerminalConnectionInfo {
+ terminalId: string;
+ baseUrl?: string;
+ username?: string;
+ workspaceId?: string;
+ agent?: string;
+}
+
+export const Terminal: React.FC = ({
+ element,
+ appState,
+ excalidrawAPI
+}) => {
+ const { data: workspaceState } = useWorkspaceState();
+ const [terminalId, setTerminalId] = useState(null);
+ const elementIdRef = useRef(element?.id);
+ const isInitializedRef = useRef(false);
+
+ // Generate a UUID for terminal ID
+ const generateUUID = () => {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+ const r = Math.random() * 16 | 0;
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+ };
+
+ // Save terminal connection info to element's customData
+ const saveConnectionInfoToCustomData = useCallback(() => {
+ if (!element || !excalidrawAPI || !workspaceState || !terminalId) return;
+
+ try {
+ // Get all elements from the scene
+ const elements = excalidrawAPI.getSceneElements();
+
+ // Find and update the element
+ const updatedElements = elements.map(el => {
+ if (el.id === element.id) {
+ // Create a new customData object with the terminal connection info
+ const connectionInfo: TerminalConnectionInfo = {
+ terminalId,
+ baseUrl: workspaceState.base_url,
+ username: workspaceState.username,
+ workspaceId: workspaceState.workspace_id,
+ agent: workspaceState.agent
+ };
+
+ const customData = {
+ ...(el.customData || {}),
+ terminalConnectionInfo: connectionInfo
+ };
+
+ return { ...el, customData };
+ }
+ return el;
+ });
+
+ // Update the scene with the modified elements
+ excalidrawAPI.updateScene({
+ elements: updatedElements
+ });
+ } catch (error) {
+ console.error('Error saving terminal connection info:', error);
+ }
+ }, [element, excalidrawAPI, workspaceState, terminalId]);
+
+ // Generate a terminal ID if one doesn't exist
+ useEffect(() => {
+ if (terminalId) return;
+
+ // Generate a new terminal ID
+ const newTerminalId = generateUUID();
+ setTerminalId(newTerminalId);
+ }, [terminalId]);
+
+ // Initialize terminal connection info
+ useEffect(() => {
+ if (!element || !workspaceState || !terminalId || isInitializedRef.current) return;
+
+ // Check if element ID has changed (indicating a new element)
+ if (element.id !== elementIdRef.current) {
+ elementIdRef.current = element.id;
+ }
+
+ // Check if element already has terminal connection info
+ if (element.customData?.terminalConnectionInfo) {
+ const connectionInfo = element.customData.terminalConnectionInfo as TerminalConnectionInfo;
+ setTerminalId(connectionInfo.terminalId);
+ } else if (excalidrawAPI) {
+ // Save the terminal ID to customData
+ saveConnectionInfoToCustomData();
+ }
+
+ isInitializedRef.current = true;
+ }, [element, workspaceState, terminalId, saveConnectionInfoToCustomData, excalidrawAPI]);
+
+ // Update terminal connection info when element changes
+ useEffect(() => {
+ if (!element || !workspaceState) return;
+
+ // Check if element ID has changed (indicating a new element)
+ if (element.id !== elementIdRef.current) {
+ elementIdRef.current = element.id;
+ isInitializedRef.current = false;
+
+ // Check if element already has terminal connection info
+ if (element.customData?.terminalConnectionInfo) {
+ const connectionInfo = element.customData.terminalConnectionInfo as TerminalConnectionInfo;
+ setTerminalId(connectionInfo.terminalId);
+ } else if (terminalId && excalidrawAPI) {
+ // Save the existing terminal ID to customData
+ saveConnectionInfoToCustomData();
+ } else if (!terminalId) {
+ // Generate a new terminal ID if one doesn't exist
+ const newTerminalId = generateUUID();
+ setTerminalId(newTerminalId);
+
+ // Save the new terminal ID to customData if excalidrawAPI is available
+ if (excalidrawAPI) {
+ setTimeout(() => {
+ saveConnectionInfoToCustomData();
+ }, 100);
+ }
+ }
+
+ isInitializedRef.current = true;
+ } else if (!isInitializedRef.current && terminalId && excalidrawAPI && !element.customData?.terminalConnectionInfo) {
+ // Handle the case where the element ID hasn't changed but we need to save the terminal ID
+ saveConnectionInfoToCustomData();
+ isInitializedRef.current = true;
+ }
+ }, [element, workspaceState, excalidrawAPI, terminalId, saveConnectionInfoToCustomData]);
+
+ // Effect to handle excalidrawAPI becoming available after component mount
+ useEffect(() => {
+ if (!excalidrawAPI || !element || !workspaceState || !terminalId) return;
+
+ // Check if element already has terminal connection info
+ if (element.customData?.terminalConnectionInfo) return;
+
+ // Save the terminal ID to customData
+ saveConnectionInfoToCustomData();
+ }, [excalidrawAPI, element, workspaceState, terminalId, saveConnectionInfoToCustomData]);
+
+ const getTerminalUrl = () => {
+ if (!workspaceState) {
+ return '';
+ }
+
+ const baseUrl = `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/terminal`;
+
+ // Add reconnect parameter if terminal ID exists
+ if (terminalId) {
+ return `${baseUrl}?reconnect=${terminalId}`;
+ }
+
+ return baseUrl;
+ };
+
+ const terminalUrl = getTerminalUrl();
+
+ return (
+
+
+
+ );
+};
+
+export default Terminal;
diff --git a/src/frontend/src/pad/index.ts b/src/frontend/src/pad/index.ts
index e8df624..1edf88a 100644
--- a/src/frontend/src/pad/index.ts
+++ b/src/frontend/src/pad/index.ts
@@ -2,6 +2,7 @@
export * from './controls/ControlButton';
export * from './controls/StateIndicator';
export * from './containers/Dashboard';
+export * from './containers/Terminal';
export * from './buttons';
export * from './editors';
@@ -9,3 +10,4 @@ export * from './editors';
export { default as ControlButton } from './controls/ControlButton';
export { default as StateIndicator } from './controls/StateIndicator';
export { default as Dashboard } from './containers/Dashboard';
+export { default as Terminal } from './containers/Terminal';
diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx
index ba6f1b3..8b34778 100644
--- a/src/frontend/src/ui/MainMenu.tsx
+++ b/src/frontend/src/ui/MainMenu.tsx
@@ -3,7 +3,7 @@ import React, { useState } from 'react';
import type { ExcalidrawImperativeAPI } from '@atyrode/excalidraw/types';
import type { MainMenu as MainMenuType } from '@atyrode/excalidraw';
-import { LogOut, SquarePlus, LayoutDashboard, SquareCode, Eye, Coffee, Grid2x2, User, Text, ArchiveRestore, Settings } from 'lucide-react';
+import { LogOut, SquarePlus, LayoutDashboard, SquareCode, Eye, Coffee, Grid2x2, User, Text, ArchiveRestore, Settings, Terminal } from 'lucide-react';
import { capture } from '../utils/posthog';
import { ExcalidrawElementFactory, PlacementMode } from '../lib/ExcalidrawElementFactory';
import { useUserProfile } from "../api/hooks";
@@ -100,6 +100,22 @@ export const MainMenuConfig: React.FC = ({
scrollToView: true
});
};
+
+ const handleTerminalClick = () => {
+ if (!excalidrawAPI) return;
+
+ const terminalElement = ExcalidrawElementFactory.createEmbeddableElement({
+ link: "!terminal",
+ width: 800,
+ height: 500
+ });
+
+ ExcalidrawElementFactory.placeInScene(terminalElement, excalidrawAPI, {
+ mode: PlacementMode.NEAR_VIEWPORT_CENTER,
+ bufferPercentage: 10,
+ scrollToView: true
+ });
+ };
const handleCanvasBackupsClick = () => {
setShowBackupsModal(true);
@@ -176,6 +192,12 @@ export const MainMenuConfig: React.FC = ({
>
Code Editor
+ }
+ onClick={handleTerminalClick}
+ >
+ Terminal
+
}
onClick={handleDashboardButtonClick}