From 2712bb0f3dd7a7a40a77b05f112fcde35d13a6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B8=80=E5=AF=B0?= Date: Fri, 13 Mar 2026 16:48:50 +0800 Subject: [PATCH 01/28] init --- ui/src/ui/app-render.helpers.ts | 20 ++ ui/src/ui/app-render.ts | 266 +++++++++++--------- ui/src/ui/app-view-state.ts | 4 + ui/src/ui/components/claw-computer-panel.ts | 236 +++++++++++++++++ ui/src/ui/server/proxy.ts | 33 +++ ui/src/ui/types/novnc.d.ts | 22 ++ 6 files changed, 466 insertions(+), 115 deletions(-) create mode 100644 ui/src/ui/components/claw-computer-panel.ts create mode 100644 ui/src/ui/server/proxy.ts create mode 100644 ui/src/ui/types/novnc.d.ts diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 0a2003fac34..c13ba076a43 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -283,6 +283,26 @@ export function renderChatControls(state: AppViewState) { > ${renderCronFilterIcon(hiddenCronCount)} + `; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 74644f07708..871f4b55244 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1316,127 +1316,163 @@ export function renderApp(state: AppViewState) { ${ state.tab === "chat" - ? renderChat({ - sessionKey: state.sessionKey, - onSessionKeyChange: (next) => { - state.sessionKey = next; - state.chatMessage = ""; - state.chatAttachments = []; - state.chatStream = null; - state.chatStreamStartedAt = null; - state.chatRunId = null; - state.chatQueue = []; - state.resetToolStream(); - state.resetChatScroll(); - state.applySettings({ - ...state.settings, - sessionKey: next, - lastActiveSessionKey: next, - }); - void state.loadAssistantIdentity(); - void loadChatHistory(state); - void refreshChatAvatar(state); - }, - thinkingLevel: state.chatThinkingLevel, - showThinking, - loading: state.chatLoading, - sending: state.chatSending, - compactionStatus: state.compactionStatus, - fallbackStatus: state.fallbackStatus, - assistantAvatarUrl: chatAvatarUrl, - messages: state.chatMessages, - toolMessages: state.chatToolMessages, - streamSegments: state.chatStreamSegments, - stream: state.chatStream, - streamStartedAt: state.chatStreamStartedAt, - draft: state.chatMessage, - queue: state.chatQueue, - connected: state.connected, - canSend: state.connected, - disabledReason: chatDisabledReason, - error: state.lastError, - sessions: state.sessionsResult, - focusMode: chatFocus, - onRefresh: () => { - state.resetToolStream(); - return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); - }, - onToggleFocusMode: () => { - if (state.onboarding) { - return; - } - state.applySettings({ - ...state.settings, - chatFocusMode: !state.settings.chatFocusMode, - }); - }, - onChatScroll: (event) => state.handleChatScroll(event), - getDraft: () => state.chatMessage, - onDraftChange: (next) => (state.chatMessage = next), - onRequestUpdate: requestHostUpdate, - attachments: state.chatAttachments, - onAttachmentsChange: (next) => (state.chatAttachments = next), - onSend: () => state.handleSendChat(), - canAbort: Boolean(state.chatRunId), - onAbort: () => void state.handleAbortChat(), - onQueueRemove: (id) => state.removeQueuedMessage(id), - onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), - onClearHistory: async () => { - if (!state.client || !state.connected) { - return; - } - try { - await state.client.request("sessions.reset", { key: state.sessionKey }); - state.chatMessages = []; - state.chatStream = null; - state.chatRunId = null; - await loadChatHistory(state); - } catch (err) { - state.lastError = String(err); - } - }, - agentsList: state.agentsList, - currentAgentId: resolvedAgentId ?? "main", - onAgentChange: (agentId: string) => { - state.sessionKey = buildAgentMainSessionKey({ agentId }); + ? html` +
+ + + ${renderChat({ + sessionKey: state.sessionKey, + onSessionKeyChange: (next) => { + state.sessionKey = next; + state.chatMessage = ""; + state.chatAttachments = []; + state.chatStream = null; + state.chatStreamStartedAt = null; + state.chatRunId = null; + state.chatQueue = []; + state.resetToolStream(); + state.resetChatScroll(); + state.applySettings({ + ...state.settings, + sessionKey: next, + lastActiveSessionKey: next, + }); + void state.loadAssistantIdentity(); + void loadChatHistory(state); + void refreshChatAvatar(state); + }, + thinkingLevel: state.chatThinkingLevel, + showThinking, + loading: state.chatLoading, + sending: state.chatSending, + compactionStatus: state.compactionStatus, + fallbackStatus: state.fallbackStatus, + assistantAvatarUrl: chatAvatarUrl, + messages: state.chatMessages, + toolMessages: state.chatToolMessages, + streamSegments: state.chatStreamSegments, + stream: state.chatStream, + streamStartedAt: state.chatStreamStartedAt, + draft: state.chatMessage, + queue: state.chatQueue, + connected: state.connected, + canSend: state.connected, + disabledReason: chatDisabledReason, + error: state.lastError, + sessions: state.sessionsResult, + focusMode: chatFocus, + onRefresh: () => { + state.resetToolStream(); + return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); + }, + onToggleFocusMode: () => { + if (state.onboarding) { + return; + } + state.applySettings({ + ...state.settings, + chatFocusMode: !state.settings.chatFocusMode, + }); + }, + onChatScroll: (event) => state.handleChatScroll(event), + getDraft: () => state.chatMessage, + onDraftChange: (next) => (state.chatMessage = next), + onRequestUpdate: requestHostUpdate, + attachments: state.chatAttachments, + onAttachmentsChange: (next) => (state.chatAttachments = next), + onSend: () => state.handleSendChat(), + canAbort: Boolean(state.chatRunId), + onAbort: () => void state.handleAbortChat(), + onQueueRemove: (id) => state.removeQueuedMessage(id), + onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), + onClearHistory: async () => { + if (!state.client || !state.connected) { + return; + } + try { + await state.client.request("sessions.reset", { key: state.sessionKey }); state.chatMessages = []; state.chatStream = null; state.chatRunId = null; - state.applySettings({ - ...state.settings, - sessionKey: state.sessionKey, - lastActiveSessionKey: state.sessionKey, - }); - void loadChatHistory(state); - void state.loadAssistantIdentity(); - }, - onNavigateToAgent: () => { - state.agentsSelectedId = resolvedAgentId; - state.setTab("agents" as import("./navigation.ts").Tab); - }, - onSessionSelect: (key: string) => { - state.setSessionKey(key); - state.chatMessages = []; - void loadChatHistory(state); - void state.loadAssistantIdentity(); - }, - showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight, - onScrollToBottom: () => state.scrollToBottom(), - // Sidebar props for tool output viewing - sidebarOpen: state.sidebarOpen, - sidebarContent: state.sidebarContent, - sidebarError: state.sidebarError, - splitRatio: state.splitRatio, - onOpenSidebar: (content: string) => state.handleOpenSidebar(content), - onCloseSidebar: () => state.handleCloseSidebar(), - onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), - assistantName: state.assistantName, - assistantAvatar: state.assistantAvatar, - basePath: state.basePath ?? "", - }) + await loadChatHistory(state); + } catch (err) { + state.lastError = String(err); + } + }, + agentsList: state.agentsList, + currentAgentId: resolvedAgentId ?? "main", + onAgentChange: (agentId: string) => { + state.sessionKey = buildAgentMainSessionKey({ agentId }); + state.chatMessages = []; + state.chatStream = null; + state.chatRunId = null; + state.applySettings({ + ...state.settings, + sessionKey: state.sessionKey, + lastActiveSessionKey: state.sessionKey, + }); + void loadChatHistory(state); + void state.loadAssistantIdentity(); + }, + onNavigateToAgent: () => { + state.agentsSelectedId = resolvedAgentId; + state.setTab("agents" as import("./navigation.ts").Tab); + }, + onSessionSelect: (key: string) => { + state.setSessionKey(key); + state.chatMessages = []; + void loadChatHistory(state); + void state.loadAssistantIdentity(); + }, + showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight, + onScrollToBottom: () => state.scrollToBottom(), + // Sidebar props for tool output viewing + sidebarOpen: state.sidebarOpen, + sidebarContent: state.sidebarContent, + sidebarError: state.sidebarError, + splitRatio: state.splitRatio, + onOpenSidebar: (content: string) => state.handleOpenSidebar(content), + onCloseSidebar: () => state.handleCloseSidebar(), + onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), + assistantName: state.assistantName, + assistantAvatar: state.assistantAvatar, + basePath: state.basePath ?? "", + })} +
+ + ${ + state.showClawComputer + ? html` + { + if (e.detail.width !== undefined) { + state.setClawComputerWidth(e.detail.width); + } + }} + > +
+
+ 🖥️ 远程桌面 + +
+ +
+ ` + : nothing + } + ` : nothing } + ${ state.tab === "config" ? renderConfig({ diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index b659c195754..ea61387d21b 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -364,4 +364,8 @@ export type AppViewState = { handleOpenSidebar: (content: string) => void; handleCloseSidebar: () => void; handleSplitRatioChange: (ratio: number) => void; + showClawComputer: boolean; + toggleClawComputer: () => void; + clawComputerWidth: number; + setClawComputerWidth: (width: number) => void; }; diff --git a/ui/src/ui/components/claw-computer-panel.ts b/ui/src/ui/components/claw-computer-panel.ts new file mode 100644 index 00000000000..a6312e054f6 --- /dev/null +++ b/ui/src/ui/components/claw-computer-panel.ts @@ -0,0 +1,236 @@ +// ui/src/ui/components/claw-computer-panel.ts +import { LitElement, html, css } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { createRef, ref, Ref } from "lit/directives/ref.js"; + +interface RFBInstance { + disconnect(): void; + addEventListener(event: string, callback: (e?: unknown) => void): void; + scaleViewport: boolean; + clipViewport: boolean; + resize?(): void; +} + +@customElement("claw-computer-panel") +export class ClawComputerPanel extends LitElement { + @state() status = "🖥️ Claw Computer 未配置"; + @state() isConnected = false; + @state() isFitted = true; + @state() password = ""; + + private rfb: RFBInstance | null = null; + private RFBConstructor: unknown = null; + private screenRef: Ref = createRef(); + + static styles = css` + :host { + display: block; + height: 100%; + background: #0a0a0a; + color: #eee; + font-family: system-ui, sans-serif; + } + .container { + height: 100%; + display: flex; + flex-direction: column; + padding: 16px; + } + h2 { + text-align: center; + margin: 0 0 16px 0; + color: #ddd; + } + .controls { + background: #1a1a1a; + padding: 16px; + border-radius: 8px; + margin-bottom: 16px; + border: 1px solid #333; + } + label { + display: block; + margin-bottom: 6px; + font-weight: 500; + } + input { + width: 100%; + padding: 10px; + background: #222; + border: 1px solid #444; + color: #eee; + border-radius: 6px; + margin-bottom: 12px; + } + .btn-group { + display: flex; + gap: 8px; + margin: 12px 0; + flex-wrap: wrap; + } + button { + padding: 10px 16px; + background: #0066cc; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: bold; + } + button:hover:not(:disabled) { + background: #007fff; + } + button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .active { + background: #0088ff !important; + } + .screen-container { + flex: 1; + min-height: 400px; + background: #000; + border: 2px solid #444; + border-radius: 8px; + overflow: hidden; + position: relative; + } + .screen { + width: 100%; + height: 100%; + } + .status { + margin-top: 8px; + text-align: center; + font-weight: bold; + min-height: 24px; + } + `; + + render() { + return html` +
+

Claw Computer - VNC 遠端畫面

+
+ + { + this.password = (e.target as HTMLInputElement).value; + }} + @keydown=${this.handlePasswordKeydown} /> +
+ + + +
+
+ 視窗縮放模式: + + +
+
+ ${this.status} +
+
+
+
+
+
+ `; + } + + private async loadRFB() { + if (this.RFBConstructor) { + return; + } + const module = await import("@novnc/novnc/lib/rfb.js"); + this.RFBConstructor = (module as unknown as { default?: unknown }).default || module; + } + + private connect = async () => { + await this.loadRFB(); + + const url = "ws://localhost:8081"; + if (this.rfb) { + this.rfb.disconnect(); + } + + this.status = "正在連接..."; + + const screen = this.screenRef.value; + if (!screen) { + return; + } + + const Constructor = this.RFBConstructor as new ( + target: HTMLElement, + url: string, + options?: unknown, + ) => RFBInstance; + this.rfb = new Constructor(screen, url, { + credentials: { password: this.password || undefined }, + resizeSession: true, + clipViewport: true, + scaleViewport: this.isFitted, + }); + + this.rfb.addEventListener("connect", () => { + this.isConnected = true; + this.status = "已連線成功 ✓(改變視窗大小會自動適配)"; + setTimeout(() => this.rfb?.resize?.(), 100); + }); + + this.rfb.addEventListener("disconnect", () => { + this.isConnected = false; + this.status = "連線中斷"; + this.rfb = null; + }); + }; + + private disconnect = () => { + if (this.rfb) { + this.rfb.disconnect(); + } + }; + + private setFitMode(fitted: boolean) { + this.isFitted = fitted; + if (this.rfb) { + this.rfb.scaleViewport = fitted; + this.rfb.clipViewport = true; + setTimeout(() => this.rfb?.resize?.(), 50); + } + } + + private handleResize = () => { + if (this.rfb && this.isConnected) { + setTimeout(() => this.rfb?.resize?.(), 80); + } + }; + + private toggleFullscreen = () => { + const container = this.shadowRoot?.querySelector(".screen-container"); + if (container) { + void (container as HTMLElement).requestFullscreen?.(); + } + }; + + private handlePasswordKeydown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + void this.connect(); + } + }; + + firstUpdated() { + window.addEventListener("resize", this.handleResize); + void this.loadRFB(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("resize", this.handleResize); + this.disconnect(); + } +} diff --git a/ui/src/ui/server/proxy.ts b/ui/src/ui/server/proxy.ts new file mode 100644 index 00000000000..c4e47296eef --- /dev/null +++ b/ui/src/ui/server/proxy.ts @@ -0,0 +1,33 @@ +import http from "http"; +// server/proxy.ts +import { WebSocket, WebSocketServer } from "ws"; + +const PORT = 8081; +const TARGET_VNC = "ws://10.75.171.0:25900"; // ← 改成你的真實 VNC 位址 + +const server = http.createServer(); +const wss = new WebSocketServer({ server }); + +wss.on("connection", (clientWs) => { + console.log("[Proxy] Client connected"); + + const targetWs = new WebSocket(TARGET_VNC); + + targetWs.on("open", () => console.log("[Proxy] 已連線到真實 VNC")); + + clientWs.on("message", (data) => targetWs.readyState === WebSocket.OPEN && targetWs.send(data)); + targetWs.on("message", (data) => clientWs.readyState === WebSocket.OPEN && clientWs.send(data)); + + const cleanup = () => { + targetWs.close(); + clientWs.close(); + }; + clientWs.on("close", cleanup); + targetWs.on("close", cleanup); + clientWs.on("error", cleanup); + targetWs.on("error", cleanup); +}); + +server.listen(PORT, () => { + console.log(`✅ noVNC Proxy 啟動成功 → ws://localhost:${PORT}`); +}); diff --git a/ui/src/ui/types/novnc.d.ts b/ui/src/ui/types/novnc.d.ts new file mode 100644 index 00000000000..98524632afd --- /dev/null +++ b/ui/src/ui/types/novnc.d.ts @@ -0,0 +1,22 @@ +// novnc.d.ts +declare module "@novnc/novnc/lib/rfb.js" { + export default class RFB { + constructor(target: HTMLElement, url: string, options?: unknown); + addEventListener(event: string, callback: (e?: unknown) => void): void; + disconnect(): void; + scaleViewport: boolean; + clipViewport: boolean; + resize?(): void; + } +} + +declare module "@novnc/novnc" { + export default class RFB { + constructor(target: HTMLElement, url: string, options?: unknown); + addEventListener(event: string, callback: (e?: unknown) => void): void; + disconnect(): void; + scaleViewport: boolean; + clipViewport: boolean; + resize?(): void; + } +} From 4be856caf327f7d00dc650578dc778d6df6372d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B8=80=E5=AF=B0?= Date: Fri, 13 Mar 2026 16:58:34 +0800 Subject: [PATCH 02/28] change size for remote desktop --- ui/src/ui/app-render.ts | 14 +++++++++- ui/src/ui/app.ts | 11 ++++++++ ui/src/ui/components/resizable-divider.ts | 31 ++++++++++++++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 871f4b55244..7c03998a07f 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -591,7 +591,7 @@ export function renderApp(state: AppViewState) { : nothing } ${ - state.tab === "config" + state.tab === "config" || state.tab === "chat" ? nothing : html`
@@ -1317,7 +1317,17 @@ export function renderApp(state: AppViewState) { ${ state.tab === "chat" ? html` +
+
+
+ ${renderChatSessionSelect(state)} +
+
+ ${state.lastError ? html`
${state.lastError}
` : nothing} + ${renderChatControls(state)} +
+
${renderChat({ @@ -1445,6 +1455,7 @@ export function renderApp(state: AppViewState) { ? html` ` : nothing } diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 7f936722ca5..9f103cdc25c 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -170,6 +170,9 @@ export class OpenClawApp extends LitElement { @state() sidebarError: string | null = null; @state() splitRatio = this.settings.splitRatio; + @state() showClawComputer = false; + @state() clawComputerWidth = 600; + @state() nodesLoading = false; @state() nodes: Array> = []; @state() devicesLoading = false; @@ -710,6 +713,14 @@ export class OpenClawApp extends LitElement { this.applySettings({ ...this.settings, splitRatio: newRatio }); } + toggleClawComputer() { + this.showClawComputer = !this.showClawComputer; + } + + setClawComputerWidth(width: number) { + this.clawComputerWidth = width; + } + render() { return renderApp(this as unknown as AppViewState); } diff --git a/ui/src/ui/components/resizable-divider.ts b/ui/src/ui/components/resizable-divider.ts index defec19e5cb..74e421596ac 100644 --- a/ui/src/ui/components/resizable-divider.ts +++ b/ui/src/ui/components/resizable-divider.ts @@ -11,9 +11,16 @@ export class ResizableDivider extends LitElement { @property({ type: Number }) minRatio = 0.4; @property({ type: Number }) maxRatio = 0.7; + @property({ type: String }) mode: "ratio" | "pixels" = "ratio"; + @property({ type: String }) side: "left" | "right" = "left"; + @property({ type: Number }) initialWidth = 0; + @property({ type: Number }) minWidth = 0; + @property({ type: Number }) maxWidth = Infinity; + private isDragging = false; private startX = 0; private startRatio = 0; + private startWidth = 0; static styles = css` :host { @@ -60,6 +67,7 @@ export class ResizableDivider extends LitElement { this.isDragging = true; this.startX = e.clientX; this.startRatio = this.splitRatio; + this.startWidth = this.initialWidth; this.classList.add("dragging"); document.addEventListener("mousemove", this.handleMouseMove); @@ -78,8 +86,29 @@ export class ResizableDivider extends LitElement { return; } - const containerWidth = container.getBoundingClientRect().width; const deltaX = e.clientX - this.startX; + + if (this.mode === "pixels") { + let newWidth = this.startWidth; + if (this.side === "left") { + newWidth += deltaX; + } else { + newWidth -= deltaX; + } + + newWidth = Math.max(this.minWidth, Math.min(this.maxWidth, newWidth)); + + this.dispatchEvent( + new CustomEvent("resize", { + detail: { width: newWidth }, + bubbles: true, + composed: true, + }), + ); + return; + } + + const containerWidth = container.getBoundingClientRect().width; const deltaRatio = deltaX / containerWidth; let newRatio = this.startRatio + deltaRatio; From 0e1cbf5ba50e7647914942d101cba82ca7504437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B8=80=E5=AF=B0?= Date: Fri, 13 Mar 2026 17:36:03 +0800 Subject: [PATCH 03/28] add vnc success --- ui/package.json | 1 + ui/src/ui/app.ts | 1 + ui/src/ui/components/claw-computer-panel.ts | 91 ++++++++++++--------- 3 files changed, 56 insertions(+), 37 deletions(-) diff --git a/ui/package.json b/ui/package.json index c326f70cf3a..e0162770999 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,7 @@ "@lit-labs/signals": "^0.2.0", "@lit/context": "^1.1.6", "@noble/ed25519": "3.0.0", + "@novnc/novnc": "^1.6.0", "dompurify": "^3.3.3", "lit": "^3.3.2", "marked": "^17.0.4", diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 9f103cdc25c..87bc945a2cb 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -87,6 +87,7 @@ import type { import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts"; +import "./components/claw-computer-panel.ts"; declare global { interface Window { diff --git a/ui/src/ui/components/claw-computer-panel.ts b/ui/src/ui/components/claw-computer-panel.ts index a6312e054f6..6a64707366a 100644 --- a/ui/src/ui/components/claw-computer-panel.ts +++ b/ui/src/ui/components/claw-computer-panel.ts @@ -1,25 +1,32 @@ +// @ts-ignore - noVNC types are not available +import RFB from "@novnc/novnc"; // ui/src/ui/components/claw-computer-panel.ts import { LitElement, html, css } from "lit"; import { customElement, state } from "lit/decorators.js"; import { createRef, ref, Ref } from "lit/directives/ref.js"; +// Compatible with both .default and non-.default versions +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const RFBClass = (RFB as any).default || RFB; + +// RFB instance type definition interface RFBInstance { disconnect(): void; addEventListener(event: string, callback: (e?: unknown) => void): void; scaleViewport: boolean; clipViewport: boolean; + resizeSession: boolean; resize?(): void; } @customElement("claw-computer-panel") export class ClawComputerPanel extends LitElement { - @state() status = "🖥️ Claw Computer 未配置"; + @state() status = "等待連接..."; @state() isConnected = false; @state() isFitted = true; @state() password = ""; private rfb: RFBInstance | null = null; - private RFBConstructor: unknown = null; private screenRef: Ref = createRef(); static styles = css` @@ -111,7 +118,10 @@ export class ClawComputerPanel extends LitElement { render() { return html`
-

Claw Computer - VNC 遠端畫面

+

noVNC - 自動調整版(穩定無錯誤)

+

+ ws://localhost:8081(已轉發到你的 10.75.171.0:25900) +

-
+
`; } - private async loadRFB() { - if (this.RFBConstructor) { - return; - } - const module = await import("@novnc/novnc/lib/rfb.js"); - this.RFBConstructor = (module as unknown as { default?: unknown }).default || module; - } - private connect = async () => { - await this.loadRFB(); - const url = "ws://localhost:8081"; if (this.rfb) { this.rfb.disconnect(); } this.status = "正在連接..."; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + let screen = this.screenRef.value; - const screen = this.screenRef.value; if (!screen) { + // Fallback: try to find the element via shadowRoot if ref failed + screen = this.shadowRoot?.querySelector(".screen") as HTMLDivElement; + } + + if (!screen) { + console.error("Screen element not found"); + this.status = "初始化失败:找不到屏幕元素"; return; } - const Constructor = this.RFBConstructor as new ( - target: HTMLElement, - url: string, - options?: unknown, - ) => RFBInstance; - this.rfb = new Constructor(screen, url, { - credentials: { password: this.password || undefined }, - resizeSession: true, - clipViewport: true, - scaleViewport: this.isFitted, - }); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Constructor = RFBClass as new ( + target: HTMLElement, + url: string, + options?: unknown, + ) => RFBInstance; - this.rfb.addEventListener("connect", () => { - this.isConnected = true; - this.status = "已連線成功 ✓(改變視窗大小會自動適配)"; - setTimeout(() => this.rfb?.resize?.(), 100); - }); + this.rfb = new Constructor(screen, url, { + credentials: { password: this.password || undefined }, + resizeSession: true, + clipViewport: true, + }); - this.rfb.addEventListener("disconnect", () => { - this.isConnected = false; - this.status = "連線中斷"; - this.rfb = null; - }); + if (this.rfb) { + this.rfb.scaleViewport = this.isFitted; + } + + this.rfb?.addEventListener("connect", () => { + this.isConnected = true; + this.status = "已連線成功 ✓(改變視窗大小會自動適配)"; + setTimeout(() => this.rfb?.resize?.(), 100); + }); + + this.rfb?.addEventListener("disconnect", () => { + this.isConnected = false; + this.status = "連線中斷"; + this.rfb = null; + }); + } catch (error) { + console.error("Failed to create RFB instance:", error); + this.status = `连接失败: ${error as string}`; + } }; private disconnect = () => { @@ -225,7 +243,6 @@ export class ClawComputerPanel extends LitElement { firstUpdated() { window.addEventListener("resize", this.handleResize); - void this.loadRFB(); } disconnectedCallback() { From 75e68f063805061626fb5cfa95e1126fcfa273eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B8=80=E5=AF=B0?= Date: Fri, 13 Mar 2026 17:42:21 +0800 Subject: [PATCH 04/28] change mv range --- ui/src/ui/app-render.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 7c03998a07f..bfe010d9c68 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1458,8 +1458,8 @@ export function renderApp(state: AppViewState) { side="right" orientation="vertical" .initialWidth=${state.clawComputerWidth} - .minWidth=${400} - .maxWidth=${900} + .minWidth=${300} + .maxWidth=${3000} @resize=${(e: CustomEvent) => { if (e.detail.width !== undefined) { state.setClawComputerWidth(e.detail.width); From 735886ba13b5731ecd2859606c6044dba48ed232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B8=80=E5=AF=B0?= Date: Fri, 13 Mar 2026 18:38:05 +0800 Subject: [PATCH 05/28] config on overview --- ui/src/ui/app-render.ts | 4 +- ui/src/ui/components/claw-computer-panel.ts | 20 +++++-- ui/src/ui/storage.ts | 11 ++++ ui/src/ui/views/overview.ts | 60 +++++++++++++++------ 4 files changed, 74 insertions(+), 21 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index bfe010d9c68..dab71302dea 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1472,8 +1472,8 @@ export function renderApp(state: AppViewState) {
` diff --git a/ui/src/ui/components/claw-computer-panel.ts b/ui/src/ui/components/claw-computer-panel.ts index 6a64707366a..c9ab8b83212 100644 --- a/ui/src/ui/components/claw-computer-panel.ts +++ b/ui/src/ui/components/claw-computer-panel.ts @@ -2,8 +2,9 @@ import RFB from "@novnc/novnc"; // ui/src/ui/components/claw-computer-panel.ts import { LitElement, html, css } from "lit"; -import { customElement, state } from "lit/decorators.js"; +import { customElement, state, property } from "lit/decorators.js"; import { createRef, ref, Ref } from "lit/directives/ref.js"; +// @ts-ignore - noVNC types are not available // Compatible with both .default and non-.default versions // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -21,13 +22,16 @@ interface RFBInstance { @customElement("claw-computer-panel") export class ClawComputerPanel extends LitElement { + @property() vncUrl = "ws://localhost:8081"; + @property() password = ""; + @state() status = "等待連接..."; @state() isConnected = false; @state() isFitted = true; - @state() password = ""; private rfb: RFBInstance | null = null; private screenRef: Ref = createRef(); + private autoConnectAttempted = false; static styles = css` :host { @@ -120,7 +124,7 @@ export class ClawComputerPanel extends LitElement {

noVNC - 自動調整版(穩定無錯誤)

- ws://localhost:8081(已轉發到你的 10.75.171.0:25900) + ${this.vncUrl}(已轉發到你的 10.75.171.0:25900)

@@ -152,7 +156,7 @@ export class ClawComputerPanel extends LitElement { } private connect = async () => { - const url = "ws://localhost:8081"; + const url = this.vncUrl || "ws://localhost:8081"; if (this.rfb) { this.rfb.disconnect(); } @@ -243,6 +247,14 @@ export class ClawComputerPanel extends LitElement { firstUpdated() { window.addEventListener("resize", this.handleResize); + + // Auto-connect if URL is configured + if (this.vncUrl) { + // Use setTimeout to ensure DOM is fully ready and to allow UI to render first + setTimeout(() => { + void this.connect(); + }, 100); + } } disconnectedCallback() { diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 4a46b8d0703..8da8fe27c60 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -22,6 +22,9 @@ export type UiSettings = { navWidth: number; // Sidebar width when expanded (240–400px) navGroupsCollapsed: Record; // Which nav groups are collapsed locale?: string; + vncWsUrl?: string; + vncPassword?: string; + vncTarget?: string; }; function isViteDevPage(): boolean { @@ -135,6 +138,8 @@ export function loadSettings(): UiSettings { navCollapsed: false, navWidth: 220, navGroupsCollapsed: {}, + vncWsUrl: "ws://localhost:8081", + vncTarget: "10.75.171.25900", }; try { @@ -190,6 +195,9 @@ export function loadSettings(): UiSettings { ? parsed.navGroupsCollapsed : defaults.navGroupsCollapsed, locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined, + vncWsUrl: typeof parsed.vncWsUrl === "string" ? parsed.vncWsUrl : defaults.vncWsUrl, + vncPassword: typeof parsed.vncPassword === "string" ? parsed.vncPassword : undefined, + vncTarget: typeof parsed.vncTarget === "string" ? parsed.vncTarget : defaults.vncTarget, }; if ("token" in parsed) { persistSettings(settings); @@ -219,6 +227,9 @@ function persistSettings(next: UiSettings) { navWidth: next.navWidth, navGroupsCollapsed: next.navGroupsCollapsed, ...(next.locale ? { locale: next.locale } : {}), + vncWsUrl: next.vncWsUrl, + vncPassword: next.vncPassword, + vncTarget: next.vncTarget, }; localStorage.setItem(KEY, JSON.stringify(persisted)); } diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index ed8ef6fb740..8bbca92f099 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -299,24 +299,13 @@ export function renderOverview(props: OverviewProps) {
-
- - - ${ - isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint") - } -
${ !props.connected ? html` - diff --git a/ui/src/ui/components/claw-computer-panel.ts b/ui/src/ui/components/claw-computer-panel.ts index 5e009a0bf5d..1de639515ad 100644 --- a/ui/src/ui/components/claw-computer-panel.ts +++ b/ui/src/ui/components/claw-computer-panel.ts @@ -37,8 +37,8 @@ export class ClawComputerPanel extends LitElement { :host { display: block; height: 100%; - background: #0a0a0a; - color: #eee; + background: var(--bg-accent); + color: var(--text); font-family: system-ui, sans-serif; } .container { @@ -51,7 +51,7 @@ export class ClawComputerPanel extends LitElement { flex: 1; width: 100%; height: 100%; - background: #000; + background: var(--bg-accent); overflow: hidden; position: relative; display: flex; @@ -59,21 +59,50 @@ export class ClawComputerPanel extends LitElement { justify-content: center; } .screen { - width: 100%; - height: 100%; + /* Remove fixed width/height to allow shrink-wrapping */ + width: auto; + height: auto; + /* Maximize but preserve aspect ratio via flex item behavior */ + max-width: 100%; + max-height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + .screen > div { + /* Fix for noVNC wrapper div */ + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 100% !important; + height: 100% !important; + background: transparent !important; + } + .screen canvas { + /* Force canvas to maintain aspect ratio within container */ + max-width: 100% !important; + max-height: 100% !important; + width: auto !important; + height: auto !important; + /* Remove default canvas outline/border */ + outline: none; + display: block; /* Remove inline gap */ + margin: auto !important; } .status-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); - background: rgba(0, 0, 0, 0.7); + background: var(--card); padding: 16px 24px; border-radius: 8px; - color: #fff; + color: var(--text); font-weight: 500; pointer-events: none; z-index: 10; + border: 1px solid var(--border); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } `; From 114eea225747cdba656f77578c496bfee83d6c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B8=80=E5=AF=B0?= Date: Fri, 13 Mar 2026 19:28:04 +0800 Subject: [PATCH 10/28] optimize for vnc window --- ui/src/ui/app-render.ts | 1 + ui/src/ui/components/claw-computer-panel.ts | 65 ++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 5285fbc147b..8c7f7df4dac 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1504,6 +1504,7 @@ export function renderApp(state: AppViewState) { state.toggleClawComputer()} style="flex: 1; min-height: 0;" >
diff --git a/ui/src/ui/components/claw-computer-panel.ts b/ui/src/ui/components/claw-computer-panel.ts index 1de639515ad..be19518dbe5 100644 --- a/ui/src/ui/components/claw-computer-panel.ts +++ b/ui/src/ui/components/claw-computer-panel.ts @@ -68,8 +68,57 @@ export class ClawComputerPanel extends LitElement { display: flex; align-items: center; justify-content: center; + + /* macOS-style window border wrapper */ + padding: 6px; + padding-top: 36px; /* Extra space for title bar controls */ + background: color-mix( + in srgb, + var(--bg-accent), + black 10% + ); /* Ensure darker than container */ + border-radius: 6px; + box-shadow: + 0 0 0 1px var(--border), + 0 20px 50px rgba(0, 0, 0, 0.4); + box-sizing: border-box; + position: relative; } - .screen > div { + + /* Window controls (fake traffic lights) */ + .window-controls { + position: absolute; + top: 12px; + left: 12px; + display: flex; + gap: 8px; + z-index: 20; + } + + .window-control { + width: 12px; + height: 12px; + border-radius: 50%; + cursor: pointer; + border: 1px solid rgba(0, 0, 0, 0.1); + transition: + transform 0.1s, + opacity 0.2s; + } + .window-control:hover { + opacity: 0.8; + transform: scale(1.1); + } + + .window-control.maximize { + background-color: #27c93f; + border-color: #1aab29; + } + .window-control.close { + background-color: #ff5f56; + border-color: #e0443e; + } + .screen > div:not(.window-controls) { /* Fix for noVNC wrapper div */ display: flex !important; align-items: center !important; @@ -88,6 +137,8 @@ export class ClawComputerPanel extends LitElement { outline: none; display: block; /* Remove inline gap */ margin: auto !important; + border-radius: 0; + box-shadow: none; } .status-overlay { position: absolute; @@ -111,12 +162,22 @@ export class ClawComputerPanel extends LitElement {
${!this.isConnected ? html`
${this.status}
` : null}
-
+
+
+
+
+
+
`; } + private handleClose = () => { + // Dispatch event to parent to close the panel + this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true })); + }; + private connect = async () => { const url = this.vncUrl || "ws://localhost:8081"; if (this.rfb) { From c80d1755569657aabe4bf38d13d928a138c4a00e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B8=80=E5=AF=B0?= Date: Sat, 14 Mar 2026 15:48:08 +0800 Subject: [PATCH 11/28] fix bug for vnc ws proxy --- ui/package.json | 4 ++- ui/src/ui/server/proxy.ts | 33 ------------------ ui/vite.config.ts | 2 ++ ui/vnc-proxy-plugin.ts | 72 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 34 deletions(-) delete mode 100644 ui/src/ui/server/proxy.ts create mode 100644 ui/vnc-proxy-plugin.ts diff --git a/ui/package.json b/ui/package.json index e0162770999..3799b1e0800 100644 --- a/ui/package.json +++ b/ui/package.json @@ -21,9 +21,11 @@ "vite": "8.0.0" }, "devDependencies": { + "@types/ws": "^8.18.1", "@vitest/browser-playwright": "4.1.0", "jsdom": "^28.1.0", "playwright": "^1.58.2", - "vitest": "4.1.0" + "vitest": "4.1.0", + "ws": "^8.19.0" } } diff --git a/ui/src/ui/server/proxy.ts b/ui/src/ui/server/proxy.ts deleted file mode 100644 index c4e47296eef..00000000000 --- a/ui/src/ui/server/proxy.ts +++ /dev/null @@ -1,33 +0,0 @@ -import http from "http"; -// server/proxy.ts -import { WebSocket, WebSocketServer } from "ws"; - -const PORT = 8081; -const TARGET_VNC = "ws://10.75.171.0:25900"; // ← 改成你的真實 VNC 位址 - -const server = http.createServer(); -const wss = new WebSocketServer({ server }); - -wss.on("connection", (clientWs) => { - console.log("[Proxy] Client connected"); - - const targetWs = new WebSocket(TARGET_VNC); - - targetWs.on("open", () => console.log("[Proxy] 已連線到真實 VNC")); - - clientWs.on("message", (data) => targetWs.readyState === WebSocket.OPEN && targetWs.send(data)); - targetWs.on("message", (data) => clientWs.readyState === WebSocket.OPEN && clientWs.send(data)); - - const cleanup = () => { - targetWs.close(); - clientWs.close(); - }; - clientWs.on("close", cleanup); - targetWs.on("close", cleanup); - clientWs.on("error", cleanup); - targetWs.on("error", cleanup); -}); - -server.listen(PORT, () => { - console.log(`✅ noVNC Proxy 啟動成功 → ws://localhost:${PORT}`); -}); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index e5a525f9ab7..7804f369740 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; +import { vncProxyPlugin } from "./vnc-proxy-plugin.ts"; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -40,6 +41,7 @@ export default defineConfig(() => { strictPort: true, }, plugins: [ + vncProxyPlugin(), { name: "control-ui-dev-stubs", configureServer(server) { diff --git a/ui/vnc-proxy-plugin.ts b/ui/vnc-proxy-plugin.ts new file mode 100644 index 00000000000..91bcc433513 --- /dev/null +++ b/ui/vnc-proxy-plugin.ts @@ -0,0 +1,72 @@ +import * as net from "net"; +import type { PluginOption } from "vite"; +import { WebSocketServer, type RawData } from "ws"; + +// Use environment variables or hardcoded defaults from the external script +const VNC_HOST = process.env.OPENCLAW_VNC_HOST || "10.75.171.0"; +const VNC_PORT = parseInt(process.env.OPENCLAW_VNC_PORT || "25900", 10); +const WS_PATH = "/vnc"; + +export function vncProxyPlugin(): PluginOption { + return { + name: "openclaw-vnc-proxy", + configureServer(server) { + // Create a WebSocket server that shares the Vite HTTP server + const wss = new WebSocketServer({ + noServer: true, + path: WS_PATH, + perMessageDeflate: false, + }); + + console.log(`🚀 [Proxy] VNC WebSocket proxy injected at ${WS_PATH}`); + console.log(` Forwarding to: ${VNC_HOST}:${VNC_PORT}`); + + wss.on("connection", (ws) => { + console.log(`[VNC Proxy] Client connected to ${WS_PATH}`); + + const tcpSocket = net.connect(VNC_PORT, VNC_HOST); + + tcpSocket.on("data", (data) => { + if (ws.readyState === ws.OPEN) { + ws.send(data); + } + }); + + ws.on("message", (data: RawData) => { + if (!tcpSocket.writable) { + return; + } + + if (Buffer.isBuffer(data)) { + tcpSocket.write(data); + } else if (Array.isArray(data)) { + tcpSocket.write(Buffer.concat(data)); + } else { + tcpSocket.write(Buffer.from(data)); + } + }); + + ws.on("close", () => tcpSocket.end()); + tcpSocket.on("close", () => ws.close()); + + tcpSocket.on("error", (e) => { + console.error("[VNC Proxy] TCP Error:", e.message); + ws.close(); + }); + ws.on("error", (e) => { + console.error("[VNC Proxy] WebSocket Error:", e.message); + tcpSocket.end(); + }); + }); + + // Hook into Vite's HTTP server upgrade event + server.httpServer?.on("upgrade", (req, socket, head) => { + if (req.url === WS_PATH) { + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + } + }); + }, + }; +} From 8cd238d9aa563b909dd2e66ac808cea2aa1548b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B8=80=E5=AF=B0?= Date: Sat, 14 Mar 2026 16:32:10 +0800 Subject: [PATCH 12/28] fix vnc bug --- ui/src/ui/app-render.ts | 2 + ui/src/ui/components/claw-computer-panel.ts | 47 +++++++++++++++++++-- ui/src/ui/storage.ts | 7 ++- ui/src/ui/views/overview.ts | 2 +- ui/vnc-proxy-plugin.ts | 32 +++++++++++--- 5 files changed, 77 insertions(+), 13 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 8336fd8561f..105804eaaef 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1509,7 +1509,9 @@ export function renderApp(state: AppViewState) { " > state.toggleClawComputer()} style="flex: 1; min-height: 0;" diff --git a/ui/src/ui/components/claw-computer-panel.ts b/ui/src/ui/components/claw-computer-panel.ts index be19518dbe5..1c3a0f4e5fb 100644 --- a/ui/src/ui/components/claw-computer-panel.ts +++ b/ui/src/ui/components/claw-computer-panel.ts @@ -22,7 +22,8 @@ interface RFBInstance { @customElement("claw-computer-panel") export class ClawComputerPanel extends LitElement { - @property() vncUrl = "ws://localhost:8081"; + @property() vncUrl = ""; + @property() vncTarget = ""; @property() password = ""; @state() status = "等待連接..."; @@ -33,6 +34,20 @@ export class ClawComputerPanel extends LitElement { private screenRef: Ref = createRef(); private autoConnectAttempted = false; + @property({ type: Boolean }) enabled = false; + + updated(changedProperties: Map) { + if (changedProperties.has("enabled")) { + if (this.enabled) { + if (!this.isConnected) { + setTimeout(() => void this.connect(), 100); + } + } else { + this.disconnect(); + } + } + } + static styles = css` :host { display: block; @@ -179,7 +194,25 @@ export class ClawComputerPanel extends LitElement { }; private connect = async () => { - const url = this.vncUrl || "ws://localhost:8081"; + let url = + this.vncUrl || `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/vnc`; + + // Append target configuration if available + if (this.vncTarget) { + try { + const urlObj = new URL(url); + urlObj.searchParams.set("target", this.vncTarget); + url = urlObj.toString(); + } catch { + // Fallback for non-standard WebSocket URLs if URL parsing fails + if (url.includes("?")) { + url += `&target=${encodeURIComponent(this.vncTarget)}`; + } else { + url += `?target=${encodeURIComponent(this.vncTarget)}`; + } + } + } + if (this.rfb) { this.rfb.disconnect(); } @@ -213,6 +246,12 @@ export class ClawComputerPanel extends LitElement { clipViewport: true, }); + // @ts-ignore + this.rfb.addEventListener("securityfailure", (e: CustomEvent) => { + console.error("VNC security failure:", e.detail); + this.status = `Security negotiation failed: ${e.detail.reason || "Unknown reason"}`; + }); + if (this.rfb) { this.rfb.scaleViewport = this.isFitted; } @@ -271,8 +310,8 @@ export class ClawComputerPanel extends LitElement { firstUpdated() { window.addEventListener("resize", this.handleResize); - // Auto-connect if URL is configured - if (this.vncUrl) { + // Auto-connect if enabled and URL is configured + if (this.enabled && this.vncUrl) { // Use setTimeout to ensure DOM is fully ready and to allow UI to render first setTimeout(() => { void this.connect(); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 8da8fe27c60..6914e026cc1 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -138,8 +138,11 @@ export function loadSettings(): UiSettings { navCollapsed: false, navWidth: 220, navGroupsCollapsed: {}, - vncWsUrl: "ws://localhost:8081", - vncTarget: "10.75.171.25900", + vncWsUrl: + typeof location !== "undefined" + ? `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/vnc` + : "ws://localhost:18789/vnc", + vncTarget: "localhost:5900", }; try { diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 2b26181c034..d3829731ca2 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -347,7 +347,7 @@ export function renderOverview(props: OverviewProps) { const v = (e.target as HTMLInputElement).value; props.onSettingsChange({ ...props.settings, vncTarget: v }); }} - placeholder="10.75.171.25900" + placeholder="localhost:5900" />