diff --git a/apps/labs/electron/main.mjs b/apps/labs/electron/main.mjs new file mode 100644 index 000000000..7118e3ec4 --- /dev/null +++ b/apps/labs/electron/main.mjs @@ -0,0 +1,54 @@ +import { app, BrowserWindow, shell } from "electron"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rendererUrl = process.env.LABS_RENDERER_URL || ""; + +let mainWindow = null; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1480, + height: 980, + minWidth: 1080, + minHeight: 720, + backgroundColor: "#141211", + titleBarStyle: "hiddenInset", + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + contextIsolation: true, + nodeIntegration: false, + webSecurity: false, + }, + }); + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url).catch(() => undefined); + return { action: "deny" }; + }); + + if (rendererUrl) { + mainWindow.loadURL(rendererUrl).catch(() => undefined); + } else { + mainWindow + .loadFile(path.join(__dirname, "..", "dist", "index.html")) + .catch(() => undefined); + } +} + +app.whenReady().then(() => { + createWindow(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); diff --git a/apps/labs/electron/preload.mjs b/apps/labs/electron/preload.mjs new file mode 100644 index 000000000..7894a68d6 --- /dev/null +++ b/apps/labs/electron/preload.mjs @@ -0,0 +1,6 @@ +import { contextBridge } from "electron"; + +contextBridge.exposeInMainWorld("openworkLabsDesktop", { + isDesktop: true, + platform: process.platform, +}); diff --git a/apps/labs/index.html b/apps/labs/index.html new file mode 100644 index 000000000..00b6df582 --- /dev/null +++ b/apps/labs/index.html @@ -0,0 +1,19 @@ + + + + + + + OpenWork Labs + + +
+ + + diff --git a/apps/labs/package.json b/apps/labs/package.json new file mode 100644 index 000000000..240c4373e --- /dev/null +++ b/apps/labs/package.json @@ -0,0 +1,30 @@ +{ + "name": "@openwork/labs", + "private": true, + "version": "0.0.1", + "type": "module", + "main": "electron/main.mjs", + "scripts": { + "dev": "node ./scripts/dev.mjs", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0 --port 3340 --strictPort", + "typecheck": "tsc -p tsconfig.json --noEmit", + "start": "electron ./electron/main.mjs" + }, + "dependencies": { + "@opencode-ai/sdk": "^1.1.31", + "@tanstack/react-virtual": "^3.13.23", + "react": "19.2.4", + "react-dom": "19.2.4", + "streamdown": "^2.5.0" + }, + "devDependencies": { + "@types/node": "^25.4.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "^5.0.4", + "electron": "^41.1.1", + "typescript": "^5.9.3", + "vite": "^7.1.12" + } +} diff --git a/apps/labs/pr/openwork-labs-template-starter.png b/apps/labs/pr/openwork-labs-template-starter.png new file mode 100644 index 000000000..dd18b7e81 Binary files /dev/null and b/apps/labs/pr/openwork-labs-template-starter.png differ diff --git a/apps/labs/scripts/dev.mjs b/apps/labs/scripts/dev.mjs new file mode 100644 index 000000000..f557b2a37 --- /dev/null +++ b/apps/labs/scripts/dev.mjs @@ -0,0 +1,84 @@ +import { spawn } from "node:child_process"; + +const rendererPort = 3340; +const rendererUrl = `http://127.0.0.1:${rendererPort}`; + +const children = []; +let shuttingDown = false; + +function cleanup(exitCode = 0) { + if (shuttingDown) return; + shuttingDown = true; + + for (const child of children) { + if (!child.killed) { + child.kill("SIGTERM"); + } + } + + setTimeout(() => process.exit(exitCode), 120); +} + +function spawnLogged(command, args, env = process.env) { + const child = spawn(command, args, { + stdio: "inherit", + env, + shell: process.platform === "win32", + }); + children.push(child); + return child; +} + +async function waitForRenderer(url, timeoutMs = 30_000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // Ignore until the server is ready. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw new Error(`Timed out waiting for ${url}`); +} + +process.on("SIGINT", () => cleanup(0)); +process.on("SIGTERM", () => cleanup(0)); + +const vite = spawnLogged("pnpm", [ + "exec", + "vite", + "--host", + "0.0.0.0", + "--port", + String(rendererPort), + "--strictPort", +]); + +vite.on("exit", (code) => { + if (!shuttingDown) { + cleanup(code ?? 1); + } +}); + +try { + await waitForRenderer(rendererUrl); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + cleanup(1); +} + +const electron = spawnLogged( + "pnpm", + ["exec", "electron", "./electron/main.mjs"], + { + ...process.env, + LABS_RENDERER_URL: rendererUrl, + }, +); + +electron.on("exit", (code) => { + cleanup(code ?? 0); +}); diff --git a/apps/labs/src/app.tsx b/apps/labs/src/app.tsx new file mode 100644 index 000000000..08421dcec --- /dev/null +++ b/apps/labs/src/app.tsx @@ -0,0 +1,948 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Streamdown } from "streamdown"; +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"; + +import { fetchTemplateProfile, builtInTemplates } from "./templates"; +import { workspaceNameFromUrl } from "./opencode"; +import type { + LabsStarter, + LabsTemplateProfile, + MessageWithParts, + SeedMessage, + WorkspaceConnectionStatus, +} from "./types"; +import { useLabsApp } from "./use-labs-app"; + +const DEFAULT_EMPTY_STATE = { + title: "Where are my workspaces, what chats do I have, and how do I start from something useful?", + body: "Connect a workspace, then open a template or start typing.", +}; + +const synthesizeSeedMessages = (seedMessages: SeedMessage[]) => + seedMessages.map((seed, index) => ({ + info: { + id: `seed-${index}`, + sessionID: `seed-session-${index}`, + role: seed.role, + time: { + created: index, + }, + } as Message, + parts: [ + { + id: `seed-part-${index}`, + messageID: `seed-${index}`, + sessionID: `seed-session-${index}`, + type: "text", + text: seed.text, + } as Part, + ], + })) satisfies MessageWithParts[]; + +const formatRelativeTime = (timestampMs?: number | null) => { + if (!timestampMs) return "Just now"; + const delta = Date.now() - timestampMs; + if (delta < 60_000) return `${Math.max(1, Math.round(delta / 1000))}s ago`; + if (delta < 60 * 60_000) return `${Math.max(1, Math.round(delta / 60_000))}m ago`; + if (delta < 24 * 60 * 60_000) return `${Math.max(1, Math.round(delta / (60 * 60_000)))}h ago`; + return new Date(timestampMs).toLocaleDateString(); +}; + +const sessionTitle = (session: Session | null | undefined) => { + const title = typeof session?.title === "string" ? session.title.trim() : ""; + return title || "Untitled chat"; +}; + +const workspaceInitials = (value: string) => + value + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part.charAt(0).toUpperCase()) + .join("") || "OW"; + +const connectionLabel = (status: WorkspaceConnectionStatus) => { + if (status === "connected") return "Live"; + if (status === "connecting") return "Checking"; + return "Offline"; +}; + +export function App() { + const labs = useLabsApp(); + const [composerText, setComposerText] = useState(""); + const [workspaceModalOpen, setWorkspaceModalOpen] = useState(false); + const [templateModalOpen, setTemplateModalOpen] = useState(false); + const [templateUrl, setTemplateUrl] = useState(""); + const [templateBusy, setTemplateBusy] = useState(false); + const [templateError, setTemplateError] = useState(null); + const [previewTemplate, setPreviewTemplate] = useState(null); + const [selectedTemplateId, setSelectedTemplateId] = useState(builtInTemplates[0]?.id ?? ""); + const [templateTargetMode, setTemplateTargetMode] = useState<"current" | "existing" | "new">("current"); + const [templateWorkspaceId, setTemplateWorkspaceId] = useState(""); + const [newWorkspaceName, setNewWorkspaceName] = useState(""); + const [newWorkspaceUrl, setNewWorkspaceUrl] = useState(""); + const [newWorkspaceToken, setNewWorkspaceToken] = useState(""); + const [workspaceForm, setWorkspaceForm] = useState({ + name: "", + baseUrl: "", + token: "", + }); + + const activeWorkspace = labs.activeWorkspace; + const activeSessions = labs.activeSessions; + const selectedSessionId = labs.selectedSessionId; + const selectedSession = useMemo( + () => activeSessions.find((session) => session.id === selectedSessionId) ?? null, + [activeSessions, selectedSessionId], + ); + const activeConnection = activeWorkspace + ? labs.state.connectionByWorkspaceId[activeWorkspace.id] ?? { + status: "disconnected", + message: "Not connected", + } + : null; + const selectedSessionMessages = selectedSessionId + ? labs.state.messagesBySessionId[selectedSessionId] ?? [] + : []; + const selectedSeedMessages = selectedSessionId + ? labs.state.seedMessagesBySessionId[selectedSessionId] ?? [] + : []; + const visibleMessages = useMemo(() => { + if (selectedSessionMessages.length > 0) return selectedSessionMessages; + if (selectedSeedMessages.length > 0) return synthesizeSeedMessages(selectedSeedMessages); + return [] as MessageWithParts[]; + }, [selectedSeedMessages, selectedSessionMessages]); + const sessionBusy = selectedSessionId + ? labs.state.statusBySessionId[selectedSessionId] === "busy" + : false; + const emptyState = activeWorkspace?.template?.blueprint.emptyState ?? DEFAULT_EMPTY_STATE; + const starterCards = activeWorkspace?.template?.blueprint.emptyState.starters ?? []; + + const templateLibrary = useMemo(() => { + const merged = [...builtInTemplates, ...labs.state.templates]; + const seen = new Set(); + return merged.filter((template) => { + const key = template.sourceUrl || template.id; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }, [labs.state.templates]); + + const displayTemplates = useMemo(() => { + if (!previewTemplate) return templateLibrary; + return [previewTemplate, ...templateLibrary.filter((template) => template.id !== previewTemplate.id)]; + }, [previewTemplate, templateLibrary]); + + const selectedTemplate = + displayTemplates.find((template) => template.id === selectedTemplateId) ?? displayTemplates[0] ?? null; + + useEffect(() => { + if (selectedTemplate) { + setSelectedTemplateId(selectedTemplate.id); + } + }, [selectedTemplate]); + + useEffect(() => { + if (!templateModalOpen) return; + if (labs.activeWorkspace) { + setTemplateTargetMode("current"); + setTemplateWorkspaceId(labs.activeWorkspace.id); + setNewWorkspaceName(""); + setNewWorkspaceUrl(""); + setNewWorkspaceToken(""); + } else if (labs.state.workspaces.length > 0) { + setTemplateTargetMode("existing"); + setTemplateWorkspaceId(labs.state.workspaces[0]?.id ?? ""); + } else { + setTemplateTargetMode("new"); + } + }, [labs.activeWorkspace, labs.state.workspaces, templateModalOpen]); + + const handleAddWorkspace = () => { + setWorkspaceForm({ + name: activeWorkspace?.name ?? "", + baseUrl: activeWorkspace?.baseUrl ?? "", + token: activeWorkspace?.token ?? "", + }); + setWorkspaceModalOpen(true); + }; + + const handleWorkspaceSave = () => { + if (!workspaceForm.baseUrl.trim()) return; + const workspaceId = labs.saveWorkspace(workspaceForm); + setWorkspaceModalOpen(false); + labs.setActiveWorkspace(workspaceId); + }; + + const handleFetchTemplate = async () => { + setTemplateBusy(true); + setTemplateError(null); + try { + const template = await fetchTemplateProfile(templateUrl); + setPreviewTemplate(template); + setSelectedTemplateId(template.id); + } catch (error) { + setTemplateError(error instanceof Error ? error.message : String(error)); + } finally { + setTemplateBusy(false); + } + }; + + const handleApplyTemplate = async () => { + if (!selectedTemplate) return; + + let workspaceId = activeWorkspace?.id ?? null; + if (templateTargetMode === "existing") { + workspaceId = templateWorkspaceId || null; + } + if (templateTargetMode === "new") { + if (!newWorkspaceUrl.trim()) { + setTemplateError("Add a server URL so Labs knows where to create the starter chats."); + return; + } + workspaceId = labs.saveWorkspace({ + name: newWorkspaceName, + baseUrl: newWorkspaceUrl, + token: newWorkspaceToken, + }); + } + + if (!workspaceId) { + setTemplateError("Choose where this template should land."); + return; + } + + await labs.applyTemplateToWorkspace(workspaceId, selectedTemplate); + labs.setActiveWorkspace(workspaceId); + setTemplateModalOpen(false); + }; + + const handleSend = async () => { + if (!activeWorkspace || !composerText.trim()) return; + const sessionId = await labs.sendPrompt(activeWorkspace.id, selectedSessionId, composerText); + if (sessionId) { + setComposerText(""); + await labs.selectSession(activeWorkspace.id, sessionId); + } + }; + + const handleStarter = async (starter: LabsStarter) => { + if (!activeWorkspace) { + if (starter.kind === "action") { + setTemplateModalOpen(true); + } else { + setWorkspaceModalOpen(true); + } + return; + } + + if (starter.kind === "prompt") { + setComposerText(starter.prompt ?? starter.title); + return; + } + + if (starter.kind === "session") { + const materialized = activeWorkspace.template?.blueprint.materialized.find( + (item) => item.templateId === starter.id, + ); + if (materialized) { + await labs.selectSession(activeWorkspace.id, materialized.sessionId); + return; + } + + const sessionId = await labs.createSession(activeWorkspace.id); + if (sessionId && starter.prompt) { + await labs.sendPrompt(activeWorkspace.id, sessionId, starter.prompt); + await labs.selectSession(activeWorkspace.id, sessionId); + } + return; + } + + if (starter.kind === "action") { + const nextAction = labs.openTemplateActionForStarter(activeWorkspace.id, starter.action); + if (nextAction === "template-library") { + setTemplateModalOpen(true); + } + } + }; + + const handleCopyTemplateLink = async () => { + if (!activeWorkspace?.template?.sourceUrl) return; + try { + await navigator.clipboard.writeText(activeWorkspace.template.sourceUrl); + } catch { + // Ignore clipboard failures in the web fallback. + } + }; + + const noWorkspaces = labs.state.workspaces.length === 0; + const noSelectedSession = !selectedSessionId; + const showStarterSurface = visibleMessages.length === 0; + + return ( +
+
+
+ + + + + +
+
+
+
+

Active chat

+

{selectedSession ? sessionTitle(selectedSession) : activeWorkspace?.name ?? "OpenWork Labs"}

+
+ +
+ + +
+
+ + {labs.state.error ? ( +
+ {labs.state.error} + +
+ ) : null} + +
+ {noWorkspaces ? ( + setTemplateModalOpen(true)} /> + ) : showStarterSurface ? ( + + ) : ( + + )} +
+ +