Merge branch 'dev' into opentui
31
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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
README.md
|
|
@ -1,20 +1,20 @@
|
|||
<p align="center">
|
||||
<a href="https://opencode.ai">
|
||||
<picture>
|
||||
<source srcset="packages/web/src/assets/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/web/src/assets/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/web/src/assets/logo-ornate-light.svg" alt="opencode logo">
|
||||
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">AI coding agent, built for the terminal.</p>
|
||||
<p align="center">The AI coding agent built for the terminal.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
[](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?
|
||||
|
||||
|
|
|
|||
1
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) |
|
||||
|
|
|
|||
4
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",
|
||||
|
|
|
|||
|
|
@ -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<TextSelection, undefined>
|
||||
|
||||
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<DefinedSelection[]>(() => {
|
||||
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<number>()
|
||||
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
|
||||
|
|
|
|||
381
packages/app/src/components/editor-pane.tsx
Normal file
|
|
@ -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<string | undefined>(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 (
|
||||
<div class="relative flex h-full flex-col">
|
||||
<Logo size={64} variant="ornate" class="absolute top-2/5 left-1/2 transform -translate-x-1/2 -translate-y-1/2" />
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs
|
||||
class="relative grow w-full flex flex-col h-full"
|
||||
value={local.file.active()?.path}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List class="grow">
|
||||
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => (
|
||||
<SortableTab file={file} onTabClick={localProps.onFileClick} onTabClose={handleTabClose} />
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</Tabs.List>
|
||||
<div class="shrink-0 h-full flex items-center gap-1 px-2 border-b border-border-subtle/40">
|
||||
<Show when={local.file.active() && local.file.active()!.content?.diff}>
|
||||
{(() => {
|
||||
const activeFile = local.file.active()!
|
||||
const view = local.file.view(activeFile.path)
|
||||
return (
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={view !== "raw"}>
|
||||
<div class="mr-1 flex items-center gap-1">
|
||||
<Tooltip value="Previous change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Next change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
|
||||
<Icon name="arrow-down" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Tooltip value="Raw" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "raw",
|
||||
"text-text-muted/70": view !== "raw",
|
||||
"bg-background-element": view === "raw",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "raw")}
|
||||
>
|
||||
<Icon name="file-text" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Unified diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-unified",
|
||||
"text-text-muted/70": view !== "diff-unified",
|
||||
"bg-background-element": view === "diff-unified",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
|
||||
>
|
||||
<Icon name="checklist" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Split diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-split",
|
||||
"text-text-muted/70": view !== "diff-split",
|
||||
"bg-background-element": view === "diff-split",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "diff-split")}
|
||||
>
|
||||
<Icon name="columns" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Show>
|
||||
<Tooltip
|
||||
value={local.layout.visible(localProps.layoutKey, localProps.timelinePane) ? "Close pane" : "Open pane"}
|
||||
placement="bottom"
|
||||
>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => local.layout.toggle(localProps.layoutKey, localProps.timelinePane)}
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
local.layout.visible(localProps.layoutKey, localProps.timelinePane) ? "close-pane" : "open-pane"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => (
|
||||
<Tabs.Content value={file.path} class="grow h-full pt-1 select-text">
|
||||
{(() => {
|
||||
const view = local.file.view(file.path)
|
||||
const showRaw = view === "raw" || !file.content?.diff
|
||||
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
|
||||
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
|
||||
})()}
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</For>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
{(() => {
|
||||
const id = activeItem()
|
||||
if (!id) return null
|
||||
const draggedFile = local.file.node(id)
|
||||
if (!draggedFile) return null
|
||||
return (
|
||||
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
|
||||
<TabVisual file={draggedFile} />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<PromptForm
|
||||
class="peer/editor absolute inset-x-4 z-50 flex items-center justify-center"
|
||||
classList={{
|
||||
"bottom-8": !!local.file.active(),
|
||||
"bottom-3/8": local.file.active() === undefined,
|
||||
}}
|
||||
onSubmit={handlePromptSubmit}
|
||||
onOpenModelSelect={localProps.onOpenModelSelect}
|
||||
onInputRefChange={(element) => localProps.onInputRefChange(element ?? null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TabVisual(props: { file: LocalFile }): JSX.Element {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon node={props.file} class="" />
|
||||
<span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}>
|
||||
{props.file.name}
|
||||
</span>
|
||||
<span class="text-xs opacity-70">
|
||||
<Switch>
|
||||
<Match when={props.file.status?.status === "modified"}>
|
||||
<span class="text-primary">M</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "added"}>
|
||||
<span class="text-success">A</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "deleted"}>
|
||||
<span class="text-error">D</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableTab(props: {
|
||||
file: LocalFile
|
||||
onTabClick: (file: LocalFile) => void
|
||||
onTabClose: (file: LocalFile) => void
|
||||
}): JSX.Element {
|
||||
const sortable = createSortable(props.file.path)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
||||
<Tooltip value={props.file.path} placement="bottom">
|
||||
<div class="relative">
|
||||
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
|
||||
<TabVisual file={props.file} />
|
||||
</Tabs.Trigger>
|
||||
<IconButton
|
||||
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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: {
|
|||
<Switch>
|
||||
<Match when={node.type === "directory"}>
|
||||
<Collapsible
|
||||
class="w-full"
|
||||
forceMount={false}
|
||||
open={local.file.node(node.path)?.expanded}
|
||||
onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
|
||||
|
|
|
|||
295
packages/app/src/components/prompt-form.tsx
Normal file
|
|
@ -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<string, boolean>
|
||||
onSubmit: (prompt: string) => Promise<void> | 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 <span class="text-text-muted/70">{placeholderText}</span>
|
||||
}
|
||||
const needsSpace = base && interim && !base.endsWith(" ") && !interim.startsWith(" ")
|
||||
return (
|
||||
<>
|
||||
<span class="text-text">{base}</span>
|
||||
{interim && (
|
||||
<span class="text-text-muted/60 italic">
|
||||
{needsSpace ? " " : ""}
|
||||
{interim}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} class={props.class} classList={props.classList}>
|
||||
<div
|
||||
class="w-full max-w-xl min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
|
||||
flex flex-col gap-1
|
||||
bg-gradient-to-b from-background-panel/90 to-background/90
|
||||
ring-1 ring-border-active/50 border border-transparent
|
||||
focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary
|
||||
transition-all duration-200"
|
||||
classList={{
|
||||
"shadow-[0_0_33px_rgba(0,0,0,0.8)]": !!local.file.active(),
|
||||
"ring-2 ring-primary/60 bg-primary/5": isDragOver(),
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Show when={local.context.all().length > 0 || local.context.active()}>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Show when={local.context.active()}>
|
||||
<ActiveTabContextTag file={local.context.active()!} onClose={() => local.context.removeActive()} />
|
||||
</Show>
|
||||
<For each={local.context.all()}>
|
||||
{(file) => <FileTag file={file} onClose={() => local.context.remove(file.key)} />}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
ref={(element) => {
|
||||
inputRef = element ?? undefined
|
||||
props.onInputRefChange?.(inputRef)
|
||||
}}
|
||||
value={prompt()}
|
||||
onInput={(event) => setPrompt(event.currentTarget.value)}
|
||||
onKeyDown={handlePromptKeyDown}
|
||||
onScroll={handlePromptScroll}
|
||||
placeholder={placeholderText}
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
class="relative w-full h-20 rounded-md px-0.5 resize-none overflow-y-auto
|
||||
bg-transparent text-transparent caret-text font-light text-base
|
||||
leading-relaxed focus:outline-none selection:bg-primary/20"
|
||||
></textarea>
|
||||
<div
|
||||
ref={(element) => {
|
||||
overlayContainerRef = element ?? undefined
|
||||
}}
|
||||
class="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div class="px-0.5 text-base font-light leading-relaxed whitespace-pre-wrap text-left text-text">
|
||||
{promptContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs text-text-muted">
|
||||
<div class="flex gap-2 items-center">
|
||||
<Select
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
class="uppercase"
|
||||
/>
|
||||
<Button onClick={() => props.onOpenModelSelect()}>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size={24} class="text-text-muted" />
|
||||
</Button>
|
||||
<span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span>
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<Show when={isSupported()}>
|
||||
<Tooltip value={isRecording() ? "Stop voice input" : "Start voice input"} placement="top">
|
||||
<IconButton
|
||||
onClick={async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
if (isRecording()) {
|
||||
stopSpeech()
|
||||
} else {
|
||||
startSpeech()
|
||||
}
|
||||
inputRef?.focus()
|
||||
}}
|
||||
classList={{
|
||||
"text-text-muted": !isRecording(),
|
||||
"text-error! animate-pulse": isRecording(),
|
||||
}}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon name="mic" size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost">
|
||||
<Icon name="photo" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
class="text-background-panel! bg-primary rounded-full! hover:bg-primary/90 ml-0.5"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
type="submit"
|
||||
>
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const ActiveTabContextTag = (props: { file: LocalFile; onClose: () => void }) => (
|
||||
<div
|
||||
class="flex items-center bg-background group/tag
|
||||
border border-border-subtle/60 border-dashed
|
||||
rounded-md text-xs text-text-muted"
|
||||
>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}>
|
||||
<Icon name="file" class="group-hover/tag:hidden" size={12} />
|
||||
<Icon name="close" class="hidden group-hover/tag:block" size={12} />
|
||||
</IconButton>
|
||||
<div class="pr-1 flex gap-1 items-center">
|
||||
<span>{getFilename(props.file.path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const FileTag = (props: { file: FileContext; onClose: () => void }) => (
|
||||
<div
|
||||
class="flex items-center bg-background group/tag
|
||||
border border-border-subtle/60
|
||||
rounded-md text-xs text-text-muted"
|
||||
>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}>
|
||||
<FileIcon node={props.file} class="group-hover/tag:hidden size-3!" />
|
||||
<Icon name="close" class="hidden group-hover/tag:block" size={12} />
|
||||
</IconButton>
|
||||
<div class="pr-1 flex gap-1 items-center">
|
||||
<span>{getFilename(props.file.path)}</span>
|
||||
<Show when={props.file.selection}>
|
||||
<span>
|
||||
({props.file.selection!.startLine}-{props.file.selection!.endLine})
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
217
packages/app/src/components/resizeable-pane.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { batch, createContext, createMemo, createSignal, onCleanup, Show, useContext } from "solid-js"
|
||||
import type { ComponentProps, JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context"
|
||||
|
||||
type PaneDefault = number | { size: number; visible?: boolean }
|
||||
|
||||
type LayoutContextValue = {
|
||||
id: string
|
||||
register: (pane: string, options: { min?: number | string; max?: number | string }) => void
|
||||
size: (pane: string) => number
|
||||
visible: (pane: string) => boolean
|
||||
percent: (pane: string) => number
|
||||
next: (pane: string) => string | undefined
|
||||
startDrag: (left: string, right: string | undefined, event: MouseEvent) => void
|
||||
dragging: () => string | undefined
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<LayoutContextValue | undefined>(undefined)
|
||||
|
||||
export interface ResizeableLayoutProps {
|
||||
id: string
|
||||
defaults: Record<string, PaneDefault>
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export interface ResizeablePaneProps {
|
||||
id: string
|
||||
minSize?: number | string
|
||||
maxSize?: number | string
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export function ResizeableLayout(props: ResizeableLayoutProps) {
|
||||
const local = useLocal()
|
||||
const [meta, setMeta] = createStore<Record<string, { min: number; max: number; minPx?: number; maxPx?: number }>>({})
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
let container: HTMLDivElement | undefined
|
||||
|
||||
local.layout.ensure(props.id, props.defaults)
|
||||
|
||||
const order = createMemo(() => local.layout.order(props.id))
|
||||
const visibleOrder = createMemo(() => order().filter((pane) => local.layout.visible(props.id, pane)))
|
||||
const totalVisible = createMemo(() => {
|
||||
const panes = visibleOrder()
|
||||
if (!panes.length) return 0
|
||||
return panes.reduce((total, pane) => total + local.layout.size(props.id, pane), 0)
|
||||
})
|
||||
|
||||
const percent = (pane: string) => {
|
||||
const panes = visibleOrder()
|
||||
if (!panes.length) return 0
|
||||
const total = totalVisible()
|
||||
if (!total) return 100 / panes.length
|
||||
return (local.layout.size(props.id, pane) / total) * 100
|
||||
}
|
||||
|
||||
const nextPane = (pane: string) => {
|
||||
const panes = visibleOrder()
|
||||
const index = panes.indexOf(pane)
|
||||
if (index === -1) return undefined
|
||||
return panes[index + 1]
|
||||
}
|
||||
|
||||
const minMax = (pane: string) => meta[pane] ?? { min: 5, max: 95 }
|
||||
|
||||
const pxToPercent = (px: number, total: number) => (px / total) * 100
|
||||
|
||||
const boundsForPair = (left: string, right: string, total: number) => {
|
||||
const leftMeta = minMax(left)
|
||||
const rightMeta = minMax(right)
|
||||
const containerWidth = container?.getBoundingClientRect().width ?? 0
|
||||
|
||||
let minLeft = leftMeta.min
|
||||
let maxLeft = leftMeta.max
|
||||
let minRight = rightMeta.min
|
||||
let maxRight = rightMeta.max
|
||||
|
||||
if (containerWidth && leftMeta.minPx !== undefined) minLeft = pxToPercent(leftMeta.minPx, containerWidth)
|
||||
if (containerWidth && leftMeta.maxPx !== undefined) maxLeft = pxToPercent(leftMeta.maxPx, containerWidth)
|
||||
if (containerWidth && rightMeta.minPx !== undefined) minRight = pxToPercent(rightMeta.minPx, containerWidth)
|
||||
if (containerWidth && rightMeta.maxPx !== undefined) maxRight = pxToPercent(rightMeta.maxPx, containerWidth)
|
||||
|
||||
const finalMinLeft = Math.max(minLeft, total - maxRight)
|
||||
const finalMaxLeft = Math.min(maxLeft, total - minRight)
|
||||
return {
|
||||
min: Math.min(finalMinLeft, finalMaxLeft),
|
||||
max: Math.max(finalMinLeft, finalMaxLeft),
|
||||
}
|
||||
}
|
||||
|
||||
const setPair = (left: string, right: string, leftSize: number, rightSize: number) => {
|
||||
batch(() => {
|
||||
local.layout.setSize(props.id, left, leftSize)
|
||||
local.layout.setSize(props.id, right, rightSize)
|
||||
})
|
||||
}
|
||||
|
||||
const startDrag = (left: string, right: string | undefined, event: MouseEvent) => {
|
||||
if (!right) return
|
||||
if (!container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (!rect.width) return
|
||||
event.preventDefault()
|
||||
const startX = event.clientX
|
||||
const startLeft = local.layout.size(props.id, left)
|
||||
const startRight = local.layout.size(props.id, right)
|
||||
const total = startLeft + startRight
|
||||
const bounds = boundsForPair(left, right, total)
|
||||
const move = (moveEvent: MouseEvent) => {
|
||||
const delta = ((moveEvent.clientX - startX) / rect.width) * 100
|
||||
const nextLeft = Math.max(bounds.min, Math.min(bounds.max, startLeft + delta))
|
||||
const nextRight = total - nextLeft
|
||||
setPair(left, right, nextLeft, nextRight)
|
||||
}
|
||||
const stop = () => {
|
||||
setDragging()
|
||||
document.removeEventListener("mousemove", move)
|
||||
document.removeEventListener("mouseup", stop)
|
||||
}
|
||||
setDragging(left)
|
||||
document.addEventListener("mousemove", move)
|
||||
document.addEventListener("mouseup", stop)
|
||||
onCleanup(() => stop())
|
||||
}
|
||||
|
||||
const register = (pane: string, options: { min?: number | string; max?: number | string }) => {
|
||||
let min = 5
|
||||
let max = 95
|
||||
let minPx: number | undefined
|
||||
let maxPx: number | undefined
|
||||
|
||||
if (typeof options.min === "string" && options.min.endsWith("px")) {
|
||||
minPx = parseInt(options.min)
|
||||
min = 0
|
||||
} else if (typeof options.min === "number") {
|
||||
min = options.min
|
||||
}
|
||||
|
||||
if (typeof options.max === "string" && options.max.endsWith("px")) {
|
||||
maxPx = parseInt(options.max)
|
||||
max = 100
|
||||
} else if (typeof options.max === "number") {
|
||||
max = options.max
|
||||
}
|
||||
|
||||
setMeta(pane, () => ({ min, max, minPx, maxPx }))
|
||||
const fallback = props.defaults[pane]
|
||||
local.layout.ensurePane(props.id, pane, fallback ?? { size: min, visible: true })
|
||||
}
|
||||
|
||||
const contextValue: LayoutContextValue = {
|
||||
id: props.id,
|
||||
register,
|
||||
size: (pane) => local.layout.size(props.id, pane),
|
||||
visible: (pane) => local.layout.visible(props.id, pane),
|
||||
percent,
|
||||
next: nextPane,
|
||||
startDrag,
|
||||
dragging,
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={contextValue}>
|
||||
<div
|
||||
ref={(node) => {
|
||||
container = node ?? undefined
|
||||
}}
|
||||
class={props.class ? `relative flex h-full w-full ${props.class}` : "relative flex h-full w-full"}
|
||||
classList={props.classList}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</LayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResizeablePane(props: ResizeablePaneProps) {
|
||||
const context = useContext(LayoutContext)!
|
||||
context.register(props.id, { min: props.minSize, max: props.maxSize })
|
||||
const visible = () => context.visible(props.id)
|
||||
const width = () => context.percent(props.id)
|
||||
const next = () => context.next(props.id)
|
||||
const dragging = () => context.dragging() === props.id
|
||||
|
||||
return (
|
||||
<Show when={visible()}>
|
||||
<div
|
||||
class={props.class ? `relative flex h-full flex-col ${props.class}` : "relative flex h-full flex-col"}
|
||||
classList={props.classList}
|
||||
style={{
|
||||
width: `${width()}%`,
|
||||
flex: `0 0 ${width()}%`,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<Show when={next()}>
|
||||
<div
|
||||
class="absolute top-0 -right-1 h-full w-1.5 cursor-col-resize z-50 group"
|
||||
onMouseDown={(event) => context.startDrag(props.id, next(), event)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-0.5 h-full bg-transparent transition-colors group-hover:bg-border-active": true,
|
||||
"bg-border-active!": dragging(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
@ -101,11 +101,7 @@ function EditToolPart(props: { part: ToolPart }) {
|
|||
</>
|
||||
}
|
||||
>
|
||||
<Code
|
||||
path={state().input["filePath"] as string}
|
||||
code={state().metadata["diff"] as string}
|
||||
class="[&_code]:pb-0!"
|
||||
/>
|
||||
<Code path={state().input["filePath"] as string} code={state().metadata["diff"] as string} />
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
|
|
@ -412,7 +408,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code path="session.json" code={JSON.stringify(session(), null, 2)} class="[&_code]:pb-0!" />
|
||||
<Code path="session.json" code={JSON.stringify(session(), null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
|
|
@ -429,15 +425,11 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code
|
||||
path={message.id + ".json"}
|
||||
code={JSON.stringify(message, null, 2)}
|
||||
class="[&_code]:pb-0!"
|
||||
/>
|
||||
<Code path={message.id + ".json"} code={JSON.stringify(message, null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
<For each={sync.data.part[message.id]?.filter(valid)}>
|
||||
<For each={sync.data.part[message.id]}>
|
||||
{(part) => (
|
||||
<li>
|
||||
<Collapsible>
|
||||
|
|
@ -449,11 +441,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code
|
||||
path={message.id + "." + part.id + ".json"}
|
||||
code={JSON.stringify(part, null, 2)}
|
||||
class="[&_code]:pb-0!"
|
||||
/>
|
||||
<Code path={message.id + "." + part.id + ".json"} code={JSON.stringify(part, null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ export type LocalModel = Omit<Model, "provider"> & {
|
|||
}
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
export type FileContext = { type: "file"; path: string; selection?: TextSelection }
|
||||
export type ContextItem = FileContext
|
||||
|
||||
function init() {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
|
|
@ -163,7 +166,16 @@ function init() {
|
|||
}
|
||||
|
||||
const resetNode = (path: string) => {
|
||||
setStore("node", path, undefined!)
|
||||
setStore("node", path, {
|
||||
loaded: undefined,
|
||||
pinned: undefined,
|
||||
content: undefined,
|
||||
selection: undefined,
|
||||
scrollTop: undefined,
|
||||
folded: undefined,
|
||||
view: undefined,
|
||||
selectedChange: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
|
||||
|
|
@ -203,6 +215,7 @@ function init() {
|
|||
]
|
||||
})
|
||||
setStore("active", relativePath)
|
||||
context.addActive()
|
||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||
if (store.node[relativePath].loaded) return
|
||||
|
|
@ -336,52 +349,103 @@ function init() {
|
|||
})()
|
||||
|
||||
const layout = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
rightPane: boolean
|
||||
leftWidth: number
|
||||
rightWidth: number
|
||||
}>({
|
||||
rightPane: false,
|
||||
leftWidth: 200, // Default 50 * 4px (w-50 = 12.5rem = 200px)
|
||||
rightWidth: 320, // Default 80 * 4px (w-80 = 20rem = 320px)
|
||||
})
|
||||
type PaneState = { size: number; visible: boolean }
|
||||
type LayoutState = { panes: Record<string, PaneState>; order: string[] }
|
||||
type PaneDefault = number | { size: number; visible?: boolean }
|
||||
|
||||
const value = localStorage.getItem("layout")
|
||||
if (value) {
|
||||
const v = JSON.parse(value)
|
||||
if (typeof v?.rightPane === "boolean") setStore("rightPane", v.rightPane)
|
||||
if (typeof v?.leftWidth === "number") setStore("leftWidth", Math.max(150, Math.min(400, v.leftWidth)))
|
||||
if (typeof v?.rightWidth === "number") setStore("rightWidth", Math.max(200, Math.min(500, v.rightWidth)))
|
||||
const [store, setStore] = createStore<Record<string, LayoutState>>({})
|
||||
|
||||
const raw = localStorage.getItem("layout")
|
||||
if (raw) {
|
||||
const data = JSON.parse(raw)
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
const first = Object.values(data)[0] as LayoutState
|
||||
if (first && typeof first === "object" && "panes" in first) {
|
||||
setStore(() => data as Record<string, LayoutState>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
localStorage.setItem("layout", JSON.stringify(store))
|
||||
})
|
||||
|
||||
const normalize = (value: PaneDefault): PaneState => {
|
||||
if (typeof value === "number") return { size: value, visible: true }
|
||||
return { size: value.size, visible: value.visible ?? true }
|
||||
}
|
||||
|
||||
const ensure = (id: string, defaults: Record<string, PaneDefault>) => {
|
||||
const entries = Object.entries(defaults)
|
||||
if (!entries.length) return
|
||||
setStore(id, (current) => {
|
||||
if (current) return current
|
||||
return {
|
||||
panes: Object.fromEntries(entries.map(([pane, config]) => [pane, normalize(config)])),
|
||||
order: entries.map(([pane]) => pane),
|
||||
}
|
||||
})
|
||||
for (const [pane, config] of entries) {
|
||||
if (!store[id]?.panes[pane]) {
|
||||
setStore(id, "panes", pane, () => normalize(config))
|
||||
}
|
||||
if (!(store[id]?.order ?? []).includes(pane)) {
|
||||
setStore(id, "order", (list) => [...list, pane])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ensurePane = (id: string, pane: string, fallback?: PaneDefault) => {
|
||||
if (!store[id]) {
|
||||
const value = normalize(fallback ?? { size: 0, visible: true })
|
||||
setStore(id, () => ({
|
||||
panes: { [pane]: value },
|
||||
order: [pane],
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (!store[id].panes[pane]) {
|
||||
const value = normalize(fallback ?? { size: 0, visible: true })
|
||||
setStore(id, "panes", pane, () => value)
|
||||
}
|
||||
if (!store[id].order.includes(pane)) {
|
||||
setStore(id, "order", (list) => [...list, pane])
|
||||
}
|
||||
}
|
||||
|
||||
const size = (id: string, pane: string) => store[id]?.panes[pane]?.size ?? 0
|
||||
const visible = (id: string, pane: string) => store[id]?.panes[pane]?.visible ?? false
|
||||
|
||||
const setSize = (id: string, pane: string, value: number) => {
|
||||
if (!store[id]?.panes[pane]) return
|
||||
const next = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
|
||||
setStore(id, "panes", pane, "size", next)
|
||||
}
|
||||
|
||||
const setVisible = (id: string, pane: string, value: boolean) => {
|
||||
if (!store[id]?.panes[pane]) return
|
||||
setStore(id, "panes", pane, "visible", value)
|
||||
}
|
||||
|
||||
const toggle = (id: string, pane: string) => {
|
||||
setVisible(id, pane, !visible(id, pane))
|
||||
}
|
||||
|
||||
const show = (id: string, pane: string) => setVisible(id, pane, true)
|
||||
const hide = (id: string, pane: string) => setVisible(id, pane, false)
|
||||
const order = (id: string) => store[id]?.order ?? []
|
||||
|
||||
return {
|
||||
rightPane() {
|
||||
return store.rightPane
|
||||
},
|
||||
leftWidth() {
|
||||
return store.leftWidth
|
||||
},
|
||||
rightWidth() {
|
||||
return store.rightWidth
|
||||
},
|
||||
toggleRightPane() {
|
||||
setStore("rightPane", (x) => !x)
|
||||
},
|
||||
openRightPane() {
|
||||
setStore("rightPane", true)
|
||||
},
|
||||
closeRightPane() {
|
||||
setStore("rightPane", false)
|
||||
},
|
||||
setLeftWidth(width: number) {
|
||||
setStore("leftWidth", Math.max(150, Math.min(400, width)))
|
||||
},
|
||||
setRightWidth(width: number) {
|
||||
setStore("rightWidth", Math.max(200, Math.min(500, width)))
|
||||
},
|
||||
ensure,
|
||||
ensurePane,
|
||||
size,
|
||||
visible,
|
||||
setSize,
|
||||
setVisible,
|
||||
toggle,
|
||||
show,
|
||||
hide,
|
||||
order,
|
||||
}
|
||||
})()
|
||||
|
||||
|
|
@ -406,12 +470,51 @@ function init() {
|
|||
}
|
||||
})()
|
||||
|
||||
const context = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
activeTab: boolean
|
||||
items: (ContextItem & { key: string })[]
|
||||
}>({
|
||||
activeTab: true,
|
||||
items: [],
|
||||
})
|
||||
|
||||
return {
|
||||
all() {
|
||||
return store.items
|
||||
},
|
||||
active() {
|
||||
return store.activeTab ? file.active() : undefined
|
||||
},
|
||||
addActive() {
|
||||
setStore("activeTab", true)
|
||||
},
|
||||
removeActive() {
|
||||
setStore("activeTab", false)
|
||||
},
|
||||
add(item: ContextItem) {
|
||||
let key = item.type
|
||||
switch (item.type) {
|
||||
case "file":
|
||||
key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
|
||||
break
|
||||
}
|
||||
if (store.items.find((x) => x.key === key)) return
|
||||
setStore("items", (x) => [...x, { key, ...item }])
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("items", (x) => x.filter((x) => x.key !== key))
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const result = {
|
||||
model,
|
||||
agent,
|
||||
file,
|
||||
layout,
|
||||
session,
|
||||
context,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ function init() {
|
|||
|
||||
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
|
||||
const sanitize = (text: string) => text.replace(sanitizer(), "")
|
||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||
|
||||
const result = {
|
||||
data: store,
|
||||
|
|
@ -148,6 +149,7 @@ function init() {
|
|||
},
|
||||
},
|
||||
load,
|
||||
absolute,
|
||||
sanitize,
|
||||
}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,21 +1,11 @@
|
|||
import { Button, FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
|
||||
import { FileIcon, Icon, IconButton, Tooltip } from "@/ui"
|
||||
import { Tabs } from "@/ui/tabs"
|
||||
import { Select } from "@/components/select"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import EditorPane from "@/components/editor-pane"
|
||||
import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { SelectDialog } from "@/components/select-dialog"
|
||||
import { useLocal, useSDK } from "@/context"
|
||||
import { Code } from "@/components/code"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
createSortable,
|
||||
closestCenter,
|
||||
useDragDropContext,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import { useLocal } from "@/context"
|
||||
import { ResizeableLayout, ResizeablePane } from "@/components/resizeable-pane"
|
||||
import type { LocalFile } from "@/context/local"
|
||||
import SessionList from "@/components/session-list"
|
||||
import SessionTimeline from "@/components/session-timeline"
|
||||
|
|
@ -23,18 +13,17 @@ import { createStore } from "solid-js/store"
|
|||
import { getDirectory, getFilename } from "@/utils"
|
||||
|
||||
export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const local = useLocal()
|
||||
const [store, setStore] = createStore({
|
||||
clickTimer: undefined as number | undefined,
|
||||
activeItem: undefined as string | undefined,
|
||||
prompt: "",
|
||||
dragging: undefined as "left" | "right" | undefined,
|
||||
modelSelectOpen: false,
|
||||
fileSelectOpen: false,
|
||||
})
|
||||
|
||||
let inputRef: HTMLInputElement | undefined = undefined
|
||||
const layoutKey = "workspace"
|
||||
const timelinePane = "timeline"
|
||||
|
||||
let inputRef: HTMLTextAreaElement | undefined = undefined
|
||||
|
||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||
|
||||
|
|
@ -46,54 +35,52 @@ export default function Page() {
|
|||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.getModifierState(MOD) && e.shiftKey && e.key.toLowerCase() === "p") {
|
||||
e.preventDefault()
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault()
|
||||
// TODO: command palette
|
||||
return
|
||||
}
|
||||
if (e.getModifierState(MOD) && e.key.toLowerCase() === "p") {
|
||||
e.preventDefault()
|
||||
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault()
|
||||
setStore("fileSelectOpen", true)
|
||||
return
|
||||
}
|
||||
|
||||
const inputFocused = document.activeElement === inputRef
|
||||
if (inputFocused) {
|
||||
if (e.key === "Escape") {
|
||||
const focused = document.activeElement === inputRef
|
||||
if (focused) {
|
||||
if (event.key === "Escape") {
|
||||
inputRef?.blur()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (document.activeElement?.id === "select-filter") {
|
||||
return
|
||||
}
|
||||
|
||||
if (local.file.active()) {
|
||||
if (e.getModifierState(MOD)) {
|
||||
if (e.key.toLowerCase() === "a") {
|
||||
const active = local.file.active()!
|
||||
if (event.key === "Enter" && active.selection) {
|
||||
local.context.add({
|
||||
type: "file",
|
||||
path: active.path,
|
||||
selection: { ...active.selection },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.getModifierState(MOD)) {
|
||||
if (event.key.toLowerCase() === "a") {
|
||||
return
|
||||
}
|
||||
if (e.key.toLowerCase() === "c") {
|
||||
if (event.key.toLowerCase() === "c") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key.length === 1 && e.key !== "Unidentified") {
|
||||
if (event.key.length === 1 && event.key !== "Unidentified") {
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
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 resetClickTimer = () => {
|
||||
if (!store.clickTimer) return
|
||||
clearTimeout(store.clickTimer)
|
||||
|
|
@ -117,190 +104,80 @@ export default function Page() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleTabChange = (path: string) => {
|
||||
local.file.open(path)
|
||||
}
|
||||
|
||||
const handleTabClose = (file: LocalFile) => {
|
||||
local.file.close(file.path)
|
||||
}
|
||||
|
||||
const onDragStart = (event: any) => {
|
||||
setStore("activeItem", event.draggable.id as string)
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const currentFiles = local.file.opened().map((f) => f.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 onDragEnd = () => {
|
||||
setStore("activeItem", undefined)
|
||||
}
|
||||
|
||||
const handleLeftDragStart = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setStore("dragging", "left")
|
||||
const startX = e.clientX
|
||||
const startWidth = local.layout.leftWidth()
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - startX
|
||||
const newWidth = startWidth + deltaX
|
||||
local.layout.setLeftWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setStore("dragging", undefined)
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
const handleRightDragStart = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setStore("dragging", "right")
|
||||
const startX = e.clientX
|
||||
const startWidth = local.layout.rightWidth()
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = startX - e.clientX
|
||||
const newWidth = startWidth + deltaX
|
||||
local.layout.setRightWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setStore("dragging", undefined)
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
const prompt = store.prompt
|
||||
setStore("prompt", "")
|
||||
inputRef?.blur()
|
||||
|
||||
const session =
|
||||
(local.layout.rightPane() ? local.session.active() : undefined) ??
|
||||
(await sdk.session.create().then((x) => x.data!))
|
||||
local.session.setActive(session!.id)
|
||||
local.layout.openRightPane()
|
||||
|
||||
const response = 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.file
|
||||
.opened()
|
||||
.filter((f) => f.selection || local.file.active()?.path === f.path)
|
||||
.flatMap((f) => [
|
||||
{
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
url: `file://${f.absolute}${f.selection ? `?start=${f.selection.startLine}&end=${f.selection.endLine}` : ""}`,
|
||||
filename: f.name,
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: "@" + f.name,
|
||||
start: 0, // f.start,
|
||||
end: 0, // f.end,
|
||||
},
|
||||
path: f.absolute,
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
console.log("response", response)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<div
|
||||
class="fixed top-0 left-0 h-full border-r border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10"
|
||||
style={`width: ${local.layout.leftWidth()}px`}
|
||||
<ResizeableLayout
|
||||
id={layoutKey}
|
||||
defaults={{
|
||||
explorer: { size: 24, visible: true },
|
||||
editor: { size: 56, visible: true },
|
||||
timeline: { size: 20, visible: false },
|
||||
}}
|
||||
class="h-screen"
|
||||
>
|
||||
<Tabs class="relative flex flex-col h-full" defaultValue="files">
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List class="grow w-full after:hidden">
|
||||
<Tabs.Trigger value="files" class="flex-1 justify-center text-xs">
|
||||
Files
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="changes" class="flex-1 justify-center text-xs">
|
||||
Changes
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
<Tabs.Content value="files" class="grow min-h-0 py-2 bg-background">
|
||||
<FileTree path="" onFileClick={handleFileClick} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
|
||||
<Show
|
||||
when={local.file.changes().length}
|
||||
fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
|
||||
>
|
||||
<ul class="">
|
||||
<For each={local.file.changes()}>
|
||||
{(path) => (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
|
||||
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 cursor-pointer hover:bg-background-element"
|
||||
>
|
||||
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
|
||||
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
|
||||
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
|
||||
{getDirectory(path)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div
|
||||
class="fixed top-0 h-full w-1.5 bg-transparent cursor-col-resize z-50 group"
|
||||
style={`left: ${local.layout.leftWidth()}px`}
|
||||
onMouseDown={(e) => handleLeftDragStart(e)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors": true,
|
||||
"bg-border-active!": store.dragging === "left",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Show when={local.layout.rightPane()}>
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10"
|
||||
style={`width: ${local.layout.rightWidth()}px`}
|
||||
<ResizeablePane
|
||||
id="explorer"
|
||||
minSize="150px"
|
||||
maxSize="300px"
|
||||
class="border-r border-border-subtle/30 bg-background z-10 overflow-hidden"
|
||||
>
|
||||
<Tabs class="relative flex flex-col h-full" defaultValue="files">
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List class="grow w-full after:hidden">
|
||||
<Tabs.Trigger value="files" class="flex-1 justify-center text-xs">
|
||||
Files
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="changes" class="flex-1 justify-center text-xs">
|
||||
Changes
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
<Tabs.Content value="files" class="grow min-h-0 py-2 bg-background">
|
||||
<FileTree path="" onFileClick={handleFileClick} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
|
||||
<Show
|
||||
when={local.file.changes().length}
|
||||
fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
|
||||
>
|
||||
<ul class="">
|
||||
<For each={local.file.changes()}>
|
||||
{(path) => (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
|
||||
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 cursor-pointer hover:bg-background-element"
|
||||
>
|
||||
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
|
||||
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
|
||||
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
|
||||
{getDirectory(path)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</ResizeablePane>
|
||||
<ResizeablePane id="editor" minSize={30} maxSize={80} class="bg-background">
|
||||
<EditorPane
|
||||
layoutKey={layoutKey}
|
||||
timelinePane={timelinePane}
|
||||
onFileClick={handleFileClick}
|
||||
onOpenModelSelect={() => setStore("modelSelectOpen", true)}
|
||||
onInputRefChange={(element: HTMLTextAreaElement | null) => {
|
||||
inputRef = element ?? undefined
|
||||
}}
|
||||
/>
|
||||
</ResizeablePane>
|
||||
<ResizeablePane
|
||||
id="timeline"
|
||||
minSize={20}
|
||||
maxSize={40}
|
||||
class="border-l border-border-subtle/30 bg-background z-10 overflow-hidden"
|
||||
>
|
||||
<div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||
<Show when={local.session.active()} fallback={<SessionList />}>
|
||||
|
|
@ -326,216 +203,8 @@ export default function Page() {
|
|||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="fixed top-0 h-full w-1.5 bg-transparent cursor-col-resize z-50 group flex justify-end"
|
||||
style={`right: ${local.layout.rightWidth()}px`}
|
||||
onMouseDown={(e) => handleRightDragStart(e)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors": true,
|
||||
"bg-border-active!": store.dragging === "right",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="relative"
|
||||
style={`margin-left: ${local.layout.leftWidth()}px; margin-right: ${local.layout.rightPane() ? local.layout.rightWidth() : 0}px`}
|
||||
>
|
||||
<Logo
|
||||
size={64}
|
||||
variant="ornate"
|
||||
class="absolute top-2/5 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
<DragDropProvider
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs
|
||||
class="relative grow w-full flex flex-col h-screen"
|
||||
value={local.file.active()?.path}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List class="grow">
|
||||
<SortableProvider ids={local.file.opened().map((f) => f.path)}>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</Tabs.List>
|
||||
<div class="shrink-0 h-full flex items-center gap-1 px-2 border-b border-border-subtle/40">
|
||||
<Show when={local.file.active() && local.file.active()!.content?.diff}>
|
||||
{(() => {
|
||||
const f = local.file.active()!
|
||||
const view = local.file.view(f.path)
|
||||
return (
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={view !== "raw"}>
|
||||
<div class="mr-1 flex items-center gap-1">
|
||||
<Tooltip value="Previous change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Next change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
|
||||
<Icon name="arrow-down" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Tooltip value="Raw" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "raw",
|
||||
"text-text-muted/70": view !== "raw",
|
||||
"bg-background-element": view === "raw",
|
||||
}}
|
||||
onClick={() => local.file.setView(f.path, "raw")}
|
||||
>
|
||||
<Icon name="file-text" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Unified diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-unified",
|
||||
"text-text-muted/70": view !== "diff-unified",
|
||||
"bg-background-element": view === "diff-unified",
|
||||
}}
|
||||
onClick={() => local.file.setView(f.path, "diff-unified")}
|
||||
>
|
||||
<Icon name="checklist" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Split diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-split",
|
||||
"text-text-muted/70": view !== "diff-split",
|
||||
"bg-background-element": view === "diff-split",
|
||||
}}
|
||||
onClick={() => local.file.setView(f.path, "diff-split")}
|
||||
>
|
||||
<Icon name="columns" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Show>
|
||||
<Tooltip value={local.layout.rightPane() ? "Close pane" : "Open pane"} placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => local.layout.toggleRightPane()}>
|
||||
<Icon name={local.layout.rightPane() ? "close-pane" : "open-pane"} size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => (
|
||||
<Tabs.Content value={file.path} class="grow h-full pt-1 select-text">
|
||||
{(() => {
|
||||
const view = local.file.view(file.path)
|
||||
const showRaw = view === "raw" || !file.content?.diff
|
||||
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
|
||||
return <Code path={file.path} code={code} />
|
||||
})()}
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</For>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
{store.activeItem &&
|
||||
(() => {
|
||||
const draggedFile = local.file.node(store.activeItem!)
|
||||
return (
|
||||
<div
|
||||
class="relative px-3 h-8 flex items-center
|
||||
text-sm font-medium text-text whitespace-nowrap
|
||||
shrink-0 bg-background-panel
|
||||
border-x border-border-subtle/40 border-b border-b-transparent"
|
||||
>
|
||||
<TabVisual file={draggedFile} />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
class="peer/editor absolute inset-x-4 z-50 flex items-center justify-center"
|
||||
classList={{
|
||||
"bottom-8": !!local.file.active(),
|
||||
"bottom-2/5": local.file.active() === undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-xl min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
|
||||
flex flex-col gap-1
|
||||
bg-gradient-to-b from-background-panel/90 to-background/90
|
||||
ring-1 ring-border-active/50 border border-transparent
|
||||
shadow-[0_0_33px_rgba(0,0,0,0.8)]
|
||||
focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary"
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Show when={local.file.active()}>
|
||||
<FileTag
|
||||
default
|
||||
file={local.file.active()!}
|
||||
onClose={() => local.file.close(local.file.active()?.path ?? "")}
|
||||
/>
|
||||
</Show>
|
||||
<For each={local.file.opened().filter((x) => x.selection)}>
|
||||
{(file) => <FileTag file={file} onClose={() => local.file.select(file.path, undefined)} />}
|
||||
</For>
|
||||
</div>
|
||||
<input
|
||||
ref={(el) => (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"
|
||||
/>
|
||||
<div class="flex justify-between items-center text-xs text-text-muted">
|
||||
<div class="flex gap-2 items-center">
|
||||
<Select
|
||||
options={local.agent.list().map((a) => a.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
class="uppercase"
|
||||
/>
|
||||
<Button onClick={() => setStore("modelSelectOpen", true)}>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size={24} class="text-text-muted" />
|
||||
</Button>
|
||||
<span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span>
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost">
|
||||
<Icon name="photo" size={16} />
|
||||
</IconButton>
|
||||
<IconButton class="text-background-panel! bg-primary rounded-full!" size="xs" variant="ghost">
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ResizeablePane>
|
||||
</ResizeableLayout>
|
||||
<Show when={store.modelSelectOpen}>
|
||||
<SelectDialog
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
|
|
@ -607,102 +276,3 @@ export default function Page() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TabVisual = (props: { file: LocalFile }) => {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon node={props.file} class="" />
|
||||
<span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}>
|
||||
{props.file.name}
|
||||
</span>
|
||||
<span class="text-xs opacity-70">
|
||||
<Switch>
|
||||
<Match when={props.file.status?.status === "modified"}>
|
||||
<span class="text-primary">M</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "added"}>
|
||||
<span class="text-success">A</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "deleted"}>
|
||||
<span class="text-error">D</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SortableTab = (props: {
|
||||
file: LocalFile
|
||||
onTabClick: (file: LocalFile) => void
|
||||
onTabClose: (file: LocalFile) => void
|
||||
}) => {
|
||||
const sortable = createSortable(props.file.path)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
||||
<Tooltip value={props.file.path} placement="bottom">
|
||||
<div class="relative">
|
||||
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
|
||||
<TabVisual file={props.file} />
|
||||
</Tabs.Trigger>
|
||||
<IconButton
|
||||
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60
|
||||
peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text
|
||||
peer-data-[selected]/tab:hover:bg-border-subtle
|
||||
hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FileTag = (props: { file: LocalFile; default?: boolean; onClose: () => void }) => (
|
||||
<div
|
||||
class="flex items-center bg-background group/tag
|
||||
border border-border-subtle/60 border-dashed
|
||||
rounded-md text-xs text-text-muted"
|
||||
>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}>
|
||||
<Switch fallback={<FileIcon node={props.file} class="group-hover/tag:hidden size-3!" />}>
|
||||
<Match when={props.default}>
|
||||
<Icon name="file" class="group-hover/tag:hidden" size={12} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="close" class="hidden group-hover/tag:block" size={12} />
|
||||
</IconButton>
|
||||
<div class="pr-1 flex gap-1 items-center">
|
||||
<span>{props.file.name}</span>
|
||||
<Show when={!props.default && props.file.selection}>
|
||||
<span class="">
|
||||
({props.file.selection!.startLine}-{props.file.selection!.endLine})
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ConstrainDragYAxis = () => {
|
||||
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: any) => {
|
||||
addTransformer("draggables", event.draggable.id, transformer)
|
||||
})
|
||||
onDragEnd((event: any) => {
|
||||
removeTransformer("draggables", event.draggable.id, transformer.id)
|
||||
})
|
||||
return <></>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,18 +118,19 @@ const icons = {
|
|||
message: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 18.25C15.5 18.25 19.25 16.5 19.25 12C19.25 7.5 15.5 5.75 12 5.75C8.5 5.75 4.75 7.5 4.75 12C4.75 13.0298 4.94639 13.9156 5.29123 14.6693C5.50618 15.1392 5.62675 15.6573 5.53154 16.1651L5.26934 17.5635C5.13974 18.2547 5.74527 18.8603 6.43651 18.7307L9.64388 18.1293C9.896 18.082 10.1545 18.0861 10.4078 18.1263C10.935 18.2099 11.4704 18.25 12 18.25Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M9.5 12C9.5 12.2761 9.27614 12.5 9 12.5C8.72386 12.5 8.5 12.2761 8.5 12C8.5 11.7239 8.72386 11.5 9 11.5C9.27614 11.5 9.5 11.7239 9.5 12Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M12.5 12C12.5 12.2761 12.2761 12.5 12 12.5C11.7239 12.5 11.5 12.2761 11.5 12C11.5 11.7239 11.7239 11.5 12 11.5C12.2761 11.5 12.5 11.7239 12.5 12Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M15.5 12C15.5 12.2761 15.2761 12.5 15 12.5C14.7239 12.5 14.5 12.2761 14.5 12C14.5 11.7239 14.7239 11.5 15 11.5C15.2761 11.5 15.5 11.7239 15.5 12Z"></path>',
|
||||
annotation: '<path d="M4.75 6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H17.25C18.3546 4.75 19.25 5.64543 19.25 6.75V15.25C19.25 16.3546 18.3546 17.25 17.25 17.25H14.625L12 19.25L9.375 17.25H6.75C5.64543 17.25 4.75 16.3546 4.75 15.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>',
|
||||
square: '<path d="M17.2502 19.25H6.75C5.64543 19.25 4.75 18.3546 4.75 17.25V6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H17.2502C18.3548 4.75 19.2502 5.64543 19.2502 6.75V17.25C19.2502 18.3546 18.3548 19.25 17.2502 19.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>',
|
||||
"pull-request": '<path d="M9.25 7C9.25 8.24264 8.24264 9.25 7 9.25C5.75736 9.25 4.75 8.24264 4.75 7C4.75 5.75736 5.75736 4.75 7 4.75C8.24264 4.75 9.25 5.75736 9.25 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9.25 17C9.25 18.2426 8.24264 19.25 7 19.25C5.75736 19.25 4.75 18.2426 4.75 17C4.75 15.7574 5.75736 14.75 7 14.75C8.24264 14.75 9.25 15.7574 9.25 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M19.25 17C19.25 18.2426 18.2426 19.25 17 19.25C15.7574 19.25 14.75 18.2426 14.75 17C14.75 15.7574 15.7574 14.75 17 14.75C18.2426 14.75 19.25 15.7574 19.25 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6.75 9.5V14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M17.25 14.25V10.4701C17.25 9.83943 17.0513 9.22483 16.682 8.71359L14 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M13.75 8.25V4.75H17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>',
|
||||
"pull-request": '<path d="M9.25 7C9.25 8.24264 8.24264 9.25 7 9.25C5.75736 9.25 4.75 8.24264 4.75 7C4.75 5.75736 5.75736 4.75 7 4.75C8.24264 4.75 9.25 5.75736 9.25 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9.25 17C9.25 18.2426 8.24264 19.25 7 19.25C5.75736 19.25 4.75 18.2426 4.75 17C4.75 15.7574 5.75736 14.75 7 14.75C8.24264 14.75 9.25 15.7574 9.25 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M19.25 17C19.25 18.2426 18.2426 19.25 17 19.25C15.7574 19.25 14.75 18.2426 14.75 17C14.75 15.7574 15.7574 14.75 17 14.75C18.2426 14.75 19.25 15.7574 19.25 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6.75 9.5V14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M17.25 14.25V10.4701C17.25 9.83943 17.0513 9.22483 16.682 8.71359L14 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M13.75 8.25V4.75H17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>',
|
||||
pencil: '<path d="M4.75 19.25L9 18.25L18.9491 8.30083C19.3397 7.9103 19.3397 7.27714 18.9491 6.88661L17.1134 5.05083C16.7228 4.6603 16.0897 4.6603 15.6991 5.05083L5.75 15L4.75 19.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M14.0234 7.03906L17.0234 10.0391" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>',
|
||||
sparkles: '<path d="M17 4.75C17 5.89705 15.8971 7 14.75 7C15.8971 7 17 8.10295 17 9.25C17 8.10295 18.1029 7 19.25 7C18.1029 7 17 5.89705 17 4.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M17 14.75C17 15.8971 15.8971 17 14.75 17C15.8971 17 17 18.1029 17 19.25C17 18.1029 18.1029 17 19.25 17C18.1029 17 17 15.8971 17 14.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9 7.75C9 9.91666 6.91666 12 4.75 12C6.91666 12 9 14.0833 9 16.25C9 14.0833 11.0833 12 13.25 12C11.0833 12 9 9.91666 9 7.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>',
|
||||
photo: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.75 16L7.49619 12.5067C8.2749 11.5161 9.76453 11.4837 10.5856 12.4395L13 15.25M10.915 12.823C11.9522 11.5037 13.3973 9.63455 13.4914 9.51294C13.4947 9.50859 13.4979 9.50448 13.5013 9.50017C14.2815 8.51598 15.7663 8.48581 16.5856 9.43947L19 12.25M6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V6.75C19.25 5.64543 18.3546 4.75 17.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25Z"></path>',
|
||||
columns: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.75 6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H17.25C18.3546 4.75 19.25 5.64543 19.25 6.75V17.25C19.25 18.3546 18.3546 19.25 17.25 19.25H6.75C5.64543 19.25 4.75 18.3546 4.75 17.25V6.75Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 5V19"></path>',
|
||||
"open-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.25 4.75v14.5m-3-8.5L9.75 12l1.5 1.25m-4.5 6h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
||||
"close-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 4.75v14.5m3-8.5L14.25 12l-1.5 1.25M6.75 19.25h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
||||
"file-search": '<path fill="currentColor" d="M17.25 9.25V10a.75.75 0 0 0 .53-1.28l-.53.53Zm-4.5-4.5.53-.53a.75.75 0 0 0-.53-.22v.75ZM10.25 20a.75.75 0 0 0 0-1.5V20Zm7.427-3.383a.75.75 0 0 0-1.06 1.06l1.06-1.06Zm1.043 3.163a.75.75 0 1 0 1.06-1.06l-1.06 1.06Zm-.94-11.06-4.5-4.5-1.06 1.06 4.5 4.5 1.06-1.06ZM12.75 4h-6v1.5h6V4ZM4 6.75v10.5h1.5V6.75H4ZM6.75 20h3.5v-1.5h-3.5V20ZM12 4.75v3.5h1.5v-3.5H12ZM13.75 10h3.5V8.5h-3.5V10ZM12 8.25c0 .966.784 1.75 1.75 1.75V8.5a.25.25 0 0 1-.25-.25H12Zm-8 9A2.75 2.75 0 0 0 6.75 20v-1.5c-.69 0-1.25-.56-1.25-1.25H4ZM6.75 4A2.75 2.75 0 0 0 4 6.75h1.5c0-.69.56-1.25 1.25-1.25V4Zm8.485 14.47a3.235 3.235 0 0 0 3.236-3.235h-1.5c0 .959-.777 1.736-1.736 1.736v1.5Zm0-4.97c.959 0 1.736.777 1.736 1.735h1.5A3.235 3.235 0 0 0 15.235 12v1.5Zm0-1.5A3.235 3.235 0 0 0 12 15.235h1.5c0-.958.777-1.735 1.735-1.735V12Zm0 4.97a1.735 1.735 0 0 1-1.735-1.735H12a3.235 3.235 0 0 0 3.235 3.236v-1.5Zm1.382.707 2.103 2.103 1.06-1.06-2.103-2.103-1.06 1.06Z"></path>',
|
||||
"folder-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.25 19.25h-3.5a2 2 0 0 1-2-2v-9.5h12.5a2 2 0 0 1 2 2v.5m-5.75-2.5-.931-1.958a2 2 0 0 0-1.756-1.042H6.75a2 2 0 0 0-2 2V11m12.695 6.445 1.805 1.805m-3.75-1a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5Z"></path>',
|
||||
search: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 19.25L15.5 15.5M4.75 11C4.75 7.54822 7.54822 4.75 11 4.75C14.4518 4.75 17.25 7.54822 17.25 11C17.25 14.4518 14.4518 17.25 11 17.25C7.54822 17.25 4.75 14.4518 4.75 11Z"></path>',
|
||||
"web-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 8.25v-.5a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v.5m14.5 0H4.75m14.5 0v2m-14.5-2v8a2 2 0 0 0 2 2h2.5m7.743-1.257 2.257 2.257m-4.015-1.53a2.485 2.485 0 1 0 0-4.97 2.485 2.485 0 0 0 0 4.97Z"></path>',
|
||||
loading: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.75v1.5m5.126.624L16 8m3.25 4h-1.5m-.624 5.126-1.768-1.768M12 16.75v2.5m-3.36-3.891-1.768 1.768M7.25 12h-2.5m3.891-3.358L6.874 6.874"></path>',
|
||||
"open-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.25 4.75v14.5m-3-8.5L9.75 12l1.5 1.25m-4.5 6h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
||||
"close-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 4.75v14.5m3-8.5L14.25 12l-1.5 1.25M6.75 19.25h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
||||
"file-search": '<path fill="currentColor" d="M17.25 9.25V10a.75.75 0 0 0 .53-1.28l-.53.53Zm-4.5-4.5.53-.53a.75.75 0 0 0-.53-.22v.75ZM10.25 20a.75.75 0 0 0 0-1.5V20Zm7.427-3.383a.75.75 0 0 0-1.06 1.06l1.06-1.06Zm1.043 3.163a.75.75 0 1 0 1.06-1.06l-1.06 1.06Zm-.94-11.06-4.5-4.5-1.06 1.06 4.5 4.5 1.06-1.06ZM12.75 4h-6v1.5h6V4ZM4 6.75v10.5h1.5V6.75H4ZM6.75 20h3.5v-1.5h-3.5V20ZM12 4.75v3.5h1.5v-3.5H12ZM13.75 10h3.5V8.5h-3.5V10ZM12 8.25c0 .966.784 1.75 1.75 1.75V8.5a.25.25 0 0 1-.25-.25H12Zm-8 9A2.75 2.75 0 0 0 6.75 20v-1.5c-.69 0-1.25-.56-1.25-1.25H4ZM6.75 4A2.75 2.75 0 0 0 4 6.75h1.5c0-.69.56-1.25 1.25-1.25V4Zm8.485 14.47a3.235 3.235 0 0 0 3.236-3.235h-1.5c0 .959-.777 1.736-1.736 1.736v1.5Zm0-4.97c.959 0 1.736.777 1.736 1.735h1.5A3.235 3.235 0 0 0 15.235 12v1.5Zm0-1.5A3.235 3.235 0 0 0 12 15.235h1.5c0-.958.777-1.735 1.735-1.735V12Zm0 4.97a1.735 1.735 0 0 1-1.735-1.735H12a3.235 3.235 0 0 0 3.235 3.236v-1.5Zm1.382.707 2.103 2.103 1.06-1.06-2.103-2.103-1.06 1.06Z"></path>',
|
||||
"folder-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.25 19.25h-3.5a2 2 0 0 1-2-2v-9.5h12.5a2 2 0 0 1 2 2v.5m-5.75-2.5-.931-1.958a2 2 0 0 0-1.756-1.042H6.75a2 2 0 0 0-2 2V11m12.695 6.445 1.805 1.805m-3.75-1a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5Z"></path>',
|
||||
search: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 19.25L15.5 15.5M4.75 11C4.75 7.54822 7.54822 4.75 11 4.75C14.4518 4.75 17.25 7.54822 17.25 11C17.25 14.4518 14.4518 17.25 11 17.25C7.54822 17.25 4.75 14.4518 4.75 11Z"></path>',
|
||||
"web-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 8.25v-.5a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v.5m14.5 0H4.75m14.5 0v2m-14.5-2v8a2 2 0 0 0 2 2h2.5m7.743-1.257 2.257 2.257m-4.015-1.53a2.485 2.485 0 1 0 0-4.97 2.485 2.485 0 0 0 0 4.97Z"></path>',
|
||||
loading: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.75v1.5m5.126.624L16 8m3.25 4h-1.5m-.624 5.126-1.768-1.768M12 16.75v2.5m-3.36-3.891-1.768 1.768M7.25 12h-2.5m3.891-3.358L6.874 6.874"></path>',
|
||||
mic: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 8C8.75 6.20507 10.2051 4.75 12 4.75C13.7949 4.75 15.25 6.20507 15.25 8V11C15.25 12.7949 13.7949 14.25 12 14.25C10.2051 14.25 8.75 12.7949 8.75 11V8Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.75 12.75C5.75 12.75 6 17.25 12 17.25C18 17.25 18.25 12.75 18.25 12.75"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 17.75V19.25"></path>',
|
||||
} as const
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,9 @@ export function Tooltip(props: TooltipProps) {
|
|||
|
||||
return (
|
||||
<KobalteTooltip forceMount {...others} open={open()} onOpenChange={setOpen}>
|
||||
<KobalteTooltip.Trigger as={"div"}>{c()}</KobalteTooltip.Trigger>
|
||||
<KobalteTooltip.Trigger as={"div"} class="flex items-center">
|
||||
{c()}
|
||||
</KobalteTooltip.Trigger>
|
||||
<KobalteTooltip.Portal>
|
||||
<KobalteTooltip.Content
|
||||
classList={{
|
||||
|
|
|
|||
302
packages/app/src/utils/speech.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { createSignal, onCleanup } from "solid-js"
|
||||
|
||||
// Minimal types to avoid relying on non-standard DOM typings
|
||||
type RecognitionResult = {
|
||||
0: { transcript: string }
|
||||
isFinal: boolean
|
||||
}
|
||||
|
||||
type RecognitionEvent = {
|
||||
results: RecognitionResult[]
|
||||
resultIndex: number
|
||||
}
|
||||
|
||||
interface Recognition {
|
||||
continuous: boolean
|
||||
interimResults: boolean
|
||||
lang: string
|
||||
start: () => void
|
||||
stop: () => void
|
||||
onresult: ((e: RecognitionEvent) => void) | null
|
||||
onerror: ((e: { error: string }) => void) | null
|
||||
onend: (() => void) | null
|
||||
onstart: (() => void) | null
|
||||
}
|
||||
|
||||
const COMMIT_DELAY = 250
|
||||
|
||||
const appendSegment = (base: string, addition: string) => {
|
||||
const trimmed = addition.trim()
|
||||
if (!trimmed) return base
|
||||
if (!base) return trimmed
|
||||
const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed)
|
||||
return `${base}${needsSpace ? " " : ""}${trimmed}`
|
||||
}
|
||||
|
||||
const extractSuffix = (committed: string, hypothesis: string) => {
|
||||
const cleanHypothesis = hypothesis.trim()
|
||||
if (!cleanHypothesis) return ""
|
||||
const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : []
|
||||
const hypothesisTokens = cleanHypothesis.split(/\s+/)
|
||||
let index = 0
|
||||
while (
|
||||
index < baseTokens.length &&
|
||||
index < hypothesisTokens.length &&
|
||||
baseTokens[index] === hypothesisTokens[index]
|
||||
) {
|
||||
index += 1
|
||||
}
|
||||
if (index < baseTokens.length) return ""
|
||||
return hypothesisTokens.slice(index).join(" ")
|
||||
}
|
||||
|
||||
export function createSpeechRecognition(opts?: {
|
||||
lang?: string
|
||||
onFinal?: (text: string) => void
|
||||
onInterim?: (text: string) => void
|
||||
}) {
|
||||
const hasSupport =
|
||||
typeof window !== "undefined" &&
|
||||
Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
|
||||
|
||||
const [isRecording, setIsRecording] = createSignal(false)
|
||||
const [committed, setCommitted] = createSignal("")
|
||||
const [interim, setInterim] = createSignal("")
|
||||
|
||||
let recognition: Recognition | undefined
|
||||
let shouldContinue = false
|
||||
let committedText = ""
|
||||
let sessionCommitted = ""
|
||||
let pendingHypothesis = ""
|
||||
let lastInterimSuffix = ""
|
||||
let shrinkCandidate: string | undefined
|
||||
let commitTimer: number | undefined
|
||||
|
||||
const cancelPendingCommit = () => {
|
||||
if (commitTimer === undefined) return
|
||||
clearTimeout(commitTimer)
|
||||
commitTimer = undefined
|
||||
}
|
||||
|
||||
const commitSegment = (segment: string) => {
|
||||
const nextCommitted = appendSegment(committedText, segment)
|
||||
if (nextCommitted === committedText) return
|
||||
committedText = nextCommitted
|
||||
setCommitted(committedText)
|
||||
if (opts?.onFinal) opts.onFinal(segment.trim())
|
||||
}
|
||||
|
||||
const promotePending = () => {
|
||||
if (!pendingHypothesis) return
|
||||
const suffix = extractSuffix(sessionCommitted, pendingHypothesis)
|
||||
if (!suffix) {
|
||||
pendingHypothesis = ""
|
||||
return
|
||||
}
|
||||
sessionCommitted = appendSegment(sessionCommitted, suffix)
|
||||
commitSegment(suffix)
|
||||
pendingHypothesis = ""
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
}
|
||||
|
||||
const applyInterim = (suffix: string, hypothesis: string) => {
|
||||
cancelPendingCommit()
|
||||
pendingHypothesis = hypothesis
|
||||
lastInterimSuffix = suffix
|
||||
shrinkCandidate = undefined
|
||||
setInterim(suffix)
|
||||
if (opts?.onInterim) {
|
||||
opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
|
||||
}
|
||||
if (!suffix) return
|
||||
const snapshot = hypothesis
|
||||
commitTimer = window.setTimeout(() => {
|
||||
if (pendingHypothesis !== snapshot) return
|
||||
const currentSuffix = extractSuffix(sessionCommitted, pendingHypothesis)
|
||||
if (!currentSuffix) return
|
||||
sessionCommitted = appendSegment(sessionCommitted, currentSuffix)
|
||||
commitSegment(currentSuffix)
|
||||
pendingHypothesis = ""
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
}, COMMIT_DELAY)
|
||||
}
|
||||
|
||||
if (hasSupport) {
|
||||
const Ctor: new () => Recognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition
|
||||
|
||||
recognition = new Ctor()
|
||||
recognition.continuous = false
|
||||
recognition.interimResults = true
|
||||
recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
|
||||
|
||||
recognition.onresult = (event: RecognitionEvent) => {
|
||||
if (!event.results.length) return
|
||||
|
||||
let aggregatedFinal = ""
|
||||
let latestHypothesis = ""
|
||||
|
||||
for (let i = 0; i < event.results.length; i += 1) {
|
||||
const result = event.results[i]
|
||||
const transcript = (result[0]?.transcript || "").trim()
|
||||
if (!transcript) continue
|
||||
if (result.isFinal) {
|
||||
aggregatedFinal = appendSegment(aggregatedFinal, transcript)
|
||||
} else {
|
||||
latestHypothesis = transcript
|
||||
}
|
||||
}
|
||||
|
||||
if (aggregatedFinal) {
|
||||
cancelPendingCommit()
|
||||
const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal)
|
||||
if (finalSuffix) {
|
||||
sessionCommitted = appendSegment(sessionCommitted, finalSuffix)
|
||||
commitSegment(finalSuffix)
|
||||
}
|
||||
pendingHypothesis = ""
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
return
|
||||
}
|
||||
|
||||
cancelPendingCommit()
|
||||
|
||||
if (!latestHypothesis) {
|
||||
shrinkCandidate = undefined
|
||||
applyInterim("", "")
|
||||
return
|
||||
}
|
||||
|
||||
const suffix = extractSuffix(sessionCommitted, latestHypothesis)
|
||||
|
||||
if (!suffix) {
|
||||
if (!lastInterimSuffix) {
|
||||
shrinkCandidate = undefined
|
||||
applyInterim("", latestHypothesis)
|
||||
return
|
||||
}
|
||||
if (shrinkCandidate === "") {
|
||||
applyInterim("", latestHypothesis)
|
||||
return
|
||||
}
|
||||
shrinkCandidate = ""
|
||||
pendingHypothesis = latestHypothesis
|
||||
return
|
||||
}
|
||||
|
||||
if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) {
|
||||
if (shrinkCandidate === suffix) {
|
||||
applyInterim(suffix, latestHypothesis)
|
||||
return
|
||||
}
|
||||
shrinkCandidate = suffix
|
||||
pendingHypothesis = latestHypothesis
|
||||
return
|
||||
}
|
||||
|
||||
shrinkCandidate = undefined
|
||||
applyInterim(suffix, latestHypothesis)
|
||||
}
|
||||
|
||||
recognition.onerror = (e: { error: string }) => {
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
if (e.error === "no-speech" && shouldContinue) {
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
setTimeout(() => {
|
||||
try {
|
||||
recognition?.start()
|
||||
} catch {}
|
||||
}, 150)
|
||||
return
|
||||
}
|
||||
shouldContinue = false
|
||||
setIsRecording(false)
|
||||
}
|
||||
|
||||
recognition.onstart = () => {
|
||||
sessionCommitted = ""
|
||||
pendingHypothesis = ""
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
setIsRecording(true)
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setIsRecording(false)
|
||||
if (shouldContinue) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
recognition?.start()
|
||||
} catch {}
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (!recognition) return
|
||||
shouldContinue = true
|
||||
sessionCommitted = ""
|
||||
pendingHypothesis = ""
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
try {
|
||||
recognition.start()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (!recognition) return
|
||||
shouldContinue = false
|
||||
promotePending()
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
try {
|
||||
recognition.stop()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
shouldContinue = false
|
||||
promotePending()
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
try {
|
||||
recognition?.stop()
|
||||
} catch {}
|
||||
})
|
||||
|
||||
return {
|
||||
isSupported: () => hasSupport,
|
||||
isRecording,
|
||||
committed,
|
||||
interim,
|
||||
start,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="400" height="400" fill="#0E0E0E"/>
|
||||
<path d="M252 278H148V174H252V278Z" fill="#6A6565"/>
|
||||
<path d="M252 122H148V278H252V122ZM304 330H96V70H304V330Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M312 340H88V60H312V340ZM256 116H144V284H256V116Z" fill="white"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 269 B |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 13 KiB |
|
|
@ -1,6 +1,6 @@
|
|||
import { action, useSubmission } from "@solidjs/router"
|
||||
import dock from "../asset/lander/dock.png"
|
||||
import { Resource } from "sst"
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
import { Show } from "solid-js"
|
||||
|
||||
const emailSignup = action(async (formData: FormData) => {
|
||||
|
|
@ -39,7 +39,9 @@ export function EmailSignup() {
|
|||
</button>
|
||||
</form>
|
||||
<Show when={submission.result}>
|
||||
<div style="color: #03B000; margin-top: 24px;">Almost done, check your inbox and confirm your email address</div>
|
||||
<div style="color: #03B000; margin-top: 24px;">
|
||||
Almost done, check your inbox and confirm your email address
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={submission.error}>
|
||||
<div style="color: #FF408F; margin-top: 24px;">{submission.error}</div>
|
||||
|
|
|
|||
|
|
@ -10,20 +10,20 @@ export function Faq(props: ParentProps & { question: string }) {
|
|||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M12.5 11.5H19V12.5H12.5V19H11.5V12.5H5V11.5H11.5V5H12.5V11.5Z" fill="#6D717D" />
|
||||
<path d="M12.5 11.5H19V12.5H12.5V19H11.5V12.5H5V11.5H11.5V5H12.5V11.5Z" fill="currentColor" />
|
||||
</svg>
|
||||
<svg
|
||||
data-slot="faq-icon-minus"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M5 11.5H19V12.5H5Z" fill="#6D717D" />
|
||||
<path d="M5 11.5H19V12.5H5Z" fill="currentColor" />
|
||||
</svg>
|
||||
<div data-slot="faq-question-text">{props.question}</div>
|
||||
</Collapsible.Trigger>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { A, createAsync } from "@solidjs/router"
|
||||
import { createAsync } from "@solidjs/router"
|
||||
import { createMemo } from "solid-js"
|
||||
import { github } from "~/lib/github"
|
||||
|
||||
|
|
@ -16,18 +16,18 @@ export function Footer() {
|
|||
return (
|
||||
<footer data-component="footer">
|
||||
<div data-slot="cell">
|
||||
<A href="https://github.com/sst/opencode" target="_blank">
|
||||
<a href="https://github.com/sst/opencode" target="_blank">
|
||||
GitHub <span>[{starCount()}]</span>
|
||||
</A>
|
||||
</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<A href="/docs">Docs</A>
|
||||
<a href="/docs">Docs</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<A href="/discord">Discord</A>
|
||||
<a href="/discord">Discord</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<A href="https://x.com/opencode">X</A>
|
||||
<a href="https://x.com/opencode">X</a>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ export function Header(props: { zen?: boolean }) {
|
|||
<nav data-component="nav-desktop">
|
||||
<ul>
|
||||
<li>
|
||||
<A href="https://github.com/sst/opencode" target="_blank">
|
||||
<a href="https://github.com/sst/opencode" target="_blank">
|
||||
GitHub <span>[{starCount()}]</span>
|
||||
</A>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/docs">Docs</a>
|
||||
|
|
@ -100,17 +100,17 @@ export function Header(props: { zen?: boolean }) {
|
|||
<A href="/">Home</A>
|
||||
</li>
|
||||
<li>
|
||||
<A href="https://github.com/sst/opencode" target="_blank">
|
||||
<a href="https://github.com/sst/opencode" target="_blank">
|
||||
GitHub <span>[{starCount()}]</span>
|
||||
</A>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<A href="/docs">Docs</A>
|
||||
<a href="/docs">Docs</a>
|
||||
</li>
|
||||
<li>
|
||||
<Switch>
|
||||
<Match when={props.zen}>
|
||||
<A href="/auth">Login</A>
|
||||
<a href="/auth">Login</a>
|
||||
</Match>
|
||||
<Match when={!props.zen}>
|
||||
<A href="/zen">Zen</A>
|
||||
|
|
|
|||
|
|
@ -2,34 +2,26 @@ import { JSX } from "solid-js"
|
|||
|
||||
export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="currentColor" />
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="currentColor" />
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="currentColor" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="currentColor" />
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="currentColor" />
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="currentColor" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
<svg {...props} width="234" height="42" viewBox="0 0 234 42" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M54 36H36V42H30V6H54V36ZM36 30H48V12H36V30Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M24 36H0V6H24V36ZM6 30H18V12H6V30Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="currentColor"/>
|
||||
<path d="M108 12H96V36H90V6H108V12Z" fill="currentColor"/>
|
||||
<path d="M114 36H108V12H114V36Z" fill="currentColor"/>
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M174 36H150V6H174V36ZM156 30H168V12H156V30Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M204 36H180V6H198V0H204V36ZM186 30H198V12H186V30Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M234 24H216V30H234V36H210V6H234V24ZM216 18H228V12H216V18Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export function IconCopy(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { getRequestEvent } from "solid-js/web"
|
||||
import { and, Database, eq, inArray, sql } from "@opencode/console-core/drizzle/index.js"
|
||||
import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
|
||||
import { UserTable } from "@opencode/console-core/schema/user.sql.js"
|
||||
import { redirect } from "@solidjs/router"
|
||||
import { AccountTable } from "@opencode/console-core/schema/account.sql.js"
|
||||
import { Actor } from "@opencode/console-core/actor.js"
|
||||
|
||||
import { createClient } from "@openauthjs/openauth/client"
|
||||
|
|
@ -54,32 +52,27 @@ export const getActor = async (workspace?: string): Promise<Actor.Info> => {
|
|||
}
|
||||
const accounts = Object.keys(auth.data.account ?? {})
|
||||
if (accounts.length) {
|
||||
const result = await Database.use((tx) =>
|
||||
const user = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
user: UserTable,
|
||||
})
|
||||
.from(AccountTable)
|
||||
.innerJoin(UserTable, and(eq(UserTable.email, AccountTable.email)))
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
|
||||
.where(and(inArray(AccountTable.id, accounts), eq(WorkspaceTable.id, workspace)))
|
||||
.select()
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.workspaceID, workspace), inArray(UserTable.accountID, accounts)))
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then((x) => x[0]),
|
||||
)
|
||||
if (result) {
|
||||
if (user) {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(UserTable)
|
||||
.set({ timeSeen: sql`now()` })
|
||||
.where(eq(UserTable.id, result.user.id)),
|
||||
.where(and(eq(UserTable.workspaceID, workspace), eq(UserTable.id, user.id))),
|
||||
)
|
||||
return {
|
||||
type: "user",
|
||||
properties: {
|
||||
userID: result.user.id,
|
||||
workspaceID: result.user.workspaceID,
|
||||
role: result.user.role,
|
||||
userID: user.id,
|
||||
workspaceID: user.workspaceID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export default createHandler(
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon-zen.svg" />
|
||||
<meta property="og:image" content="/social-share.png" />
|
||||
<meta property="twitter:image" content="/social-share.png" />
|
||||
{assets}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@
|
|||
--color-icon: hsl(0, 1%, 55%);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
[data-page="opencode"] {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--color-background: hsl(0, 9%, 7%);
|
||||
|
|
@ -52,12 +56,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
@supports (background: -webkit-named-image(i)) {
|
||||
[data-page="opencode"] {
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="opencode"] {
|
||||
background: var(--color-background);
|
||||
--padding: 5rem;
|
||||
--vertical-padding: 4rem;
|
||||
--heading-font-size: 1.375rem;
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
--padding: 1.5rem;
|
||||
|
|
@ -80,6 +90,10 @@
|
|||
|
||||
p {
|
||||
line-height: 200%;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
line-height: 180%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
|
|
@ -162,6 +176,15 @@
|
|||
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;
|
||||
margin: 0 auto;
|
||||
|
|
@ -220,6 +243,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
[data-component="nav-mobile"] {
|
||||
button > svg {
|
||||
color: var(--color-icon);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="nav-mobile-toggle"] {
|
||||
border: none;
|
||||
background: none;
|
||||
|
|
@ -227,6 +256,7 @@
|
|||
height: 40px;
|
||||
width: 40px;
|
||||
cursor: pointer;
|
||||
margin-right: -8px;
|
||||
}
|
||||
|
||||
[data-component="nav-mobile-toggle"]:hover {
|
||||
|
|
@ -549,9 +579,6 @@
|
|||
margin-bottom: 24px;
|
||||
max-width: 100%;
|
||||
|
||||
@media (max-width: 30Rem) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
|
|
@ -561,6 +588,10 @@
|
|||
span {
|
||||
color: var(--color-icon);
|
||||
line-height: 200%;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
line-height: 180%;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
|
|
@ -595,6 +626,10 @@
|
|||
gap: 12px;
|
||||
line-height: 200%;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
line-height: 180%;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--color-icon);
|
||||
}
|
||||
|
|
@ -762,15 +797,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 {
|
||||
|
|
@ -850,6 +899,9 @@
|
|||
|
||||
[data-slot="faq-icon-plus"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-weak);
|
||||
margin-top: 2px;
|
||||
|
||||
[data-closed] & {
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -859,6 +911,9 @@
|
|||
}
|
||||
[data-slot="faq-icon-minus"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-weak);
|
||||
margin-top: 2px;
|
||||
|
||||
[data-closed] & {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -1035,6 +1090,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 */
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export default function Home() {
|
|||
<main data-page="opencode">
|
||||
<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />
|
||||
<Title>OpenCode | The AI coding agent built for the terminal</Title>
|
||||
<Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
|
||||
<Meta property="og:image" content="/social-share.png" />
|
||||
<Meta name="twitter:image" content="/social-share.png" />
|
||||
<div data-component="container">
|
||||
|
|
@ -647,7 +647,7 @@ export default function Home() {
|
|||
|
||||
<p>
|
||||
OpenCode does not store any of your code or context data, so that it can operate in privacy sensitive
|
||||
environments. Learn more about <a href="/docs/enterprise/ ">privacy and enterprise</a>.
|
||||
environments. Learn more about <a href="/docs/enterprise/ ">privacy</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -799,6 +799,8 @@ export default function Home() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<EmailSignup />
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const logout = action(async () => {
|
|||
event!.locals.actor = undefined
|
||||
return val
|
||||
})
|
||||
throw redirect("/")
|
||||
throw redirect("/zen")
|
||||
})
|
||||
|
||||
export default function WorkspaceLayout(props: RouteSectionProps) {
|
||||
|
|
|
|||
|
|
@ -10,24 +10,14 @@ import { Show } from "solid-js"
|
|||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { Actor } from "@opencode/console-core/actor.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { and, Database, eq } from "@opencode/console-core/drizzle/index.js"
|
||||
import { UserTable } from "@opencode/console-core/schema/user.sql.js"
|
||||
import { User } from "@opencode/console-core/user.js"
|
||||
|
||||
const getUser = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
const actor = Actor.use()
|
||||
const isAdmin = await (async () => {
|
||||
if (actor.type !== "user") return false
|
||||
const role = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ role: UserTable.role })
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.id, actor.properties.userID))),
|
||||
).then((x) => x[0]?.role)
|
||||
return role === "admin"
|
||||
})()
|
||||
return { isAdmin }
|
||||
const actor = Actor.assert("user")
|
||||
const user = await User.fromID(actor.properties.userID)
|
||||
return { isAdmin: user?.role === "admin" }
|
||||
}, workspaceID)
|
||||
}, "user.get")
|
||||
|
||||
|
|
|
|||
|
|
@ -3,46 +3,18 @@ import { createEffect, createSignal, For, Show } from "solid-js"
|
|||
import { withActor } from "~/context/auth.withActor"
|
||||
import { createStore } from "solid-js/store"
|
||||
import styles from "./member-section.module.css"
|
||||
import { and, Database, eq, isNull, sql } from "@opencode/console-core/drizzle/index.js"
|
||||
import { UserTable, UserRole } from "@opencode/console-core/schema/user.sql.js"
|
||||
import { Identifier } from "@opencode/console-core/identifier.js"
|
||||
import { UserRole } from "@opencode/console-core/schema/user.sql.js"
|
||||
import { Actor } from "@opencode/console-core/actor.js"
|
||||
import { AWS } from "@opencode/console-core/aws.js"
|
||||
|
||||
const assertAdmin = async (workspaceID: string) => {
|
||||
const actor = Actor.use()
|
||||
if (actor.type !== "user") throw new Error(`Expected admin user, got ${actor.type}`)
|
||||
const user = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.id, actor.properties.userID))),
|
||||
).then((x) => x[0])
|
||||
if (user?.role !== "admin") throw new Error(`Expected admin user, got ${user?.role}`)
|
||||
return actor
|
||||
}
|
||||
|
||||
const assertNotSelf = (id: string) => {
|
||||
const actor = Actor.use()
|
||||
if (actor.type === "user" && actor.properties.userID === id) {
|
||||
throw new Error(`Expected not self actor, got self actor`)
|
||||
}
|
||||
return actor
|
||||
}
|
||||
import { User } from "@opencode/console-core/user.js"
|
||||
|
||||
const listMembers = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
const actor = await assertAdmin(workspaceID)
|
||||
return Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
||||
).then((members) => ({
|
||||
members,
|
||||
const actor = Actor.assert("user")
|
||||
return {
|
||||
members: await User.list(),
|
||||
currentUserID: actor.properties.userID,
|
||||
}))
|
||||
}
|
||||
}, workspaceID)
|
||||
}, "member.list")
|
||||
|
||||
|
|
@ -55,43 +27,13 @@ const inviteMember = action(async (form: FormData) => {
|
|||
const role = form.get("role")?.toString() as (typeof UserRole)[number]
|
||||
if (!role) return { error: "Role is required" }
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
await assertAdmin(workspaceID)
|
||||
return Database.use((tx) =>
|
||||
tx
|
||||
.insert(UserTable)
|
||||
.values({
|
||||
id: Identifier.create("user"),
|
||||
name: "",
|
||||
email,
|
||||
workspaceID,
|
||||
role,
|
||||
})
|
||||
await withActor(
|
||||
() =>
|
||||
User.invite({ email, role })
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.then(async (data) => {
|
||||
const { render } = await import("@jsx-email/render")
|
||||
const { InviteEmail } = await import("@opencode/console-mail/InviteEmail.jsx")
|
||||
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,
|
||||
}),
|
||||
),
|
||||
})
|
||||
return data
|
||||
})
|
||||
.catch((e) => {
|
||||
let error = e.message
|
||||
if (error.match(/Duplicate entry '.*' for key 'user.user_email'/))
|
||||
error = "A user with this email has already been invited."
|
||||
return { error }
|
||||
}),
|
||||
)
|
||||
}, workspaceID),
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: listMembers.key },
|
||||
)
|
||||
}, "member.create")
|
||||
|
|
@ -103,29 +45,13 @@ const removeMember = action(async (form: FormData) => {
|
|||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
await assertAdmin(workspaceID)
|
||||
assertNotSelf(id)
|
||||
return Database.transaction(async (tx) => {
|
||||
const email = await tx
|
||||
.select({ email: UserTable.email })
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, workspaceID)))
|
||||
.execute()
|
||||
.then((rows) => rows[0].email)
|
||||
if (!email) return { error: "User not found" }
|
||||
await tx
|
||||
.update(UserTable)
|
||||
.set({
|
||||
oldEmail: email,
|
||||
email: null,
|
||||
timeDeleted: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, workspaceID)))
|
||||
})
|
||||
.then(() => ({ error: undefined }))
|
||||
.catch((e) => ({ error: e.message as string }))
|
||||
}, workspaceID),
|
||||
await withActor(
|
||||
() =>
|
||||
User.remove(id)
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: listMembers.key },
|
||||
)
|
||||
}, "member.remove")
|
||||
|
|
@ -139,18 +65,13 @@ const updateMemberRole = action(async (form: FormData) => {
|
|||
const role = form.get("role")?.toString() as (typeof UserRole)[number]
|
||||
if (!role) return { error: "Role is required" }
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
await assertAdmin(workspaceID)
|
||||
if (role === "member") assertNotSelf(id)
|
||||
return Database.use((tx) =>
|
||||
tx
|
||||
.update(UserTable)
|
||||
.set({ role })
|
||||
.where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, workspaceID)))
|
||||
await withActor(
|
||||
() =>
|
||||
User.updateRole({ id, role })
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
)
|
||||
}, workspaceID),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: listMembers.key },
|
||||
)
|
||||
}, "member.updateRole")
|
||||
|
|
@ -248,7 +169,7 @@ function MemberRow(props: { member: any; workspaceID: string; currentUserID: str
|
|||
when={editing()}
|
||||
fallback={
|
||||
<tr>
|
||||
<td data-slot="member-email">{props.member.email}</td>
|
||||
<td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td>
|
||||
<td data-slot="member-role">{props.member.role}</td>
|
||||
<Show when={!props.member.timeSeen} fallback={<td data-slot="member-joined"></td>}>
|
||||
<td data-slot="member-joined">invited</td>
|
||||
|
|
@ -271,7 +192,7 @@ function MemberRow(props: { member: any; workspaceID: string; currentUserID: str
|
|||
<tr>
|
||||
<td colspan="4">
|
||||
<form action={updateMemberRole} method="post">
|
||||
<div data-slot="edit-member-email">{props.member.email}</div>
|
||||
<div data-slot="edit-member-email">{props.member.accountEmail ?? props.member.email}</div>
|
||||
<input type="hidden" name="id" value={props.member.id} />
|
||||
<input type="hidden" name="workspaceID" value={props.workspaceID} />
|
||||
<Show when={!isCurrentUser()} fallback={<div data-slot="current-user-role">Role: {props.member.role}</div>}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -146,9 +146,7 @@ export default function Home() {
|
|||
<div data-slot="section-title">
|
||||
<h3>What problem is Zen solving?</h3>
|
||||
<p>
|
||||
There are so many models available, but only a few work well with coding agents.
|
||||
<br />
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<p>We're fixing this for everyone, not just OpenCode users.</p>
|
||||
|
|
@ -218,8 +216,8 @@ export default function Home() {
|
|||
<strong>Dax Raad</strong>
|
||||
<span>ex-CEO, Terminal Products</span>
|
||||
</div>
|
||||
<div data-slot="quote">
|
||||
OpenCode Zen has been life changing, it's truly a no-brainer
|
||||
<div data-slot="quote"><span>@OpenCode</span> Zen has been life
|
||||
changing, it's truly a no-brainer.
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
|
@ -237,7 +235,7 @@ export default function Home() {
|
|||
</div>
|
||||
</a>
|
||||
{/*Adam*/}
|
||||
<a href="#">
|
||||
<a href="https://x.com/adamdotdev/status/1973732040718860563">
|
||||
<div data-slot="testimonial">
|
||||
<div data-slot="name">
|
||||
<img src={avatarAdam} alt="" />
|
||||
|
|
@ -329,6 +327,8 @@ export default function Home() {
|
|||
</ul>
|
||||
</section>
|
||||
|
||||
<EmailSignup />
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
724
packages/console/core/migrations/meta/0022_snapshot.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -155,6 +155,13 @@
|
|||
"when": 1759186023755,
|
||||
"tag": "0021_flawless_clea",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "5",
|
||||
"when": 1759427432588,
|
||||
"tag": "0022_nice_dreadnoughts",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "react",
|
||||
"types": ["@cloudflare/workers-types", "node"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="white"/>
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="white"/>
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="white"/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="white"/>
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="white"/>
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="white"/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="white"/>
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
|
||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
|
||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
|
||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
|
||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
|
||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
|
||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 981 B After Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,12 +1,18 @@
|
|||
<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="black"/>
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="black"/>
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="black"/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="black"/>
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="black"/>
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="black"/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="black"/>
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/>
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/>
|
||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/>
|
||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/>
|
||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/>
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/>
|
||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/>
|
||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/>
|
||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 981 B After Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,18 +1,18 @@
|
|||
<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.5 16.5H24.5V33H8.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M48.5 16.5H64.5V33H48.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M120.5 16.5H136.5V33H120.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M160.5 16.5H176.5V33H160.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M192.5 16.5H208.5V33H192.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M232.5 16.5H248.5V33H232.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="white" fill-opacity="0.95"/>
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="white" fill-opacity="0.95"/>
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="white" fill-opacity="0.95"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="white" fill-opacity="0.95"/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="white" fill-opacity="0.5"/>
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="white" fill-opacity="0.5"/>
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="white" fill-opacity="0.5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="white" fill-opacity="0.5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="white" fill-opacity="0.5"/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="white" fill-opacity="0.95"/>
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
|
||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
|
||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
|
||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
|
||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
|
||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
|
||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,18 +1,18 @@
|
|||
<svg width="288" height="50" viewBox="0 0 288 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 16.5H24V33H8V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M48 16.5H64V33H48V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M120 16.5H136V33H120V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M160 16.5H176V33H160V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M192 16.5H208V33H192V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M232 16.5H248V33H232V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M264 0H288V8.5H272V16.5H288V25H272V33H288V41.5H264V0Z" fill="black" fill-opacity="0.95"/>
|
||||
<path d="M248 0H224V41.5H248V33H232V8.5H248V0Z" fill="black" fill-opacity="0.95"/>
|
||||
<path d="M256 8.5H248V33H256V8.5Z" fill="black" fill-opacity="0.95"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184 0H216V41.5H184V0ZM208 8.5H192V33H208V8.5Z" fill="black" fill-opacity="0.95"/>
|
||||
<path d="M144 8.5H136V41.5H144V8.5Z" fill="black" fill-opacity="0.55"/>
|
||||
<path d="M136 0H112V41.5H120V8.5H136V0Z" fill="black" fill-opacity="0.55"/>
|
||||
<path d="M80 0H104V8.5H88V16.5H104V25H88V33H104V41.5H80V0Z" fill="black" fill-opacity="0.55"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40 0H72V41.5H48V49.5H40V0ZM64 8.5H48V33H64V8.5Z" fill="black" fill-opacity="0.55"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0H32V41.5955H0V0ZM24 8.5H8V33H24V8.5Z" fill="black" fill-opacity="0.55"/>
|
||||
<path d="M152 0H176V8.5H160V33H176V41.5H152V0Z" fill="black" fill-opacity="0.95"/>
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/>
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/>
|
||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/>
|
||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/>
|
||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/>
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/>
|
||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/>
|
||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/>
|
||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
|
@ -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 {
|
||||
|
|
|
|||