From 9e8fd16e6e4154ae0bccff8342e4b0c7780d8db8 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 6 Oct 2025 16:15:10 -0400 Subject: [PATCH] wip: zen --- packages/console/app/src/context/auth.ts | 1 + packages/console/app/src/lib/beta.ts | 7 + packages/console/app/src/routes/auth/index.ts | 22 ++- .../app/src/routes/workspace-picker.css | 184 ++++++++++++++++++ .../app/src/routes/workspace-picker.tsx | 144 ++++++++++++++ packages/console/app/src/routes/workspace.tsx | 11 +- .../console/app/src/routes/workspace/[id].tsx | 14 +- packages/console/core/src/account.ts | 17 +- packages/console/core/src/actor.ts | 9 + .../console/core/src/schema/workspace.sql.ts | 2 +- packages/console/core/src/user.ts | 2 - packages/console/core/src/workspace.ts | 64 +++--- packages/console/function/src/auth.ts | 22 ++- 13 files changed, 437 insertions(+), 62 deletions(-) create mode 100644 packages/console/app/src/lib/beta.ts create mode 100644 packages/console/app/src/routes/workspace-picker.css create mode 100644 packages/console/app/src/routes/workspace-picker.tsx diff --git a/packages/console/app/src/context/auth.ts b/packages/console/app/src/context/auth.ts index 14a876fd..14f27556 100644 --- a/packages/console/app/src/context/auth.ts +++ b/packages/console/app/src/context/auth.ts @@ -73,6 +73,7 @@ export const getActor = async (workspace?: string): Promise => { properties: { userID: user.id, workspaceID: user.workspaceID, + accountID: user.accountID, }, } } diff --git a/packages/console/app/src/lib/beta.ts b/packages/console/app/src/lib/beta.ts new file mode 100644 index 00000000..910731c5 --- /dev/null +++ b/packages/console/app/src/lib/beta.ts @@ -0,0 +1,7 @@ +import { query } from "@solidjs/router" +import { Resource } from "@opencode/console-resource" + +export const beta = query(async (workspaceID?: string) => { + "use server" + return Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true +}, "beta") diff --git a/packages/console/app/src/routes/auth/index.ts b/packages/console/app/src/routes/auth/index.ts index 59d17238..f522e761 100644 --- a/packages/console/app/src/routes/auth/index.ts +++ b/packages/console/app/src/routes/auth/index.ts @@ -1,11 +1,29 @@ -import { Account } from "@opencode-ai/console-core/account.js" +import { Actor } from "@opencode-ai/console-core/actor.js" +import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js" +import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" +import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" import { redirect } from "@solidjs/router" import type { APIEvent } from "@solidjs/start/server" import { withActor } from "~/context/auth.withActor" export async function GET(input: APIEvent) { try { - const workspaces = await withActor(async () => Account.workspaces()) + const workspaces = await withActor(async () => { + const actor = Actor.assert("account") + return Database.transaction(async (tx) => + tx + .select({ id: WorkspaceTable.id }) + .from(UserTable) + .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id)) + .where( + and( + eq(UserTable.accountID, actor.properties.accountID), + isNull(UserTable.timeDeleted), + isNull(WorkspaceTable.timeDeleted), + ), + ), + ) + }) return redirect(`/workspace/${workspaces[0].id}`) } catch { return redirect("/auth/authorize") diff --git a/packages/console/app/src/routes/workspace-picker.css b/packages/console/app/src/routes/workspace-picker.css new file mode 100644 index 00000000..c22ced86 --- /dev/null +++ b/packages/console/app/src/routes/workspace-picker.css @@ -0,0 +1,184 @@ +[data-component="workspace-picker"] { + position: relative; + /* Override blue accent colors with neutral colors */ + --color-accent: var(--color-border); + --color-accent-hover: var(--color-border); + --color-accent-active: var(--color-border); + --color-primary: var(--color-border); + --color-primary-hover: var(--color-border); + --color-primary-active: var(--color-border); + --color-primary-alpha-20: transparent; + + [data-slot="trigger"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + font-family: var(--font-sans); + cursor: pointer; + min-width: 200px; + + span { + flex: 1; + text-align: left; + font-weight: 500; + } + } + + [data-slot="chevron"] { + flex-shrink: 0; + color: var(--color-text-secondary); + } + + [data-slot="dropdown"] button { + text-decoration: none !important; + } + + /* Ensure text inside buttons has no underline */ + [data-slot="dropdown"] button * { + text-decoration: none !important; + } + + [data-slot="dropdown"] { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1000; + margin-top: var(--space-1); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + max-height: 240px; + overflow-y: auto; + + @media (prefers-color-scheme: dark) { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + } + } + + [data-slot="option"], + [data-slot="create-option"] { + width: 100%; + padding: var(--space-2-5) var(--space-3); + border: none; + background: none; + color: var(--color-text); + font-size: var(--font-size-sm); + font-family: var(--font-sans); + text-align: left; + cursor: pointer; + text-decoration: none; + + &:hover { + background-color: var(--color-surface); + text-decoration: none; + } + + &:focus { + text-decoration: none; + } + + &:active { + text-decoration: none; + } + + &:first-child { + border-top-left-radius: var(--border-radius-sm); + border-top-right-radius: var(--border-radius-sm); + } + + &:last-child { + border-bottom-left-radius: var(--border-radius-sm); + border-bottom-right-radius: var(--border-radius-sm); + } + } + + [data-slot="option"][data-selected="true"] { + background-color: transparent; + color: var(--color-text); + } + + [data-slot="create-option"] { + color: var(--color-text-secondary); + font-weight: 500; + } + + [data-slot="create-form"] { + margin-top: var(--space-4); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-surface); + } + + [data-slot="create-input-group"] { + display: flex; + gap: var(--space-2); + align-items: center; + + @media (max-width: 30rem) { + flex-direction: column; + align-items: stretch; + } + } + + [data-slot="create-input"] { + flex: 1; + padding: var(--space-2-5) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + font-family: var(--font-sans); + + &:focus { + outline: none; + border-color: var(--color-border); + box-shadow: none; + } + + &::placeholder { + color: var(--color-text-muted); + } + } + + button[type="submit"], + button[type="button"] { + padding: var(--space-2-5) var(--space-4); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + font-family: var(--font-sans); + font-weight: 500; + cursor: pointer; + white-space: nowrap; + + &:focus { + outline: none; + box-shadow: none; + } + + &:active { + transform: translateY(1px); + } + + &[data-color="primary"] { + background-color: var(--color-text-secondary); + border-color: var(--color-text-secondary); + color: var(--color-bg); + } + + @media (max-width: 30rem) { + flex: 1; + } + } +} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace-picker.tsx b/packages/console/app/src/routes/workspace-picker.tsx new file mode 100644 index 00000000..18182633 --- /dev/null +++ b/packages/console/app/src/routes/workspace-picker.tsx @@ -0,0 +1,144 @@ +import { query, useParams, action, createAsync, redirect } from "@solidjs/router" +import { For, Show, createEffect, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { withActor } from "~/context/auth.withActor" +import { Actor } from "@opencode-ai/console-core/actor.js" +import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js" +import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" +import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" +import { Workspace } from "@opencode-ai/console-core/workspace.js" +import "./workspace-picker.css" + +const getWorkspaces = query(async () => { + "use server" + return withActor(async () => { + return Database.transaction((tx) => + tx + .select({ + id: WorkspaceTable.id, + name: WorkspaceTable.name, + slug: WorkspaceTable.slug, + }) + .from(UserTable) + .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id)) + .where(and(eq(UserTable.accountID, Actor.account()), isNull(WorkspaceTable.timeDeleted))), + ) + }) +}, "workspaces") + +const createWorkspace = action(async (form: FormData) => { + "use server" + const name = form.get("workspaceName") as string + if (name?.trim()) { + return withActor(async () => { + const workspaceID = await Workspace.create({ name: name.trim() }) + return redirect(`/workspace/${workspaceID}`) + }) + } +}, "createWorkspace") + +export function WorkspacePicker() { + const params = useParams() + const workspaces = createAsync(() => getWorkspaces()) + const [store, setStore] = createStore({ + showForm: false, + showDropdown: false, + }) + let dropdownRef: HTMLDivElement | undefined + + const currentWorkspace = () => { + const ws = workspaces()?.find((w) => w.id === params.id) + return ws ? ws.name : "Select workspace" + } + + const handleWorkspaceNew = () => { + setStore({ showForm: true, showDropdown: false }) + } + + const handleSelectWorkspace = (workspaceID: string) => { + if (workspaceID === params.id) { + setStore("showDropdown", false) + return + } + + window.location.href = `/workspace/${workspaceID}` + } + + // Reset signals when workspace ID changes + createEffect(() => { + params.id + setStore("showForm", false) + setStore("showDropdown", false) + }) + + createEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef && !dropdownRef.contains(event.target as Node)) { + setStore("showDropdown", false) + } + } + + document.addEventListener("click", handleClickOutside) + onCleanup(() => document.removeEventListener("click", handleClickOutside)) + }) + + return ( +
+
+
setStore("showDropdown", !store.showDropdown)}> + {currentWorkspace()} + + + +
+ + +
+ + {(workspace) => ( + + )} + + +
+
+
+ + +
+
+ + + +
+
+
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace.tsx b/packages/console/app/src/routes/workspace.tsx index 8e42815f..ac394f58 100644 --- a/packages/console/app/src/routes/workspace.tsx +++ b/packages/console/app/src/routes/workspace.tsx @@ -1,11 +1,14 @@ +import { Show } from "solid-js" +import { getRequestEvent } from "solid-js/web" +import { query, action, redirect, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router" import "./workspace.css" import { useAuthSession } from "~/context/auth.session" import { IconLogo } from "../component/icon" +import { WorkspacePicker } from "./workspace-picker" import { withActor } from "~/context/auth.withActor" -import { query, action, redirect, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router" import { User } from "@opencode-ai/console-core/user.js" import { Actor } from "@opencode-ai/console-core/actor.js" -import { getRequestEvent } from "solid-js/web" +import { beta } from "~/lib/beta" const getUserInfo = query(async (workspaceID: string) => { "use server" @@ -35,6 +38,7 @@ const logout = action(async () => { export default function WorkspaceLayout(props: RouteSectionProps) { const params = useParams() const userInfo = createAsync(() => getUserInfo(params.id)) + const isBeta = createAsync(() => beta(params.id)) return (
@@ -44,6 +48,9 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
+ + + {userInfo()?.email}