mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-26 02:14:43 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76d814e747 | |||
| 9941e70d29 | |||
| 2afb812620 | |||
| ad2fc1ce4b | |||
| 5acc368ef4 | |||
| e2dc89c6f3 | |||
| e1581183ff | |||
| f195c952fc | |||
| 34b1045a01 | |||
| aa9956d46d | |||
| cec5a752bb | |||
| 0f0a4be253 | |||
| 0e88dd99e7 | |||
| f965db9e13 | |||
| 519d344470 | |||
| a78605f8ea |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -121,7 +121,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -148,7 +148,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -170,7 +170,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -194,7 +194,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "0.0.0",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -214,7 +214,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -275,7 +275,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -330,7 +330,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -344,7 +344,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -374,7 +374,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -390,7 +390,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -403,7 +403,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -421,7 +421,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -559,7 +559,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -597,7 +597,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -612,7 +612,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -625,7 +625,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -648,7 +648,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -667,7 +667,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -707,7 +707,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -756,7 +756,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
+1
-3
@@ -5,10 +5,8 @@ export const domain = (() => {
|
||||
})()
|
||||
|
||||
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
||||
// Dev owns the shared AWS lake/stats infra for all non-production stages.
|
||||
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
||||
// Temporarily omit AWS infra so SST removes the lake/stats resources.
|
||||
export const deployAws = false
|
||||
export const deployAws = $app.stage === awsStage
|
||||
|
||||
new cloudflare.RegionalHostname("RegionalHostname", {
|
||||
hostname: domain,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
@@ -40,7 +41,7 @@ import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
@@ -48,11 +49,6 @@ import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { ServersProvider } from "./context/servers"
|
||||
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "prod") {
|
||||
document.body.classList.remove("text-12-regular")
|
||||
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
||||
}
|
||||
|
||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
|
||||
@@ -97,9 +93,26 @@ function QueryProvider(props: ParentProps) {
|
||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
function BodyDesignClass() {
|
||||
const settings = useSettings()
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const enabled = settings.general.newLayoutDesigns()
|
||||
document.body.classList.toggle("text-12-regular", !enabled)
|
||||
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
|
||||
document.body.classList.toggle("text-[13px]", enabled)
|
||||
document.body.classList.toggle("font-[440]", enabled)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function AppShellProviders(props: ParentProps) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<BodyDesignClass />
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
|
||||
@@ -79,8 +79,6 @@ import { pathKey } from "@/utils/path-key"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
|
||||
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
interface PromptInputProps {
|
||||
class?: string
|
||||
variant?: "dock" | "new-session"
|
||||
@@ -1456,7 +1454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={USE_V2_INPUT}>
|
||||
<Match when={settings.general.newLayoutDesigns()}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<DockShellForm
|
||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||
@@ -1528,7 +1526,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
|
||||
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
|
||||
@@ -45,8 +45,6 @@ const OPEN_APPS = [
|
||||
"sublime-text",
|
||||
] as const
|
||||
|
||||
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
type OpenApp = (typeof OPEN_APPS)[number]
|
||||
type OS = "macos" | "windows" | "linux" | "unknown"
|
||||
|
||||
@@ -157,11 +155,11 @@ export function SessionHeader() {
|
||||
})
|
||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
const isDesktopV2 = platform.platform === "desktop" && USE_V2_TITLEBAR
|
||||
const search = createMemo(() => (isDesktopV2 ? settings.general.showSearch() : true))
|
||||
const tree = createMemo(() => (isDesktopV2 ? settings.general.showFileTree() : true))
|
||||
const term = createMemo(() => (isDesktopV2 ? settings.general.showTerminal() : true))
|
||||
const status = createMemo(() => (isDesktopV2 ? settings.general.showStatus() : true))
|
||||
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
|
||||
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
|
||||
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
|
||||
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
|
||||
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
|
||||
@@ -399,6 +399,18 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => settings.general.setNewLayoutDesigns(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
@@ -802,7 +814,7 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
<DisplaySection />
|
||||
|
||||
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<Show when={desktop()}>
|
||||
<AdvancedSection />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,6 @@ const legacyTitlebarHeight = 40
|
||||
const v2TitlebarHeight = 44
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
|
||||
|
||||
@@ -75,6 +74,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const params = useParams()
|
||||
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
|
||||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
@@ -85,7 +85,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||
const minHeight = () => {
|
||||
const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
|
||||
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
||||
if (mac()) return `${height / zoom()}px`
|
||||
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
||||
return undefined
|
||||
@@ -119,7 +119,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const canBack = createMemo(() => history.index > 0)
|
||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||
const nav = createMemo(() => (USE_V2_TITLEBAR ? settings.general.showNavigation() : true))
|
||||
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
||||
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
||||
const version = props.update?.version()
|
||||
return {
|
||||
@@ -222,8 +222,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<header
|
||||
classList={{
|
||||
"shrink-0 relative overflow-hidden flex flex-row": true,
|
||||
"h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
|
||||
"h-10 bg-background-base": !USE_V2_TITLEBAR,
|
||||
"h-11 bg-v2-background-bg-deep": useV2Titlebar(),
|
||||
"h-10 bg-background-base": !useV2Titlebar(),
|
||||
}}
|
||||
style={{
|
||||
"min-height": minHeight(),
|
||||
@@ -239,7 +239,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
onDblClick={maximize}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={USE_V2_TITLEBAR}>
|
||||
<Match when={useV2Titlebar()}>
|
||||
{(_) => {
|
||||
const serverSync = useServerSync()
|
||||
const navigate = useNavigate()
|
||||
@@ -490,7 +490,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
project={projectForSession(tab.info, projects(), projectByID())}
|
||||
directory={tab.dir}
|
||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||
hideClose={tabsEnriched().length < 2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -737,14 +736,13 @@ function TabNavItem(props: {
|
||||
title: string
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
hideClose?: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const match = useMatch(() => props.href)
|
||||
const isActive = () => !!match()
|
||||
return (
|
||||
<div
|
||||
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
data-active={isActive()}
|
||||
>
|
||||
<a
|
||||
@@ -755,7 +753,7 @@ function TabNavItem(props: {
|
||||
<span class="text-clip leading-5">{props.title}</span>
|
||||
</a>
|
||||
|
||||
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||
<div
|
||||
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
|
||||
style={{
|
||||
@@ -766,7 +764,7 @@ function TabNavItem(props: {
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
|
||||
onClick={props.onClose}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface Settings {
|
||||
editToolPartsExpanded: boolean
|
||||
showSessionProgressBar: boolean
|
||||
showCustomAgents: boolean
|
||||
newLayoutDesigns?: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
@@ -54,6 +55,7 @@ export interface Settings {
|
||||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
@@ -242,6 +244,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowCustomAgents(value: boolean) {
|
||||
setStore("general", "showCustomAgents", value)
|
||||
},
|
||||
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||
|
||||
@@ -791,6 +791,8 @@ export const dict = {
|
||||
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
|
||||
"settings.general.row.showSessionProgressBar.description":
|
||||
"Display the animated progress bar at the top of the session when the agent is working",
|
||||
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
|
||||
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
|
||||
"settings.general.row.pinchZoom.title": "Pinch to zoom",
|
||||
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
|
||||
|
||||
|
||||
+109
-104
@@ -31,8 +31,8 @@ import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useServers } from "@/context/servers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
|
||||
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const HOME_SESSION_LIMIT = 15
|
||||
const HOME_ROW =
|
||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
@@ -52,8 +52,12 @@ type HomeSessionGroup = {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
if (USE_HOME_DESIGN) return <HomeDesign />
|
||||
return <LegacyHome />
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
|
||||
<HomeDesign />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeDesign() {
|
||||
@@ -68,13 +72,11 @@ function HomeDesign() {
|
||||
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
|
||||
|
||||
const projects = createMemo(() => layout.projects.list())
|
||||
const selectedProject = createMemo(
|
||||
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
|
||||
)
|
||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
|
||||
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const projectDirectories = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (!project) return []
|
||||
if (!project) return [...projects().flatMap((project) => directories(project))]
|
||||
return directories(project)
|
||||
})
|
||||
const search = createMemo(() => state.search.trim())
|
||||
@@ -89,8 +91,8 @@ function HomeDesign() {
|
||||
const projectByID = createMemo(
|
||||
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
const records = createMemo(() =>
|
||||
[
|
||||
const records = createMemo(() => {
|
||||
return [
|
||||
...new Map(
|
||||
projectDirectories()
|
||||
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
|
||||
@@ -112,8 +114,8 @@ function HomeDesign() {
|
||||
if (!value) return true
|
||||
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
|
||||
})
|
||||
.slice(0, HOME_SESSION_LIMIT),
|
||||
)
|
||||
.slice(0, HOME_SESSION_LIMIT)
|
||||
})
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
|
||||
function selectProject(directory: string) {
|
||||
@@ -220,7 +222,7 @@ function HomeDesign() {
|
||||
aria-label={language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<Show
|
||||
when={selectedProject()}
|
||||
when={projectDirectories().length > 0}
|
||||
fallback={
|
||||
<HomeEmptyState
|
||||
icon="folder-add-left"
|
||||
@@ -298,8 +300,8 @@ function HomeProjectColumn(props: {
|
||||
const layout = useLayout()
|
||||
const projects = createMemo(() => layout.projects.list())
|
||||
return (
|
||||
<aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
|
||||
<aside class="flex min-w-0 flex-col lg:pt-[52px] gap-4" aria-label={props.language.t("home.projects")}>
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
@@ -311,8 +313,8 @@ function HomeProjectColumn(props: {
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</div>
|
||||
<For
|
||||
each={servers.list()}
|
||||
<Show
|
||||
when={servers.list().length > 1}
|
||||
fallback={
|
||||
<ProjectList
|
||||
projects={projects()}
|
||||
@@ -328,61 +330,63 @@ function HomeProjectColumn(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(server) => {
|
||||
const key = ServerConnection.key(server)
|
||||
const healthy = () => !!servers.health[key]?.healthy
|
||||
const [open, setOpen] = createSignal(true)
|
||||
<For each={servers.list()}>
|
||||
{(server) => {
|
||||
const key = ServerConnection.key(server)
|
||||
const healthy = () => !!servers.health[key]?.healthy
|
||||
const [open, setOpen] = createSignal(true)
|
||||
|
||||
return (
|
||||
<div class="mt-4 max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div class="relative h-7 group">
|
||||
<button
|
||||
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
|
||||
disabled={!healthy()}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center">
|
||||
<ServerHealthIndicator health={servers.health[key]} />
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<span>{server.displayName ?? new URL(server.http.url).host}</span>
|
||||
<Show when={healthy()}>
|
||||
<IconV2
|
||||
name="outline-chevron-down"
|
||||
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
|
||||
data-open={open()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<IconButtonV2
|
||||
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
|
||||
name="out"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
|
||||
/>
|
||||
return (
|
||||
<div class="max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div class="relative h-7 group">
|
||||
<button
|
||||
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
|
||||
disabled={!healthy()}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center">
|
||||
<ServerHealthIndicator health={servers.health[key]} />
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<span>{server.displayName ?? new URL(server.http.url).host}</span>
|
||||
<Show when={healthy()}>
|
||||
<IconV2
|
||||
name="outline-chevron-down"
|
||||
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
|
||||
data-open={open()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<IconButtonV2
|
||||
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
|
||||
name="out"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
|
||||
/>
|
||||
</div>
|
||||
<Show when={healthy() && open()}>
|
||||
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
|
||||
<ProjectList
|
||||
projects={projects()}
|
||||
selectedProject={props.selectedProject}
|
||||
onSelectedProjectChange={props.selectProject}
|
||||
onChooseProject={props.chooseProject}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
closeProject={props.closeProject}
|
||||
clearNotifications={props.clearNotifications}
|
||||
unseenCount={props.unseenCount}
|
||||
language={props.language}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={healthy() && open()}>
|
||||
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
|
||||
<ProjectList
|
||||
projects={projects()}
|
||||
selectedProject={props.selectedProject}
|
||||
onSelectedProjectChange={props.selectProject}
|
||||
onChooseProject={props.chooseProject}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
closeProject={props.closeProject}
|
||||
clearNotifications={props.clearNotifications}
|
||||
unseenCount={props.unseenCount}
|
||||
language={props.language}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<div class="mt-4 flex min-w-0 flex-col gap-1">
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
@@ -416,12 +420,14 @@ function HomeProjectRow(props: {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const name = createMemo(() => displayName(props.project))
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px] hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
|
||||
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 peer`}
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected }}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
@@ -430,7 +436,37 @@ function HomeProjectRow(props: {
|
||||
<HomeProjectAvatar project={props.project} />
|
||||
<span>{name()}</span>
|
||||
</button>
|
||||
<div class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100">
|
||||
<div
|
||||
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 peer-focus-visible:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={menuOpen()}
|
||||
>
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={menuOpen()} onOpenChange={setMenuOpen}>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="home-project-menu"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
|
||||
{props.language.t("common.edit")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
|
||||
{props.language.t("sidebar.project.clearNotifications")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
|
||||
{props.language.t("common.close")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
@@ -442,37 +478,6 @@ function HomeProjectRow(props: {
|
||||
props.openNewSession(props.project.worktree)
|
||||
}}
|
||||
/>
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end">
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="home-project-menu"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="menu" />}
|
||||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
|
||||
<Icon name="new-session" size="small" />
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
|
||||
<Icon name="edit" size="small" />
|
||||
{props.language.t("common.edit")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
|
||||
<Icon name="circle-check" size="small" />
|
||||
{props.language.t("sidebar.project.clearNotifications")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
|
||||
<Icon name="close" size="small" />
|
||||
{props.language.t("common.close")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -561,7 +566,7 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
|
||||
class="h-7 px-2 text-v2-text-text-muted"
|
||||
onClick={onNewSession()}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
|
||||
+160
-160
@@ -89,8 +89,6 @@ import {
|
||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||
import { SidebarContent } from "./layout/sidebar-shell"
|
||||
|
||||
const USE_NEW_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
Persist.global("layout.page", ["layout.page.v1"]),
|
||||
@@ -129,6 +127,7 @@ export default function Layout(props: ParentProps) {
|
||||
const command = useCommand()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
const initialDirectory = decode64(params.dir)
|
||||
const location = useLocation()
|
||||
const route = createMemo(() => {
|
||||
@@ -154,7 +153,7 @@ export default function Layout(props: ParentProps) {
|
||||
const currentDir = createMemo(() => route().dir)
|
||||
|
||||
const [state, setState] = createStore({
|
||||
autoselect: !initialDirectory && !USE_NEW_DESIGN,
|
||||
autoselect: !initialDirectory && !newDesign(),
|
||||
busyWorkspaces: {} as Record<string, boolean>,
|
||||
hoverProject: undefined as string | undefined,
|
||||
scrollSessionKey: undefined as string | undefined,
|
||||
@@ -1142,7 +1141,7 @@ export default function Layout(props: ParentProps) {
|
||||
},
|
||||
]
|
||||
|
||||
if (!USE_NEW_DESIGN)
|
||||
if (!newDesign())
|
||||
Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
@@ -1821,7 +1820,7 @@ export default function Layout(props: ParentProps) {
|
||||
createEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--dialog-left-margin",
|
||||
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2363,179 +2362,180 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
)
|
||||
|
||||
if (USE_NEW_DESIGN) {
|
||||
return (
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
return (
|
||||
<Show
|
||||
when={!newDesign()}
|
||||
fallback={
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<main
|
||||
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<main
|
||||
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</nav>
|
||||
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
/>
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
arm()
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peekProject()}>
|
||||
<SidebarPanel project={peekProject} merged={false} />
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peekProject()}>
|
||||
<SidebarPanel project={peekProject} merged={false} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
</div>
|
||||
<Toast.Region />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ const emptyFollowups: FollowupItem[] = []
|
||||
|
||||
type ChangeMode = "git" | "branch" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
@@ -198,6 +197,7 @@ export default function Page() {
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const location = useLocation()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
@@ -265,7 +265,7 @@ export default function Page() {
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const size = createSizing()
|
||||
const isV2NewSessionPage = () =>
|
||||
shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
|
||||
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id })
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
|
||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
|
||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||
@@ -1798,14 +1798,14 @@ export default function Page() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
|
||||
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
|
||||
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show>
|
||||
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
|
||||
|
||||
<Show when={desktopReviewOpen()}>
|
||||
<div onPointerDown={() => size.start()}>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
|
||||
import { shouldUseV2NewSessionPage } from "./new-session-layout"
|
||||
|
||||
describe("shouldUseV2NewSessionPage", () => {
|
||||
test("keeps prod session pages on the legacy layout", () => {
|
||||
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
|
||||
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
|
||||
test("keeps disabled pages on the legacy layout", () => {
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false)
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false })).toBe(false)
|
||||
})
|
||||
|
||||
test("uses the v2 layout only for non-prod new-session pages", () => {
|
||||
expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
|
||||
expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
|
||||
test("uses the v2 layout only for enabled new-session pages", () => {
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true })).toBe(true)
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true, sessionID: "ses_123" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
|
||||
return input.channel !== "prod" && !input.sessionID
|
||||
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
|
||||
return input.newLayoutDesigns && !input.sessionID
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
@@ -60,7 +58,7 @@ export function SessionSidePanel(props: {
|
||||
const { sessionKey, tabs, view, params } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
|
||||
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
|
||||
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
|
||||
|
||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
@@ -205,7 +203,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
|
||||
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
|
||||
<aside
|
||||
id="review-panel"
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
|
||||
@@ -20,8 +20,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
@@ -72,7 +70,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
})
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
const closableTab = tabState.closableTab
|
||||
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
|
||||
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
|
||||
const shown = () => (desktopV2() ? settings.general.showFileTree() : true)
|
||||
|
||||
const messages = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { FreeUsageLimitError } from "./error"
|
||||
import { logger } from "./logger"
|
||||
import { buildRateLimitKey, getRedis } from "./redis"
|
||||
@@ -22,7 +20,6 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
|
||||
const ip = !rawIp.length ? "unknown" : rawIp
|
||||
const now = Date.now()
|
||||
const lifetimeInterval = ""
|
||||
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
|
||||
const retryAfter = getRetryAfterDay(now)
|
||||
const redis = getRedis()
|
||||
@@ -32,33 +29,12 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
|
||||
return {
|
||||
check: async () => {
|
||||
const [counts, rows] = await Promise.all([
|
||||
redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey]).catch(() => []),
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
|
||||
.from(IpRateLimitTable)
|
||||
.where(
|
||||
and(
|
||||
eq(IpRateLimitTable.ip, ip),
|
||||
isDefaultModel
|
||||
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
|
||||
: inArray(IpRateLimitTable.interval, [dailyInterval]),
|
||||
),
|
||||
),
|
||||
),
|
||||
])
|
||||
const redisLifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
|
||||
const redisDailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
|
||||
const databaseLifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
|
||||
const databaseDailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
|
||||
const lifetimeCount = Math.max(redisLifetimeCount, databaseLifetimeCount)
|
||||
const dailyCount = Math.max(redisDailyCount, databaseDailyCount)
|
||||
const counts = await redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey])
|
||||
const lifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
|
||||
const dailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
|
||||
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
|
||||
|
||||
isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
|
||||
if (isDefaultModel && databaseLifetimeCount > redisLifetimeCount)
|
||||
await redis.set(lifetimeKey, databaseLifetimeCount).catch(() => {})
|
||||
|
||||
if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit))
|
||||
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter)
|
||||
@@ -68,18 +44,7 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
pipeline.incr(dailyKey)
|
||||
pipeline.expire(dailyKey, retryAfter)
|
||||
if (isNew) pipeline.incr(lifetimeKey)
|
||||
await Promise.all([
|
||||
pipeline.exec().catch(() => {}),
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.insert(IpRateLimitTable)
|
||||
.values([
|
||||
{ ip, interval: dailyInterval, count: 1 },
|
||||
...(isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
|
||||
])
|
||||
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
||||
),
|
||||
])
|
||||
await pipeline.exec()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Database, eq, and, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { KeyRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { RateLimitError } from "./error"
|
||||
import { buildRateLimitKey, getRedis } from "./redis"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
|
||||
@@ -19,26 +18,20 @@ export function createRateLimiter(
|
||||
.replace(/[^0-9]/g, "")
|
||||
.substring(0, 12)
|
||||
const interval = `${modelId.substring(0, 27)}-${yyyyMMddHHmm}`
|
||||
const redis = getRedis()
|
||||
const key = buildRateLimitKey("key", zenApiKey, interval)
|
||||
|
||||
return {
|
||||
check: async () => {
|
||||
const rows = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ interval: KeyRateLimitTable.interval, count: KeyRateLimitTable.count })
|
||||
.from(KeyRateLimitTable)
|
||||
.where(and(eq(KeyRateLimitTable.key, zenApiKey), eq(KeyRateLimitTable.interval, interval))),
|
||||
).then((rows) => rows[0])
|
||||
const count = rows?.count ?? 0
|
||||
const count = Number((await redis.mget<(string | number | null)[]>([key]))[0] ?? 0)
|
||||
|
||||
if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60)
|
||||
},
|
||||
track: async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.insert(KeyRateLimitTable)
|
||||
.values({ key: zenApiKey, interval, count: 1 })
|
||||
.onDuplicateKeyUpdate({ set: { count: sql`${KeyRateLimitTable.count} + 1` } }),
|
||||
)
|
||||
const pipeline = redis.pipeline()
|
||||
pipeline.incr(key)
|
||||
pipeline.expire(key, 60)
|
||||
await pipeline.exec()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "0.0.0",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -108,7 +108,7 @@ export const layer = Layer.effect(
|
||||
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
|
||||
|
||||
const previous = yield* cached(repo.store)
|
||||
const id = previous ?? (yield* root(repo))
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
|
||||
return {
|
||||
previous,
|
||||
|
||||
@@ -5,11 +5,16 @@ import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Project.defaultLayer)
|
||||
|
||||
function remoteID(remote: string) {
|
||||
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
|
||||
function abs(value: string) {
|
||||
return AbsolutePath.make(value)
|
||||
}
|
||||
@@ -86,7 +91,7 @@ describe("ProjectV2.resolve", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses root commit when origin exists", () =>
|
||||
it.live("prefers normalized origin over root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -97,13 +102,36 @@ describe("ProjectV2.resolve", () => {
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
expect(result.id).toBe(remoteID("github.com/Acme/App"))
|
||||
expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses root commit when local remote exists", () =>
|
||||
it.live("normalizes ssh and https remotes to the same id", () =>
|
||||
Effect.gen(function* () {
|
||||
const ssh = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const https = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const a = yield* project.resolve(abs(ssh.path))
|
||||
const b = yield* project.resolve(abs(https.path))
|
||||
|
||||
expect(a.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores file remotes and falls back to root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -118,7 +146,7 @@ describe("ProjectV2.resolve", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers previous cached id over origin", () =>
|
||||
it.live("returns previous cached id from common dir", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -131,7 +159,7 @@ describe("ProjectV2.resolve", () => {
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.previous).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -185,7 +213,7 @@ describe("ProjectV2.resolve", () => {
|
||||
|
||||
expect(result.directory).toBe(yield* real(worktree))
|
||||
expect(result.previous).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.15.10"
|
||||
version = "1.15.11"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -50,7 +50,7 @@ import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||
import { Toast, ToastProvider, useToast } from "./ui/toast"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
|
||||
import { Session as SessionApi } from "@/session/session"
|
||||
import { TuiEvent } from "./event"
|
||||
@@ -1072,8 +1072,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
<TuiPluginRuntime.Slot name="app_bottom" />
|
||||
</box>
|
||||
<TuiPluginRuntime.Slot name="app" />
|
||||
{/* One Toast for the whole app, not per-route, so toasts survive route.navigate */}
|
||||
<Toast />
|
||||
</Show>
|
||||
<StartupLoading ready={ready} />
|
||||
</box>
|
||||
|
||||
@@ -512,7 +512,10 @@ export function Prompt(props: PromptProps) {
|
||||
const content = await Editor.open({
|
||||
value,
|
||||
renderer,
|
||||
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
})
|
||||
if (!content) return
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useArgs } from "../context/args"
|
||||
import { useRouteData } from "@tui/context/route"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
@@ -82,6 +83,7 @@ export function Home() {
|
||||
</box>
|
||||
<TuiPluginRuntime.Slot name="home_bottom" />
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<Toast />
|
||||
</box>
|
||||
<box width="100%" flexShrink={0}>
|
||||
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
|
||||
|
||||
@@ -5,8 +5,6 @@ import type { TextPart } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { Spinner } from "../../component/spinner"
|
||||
import { useDialog, type DialogContext } from "../../ui/dialog"
|
||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||
import { strip } from "@tui/component/prompt/part"
|
||||
@@ -16,37 +14,24 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
// Forking a large session can take a moment, so swap the dialog to a progress view instead of
|
||||
// leaving it open (which looks frozen), then navigate to the fork and confirm with a toast.
|
||||
const fork = async (dialog: DialogContext, messageID?: string, prompt?: PromptInfo) => {
|
||||
dialog.replace(() => (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1}>
|
||||
<Spinner>Forking session…</Spinner>
|
||||
</box>
|
||||
))
|
||||
const forked = await sdk.client.session.fork({ sessionID: props.sessionID, messageID })
|
||||
if (!forked.data) {
|
||||
toast.show({ variant: "error", message: "Failed to fork session" })
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
route.navigate({ sessionID: forked.data.id, type: "session", prompt })
|
||||
dialog.clear()
|
||||
toast.show({ variant: "success", message: "Forked session", duration: 4000 })
|
||||
}
|
||||
|
||||
const options = createMemo((): DialogSelectOption<string | undefined>[] => {
|
||||
const messages = sync.data.message[props.sessionID] ?? []
|
||||
const fullSession = {
|
||||
title: "Full session",
|
||||
value: undefined,
|
||||
onSelect: fork,
|
||||
onSelect: async (dialog: DialogContext) => {
|
||||
const forked = await sdk.client.session.fork({ sessionID: props.sessionID })
|
||||
route.navigate({
|
||||
sessionID: forked.data!.id,
|
||||
type: "session",
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
} satisfies DialogSelectOption<string | undefined>
|
||||
const result = [] as DialogSelectOption<string | undefined>[]
|
||||
for (const message of messages) {
|
||||
@@ -59,8 +44,13 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
||||
title: part.text.replace(/\n/g, " "),
|
||||
value: message.id,
|
||||
footer: Locale.time(message.time.created),
|
||||
onSelect: (dialog) => {
|
||||
const prompt = (sync.data.part[message.id] ?? []).reduce(
|
||||
onSelect: async (dialog) => {
|
||||
const forked = await sdk.client.session.fork({
|
||||
sessionID: props.sessionID,
|
||||
messageID: message.id,
|
||||
})
|
||||
const parts = sync.data.part[message.id] ?? []
|
||||
const prompt = parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
@@ -70,7 +60,12 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
return fork(dialog, message.id, prompt)
|
||||
route.navigate({
|
||||
sessionID: forked.data!.id,
|
||||
type: "session",
|
||||
prompt,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import parsers from "../../../../../../parsers-config.ts"
|
||||
import * as Clipboard from "../../util/clipboard"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { Toast, useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv.tsx"
|
||||
import * as Editor from "../../util/editor"
|
||||
import stripAnsi from "strip-ansi"
|
||||
@@ -968,7 +968,10 @@ export function Session() {
|
||||
await Editor.open({
|
||||
value: transcript,
|
||||
renderer,
|
||||
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
})
|
||||
} else {
|
||||
const exportDir = process.cwd()
|
||||
@@ -981,7 +984,10 @@ export function Session() {
|
||||
const result = await Editor.open({
|
||||
value: transcript,
|
||||
renderer,
|
||||
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
})
|
||||
if (result !== undefined) {
|
||||
await Filesystem.write(filepath, result)
|
||||
@@ -1263,6 +1269,7 @@ export function Session() {
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Toast />
|
||||
</box>
|
||||
<Show when={sidebarVisible()}>
|
||||
<Switch>
|
||||
|
||||
@@ -90,13 +90,20 @@ export const Info = Schema.Struct({
|
||||
description: "Enable promptCacheKey for this provider (default false)",
|
||||
}),
|
||||
timeout: Schema.optional(
|
||||
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
|
||||
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
|
||||
}),
|
||||
).annotate({
|
||||
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
|
||||
}),
|
||||
headerTimeout: Schema.optional(
|
||||
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
|
||||
}),
|
||||
).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
|
||||
}),
|
||||
chunkTimeout: Schema.optional(PositiveInt).annotate({
|
||||
description:
|
||||
|
||||
@@ -638,7 +638,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
if (input.model.providerID !== "openai") return
|
||||
output.headers.originator = "opencode"
|
||||
output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
|
||||
output.headers.session_id = input.sessionID
|
||||
output.headers["session-id"] = input.sessionID
|
||||
},
|
||||
"chat.params": async (input, output) => {
|
||||
if (input.model.providerID !== "openai") return
|
||||
|
||||
@@ -244,6 +244,20 @@ export const layer = Layer.effect(
|
||||
}).pipe(Effect.ignore)
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(
|
||||
hooks,
|
||||
(hook) =>
|
||||
Effect.tryPromise({
|
||||
try: () => Promise.resolve(hook.dispose?.()),
|
||||
catch: (error) => {
|
||||
log.error("plugin dispose hook failed", { error })
|
||||
},
|
||||
}).pipe(Effect.ignore),
|
||||
{ discard: true },
|
||||
),
|
||||
)
|
||||
|
||||
// Subscribe to bus events, fiber interrupted when scope closes
|
||||
yield* (yield* bus.subscribeAll()).pipe(
|
||||
Stream.runForEach((input) =>
|
||||
|
||||
@@ -3,6 +3,14 @@ import { STATUS_CODES } from "http"
|
||||
import { iife } from "@/util/iife"
|
||||
import type { ProviderID } from "./schema"
|
||||
|
||||
export class HeaderTimeoutError extends Error {
|
||||
public override readonly name = "ProviderHeaderTimeoutError"
|
||||
|
||||
constructor(public readonly ms: number) {
|
||||
super(`Provider response headers timed out after ${ms}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted from overflow detection patterns in:
|
||||
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts
|
||||
const OVERFLOW_PATTERNS = [
|
||||
|
||||
@@ -28,9 +28,10 @@ import * as ProviderTransform from "./transform"
|
||||
import { ModelID, ProviderID } from "./schema"
|
||||
import { ModelStatus } from "./model-status"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderError } from "./error"
|
||||
|
||||
const log = Log.create({ service: "provider" })
|
||||
|
||||
const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000
|
||||
function shouldUseCopilotResponsesApi(modelID: string): boolean {
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (!match) return false
|
||||
@@ -85,6 +86,15 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
})
|
||||
}
|
||||
|
||||
function timeoutController(ms: number) {
|
||||
const ctl = new AbortController()
|
||||
const id = setTimeout(() => ctl.abort(new ProviderError.HeaderTimeoutError(ms)), ms)
|
||||
return {
|
||||
signal: ctl.signal,
|
||||
clear: () => clearTimeout(id),
|
||||
}
|
||||
}
|
||||
|
||||
function googleVertexAnthropicBaseURL(project: string | undefined, location: string | undefined) {
|
||||
if (!project) return
|
||||
if (location !== "eu" && location !== "us") return
|
||||
@@ -194,7 +204,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
return sdk.responses(modelID)
|
||||
},
|
||||
options: {},
|
||||
options: { headerTimeout: OPENAI_HEADER_TIMEOUT_DEFAULT },
|
||||
}),
|
||||
xai: () =>
|
||||
Effect.succeed({
|
||||
@@ -1601,16 +1611,21 @@ export const layer = Layer.effect(
|
||||
|
||||
const customFetch = options["fetch"]
|
||||
const chunkTimeout = options["chunkTimeout"]
|
||||
const headerTimeout = options["headerTimeout"]
|
||||
delete options["chunkTimeout"]
|
||||
delete options["headerTimeout"]
|
||||
|
||||
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
||||
const fetchFn = customFetch ?? fetch
|
||||
const opts = init ?? {}
|
||||
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
|
||||
const headerTimeoutMs = headerTimeout === false ? undefined : headerTimeout
|
||||
const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined
|
||||
const signals: AbortSignal[] = []
|
||||
|
||||
if (opts.signal) signals.push(opts.signal)
|
||||
if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
|
||||
if (headerTimeoutCtl) signals.push(headerTimeoutCtl.signal)
|
||||
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
|
||||
signals.push(AbortSignal.timeout(options["timeout"]))
|
||||
|
||||
@@ -1639,7 +1654,7 @@ export const layer = Layer.effect(
|
||||
...opts,
|
||||
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
|
||||
timeout: false,
|
||||
})
|
||||
}).finally(() => headerTimeoutCtl?.clear())
|
||||
|
||||
if (!chunkAbortCtl) return res
|
||||
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
|
||||
|
||||
@@ -14,18 +14,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
|
||||
For SSE endpoints, stay in `HttpApiBuilder.group(...)` and return `HttpServerResponse.stream(...)` from the handler. Annotate the endpoint success schema with `HttpApiSchema.asText({ contentType: "text/event-stream" })` so OpenAPI documents the stream content type.
|
||||
|
||||
Use raw `HttpRouter.use(...)` only for routes that do not fit the request/response HttpApi model, such as WebSocket upgrade routes or catch-all fallback routes. Yield stable services at route-layer construction and close over them in `router.add(...)` callbacks.
|
||||
Use `HttpApiBuilder.group(...)` with `handleRaw(...)` for declared endpoints that need the raw request or response, including WebSocket upgrade routes. This keeps endpoint middleware, routing context, and OpenAPI metadata on one typed route tree.
|
||||
|
||||
```ts
|
||||
export const rawRoute = HttpRouter.use((router) =>
|
||||
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
|
||||
yield* router.add("GET", PtyPaths.connect, (request) => connectPty(request, pty))
|
||||
return handlers.handleRaw("connect", (ctx) => connectPty(ctx.request, pty))
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use raw `HttpRouter.use(...)` only for routes outside the declared API surface, such as a catch-all UI fallback.
|
||||
|
||||
Avoid `Effect.provide(SomeLayer)` inside request handlers or raw route callbacks. Stable layers should be provided once at the application/layer boundary, not rebuilt or scoped per request.
|
||||
|
||||
Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally request-level. Prefer `HttpRouter.use(...)` for stable app services.
|
||||
@@ -34,4 +36,4 @@ Use `Effect.provideService(...)` in middleware only for request-derived context,
|
||||
|
||||
Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary.
|
||||
|
||||
When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled.
|
||||
When adding middleware, declare endpoint-contract middleware on the owning `HttpApiGroup` and provide its implementation layer at the assembly boundary in `server.ts`. Keep router middleware for truly raw fallback routes or global transport policy.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
|
||||
export const EventPaths = {
|
||||
event: "/event",
|
||||
@@ -20,5 +22,8 @@ export const EventApi = HttpApi.make("event").add(
|
||||
}),
|
||||
),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(Authorization)
|
||||
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { Authorization, PtyConnectAuthorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
@@ -138,9 +139,10 @@ export const PtyApi = HttpApi.make("pty")
|
||||
export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
||||
HttpApiGroup.make("pty-connect")
|
||||
.add(
|
||||
// Decode PTY connection query fields in the raw handler after checking
|
||||
// existence, preserving the established empty-404 response ordering.
|
||||
HttpApiEndpoint.get("connect", PtyPaths.connect, {
|
||||
params: Params,
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Connected session"),
|
||||
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -149,8 +151,22 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
||||
summary: "Connect to PTY session",
|
||||
description:
|
||||
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
|
||||
transform: (operation) => ({
|
||||
...operation,
|
||||
parameters: [
|
||||
...(operation.parameters ?? []),
|
||||
...["directory", "workspace", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
|
||||
in: "query",
|
||||
name,
|
||||
schema: { type: "string" },
|
||||
})),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
|
||||
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." }))
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(PtyConnectAuthorization),
|
||||
)
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} from "@/server/shared/pty-ticket"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import * as ApiError from "../errors"
|
||||
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
|
||||
import { CursorQuery, PtyConnectApi } from "../groups/pty"
|
||||
import { WebSocketTracker } from "../websocket-tracker"
|
||||
|
||||
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
|
||||
@@ -121,37 +121,39 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const cors = yield* CorsConfig
|
||||
yield* router.add(
|
||||
"GET",
|
||||
PtyPaths.connect,
|
||||
Effect.gen(function* () {
|
||||
const params = yield* HttpRouter.schemaPathParams(Params)
|
||||
const exists = yield* pty.get(params.ptyID).pipe(
|
||||
|
||||
return handlers.handleRaw(
|
||||
"connect",
|
||||
Effect.fn("PtyHttpApi.connect")(function* (ctx: {
|
||||
params: { ptyID: PtyID }
|
||||
request: HttpServerRequest.HttpServerRequest
|
||||
}) {
|
||||
const exists = yield* pty.get(ctx.params.ptyID).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
|
||||
)
|
||||
if (!exists) return HttpServerResponse.empty({ status: 404 })
|
||||
|
||||
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
|
||||
const query = Schema.decodeUnknownOption(CursorQuery)(yield* HttpServerRequest.ParsedSearchParams)
|
||||
if (Option.isNone(query)) return HttpServerResponse.empty({ status: 400 })
|
||||
const ticket = new URL(ctx.request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
|
||||
if (ticket) {
|
||||
const valid = validOrigin(request, cors)
|
||||
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
const valid = validOrigin(ctx.request, cors)
|
||||
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
: false
|
||||
if (!valid) return HttpServerResponse.empty({ status: 403 })
|
||||
}
|
||||
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
|
||||
const parsedCursor = query.value.cursor === undefined ? undefined : Number(query.value.cursor)
|
||||
const cursor =
|
||||
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
|
||||
? parsedCursor
|
||||
: undefined
|
||||
const socket = yield* Effect.orDie(request.upgrade)
|
||||
const socket = yield* Effect.orDie(ctx.request.upgrade)
|
||||
const write = yield* socket.writer
|
||||
const closeAccepted = (event: Socket.CloseEvent) =>
|
||||
socket
|
||||
@@ -186,7 +188,7 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
},
|
||||
}
|
||||
const handler = yield* pty
|
||||
.connect(params.ptyID, adapter, cursor)
|
||||
.connect(ctx.params.ptyID, adapter, cursor)
|
||||
.pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
@@ -194,12 +196,8 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
|
||||
// No `pending[]`-style early-frame buffer (the legacy handler had one).
|
||||
// `request.upgrade` returns a Socket without running the WS handshake; the
|
||||
// handshake fires inside `socket.runRaw` below, AFTER `pty.connect` resolves
|
||||
// and the message callback is registered. The client therefore can't fire
|
||||
// `open` and start sending until the listener is already wired. Don't move
|
||||
// `runRaw` ahead of `pty.connect` without re-introducing a buffer.
|
||||
// The handshake runs inside `socket.runRaw`, after the input callback is
|
||||
// registered, so the client cannot send frames before PTY input is wired.
|
||||
yield* socket
|
||||
.runRaw((message) => handlePtyInput(handler, message))
|
||||
.pipe(
|
||||
|
||||
@@ -27,6 +27,13 @@ export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>(
|
||||
},
|
||||
) {}
|
||||
|
||||
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
|
||||
"@opencode/ExperimentalHttpApiPtyConnectAuthorization",
|
||||
{
|
||||
error: HttpApiError.UnauthorizedNoContent,
|
||||
},
|
||||
) {}
|
||||
|
||||
function emptyCredential() {
|
||||
return {
|
||||
username: "",
|
||||
@@ -105,7 +112,6 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (isPublicUIPath(request.method, url.pathname)) return yield* effect
|
||||
if (hasPtyConnectTicketURL(url)) return yield* effect
|
||||
return yield* credentialFromURL(url, request).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
@@ -129,6 +135,24 @@ export const authorizationLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyConnectAuthorizationLayer = Layer.effect(
|
||||
PtyConnectAuthorization,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* ServerAuth.Config
|
||||
if (!ServerAuth.required(config)) return PtyConnectAuthorization.of((effect) => effect)
|
||||
return PtyConnectAuthorization.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (hasPtyConnectTicketURL(url)) return yield* effect
|
||||
return yield* credentialFromURL(url, request).pipe(
|
||||
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const v2AuthorizationLayer = Layer.effect(
|
||||
V2Authorization,
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { WorkspaceRouteContext } from "./workspace-routing"
|
||||
|
||||
@@ -41,10 +41,3 @@ export const instanceContextLayer = Layer.effect(
|
||||
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
|
||||
}),
|
||||
)
|
||||
|
||||
export const instanceRouterMiddleware = HttpRouter.middleware()(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* InstanceStore.Service
|
||||
return (effect) => provideInstanceContext(effect, store)
|
||||
}),
|
||||
)
|
||||
|
||||
+5
-19
@@ -9,7 +9,7 @@ import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL }
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Context, Data, Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { InvalidRequestError } from "../errors"
|
||||
@@ -219,7 +219,10 @@ function routeHttpApiWorkspace<E>(
|
||||
const sessionID = getWorkspaceRouteSessionID(requestURL(request))
|
||||
const session = sessionID
|
||||
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
|
||||
Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)),
|
||||
Effect.catchIf(
|
||||
(error): error is NotFoundError => NotFoundError.isInstance(error),
|
||||
() => Effect.succeed(undefined),
|
||||
),
|
||||
Effect.catchDefect(() => Effect.succeed(undefined)),
|
||||
)
|
||||
: undefined
|
||||
@@ -242,20 +245,3 @@ export const workspaceRoutingLayer = Layer.effect(
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()(
|
||||
Effect.gen(function* () {
|
||||
const makeWebSocket = yield* Socket.WebSocketConstructor
|
||||
const workspace = yield* Workspace.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
return (effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const plan = yield* planRequest(request)
|
||||
return yield* routeWorkspace(client, effect, plan)
|
||||
}).pipe(
|
||||
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
|
||||
Effect.provideService(Workspace.Service, workspace),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -59,8 +59,14 @@ import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { PublicApi } from "./public"
|
||||
import { authorizationLayer, authorizationRouterMiddleware, v2AuthorizationLayer } from "./middleware/authorization"
|
||||
import {
|
||||
authorizationLayer,
|
||||
authorizationRouterMiddleware,
|
||||
ptyConnectAuthorizationLayer,
|
||||
v2AuthorizationLayer,
|
||||
} from "./middleware/authorization"
|
||||
import { EventApi } from "./groups/event"
|
||||
import { PtyConnectApi } from "./groups/pty"
|
||||
import { eventHandlers } from "./handlers/event"
|
||||
import { configHandlers } from "./handlers/config"
|
||||
import { controlHandlers } from "./handlers/control"
|
||||
@@ -72,15 +78,15 @@ import { mcpHandlers } from "./handlers/mcp"
|
||||
import { permissionHandlers } from "./handlers/permission"
|
||||
import { projectHandlers } from "./handlers/project"
|
||||
import { providerHandlers } from "./handlers/provider"
|
||||
import { ptyConnectRoute, ptyHandlers } from "./handlers/pty"
|
||||
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
|
||||
import { questionHandlers } from "./handlers/question"
|
||||
import { sessionHandlers } from "./handlers/session"
|
||||
import { syncHandlers } from "./handlers/sync"
|
||||
import { tuiHandlers } from "./handlers/tui"
|
||||
import { v2Handlers } from "./handlers/v2"
|
||||
import { workspaceHandlers } from "./handlers/workspace"
|
||||
import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context"
|
||||
import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing"
|
||||
import { instanceContextLayer } from "./middleware/instance-context"
|
||||
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
|
||||
import { disposeMiddleware } from "./lifecycle"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import { compressionLayer } from "./middleware/compression"
|
||||
@@ -102,24 +108,27 @@ const cors = (corsOptions?: CorsOptions) =>
|
||||
|
||||
// Route tree:
|
||||
// - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi.
|
||||
// - eventApiRoutes/rawInstanceRoutes: raw instance routes; auth and workspace routing happen as router middleware.
|
||||
// - instanceApiRoutes: schema routes; auth is declared on each group and workspace context is provided below.
|
||||
// - eventApiRoutes: typed SSE route with instance routing context and its existing API contract.
|
||||
// - ptyConnectApiRoutes: typed WebSocket upgrade route with ticket-aware auth.
|
||||
// - instanceApiRoutes: remaining typed instance routes.
|
||||
// - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it.
|
||||
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, globalHandlers]),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
Layer.provide(httpApiAuthLayer),
|
||||
)
|
||||
const instanceRouterLayer = authorizationRouterMiddleware
|
||||
.combine(instanceRouterMiddleware)
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe(
|
||||
Layer.provide(eventHandlers),
|
||||
Layer.provide(instanceRouterLayer),
|
||||
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
|
||||
)
|
||||
const ptyConnectApiRoutes = HttpApiBuilder.layer(PtyConnectApi).pipe(
|
||||
Layer.provide(ptyConnectHandlers),
|
||||
Layer.provide([ptyConnectHttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
|
||||
)
|
||||
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
Layer.provide([
|
||||
@@ -141,15 +150,8 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
]),
|
||||
)
|
||||
|
||||
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
|
||||
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
|
||||
Layer.provide([
|
||||
httpApiAuthLayer,
|
||||
v2HttpApiAuthLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
instanceContextLayer,
|
||||
schemaErrorLayer,
|
||||
]),
|
||||
const instanceRoutes = instanceApiRoutes.pipe(
|
||||
Layer.provide([httpApiAuthLayer, v2HttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
|
||||
)
|
||||
|
||||
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
|
||||
@@ -184,7 +186,7 @@ type RouteRequirements =
|
||||
export function createRoutes(
|
||||
corsOptions?: CorsOptions,
|
||||
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
|
||||
Layer.provide([
|
||||
errorLayer,
|
||||
compressionLayer,
|
||||
|
||||
@@ -1143,6 +1143,18 @@ export function fromError(
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case e instanceof ProviderError.HeaderTimeoutError:
|
||||
return new APIError(
|
||||
{
|
||||
message: e.message,
|
||||
isRetryable: true,
|
||||
metadata: {
|
||||
code: e.name,
|
||||
timeoutMs: String(e.ms),
|
||||
},
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case APICallError.isInstance(e):
|
||||
const parsed = ProviderError.parseAPICallError({
|
||||
providerID: ctx.providerID,
|
||||
|
||||
@@ -7,6 +7,15 @@ import path from "path"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Database } from "@/storage/db"
|
||||
import { ProjectTable } from "@/project/project.sql"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { WorkspaceTable } from "@/control-plane/workspace.sql"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Cause, Effect, Exit, Layer, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
@@ -31,6 +40,10 @@ function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
|
||||
})
|
||||
}
|
||||
|
||||
function remoteProjectID(remote: string) {
|
||||
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
|
||||
* matching `failArg` and returns exit code 128, while delegating everything
|
||||
@@ -154,28 +167,91 @@ describe("Project.fromDirectory", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps root commit identity when origin exists", () =>
|
||||
it.live("prefers normalized origin remote over root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const root = (yield* Effect.promise(() => $`git rev-list --max-parents=0 HEAD`.cwd(tmp).text())).trim()
|
||||
|
||||
expect(project.id).toBe(ProjectID.make(root))
|
||||
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps cached project identity when origin becomes available", () =>
|
||||
it.live("normalizes equivalent origin URL forms to the same project ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const ssh = yield* tmpdirScoped({ git: true })
|
||||
const https = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
|
||||
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
|
||||
|
||||
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh))
|
||||
const { project: b } = yield* run((svc) => svc.fromDirectory(https))
|
||||
|
||||
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("migrates cached root project data when origin becomes available", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const projects = yield* Project.Service
|
||||
const { project: rootProject } = yield* projects.fromDirectory(tmp)
|
||||
const remoteID = remoteProjectID("github.com/acme/app")
|
||||
const sessionID = crypto.randomUUID() as SessionID
|
||||
const workspaceID = WorkspaceID.ascending()
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
Database.use((db) => {
|
||||
db.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: rootProject.id,
|
||||
slug: sessionID,
|
||||
directory: tmp,
|
||||
title: "test",
|
||||
version: "0.0.0-test",
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
db.insert(PermissionTable)
|
||||
.values({
|
||||
project_id: rootProject.id,
|
||||
data: [{ permission: "edit", pattern: "*", action: "allow" }],
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
db.insert(WorkspaceTable)
|
||||
.values({
|
||||
id: workspaceID,
|
||||
type: "local",
|
||||
name: "test",
|
||||
project_id: rootProject.id,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
})
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* projects.fromDirectory(tmp)
|
||||
|
||||
expect(project.id).toBe(rootProject.id)
|
||||
expect(project.id).toBe(remoteID)
|
||||
expect(
|
||||
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get()),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())?.project_id,
|
||||
).toBe(remoteID)
|
||||
expect(
|
||||
Database.use((db) => db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get()),
|
||||
).toBeDefined()
|
||||
expect(
|
||||
Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get())
|
||||
?.project_id,
|
||||
).toBe(remoteID)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { afterEach, expect } from "bun:test"
|
||||
import { createServer, type Server } from "node:http"
|
||||
import { streamText } from "ai"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { disposeAllInstances, provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { Env } from "@/env"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Provider.defaultLayer,
|
||||
Env.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
TestLLMServer.layer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
it.live("headerTimeout does not abort delayed SSE body after headers arrive", () =>
|
||||
provideTmpdirServer(
|
||||
({ llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop())
|
||||
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
|
||||
expect(yield* Effect.promise(() => result.text)).toBe("late")
|
||||
}),
|
||||
{
|
||||
config: (url) => {
|
||||
const config = testProviderConfig(url)
|
||||
return {
|
||||
...config,
|
||||
provider: {
|
||||
test: {
|
||||
...config.provider.test,
|
||||
options: { ...config.provider.test.options, headerTimeout: 50 },
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("headerTimeout aborts when response headers do not arrive", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => delayedHeaderServer(250)),
|
||||
(server) => Effect.sync(() => server.server.close()),
|
||||
)
|
||||
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
onError() {},
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
|
||||
const errors = yield* Effect.promise(async () => {
|
||||
const errors: string[] = []
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") errors.push(String(part.error))
|
||||
}
|
||||
return errors
|
||||
})
|
||||
expect(errors.join("\n")).toContain("response headers timed out")
|
||||
}),
|
||||
{ config: providerConfig(server.url, { headerTimeout: 50 }) },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("headerTimeout is opt-in for non-OpenAI providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => delayedHeaderServer(100)),
|
||||
(server) => Effect.sync(() => server.server.close()),
|
||||
)
|
||||
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
|
||||
expect(yield* Effect.promise(() => result.text)).toBe("ok")
|
||||
}),
|
||||
{ config: providerConfig(server.url) },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("OpenAI Codex headerTimeout default can be disabled by config", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* withAuthContent(
|
||||
Effect.gen(function* () {
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const openai = yield* provider.getProvider(ProviderID.openai)
|
||||
expect(openai.options.headerTimeout).toBe(false)
|
||||
}),
|
||||
{ config: { provider: { openai: { options: { headerTimeout: false } } } } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("OpenAI API auth gets default headerTimeout", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* withAuthContent(
|
||||
Effect.gen(function* () {
|
||||
yield* provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const openai = yield* provider.getProvider(ProviderID.openai)
|
||||
expect(openai.options.headerTimeout).toBe(10_000)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ openai: { type: "api", key: "sk-test" } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function providerConfig(url: string, options: Record<string, unknown> = {}) {
|
||||
const config = testProviderConfig(url)
|
||||
return {
|
||||
...config,
|
||||
provider: {
|
||||
test: {
|
||||
...config.provider.test,
|
||||
options: { ...config.provider.test.options, ...options },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function delayedHeaderServer(delay: number): Promise<{ server: Server; url: string }> {
|
||||
const server = createServer((_, res) => {
|
||||
setTimeout(() => {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" })
|
||||
res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n')
|
||||
}, delay)
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port")
|
||||
return { server, url: `http://127.0.0.1:${address.port}` }
|
||||
}
|
||||
|
||||
function withAuthContent<A, E, R>(self: Effect.Effect<A, E, R>, value: Record<string, unknown> = defaultAuthContent()) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.OPENCODE_AUTH_CONTENT
|
||||
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify(value)
|
||||
return previous
|
||||
}),
|
||||
() => self,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.OPENCODE_AUTH_CONTENT
|
||||
else process.env.OPENCODE_AUTH_CONTENT = previous
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function defaultAuthContent() {
|
||||
return {
|
||||
openai: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
|
||||
}
|
||||
}
|
||||
@@ -282,9 +282,10 @@ it.instance(
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
// Config options should be merged
|
||||
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
|
||||
expect(providers[ProviderID.anthropic].options.headerTimeout).toBe(10000)
|
||||
expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000)
|
||||
}),
|
||||
{ config: { provider: { anthropic: { options: { timeout: 60000, chunkTimeout: 15000 } } } } },
|
||||
{ config: { provider: { anthropic: { options: { timeout: 60000, headerTimeout: 10000, chunkTimeout: 15000 } } } } },
|
||||
)
|
||||
|
||||
it.instance("getModel returns model for valid provider/model", () =>
|
||||
|
||||
@@ -4,8 +4,8 @@ Use these patterns for server and HttpApi middleware tests in this directory.
|
||||
|
||||
- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy.
|
||||
- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it.
|
||||
- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
|
||||
- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`.
|
||||
- Use tiny `HttpApiBuilder` probe groups that declare the typed middleware under test and expose context such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
|
||||
- Declare middleware in the same order as production when testing interactions, for example `InstanceContextMiddleware` followed by `WorkspaceRoutingMiddleware`.
|
||||
- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits.
|
||||
- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific.
|
||||
- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import { Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -12,9 +13,17 @@ import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
|
||||
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import {
|
||||
InstanceContextMiddleware,
|
||||
instanceContextLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { withFixedWorkspaceID } from "../fixture/flag"
|
||||
@@ -47,9 +56,10 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
const instanceContextTestLayer = instanceRouterMiddleware
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
const instanceContextTestLayer = Layer.mergeAll(
|
||||
instanceContextLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
)
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
name: "Local Test",
|
||||
@@ -80,20 +90,57 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
|
||||
const probeInstanceContext = Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return yield* HttpServerResponse.json({
|
||||
return {
|
||||
directory: instance?.directory,
|
||||
worktree: instance?.worktree,
|
||||
projectID: instance?.project.id,
|
||||
workspaceID,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
|
||||
HttpRouter.add("GET", probePath, probeInstanceContext).pipe(
|
||||
Layer.provide(instanceContextTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
const ProbeResult = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
worktree: Schema.optional(Schema.String),
|
||||
projectID: Schema.optional(Schema.String),
|
||||
workspaceID: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ProbeApi = HttpApi.make("instance-context-probe").add(
|
||||
HttpApiGroup.make("probe")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.post("dispose", "/dispose-probe", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.Boolean,
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware),
|
||||
)
|
||||
|
||||
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
||||
handlers
|
||||
.handle("get", () => probeInstanceContext)
|
||||
.handle("session", () => probeInstanceContext)
|
||||
.handle(
|
||||
"dispose",
|
||||
Effect.fn("InstanceContextProbe.dispose")(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
if (!instance) return false
|
||||
yield* markInstanceForDisposal(instance)
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const probeRoutes = HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(probeHandlers),
|
||||
Layer.provide(instanceContextTestLayer),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
)
|
||||
|
||||
const serveProbe = () => probeRoutes.pipe(HttpRouter.serve, Layer.build)
|
||||
|
||||
const waitDisposedEvent = waitGlobalBusEvent({
|
||||
message: "timed out waiting for instance disposal",
|
||||
@@ -101,19 +148,9 @@ const waitDisposedEvent = waitGlobalBusEvent({
|
||||
}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
|
||||
|
||||
const serveDisposeProbe = () =>
|
||||
HttpRouter.serve(
|
||||
HttpRouter.add(
|
||||
"POST",
|
||||
"/dispose-probe",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
if (!instance) return HttpServerResponse.empty({ status: 500 })
|
||||
yield* markInstanceForDisposal(instance)
|
||||
return yield* HttpServerResponse.json(true)
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer)),
|
||||
{ middleware: disposeMiddleware, disableListenLog: true, disableLogger: true },
|
||||
).pipe(Layer.build)
|
||||
HttpRouter.serve(probeRoutes, { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
describe("HttpApi instance context middleware", () => {
|
||||
it.live("provides instance context from the routed directory", () =>
|
||||
@@ -129,6 +166,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
directory: dir,
|
||||
worktree: dir,
|
||||
projectID: project.project.id,
|
||||
workspaceID: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -156,7 +194,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
type: "instance-context-workspace-ref",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
yield* serveProbe("/session")
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
@@ -269,7 +307,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
// is true. Combined with the env override, the route must stay Local with
|
||||
// the configured workspace id (not divert to the requested workspace's
|
||||
// local directory).
|
||||
yield* serveProbe("/session")
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
|
||||
+5
-4
@@ -4,6 +4,7 @@ import { HttpRouter } from "effect/unstable/http"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { PtyID } from "../../src/pty/schema"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
@@ -35,7 +36,7 @@ function app(input: { password?: string; username?: string }) {
|
||||
}
|
||||
|
||||
function basic(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
return ServerAuth.header({ username, password }) ?? ""
|
||||
}
|
||||
|
||||
async function cancelBody(response: Response) {
|
||||
@@ -47,8 +48,8 @@ afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("HttpApi raw route authorization", () => {
|
||||
test("requires configured auth before opening the raw instance event stream", async () => {
|
||||
describe("HttpApi instance route authorization", () => {
|
||||
test("requires configured auth before opening the instance event stream", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const server = app({ password: "secret" })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
@@ -64,7 +65,7 @@ describe("HttpApi raw route authorization", () => {
|
||||
expect(authed.status).toBe(200)
|
||||
})
|
||||
|
||||
test("requires configured auth before resolving the raw PTY websocket route", async () => {
|
||||
test("requires configured auth before resolving the PTY websocket route", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const server = app({ password: "secret" })
|
||||
const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending())
|
||||
@@ -9,10 +9,11 @@
|
||||
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Scope } from "effect"
|
||||
import { Deferred, Effect, Layer, Schema, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
@@ -20,8 +21,16 @@ import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { Session } from "../../src/session/session"
|
||||
import {
|
||||
InstanceContextMiddleware,
|
||||
instanceContextLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
|
||||
@@ -52,9 +61,10 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
const instanceContextTestLayer = instanceRouterMiddleware
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
const instanceContextTestLayer = Layer.mergeAll(
|
||||
instanceContextLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
)
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
name: "Local Test",
|
||||
@@ -87,6 +97,46 @@ const captureInstance = Effect.gen(function* () {
|
||||
return { directory: instance?.directory, workspaceID } satisfies Capture
|
||||
})
|
||||
|
||||
const ProbeApi = HttpApi.make("handler-context-probe").add(
|
||||
HttpApiGroup.make("probe")
|
||||
.add(
|
||||
HttpApiEndpoint.post("fork", "/fork-probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
|
||||
HttpApiEndpoint.post("streamWithout", "/stream-probe-without", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
|
||||
}),
|
||||
HttpApiEndpoint.post("streamWith", "/stream-probe-with", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware),
|
||||
)
|
||||
|
||||
const serveProbes = (input: {
|
||||
fork?: Effect.Effect<boolean, never, Scope.Scope>
|
||||
streamWithout?: Effect.Effect<HttpServerResponse.HttpServerResponse>
|
||||
streamWith?: Effect.Effect<HttpServerResponse.HttpServerResponse>
|
||||
}) =>
|
||||
HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(
|
||||
HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
||||
handlers
|
||||
.handle("fork", () => input.fork ?? Effect.succeed(false))
|
||||
.handleRaw(
|
||||
"streamWithout",
|
||||
() => input.streamWithout ?? Effect.succeed(HttpServerResponse.empty({ status: 404 })),
|
||||
)
|
||||
.handleRaw("streamWith", () => input.streamWith ?? Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
|
||||
),
|
||||
),
|
||||
Layer.provide(instanceContextTestLayer),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
describe("HttpApi handler context inheritance", () => {
|
||||
// Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
|
||||
// the request's Context — including InstanceRef and WorkspaceRef provided
|
||||
@@ -96,22 +146,20 @@ describe("HttpApi handler context inheritance", () => {
|
||||
const { dir, workspace } = yield* setupWorkspace("local-fork")
|
||||
const capture = yield* Deferred.make<Capture>()
|
||||
|
||||
yield* HttpRouter.add(
|
||||
"POST",
|
||||
"/fork-probe",
|
||||
Effect.gen(function* () {
|
||||
yield* serveProbes({
|
||||
fork: Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Deferred.succeed(capture, yield* captureInstance)
|
||||
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
return HttpServerResponse.empty({ status: 204 })
|
||||
return true
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
|
||||
})
|
||||
|
||||
const response = yield* HttpClient.post(
|
||||
`/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
|
||||
)
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
|
||||
expect(observed.directory).toBe(dir)
|
||||
@@ -129,10 +177,8 @@ describe("HttpApi handler context inheritance", () => {
|
||||
const withoutCapture = yield* Deferred.make<Capture>()
|
||||
const withCapture = yield* Deferred.make<Capture>()
|
||||
|
||||
yield* HttpRouter.add(
|
||||
"POST",
|
||||
"/stream-probe-without",
|
||||
Effect.gen(function* () {
|
||||
yield* serveProbes({
|
||||
streamWithout: Effect.gen(function* () {
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromEffect(
|
||||
Effect.gen(function* () {
|
||||
@@ -143,12 +189,7 @@ describe("HttpApi handler context inheritance", () => {
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
|
||||
|
||||
yield* HttpRouter.add(
|
||||
"POST",
|
||||
"/stream-probe-with",
|
||||
Effect.gen(function* () {
|
||||
streamWith: Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return HttpServerResponse.stream(
|
||||
@@ -161,7 +202,7 @@ describe("HttpApi handler context inheritance", () => {
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
|
||||
})
|
||||
|
||||
const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
|
||||
const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
|
||||
|
||||
@@ -147,6 +147,14 @@ describe("pty HttpApi bridge", () => {
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
test("returns 404 for missing PTY websocket before decoding cursor query", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await app().request(`${PtyPaths.connect.replace(":ptyID", PtyID.ascending())}?cursor=a&cursor=b`, {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
test("returns typed not found errors for missing PTY HTTP resources", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
|
||||
@@ -9,6 +9,7 @@ type OpenApiResponse = {
|
||||
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
|
||||
}
|
||||
type OpenApiOperation = {
|
||||
readonly parameters?: ReadonlyArray<{ readonly name: string; readonly in: string }>
|
||||
readonly responses?: Record<string, OpenApiResponse>
|
||||
readonly security?: unknown
|
||||
}
|
||||
@@ -207,6 +208,11 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
||||
expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe(
|
||||
"PtyForbiddenError",
|
||||
)
|
||||
expect(
|
||||
spec.paths["/pty/{ptyID}/connect"]?.get?.parameters
|
||||
?.filter((parameter) => parameter.in === "query")
|
||||
.map((parameter) => parameter.name),
|
||||
).toEqual(["directory", "workspace", "cursor", "ticket"])
|
||||
})
|
||||
|
||||
test("documents project not-found errors", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Queue, Ref } from "effect"
|
||||
import { Context, Effect, Layer, Queue, Ref, Schema } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import Http from "node:http"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -20,10 +21,13 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRouteContext,
|
||||
workspaceRouterMiddleware,
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
|
||||
import { Database } from "../../src/storage/db"
|
||||
@@ -66,7 +70,7 @@ type TestHandler<E, R> = (
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
|
||||
|
||||
const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
|
||||
const workspaceRoutingTestLayer = workspaceRoutingLayer.pipe(
|
||||
Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]),
|
||||
)
|
||||
|
||||
@@ -203,16 +207,45 @@ const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) =>
|
||||
return HttpServerResponse.empty()
|
||||
})
|
||||
|
||||
const serveRouteContextProbe = HttpRouter.add(
|
||||
"GET",
|
||||
"/probe",
|
||||
Effect.gen(function* () {
|
||||
// The fake route exposes the context installed by the middleware, so tests
|
||||
// can assert routing decisions without pulling in the production API tree.
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
||||
}),
|
||||
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
||||
const ProbeResult = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
workspaceID: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ProbeApi = HttpApi.make("workspace-routing-probe").add(
|
||||
HttpApiGroup.make("probe")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.patch("patch", "/probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
|
||||
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.get("workspace", WorkspacePaths.list, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: ProbeResult,
|
||||
}),
|
||||
)
|
||||
.middleware(WorkspaceRoutingMiddleware),
|
||||
)
|
||||
|
||||
const routeContextResponse = Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return { directory: route.directory, workspaceID: route.workspaceID }
|
||||
})
|
||||
|
||||
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
||||
handlers
|
||||
.handle("get", () => routeContextResponse)
|
||||
.handle("patch", () => Effect.succeed(false))
|
||||
.handle("session", () => routeContextResponse)
|
||||
.handle("workspace", () => routeContextResponse),
|
||||
)
|
||||
|
||||
const serveProbe = HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(probeHandlers),
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
describe("HttpApi workspace routing middleware", () => {
|
||||
it.live("proxies remote workspace HTTP requests through the selected workspace target", () =>
|
||||
@@ -250,11 +283,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
|
||||
// The local /probe handler should not run. Selecting a remote workspace
|
||||
// should make the middleware call HttpApiProxy.http instead.
|
||||
yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
|
||||
HttpClientRequest.setHeaders({
|
||||
@@ -325,9 +354,11 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
startWorkspaceSyncing: () => Effect.die("unused"),
|
||||
})
|
||||
|
||||
yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
yield* HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(probeHandlers),
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
Layer.provide(Layer.succeed(Workspace.Service, workspace)),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
@@ -351,11 +382,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
url: "http://127.0.0.1:1/base",
|
||||
})
|
||||
|
||||
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
|
||||
|
||||
@@ -378,11 +405,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
|
||||
// The client connects to the local test server. The middleware should
|
||||
// detect the WebSocket upgrade and proxy it to the remote /base/probe.
|
||||
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
yield* serveProbe
|
||||
|
||||
const socket = yield* Socket.makeWebSocket(
|
||||
`${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
|
||||
@@ -406,11 +429,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
const workspaceID = WorkspaceID.ascending("wrk_missing")
|
||||
// If the middleware resolves the workspace first, this handler is never
|
||||
// reached and the response should be the middleware error response.
|
||||
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
|
||||
|
||||
@@ -433,14 +452,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
|
||||
// GET /session is a control-plane route: it lists sessions for the main
|
||||
// process and should not be redirected into the selected workspace target.
|
||||
yield* HttpRouter.add(
|
||||
"GET",
|
||||
"/session",
|
||||
Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
||||
}),
|
||||
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`)
|
||||
|
||||
@@ -463,14 +475,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
// Workspace CRUD/status routes manage the control plane itself. Selecting
|
||||
// a workspace should preserve the selected id for handlers, but must not
|
||||
// swap the route context to the workspace target directory.
|
||||
yield* HttpRouter.add(
|
||||
"GET",
|
||||
WorkspacePaths.list,
|
||||
Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
||||
}),
|
||||
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`)
|
||||
|
||||
@@ -484,7 +489,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const queryDir = path.join(dir, "query-target")
|
||||
const headerDir = path.join(dir, "header-target")
|
||||
yield* serveRouteContextProbe
|
||||
yield* serveProbe
|
||||
|
||||
// Without a selected workspace, the middleware falls back to request
|
||||
// directory hints before using the process cwd.
|
||||
@@ -495,9 +500,9 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
)
|
||||
|
||||
expect(queryResponse.status).toBe(200)
|
||||
expect(yield* queryResponse.json).toEqual({ directory: queryDir })
|
||||
expect(yield* queryResponse.json).toEqual({ directory: queryDir, workspaceID: null })
|
||||
expect(headerResponse.status).toBe(200)
|
||||
expect(yield* headerResponse.json).toEqual({ directory: headerDir })
|
||||
expect(yield* headerResponse.json).toEqual({ directory: headerDir, workspaceID: null })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -513,7 +518,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
directory: workspaceDir,
|
||||
})
|
||||
|
||||
yield* serveRouteContextProbe
|
||||
yield* serveProbe
|
||||
|
||||
// /probe is not a control-plane route, so selecting a local workspace
|
||||
// should swap the route context to the workspace target directory.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { Session } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Server } from "../../src/server/server"
|
||||
@@ -344,6 +345,7 @@ describe("workspace HttpApi", () => {
|
||||
proxied.push(request)
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/base/global/event") return eventStreamResponse()
|
||||
if (url.pathname === "/base/event") return eventStreamResponse()
|
||||
if (url.pathname === "/base/sync/history") return Response.json([])
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -413,6 +415,18 @@ describe("workspace HttpApi", () => {
|
||||
])
|
||||
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory")
|
||||
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace")
|
||||
|
||||
const eventURL = new URL(`http://localhost${EventPaths.event}`)
|
||||
eventURL.searchParams.set("workspace", workspace.id)
|
||||
const eventResponse = yield* request(eventURL.toString(), dir)
|
||||
expect(eventResponse.status).toBe(200)
|
||||
expect(eventResponse.headers.get("content-type")).toContain("text/event-stream")
|
||||
if (!eventResponse.body) throw new Error("missing proxied event response body")
|
||||
const eventReader = eventResponse.body.getReader()
|
||||
const event = yield* Effect.promise(() => eventReader.read())
|
||||
yield* Effect.promise(() => eventReader.cancel())
|
||||
expect(new TextDecoder().decode(event.value)).toContain("server.connected")
|
||||
expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true)
|
||||
} finally {
|
||||
void remote.stop(true)
|
||||
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { SessionRetry } from "../../src/session/retry"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
import { ProviderError } from "../../src/provider/error"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
@@ -163,6 +164,14 @@ describe("session.retry.retryable", () => {
|
||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
|
||||
})
|
||||
|
||||
test("retries transport timeout errors", () => {
|
||||
const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID })
|
||||
expect(MessageV2.APIError.isInstance(request)).toBe(true)
|
||||
expect(SessionRetry.retryable(request, retryProvider)).toEqual({
|
||||
message: "Provider response headers timed out after 10000ms",
|
||||
})
|
||||
})
|
||||
|
||||
test("does not retry context overflow errors", () => {
|
||||
const error = new MessageV2.ContextOverflowError({
|
||||
message: "Input exceeds context window of this model",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -220,6 +220,7 @@ export type ProviderHook = {
|
||||
export type AuthOuathResult = AuthOAuthResult
|
||||
|
||||
export interface Hooks {
|
||||
dispose?: () => Promise<void>
|
||||
event?: (input: { event: Event }) => Promise<void>
|
||||
config?: (input: Config) => Promise<void>
|
||||
tool?: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -2633,6 +2633,8 @@ export class Pty extends HeyApiClient {
|
||||
ptyID: string
|
||||
directory?: string
|
||||
workspace?: string
|
||||
cursor?: string
|
||||
ticket?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
@@ -2644,6 +2646,8 @@ export class Pty extends HeyApiClient {
|
||||
{ in: "path", key: "ptyID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "query", key: "cursor" },
|
||||
{ in: "query", key: "ticket" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1049,11 +1049,15 @@ export type ProviderConfig = {
|
||||
enterpriseUrl?: string
|
||||
setCacheKey?: boolean
|
||||
/**
|
||||
* Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.
|
||||
* Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.
|
||||
*/
|
||||
timeout?: number | false
|
||||
/**
|
||||
* Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.
|
||||
*/
|
||||
headerTimeout?: number | false
|
||||
chunkTimeout?: number
|
||||
[key: string]: unknown | string | boolean | number | false | number | undefined
|
||||
[key: string]: unknown | string | boolean | number | false | number | false | number | undefined
|
||||
}
|
||||
models?: {
|
||||
[key: string]: {
|
||||
@@ -8196,6 +8200,8 @@ export type PtyConnectData = {
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
cursor?: string
|
||||
ticket?: string
|
||||
}
|
||||
url: "/pty/{ptyID}/connect"
|
||||
}
|
||||
|
||||
@@ -13588,7 +13588,20 @@
|
||||
"enum": [false]
|
||||
}
|
||||
],
|
||||
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout."
|
||||
"description": "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout."
|
||||
},
|
||||
"headerTimeout": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
],
|
||||
"description": "Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout."
|
||||
},
|
||||
"chunkTimeout": {
|
||||
"type": "integer",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,40 +1,29 @@
|
||||
FROM oven/bun:1.3.14-alpine
|
||||
FROM oven/bun:1.3.14-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=0
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
COPY patches ./patches
|
||||
COPY packages/app/package.json ./packages/app/package.json
|
||||
COPY packages/console/app/package.json ./packages/console/app/package.json
|
||||
COPY packages/console/core/package.json ./packages/console/core/package.json
|
||||
COPY packages/console/function/package.json ./packages/console/function/package.json
|
||||
COPY packages/console/mail/package.json ./packages/console/mail/package.json
|
||||
COPY packages/console/resource/package.json ./packages/console/resource/package.json
|
||||
COPY packages/core/package.json ./packages/core/package.json
|
||||
COPY packages/desktop/package.json ./packages/desktop/package.json
|
||||
COPY packages/effect-drizzle-sqlite/package.json ./packages/effect-drizzle-sqlite/package.json
|
||||
COPY packages/enterprise/package.json ./packages/enterprise/package.json
|
||||
COPY packages/function/package.json ./packages/function/package.json
|
||||
COPY packages/http-recorder/package.json ./packages/http-recorder/package.json
|
||||
COPY packages/llm/package.json ./packages/llm/package.json
|
||||
COPY packages/opencode/package.json ./packages/opencode/package.json
|
||||
COPY packages/plugin/package.json ./packages/plugin/package.json
|
||||
COPY packages/script/package.json ./packages/script/package.json
|
||||
COPY packages/sdk/js/package.json ./packages/sdk/js/package.json
|
||||
COPY packages/slack/package.json ./packages/slack/package.json
|
||||
COPY packages/stats/app/package.json ./packages/stats/app/package.json
|
||||
COPY packages/stats/core/package.json ./packages/stats/core/package.json
|
||||
COPY packages/stats/server/package.json ./packages/stats/server/package.json
|
||||
COPY packages/storybook/package.json ./packages/storybook/package.json
|
||||
COPY packages/ui/package.json ./packages/ui/package.json
|
||||
COPY packages/web/package.json ./packages/web/package.json
|
||||
FROM base AS pruner
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN bunx turbo@2.8.13 prune @opencode-ai/stats-server --docker --no-update-notifier --no-color
|
||||
|
||||
FROM base AS installer
|
||||
|
||||
COPY --from=pruner /app/out/json/ ./
|
||||
|
||||
# Bun 1.3.x needs the pruned workspace globs and lockfile metadata refreshed before the frozen production install.
|
||||
RUN bun -e 'const packageJson = await Bun.file("package.json").json(); packageJson.workspaces.packages = Array.from(new Bun.Glob("packages/**/package.json").scanSync(".")).map((file) => file.slice(0, -"/package.json".length)).sort(); await Bun.write("package.json", JSON.stringify(packageJson, null, 2) + "\n")'
|
||||
RUN rm -f bun.lock && bun install --lockfile-only --ignore-scripts
|
||||
RUN bun install --frozen-lockfile --production --ignore-scripts
|
||||
|
||||
COPY packages ./packages
|
||||
FROM base AS runner
|
||||
|
||||
COPY --from=installer /app/node_modules ./node_modules
|
||||
COPY --from=pruner /app/out/full/ ./
|
||||
|
||||
WORKDIR /app/packages/stats/server
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
box-shadow: 0 0 0 0.5px var(--accordion-v2-border);
|
||||
overflow: hidden;
|
||||
|
||||
font-family: var(--font-family-sans), "Inter", system-ui, sans-serif;
|
||||
font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif;
|
||||
color: var(--accordion-v2-fg);
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
@@ -32,7 +32,7 @@ export default {
|
||||
},
|
||||
}
|
||||
|
||||
const frame = { width: "346px", "font-family": "var(--font-family-sans)", "font-size": "13px" } as const
|
||||
const frame = { width: "346px", "font-family": "var(--v2-font-family-sans)", "font-size": "13px" } as const
|
||||
|
||||
export const Basic = {
|
||||
render: () => (
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
height: 28px;
|
||||
border-radius: var(--avatar-radius);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-weight: 530;
|
||||
font-size: var(--avatar-font-size);
|
||||
line-height: 1;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
border: 0.5px solid var(--border-border-base);
|
||||
background: var(--background-bg-layer-02);
|
||||
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-family: var(--font-family-sans), var(--sans), system-ui, sans-serif;
|
||||
font-family: var(--v2-font-family-sans), var(--sans), system-ui, sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
[data-slot="basic-tool-v2-trigger"] {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
[data-slot="checkbox-v2-error"] {
|
||||
color: var(--state-fg-danger);
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-normal);
|
||||
@@ -90,7 +90,7 @@
|
||||
display: inline-flex;
|
||||
user-select: none;
|
||||
color: inherit;
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -109,7 +109,7 @@
|
||||
|
||||
[data-slot="checkbox-v2-description"] {
|
||||
color: var(--text-text-muted);
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
|
||||
@@ -49,7 +49,7 @@ export const Controlled = {
|
||||
label="Controlled checkbox"
|
||||
description="Toggled from Storybook state."
|
||||
/>
|
||||
<div style={{ "font-family": "var(--font-family-sans)", "font-size": "12px", color: "#808080" }}>
|
||||
<div style={{ "font-family": "var(--v2-font-family-sans)", "font-size": "12px", color: "#808080" }}>
|
||||
Checked: {String(checked())}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
[data-slot="dialog-title"] {
|
||||
margin: 0;
|
||||
font-family: "Inter", var(--font-family-sans);
|
||||
font-family: "Inter", var(--v2-font-family-sans);
|
||||
font-weight: 530;
|
||||
font-size: 15px;
|
||||
line-height: 100%;
|
||||
@@ -74,7 +74,7 @@
|
||||
}
|
||||
|
||||
[data-slot="dialog-description"] {
|
||||
font-family: "Inter", var(--font-family-sans);
|
||||
font-family: "Inter", var(--v2-font-family-sans);
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 100%;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
[data-slot="diff-changes-additions"],
|
||||
[data-slot="diff-changes-deletions"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 13px;
|
||||
@@ -76,7 +76,7 @@
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
min-height: 11px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
@@ -138,7 +138,7 @@
|
||||
border: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -86,7 +86,7 @@ export const Controlled = {
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
"font-family": "var(--font-family-sans)",
|
||||
"font-family": "var(--v2-font-family-sans)",
|
||||
"font-size": "12px",
|
||||
color: "var(--text-text-faint)",
|
||||
}}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
[data-component="keybind-v2"] {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-family-sans), var(--sans), system-ui, sans-serif;
|
||||
font-family: var(--v2-font-family-sans), var(--sans), system-ui, sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
@@ -53,7 +53,7 @@
|
||||
padding: 0px;
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
font-family: "Inter", var(--font-family-sans), var(--sans), system-ui, sans-serif;
|
||||
font-family: "Inter", var(--v2-font-family-sans), var(--sans), system-ui, sans-serif;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
[data-component="line-comment-v2"] {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-family-sans), var(--sans), system-ui, sans-serif;
|
||||
font-family: var(--v2-font-family-sans), var(--sans), system-ui, sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
padding: 2px;
|
||||
min-width: 160px;
|
||||
|
||||
background: var(--background-bg-layer-01);
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--elevation-floating);
|
||||
box-shadow: var(--v2-elevation-floating);
|
||||
|
||||
outline: none;
|
||||
font-family: var(--font-family-sans), "Inter", system-ui, sans-serif;
|
||||
font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif;
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -31,9 +31,9 @@
|
||||
--menu-v2-fg-subtle: var(--text-text-muted);
|
||||
--menu-v2-icon: var(--icon-icon-base);
|
||||
--menu-v2-accent: var(--text-text-accent);
|
||||
--menu-v2-badge-bg: var(--background-bg-layer-02);
|
||||
--menu-v2-badge-border: var(--border-border-base);
|
||||
--menu-v2-hover: var(--overlay-simple-overlay-hover);
|
||||
--menu-v2-badge-bg: var(--v2-background-bg-layer-02);
|
||||
--menu-v2-badge-border: var(--v2-border-border-base);
|
||||
--menu-v2-hover: var(--v2-overlay-simple-overlay-hover);
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -52,7 +52,7 @@
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
font-family: var(--font-family-sans), "Inter", system-ui, sans-serif;
|
||||
font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif;
|
||||
font-variation-settings: "slnt" 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--menu-v2-fg);
|
||||
@@ -153,7 +153,7 @@
|
||||
height: 1px;
|
||||
width: calc(100% + 4px);
|
||||
margin: 2px -2px;
|
||||
background: var(--border-border-muted);
|
||||
background: var(--v2-border-border-muted);
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
|
||||
font-family: var(--font-family-sans), "Inter", system-ui, sans-serif;
|
||||
font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 530;
|
||||
line-height: 100%;
|
||||
|
||||
@@ -179,7 +179,7 @@ export const Context = {
|
||||
border: "1px dashed rgba(0, 0, 0, 0.2)",
|
||||
color: "#5c5c5c",
|
||||
"font-size": "13px",
|
||||
"font-family": "var(--font-family-sans)",
|
||||
"font-family": "var(--v2-font-family-sans)",
|
||||
"user-select": "none",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
color: var(--text-text-faint);
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
[data-slot="radio-v2-description"] {
|
||||
color: var(--text-text-faint);
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1.2;
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
[data-slot="radio-v2-error"] {
|
||||
color: var(--state-fg-danger);
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-normal);
|
||||
@@ -167,7 +167,7 @@
|
||||
display: inline-flex;
|
||||
user-select: none;
|
||||
color: inherit;
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -186,7 +186,7 @@
|
||||
|
||||
[data-slot="radio-v2-item-description"] {
|
||||
color: var(--text-text-muted);
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
|
||||
@@ -50,7 +50,7 @@ export const Controlled = {
|
||||
<RadioItemV2 value="weekly" label="Weekly" />
|
||||
<RadioItemV2 value="never" label="Never" />
|
||||
</RadioGroupV2>
|
||||
<div style={{ "font-family": "var(--font-family-sans)", "font-size": "12px", color: "#808080" }}>
|
||||
<div style={{ "font-family": "var(--v2-font-family-sans)", "font-size": "12px", color: "#808080" }}>
|
||||
Selected: {value()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-family-sans), var(--sans);
|
||||
font-family: var(--v2-font-family-sans), var(--sans);
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -56,7 +56,7 @@ export const Controlled = {
|
||||
<SegmentedControlItemV2 value="b">Grid</SegmentedControlItemV2>
|
||||
<SegmentedControlItemV2 value="c">Board</SegmentedControlItemV2>
|
||||
</SegmentedControlV2>
|
||||
<div style={{ "font-family": "var(--font-family-sans)", "font-size": "12px", color: "#808080" }}>
|
||||
<div style={{ "font-family": "var(--v2-font-family-sans)", "font-size": "12px", color: "#808080" }}>
|
||||
Value: {value()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,7 +74,7 @@ export const AllowDeselect = {
|
||||
<SegmentedControlItemV2 value="b">B</SegmentedControlItemV2>
|
||||
<SegmentedControlItemV2 value="c">C</SegmentedControlItemV2>
|
||||
</SegmentedControlV2>
|
||||
<div style={{ "font-family": "var(--font-family-sans)", "font-size": "12px", color: "#808080" }}>
|
||||
<div style={{ "font-family": "var(--v2-font-family-sans)", "font-size": "12px", color: "#808080" }}>
|
||||
Value: {value() === null ? "none" : value()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
background: transparent;
|
||||
outline: none;
|
||||
text-align: left;
|
||||
font-family: var(--font-family-sans);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user