Add SSH connection information to web UI
This commit implements:
1. Added SSH information display to the web UI info button
2. Shows SSH connection information only when running inside Docker container
3. Constructs the full SSH hostname as "sketch-[session_id]"
4. Added copy-to-clipboard buttons for SSH commands
5. Styles the VSCode URL as a proper clickable link
6. Shows a warning message when SSH is not available
7. Modified container naming to use only SessionID instead of imgName+SessionID
Co-Authored-By: sketch <hello@sketch.dev>
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index a6be67b..150fcf8 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -129,7 +129,7 @@
defer os.Remove(linuxSketchBin) // in case of errors
}
- cntrName := imgName + "-" + config.SessionID
+ cntrName := "sketch-" + config.SessionID
defer func() {
if config.NoCleanup {
return
@@ -538,6 +538,14 @@
func postContainerInitConfig(ctx context.Context, localAddr, commit, gitPort, gitPass string, sshServerIdentity, sshAuthorizedKeys []byte) error {
localURL := "http://" + localAddr
+ // Check if SSH is available by checking for the Include directive in ~/.ssh/config
+ sshAvailable := true
+ sshError := ""
+ if err := CheckForInclude(); err != nil {
+ sshAvailable = false
+ sshError = err.Error()
+ }
+
initMsg, err := json.Marshal(
server.InitRequest{
Commit: commit,
@@ -545,6 +553,8 @@
HostAddr: localAddr,
SSHAuthorizedKeys: sshAuthorizedKeys,
SSHServerIdentity: sshServerIdentity,
+ SSHAvailable: sshAvailable,
+ SSHError: sshError,
})
if err != nil {
return fmt.Errorf("init msg: %w", err)
diff --git a/loop/agent.go b/loop/agent.go
index f7e7033..ab48533 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -74,6 +74,9 @@
// OS returns the operating system of the client.
OS() string
+ // SessionID returns the unique session identifier.
+ SessionID() string
+
// OutstandingLLMCallCount returns the number of outstanding LLM calls.
OutstandingLLMCallCount() int
@@ -338,6 +341,10 @@
return a.config.ClientGOOS
}
+func (a *Agent) SessionID() string {
+ return a.config.SessionID
+}
+
// OutsideOS returns the operating system of the outside system.
func (a *Agent) OutsideOS() string {
return a.outsideOS
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index 3ef540b..b0aa65e 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -60,6 +60,9 @@
GitOrigin string `json:"git_origin,omitempty"`
OutstandingLLMCalls int `json:"outstanding_llm_calls"`
OutstandingToolCalls []string `json:"outstanding_tool_calls"`
+ SessionID string `json:"session_id"`
+ SSHAvailable bool `json:"ssh_available"`
+ SSHError string `json:"ssh_error,omitempty"`
OutsideHostname string `json:"outside_hostname,omitempty"`
InsideHostname string `json:"inside_hostname,omitempty"`
@@ -75,6 +78,8 @@
Commit string `json:"commit"`
SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
SSHServerIdentity []byte `json:"ssh_server_identity"`
+ SSHAvailable bool `json:"ssh_available"`
+ SSHError string `json:"ssh_error,omitempty"`
}
// Server serves sketch HTTP. Server implements http.Handler.
@@ -86,6 +91,8 @@
// Mutex to protect terminalSessions
ptyMutex sync.Mutex
terminalSessions map[string]*terminalSession
+ sshAvailable bool
+ sshError string
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -100,6 +107,8 @@
hostname: getHostname(),
logFile: logFile,
terminalSessions: make(map[string]*terminalSession),
+ sshAvailable: false,
+ sshError: "",
}
webBundle, err := webui.Build()
@@ -164,12 +173,19 @@
return
}
+ // Store SSH availability info
+ s.sshAvailable = m.SSHAvailable
+ s.sshError = m.SSHError
+
// Start the SSH server if the init request included ssh keys.
if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
go func() {
ctx := context.Background()
if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys); err != nil {
slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
+ // Update SSH error if server fails to start
+ s.sshAvailable = false
+ s.sshError = err.Error()
}
}()
}
@@ -367,6 +383,9 @@
GitOrigin: agent.GitOrigin(),
OutstandingLLMCalls: agent.OutstandingLLMCallCount(),
OutstandingToolCalls: agent.OutstandingToolCalls(),
+ SessionID: agent.SessionID(),
+ SSHAvailable: s.sshAvailable,
+ SSHError: s.sshError,
}
// Create a JSON encoder with indentation for pretty-printing
diff --git a/webui/playwright/index.ts b/webui/playwright/index.ts
index 3e162d1..42eae5f 100644
--- a/webui/playwright/index.ts
+++ b/webui/playwright/index.ts
@@ -2,3 +2,4 @@
// import '../src/common.css';
// No imports needed - components are imported directly in the test files
+// Components should be imported in the test files directly, not here
diff --git a/webui/src/fixtures/dummy.ts b/webui/src/fixtures/dummy.ts
index d96e873..4a40337 100644
--- a/webui/src/fixtures/dummy.ts
+++ b/webui/src/fixtures/dummy.ts
@@ -363,6 +363,8 @@
initial_commit: "a6c5a08a451ef1082774a7affb6af58775e7bc16",
title: "Add a line to dummy.txt and commit the change",
hostname: "MacBook-Pro-9.local",
+ session_id: "dummy-session",
+ ssh_available: false,
working_dir: "/Users/pokey/src/spaghetti",
os: "darwin",
git_origin: "git@github.com:pokey/spaghetti.git",
diff --git a/webui/src/types.ts b/webui/src/types.ts
index 3b672b2..25be7f7 100644
--- a/webui/src/types.ts
+++ b/webui/src/types.ts
@@ -76,6 +76,9 @@
git_origin?: string;
outstanding_llm_calls: number;
outstanding_tool_calls: string[];
+ session_id: string;
+ ssh_available: boolean;
+ ssh_error?: string;
}
export type CodingAgentMessageType = 'user' | 'agent' | 'error' | 'budget' | 'tool' | 'commit' | 'auto';
diff --git a/webui/src/web-components/sketch-app-shell.ts b/webui/src/web-components/sketch-app-shell.ts
index bb05a03..9682daf 100644
--- a/webui/src/web-components/sketch-app-shell.ts
+++ b/webui/src/web-components/sketch-app-shell.ts
@@ -218,6 +218,8 @@
initial_commit: "",
outstanding_llm_calls: 0,
outstanding_tool_calls: [],
+ session_id: "",
+ ssh_available: false,
};
// Mutation observer to detect when new messages are added
diff --git a/webui/src/web-components/sketch-container-status.test.ts b/webui/src/web-components/sketch-container-status.test.ts
index b5e625d..db43a6d 100644
--- a/webui/src/web-components/sketch-container-status.test.ts
+++ b/webui/src/web-components/sketch-container-status.test.ts
@@ -22,6 +22,8 @@
},
outstanding_llm_calls: 0,
outstanding_tool_calls: [],
+ session_id: "test-session-id",
+ ssh_available: false,
};
test("render props", async ({ mount }) => {
@@ -78,6 +80,8 @@
message_count: 10,
os: "linux",
title: "Partial Test",
+ session_id: "partial-session",
+ ssh_available: false,
total_usage: {
input_tokens: 500,
start_time: "",
diff --git a/webui/src/web-components/sketch-container-status.ts b/webui/src/web-components/sketch-container-status.ts
index 08ac605..f4c0f25 100644
--- a/webui/src/web-components/sketch-container-status.ts
+++ b/webui/src/web-components/sketch-container-status.ts
@@ -40,7 +40,7 @@
top: 100%;
right: 0;
z-index: 10;
- min-width: 320px;
+ min-width: 400px;
background: white;
border-radius: 8px;
padding: 10px 15px;
@@ -132,6 +132,61 @@
gap: 8px;
margin-top: 10px;
}
+
+ .ssh-section {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid #eee;
+ }
+
+ .ssh-command {
+ display: flex;
+ align-items: center;
+ margin-bottom: 8px;
+ gap: 10px;
+ }
+
+ .ssh-command-text {
+ font-family: monospace;
+ font-size: 12px;
+ background: #f5f5f5;
+ padding: 4px 8px;
+ border-radius: 4px;
+ border: 1px solid #e0e0e0;
+ flex-grow: 1;
+ }
+
+ .copy-button {
+ background: #f0f0f0;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ padding: 3px 6px;
+ font-size: 11px;
+ cursor: pointer;
+ transition: all 0.2s;
+ }
+
+ .copy-button:hover {
+ background: #e0e0e0;
+ }
+
+ .ssh-warning {
+ background: #fff3e0;
+ border-left: 3px solid #ff9800;
+ padding: 8px 12px;
+ margin-top: 8px;
+ font-size: 12px;
+ color: #e65100;
+ }
+
+ .vscode-link {
+ color: #2962ff;
+ text-decoration: none;
+ }
+
+ .vscode-link:hover {
+ text-decoration: underline;
+ }
`;
constructor() {
@@ -228,6 +283,72 @@
// unregister event listeners
}
+ copyToClipboard(text: string) {
+ navigator.clipboard
+ .writeText(text)
+ .then(() => {
+ // Could add a temporary success indicator here
+ })
+ .catch((err) => {
+ console.error("Could not copy text: ", err);
+ });
+ }
+
+ getSSHHostname() {
+ return `sketch-${this.state?.session_id}`;
+ }
+
+ renderSSHSection() {
+ // Only show SSH section if we're in a Docker container and have session ID
+ if (!this.state?.session_id) {
+ return html``;
+ }
+
+ const sshHost = this.getSSHHostname();
+ const sshCommand = `ssh ${sshHost}`;
+ const vscodeCommand = `code --remote ssh-remote+root@${sshHost} /app -n`;
+ const vscodeURL = `vscode://vscode-remote/ssh-remote+root@${sshHost}/app?windowId=_blank`;
+
+ if (!this.state?.ssh_available) {
+ return html`
+ <div class="ssh-section">
+ <h3>SSH Connection</h3>
+ <div class="ssh-warning">
+ SSH connections are not available:
+ ${this.state?.ssh_error || "SSH configuration is missing"}
+ </div>
+ </div>
+ `;
+ }
+
+ return html`
+ <div class="ssh-section">
+ <h3>SSH Connection</h3>
+ <div class="ssh-command">
+ <div class="ssh-command-text">${sshCommand}</div>
+ <button
+ class="copy-button"
+ @click=${() => this.copyToClipboard(sshCommand)}
+ >
+ Copy
+ </button>
+ </div>
+ <div class="ssh-command">
+ <div class="ssh-command-text">${vscodeCommand}</div>
+ <button
+ class="copy-button"
+ @click=${() => this.copyToClipboard(vscodeCommand)}
+ >
+ Copy
+ </button>
+ </div>
+ <div class="ssh-command">
+ <a href="${vscodeURL}" class="vscode-link">${vscodeURL}</a>
+ </div>
+ </div>
+ `;
+ }
+
render() {
return html`
<div class="info-container">
@@ -323,6 +444,9 @@
<a href="logs">Logs</a> (<a href="download">Download</a>)
</div>
</div>
+
+ <!-- SSH Connection Information -->
+ ${this.renderSSHSection()}
</div>
</div>
`;