diff --git a/AGENTS.md b/AGENTS.md index 507cfea53..36a37713b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,3 +14,34 @@ ## Debugging - To test opencode in the `packages/opencode` directory you can run `bun dev` + +## Tool Calling + +- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environnement: + +json +{ + "recipient_name": "multi_tool_use.parallel", + "parameters": { + "tool_uses": [ + { + "recipient_name": "functions.read", + "parameters": { + "filePath": "path/to/file.tsx" + } + }, + { + "recipient_name": "functions.read", + "parameters": { + "filePath": "path/to/file.ts" + } + }, + { + "recipient_name": "functions.read", + "parameters": { + "filePath": "path/to/file.md" + } + } + ] + } +} diff --git a/README.md b/README.md index e1a9974f3..6e91d3ccb 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@

- - - opencode logo + + + OpenCode logo

-

AI coding agent, built for the terminal.

+

The AI coding agent built for the terminal.

Discord npm Build status

-[![opencode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) +[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) --- @@ -50,11 +50,11 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ### Documentation -For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs). +For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs). ### Contributing -opencode is an opinionated tool so any fundamental feature needs to go through a +OpenCode is an opinionated tool so any fundamental feature needs to go through a design process with the core team. > [!IMPORTANT] @@ -74,7 +74,7 @@ Take a look at the git history to see what kind of PRs we end up merging. > [!NOTE] > If you do not follow the above guidelines we might close your PR. -To run opencode locally you need. +To run OpenCode locally you need. - Bun - Golang 1.24.x @@ -88,7 +88,7 @@ $ bun dev #### Development Notes -**API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients. +**API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the OpenCode team to generate a new stainless sdk for the clients. ### FAQ @@ -97,9 +97,9 @@ $ bun dev It's very similar to Claude Code in terms of capability. Here are the key differences: - 100% open source -- Not coupled to any provider. Although Anthropic is recommended, opencode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important. -- A focus on TUI. opencode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal. -- A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients. +- Not coupled to any provider. Although Anthropic is recommended, OpenCode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important. +- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal. +- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients. #### What's the other repo? diff --git a/STATS.md b/STATS.md index ffcd1a17e..002e391c3 100644 --- a/STATS.md +++ b/STATS.md @@ -96,3 +96,4 @@ | 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) | | 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) | | 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) | +| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) | diff --git a/bun.lock b/bun.lock index 26ce83501..7306c8c19 100644 --- a/bun.lock +++ b/bun.lock @@ -48,11 +48,9 @@ "name": "@opencode/console-app", "dependencies": { "@ibm/plex": "6.4.1", - "@jsx-email/render": "1.1.1", "@kobalte/core": "catalog:", "@openauthjs/openauth": "catalog:", "@opencode/console-core": "workspace:*", - "@opencode/console-mail": "workspace:*", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.0", "@solidjs/start": "^1.1.0", @@ -66,6 +64,8 @@ "version": "0.14.0", "dependencies": { "@aws-sdk/client-sts": "3.782.0", + "@jsx-email/render": "1.1.1", + "@opencode/console-mail": "workspace:*", "@opencode/console-resource": "workspace:*", "@planetscale/database": "1.19.0", "aws4fetch": "1.0.20", diff --git a/packages/app/src/components/code.tsx b/packages/app/src/components/code.tsx index 63f527c46..40a40aa9a 100644 --- a/packages/app/src/components/code.tsx +++ b/packages/app/src/components/code.tsx @@ -1,8 +1,11 @@ import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki" import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js" import { useLocal, useShiki } from "@/context" +import type { TextSelection } from "@/context/local" import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils" +type DefinedSelection = Exclude + interface Props extends ComponentProps<"div"> { code: string path: string @@ -21,17 +24,66 @@ export function Code(props: Props) { let container: HTMLDivElement | undefined let isProgrammaticSelection = false - const [html] = createResource(async () => { - if (!highlighter.getLoadedLanguages().includes(lang())) { - await highlighter.loadLanguage(lang() as BundledLanguage) + const ranges = createMemo(() => { + const items = ctx.context.all() as Array<{ type: "file"; path: string; selection?: DefinedSelection }> + const result: DefinedSelection[] = [] + for (const item of items) { + if (item.path !== local.path) continue + const selection = item.selection + if (!selection) continue + result.push(selection) } - return highlighter.codeToHtml(local.code || "", { - lang: lang() && lang() in bundledLanguages ? lang() : "text", - theme: "opencode", - transformers: [transformerUnifiedDiff(), transformerDiffGroups()], - }) as string + return result }) + const createLineNumberTransformer = (selections: DefinedSelection[]): ShikiTransformer => { + const highlighted = new Set() + for (const selection of selections) { + const startLine = selection.startLine + const endLine = selection.endLine + const start = Math.max(1, Math.min(startLine, endLine)) + const end = Math.max(start, Math.max(startLine, endLine)) + const count = end - start + 1 + if (count <= 0) continue + const values = Array.from({ length: count }, (_, index) => start + index) + for (const value of values) highlighted.add(value) + } + return { + name: "line-number-highlight", + line(node, index) { + if (!highlighted.has(index)) return + this.addClassToHast(node, "line-number-highlight") + const children = node.children + if (!Array.isArray(children)) return + for (const child of children) { + if (!child || typeof child !== "object") continue + const element = child as { type?: string; properties?: { className?: string[] } } + if (element.type !== "element") continue + const className = element.properties?.className + if (!Array.isArray(className)) continue + const matches = className.includes("diff-oldln") || className.includes("diff-newln") + if (!matches) continue + if (className.includes("line-number-highlight")) continue + className.push("line-number-highlight") + } + }, + } + } + + const [html] = createResource( + () => ranges(), + async (activeRanges) => { + if (!highlighter.getLoadedLanguages().includes(lang())) { + await highlighter.loadLanguage(lang() as BundledLanguage) + } + return highlighter.codeToHtml(local.code || "", { + lang: lang() && lang() in bundledLanguages ? lang() : "text", + theme: "opencode", + transformers: [transformerUnifiedDiff(), transformerDiffGroups(), createLineNumberTransformer(activeRanges)], + }) as string + }, + ) + onMount(() => { if (!container) return @@ -283,7 +335,7 @@ export function Code(props: Props) { [&]:[counter-reset:line] [&_pre]:focus-visible:outline-none [&_pre]:overflow-x-auto [&_pre]:no-scrollbar - [&_code]:min-w-full [&_code]:inline-block [&_code]:pb-40 + [&_code]:min-w-full [&_code]:inline-block [&_.tab]:relative [&_.tab::before]:content['⇥'] [&_.tab::before]:absolute @@ -303,6 +355,9 @@ export function Code(props: Props) { [&_.line::before]:select-none [&_.line::before]:[counter-increment:line] [&_.line::before]:content-[counter(line)] + [&_.line-number-highlight]:bg-accent/20 + [&_.line-number-highlight::before]:bg-accent/40! + [&_.line-number-highlight::before]:text-background-panel! [&_code.code-diff_.line::before]:content-[''] [&_code.code-diff_.line::before]:w-0 [&_code.code-diff_.line::before]:pr-0 diff --git a/packages/app/src/components/editor-pane.tsx b/packages/app/src/components/editor-pane.tsx new file mode 100644 index 000000000..faf70811d --- /dev/null +++ b/packages/app/src/components/editor-pane.tsx @@ -0,0 +1,381 @@ +import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js" +import { Tabs } from "@/ui/tabs" +import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + createSortable, + useDragDropContext, +} from "@thisbeyond/solid-dnd" +import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" +import type { LocalFile } from "@/context/local" +import { Code } from "@/components/code" +import PromptForm from "@/components/prompt-form" +import { useLocal, useSDK, useSync } from "@/context" +import { getFilename } from "@/utils" +import type { JSX } from "solid-js" + +interface EditorPaneProps { + layoutKey: string + timelinePane: string + onFileClick: (file: LocalFile) => void + onOpenModelSelect: () => void + onInputRefChange: (element: HTMLTextAreaElement | null) => void +} + +export default function EditorPane(props: EditorPaneProps): JSX.Element { + const [localProps] = splitProps(props, [ + "layoutKey", + "timelinePane", + "onFileClick", + "onOpenModelSelect", + "onInputRefChange", + ]) + const local = useLocal() + const sdk = useSDK() + const sync = useSync() + const [activeItem, setActiveItem] = createSignal(undefined) + + const navigateChange = (dir: 1 | -1) => { + const active = local.file.active() + if (!active) return + const current = local.file.changeIndex(active.path) + const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir + local.file.setChangeIndex(active.path, next) + } + + const handleTabChange = (path: string) => { + local.file.open(path) + } + + const handleTabClose = (file: LocalFile) => { + local.file.close(file.path) + } + + const handlePromptSubmit = async (prompt: string) => { + const existingSession = local.layout.visible(localProps.layoutKey, localProps.timelinePane) + ? local.session.active() + : undefined + let session = existingSession + if (!session) { + const created = await sdk.session.create() + session = created.data ?? undefined + } + if (!session) return + local.session.setActive(session.id) + local.layout.show(localProps.layoutKey, localProps.timelinePane) + + await sdk.session.prompt({ + path: { id: session.id }, + body: { + agent: local.agent.current()!.name, + model: { + modelID: local.model.current()!.id, + providerID: local.model.current()!.provider.id, + }, + parts: [ + { + type: "text", + text: prompt, + }, + ...(local.context.active() + ? [ + { + type: "file" as const, + mime: "text/plain", + url: `file://${local.context.active()!.absolute}`, + filename: local.context.active()!.name, + source: { + type: "file" as const, + text: { + value: "@" + local.context.active()!.name, + start: 0, + end: 0, + }, + path: local.context.active()!.absolute, + }, + }, + ] + : []), + ...local.context.all().flatMap((file) => [ + { + type: "file" as const, + mime: "text/plain", + url: `file://${sync.absolute(file.path)}${file.selection ? `?start=${file.selection.startLine}&end=${file.selection.endLine}` : ""}`, + filename: getFilename(file.path), + source: { + type: "file" as const, + text: { + value: "@" + getFilename(file.path), + start: 0, + end: 0, + }, + path: sync.absolute(file.path), + }, + }, + ]), + ], + }, + }) + } + + const handleDragStart = (event: unknown) => { + const id = getDraggableId(event) + if (!id) return + setActiveItem(id) + } + + const handleDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (draggable && droppable) { + const currentFiles = local.file.opened().map((file) => file.path) + const fromIndex = currentFiles.indexOf(draggable.id.toString()) + const toIndex = currentFiles.indexOf(droppable.id.toString()) + if (fromIndex !== toIndex) { + local.file.move(draggable.id.toString(), toIndex) + } + } + } + + const handleDragEnd = () => { + setActiveItem(undefined) + } + + return ( +
+ + + + + +
+ + file.path)}> + + {(file) => ( + + )} + + + +
+ + {(() => { + const activeFile = local.file.active()! + const view = local.file.view(activeFile.path) + return ( +
+ +
+ + navigateChange(-1)}> + + + + + navigateChange(1)}> + + + +
+
+ + local.file.setView(activeFile.path, "raw")} + > + + + + + local.file.setView(activeFile.path, "diff-unified")} + > + + + + + local.file.setView(activeFile.path, "diff-split")} + > + + + +
+ ) + })()} +
+ + local.layout.toggle(localProps.layoutKey, localProps.timelinePane)} + > + + + +
+
+ + {(file) => ( + + {(() => { + const view = local.file.view(file.path) + const showRaw = view === "raw" || !file.content?.diff + const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") + return + })()} + + )} + +
+ + {(() => { + const id = activeItem() + if (!id) return null + const draggedFile = local.file.node(id) + if (!draggedFile) return null + return ( +
+ +
+ ) + })()} +
+
+ localProps.onInputRefChange(element ?? null)} + /> +
+ ) +} + +function TabVisual(props: { file: LocalFile }): JSX.Element { + return ( +
+ + + {props.file.name} + + + + + M + + + A + + + D + + + +
+ ) +} + +function SortableTab(props: { + file: LocalFile + onTabClick: (file: LocalFile) => void + onTabClose: (file: LocalFile) => void +}): JSX.Element { + const sortable = createSortable(props.file.path) + + return ( + // @ts-ignore +
+ +
+ props.onTabClick(props.file)}> + + + props.onTabClose(props.file)} + > + + +
+
+
+ ) +} + +function ConstrainDragYAxis(): JSX.Element { + const context = useDragDropContext() + if (!context) return <> + const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context + const transformer: Transformer = { + id: "constrain-y-axis", + order: 100, + callback: (transform) => ({ ...transform, y: 0 }), + } + onDragStart((event) => { + const id = getDraggableId(event) + if (!id) return + addTransformer("draggables", id, transformer) + }) + onDragEnd((event) => { + const id = getDraggableId(event) + if (!id) return + removeTransformer("draggables", id, transformer.id) + }) + return <> +} + +const getDraggableId = (event: unknown): string | undefined => { + if (typeof event !== "object" || event === null) return undefined + if (!("draggable" in event)) return undefined + const draggable = (event as { draggable?: { id?: unknown } }).draggable + if (!draggable) return undefined + return typeof draggable.id === "string" ? draggable.id : undefined +} diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 12d357dd8..d31255ced 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -23,6 +23,30 @@ export default function FileTree(props: { [props.nodeClass ?? ""]: !!props.nodeClass, }} style={`padding-left: ${level * 10}px`} + draggable={true} + onDragStart={(e: any) => { + const evt = e as globalThis.DragEvent + evt.dataTransfer!.effectAllowed = "copy" + evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`) + + // Create custom drag image without margins + const dragImage = document.createElement("div") + dragImage.className = + "flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1" + dragImage.style.position = "absolute" + dragImage.style.top = "-1000px" + + // Copy only the icon and text content without padding + const icon = e.currentTarget.querySelector("svg") + const text = e.currentTarget.querySelector("span") + if (icon && text) { + dragImage.innerHTML = icon.outerHTML + text.outerHTML + } + + document.body.appendChild(dragImage) + evt.dataTransfer!.setDragImage(dragImage, 0, 12) + setTimeout(() => document.body.removeChild(dragImage), 0) + }} {...p} > {p.children} @@ -51,6 +75,7 @@ export default function FileTree(props: { (open ? local.file.expand(node.path) : local.file.collapse(node.path))} diff --git a/packages/app/src/components/prompt-form.tsx b/packages/app/src/components/prompt-form.tsx new file mode 100644 index 000000000..9d7c45a32 --- /dev/null +++ b/packages/app/src/components/prompt-form.tsx @@ -0,0 +1,295 @@ +import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { Button, FileIcon, Icon, IconButton, Tooltip } from "@/ui" +import { Select } from "@/components/select" +import { useLocal } from "@/context" +import type { FileContext, LocalFile } from "@/context/local" +import { getFilename } from "@/utils" +import { createSpeechRecognition } from "@/utils/speech" + +interface PromptFormProps { + class?: string + classList?: Record + onSubmit: (prompt: string) => Promise | void + onOpenModelSelect: () => void + onInputRefChange?: (element: HTMLTextAreaElement | undefined) => void +} + +export default function PromptForm(props: PromptFormProps) { + const local = useLocal() + + const [prompt, setPrompt] = createSignal("") + const [isDragOver, setIsDragOver] = createSignal(false) + + const placeholderText = "Start typing or speaking..." + + const { + isSupported, + isRecording, + interim: interimTranscript, + start: startSpeech, + stop: stopSpeech, + } = createSpeechRecognition({ + onFinal: (text) => setPrompt((prev) => (prev && !prev.endsWith(" ") ? prev + " " : prev) + text), + }) + + let inputRef: HTMLTextAreaElement | undefined = undefined + let overlayContainerRef: HTMLDivElement | undefined = undefined + let shouldAutoScroll = true + + const promptContent = createMemo(() => { + const base = prompt() || "" + const interim = isRecording() ? interimTranscript() : "" + if (!base && !interim) { + return {placeholderText} + } + const needsSpace = base && interim && !base.endsWith(" ") && !interim.startsWith(" ") + return ( + <> + {base} + {interim && ( + + {needsSpace ? " " : ""} + {interim} + + )} + + ) + }) + + createEffect(() => { + prompt() + interimTranscript() + queueMicrotask(() => { + if (!inputRef) return + if (!overlayContainerRef) return + if (!shouldAutoScroll) { + overlayContainerRef.scrollTop = inputRef.scrollTop + return + } + scrollPromptToEnd() + }) + }) + + const handlePromptKeyDown = (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => { + if (event.isComposing) return + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault() + inputRef?.form?.requestSubmit() + } + } + + const handlePromptScroll = (event: Event & { currentTarget: HTMLTextAreaElement }) => { + const target = event.currentTarget + shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4 + if (overlayContainerRef) overlayContainerRef.scrollTop = target.scrollTop + } + + const scrollPromptToEnd = () => { + if (!inputRef) return + const maxInputScroll = inputRef.scrollHeight - inputRef.clientHeight + const next = maxInputScroll > 0 ? maxInputScroll : 0 + inputRef.scrollTop = next + if (overlayContainerRef) overlayContainerRef.scrollTop = next + shouldAutoScroll = true + } + + const handleSubmit = async (event: SubmitEvent) => { + event.preventDefault() + const currentPrompt = prompt() + setPrompt("") + shouldAutoScroll = true + if (overlayContainerRef) overlayContainerRef.scrollTop = 0 + if (inputRef) { + inputRef.scrollTop = 0 + inputRef.blur() + } + + await props.onSubmit(currentPrompt) + } + + onCleanup(() => { + props.onInputRefChange?.(undefined) + }) + + return ( +
+
{ + const evt = event as unknown as globalThis.DragEvent + if (evt.dataTransfer?.types.includes("text/plain")) { + evt.preventDefault() + setIsDragOver(true) + } + }} + onDragLeave={(event) => { + if (event.currentTarget === event.target) { + setIsDragOver(false) + } + }} + onDragOver={(event) => { + const evt = event as unknown as globalThis.DragEvent + if (evt.dataTransfer?.types.includes("text/plain")) { + evt.preventDefault() + evt.dataTransfer.dropEffect = "copy" + } + }} + onDrop={(event) => { + const evt = event as unknown as globalThis.DragEvent + evt.preventDefault() + setIsDragOver(false) + + const data = evt.dataTransfer?.getData("text/plain") + if (data && data.startsWith("file:")) { + const filePath = data.slice(5) + const fileNode = local.file.node(filePath) + if (fileNode) { + local.context.add({ + type: "file", + path: filePath, + }) + } + } + }} + > + 0 || local.context.active()}> +
+ + local.context.removeActive()} /> + + + {(file) => local.context.remove(file.key)} />} + +
+
+
+ +
{ + overlayContainerRef = element ?? undefined + }} + class="pointer-events-none absolute inset-0 overflow-hidden" + > +
+ {promptContent()} +
+
+
+
+
+ (inputRef = el)} - type="text" - value={store.prompt} - onInput={(e) => setStore("prompt", e.currentTarget.value)} - placeholder="Placeholder text..." - class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none" - /> -
-
- Role: {props.member.role}
}> diff --git a/packages/console/app/src/routes/zen/handler.ts b/packages/console/app/src/routes/zen/handler.ts index f302ec073..be1a5b33d 100644 --- a/packages/console/app/src/routes/zen/handler.ts +++ b/packages/console/app/src/routes/zen/handler.ts @@ -69,7 +69,7 @@ export async function handler( // Request to model provider const startTimestamp = Date.now() - const res = await fetch(path.posix.join(providerInfo.api, url.pathname.replace(/^\/zen/, "") + url.search), { + const res = await fetch(path.posix.join(providerInfo.api, url.pathname.replace(/^\/zen\/v1/, "") + url.search), { method: "POST", headers: (() => { const headers = input.request.headers diff --git a/packages/console/app/src/routes/zen/index.css b/packages/console/app/src/routes/zen/index.css index 3856bd14b..88ca0762e 100644 --- a/packages/console/app/src/routes/zen/index.css +++ b/packages/console/app/src/routes/zen/index.css @@ -50,11 +50,21 @@ } } +body { + background: var(--color-background); +} + +@supports (background: -webkit-named-image(i)) { + [data-page="opencode"] { + border-top: 1px solid var(--color-border-weak); + } +} + + [data-page="zen"] { background: var(--color-background); --padding: 5rem; --vertical-padding: 4rem; - border-top: 1px solid var(--color-border-weak); @media (max-width: 60rem) { --padding: 1.5rem; @@ -77,6 +87,10 @@ p { line-height: 200%; + + @media (max-width: 60rem) { + line-height: 180%; + } } @media (max-width: 60rem) { @@ -90,6 +104,16 @@ transition: background-color 5000000s ease-in-out 0s; } + input:-webkit-autofill { + -webkit-text-fill-color: var(--color-text-strong) !important; + } + + input:-moz-autofill { + -moz-text-fill-color: var(--color-text-strong) !important; + } + + + [data-component="container"] { max-width: 67.5rem; @@ -149,6 +173,14 @@ } } + [data-component="nav-mobile"] { + button > svg { + color: var(--color-icon); + } + } + + + [data-component="nav-mobile-toggle"] { border: none; background: none; @@ -434,6 +466,10 @@ span { color: var(--color-icon); line-height: 200%; + + @media (max-width: 60rem) { + line-height: 180%; + } } div { @@ -482,14 +518,29 @@ border-radius: 6px; border: 1px solid var(--color-border-weak); padding: 20px; - display: flex; - flex-direction: column; - gap: 12px; width: 100%; + /* Use color, not -moz-text-fill-color, for normal text */ + color: var(--color-text-strong); + @media (max-width: 30rem) { padding-bottom: 80px; } + + &:not(:focus) { + color: var(--color-text-strong); + } + + &::placeholder { + color: var(--color-text-weak); + opacity: 1; + } + + /* Optional legacy */ + &::-moz-placeholder { + color: var(--color-text-weak); + opacity: 1; + } } input:focus { @@ -514,6 +565,8 @@ color: var(--color-text-inverted); border-radius: 4px; font-weight: 500; + border: none; + outline: none; cursor: pointer; top: 50%; margin-top: -20px; @@ -539,6 +592,10 @@ list-style: none; margin-bottom: 24px; line-height: 200%; + + @media (max-width: 60rem) { + line-height: 180%; + } } } @@ -555,6 +612,9 @@ [data-slot="faq-icon-plus"] { flex-shrink: 0; + color: var(--color-text-weak); + margin-top: 2px; + [data-closed] & { display: block; } @@ -564,6 +624,9 @@ } [data-slot="faq-icon-minus"] { flex-shrink: 0; + color: var(--color-text-weak); + margin-top: 2px; + [data-closed] & { display: none; } @@ -590,14 +653,10 @@ flex-direction: column; gap: 20px; - @media (max-width: 60rem) { - --padding: 1rem; - --vertical-padding: 1rem; - } - a { text-decoration: none; } + [data-slot="testimonial"] { background: var(--color-background-weak); border-radius: 6px; @@ -607,10 +666,9 @@ flex-direction: column; gap: 12px; - @media (max-width: 60rem) { + @media (max-width: 30rem) { flex-direction: column-reverse; gap: 24px; - padding: 24px 48px; } [data-slot="name"] { @@ -619,13 +677,14 @@ strong { font-weight: 500; + flex: 0 0 auto; } span { color: var(--color-text); } - @media (max-width: 60rem) { + @media (max-width: 30rem) { flex-direction: column; gap: 8px; } @@ -644,7 +703,7 @@ [data-slot="quote"] { margin-left: 40px; - @media (max-width: 60rem) { + @media (max-width: 30rem) { margin-left: 0; } span { @@ -751,6 +810,10 @@ [data-slot="cell"] + [data-slot="cell"] { border-left: 1px solid var(--color-border-weak); + + @media (max-width: 40rem) { + border-left: none; + } } /* Mobile: third column on its own row */ diff --git a/packages/console/app/src/routes/zen/index.tsx b/packages/console/app/src/routes/zen/index.tsx index 21e596080..57cca2e2b 100644 --- a/packages/console/app/src/routes/zen/index.tsx +++ b/packages/console/app/src/routes/zen/index.tsx @@ -146,9 +146,7 @@ export default function Home() {

What problem is Zen solving?

- There are so many models available, but only a few work well with coding agents. -
- Most providers configure them differently with varying results. + There are so many models available, but only a few work well with coding agents. Most providers configure them differently with varying results.

We're fixing this for everyone, not just OpenCode users.

@@ -218,8 +216,8 @@ export default function Home() { Dax Raad ex-CEO, Terminal Products
-
- OpenCode Zen has been life changing, it's truly a no-brainer +
@OpenCode Zen has been life + changing, it's truly a no-brainer.
@@ -237,7 +235,7 @@ export default function Home() {
{/*Adam*/} - +
@@ -329,6 +327,8 @@ export default function Home() { + +
diff --git a/packages/console/core/migrations/0022_nice_dreadnoughts.sql b/packages/console/core/migrations/0022_nice_dreadnoughts.sql new file mode 100644 index 000000000..60c7f8691 --- /dev/null +++ b/packages/console/core/migrations/0022_nice_dreadnoughts.sql @@ -0,0 +1,3 @@ +ALTER TABLE `user` ADD `account_id` varchar(30);--> statement-breakpoint +ALTER TABLE `user` ADD `old_account_id` varchar(30);--> statement-breakpoint +ALTER TABLE `user` ADD CONSTRAINT `user_account_id` UNIQUE(`workspace_id`,`account_id`); \ No newline at end of file diff --git a/packages/console/core/migrations/meta/0022_snapshot.json b/packages/console/core/migrations/meta/0022_snapshot.json new file mode 100644 index 000000000..9486ee345 --- /dev/null +++ b/packages/console/core/migrations/meta/0022_snapshot.json @@ -0,0 +1,724 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "2296e9e4-bee6-485b-a146-6666ac8dc0d0", + "prevId": "14616ba2-c21e-4787-a289-f2a3eb6de04f", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email": { + "name": "email", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": [ + "customer_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor": { + "name": "actor", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "old_name": { + "name": "old_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": [ + "key" + ], + "isUnique": true + }, + "name": { + "name": "name", + "columns": [ + "workspace_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "old_account_id": { + "name": "old_account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "old_email": { + "name": "old_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": [ + "workspace_id", + "account_id" + ], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": [ + "workspace_id", + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index 6879a3b3f..a240ce4a4 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1759186023755, "tag": "0021_flawless_clea", "breakpoints": true + }, + { + "idx": 22, + "version": "5", + "when": 1759427432588, + "tag": "0022_nice_dreadnoughts", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/console/core/package.json b/packages/console/core/package.json index c8d52f77a..f20c0cc66 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -6,6 +6,8 @@ "type": "module", "dependencies": { "@aws-sdk/client-sts": "3.782.0", + "@jsx-email/render": "1.1.1", + "@opencode/console-mail": "workspace:*", "@opencode/console-resource": "workspace:*", "@planetscale/database": "1.19.0", "aws4fetch": "1.0.20", diff --git a/packages/console/core/src/account.ts b/packages/console/core/src/account.ts index 3bed2bef1..cd1eed4b1 100644 --- a/packages/console/core/src/account.ts +++ b/packages/console/core/src/account.ts @@ -54,7 +54,7 @@ export namespace Account { .select(getTableColumns(WorkspaceTable)) .from(WorkspaceTable) .innerJoin(UserTable, eq(UserTable.workspaceID, WorkspaceTable.id)) - .where(and(eq(UserTable.email, actor.properties.email), isNull(WorkspaceTable.timeDeleted))) + .where(and(eq(UserTable.accountID, actor.properties.accountID), isNull(WorkspaceTable.timeDeleted))) .execute(), ) } diff --git a/packages/console/core/src/schema/user.sql.ts b/packages/console/core/src/schema/user.sql.ts index eaadb06d5..861c14b47 100644 --- a/packages/console/core/src/schema/user.sql.ts +++ b/packages/console/core/src/schema/user.sql.ts @@ -1,6 +1,7 @@ -import { mysqlTable, uniqueIndex, varchar, int, mysqlEnum } from "drizzle-orm/mysql-core" -import { timestamps, utc, workspaceColumns } from "../drizzle/types" +import { mysqlTable, uniqueIndex, varchar, int, mysqlEnum, foreignKey } from "drizzle-orm/mysql-core" +import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types" import { workspaceIndexes } from "./workspace.sql" +import { AccountTable } from "./account.sql" export const UserRole = ["admin", "member"] as const @@ -9,6 +10,8 @@ export const UserTable = mysqlTable( { ...workspaceColumns, ...timestamps, + accountID: ulid("account_id"), + oldAccountID: ulid("old_account_id"), email: varchar("email", { length: 255 }), oldEmail: varchar("old_email", { length: 255 }), name: varchar("name", { length: 255 }).notNull(), @@ -16,5 +19,14 @@ export const UserTable = mysqlTable( color: int("color"), role: mysqlEnum("role", UserRole).notNull(), }, - (table) => [...workspaceIndexes(table), uniqueIndex("user_email").on(table.workspaceID, table.email)], + (table) => [ + ...workspaceIndexes(table), + uniqueIndex("user_account_id").on(table.workspaceID, table.accountID), + uniqueIndex("user_email").on(table.workspaceID, table.email), + foreignKey({ + columns: [table.accountID], + foreignColumns: [AccountTable.id], + name: "global_account_id", + }), + ], ) diff --git a/packages/console/core/src/user.ts b/packages/console/core/src/user.ts index 7914926ff..8f00722e6 100644 --- a/packages/console/core/src/user.ts +++ b/packages/console/core/src/user.ts @@ -1,18 +1,188 @@ import { z } from "zod" -import { eq } from "drizzle-orm" +import { and, eq, getTableColumns, isNull, sql } from "drizzle-orm" import { fn } from "./util/fn" import { Database } from "./drizzle" -import { UserTable } from "./schema/user.sql" +import { UserRole, UserTable } from "./schema/user.sql" +import { Actor } from "./actor" +import { Identifier } from "./identifier" +import { render } from "@jsx-email/render" +import { InviteEmail } from "@opencode/console-mail/InviteEmail.jsx" +import { AWS } from "./aws" +import { Account } from "./account" +import { AccountTable } from "./schema/account.sql" export namespace User { - export const fromID = fn(z.string(), async (id) => - Database.transaction(async (tx) => { - return tx + const assertAdmin = async () => { + const actor = Actor.assert("user") + const user = await User.fromID(actor.properties.userID) + if (user?.role !== "admin") { + throw new Error(`Expected admin user, got ${user?.role}`) + } + } + + const assertNotSelf = (id: string) => { + const actor = Actor.assert("user") + if (actor.properties.userID === id) { + throw new Error(`Expected not self actor, got self actor`) + } + } + + export const list = fn(z.void(), () => + Database.use((tx) => + tx + .select({ + ...getTableColumns(UserTable), + accountEmail: AccountTable.email, + }) + .from(UserTable) + .leftJoin(AccountTable, eq(UserTable.accountID, AccountTable.id)) + .where(and(eq(UserTable.workspaceID, Actor.workspace()), isNull(UserTable.timeDeleted))), + ), + ) + + export const fromID = fn(z.string(), (id) => + Database.use((tx) => + tx .select() .from(UserTable) - .where(eq(UserTable.id, id)) - .execute() - .then((rows) => rows[0]) - }), + .where(and(eq(UserTable.workspaceID, Actor.workspace()), eq(UserTable.id, id), isNull(UserTable.timeDeleted))) + .then((rows) => rows[0]), + ), ) + + export const invite = fn( + z.object({ + email: z.string(), + role: z.enum(UserRole), + }), + async ({ email, role }) => { + await assertAdmin() + + const workspaceID = Actor.workspace() + await Database.transaction(async (tx) => { + const account = await Account.fromEmail(email) + const members = await tx.select().from(UserTable).where(eq(UserTable.workspaceID, Actor.workspace())) + + await (async () => { + if (account) { + // case: account previously invited and removed + if (members.some((m) => m.oldAccountID === account.id)) { + await tx + .update(UserTable) + .set({ + timeDeleted: null, + oldAccountID: null, + accountID: account.id, + }) + .where(and(eq(UserTable.workspaceID, Actor.workspace()), eq(UserTable.accountID, account.id))) + return + } + // case: account previously not invited + await tx + .insert(UserTable) + .values({ + id: Identifier.create("user"), + name: "", + accountID: account.id, + workspaceID, + role, + }) + .catch((e: any) => { + if (e.message.match(/Duplicate entry '.*' for key 'user.user_account_id'/)) + throw new Error("A user with this email has already been invited.") + throw e + }) + return + } + // case: email previously invited and removed + if (members.some((m) => m.oldEmail === email)) { + await tx + .update(UserTable) + .set({ + timeDeleted: null, + oldEmail: null, + email, + }) + .where(and(eq(UserTable.workspaceID, Actor.workspace()), eq(UserTable.email, email))) + return + } + // case: email previously not invited + await tx + .insert(UserTable) + .values({ + id: Identifier.create("user"), + name: "", + email, + workspaceID, + role, + }) + .catch((e: any) => { + if (e.message.match(/Duplicate entry '.*' for key 'user.user_email'/)) + throw new Error("A user with this email has already been invited.") + throw e + }) + })() + }) + + // send email, ignore errors + try { + await AWS.sendEmail({ + to: email, + subject: `You've been invited to join the ${workspaceID} workspace on OpenCode Zen`, + body: render( + // @ts-ignore + InviteEmail({ + assetsUrl: `https://opencode.ai/email`, + workspace: workspaceID, + }), + ), + }) + } catch (e) { + console.error(e) + } + }, + ) + + export const updateRole = fn( + z.object({ + id: z.string(), + role: z.enum(UserRole), + }), + async ({ id, role }) => { + await assertAdmin() + if (role === "member") assertNotSelf(id) + return await Database.use((tx) => + tx + .update(UserTable) + .set({ role }) + .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, Actor.workspace()))), + ) + }, + ) + + export const remove = fn(z.string(), async (id) => { + await assertAdmin() + assertNotSelf(id) + + return await Database.transaction(async (tx) => { + const user = await fromID(id) + if (!user) throw new Error("User not found") + + await tx + .update(UserTable) + .set({ + ...(user.email + ? { + oldEmail: user.email, + email: null, + } + : { + oldAccountID: user.accountID, + accountID: null, + }), + timeDeleted: sql`now()`, + }) + .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, Actor.workspace()))) + }) + }) } diff --git a/packages/console/core/src/workspace.ts b/packages/console/core/src/workspace.ts index d6eeb80cf..6119f51df 100644 --- a/packages/console/core/src/workspace.ts +++ b/packages/console/core/src/workspace.ts @@ -1,7 +1,7 @@ import { z } from "zod" import { fn } from "./util/fn" import { Actor } from "./actor" -import { Database, sql } from "./drizzle" +import { Database } from "./drizzle" import { Identifier } from "./identifier" import { UserTable } from "./schema/user.sql" import { BillingTable } from "./schema/billing.sql" @@ -19,7 +19,7 @@ export namespace Workspace { await tx.insert(UserTable).values({ workspaceID, id: Identifier.create("user"), - email: account.properties.email, + accountID: account.properties.accountID, name: "", role: "admin", }) @@ -34,9 +34,7 @@ export namespace Workspace { { workspaceID, }, - async () => { - await Key.create({ name: "Default API Key" }) - }, + () => Key.create({ name: "Default API Key" }), ) return workspaceID }) diff --git a/packages/console/core/tsconfig.json b/packages/console/core/tsconfig.json index 0faf16aab..3218dd7e3 100644 --- a/packages/console/core/tsconfig.json +++ b/packages/console/core/tsconfig.json @@ -4,6 +4,8 @@ "compilerOptions": { "module": "ESNext", "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "react", "types": ["@cloudflare/workers-types", "node"] } } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0368b24cd..e04ed8103 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -165,6 +165,7 @@ export const RunCommand = cmd({ } let text = "" + const messageID = Identifier.ascending("message") Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { if (evt.properties.part.sessionID !== session.id) return @@ -223,35 +224,34 @@ export const RunCommand = cmd({ UI.error(err) }) - if (args.command) { - await SessionPrompt.command({ - messageID: Identifier.ascending("message"), + const result = await (async () => { + if (args.command) { + return await SessionPrompt.command({ + messageID, + sessionID: session.id, + agent: agent.name, + model: providerID + "/" + modelID, + command: args.command, + arguments: message, + }) + } + return await SessionPrompt.prompt({ sessionID: session.id, - agent: agent.name, - model: providerID + "/" + modelID, - command: args.command, - arguments: message, - }) - return - } - - const messageID = Identifier.ascending("message") - const result = await SessionPrompt.prompt({ - sessionID: session.id, - messageID, - model: { - providerID, - modelID, - }, - agent: agent.name, - parts: [ - { - id: Identifier.ascending("part"), - type: "text", - text: message, + messageID, + model: { + providerID, + modelID, }, - ], - }) + agent: agent.name, + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: message, + }, + ], + }) + })() const isPiped = !process.stdout.isTTY if (isPiped) { diff --git a/packages/web/src/assets/logo-dark.svg b/packages/web/src/assets/logo-dark.svg index a4e433958..a15827324 100644 --- a/packages/web/src/assets/logo-dark.svg +++ b/packages/web/src/assets/logo-dark.svg @@ -1,12 +1,18 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/packages/web/src/assets/logo-light.svg b/packages/web/src/assets/logo-light.svg index cbfcccf51..2a856dcce 100644 --- a/packages/web/src/assets/logo-light.svg +++ b/packages/web/src/assets/logo-light.svg @@ -1,12 +1,18 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/packages/web/src/assets/logo-ornate-dark.svg b/packages/web/src/assets/logo-ornate-dark.svg index b937be0af..a15827324 100644 --- a/packages/web/src/assets/logo-ornate-dark.svg +++ b/packages/web/src/assets/logo-ornate-dark.svg @@ -1,18 +1,18 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/packages/web/src/assets/logo-ornate-light.svg b/packages/web/src/assets/logo-ornate-light.svg index 789223bc4..2a856dcce 100644 --- a/packages/web/src/assets/logo-ornate-light.svg +++ b/packages/web/src/assets/logo-ornate-light.svg @@ -1,18 +1,18 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/packages/web/src/styles/custom.css b/packages/web/src/styles/custom.css index deff8ae90..308e94a18 100644 --- a/packages/web/src/styles/custom.css +++ b/packages/web/src/styles/custom.css @@ -1,12 +1,379 @@ :root { - --sl-color-bg-surface: var(--sl-color-bg-nav); - --sl-color-divider: var(--sl-color-gray-5); + --sl-color-bg: hsl(0, 20%, 99%); + --sl-color-gray-5: hsl(0, 1%, 85%); + --sl-nav-gap: 40px; + --sl-color-text: hsl(0, 1%, 39%); + --sl-border-color: hsl(0, 1%, 85%); + --sl-color-hairline-shade: hsl(0, 1%, 85%); + + --color-background: hsl(0, 20%, 99%); + --color-background-weak: hsl(0, 8%, 97%); + --color-background-weak-hover: hsl(0, 8%, 94%); + --color-background-strong: hsl(0, 5%, 12%); + --color-background-strong-hover: hsl(0, 5%, 18%); + --color-background-interactive: hsl(62, 84%, 88%); + --color-background-interactive-weaker: hsl(64, 74%, 95%); + + --color-text: hsl(0, 1%, 39%); + --color-text-weak: hsl(0, 1%, 60%); + --color-text-weaker: hsl(0, 3%, 88%); + --color-text-strong: hsl(0, 5%, 12%); + --color-text-inverted: hsl(0, 20%, 99%); + + --color-border: hsl(30, 2%, 81%); + --color-border-weak: hsl(0, 1%, 85%); + + --color-icon: hsl(0, 1%, 55%); +} + + + +body { + color: var(--color-text) !important; + font-size: 14px !important; + + @media (prefers-color-scheme: dark) { + --sl-color-bg: hsl(0, 9%, 7%); + --sl-color-gray-5: hsl(0, 4%, 23%); + --sl-color-text: hsl(0, 4%, 71%); + --sl-border-color: hsl(0, 4%, 23%); + --sl-color-hairline-shade: hsl(0, 4%, 23%); + + --color-background: hsl(0, 9%, 7%); + --color-background-weak: hsl(0, 6%, 10%); + --color-background-strong: hsl(0, 15%, 94%); + --color-background-strong-hover: hsl(0, 15%, 97%); + --color-background-interactive: hsl(62, 100%, 90%); + --color-background-interactive-weaker: hsl(60, 20%, 8%); + + --color-text: hsl(0, 4%, 71%); + --color-text-weak: hsl(0, 2%, 49%); + --color-text-weaker: hsl(0, 3%, 28%); + --color-text-strong: hsl(0, 15%, 94%); + --color-text-inverted: hsl(0, 9%, 7%); + + --color-border: hsl(0, 3%, 28%); + --color-border-weak: hsl(0, 4%, 23%); + + --color-icon: hsl(10, 3%, 43%); + + } +} + +.header:where(.astro-tcroauqe) { + border-bottom: 1px solid var(--color-border-weak) !important; +} + +.sl-markdown-content hr { + border-bottom: 1px solid var(--color-border-weak) !important; +} + +#starlight__on-this-page--mobile { + border-bottom: 1px solid var(--color-border-weak) !important; +} + +mobile-starlight-toc nav summary .toggle { + opacity: 60% !important; + text-decoration: none !important; +} + +nav.sidebar summary svg.caret { + color: var(--color-icon) !important; +} + +body > .page > header button[data-open-modal] > kbd kbd { + color: var(--color-icon) !important; + font-size: 16px !important; + display: flex; +} + +body > .page > header button[data-open-modal] > kbd kbd:first-child { + font-size: 20px !important; +} + +.starlight-aside__title { + flex: 0 0 auto; + margin-top: 3px; +} + +body > .page > .main-frame .main-pane > main > .content-panel + .content-panel { + border-top: none !important; +} + +body > .page > header a.site-title img { + height: 2rem !important; +} + +a { + color: var(--color-text-strong) !important; +} + +.page-description { + color: var(--color-text) !important; +} + +.right-sidebar { + border-inline-start: none !important; +} + +.sidebar-pane { + border-inline-end: 1px solid var(--color-border-weak) !important; +} + +.right-sidebar-panel { + padding: 24px 0 !important; + color: var(--color-text-weaker); +} + +.sidebar-content { + padding: 24px 0 !important; +} + +a[aria-current="page"] { + border-left: 2px solid var(--color-background-strong); + background: var(--color-background-weak) !important; + font-weight: 600 !important; +} + +ul.top-level a[aria-current="page"] > span { + color: var(--color-text-strong) !important; +} + +#starlight__sidebar > div > sl-sidebar-state-persist > ul > li > details > summary { + padding: 0 24px !important; + margin-top: 20px !important; +} + +#starlight__sidebar > div > sl-sidebar-state-persist > ul > li > details > summary:hover { + background: var(--color-background-weak); +} + +#starlight__sidebar > div > sl-sidebar-state-persist > ul > li > details > summary span { + color: var(--color-text-strong) !important; + font-weight: 600 !important; +} + + +.top-level li a { + border-radius: 0; + width: 100%; + padding: 4px 24px !important; +} + +.top-level li a:hover { + background: var(--color-background-weak) !important; +} + +.right-group { + gap: 40px !important; +} + +.social-icons { + gap: 24px !important; +} + +.social-icons a svg { + height: 18px !important; + width: 18px !important; +} + +site-search > button { + text-transform: none !important; +} + +body > .page > header button[data-open-modal] { + gap: 24px !important; + background: var(--color-background-weak); + border: 1px solid var(--color-border-weak) !important; + padding: 6px 16px !important; + border-radius: 4px; + + @media (prefers-color-scheme: dark) { + background: var(--color-background-weak); + } +} + +body > .page > header button[data-open-modal] { + background: var(--color-background-weaker); +} + +site-search > button span { + text-decoration: none !important; +} + +.starlight-aside { + display: flex; + gap: 16px; + align-items: start; + + .starlight-aside__content { + margin-top: 0; + } +} + + + +site-search > button > kbd { + font-size: 14px !important; +} + +h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { + color: var(--color-text-strong) !important; +} + +h1 { + font-size: 26px !important; + text-transform: none !important; + font-weight: 500 !important; + color: var(--color-text-strong) !important; +} + +h2 { + font-size: 22px !important; + text-transform: none !important; + font-weight: 500 !important; + color: var(--color-text-strong) !important; +} + +h3 { + font-size: 18px !important; + text-transform: none !important; + font-weight: 500 !important; + color: var(--color-text-strong) !important; +} + +h4 { + font-size: 16px !important; + text-transform: none !important; + font-weight: 500 !important; + color: var(--color-text-strong) !important; +} + +strong { + font-weight: 500 !important; +} + +ul, ol { + list-style: none !important; + padding: 0 !important; +} + +.sl-markdown-content .tab > [role="tab"][aria-selected="true"] { + border-color: var(--color-text-strong); +} + +.social-icons a svg { + color: var(--color-text-weak) !important; +} + +.social-icons a svg:hover { + color: var(--color-text-strong) !important; +} + +body > .page > header, :root[data-has-sidebar] body > .page > header { + background: var(--color-background) !important; + padding: 24px !important; +} + +.sl-container { + box-sizing: border-box !important; + width: 100% !important; +} + +.right-sidebar-panel nav, +.right-sidebar-panel h2, +.right-sidebar-panel ul, +.right-sidebar-panel li, +.right-sidebar-panel a { + display: block; + width: 100%; +} + +.sl-container { + max-width: 100% !important; +} + + +.sl-container ul li a { + padding: 4px 24px !important; + width: 100% !important; + color: var(--color-text-weaker); + opacity: 50%; +} + +.sl-container ul li a:hover { + background: var(--color-background-weak); + + @media (prefers-color-scheme: dark) { + background: var(--color-background-weak) + } +} + +.sl-container ul li ul li { + padding: 4px 12px 0 12px !important; +} + + +.sl-container ul li a[aria-current="true"] { + color: var(--color-text-strong) !important; + opacity: 100%; +} + + +h2#starlight__on-this-page { + font-size: 14px !important; + color: var(--color-text-strong) !important; + margin: 0 !important; + font-weight: 400 !important; + padding: 0 24px 12px 24px; +} + +#starlight__on-this-page ul { + color: var(--color-text-strong) !important; + font-size: 16px !important; +} + +.middle-group .links { + color: var(--color-icon); + text-decoration: none; + text-transform: none; + font-size: 16px; + display: none !important; +} + +.middle-group .links:hover { + text-decoration: underline; + text-underline-offset: 4px; + text-decoration-thickness: 1px; +} + +nav.sidebar ul.top-level > li > details > summary .group-label > span { + margin-top: 24px !important; + color: var(--color-text-strong) !important; + text-transform: none !important; + font-weight: 500 !important; +} + +.content-panel { + padding: 2rem 3rem !important; +} + +.expressive-code { + margin: 12px 0 56px 0 !important; + border-radius: 6px; + +} + +.expressive-code figure { + background: var(--color-background-weak) !important; } .expressive-code .frame { box-shadow: none; } + + @media (prefers-color-scheme: dark) { .shiki, .shiki span {