From cb22373549a354ad9050d9fc9143777a1abbab0b Mon Sep 17 00:00:00 2001 From: fullstack-engineer Date: Fri, 22 May 2026 22:42:08 -0700 Subject: [PATCH 1/2] Add display unavailable surface --- canvas/src/components/SidePanel.tsx | 3 + .../__tests__/SidePanel.tabs.test.tsx | 18 ++-- canvas/src/components/tabs/DisplayTab.tsx | 96 +++++++++++++++++++ .../tabs/__tests__/DisplayTab.test.tsx | 33 +++++++ canvas/src/store/canvas.ts | 2 +- .../internal/handlers/workspace_compute.go | 48 ++++++++++ .../handlers/workspace_compute_test.go | 35 +++++++ workspace-server/internal/router/router.go | 4 + 8 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 canvas/src/components/tabs/DisplayTab.tsx create mode 100644 canvas/src/components/tabs/__tests__/DisplayTab.test.tsx diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx index bb608bf40..8ae568753 100644 --- a/canvas/src/components/SidePanel.tsx +++ b/canvas/src/components/SidePanel.tsx @@ -9,6 +9,7 @@ import { DetailsTab } from "./tabs/DetailsTab"; import { SkillsTab } from "./tabs/SkillsTab"; import { ChatTab } from "./tabs/ChatTab"; import { ConfigTab } from "./tabs/ConfigTab"; +import { DisplayTab } from "./tabs/DisplayTab"; import { TerminalTab } from "./tabs/TerminalTab"; import { FilesTab } from "./tabs/FilesTab"; import { MemoryInspectorPanel } from "./MemoryInspectorPanel"; @@ -31,6 +32,7 @@ const TABS: { id: PanelTab; label: string; icon: string }[] = [ { id: "details", label: "Details", icon: "◉" }, { id: "skills", label: "Plugins", icon: "✦" }, { id: "terminal", label: "Terminal", icon: "▸" }, + { id: "display", label: "Display", icon: "▣" }, { id: "config", label: "Config", icon: "⚙" }, { id: "schedule", label: "Schedule", icon: "⏲" }, { id: "channels", label: "Channels", icon: "⇌" }, @@ -300,6 +302,7 @@ export function SidePanel() { {panelTab === "activity" && } {panelTab === "chat" && } {panelTab === "terminal" && } + {panelTab === "display" && } {panelTab === "config" && } {panelTab === "schedule" && } {panelTab === "channels" && } diff --git a/canvas/src/components/__tests__/SidePanel.tabs.test.tsx b/canvas/src/components/__tests__/SidePanel.tabs.test.tsx index 8de0252cd..f9d1e4fe2 100644 --- a/canvas/src/components/__tests__/SidePanel.tabs.test.tsx +++ b/canvas/src/components/__tests__/SidePanel.tabs.test.tsx @@ -11,6 +11,7 @@ vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null })); vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null })); vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null })); vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null })); +vi.mock("../tabs/DisplayTab", () => ({ DisplayTab: () => null })); vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null })); vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null })); vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null })); @@ -74,7 +75,7 @@ import { SidePanel } from "../SidePanel"; const TABS = [ "chat", "activity", "details", "skills", "terminal", - "config", "schedule", "channels", "files", "memory", "traces", "events", "audit", + "display", "config", "schedule", "channels", "files", "memory", "traces", "events", "audit", ]; describe("SidePanel — ARIA tablist pattern", () => { @@ -85,10 +86,15 @@ describe("SidePanel — ARIA tablist pattern", () => { expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs"); }); - it("renders exactly 13 tab buttons", () => { + it("renders exactly 14 tab buttons", () => { render(); const tabs = screen.getAllByRole("tab"); - expect(tabs.length).toBe(13); + expect(tabs.length).toBe(14); + }); + + it("renders the Display tab", () => { + render(); + expect(document.getElementById("tab-display")).toBeTruthy(); }); it("active tab (chat) has aria-selected='true'", () => { @@ -99,11 +105,11 @@ describe("SidePanel — ARIA tablist pattern", () => { expect(chatTab?.getAttribute("aria-selected")).toBe("true"); }); - it("all other 12 tabs have aria-selected='false'", () => { + it("all other 13 tabs have aria-selected='false'", () => { render(); const tabs = screen.getAllByRole("tab"); const inactive = tabs.filter((t) => t.id !== "tab-chat"); - expect(inactive.length).toBe(12); + expect(inactive.length).toBe(13); for (const tab of inactive) { expect(tab.getAttribute("aria-selected")).toBe("false"); } @@ -116,7 +122,7 @@ describe("SidePanel — ARIA tablist pattern", () => { const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1"); expect(zeros.length).toBe(1); expect(zeros[0].id).toBe("tab-chat"); - expect(minusOnes.length).toBe(12); + expect(minusOnes.length).toBe(13); }); it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => { diff --git a/canvas/src/components/tabs/DisplayTab.tsx b/canvas/src/components/tabs/DisplayTab.tsx new file mode 100644 index 000000000..e9fa002ad --- /dev/null +++ b/canvas/src/components/tabs/DisplayTab.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; + +interface DisplayStatus { + available: boolean; + reason?: string; + mode?: string; + status?: string; + protocol?: string; + width?: number; + height?: number; +} + +interface Props { + workspaceId: string; +} + +export function DisplayTab({ workspaceId }: Props) { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setStatus(null); + setError(null); + api + .get(`/workspaces/${workspaceId}/display`) + .then((data) => { + if (!cancelled) setStatus(data); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "Display status unavailable"); + }); + return () => { + cancelled = true; + }; + }, [workspaceId]); + + if (error) { + return ( +
+
+

Display status unavailable

+

{error}

+
+
+ ); + } + + if (!status) { + return ( +
+
+
+ ); + } + + if (!status.available) { + const isNotEnabled = status.reason === "display_not_enabled"; + return ( +
+ +

+ {isNotEnabled ? "Display is not enabled for this workspace." : "Display session is not ready."} +

+

+ {isNotEnabled + ? "Recreate this workspace with display enabled to view and take over its desktop." + : "This workspace has display configuration, but the desktop session infrastructure is not configured yet."} +

+ {!isNotEnabled && ( +
+
Mode
+
{status.mode || "unknown"}
+
Status
+
{status.status || "unknown"}
+
+ )} +
+ ); + } + + return null; +} diff --git a/canvas/src/components/tabs/__tests__/DisplayTab.test.tsx b/canvas/src/components/tabs/__tests__/DisplayTab.test.tsx new file mode 100644 index 000000000..ad34b421c --- /dev/null +++ b/canvas/src/components/tabs/__tests__/DisplayTab.test.tsx @@ -0,0 +1,33 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; + +const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() })); + +vi.mock("@/lib/api", () => ({ + api: { + get: mockGet, + }, +})); + +import { DisplayTab } from "../DisplayTab"; + +describe("DisplayTab", () => { + beforeEach(() => { + mockGet.mockReset(); + }); + + it("renders unavailable state for non-display workspaces", async () => { + mockGet.mockResolvedValueOnce({ + available: false, + reason: "display_not_enabled", + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Display is not enabled for this workspace.")).toBeTruthy(); + }); + expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-no-display/display"); + }); +}); diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts index fd8117982..6bdabf715 100644 --- a/canvas/src/store/canvas.ts +++ b/canvas/src/store/canvas.ts @@ -130,7 +130,7 @@ export interface WorkspaceNodeData extends Record { deliveryMode?: string; } -export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit"; +export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit"; export interface ContextMenuState { x: number; diff --git a/workspace-server/internal/handlers/workspace_compute.go b/workspace-server/internal/handlers/workspace_compute.go index 58522f955..ddb0dc212 100644 --- a/workspace-server/internal/handlers/workspace_compute.go +++ b/workspace-server/internal/handlers/workspace_compute.go @@ -9,6 +9,7 @@ import ( "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" "github.com/Molecule-AI/molecule-monorepo/platform/internal/models" + "github.com/gin-gonic/gin" ) const ( @@ -126,3 +127,50 @@ func withStoredCompute(ctx context.Context, workspaceID string, payload models.C payload.Compute = compute return payload } + +// Display handles GET /workspaces/:id/display. +// +// Phase 1 only exposes the product contract and the non-display unavailable +// state. Future desktop-control work will replace the display-enabled branch +// with short-lived proxied DCV session details. +func (h *WorkspaceHandler) Display(c *gin.Context) { + workspaceID := c.Param("id") + var raw string + err := db.DB.QueryRowContext(c.Request.Context(), + `SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`, + workspaceID, + ).Scan(&raw) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(404, gin.H{"error": "workspace not found"}) + return + } + log.Printf("Display: load compute for %s failed: %v", workspaceID, err) + c.JSON(500, gin.H{"error": "failed to load display config"}) + return + } + var compute models.WorkspaceCompute + if raw != "" && raw != "{}" { + if err := json.Unmarshal([]byte(raw), &compute); err != nil { + log.Printf("Display: invalid compute JSON for %s: %v", workspaceID, err) + c.JSON(500, gin.H{"error": "invalid display config"}) + return + } + } + if compute.Display.Mode == "" || compute.Display.Mode == "none" { + c.JSON(200, gin.H{ + "available": false, + "reason": "display_not_enabled", + }) + return + } + c.JSON(200, gin.H{ + "available": false, + "reason": "display_session_unavailable", + "mode": compute.Display.Mode, + "protocol": compute.Display.Protocol, + "width": compute.Display.Width, + "height": compute.Display.Height, + "status": "not_configured", + }) +} diff --git a/workspace-server/internal/handlers/workspace_compute_test.go b/workspace-server/internal/handlers/workspace_compute_test.go index f82c4262a..96933b8be 100644 --- a/workspace-server/internal/handlers/workspace_compute_test.go +++ b/workspace-server/internal/handlers/workspace_compute_test.go @@ -3,6 +3,7 @@ package handlers import ( "bytes" "context" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -173,3 +174,37 @@ func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) { t.Errorf("unmet sqlmock expectations: %v", err) } } + +func TestWorkspaceDisplay_NonDisplayWorkspaceReturnsUnavailable(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`). + WithArgs("ws-no-display"). + WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`)) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-no-display"}} + c.Request = httptest.NewRequest("GET", "/workspaces/ws-no-display/display", nil) + + handler.Display(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse display response: %v", err) + } + if resp["available"] != false { + t.Fatalf("available = %v, want false", resp["available"]) + } + if resp["reason"] != "display_not_enabled" { + t.Fatalf("reason = %v, want display_not_enabled", resp["reason"]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} diff --git a/workspace-server/internal/router/router.go b/workspace-server/internal/router/router.go index 7ce0c1834..5d2bad31e 100644 --- a/workspace-server/internal/router/router.go +++ b/workspace-server/internal/router/router.go @@ -178,6 +178,10 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi // the tenant AWS credentials. Admin-gated because console output // can include user-data snippets we treat as semi-sensitive. wsAdmin.GET("/workspaces/:id/console", wh.Console) + // Display sessions will eventually return short-lived proxied DCV + // URLs, so keep the endpoint admin-gated from the first unavailable + // state rather than widening it later. + wsAdmin.GET("/workspaces/:id/display", wh.Display) // Admin memory backup/restore (#1051) — bulk export/import of agent // memories for safe Docker rebuilds. Matches workspaces by name on import. -- 2.52.0 From ee2d62f6795ba70ae77413ab9990be183b2c72fc Mon Sep 17 00:00:00 2001 From: fullstack-engineer Date: Fri, 22 May 2026 22:59:54 -0700 Subject: [PATCH 2/2] Add display route auth regression test --- .../router/workspace_display_route_test.go | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 workspace-server/internal/router/workspace_display_route_test.go diff --git a/workspace-server/internal/router/workspace_display_route_test.go b/workspace-server/internal/router/workspace_display_route_test.go new file mode 100644 index 000000000..072df6513 --- /dev/null +++ b/workspace-server/internal/router/workspace_display_route_test.go @@ -0,0 +1,41 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware" + "github.com/gin-gonic/gin" +) + +func buildWorkspaceDisplayEngine(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + wh := handlers.NewWorkspaceHandler(nil, nil, "http://localhost:8080", t.TempDir()) + r.GET("/workspaces/:id/display", middleware.AdminAuth(db.DB), wh.Display) + return r +} + +func TestWorkspaceDisplayRoute_RequiresAdminAuth(t *testing.T) { + t.Setenv("ADMIN_TOKEN", "test-admin-secret-not-presented-by-caller") + mock := setupRouterTestDB(t) + mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := buildWorkspaceDisplayEngine(t) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/workspaces/ws-display/display", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for unauthenticated request, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock unmet: %v", err) + } +} -- 2.52.0