Compare commits

..
207 changed files with 5101 additions and 8839 deletions
+18 -20
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.15.7", "version": "1.15.6",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -253,9 +253,8 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
@@ -308,7 +307,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.7", "version": "1.15.5",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -322,7 +321,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -352,7 +351,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -368,7 +367,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -381,7 +380,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2", "@smithy/util-utf8": "4.2.2",
@@ -399,7 +398,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.15.7", "version": "1.15.6",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -537,7 +536,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
@@ -575,7 +574,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -590,7 +589,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -625,7 +624,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -674,7 +673,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -717,7 +716,6 @@
], ],
"patchedDependencies": { "patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-hIarzU3QNIvkwpfnearfsGaBMCSdovkOWAuvX+EBQI8=", "x86_64-linux": "sha256-kCSAVPQgJROcvnnwf0Cn6PuYL25hYgTasJeBJlmnFgQ=",
"aarch64-linux": "sha256-qu+86RNsBZhqzE3CEyg4XHD/3Czc/BqHhY3O1vyogtY=", "aarch64-linux": "sha256-prY27Ek2QhW+4OvBJ3bHHkUDoLTA4mD3KQmOQqSbAuo=",
"aarch64-darwin": "sha256-7sXZdPh/R4fQBBMq36YdpQE6G2ELMZd08BqMhHbhwv4=", "aarch64-darwin": "sha256-0yIqnnjreVHTgGZLrKFpT9Cc2B2LNfmYcRByaCu7tiU=",
"x86_64-darwin": "sha256-g5QWpX33R143sNR1G9m7UsSA3jygFyJjT7Kjyjo6sCc=" "x86_64-darwin": "sha256-n+urvMRozB9nO5D3qyCweSa5HExFk1YGEzOt2445LEE="
} }
} }
+1 -2
View File
@@ -139,7 +139,6 @@
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch"
} }
} }
+17 -33
View File
@@ -1,5 +1,4 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture, pageMessages } from "./session-timeline.fixture" import { fixture, pageMessages } from "./session-timeline.fixture"
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
@@ -38,12 +37,12 @@ test.describe("smoke: session timeline", () => {
project: fixture.project, project: fixture.project,
pageMessages, pageMessages,
}) })
await configureSmokePage(page, fixture.directory) await configureSmokePage(page)
await selectHomeProject(page, fixture.project.name) await openProject(page, "SmokeProject")
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle) await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
await expectSessionReady(page) await expectSessionReady(page, "smoke-project")
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
const expectedPartIDs = fixture.expected.targetPartIDs const expectedPartIDs = fixture.expected.targetPartIDs
const expectedMessageIDs = fixture.expected.targetMessageIDs const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
@@ -51,7 +50,7 @@ test.describe("smoke: session timeline", () => {
}) })
}) })
async function configureSmokePage(page: Page, directory: string) { async function configureSmokePage(page: Page) {
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem( localStorage.setItem(
"settings.v3", "settings.v3",
@@ -64,23 +63,7 @@ async function configureSmokePage(page: Page, directory: string) {
}, },
}), }),
) )
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: {
local: [{ worktree: directory, expanded: true }],
},
lastProject: {
local: directory,
},
}),
)
}, directory)
await page.addInitScript(() => {
const smoke = window as SmokeWindow const smoke = window as SmokeWindow
smoke.__timelineSmokeErrorToasts = [] smoke.__timelineSmokeErrorToasts = []
smoke.__timelineSmokeForbiddenText = [] smoke.__timelineSmokeForbiddenText = []
@@ -409,20 +392,21 @@ function expectCompleteScroll(
expect(expectedPartIDs.length).toBe(331) expect(expectedPartIDs.length).toBe(331)
} }
async function selectHomeProject(page: Page, projectName: string) { async function openProject(page: Page, projectName: string) {
await page.goto("/") await page.goto("/")
await page await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
.locator('[data-component="home-project-row"]')
.filter({ hasText: new RegExp(projectName, "i") })
.click()
await expect(page).toHaveURL(/\/$/)
} }
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) { async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`) // Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
await page
.locator(`a[href*="${sessionId}"]`)
.first()
.evaluate((el) => (el as HTMLElement).click())
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible() await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
} }
async function expectSessionReady(page: Page) { async function expectSessionReady(page: Page, projectName: string) {
await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible() await expect(page.getByText(projectName).first()).toBeVisible()
await expect(page.getByText("Ask anything...")).toBeVisible()
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.15.7", "version": "1.15.6",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
-1
View File
@@ -78,7 +78,6 @@ declare global {
} }
api?: { api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void> setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
} }
} }
} }
@@ -12,7 +12,7 @@ import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers" import { getProjectAvatarSource } from "@/pages/layout/sidebar-items"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
+280 -559
View File
@@ -1,17 +1,6 @@
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal, createResource } from "solid-js"
createEffect,
on,
Component,
Show,
onCleanup,
createMemo,
createSignal,
createResource,
Switch,
Match,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -68,14 +57,11 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query" import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/global-sync" import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { getFilename } from "@opencode-ai/core/util/path"
interface PromptInputProps { interface PromptInputProps {
class?: string class?: string
variant?: "dock" | "new-session"
ref?: (el: HTMLDivElement) => void ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string newSessionWorktree?: string
onNewSessionWorktreeChange?: (worktree: string) => void
onNewSessionWorktreeReset?: () => void onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
onEditLoaded?: () => void onEditLoaded?: () => void
@@ -113,9 +99,6 @@ const EXAMPLES = [
"prompt.example.25", "prompt.example.25",
] as const ] as const
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
export const PromptInput: Component<PromptInputProps> = (props) => { export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const queryOptions = useQueryOptions() const queryOptions = useQueryOptions()
@@ -1072,21 +1055,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
readClipboardImage: platform.readClipboardImage, readClipboardImage: platform.readClipboardImage,
}) })
const fileAttachmentInput = () => (
<input
ref={(el) => (fileInputRef = el)}
type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const list = e.currentTarget.files
if (list) void addAttachments(Array.from(list))
e.currentTarget.value = ""
}}
/>
)
const variants = createMemo(() => ["default", ...local.model.variant.list()]) const variants = createMemo(() => ["default", ...local.model.variant.list()])
const accepting = createMemo(() => { const accepting = createMemo(() => {
const id = params.id const id = params.id
@@ -1298,99 +1266,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
(p) => p, (p) => p,
) )
const designPlaceholder = () => {
if (store.mode === "shell") return placeholder()
return "Ask anything, / for commands, @ for context..."
}
const modelControl = () => (
<Show when={!providersLoading()}>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group",
"data-action": "prompt-model",
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</Show>
)
const newSession = () => props.variant === "new-session"
const worktrees = createMemo(() => [MAIN_WORKTREE, ...(sync.project?.sandboxes ?? []), CREATE_WORKTREE])
const currentWorktree = createMemo(() => {
if (worktrees().includes(props.newSessionWorktree ?? MAIN_WORKTREE))
return props.newSessionWorktree ?? MAIN_WORKTREE
return MAIN_WORKTREE
})
const worktreeLabel = (value: string) => {
if (value === MAIN_WORKTREE) return MAIN_WORKTREE
if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create")
return getFilename(value)
}
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
return ( return (
<div class="relative size-full flex flex-col gap-0"> <div class="relative size-full _max-h-[320px] flex flex-col gap-0">
{(promptReady(), null)} {(promptReady(), null)}
<PromptPopover <PromptPopover
popover={store.popover} popover={store.popover}
@@ -1407,138 +1284,121 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
commandKeybind={command.keybind} commandKeybind={command.keybind}
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<Switch> <DockShellForm
<Match when={USE_V2_INPUT}> onSubmit={handleSubmit}
<DockShellForm classList={{
data-component={newSession() ? "session-new-composer" : "session-composer"} "group/prompt-input": true,
onSubmit={handleSubmit} "focus-within:shadow-xs-border": true,
classList={{ "border-icon-info-active border-dashed": store.draggingType !== null,
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true, [props.class ?? ""]: !!props.class,
"border-icon-info-active border-dashed": store.draggingType !== null, }}
[props.class ?? ""]: !!props.class, >
}} <PromptDragOverlay
type={store.draggingType}
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
/>
<PromptContextItems
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}}
openComment={openComment}
remove={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<div
class="relative"
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
return
}
editorRef?.focus()
}}
>
<div
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
ref={(el) => (scrollRef = el)}
style={{ "scroll-padding-bottom": space }}
> >
<PromptDragOverlay <div
type={store.draggingType} data-component="prompt-input"
label={language.t( ref={(el) => {
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label", editorRef = el
)} props.ref?.(el)
/>
<PromptContextItems
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}} }}
openComment={openComment} role="textbox"
remove={(item) => { aria-multiline="true"
if (item.commentID) comments.remove(item.path, item.commentID) aria-label={placeholder()}
prompt.context.remove(item.key) contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}} }}
t={(key) => language.t(key as Parameters<typeof language.t>[0])} style={{ "padding-bottom": space }}
/>
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/> />
<div <div
class="relative min-h-[52px]" class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
onMouseDown={(e) => { classList={{ "font-mono!": store.mode === "shell" }}
const target = e.target style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) return
editorRef?.focus()
}}
> >
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}> {placeholder()}
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox"
aria-multiline="true"
aria-label={designPlaceholder()}
contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
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,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<div
data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
>
{designPlaceholder()}
</div>
</div>
</div> </div>
<div class="flex h-11 items-center px-2"> </div>
<div class="flex min-w-0 flex-1 items-center gap-0">
{fileAttachmentInput()} <div
<TooltipKeybind aria-hidden="true"
placement="top" class="pointer-events-none absolute inset-x-0 bottom-0"
title={language.t("prompt.action.attachFile")} style={{
keybind={command.keybind("file.attach")} height: space,
> background:
<IconButton "linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
data-action="prompt-attach" }}
type="button" />
icon="plus"
variant="ghost" <div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted" <input
style={buttons()} ref={fileInputRef}
onClick={pick} type="file"
disabled={store.mode !== "normal"} multiple
tabIndex={store.mode === "normal" ? undefined : -1} accept={ACCEPTED_FILE_TYPES.join(",")}
aria-label={language.t("prompt.action.attachFile")} class="hidden"
/> onChange={(e) => {
</TooltipKeybind> const list = e.currentTarget.files
<Show when={newSession()}> if (list) void addAttachments(Array.from(list))
<div class="relative"> e.currentTarget.value = ""
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center"> }}
<Icon name="sliders" size="small" /> />
</div>
<Select <div class="flex items-center gap-1 pointer-events-auto">
size="normal"
options={worktrees()}
current={currentWorktree()}
label={worktreeLabel}
onSelect={(value) => {
if (value) props.onNewSessionWorktreeChange?.(value)
restoreFocus()
}}
class="max-w-[175px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 text-[13px] font-[440] leading-4 text-v2-text-text-faint"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-workspace" }}
variant="ghost"
/>
</div>
</Show>
{modelControl()}
</div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}> <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton <IconButton
data-action="prompt-submit" data-action="prompt-submit"
@@ -1547,348 +1407,209 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
tabIndex={store.mode === "normal" ? undefined : -1} tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"} icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary" variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50" class="size-8"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/> />
</Tooltip> </Tooltip>
</div> </div>
</DockShellForm> </div>
</Match>
<Match when> <div class="pointer-events-none absolute bottom-2 left-2">
<DockShellForm
onSubmit={handleSubmit}
classList={{
"group/prompt-input": true,
"focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
>
<PromptDragOverlay
type={store.draggingType}
label={language.t(
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
)}
/>
<PromptContextItems
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}}
openComment={openComment}
remove={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<div <div
class="relative" aria-hidden={store.mode !== "normal"}
onMouseDown={(e) => { class="pointer-events-auto"
const target = e.target style={{
if (!(target instanceof HTMLElement)) return "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
return
}
editorRef?.focus()
}} }}
> >
<div <TooltipKeybind
class="relative max-h-[240px] overflow-y-auto no-scrollbar" placement="top"
ref={(el) => (scrollRef = el)} title={language.t("prompt.action.attachFile")}
style={{ "scroll-padding-bottom": space }} keybind={command.keybind("file.attach")}
> >
<div <Button
data-component="prompt-input" data-action="prompt-attach"
ref={(el) => { type="button"
editorRef = el variant="ghost"
props.ref?.(el) class="size-8 p-0"
}} style={buttons()}
role="textbox" onClick={pick}
aria-multiline="true" disabled={store.mode !== "normal"}
aria-label={placeholder()} tabIndex={store.mode === "normal" ? undefined : -1}
contenteditable="true" aria-label={language.t("prompt.action.attachFile")}
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
style={{ "padding-bottom": space }}
/>
<div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }}
style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
> >
{placeholder()} <Icon name="plus" class="size-4.5" />
</div> </Button>
</div> </TooltipKeybind>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 bottom-0"
style={{
height: space,
background:
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
}}
/>
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const list = e.currentTarget.files
if (list) void addAttachments(Array.from(list))
e.currentTarget.value = ""
}}
/>
<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-8"
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<div class="pointer-events-none absolute bottom-2 left-2">
<div
aria-hidden={store.mode !== "normal"}
class="pointer-events-auto"
style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
>
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<Button
data-action="prompt-attach"
type="button"
variant="ghost"
class="size-8 p-0"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="plus" class="size-4.5" />
</Button>
</TooltipKeybind>
</div>
</div>
</div> </div>
</DockShellForm> </div>
<Show when={store.mode === "normal" || store.mode === "shell"}> </div>
<DockTray attach="top"> </DockShellForm>
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0"> <Show when={store.mode === "normal" || store.mode === "shell"}>
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative"> <DockTray attach="top">
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
<div
class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
style={{
padding: "0 0px 0 8px",
...shell(),
}}
>
<Icon name="console" />
<span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
<div class="flex-1" />
<Button
variant="ghost"
class="text-text-base"
onClick={() => {
setStore("mode", "normal")
}}
>
{language.t("common.cancel")}
</Button>
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
<Show when={!agentsLoading()}>
<div <div
class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0" data-component="prompt-agent-control"
style={{ style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
padding: "0 0px 0 8px",
...shell(),
}}
> >
<Icon name="console" /> <TooltipKeybind
<span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span> placement="top"
<div class="flex-1" /> gutter={4}
<Button title={language.t("command.agent.cycle")}
variant="ghost" keybind={command.keybind("agent.cycle")}
class="text-text-base"
onClick={() => {
setStore("mode", "normal")
}}
> >
{language.t("common.cancel")} <Select
</Button> size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={(value) => {
local.agent.set(value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
</TooltipKeybind>
</div> </div>
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7"> </Show>
<Show when={!agentsLoading()}> <Show when={!providersLoading()}>
<div <Show when={store.mode !== "shell"}>
data-component="prompt-agent-control" <div
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined} data-component="prompt-model-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
> >
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.agent.cycle")} title={language.t("command.model.choose")}
keybind={command.keybind("agent.cycle")} keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</div>
<Show when={variants().length > 2}>
<div
data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
> >
<Select <Select
size="normal" size="normal"
options={agentNames()} options={variants()}
current={local.agent.current()?.name ?? ""} current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => { onSelect={(value) => {
local.agent.set(value) local.model.variant.set(value === "default" ? undefined : value)
restoreFocus() restoreFocus()
}} }}
class="capitalize max-w-[160px] text-text-base" class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base" valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()} triggerStyle={control()}
triggerProps={{ "data-action": "prompt-agent" }} triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost" variant="ghost"
/> />
</TooltipKeybind> </TooltipKeybind>
</div> </div>
</Show> </Show>
<Show when={!providersLoading()}> </Show>
<Show when={store.mode !== "shell"}> </Show>
<div
data-component="prompt-model-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</div>
<Show when={variants().length > 2}>
<div
data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show>
</Show>
</Show>
</div>
</div>
</div> </div>
</DockTray> </div>
</Show> </div>
</Match> </DockTray>
</Switch> </Show>
</div> </div>
) )
} }
@@ -3,4 +3,3 @@ export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab" export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view" export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
@@ -1,78 +0,0 @@
import type { JSX } from "solid-js"
import { createMemo } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
const MAIN_WORKTREE = "main"
export function NewSessionDesignView(props: { worktree: string; children: JSX.Element }) {
const globalSync = useGlobalSync()
const layout = useLayout()
const navigate = useNavigate()
const sdk = useSDK()
const server = useServer()
const sync = useSync()
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const projects = createMemo(() => {
const roots = globalSync.data.project.map((project) => project.worktree)
if (roots.includes(projectRoot())) return roots
return [projectRoot(), ...roots]
})
const branch = createMemo(() => sync.data.vcs?.branch ?? MAIN_WORKTREE)
const openProject = (directory: string | undefined) => {
if (!directory) return
if (directory === projectRoot()) return
layout.projects.open(directory)
server.projects.touch(directory)
navigate(`/${base64Encode(directory)}/session`)
}
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class="w-full max-w-[720px]">
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
<div class="mt-8">
{props.children}
<div class="mt-3 flex h-7 items-center gap-0 pl-2">
<Select
size="normal"
variant="ghost"
options={projects()}
current={projectRoot()}
label={getFilename}
onSelect={openProject}
class="max-w-[203px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate text-[length:13px] font-[440] text-v2-text-text-faint"
/>
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
<Icon name="branch" size="small" />
</div>
<Select
size="normal"
variant="ghost"
options={[branch()]}
current={branch()}
class="max-w-[240px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 font-[440] text-v2-text-text-faint"
/>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -193,12 +193,6 @@ export const SettingsGeneral: Component = () => {
{ initialValue: null as DisplayBackend | null }, { initialValue: null as DisplayBackend | null },
) )
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: false },
)
onMount(() => { onMount(() => {
void theme.loadThemes() void theme.loadThemes()
}) })
@@ -245,13 +239,6 @@ export const SettingsGeneral: Component = () => {
}) })
} }
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
if (!update) return
void update.catch(() => setPinchZoom(!checked))
}
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") }, { value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") }, { value: "light", label: language.t("theme.scheme.light") },
@@ -742,45 +729,6 @@ export const SettingsGeneral: Component = () => {
</div> </div>
) )
const DisplaySection = () => (
<Show when={desktop()}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
<Show when={linux()}>
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRow>
</Show>
</SettingsList>
</div>
</Show>
)
console.log(import.meta.env) console.log(import.meta.env)
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
@@ -801,7 +749,31 @@ export const SettingsGeneral: Component = () => {
<UpdatesSection /> <UpdatesSection />
<DisplaySection /> <Show when={linux()}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<SettingsList>
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRow>
</SettingsList>
</div>
</Show>
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}> <Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
<AdvancedSection /> <AdvancedSection />
+92 -230
View File
@@ -7,9 +7,8 @@ import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context" import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx" import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout" import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -19,10 +18,6 @@ import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { decodeDirectory } from "@/pages/directory-layout" import { decodeDirectory } from "@/pages/directory-layout"
import { iife } from "@opencode-ai/core/util/iife" import { iife } from "@opencode-ai/core/util/iife"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { makeEventListener } from "@solid-primitives/event-listener"
type TauriDesktopWindow = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@@ -45,21 +40,13 @@ type TauriApi = {
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
const legacyTitlebarHeight = 40 const titlebarHeight = 40
const v2TitlebarHeight = 44
const minTitlebarZoom = 0.25 const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. 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}` const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
export type TitlebarUpdate = { export function Titlebar() {
version: () => string | undefined
installing: () => boolean
install: () => void
}
export function Titlebar(props: { update?: TitlebarUpdate }) {
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
const command = useCommand() const command = useCommand()
@@ -72,16 +59,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const electronWindows = createMemo(() => windows() && !tauriApi())
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web") const web = createMemo(() => platform.platform === "web")
const zoom = () => platform.webviewZoom?.() ?? 1 const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1) const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => { const minHeight = () => {
const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight if (mac()) return `${titlebarHeight / zoom()}px`
if (mac()) return `${height / zoom()}px` if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px`
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
return undefined return undefined
} }
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px` const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
@@ -198,47 +183,19 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return ( return (
<header <header
classList={{ class="h-10 shrink-0 bg-background-base relative overflow-hidden flex flex-row"
"shrink-0 relative overflow-hidden flex flex-row": true, style={{ "min-height": minHeight(), "padding-left": mac() ? `${84 / zoom()}px` : 0 }}
"h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
"h-10 bg-background-base": !USE_V2_TITLEBAR,
}}
style={{
"min-height": minHeight(),
"padding-left": mac() ? `${84 / zoom()}px` : 0,
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": electronWindows()
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
: undefined,
"align-self": electronWindows() ? "flex-start" : undefined,
}}
data-tauri-drag-region data-tauri-drag-region
onMouseDown={drag} onMouseDown={drag}
onDblClick={maximize} onDblClick={maximize}
> >
<Switch> <Switch>
<Match when={USE_V2_TITLEBAR}> <Match when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
{(_) => { {(_) => {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const navigate = useNavigate() const navigate = useNavigate()
const homeMatch = useMatch(() => "/")
const openNewSession = () => { type Tab = { dir: string; sessionId: string; params: any; href: string }
if (params.dir) {
navigate(`/${params.dir}/session`)
return
}
const project = layout.projects.list()[0]
if (!project) {
navigate("/")
return
}
navigate(`/${base64Encode(project.worktree)}/session`)
}
type Tab = { dir: string; sessionId: string; href: string }
const [tabsStore, tabsStoreActions] = iife(() => { const [tabsStore, tabsStoreActions] = iife(() => {
const [store, setStore] = createStore<Tab[]>( const [store, setStore] = createStore<Tab[]>(
@@ -248,6 +205,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
{ {
dir: decodeDirectory(params.dir) ?? "", dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id, sessionId: params.id,
params: { id: params.id, dir: params.dir },
href: makeSessionHref(params.dir, params.id), href: makeSessionHref(params.dir, params.id),
}, },
] ]
@@ -290,43 +248,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
tabsStoreActions.addTab({ tabsStoreActions.addTab({
dir: decodeDirectory(params.dir) ?? "", dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id, sessionId: params.id,
params: { id: params.id, dir: params.dir },
href: makeSessionHref(params.dir, params.id), href: makeSessionHref(params.dir, params.id),
}) })
}) })
const projects = createMemo(() => layout.projects.list())
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const currentSessionTab = () => {
if (!params.dir || !params.id) return
const href = makeSessionHref(params.dir, params.id)
if (!tabsStore.some((tab) => tab.href === href)) return
return href
}
const closeCurrentSessionTab = () => {
const href = currentSessionTab()
if (!href) return false
tabsStoreActions.removeTab(href)
return true
}
makeEventListener(
document,
"keydown",
(event) => {
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
if (event.key.toLowerCase() !== "w") return
if (!closeCurrentSessionTab()) return
event.preventDefault()
event.stopPropagation()
},
{ capture: true },
)
const tabsEnriched = iife(() => { const tabsEnriched = iife(() => {
const base = mapArray( const base = mapArray(
() => tabsStore, () => tabsStore,
@@ -341,73 +267,78 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}) })
return ( return (
<div <div class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3">
class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3 py-2"
classList={{
"pl-2": mac(),
"pl-4": !mac(),
}}
>
<ChannelIndicator /> <ChannelIndicator />
<Show when={windows() || linux()}> <Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" /> <WindowsAppMenu command={command} platform={platform} />
</Show> </Show>
<IconButtonV2 <IconButtonV2
variant="ghost-muted"
size="large"
as="a" as="a"
href="/" href="/"
class="!w-9" variant="ghost-muted"
icon={<IconV2 name="grid-plus" />} size="large"
state={!!homeMatch() ? "pressed" : undefined} class="!w-8"
/> state={!!useMatch(() => "/")() ? "pressed" : undefined}
>
<div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden"> <path
<For each={tabsEnriched()}> d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z"
{(tab, i) => ( stroke="currentColor"
<> stroke-miterlimit="10"
{i() !== 0 && ( stroke-linecap="square"
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)}
<TabNavItem
href={tab.href}
title={tab.info.title}
project={projectForSession(tab.info, projects(), projectByID())}
directory={tab.dir}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
</For>
</div>
<Show
when={creating() && params.dir}
fallback={
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewSession}
aria-label={language.t("command.session.new")}
/>
}
>
<NewSessionTabItem
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
/> />
</Show> </svg>
<div class="min-w-0 flex-1" /> </IconButtonV2>
<div class="flex flex-row items-center gap-2">
<For each={tabsEnriched()}>
{(tab, i) => (
<>
{i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
<TabNavItem
href={tab.href}
title={tab.info.title}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
</For>
</div> </div>
<TitlebarUpdatePill update={props.update} /> <button>
<Show when={windows() && !electronWindows()}> <div class="p-1.5">
<div data-tauri-decorum-tb class="flex flex-row" /> <svg
</Show> xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path
d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
stroke="#808080"
stroke-linejoin="round"
/>
</svg>
</div>
</button>
<div class="flex-1" />
{/*<button class="px-2.5 py-1.5 bg-[rgba(0,0,0,0.08)] rounded-[6px]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path
d="M10.4443 2.44436V13.5555M1.55546 13.5554H14.4443V2.44434H1.55542L1.55546 13.5554Z"
stroke="#3A3A3A"
/>
</svg>
</button>*/}
</div> </div>
) )
}} }}
@@ -427,7 +358,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<WindowsAppMenu command={command} platform={platform} /> <WindowsAppMenu command={command} platform={platform} />
</Show> </Show>
<Show when={mac()}> <Show when={mac()}>
{/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/} <div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center"> <div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton <IconButton
icon="menu" icon="menu"
@@ -571,51 +502,19 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
) )
} }
function TitlebarUpdatePill(props: { update?: TitlebarUpdate }) { function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) {
const language = useLanguage()
const version = () => props.update?.version()
return (
<Show when={version() !== undefined}>
<button
type="button"
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-accent)] px-2.5 text-[11px] font-[530] leading-[1.1] tracking-[-0.04px] text-[var(--v2-text-text-contrast)] disabled:opacity-60"
onClick={() => props.update?.install()}
disabled={props.update?.installing()}
aria-label={language.t("toast.update.action.installRestart")}
title={version() ? `Update ${version()}` : undefined}
>
Update
</button>
</Show>
)
}
function DesktopTitlebarIconButton(props: Parameters<typeof IconButtonV2>[0]) {
return
}
function TabNavItem(props: {
href: string
title: string
project?: LocalProject
directory: string
hideClose?: boolean
onClose: () => void
}) {
const match = useMatch(() => props.href) const match = useMatch(() => props.href)
const isActive = () => !!match() const isActive = () => !!match()
return ( return (
<div <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 flex flex-row items-center max-w-60 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] bg-[var(--tab-bg)] h-7 rounded-[6px] relative overflow-hidden"
data-active={isActive()} data-active={isActive()}
> >
<a <a
href={props.href} href={props.href}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base" class="w-full h-full pl-1.5 flex-1 max-w-full flex flex-row items-center overflow-hidden font-medium"
> >
<ProjectTabAvatar project={props.project} directory={props.directory} /> {props.title}
<span class="text-clip">{props.title}</span>
</a> </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 right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
@@ -631,60 +530,23 @@ function TabNavItem(props: {
variant="ghost-muted" 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"
onClick={props.onClose} onClick={props.onClose}
icon={<IconV2 name="xmark-small" />} icon={
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
</svg>
}
/> />
</div> </div>
</div> </div>
) )
} }
function ProjectTabAvatar(props: { project?: LocalProject; directory: string }) {
return (
<AvatarV2
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
kind="org"
size="small"
{...getAvatarColors(props.project?.icon?.color)}
class="size-4 rounded"
/>
)
}
function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
return (
<div class="group relative flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]">
<a
href={props.href}
aria-current="page"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
}
function ChannelIndicator() { function ChannelIndicator() {
return ( return (
<> <>
@@ -2,8 +2,6 @@ import { Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu" import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
@@ -12,7 +10,6 @@ import { usePlatform } from "@/context/platform"
export function WindowsAppMenu(props: { export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand> command: ReturnType<typeof useCommand>
platform: ReturnType<typeof usePlatform> platform: ReturnType<typeof usePlatform>
variant?: "legacy" | "v2"
}) { }) {
let lastFocused: HTMLElement | undefined let lastFocused: HTMLElement | undefined
@@ -48,32 +45,15 @@ export function WindowsAppMenu(props: {
return ( return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start"> <DropdownMenu gutter={4} modal={false} placement="bottom-start">
{props.variant === "v2" ? ( <DropdownMenu.Trigger
<div as={IconButton}
data-component="desktop-icon-button" icon="menu"
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1" variant="ghost"
> class="titlebar-icon rounded-md shrink-0"
<DropdownMenu.Trigger aria-label="OpenCode menu"
as={IconButtonV2} onPointerDown={rememberFocus}
variant="ghost-muted" onKeyDown={rememberFocus}
size="large" />
icon={<IconV2 name="menu" />}
aria-label="OpenCode menu"
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
</div>
) : (
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label="OpenCode menu"
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
)}
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu"> <DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group> <DropdownMenu.Group>
+2 -24
View File
@@ -9,23 +9,13 @@ type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] } type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type SaveFilePickerOptions = { title?: string; defaultPath?: string } type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string } type UpdateInfo = { updateAvailable: boolean; version?: string }
type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux"
export type FatalRendererErrorLog = {
error: string
url: string
version?: string
platform: PlatformName
os?: DesktopOS
}
export type Platform = { export type Platform = {
/** Platform discriminator */ /** Platform discriminator */
platform: PlatformName platform: "web" | "desktop"
/** Desktop OS (Tauri only) */ /** Desktop OS (Tauri only) */
os?: DesktopOS os?: "macos" | "windows" | "linux"
/** App version */ /** App version */
version?: string version?: string
@@ -93,12 +83,6 @@ export type Platform = {
/** Webview zoom level (desktop only) */ /** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number> webviewZoom?: Accessor<number>
/** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */
getPinchZoomEnabled?(): Promise<boolean> | boolean
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
/** Run a desktop-only menu action from the app chrome */ /** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
@@ -107,12 +91,6 @@ export type Platform = {
/** Read image from clipboard (desktop only) */ /** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise<File | null> readClipboardImage?(): Promise<File | null>
/** Export collected diagnostic logs (desktop only) */
exportDebugLogs?(): Promise<string>
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
} }
export type DisplayBackend = "auto" | "wayland" export type DisplayBackend = "auto" | "wayland"
-2
View File
@@ -80,7 +80,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, { type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", label: "Reload Webview", action: "view.reload" }, { type: "item", label: "Reload Webview", action: "view.reload" },
{ type: "item", label: "Restart", action: "app.relaunch" }, { type: "item", label: "Restart", action: "app.relaunch" },
{ type: "item", label: "Export Logs...", command: "logs.export" },
{ type: "separator" }, { type: "separator" },
{ type: "item", role: "hide" }, { type: "item", role: "hide" },
{ type: "item", role: "hideOthers" }, { type: "item", role: "hideOthers" },
@@ -202,7 +201,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [
items: [ items: [
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" }, { type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" }, { type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
{ type: "item", label: "Export Logs...", command: "logs.export" },
{ type: "separator" }, { type: "separator" },
{ {
type: "item", type: "item",
-11
View File
@@ -470,7 +470,6 @@ export const dict = {
"error.page.action.restart": "Restart", "error.page.action.restart": "Restart",
"error.page.action.report": "Report Error", "error.page.action.report": "Report Error",
"error.page.action.reported": "Error Reported", "error.page.action.reported": "Error Reported",
"error.page.action.exportLogs": "Export Logs",
"error.page.action.checking": "Checking...", "error.page.action.checking": "Checking...",
"error.page.action.checkUpdates": "Check for updates", "error.page.action.checkUpdates": "Check for updates",
"error.page.action.updateTo": "Update to {{version}}", "error.page.action.updateTo": "Update to {{version}}",
@@ -526,14 +525,6 @@ export const dict = {
"home.recentProjects": "Recent projects", "home.recentProjects": "Recent projects",
"home.empty.title": "No recent projects", "home.empty.title": "No recent projects",
"home.empty.description": "Get started by opening a local project", "home.empty.description": "Get started by opening a local project",
"home.title": "Home",
"home.projects": "Projects",
"home.project.add": "Add project",
"home.sessions.search.placeholder": "Search sessions",
"home.sessions.empty": "No sessions found",
"home.sessions.group.today": "Today",
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
"session.tab.session": "Session", "session.tab.session": "Session",
"session.tab.review": "Review", "session.tab.review": "Review",
@@ -784,8 +775,6 @@ export const dict = {
"settings.general.row.showSessionProgressBar.title": "Show session progress bar", "settings.general.row.showSessionProgressBar.title": "Show session progress bar",
"settings.general.row.showSessionProgressBar.description": "settings.general.row.showSessionProgressBar.description":
"Display the animated progress bar at the top of the session when the agent is working", "Display the animated progress bar at the top of the session when the agent is working",
"settings.general.row.pinchZoom.title": "Pinch to zoom",
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
"settings.general.row.wayland.title": "Use native Wayland", "settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.", "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
-8
View File
@@ -498,14 +498,6 @@ export const dict = {
"home.recentProjects": "最近项目", "home.recentProjects": "最近项目",
"home.empty.title": "没有最近项目", "home.empty.title": "没有最近项目",
"home.empty.description": "通过打开本地项目开始使用", "home.empty.description": "通过打开本地项目开始使用",
"home.title": "主页",
"home.projects": "项目",
"home.project.add": "添加项目",
"home.sessions.search.placeholder": "搜索会话",
"home.sessions.empty": "未找到会话",
"home.sessions.group.today": "今天",
"home.sessions.group.yesterday": "昨天",
"home.sessions.group.older": "更早",
"session.tab.session": "会话", "session.tab.session": "会话",
"session.tab.review": "审查", "session.tab.review": "审查",
+2
View File
@@ -43,7 +43,9 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 999px; border-radius: 999px;
background: var(--session-progress-color);
clip-path: inset(0 100% 0 0 round 999px); clip-path: inset(0 100% 0 0 round 999px);
animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
will-change: clip-path; will-change: clip-path;
} }
+1 -1
View File
@@ -2,6 +2,6 @@ export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command" export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server" export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click" export { handleNotificationClick } from "./utils/notification-click"
+2 -36
View File
@@ -2,7 +2,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import * as Sentry from "@sentry/solid" import * as Sentry from "@sentry/solid"
import { Logo } from "@opencode-ai/ui/logo" import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Component, createSignal, onMount, Show } from "solid-js" import { Component, createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -221,30 +221,12 @@ interface ErrorPageProps {
export const ErrorPage: Component<ErrorPageProps> = (props) => { export const ErrorPage: Component<ErrorPageProps> = (props) => {
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const formattedError = () => formatError(props.error, language.t)
let recordedFatalError: Promise<void> | undefined
const [store, setStore] = createStore({ const [store, setStore] = createStore({
checking: false, checking: false,
version: undefined as string | undefined, version: undefined as string | undefined,
actionError: undefined as string | undefined, actionError: undefined as string | undefined,
}) })
function ensureFatalErrorRecorded() {
recordedFatalError ??=
platform.recordFatalRendererError?.({
error: formattedError(),
url: location.href,
version: platform.version,
platform: platform.platform,
os: platform.os,
}) ?? Promise.resolve()
return recordedFatalError
}
onMount(() => {
void ensureFatalErrorRecorded().catch(() => undefined)
})
async function checkForUpdates() { async function checkForUpdates() {
if (!platform.checkUpdate) return if (!platform.checkUpdate) return
setStore("checking", true) setStore("checking", true)
@@ -272,17 +254,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
}) })
} }
async function exportDebugLogs() {
const exportLogs = platform.exportDebugLogs
if (!exportLogs) return
await ensureFatalErrorRecorded()
.then(() => exportLogs())
.then(() => setStore("actionError", undefined))
.catch((err) => {
setStore("actionError", formatError(err, language.t))
})
}
return ( return (
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans"> <div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8"> <div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
@@ -292,7 +263,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<p class="text-sm text-text-weak">{language.t("error.page.description")}</p> <p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
</div> </div>
<TextField <TextField
value={formattedError()} value={formatError(props.error, language.t)}
readOnly readOnly
copyable copyable
multiline multiline
@@ -304,11 +275,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<Button size="large" onClick={platform.restart}> <Button size="large" onClick={platform.restart}>
{language.t("error.page.action.restart")} {language.t("error.page.action.restart")}
</Button> </Button>
<Show when={platform.platform === "desktop" && platform.exportDebugLogs}>
<Button size="large" variant="ghost" onClick={exportDebugLogs}>
{language.t("error.page.action.exportLogs")}
</Button>
</Show>
<Show when={Sentry.isEnabled}> <Show when={Sentry.isEnabled}>
{(_) => { {(_) => {
const [reported, setReported] = createSignal(false) const [reported, setReported] = createSignal(false)
+58 -626
View File
@@ -1,15 +1,7 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import { createMemo, For, Match, Switch } from "solid-js"
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo" import { Logo } from "@opencode-ai/ui/logo"
import { Spinner } from "@opencode-ai/ui/spinner" import { useLayout } from "@/context/layout"
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
import { ButtonV2 } from "@opencode-ai/ui/v2/components/button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -18,44 +10,11 @@ import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server" import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
import { getFilename } from "@opencode-ai/core/util/path"
import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
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 [font-weight:530] 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"
const HOME_PROJECT_NAV_ROW = `${HOME_ROW} h-8 gap-1.5 px-3 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap`
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
type HomeSessionRecord = {
session: Session
project: LocalProject
projectName: string
}
type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
title: string
sessions: HomeSessionRecord[]
}
export default function Home() { export default function Home() {
if (USE_HOME_DESIGN) return <HomeDesign />
return <LegacyHome />
}
function HomeDesign() {
const sync = useGlobalSync() const sync = useGlobalSync()
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
@@ -63,415 +22,6 @@ function HomeDesign() {
const navigate = useNavigate() const navigate = useNavigate()
const server = useServer() const server = useServer()
const language = useLanguage() const language = useLanguage()
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 projectDirectories = createMemo(() => {
const project = selectedProject()
if (!project) return []
return [project.worktree, ...(project.sandboxes ?? [])]
})
const search = createMemo(() => state.search.trim())
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", ...projectDirectories()] as const,
queryFn: async () => {
await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory)))
return null
},
}))
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const records = createMemo(() =>
[
...new Map(
projectDirectories()
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
.map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
).values(),
]
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.flatMap((session) => {
const project = projectForSession(session, projects(), projectByID())
if (!project) return []
return {
session,
project,
projectName: displayName(project),
}
})
.filter((record) => {
const value = search().toLowerCase()
if (!value) return true
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
})
.slice(0, HOME_SESSION_LIMIT),
)
const groups = createMemo(() => groupSessions(records(), language))
function selectProject(directory: string) {
if (!projects().some((project) => project.worktree === directory)) return
setState("project", directory)
}
function addProject(directory: string) {
layout.projects.open(directory)
server.projects.touch(directory)
setState("project", directory)
}
function openNewSession() {
const project = selectedProject()
if (!project) {
void chooseProject()
return
}
layout.projects.open(project.worktree)
server.projects.touch(project.worktree)
navigate(`/${base64Encode(project.worktree)}/session`)
}
function openSession(session: Session) {
const project = projectForSession(session, projects(), projectByID())
layout.projects.open(project?.worktree ?? session.directory)
server.projects.touch(project?.worktree ?? session.directory)
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
}
async function chooseProject() {
function resolve(result: string | string[] | null) {
if (Array.isArray(result)) {
result.forEach(addProject)
if (result[0]) setState("project", result[0])
return
}
if (result) addProject(result)
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
})
resolve(result)
return
}
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
}
function openSettings() {
void import("@/components/dialog-settings").then((x) => {
dialog.show(() => <x.DialogSettings />)
})
}
return (
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
<HomeProjectColumn
projects={projects()}
selected={selectedProject()?.worktree}
selectProject={selectProject}
chooseProject={() => void chooseProject()}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
<section
class="min-w-0 flex-1 flex flex-col overflow-y-hidden pt-12"
aria-label={language.t("sidebar.project.recentSessions")}
>
<HomeSessionSearch
value={state.search}
placeholder={language.t("home.sessions.search.placeholder")}
onInput={(value) => setState("search", value)}
/>
<div class="mt-3 overflow-auto flex-1">
<div class="pt-3 flex flex-col gap-6">
<Show when={!sessionLoad.isLoading} fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}>
<Show
when={groups().length > 0}
fallback={
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader title={language.t("home.sessions.empty")} onNewSession={openNewSession} />
</div>
}
>
<For each={groups()}>
{(group, index) => (
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader
title={group.title}
onNewSession={index() === 0 ? openNewSession : undefined}
/>
<div class="flex min-w-0 flex-col gap-px">
<For each={group.sessions}>
{(record) => <HomeSessionRow record={record} openSession={openSession} />}
</For>
</div>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
</section>
</div>
)
}
function HomeProjectColumn(props: {
projects: LocalProject[]
selected?: string
selectProject: (directory: string) => void
chooseProject: () => void
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
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">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<IconV2 name="folder-add-left" />}
onClick={props.chooseProject}
aria-label={props.language.t("home.project.add")}
/>
</div>
<div class="mt-4 flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Show
when={props.projects.length > 0}
fallback={
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.chooseProject}
>
<IconV2 name="folder-add-left" size="small" />
<span>{props.language.t("home.project.add")}</span>
</button>
}
>
<For each={props.projects}>
{(project) => (
<button
type="button"
data-component="home-project-row"
class={HOME_PROJECT_NAV_ROW}
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected === project.worktree }}
data-selected={props.selected === project.worktree ? "" : undefined}
aria-current={props.selected === project.worktree ? "page" : undefined}
onClick={() => props.selectProject(project.worktree)}
>
<HomeProjectAvatar project={project} />
<span>{displayName(project)}</span>
</button>
)}
</For>
</Show>
</div>
<div class="mt-4 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`}
onClick={props.openSettings}
>
<IconV2 name="settings-gear" size="small" />
<span>{props.language.t("sidebar.settings")}</span>
</button>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openHelp}
>
<IconV2 name="help" size="small" />
<span>{props.language.t("sidebar.help")}</span>
</button>
</div>
</aside>
)
}
function HomeProjectAvatar(props: { project: LocalProject }) {
const name = createMemo(() => displayName(props.project))
return (
<AvatarV2
fallback={name()}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
kind="org"
size="small"
{...getAvatarColors(props.project.icon?.color)}
class="size-4 rounded"
/>
)
}
function HomeSessionSearch(props: { value: string; placeholder: string; onInput: (value: string) => void }) {
return (
<label class="ml-4 flex h-9 w-[calc(100%_-_48px)] sticky top-0 inset-x-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-deep px-3 py-1 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out focus-within:bg-v2-background-bg-base focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]">
<IconV2 name="magnifying-glass" size="small" />
<input
class="min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
value={props.value}
placeholder={props.placeholder}
aria-label={props.placeholder}
onInput={(event) => props.onInput(event.currentTarget.value)}
/>
</label>
)
}
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
const language = useLanguage()
return (
<div class="flex h-7 min-w-0 items-center justify-between px-4">
<div class={HOME_SECTION_LABEL}>{props.title}</div>
<Show when={props.onNewSession}>
{(onNewSession) => (
<ButtonV2
data-action="home-new-session"
variant="ghost"
size="normal"
icon="edit"
class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
onClick={onNewSession()}
>
{language.t("command.session.new")}
</ButtonV2>
)}
</Show>
</div>
)
}
function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) {
const globalSync = useGlobalSync()
const notification = useNotification()
const permission = usePermission()
const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
const hasPermissions = createMemo(
() =>
!!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => {
return !permission.autoResponds(item, props.record.session.directory)
}),
)
const isWorking = createMemo(() => {
if (hasPermissions()) return false
return sessionStore.session_working(props.record.session.id)
})
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent))
const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0)
return (
<button
type="button"
data-component="home-session-row"
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
onClick={() => props.openSession(props.record.session)}
>
<Show when={showStatus()}>
<div
class="flex size-4 shrink-0 items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
</Show>
<span
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
{title()}
</span>
<Show when={props.record.projectName}>
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
{props.record.projectName}
</span>
</Show>
</button>
)
}
function HomeSessionSkeleton(props: { label: string }) {
return (
<div class="flex min-w-0 flex-col gap-4">
<div class="flex h-7 min-w-0 items-center justify-between px-4">
<div class={HOME_SECTION_LABEL}>{props.label}</div>
</div>
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
<For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
</div>
</div>
)
}
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
const now = DateTime.local()
const yesterday = now.minus({ days: 1 })
const todaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
)
const yesterdaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
)
const olderSessions = records.filter((record) => {
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
})
const olderTitle =
todaySessions.length === 0 && yesterdaySessions.length === 0
? language.t("sidebar.project.recentSessions")
: language.t("home.sessions.group.older")
return [
{ id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
{ id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
{ id: "older" as const, title: olderTitle, sessions: olderSessions },
].filter((group) => group.sessions.length > 0)
}
function LegacyHome() {
const sync = useGlobalSync()
const layout = useLayout()
const platform = usePlatform()
const dialog = useDialog()
const navigate = useNavigate()
const server = useServer()
const language = useLanguage()
const [promptText, setPromptText] = createSignal("")
const [selectedAgent, setSelectedAgent] = createSignal("frontend-specialist")
const [showProjectsDropdown, setShowProjectsDropdown] = createSignal(false)
const homedir = createMemo(() => sync.data.path.home) const homedir = createMemo(() => sync.data.path.home)
const recent = createMemo(() => { const recent = createMemo(() => {
return sync.data.project return sync.data.project
@@ -480,8 +30,6 @@ function LegacyHome() {
.slice(0, 5) .slice(0, 5)
}) })
const currentProject = createMemo(() => recent()[0]?.worktree)
const serverDotClass = createMemo(() => { const serverDotClass = createMemo(() => {
const healthy = server.healthy() const healthy = server.healthy()
if (healthy === true) return "bg-icon-success-base" if (healthy === true) return "bg-icon-success-base"
@@ -520,185 +68,69 @@ function LegacyHome() {
} }
} }
function handleModelSelect() {
dialog.show(() => <DialogSelectModel />)
}
function toggleAgent() {
const agents = ["frontend-specialist", "build", "general"]
const nextIndex = (agents.indexOf(selectedAgent()) + 1) % agents.length
setSelectedAgent(agents[nextIndex])
}
function handleSubmit() {
const projectToOpen = currentProject()
if (projectToOpen) {
openProject(projectToOpen)
} else {
chooseProject()
}
}
const activeModelName = createMemo(() => {
const model = sync.data.config.model
if (!model) return "GPT-5.7 Pro"
const parts = model.split("/")
return parts[parts.length - 1]
})
return ( return (
<div class="mx-auto mt-24 w-full max-w-2xl px-6 flex flex-col items-center"> <div class="mx-auto mt-55 w-full md:w-auto px-4">
<div class="flex flex-col items-center gap-3 mb-10"> <Logo class="md:w-xl opacity-12" />
<div onClick={chooseProject} class="cursor-pointer hover:opacity-25 transition-opacity duration-200"> <Button
<Logo class="w-48 opacity-15" /> size="large"
</div> variant="ghost"
<Button class="mt-4 mx-auto text-14-regular text-text-weak"
size="normal" onClick={() => dialog.show(() => <DialogSelectServer />)}
variant="ghost" >
class="text-12-regular text-text-weak px-3" <div
onClick={() => dialog.show(() => <DialogSelectServer />)} classList={{
> "size-2 rounded-full": true,
<div [serverDotClass()]: true,
classList={{ }}
"size-1.5 rounded-full mr-2": true, />
[serverDotClass()]: true, {server.name}
}} </Button>
/>
{server.name}
</Button>
</div>
<Switch> <Switch>
<Match when={recent().length > 0}> <Match when={sync.data.project.length > 0}>
<div class="w-full flex flex-col items-center gap-6"> <div class="mt-20 w-full flex flex-col gap-4">
<div class="text-20-medium text-text-strong text-center">{language.t("session.new.title")}</div> <div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
<div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md relative"> <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
<textarea {language.t("command.project.open")}
class="bg-transparent border-none outline-none text-14-regular text-text-base placeholder-text-weak w-full resize-none h-20 focus:outline-none" </Button>
placeholder="Ask anything, / for commands, @ for context..."
value={promptText()}
onInput={(e) => setPromptText(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}}
/>
<div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={toggleAgent}
>
<Icon name="sliders" size="small" class="shrink-0" />
<span>Agent: {selectedAgent()}</span>
</Button>
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={handleModelSelect}
>
<Icon name="brain" size="small" class="shrink-0" />
<span>Model: {activeModelName()}</span>
</Button>
<div class="relative">
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={() => setShowProjectsDropdown(!showProjectsDropdown())}
>
<Icon name="folder" size="small" class="shrink-0" />
<span>Project: {currentProject() ? getFilename(currentProject()) : "Select Project"}</span>
</Button>
<Show when={showProjectsDropdown()}>
<div class="absolute left-0 mt-1 w-64 bg-surface-raised-base border border-border-base rounded-lg p-2 shadow-lg z-50 flex flex-col gap-1">
<div class="text-10-semibold text-text-weak px-2 py-1 uppercase tracking-wider">
{language.t("home.recentProjects")}
</div>
<For each={recent()}>
{(project) => (
<button
class="text-12-mono text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center justify-between w-full"
onClick={() => {
openProject(project.worktree)
setShowProjectsDropdown(false)
}}
>
<span class="truncate">{getFilename(project.worktree)}</span>
<span class="text-10-regular text-text-weak shrink-0 pl-2">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</span>
</button>
)}
</For>
<div class="border-t border-border-weak-base my-1" />
<button
class="text-12-medium text-text-strong text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center gap-2 w-full"
onClick={() => {
setShowProjectsDropdown(false)
chooseProject()
}}
>
<Icon name="folder-add-left" size="small" />
{language.t("command.project.open")}
</button>
</div>
</Show>
</div>
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base border border-border-weak-base rounded-md cursor-default pointer-events-none"
>
<Icon name="branch" size="small" class="shrink-0" />
<span>Branch: dev</span>
</Button>
</div>
</div> </div>
<ul class="flex flex-col gap-2">
<For each={recent()}>
{(project) => (
<Button
size="large"
variant="ghost"
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(project.worktree)}
>
{project.worktree.replace(homedir(), "~")}
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>
</Button>
)}
</For>
</ul>
</div>
</Match>
<Match when={!sync.ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div> </div>
</Match> </Match>
<Match when={true}> <Match when={true}>
<div class="w-full flex flex-col items-center gap-6"> <div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-20-medium text-text-strong text-center">{language.t("home.empty.title")}</div> <Icon name="folder-add-left" size="large" />
<div class="flex flex-col gap-1 items-center justify-center">
<div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md"> <div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
<div class="text-14-regular text-text-weak w-full min-h-[4rem] cursor-pointer" onClick={chooseProject}> <div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
Ask anything, / for commands, @ for context...
</div>
<div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={chooseProject}
>
<Icon name="folder" size="small" class="shrink-0" />
<span>Open project</span>
</Button>
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={handleModelSelect}
>
<Icon name="brain" size="small" class="shrink-0" />
<span>Model: {activeModelName()}</span>
</Button>
</div>
</div> </div>
<Button class="px-3 mt-1" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div> </div>
</Match> </Match>
</Switch> </Switch>
+58 -109
View File
@@ -14,7 +14,6 @@ import {
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useQuery } from "@tanstack/solid-query"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -62,7 +61,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar" import { DebugBar } from "@/components/debug-bar"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language" import { useLanguage, type Locale } from "@/context/language"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
@@ -89,8 +88,6 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell" import { SidebarContent } from "./layout/sidebar-shell"
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted( const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]), Persist.global("layout.page", ["layout.page.v1"]),
@@ -154,7 +151,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir) const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory && !USE_HOME_DESIGN, autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>, busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined, hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined, scrollSessionKey: undefined as string | undefined,
@@ -165,35 +162,6 @@ export default function Layout(props: ParentProps) {
peeked: false, peeked: false,
}) })
const [update, setUpdate] = createStore({
installing: false,
})
const updateQuery = useQuery(() => ({
queryKey: ["desktop", "update"] as const,
enabled: () =>
!!platform.checkUpdate && !!platform.updateAndRestart && settings.ready() && settings.updates.startup(),
queryFn: () => platform.checkUpdate?.() ?? Promise.resolve({ updateAvailable: false, version: undefined }),
refetchInterval: (query) => (query.state.data?.updateAvailable ? false : 10 * 60 * 1000),
}))
const updateVersion = () => {
if (!settings.ready()) return
if (!settings.updates.startup()) return
if (!updateQuery.data?.updateAvailable) return
return updateQuery.data.version ?? ""
}
const installUpdate = () => {
if (!platform.updateAndRestart) return
setUpdate("installing", true)
void platform.updateAndRestart().catch(() => {
setUpdate("installing", false)
})
}
const titlebarUpdate: TitlebarUpdate = {
version: updateVersion,
installing: () => update.installing,
install: installUpdate,
}
const editor = createInlineEditorController() const editor = createInlineEditorController()
const setBusy = (directory: string, value: boolean) => { const setBusy = (directory: string, value: boolean) => {
const key = pathKey(directory) const key = pathKey(directory)
@@ -396,6 +364,58 @@ export default function Layout(props: ParentProps) {
setLocale(next) setLocale(next)
} }
const useUpdatePolling = () =>
onMount(() => {
if (!platform.checkUpdate || !platform.updateAndRestart) return
let toastId: number | undefined
let interval: ReturnType<typeof setInterval> | undefined
const pollUpdate = () =>
platform.checkUpdate!().then(({ updateAvailable, version }) => {
if (!updateAvailable) return
if (toastId !== undefined) return
toastId = showToast({
persistent: true,
icon: "download",
title: language.t("toast.update.title"),
description: language.t("toast.update.description", { version: version ?? "" }),
actions: [
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.updateAndRestart!()
},
},
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss",
},
],
})
})
createEffect(() => {
if (!settings.ready()) return
if (!settings.updates.startup()) {
if (interval === undefined) return
clearInterval(interval)
interval = undefined
return
}
if (interval !== undefined) return
void pollUpdate()
interval = setInterval(pollUpdate, 10 * 60 * 1000)
})
onCleanup(() => {
if (interval === undefined) return
clearInterval(interval)
})
})
const useSDKNotificationToasts = () => const useSDKNotificationToasts = () =>
onMount(() => { onMount(() => {
const toastBySession = new Map<string, number>() const toastBySession = new Map<string, number>()
@@ -515,6 +535,7 @@ export default function Layout(props: ParentProps) {
}) })
}) })
useUpdatePolling()
useSDKNotificationToasts() useSDKNotificationToasts()
function scrollToSession(sessionId: string, sessionKey: string) { function scrollToSession(sessionId: string, sessionKey: string) {
@@ -1060,18 +1081,6 @@ export default function Layout(props: ParentProps) {
keybind: "mod+comma", keybind: "mod+comma",
onSelect: () => openSettings(), onSelect: () => openSettings(),
}, },
...(platform.platform === "desktop" && platform.exportDebugLogs
? [
{
id: "logs.export",
title: "Export logs",
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
},
]
: []),
{ {
id: "session.previous", id: "session.previous",
title: language.t("command.session.previous"), title: language.t("command.session.previous"),
@@ -1817,10 +1826,8 @@ export default function Layout(props: ParentProps) {
) )
createEffect(() => { createEffect(() => {
document.documentElement.style.setProperty( const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
"--dialog-left-margin", document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
USE_HOME_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
)
}) })
const side = createMemo(() => Math.max(layout.sidebar.width(), 244)) const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
@@ -2361,34 +2368,10 @@ export default function Layout(props: ParentProps) {
/> />
) )
if (USE_HOME_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">
{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 ( 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"> <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() ?? ""} {autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} /> <Titlebar />
<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 min-w-0 flex">
<div class="flex-1 min-h-0 relative"> <div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden"> <div class="size-full relative overflow-x-hidden">
@@ -2536,37 +2519,3 @@ export default function Layout(props: ParentProps) {
</div> </div>
) )
} }
function UpdateAvailableToast(props: {
version: string
install: () => void
language: ReturnType<typeof useLanguage>
}) {
let toastId: number | undefined
onMount(() => {
toastId = showToast({
persistent: true,
icon: "download",
title: props.language.t("toast.update.title"),
description: props.language.t("toast.update.description", { version: props.version }),
actions: [
{
label: props.language.t("toast.update.action.installRestart"),
onClick: props.install,
},
{
label: props.language.t("toast.update.action.notYet"),
onClick: "dismiss",
},
],
})
})
onCleanup(() => {
if (toastId === undefined) return
toaster.dismiss(toastId)
})
return null
}
-23
View File
@@ -55,29 +55,6 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
export const displayName = (project: { name?: string; worktree: string }) => export const displayName = (project: { name?: string; worktree: string }) =>
project.name || getFilename(project.worktree) project.name || getFilename(project.worktree)
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
if (icon?.override) return icon.override
if (icon?.color) return undefined
return icon?.url
}
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
session: Session,
projects: T[],
byID: Map<string, T>,
) {
const direct = byID.get(session.projectID)
if (direct) return direct
const directory = pathKey(session.directory)
return projects.find(
(project) =>
pathKey(project.worktree) === directory || project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
)
}
export const errorMessage = (err: unknown, fallback: string) => { export const errorMessage = (err: unknown, fallback: string) => {
if (err && typeof err === "object" && "data" in err) { if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data const data = (err as { data?: { message?: string } }).data
@@ -15,7 +15,16 @@ import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title" import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers" import { childSessionOnPath, hasProjectPermissions } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
if (icon?.override) return icon?.override
if (icon?.color) return undefined
return icon?.url
}
export const ProjectIcon = (props: { export const ProjectIcon = (props: {
project: LocalProject project: LocalProject
+51 -62
View File
@@ -30,7 +30,7 @@ import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { checksum } from "@opencode-ai/core/util/encode" import { checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router" import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session" import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
@@ -73,7 +73,6 @@ const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn" type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch" type VcsMode = "git" | "branch"
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type SessionHistoryWindowInput = { type SessionHistoryWindowInput = {
sessionID: () => string | undefined sessionID: () => string | undefined
@@ -1649,60 +1648,6 @@ export default function Page() {
useUsageExceededDialogs() useUsageExceededDialogs()
const composerRegion = (placement: "dock" | "inline") => (
<SessionComposerRegion
state={composer}
ready={!store.deferRender && messagesReady()}
centered={placement === "dock" && centered()}
placement={placement}
inputRef={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeChange={(value) => setStore("newSessionWorktree", value)}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
resumeScroll()
}}
onResponseSubmit={resumeScroll}
followup={
params.id && !isChildSession()
? {
queue: queueEnabled,
items: followupDock(),
sending: sendingFollowup(),
edit: editingFollowup(),
onQueue: queueFollowup,
onAbort: () => {
const id = params.id
if (!id) return
setFollowup("paused", id, true)
},
onSend: (id) => {
void sendFollowup(params.id!, id, { manual: true })
},
onEdit: editFollowup,
onEditLoaded: clearFollowupEdit,
}
: undefined
}
revert={
rolled().length > 0
? {
items: rolled(),
restoring: restoring(),
disabled: reverting(),
onRestore: restore,
}
: undefined
}
setPromptDockRef={(el) => {
promptDock = el
}}
/>
)
return ( return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col"> <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
{sessionSync() ?? ""} {sessionSync() ?? ""}
@@ -1795,16 +1740,60 @@ export default function Page() {
</Show> </Show>
</Match> </Match>
<Match when={true}> <Match when={true}>
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}> <NewSessionView worktree={newSessionWorktree()} />
<NewSessionDesignView worktree={newSessionWorktree()}>
{composerRegion("inline")}
</NewSessionDesignView>
</Show>
</Match> </Match>
</Switch> </Switch>
</div> </div>
<Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show> <SessionComposerRegion
state={composer}
ready={!store.deferRender && messagesReady()}
centered={centered()}
inputRef={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
resumeScroll()
}}
onResponseSubmit={resumeScroll}
followup={
params.id && !isChildSession()
? {
queue: queueEnabled,
items: followupDock(),
sending: sendingFollowup(),
edit: editingFollowup(),
onQueue: queueFollowup,
onAbort: () => {
const id = params.id
if (!id) return
setFollowup("paused", id, true)
},
onSend: (id) => {
void sendFollowup(params.id!, id, { manual: true })
},
onEdit: editFollowup,
onEditLoaded: clearFollowupEdit,
}
: undefined
}
revert={
rolled().length > 0
? {
items: rolled(),
restoring: restoring(),
disabled: reverting(),
onRestore: restore,
}
: undefined
}
setPromptDockRef={(el) => {
promptDock = el
}}
/>
<Show when={desktopReviewOpen()}> <Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}> <div onPointerDown={() => size.start()}>
@@ -22,10 +22,8 @@ export function SessionComposerRegion(props: {
state: SessionComposerState state: SessionComposerState
ready: boolean ready: boolean
centered: boolean centered: boolean
placement?: "dock" | "inline"
inputRef: (el: HTMLDivElement) => void inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string newSessionWorktree: string
onNewSessionWorktreeChange?: (worktree: string) => void
onNewSessionWorktreeReset: () => void onNewSessionWorktreeReset: () => void
onSubmit: () => void onSubmit: () => void
onResponseSubmit: () => void onResponseSubmit: () => void
@@ -144,15 +142,11 @@ export function SessionComposerRegion(props: {
<div <div
ref={props.setPromptDockRef} ref={props.setPromptDockRef}
data-component="session-prompt-dock" data-component="session-prompt-dock"
classList={{ class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
"w-full flex flex-col justify-center items-center pointer-events-none": true,
"shrink-0 pb-3 bg-background-stronger": props.placement !== "inline",
}}
> >
<div <div
classList={{ classList={{
"w-full px-3 pointer-events-auto": true, "w-full px-3 pointer-events-auto": true,
"max-w-[720px] px-0": props.placement === "inline",
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
@@ -262,10 +256,8 @@ export function SessionComposerRegion(props: {
fallback={ fallback={
<Show when={!props.state.blocked()}> <Show when={!props.state.blocked()}>
<PromptInput <PromptInput
variant={props.placement === "inline" ? "new-session" : undefined}
ref={props.inputRef} ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree} newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeChange={props.onNewSessionWorktreeChange}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit} edit={props.followup?.edit}
onEditLoaded={props.followup?.onEditLoaded} onEditLoaded={props.followup?.onEditLoaded}
@@ -1236,6 +1236,7 @@ export function MessageTimeline(props: {
onClick={props.onAutoScrollInteraction} onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full" class="relative min-w-0 w-full h-full"
style={{ style={{
"--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px", "--sticky-accordion-top": showHeader() ? "48px" : "0px",
}} }}
> >
@@ -1255,14 +1256,16 @@ export function MessageTimeline(props: {
}} }}
> >
<Show when={workingStatus() !== "hidden" && settings.general.showSessionProgressBar()}> <Show when={workingStatus() !== "hidden" && settings.general.showSessionProgressBar()}>
<div data-component="session-progress" data-state={workingStatus()} aria-hidden="true"> <div
<div data-component="session-progress"
data-component="session-progress-bar" data-state={workingStatus()}
style={{ aria-hidden="true"
background: tint() ?? "var(--icon-interactive-base)", style={{
animation: `session-progress-whip ${bar.ms}ms infinite`, "--session-progress-color": tint() ?? "var(--icon-interactive-base)",
}} "--session-progress-ms": `${bar.ms}ms`,
/> }}
>
<div data-component="session-progress-bar" />
</div> </div>
</Show> </Show>
<div class="h-12 w-full flex items-center justify-between gap-2"> <div class="h-12 w-full flex items-center justify-between gap-2">
-10
View File
@@ -5,13 +5,6 @@ import { fileURLToPath } from "url"
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url)) const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (process.env.OPENCODE_CHANNEL === "latest") return "prod"
return "dev"
})()
/** /**
* @type {import("vite").PluginOption} * @type {import("vite").PluginOption}
*/ */
@@ -25,9 +18,6 @@ export default [
"@": fileURLToPath(new URL("./src", import.meta.url)), "@": fileURLToPath(new URL("./src", import.meta.url)),
}, },
}, },
define: {
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},
worker: { worker: {
format: "es", format: "es",
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.15.7", "version": "1.15.6",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -0,0 +1,45 @@
import { Resource, waitUntil } from "@opencode-ai/console-resource"
export function createDataDumper(sessionId: string, requestId: string, projectId: string) {
return
if (Resource.App.stage !== "production") return
if (sessionId === "") return
let data: Record<string, any> = { sessionId, requestId, projectId }
let metadata: Record<string, any> = { sessionId, requestId, projectId }
return {
provideModel: (model?: string) => {
data.modelName = model
metadata.modelName = model
},
provideRequest: (request: string) => (data.request = request),
provideResponse: (response: string) => (data.response = response),
provideStream: (chunk: string) => (data.response = (data.response ?? "") + chunk),
flush: () => {
if (!data.modelName) return
const timestamp = new Date().toISOString().replace(/[^0-9]/g, "")
const year = timestamp.substring(0, 4)
const month = timestamp.substring(4, 6)
const day = timestamp.substring(6, 8)
const hour = timestamp.substring(8, 10)
const minute = timestamp.substring(10, 12)
const second = timestamp.substring(12, 14)
void waitUntil(
Resource.ZenDataNew.put(
`data/${data.modelName}/${year}/${month}/${day}/${hour}/${minute}/${second}/${requestId}.json`,
JSON.stringify({ timestamp, ...data }),
),
)
void waitUntil(
Resource.ZenDataNew.put(
`meta/${data.modelName}/${sessionId}/${requestId}.json`,
JSON.stringify({ timestamp, ...metadata }),
),
)
},
}
}
@@ -39,6 +39,7 @@ import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible" import { oaCompatHelper } from "./provider/openai-compatible"
import { createRateLimiter as createIpRateLimiter } from "./ipRateLimiter" import { createRateLimiter as createIpRateLimiter } from "./ipRateLimiter"
import { createRateLimiter as createKeyRateLimiter } from "./keyRateLimiter" import { createRateLimiter as createKeyRateLimiter } from "./keyRateLimiter"
import { createDataDumper } from "./dataDumper"
import { createTrialLimiter } from "./trialLimiter" import { createTrialLimiter } from "./trialLimiter"
import { createStickyTracker } from "./stickyProviderTracker" import { createStickyTracker } from "./stickyProviderTracker"
import { LiteData } from "@opencode-ai/console-core/lite.js" import { LiteData } from "@opencode-ai/console-core/lite.js"
@@ -102,6 +103,7 @@ export async function handler(
const zenApiKey = rawZenApiKey === "public" ? undefined : rawZenApiKey const zenApiKey = rawZenApiKey === "public" ? undefined : rawZenApiKey
const sessionId = input.request.headers.get("x-opencode-session") ?? "" const sessionId = input.request.headers.get("x-opencode-session") ?? ""
const requestId = input.request.headers.get("x-opencode-request") ?? "" const requestId = input.request.headers.get("x-opencode-request") ?? ""
const projectId = input.request.headers.get("x-opencode-project") ?? ""
const ocClient = input.request.headers.get("x-opencode-client") ?? "" const ocClient = input.request.headers.get("x-opencode-client") ?? ""
const userAgent = input.request.headers.get("user-agent") ?? "" const userAgent = input.request.headers.get("user-agent") ?? ""
logger.metric({ logger.metric({
@@ -114,16 +116,16 @@ export async function handler(
}) })
const zenData = ZenData.list(opts.modelList) const zenData = ZenData.list(opts.modelList)
const modelInfo = validateModel(zenData, model) const modelInfo = validateModel(zenData, model)
const dataDumper = createDataDumper(sessionId, requestId, projectId)
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip) const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
const trialProviders = await trialLimiter?.check() const trialProviders = await trialLimiter?.check()
const rateLimiter = modelInfo.allowAnonymous const rateLimiter = modelInfo.allowAnonymous
? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request) ? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request)
: createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request) : createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request)
await rateLimiter?.check() await rateLimiter?.check()
const authInfo = await authenticate(modelInfo, zenApiKey) const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, sessionId)
const stickyId = sessionId ? sessionId : (authInfo?.workspaceID ?? ip)
const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, stickyId)
const stickyProvider = await stickyTracker?.get() const stickyProvider = await stickyTracker?.get()
const authInfo = await authenticate(modelInfo, zenApiKey)
const billingSource = validateBilling(authInfo, modelInfo) const billingSource = validateBilling(authInfo, modelInfo)
logger.metric({ source: billingSource }) logger.metric({ source: billingSource })
const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers) const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers)
@@ -137,7 +139,8 @@ export async function handler(
zenData, zenData,
authInfo, authInfo,
modelInfo, modelInfo,
stickyId, ip,
sessionId,
trialProviders, trialProviders,
retry, retry,
stickyProvider, stickyProvider,
@@ -164,8 +167,13 @@ export async function handler(
if (Array.isArray(v)) return [[k, v]] if (Array.isArray(v)) return [[k, v]]
if (typeof v === "object") return [[k, replacer(v)]] if (typeof v === "object") return [[k, replacer(v)]]
if (typeof v === "string") { if (typeof v === "string") {
if (v === "$ip") return [[k, ip]]
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
if (v === "$user") return stickyId ? [[k, stickyId]] : [] if (v === "$session") return sessionId ? [[k, sessionId]] : []
if (v === "$user") {
const user = sessionId ?? authInfo?.workspaceID ?? ip
return user ? [[k, user]] : []
}
if (v.startsWith("$header.")) { if (v.startsWith("$header.")) {
const headerValue = input.request.headers.get(v.slice(8)) const headerValue = input.request.headers.get(v.slice(8))
return headerValue ? [[k, headerValue]] : [] return headerValue ? [[k, headerValue]] : []
@@ -184,7 +192,7 @@ export async function handler(
method: "POST", method: "POST",
headers: (() => { headers: (() => {
const headers = new Headers(input.request.headers) const headers = new Headers(input.request.headers)
providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId) providerInfo.modifyHeaders(headers, body, providerInfo.apiKey)
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => { Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!) headers.set(k, headers.get(v)!)
}) })
@@ -229,6 +237,10 @@ export async function handler(
const { providerInfo, reqBody, res, startTimestamp } = await retriableRequest() const { providerInfo, reqBody, res, startTimestamp } = await retriableRequest()
// Store model request
dataDumper?.provideModel(providerInfo.storeModel)
dataDumper?.provideRequest(reqBody)
// Store sticky provider // Store sticky provider
if (res.status === 200) await stickyTracker?.set(providerInfo.id) if (res.status === 200) await stickyTracker?.set(providerInfo.id)
@@ -269,6 +281,8 @@ export async function handler(
const body = JSON.stringify(responseConverter(json)) const body = JSON.stringify(responseConverter(json))
logger.metric({ response_length: body.length }) logger.metric({ response_length: body.length })
logger.debug("RESPONSE: " + body) logger.debug("RESPONSE: " + body)
dataDumper?.provideResponse(body)
dataDumper?.flush()
return new Response(body, { return new Response(body, {
status: resStatus, status: resStatus,
statusText: res.statusText, statusText: res.statusText,
@@ -299,6 +313,7 @@ export async function handler(
response_length: responseLength, response_length: responseLength,
"timestamp.last_byte": timestampLastByte, "timestamp.last_byte": timestampLastByte,
}) })
dataDumper?.flush()
await rateLimiter?.track() await rateLimiter?.track()
const usage = usageParser.retrieve() const usage = usageParser.retrieve()
if (usage) { if (usage) {
@@ -336,6 +351,7 @@ export async function handler(
responseLength += value.length responseLength += value.length
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
dataDumper?.provideStream(buffer)
const parts = buffer.split(providerInfo.streamSeparator) const parts = buffer.split(providerInfo.streamSeparator)
buffer = parts.pop() ?? "" buffer = parts.pop() ?? ""
@@ -474,7 +490,8 @@ export async function handler(
zenData: ZenData, zenData: ZenData,
authInfo: AuthInfo, authInfo: AuthInfo,
modelInfo: ModelInfo, modelInfo: ModelInfo,
stickyId: string, ip: string,
sessionId: string,
trialProviders: string[] | undefined, trialProviders: string[] | undefined,
retry: RetryOptions, retry: RetryOptions,
stickyProvider: string | undefined, stickyProvider: string | undefined,
@@ -524,10 +541,11 @@ export async function handler(
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider)) .flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
// Use the last 4 characters of session ID to select a provider // Use the last 4 characters of session ID to select a provider
const identifier = sessionId.length ? sessionId : ip
let h = 0 let h = 0
const l = stickyId.length const l = identifier.length
for (let i = l - 4; i < l; i++) { for (let i = l - 4; i < l; i++) {
h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int h = (h * 31 + identifier.charCodeAt(i)) | 0 // 32-bit int
} }
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1 const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
const provider = providers[index || 0] const provider = providers[index || 0]
@@ -28,7 +28,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
isBedrock isBedrock
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}` ? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
: providerApi + "/messages", : providerApi + "/messages",
modifyHeaders: (headers: Headers, apiKey: string, _stickyId: string) => { modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
if (isBedrock || isDatabricks) { if (isBedrock || isDatabricks) {
headers.set("Authorization", `Bearer ${apiKey}`) headers.set("Authorization", `Bearer ${apiKey}`)
} else { } else {
@@ -30,7 +30,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
format: "google", format: "google",
modifyUrl: (providerApi: string, isStream?: boolean) => modifyUrl: (providerApi: string, isStream?: boolean) =>
`${providerApi}/models/${providerModel}:${isStream ? "streamGenerateContent?alt=sse" : "generateContent"}`, `${providerApi}/models/${providerModel}:${isStream ? "streamGenerateContent?alt=sse" : "generateContent"}`,
modifyHeaders: (headers: Headers, apiKey: string, _stickyId: string) => { modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("x-goog-api-key", apiKey) headers.set("x-goog-api-key", apiKey)
}, },
modifyBody: (body: Record<string, any>) => { modifyBody: (body: Record<string, any>) => {
@@ -26,9 +26,9 @@ type Usage = {
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
format: "oa-compat", format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions", modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, apiKey: string, stickyId: string) => { modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`) headers.set("authorization", `Bearer ${apiKey}`)
headers.set("x-session-affinity", stickyId) headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "")
}, },
modifyBody: (body: Record<string, any>, _workspaceID?: string) => { modifyBody: (body: Record<string, any>, _workspaceID?: string) => {
return { return {
@@ -15,7 +15,7 @@ type Usage = {
export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
format: "openai", format: "openai",
modifyUrl: (providerApi: string) => providerApi + "/responses", modifyUrl: (providerApi: string) => providerApi + "/responses",
modifyHeaders: (headers: Headers, apiKey: string, _stickyId: string) => { modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`) headers.set("authorization", `Bearer ${apiKey}`)
}, },
modifyBody: (body: Record<string, any>) => body, modifyBody: (body: Record<string, any>) => body,
@@ -41,7 +41,7 @@ export type ProviderHelper = (input: {
}) => { }) => {
format: ZenData.Format format: ZenData.Format
modifyUrl: (providerApi: string, isStream?: boolean) => string modifyUrl: (providerApi: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, apiKey: string, stickyId: string) => void modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
modifyBody: (body: Record<string, any>) => Record<string, any> modifyBody: (body: Record<string, any>) => Record<string, any>
createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined
streamSeparator: string streamSeparator: string
@@ -1,14 +1,10 @@
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js" import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { ModelStickyProviderTable } from "@opencode-ai/console-core/schema/ip.sql.js" import { ModelStickyProviderTable } from "@opencode-ai/console-core/schema/ip.sql.js"
export function createStickyTracker( export function createStickyTracker(modelId: string, stickyProvider: "strict" | "prefer" | undefined, session: string) {
modelId: string,
stickyProvider: "strict" | "prefer" | undefined,
stickyId: string,
) {
if (!stickyProvider) return if (!stickyProvider) return
if (!stickyId) return if (!session) return
const id = `${modelId}/${stickyId}` const id = `${modelId}/${session}`
let _providerId: string | undefined let _providerId: string | undefined
return { return {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.15.7", "version": "1.15.6",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.15.7", "version": "1.15.6",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.15.7", "version": "1.15.6",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7", "version": "1.15.6",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
-1
View File
@@ -65,6 +65,5 @@ export abstract class NamedError extends Error {
public static readonly Unknown = NamedError.create("UnknownError", { public static readonly Unknown = NamedError.create("UnknownError", {
message: Schema.String, message: Schema.String,
ref: Schema.optional(Schema.String),
}) })
} }
+5 -3
View File
@@ -3,15 +3,14 @@ import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite" import appPlugin from "@opencode-ai/app/vite"
import * as fs from "node:fs/promises" import * as fs from "node:fs/promises"
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
const channel = (() => { const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (process.env.OPENCODE_CHANNEL === "latest") return "prod"
return "dev" return "dev"
})() })()
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}` const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
const sentry = const sentry =
@@ -83,6 +82,9 @@ export default defineConfig({
plugins: [appPlugin, sentry], plugins: [appPlugin, sentry],
publicDir: "../../../app/public", publicDir: "../../../app/public",
root: "src/renderer", root: "src/renderer",
define: {
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},
build: { build: {
sourcemap: true, sourcemap: true,
rollupOptions: { rollupOptions: {
+1 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.15.7", "version": "1.15.6",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
@@ -24,7 +24,6 @@
}, },
"main": "./out/main/index.js", "main": "./out/main/index.js",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
"electron-log": "^5", "electron-log": "^5",
-1
View File
@@ -7,5 +7,4 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
export const SETTINGS_STORE = "opencode.settings" export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_ENABLED_KEY = "wslEnabled" export const WSL_ENABLED_KEY = "wslEnabled"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
+4 -33
View File
@@ -15,7 +15,7 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } fr
import { checkAppExists, resolveAppPath, wslPath } from "./apps" import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import { CHANNEL, UPDATER_ENABLED } from "./constants" import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging" import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown" import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu" import { createMenu } from "./menu"
import { import {
@@ -31,7 +31,6 @@ import {
createLoadingWindow, createLoadingWindow,
createMainWindow, createMainWindow,
registerRendererProtocol, registerRendererProtocol,
setRelaunchHandler,
setBackgroundColor, setBackgroundColor,
setDockIcon, setDockIcon,
} from "./windows" } from "./windows"
@@ -50,7 +49,6 @@ const APP_IDS: Record<string, string> = {
prod: "ai.opencode.desktop", prod: "ai.opencode.desktop",
} }
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
let logger: ReturnType<typeof initLogging> let logger: ReturnType<typeof initLogging>
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
@@ -143,7 +141,6 @@ const main = Effect.gen(function* () {
) )
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
logger = initLogging() logger = initLogging()
initCrashReporter()
try { try {
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
@@ -160,8 +157,6 @@ const main = Effect.gen(function* () {
ensureLoopbackNoProxy() ensureLoopbackNoProxy()
useEnvProxy() useEnvProxy()
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
const features = app.commandLine.getSwitchValue("enable-features")
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
if (!app.requestSingleInstanceLock()) { if (!app.requestSingleInstanceLock()) {
@@ -197,21 +192,6 @@ const main = Effect.gen(function* () {
void killSidecar() void killSidecar()
}) })
app.on("child-process-gone", (_event, details) => {
writeLog("utility", "child process gone", { details }, "error")
})
app.on("render-process-gone", (_event, webContents, details) => {
writeLog("window", "app render process gone", { url: webContents.getURL(), details }, "error")
})
setRelaunchHandler(() => {
void killSidecar().finally(() => {
app.relaunch()
app.exit(0)
})
})
for (const signal of ["SIGINT", "SIGTERM"] as const) { for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => { process.on(signal, () => {
void killSidecar().finally(() => app.exit(0)) void killSidecar().finally(() => app.exit(0))
@@ -256,8 +236,6 @@ const main = Effect.gen(function* () {
checkUpdate: async () => checkUpdate(), checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(killSidecar), installUpdate: async () => installUpdate(killSidecar),
setBackgroundColor: (color) => setBackgroundColor(color), setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
}) })
yield* Effect.promise(() => app.whenReady()) yield* Effect.promise(() => app.whenReady())
@@ -267,13 +245,6 @@ const main = Effect.gen(function* () {
registerRendererProtocol() registerRendererProtocol()
setDockIcon() setDockIcon()
setupAutoUpdater() setupAutoUpdater()
yield* Effect.promise(() => startNetLog()).pipe(
Effect.catch((error) =>
Effect.sync(() => {
logger.warn("failed to start net log", error)
}),
),
)
const needsMigration = ((): boolean => { const needsMigration = ((): boolean => {
if (process.env.OPENCODE_DB === ":memory:") return false if (process.env.OPENCODE_DB === ":memory:") return false
@@ -329,9 +300,9 @@ const main = Effect.gen(function* () {
needsMigration, needsMigration,
userDataPath: app.getPath("userData"), userDataPath: app.getPath("userData"),
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
onStdout: (message) => writeLog("server", "stdout", { message }), onStdout: (message) => logger.log("sidecar stdout", { message }),
onStderr: (message) => writeLog("server", "stderr", { message }, "warn"), onStderr: (message) => logger.warn("sidecar stderr", { message }),
onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"), onExit: (code) => logger.warn("sidecar exited", { code }),
}), }),
) )
server = listener server = listener
+1 -12
View File
@@ -5,7 +5,6 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { import type {
InitStep, InitStep,
FatalRendererError,
ServerReadyData, ServerReadyData,
SqliteMigrationProgress, SqliteMigrationProgress,
TitlebarTheme, TitlebarTheme,
@@ -14,7 +13,7 @@ import type {
} from "../preload/types" } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions" import { runDesktopMenuAction } from "./desktop-menu-actions"
import { getStore } from "./store" import { getStore } from "./store"
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows" import { setTitlebar, updateTitlebar } from "./windows"
const pickerFilters = (ext?: string[]) => { const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined if (!ext || ext.length === 0) return undefined
@@ -41,8 +40,6 @@ type Deps = {
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void> | void installUpdate: () => Promise<void> | void
setBackgroundColor: (color: string) => void setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
} }
export function registerIpcHandlers(deps: Deps) { export function registerIpcHandlers(deps: Deps) {
@@ -74,10 +71,6 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("check-update", () => deps.checkUpdate()) ipcMain.handle("check-update", () => deps.checkUpdate())
ipcMain.handle("install-update", () => deps.installUpdate()) ipcMain.handle("install-update", () => deps.installUpdate())
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
deps.recordFatalRendererError(error),
)
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
try { try {
const store = getStore(name) const store = getStore(name)
@@ -202,10 +195,6 @@ export function registerIpcHandlers(deps: Deps) {
if (!win) return if (!win) return
updateTitlebar(win) updateTitlebar(win)
}) })
ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled())
ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => {
setPinchZoomEnabled(enabled)
})
ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
const win = BrowserWindow.fromWebContents(event.sender) const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return if (!win) return
+5 -149
View File
@@ -1,92 +1,21 @@
import { MainLogger } from "electron-log" import { MainLogger } from "electron-log"
import log from "electron-log/main.js" import log from "electron-log/main.js"
import { app, crashReporter, netLog, shell } from "electron" import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs"
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js"
import { dirname, join } from "node:path" import { dirname, join } from "node:path"
import { homedir } from "node:os"
const MAX_LOG_AGE_DAYS = 7 const MAX_LOG_AGE_DAYS = 7
const TAIL_LINES = 1000 const TAIL_LINES = 1000
const EXPORT_WINDOW = 24 * 60 * 60 * 1000
const MAX_EXPORT_FILE_SIZE = 50 * 1024 * 1024
const NET_LOG_SIZE = 20 * 1024 * 1024
let root = ""
let run = ""
let netLogPath: string | undefined
let logger: MainLogger let logger: MainLogger
export const getLogger = () => logger export const getLogger = () => logger
export function initLogging() { export function initLogging() {
initRunDirectory()
log.transports.file.maxSize = 5 * 1024 * 1024 log.transports.file.maxSize = 5 * 1024 * 1024
log.transports.file.resolvePathFn = (_vars, message) =>
join(
run,
`${safeLogName(message?.scope ?? (message?.variables?.processType === "renderer" ? "renderer" : "main"))}.log`,
)
log.initialize({ preload: false, spyRendererConsole: true })
initConsoleTransport() initConsoleTransport()
cleanup() cleanup()
return (logger = log) return (logger = log)
} }
export function initCrashReporter() {
const dir = join(app.getPath("userData"), "Crashpad")
mkdirSync(dir, { recursive: true })
app.setPath("crashDumps", dir)
crashReporter.start({ uploadToServer: false, compress: true })
write("crash", "crash reporter started", { path: dir })
}
export async function startNetLog() {
if (netLog.currentlyLogging) return
netLogPath = join(run, "network.netlog")
await netLog.startLogging(netLogPath, { captureMode: "default", maxFileSize: NET_LOG_SIZE })
write("network", "net log started", { path: netLogPath })
}
export async function exportDebugLogs() {
const restartNetLog = netLog.currentlyLogging
if (restartNetLog) {
await netLog.stopLogging().catch((error) => write("network", "failed to stop net log", { error }))
}
const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
try {
write("main", "exporting debug logs", { output })
await writeZip(output, [
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(), null, 2)) },
...collect(root, "desktop"),
...serverLogRoots().flatMap((dir, i) => collect(dir, `server-${i + 1}`)),
...collect(app.getPath("crashDumps"), "crashpad"),
])
shell.showItemInFolder(output)
return output
} finally {
if (restartNetLog) {
await startNetLog().catch((error) => write("network", "failed to restart net log", { error }))
}
}
}
export function write(
name: string,
message: string,
extra?: Record<string, unknown>,
level: "info" | "warn" | "error" = "info",
) {
if (!run) return
const scoped = log.scope(safeLogName(name))
if (extra !== undefined) {
scoped[level](message, extra)
return
}
scoped[level](message)
}
export function tail(): string { export function tail(): string {
try { try {
const path = log.transports.file.getFile().path const path = log.transports.file.getFile().path
@@ -98,96 +27,23 @@ export function tail(): string {
} }
} }
function initRunDirectory() {
root = join(app.getPath("userData"), "logs")
run = join(root, stamp())
mkdirSync(run, { recursive: true })
}
function stamp() {
return new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d+Z$/, "")
}
function safeLogName(name: string) {
return name.replace(/[^a-z0-9_.-]/gi, "_") || "main"
}
function cleanup() { function cleanup() {
const dir = root || dirname(log.transports.file.getFile().path) const path = log.transports.file.getFile().path
const dir = dirname(path)
const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000 const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000
for (const entry of readdirSync(dir)) { for (const entry of readdirSync(dir)) {
const file = join(dir, entry) const file = join(dir, entry)
try { try {
const info = statSync(file) const info = statSync(file)
if (info.mtimeMs < cutoff) rmSync(file, { recursive: true, force: true }) if (!info.isFile()) continue
if (info.mtimeMs < cutoff) unlinkSync(file)
} catch { } catch {
continue continue
} }
} }
} }
function manifest() {
return {
generated: new Date().toISOString(),
version: app.getVersion(),
name: app.getName(),
packaged: app.isPackaged,
platform: process.platform,
arch: process.arch,
versions: process.versions,
uptime: process.uptime(),
userData: app.getPath("userData"),
logs: root,
currentRun: run,
crashDumps: app.getPath("crashDumps"),
serverLogs: serverLogRoots(),
netLog: netLogPath,
}
}
function serverLogRoots() {
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
return [...new Set([join(xdgData, "opencode", "log"), join(app.getPath("userData"), "opencode", "log")])]
}
type Entry = { name: string; path?: string; data?: Buffer }
function collect(dir: string, prefix: string): Entry[] {
if (!existsSync(dir)) return []
const cutoff = Date.now() - EXPORT_WINDOW
const result: Entry[] = []
const walk = (current: string) => {
for (const entry of readdirSync(current)) {
const file = join(current, entry)
const info = statSync(file)
if (info.isDirectory()) {
walk(file)
continue
}
if (info.mtimeMs < cutoff) continue
if (info.size > MAX_EXPORT_FILE_SIZE) continue
if (file.endsWith(".heapsnapshot")) continue
result.push({ name: join(prefix, file.slice(dir.length + 1)).replace(/\\/g, "/"), path: file })
}
}
walk(dir)
return result
}
async function writeZip(output: string, entries: Entry[]) {
const writer = new ZipWriter(new BlobWriter("application/zip"))
for (const entry of entries) {
const data = entry.data ?? readFileSync(entry.path!)
await writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)])))
}
const zip = await writer.close()
writeFileSync(output, Buffer.from(await zip.arrayBuffer()))
}
function initConsoleTransport() { function initConsoleTransport() {
const write = log.transports.console.writeFn.bind(log.transports.console) const write = log.transports.console.writeFn.bind(log.transports.console)
log.transports.console.writeFn = (options) => { log.transports.console.writeFn = (options) => {
-69
View File
@@ -1,69 +0,0 @@
import type { BrowserWindow } from "electron"
import { write as writeLog } from "./logging"
const sampleInterval = 1000
const samplePeriod = 15000
export function createUnresponsiveSampler(win: BrowserWindow, name: string) {
let sampleTimer: ReturnType<typeof setTimeout> | undefined
let stopTimer: ReturnType<typeof setTimeout> | undefined
let sampling = false
const samples = new Map<string, number>()
const active = () => sampling && !win.isDestroyed() && !win.webContents.isDestroyed()
const clearTimers = () => {
if (sampleTimer) clearTimeout(sampleTimer)
if (stopTimer) clearTimeout(stopTimer)
sampleTimer = undefined
stopTimer = undefined
}
const schedule = () => {
sampleTimer = setTimeout(() => {
void collect()
}, sampleInterval)
}
const collect = async () => {
if (!active()) return
const stack = await win.webContents.mainFrame.collectJavaScriptCallStack().catch((error) => {
writeLog("window", "failed to collect unresponsive sample", { window: name, error }, "error")
return undefined
})
if (!active()) return
if (stack) samples.set(stack, (samples.get(stack) ?? 0) + 1)
schedule()
}
const stopAndFlush = () => {
const wasSampling = sampling
sampling = false
clearTimers()
if (samples.size === 0) return wasSampling
const entries = [...samples.entries()].sort((a, b) => b[1] - a[1])
const total = entries.reduce((sum, entry) => sum + entry[1], 0)
const message = [
"renderer unresponsive samples",
`Window: ${name}`,
`URL: ${win.isDestroyed() ? "<destroyed>" : win.webContents.getURL()}`,
...entries.map((entry) => `<${entry[1]}> ${entry[0]}`),
`Total Samples: ${total}`,
].join("\n")
writeLog("window", message, undefined, "error")
samples.clear()
return wasSampling
}
const start = () => {
if (sampling || win.isDestroyed() || win.webContents.isDestroyed() || win.webContents.isDevToolsOpened()) return
sampling = true
samples.clear()
schedule()
stopTimer = setTimeout(stopAndFlush, samplePeriod)
}
win.on("closed", stopAndFlush)
return { start, stopAndFlush }
}
+5 -22
View File
@@ -1,16 +1,13 @@
import { app, dialog } from "electron" import { app, dialog } from "electron"
import pkg from "electron-updater" import pkg from "electron-updater"
import { UPDATER_ENABLED } from "./constants" import { UPDATER_ENABLED } from "./constants"
import { getLogger } from "./logging" import { initLogging } from "./logging"
const logger = initLogging()
const { autoUpdater } = pkg const { autoUpdater } = pkg
type UpdateCheckResult = { updateAvailable: boolean; version?: string; failed?: boolean }
let downloadedVersion: string | undefined
let pendingCheck: Promise<UpdateCheckResult> | undefined
export function setupAutoUpdater() { export function setupAutoUpdater() {
if (!UPDATER_ENABLED) return if (!UPDATER_ENABLED) return
const logger = getLogger()
autoUpdater.logger = logger autoUpdater.logger = logger
autoUpdater.channel = "latest" autoUpdater.channel = "latest"
autoUpdater.allowPrerelease = false autoUpdater.allowPrerelease = false
@@ -25,19 +22,8 @@ export function setupAutoUpdater() {
}) })
} }
export async function checkUpdate(): Promise<UpdateCheckResult> { export async function checkUpdate() {
if (!UPDATER_ENABLED) return { updateAvailable: false } if (!UPDATER_ENABLED) return { updateAvailable: false }
if (downloadedVersion) return { updateAvailable: true, version: downloadedVersion }
if (pendingCheck) return pendingCheck
pendingCheck = checkAndDownloadUpdate().finally(() => {
pendingCheck = undefined
})
return pendingCheck
}
async function checkAndDownloadUpdate(): Promise<UpdateCheckResult> {
const logger = getLogger()
logger.log("checking for updates", { logger.log("checking for updates", {
currentVersion: app.getVersion(), currentVersion: app.getVersion(),
channel: autoUpdater.channel, channel: autoUpdater.channel,
@@ -62,7 +48,6 @@ async function checkAndDownloadUpdate(): Promise<UpdateCheckResult> {
} }
logger.log("update available", { version }) logger.log("update available", { version })
await autoUpdater.downloadUpdate() await autoUpdater.downloadUpdate()
downloadedVersion = version
logger.log("update download completed", { version }) logger.log("update download completed", { version })
return { updateAvailable: true, version } return { updateAvailable: true, version }
} catch (error) { } catch (error) {
@@ -72,9 +57,8 @@ async function checkAndDownloadUpdate(): Promise<UpdateCheckResult> {
} }
export async function installUpdate(killSidecar: () => Promise<void>) { export async function installUpdate(killSidecar: () => Promise<void>) {
const result = downloadedVersion ? { updateAvailable: true, version: downloadedVersion } : await checkUpdate() const result = await checkUpdate()
const logger = getLogger() if (!result.updateAvailable) {
if (!result.updateAvailable || !downloadedVersion) {
logger.log("install update skipped", { logger.log("install update skipped", {
reason: result.failed ? "update check failed" : "no update available", reason: result.failed ? "update check failed" : "no update available",
}) })
@@ -89,7 +73,6 @@ export async function installUpdate(killSidecar: () => Promise<void>) {
export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise<void>) { export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise<void>) {
if (!UPDATER_ENABLED) return if (!UPDATER_ENABLED) return
const logger = getLogger()
logger.log("checkForUpdates invoked", { alertOnFail }) logger.log("checkForUpdates invoked", { alertOnFail })
const result = await checkUpdate() const result = await checkUpdate()
if (!result.updateAvailable) { if (!result.updateAvailable) {
+8 -209
View File
@@ -1,12 +1,8 @@
import windowState from "electron-window-state" import windowState from "electron-window-state"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron" import { app, BrowserWindow, net, nativeImage, nativeTheme, protocol } from "electron"
import { dirname, isAbsolute, join, relative, resolve } from "node:path" import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url" import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types" import type { TitlebarTheme } from "../preload/types"
import { PINCH_ZOOM_ENABLED_KEY } from "./constants"
import { exportDebugLogs, write as writeLog } from "./logging"
import { getStore } from "./store"
import { createUnresponsiveSampler } from "./unresponsive"
const root = dirname(fileURLToPath(import.meta.url)) const root = dirname(fileURLToPath(import.meta.url))
const rendererRoot = join(root, "../renderer") const rendererRoot = join(root, "../renderer")
@@ -15,8 +11,6 @@ const rendererHost = "renderer"
const clipboardWritePermission = "clipboard-sanitized-write" const clipboardWritePermission = "clipboard-sanitized-write"
const notificationPermission = "notifications" const notificationPermission = "notifications"
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission]) const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
const documentPolicyHeader = "Document-Policy"
const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
protocol.registerSchemesAsPrivileged([ protocol.registerSchemesAsPrivileged([
{ {
@@ -30,19 +24,8 @@ protocol.registerSchemesAsPrivileged([
]) ])
let backgroundColor: string | undefined let backgroundColor: string | undefined
let relaunchHandler = () => {
app.relaunch()
app.exit(0)
}
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>() const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
const titlebarHeight = 40 const titlebarHeight = 40
const maxZoomLevel = 10
const minZoomLevel = 0.2
export function setRelaunchHandler(handler: () => void) {
relaunchHandler = handler
}
export function setBackgroundColor(color: string) { export function setBackgroundColor(color: string) {
backgroundColor = color backgroundColor = color
@@ -84,20 +67,6 @@ export function updateTitlebar(win: BrowserWindow) {
win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor())) win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor()))
} }
export function setPinchZoomEnabled(enabled: boolean) {
getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
for (const win of BrowserWindow.getAllWindows()) {
pinchZoomEnabled.set(win, enabled)
win.webContents.send("pinch-zoom-enabled-changed", enabled)
if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
updateZoom(win)
}
}
export function getPinchZoomEnabled() {
return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
}
export function setDockIcon() { export function setDockIcon() {
if (process.platform !== "darwin") return if (process.platform !== "darwin") return
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png")) const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
@@ -143,7 +112,6 @@ export function createMainWindow() {
}) })
allowRendererPermissions(win) allowRendererPermissions(win)
wireWindowRecovery(win, "main")
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details const { requestHeaders } = details
@@ -153,7 +121,8 @@ export function createMainWindow() {
win.webContents.session.webRequest.onHeadersReceived((details, callback) => { win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
const { responseHeaders = {} } = details const { responseHeaders = {} } = details
addRendererHeaders(details.url, responseHeaders) upsertKeyValue(responseHeaders, "Access-Control-Allow-Origin", ["*"])
upsertKeyValue(responseHeaders, "Access-Control-Allow-Headers", ["*"])
callback({ responseHeaders }) callback({ responseHeaders })
}) })
@@ -196,7 +165,6 @@ export function createLoadingWindow() {
}) })
allowRendererPermissions(win) allowRendererPermissions(win)
wireWindowRecovery(win, "loading")
loadWindow(win, "loading.html") loadWindow(win, "loading.html")
@@ -206,40 +174,19 @@ export function createLoadingWindow() {
export function registerRendererProtocol() { export function registerRendererProtocol() {
if (protocol.isProtocolHandled(rendererProtocol)) return if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => { protocol.handle(rendererProtocol, (request) => {
const url = new URL(request.url) const url = new URL(request.url)
if (url.host !== rendererHost) { if (url.host !== rendererHost) {
writeLog("protocol", "rejected host", { url: request.url }, "warn")
return new Response("Not found", { status: 404 }) return new Response("Not found", { status: 404 })
} }
const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`) const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = relative(rendererRoot, file) const rel = relative(rendererRoot, file)
if (rel.startsWith("..") || isAbsolute(rel)) { if (rel.startsWith("..") || isAbsolute(rel)) {
writeLog("protocol", "rejected path", { url: request.url, file }, "warn")
return new Response("Not found", { status: 404 }) return new Response("Not found", { status: 404 })
} }
try { return net.fetch(pathToFileURL(file).toString())
const response = await net.fetch(pathToFileURL(file).toString())
if (response.status >= 400) {
writeLog(
"protocol",
"fetch failed",
{
url: request.url,
file,
status: response.status,
statusText: response.statusText,
},
"error",
)
}
return addDocumentPolicy(response, file)
} catch (error) {
writeLog("protocol", "fetch error", { url: request.url, file, error }, "error")
return new Response("Not found", { status: 404 })
}
}) })
} }
@@ -254,127 +201,6 @@ function loadWindow(win: BrowserWindow, html: string) {
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`) void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
} }
function wireWindowRecovery(win: BrowserWindow, name: string) {
let showing = false
const sampler = createUnresponsiveSampler(win, name)
const handle = async (button: string | undefined, wait: boolean) => {
if (button === "Export Logs") {
const sampling = sampler.stopAndFlush()
await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error"))
if (wait && sampling) sampler.start()
return true
}
if (button === "Relaunch") {
sampler.stopAndFlush()
relaunchHandler()
return false
}
if (button === "Quit") {
sampler.stopAndFlush()
app.quit()
}
return false
}
const show = async (message: string, detail: string, wait: boolean) => {
if (showing || win.isDestroyed()) return
showing = true
try {
while (!win.isDestroyed()) {
const buttons = wait ? ["Relaunch", "Export Logs", "Keep Waiting"] : ["Relaunch", "Export Logs", "Quit"]
const result = await dialog.showMessageBox(win, {
type: "warning",
buttons,
defaultId: 0,
cancelId: 2,
message,
detail,
})
if (await handle(buttons[result.response], wait)) continue
return
}
} finally {
showing = false
}
}
const failed = (
event: string,
errorCode: number,
errorDescription: string,
validatedURL: string,
isMainFrame: boolean,
) => {
writeLog(
"window",
"renderer load failed",
{
window: name,
event,
errorCode,
errorDescription,
validatedURL,
currentURL: win.webContents.getURL(),
isMainFrame,
},
"error",
)
if (!isMainFrame || errorCode === -3) return
void show(
"OpenCode failed to load",
[`Window: ${name}`, `URL: ${validatedURL}`, `Error: ${errorCode} ${errorDescription}`].join("\n"),
false,
)
}
win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
failed("did-fail-load", errorCode, errorDescription, validatedURL, isMainFrame)
})
win.webContents.on("did-fail-provisional-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
failed("did-fail-provisional-load", errorCode, errorDescription, validatedURL, isMainFrame)
})
win.webContents.on("render-process-gone", (_event, details) => {
sampler.stopAndFlush()
writeLog(
"window",
"renderer process gone",
{ window: name, currentURL: win.webContents.getURL(), details },
"error",
)
void show(
"OpenCode window terminated unexpectedly",
[`Window: ${name}`, `Reason: ${details.reason}`, `Code: ${details.exitCode ?? "<unknown>"}`].join("\n"),
false,
)
})
win.on("unresponsive", () => {
writeLog("window", "renderer unresponsive", { window: name, currentURL: win.webContents.getURL() }, "error")
sampler.start()
void show("OpenCode is not responding", "You can relaunch the app, open the logs, or keep waiting.", true)
})
win.on("responsive", () => {
writeLog("window", "renderer responsive", { window: name, currentURL: win.webContents.getURL() }, "error")
sampler.stopAndFlush()
})
win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) {
writeLog("pty", "console", { window: name, level, message, line, sourceId })
}
})
win.webContents.on("preload-error", (_event, preloadPath, error) => {
writeLog("preload", "preload error", { window: name, preloadPath, error }, "error")
})
}
function addDocumentPolicy(response: Response, file: string) {
if (!file.toLowerCase().endsWith(".html")) return response
const headers = new Headers(response.headers)
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
}
function allowRendererPermissions(win: BrowserWindow) { function allowRendererPermissions(win: BrowserWindow) {
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => { win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
callback( callback(
@@ -391,19 +217,8 @@ function allowRendererPermissions(win: BrowserWindow) {
} }
function isTrustedRendererUrl(value?: string) { function isTrustedRendererUrl(value?: string) {
return isRendererUrl(value)
}
function addRendererHeaders(value: string, headers: Record<string, any>) {
upsertKeyValue(headers, "Access-Control-Allow-Origin", ["*"])
upsertKeyValue(headers, "Access-Control-Allow-Headers", ["*"])
if (isRendererUrl(value, true)) upsertKeyValue(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
}
function isRendererUrl(value?: string, html = false) {
if (!value || !URL.canParse(value)) return false if (!value || !URL.canParse(value)) return false
const url = new URL(value) const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
const devUrl = process.env.ELECTRON_RENDERER_URL const devUrl = process.env.ELECTRON_RENDERER_URL
if (!devUrl || !URL.canParse(devUrl)) return false if (!devUrl || !URL.canParse(devUrl)) return false
@@ -411,29 +226,13 @@ function isRendererUrl(value?: string, html = false) {
} }
function wireZoom(win: BrowserWindow) { function wireZoom(win: BrowserWindow) {
pinchZoomEnabled.set(win, getPinchZoomEnabled())
win.webContents.setZoomFactor(1) win.webContents.setZoomFactor(1)
win.webContents.on("zoom-changed", (event, zoomDirection) => { win.webContents.on("zoom-changed", () => {
event.preventDefault() win.webContents.setZoomFactor(1)
if (pinchZoomEnabled.get(win)) { updateTitlebar(win)
win.webContents.setZoomFactor(clampZoom(win.webContents.getZoomFactor() + (zoomDirection === "in" ? 0.2 : -0.2)))
updateZoom(win)
return
}
if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
updateZoom(win)
}) })
} }
function clampZoom(value: number) {
return Math.min(Math.max(value, minZoomLevel), maxZoomLevel)
}
function updateZoom(win: BrowserWindow) {
updateTitlebar(win)
win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor())
}
function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) { function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) {
const keyToChangeLower = keyToChange.toLowerCase() const keyToChangeLower = keyToChange.toLowerCase()
for (const key of Object.keys(obj)) { for (const key of Object.keys(obj)) {
-14
View File
@@ -60,18 +60,6 @@ const api: ElectronAPI = {
relaunch: () => ipcRenderer.send("relaunch"), relaunch: () => ipcRenderer.send("relaunch"),
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"), getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor), setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
getPinchZoomEnabled: () => ipcRenderer.invoke("get-pinch-zoom-enabled"),
setPinchZoomEnabled: (enabled) => ipcRenderer.invoke("set-pinch-zoom-enabled", enabled),
onPinchZoomEnabledChanged: (cb) => {
const handler = (_: unknown, enabled: boolean) => cb(enabled)
ipcRenderer.on("pinch-zoom-enabled-changed", handler)
return () => ipcRenderer.removeListener("pinch-zoom-enabled-changed", handler)
},
onZoomFactorChanged: (cb) => {
const handler = (_: unknown, factor: number) => cb(factor)
ipcRenderer.on("zoom-factor-changed", handler)
return () => ipcRenderer.removeListener("zoom-factor-changed", handler)
},
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme), setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action), runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"), loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
@@ -79,8 +67,6 @@ const api: ElectronAPI = {
checkUpdate: () => ipcRenderer.invoke("check-update"), checkUpdate: () => ipcRenderer.invoke("check-update"),
installUpdate: () => ipcRenderer.invoke("install-update"), installUpdate: () => ipcRenderer.invoke("install-update"),
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color), setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
} }
contextBridge.exposeInMainWorld("api", api) contextBridge.exposeInMainWorld("api", api)
-14
View File
@@ -20,14 +20,6 @@ export type WindowConfig = {
updaterEnabled: boolean updaterEnabled: boolean
} }
export type FatalRendererError = {
error: string
url: string
version?: string
platform: string
os?: string
}
export type ElectronAPI = { export type ElectronAPI = {
killSidecar: () => Promise<void> killSidecar: () => Promise<void>
installCli: () => Promise<string> installCli: () => Promise<string>
@@ -79,10 +71,6 @@ export type ElectronAPI = {
relaunch: () => void relaunch: () => void
getZoomFactor: () => Promise<number> getZoomFactor: () => Promise<number>
setZoomFactor: (factor: number) => Promise<void> setZoomFactor: (factor: number) => Promise<void>
getPinchZoomEnabled: () => Promise<boolean>
setPinchZoomEnabled: (enabled: boolean) => Promise<void>
onPinchZoomEnabledChanged: (cb: (enabled: boolean) => void) => () => void
onZoomFactorChanged: (cb: (factor: number) => void) => () => void
setTitlebar: (theme: TitlebarTheme) => Promise<void> setTitlebar: (theme: TitlebarTheme) => Promise<void>
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void> runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
loadingWindowComplete: () => void loadingWindowComplete: () => void
@@ -90,6 +78,4 @@ export type ElectronAPI = {
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void> installUpdate: () => Promise<void>
setBackgroundColor: (color: string) => Promise<void> setBackgroundColor: (color: string) => Promise<void>
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
} }
+1 -9
View File
@@ -21,7 +21,7 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js
import { render } from "solid-js/web" import { render } from "solid-js/web"
import pkg from "../../package.json" import pkg from "../../package.json"
import { initI18n, t } from "./i18n" import { initI18n, t } from "./i18n"
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" import { resetZoom, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import "./styles.css" import "./styles.css"
import { useTheme } from "@opencode-ai/ui/theme" import { useTheme } from "@opencode-ai/ui/theme"
@@ -216,10 +216,6 @@ const createPlatform = (): Platform => {
await window.api.installUpdate() await window.api.installUpdate()
}, },
exportDebugLogs: () => window.api.exportDebugLogs(),
recordFatalRendererError: (error) => window.api.recordFatalRendererError(error),
restart: async () => { restart: async () => {
await window.api.killSidecar().catch(() => undefined) await window.api.killSidecar().catch(() => undefined)
window.api.relaunch() window.api.relaunch()
@@ -274,10 +270,6 @@ const createPlatform = (): Platform => {
webviewZoom, webviewZoom,
getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(),
setPinchZoomEnabled,
runDesktopMenuAction, runDesktopMenuAction,
checkAppExists: async (appName: string) => { checkAppExists: async (appName: string) => {
+1 -80
View File
@@ -13,21 +13,9 @@ const OS_NAME = (() => {
const [webviewZoom, setWebviewZoom] = createSignal(1) const [webviewZoom, setWebviewZoom] = createSignal(1)
let requestedZoom = 1 let requestedZoom = 1
let pinchZoomEnabled = false
let wheelPinch = undefined as
| {
active: boolean
startZoom: number
totalDelta: number
timeout: ReturnType<typeof setTimeout> | undefined
}
| undefined
const MAX_ZOOM_LEVEL = 10 const MAX_ZOOM_LEVEL = 10
const MIN_ZOOM_LEVEL = 0.2 const MIN_ZOOM_LEVEL = 0.2
const WHEEL_PINCH_THRESHOLD = 20
const WHEEL_PINCH_STEP = 0.2
const WHEEL_PINCH_END_DELAY = 160
const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
@@ -45,77 +33,10 @@ const applyZoom = (next: number) => {
}) })
} }
window.api.onZoomFactorChanged((factor) => {
requestedZoom = clamp(factor)
setWebviewZoom(requestedZoom)
})
void window.api.getPinchZoomEnabled().then((enabled) => {
pinchZoomEnabled = enabled
})
window.api.onPinchZoomEnabledChanged((enabled) => {
pinchZoomEnabled = enabled
resetWheelPinch()
})
const setPinchZoomEnabled = (enabled: boolean) => {
pinchZoomEnabled = enabled
resetWheelPinch()
return window.api.setPinchZoomEnabled(enabled)
}
const resetZoom = () => applyZoom(1) const resetZoom = () => applyZoom(1)
const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2)) const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2))
const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2)) const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2))
const resetWheelPinch = () => {
clearTimeout(wheelPinch?.timeout)
wheelPinch = undefined
}
const normalizeWheelDelta = (event: WheelEvent) => {
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * 16
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return event.deltaY * window.innerHeight
return event.deltaY
}
const updateWheelPinch = (event: WheelEvent) => {
wheelPinch ??= {
active: false,
startZoom: requestedZoom,
totalDelta: 0,
timeout: undefined,
}
clearTimeout(wheelPinch.timeout)
wheelPinch.timeout = setTimeout(resetWheelPinch, WHEEL_PINCH_END_DELAY)
wheelPinch.totalDelta += normalizeWheelDelta(event)
if (!wheelPinch.active && Math.abs(wheelPinch.totalDelta) < WHEEL_PINCH_THRESHOLD) return
if (!wheelPinch.active) {
wheelPinch.active = true
wheelPinch.startZoom = requestedZoom
wheelPinch.totalDelta = 0
return
}
wheelPinch.active = true
applyZoom(clamp(wheelPinch.startZoom - (wheelPinch.totalDelta / WHEEL_PINCH_THRESHOLD) * WHEEL_PINCH_STEP))
}
window.addEventListener(
"wheel",
(event) => {
if (!pinchZoomEnabled) return
if (!event.ctrlKey) return
event.preventDefault()
updateWheelPinch(event)
},
{ passive: false },
)
window.addEventListener("keydown", (event) => { window.addEventListener("keydown", (event) => {
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
@@ -135,4 +56,4 @@ window.addEventListener("keydown", (event) => {
} }
}) })
export { webviewZoom, resetZoom, setPinchZoomEnabled, zoomIn, zoomOut } export { webviewZoom, resetZoom, zoomIn, zoomOut }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7", "version": "1.15.5",
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.15.7", "version": "1.15.6",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.15.7" version = "1.15.6"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.6/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.6/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.6/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.6/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.6/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.15.7", "version": "1.15.6",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7", "version": "1.15.6",
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7", "version": "1.15.6",
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -82,7 +82,6 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesCoreFields = { const OpenAIResponsesCoreFields = {
model: Schema.String, model: Schema.String,
input: Schema.Array(OpenAIResponsesInputItem), input: Schema.Array(OpenAIResponsesInputItem),
instructions: Schema.optional(Schema.String),
tools: optionalArray(OpenAIResponsesTool), tools: optionalArray(OpenAIResponsesTool),
tool_choice: Schema.optional(OpenAIResponsesToolChoice), tool_choice: Schema.optional(OpenAIResponsesToolChoice),
store: Schema.optional(Schema.Boolean), store: Schema.optional(Schema.Boolean),
@@ -271,9 +270,7 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
const summary = OpenAIOptions.reasoningSummary(request) const summary = OpenAIOptions.reasoningSummary(request)
const encryptedState = OpenAIOptions.encryptedReasoning(request) const encryptedState = OpenAIOptions.encryptedReasoning(request)
const verbosity = OpenAIOptions.textVerbosity(request) const verbosity = OpenAIOptions.textVerbosity(request)
const instructions = OpenAIOptions.instructions(request)
return { return {
...(instructions ? { instructions } : {}),
...(store !== undefined ? { store } : {}), ...(store !== undefined ? { store } : {}),
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}), ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}), ...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}),
@@ -52,9 +52,4 @@ export const textVerbosity = (request: LLMRequest) => {
return isTextVerbosity(value) ? value : undefined return isTextVerbosity(value) ? value : undefined
} }
export const instructions = (request: LLMRequest) => {
const value = options(request)?.instructions
return typeof value === "string" ? value : undefined
}
export * as OpenAIOptions from "./openai-options" export * as OpenAIOptions from "./openai-options"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7", "version": "1.15.6",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -58,7 +58,7 @@ const createEmbeddedWebUIBundle = async () => {
console.log(`Building Web UI to embed in the binary`) console.log(`Building Web UI to embed in the binary`)
const appDir = path.join(import.meta.dirname, "../../app") const appDir = path.join(import.meta.dirname, "../../app")
const dist = path.join(appDir, "dist") const dist = path.join(appDir, "dist")
await $`OPENCODE_CHANNEL=${Script.channel} bun run --cwd ${appDir} build` await $`bun run --cwd ${appDir} build`
const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist }))) const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist })))
.map((file) => file.replaceAll("\\", "/")) .map((file) => file.replaceAll("\\", "/"))
.filter((file) => !file.endsWith(".map")) .filter((file) => !file.endsWith(".map"))
-3
View File
@@ -1,5 +1,4 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import { serviceUse } from "@/effect/service-use"
import { import {
FetchHttpClient, FetchHttpClient,
HttpClient, HttpClient,
@@ -182,8 +181,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
export const use = serviceUse(Service)
export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = Layer.effect( export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
-3
View File
@@ -1,5 +1,4 @@
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { serviceUse } from "@/effect/service-use"
import { Effect, Layer, Option, Schema, Context } from "effect" import { Effect, Layer, Option, Schema, Context } from "effect"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
@@ -39,8 +38,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/AccountRepo") {} export class Service extends Context.Service<Service, Interface>()("@opencode/AccountRepo") {}
export const use = serviceUse(Service)
export const layer: Layer.Layer<Service> = Layer.effect( export const layer: Layer.Layer<Service> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
-3
View File
@@ -1,5 +1,4 @@
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { serviceUse } from "@/effect/service-use"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai" import { generateObject, streamObject, type ModelMessage } from "ai"
@@ -77,8 +76,6 @@ type State = Omit<Interface, "generate">
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
export const use = serviceUse(Service)
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
-3
View File
@@ -5,7 +5,6 @@ import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global" import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@/effect/service-use"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import type { InstanceContext } from "@/project/instance-context" import type { InstanceContext } from "@/project/instance-context"
import { InstanceRef } from "@/effect/instance-ref" import { InstanceRef } from "@/effect/instance-ref"
@@ -57,8 +56,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const use = serviceUse(Service)
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -416,7 +416,7 @@ function AssistantReasoning(props: {
drawUnstyledText={false} drawUnstyledText={false}
streaming={true} streaming={true}
syntaxStyle={props.subtleSyntax} syntaxStyle={props.subtleSyntax}
content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()} content={(inMinimal() ? "- " : "") + "_Thinking:_ " + content()}
conceal={true} conceal={true}
fg={theme.textMuted} fg={theme.textMuted}
/> />
@@ -442,7 +442,9 @@ function CollapsedReasoningText(props: { title: string | null }) {
return ( return (
<text fg={theme.warning} wrapMode="none"> <text fg={theme.warning} wrapMode="none">
<span style={{ fg: theme.warning, italic: true }}>{props.title ? "+ Thought: " + props.title : "+ Thought"}</span> <span style={{ fg: theme.warning, italic: true }}>
{props.title ? "+ Thought · " + props.title : "+ Thought"}
</span>
</text> </text>
) )
} }
@@ -1565,7 +1565,7 @@ function CollapsedReasoningText(props: { title: string | null; duration: number
return ( return (
<text fg={theme.warning} wrapMode="none"> <text fg={theme.warning} wrapMode="none">
<span style={{ fg: theme.warning, italic: true }}> <span style={{ fg: theme.warning, italic: true }}>
{props.title ? "+ Thought: " + props.title + " · " + duration() : "+ Thought: " + duration()} {props.title ? "+ Thought · " + props.title + " · " + duration() : "+ Thought · " + duration()}
</span> </span>
</text> </text>
) )
-3
View File
@@ -1,5 +1,4 @@
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { serviceUse } from "@/effect/service-use"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import os from "os" import os from "os"
@@ -320,8 +319,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {}
export const use = serviceUse(Service)
function globalConfigFile() { function globalConfigFile() {
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
path.join(Global.Path.config, file), path.join(Global.Path.config, file),
@@ -1,5 +1,4 @@
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
import { serviceUse } from "@/effect/service-use"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { asc } from "drizzle-orm" import { asc } from "drizzle-orm"
@@ -168,8 +167,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
export const use = serviceUse(Service)
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
-3
View File
@@ -1,5 +1,4 @@
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { serviceUse } from "@/effect/service-use"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
type State = Record<string, string | undefined> type State = Record<string, string | undefined>
@@ -13,8 +12,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Env") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Env") {}
export const use = serviceUse(Service)
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
-3
View File
@@ -1,5 +1,4 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { serviceUse } from "@/effect/service-use"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -327,8 +326,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/File") {} export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
export const use = serviceUse(Service)
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
-3
View File
@@ -1,5 +1,4 @@
import path from "path" import path from "path"
import { serviceUse } from "@/effect/service-use"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect" import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError" import type { PlatformError } from "effect/PlatformError"
@@ -142,8 +141,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
export const use = serviceUse(Service)
function env() { function env() {
const env = sanitizedProcessEnv() const env = sanitizedProcessEnv()
delete env.RIPGREP_CONFIG_PATH delete env.RIPGREP_CONFIG_PATH
-3
View File
@@ -1,5 +1,4 @@
import { Effect, Layer, Context, Schema } from "effect" import { Effect, Layer, Context, Schema } from "effect"
import { serviceUse } from "@/effect/service-use"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process" import { AppProcess } from "@opencode-ai/core/process"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
@@ -28,8 +27,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Format") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Format") {}
export const use = serviceUse(Service)
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -1,5 +1,4 @@
import { Effect, Layer, Schema, Context, Stream } from "effect" import { Effect, Layer, Schema, Context, Stream } from "effect"
import { serviceUse } from "@/effect/service-use"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
@@ -90,8 +89,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Installation") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Installation") {}
export const use = serviceUse(Service)
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProcess.Service> = Layer.effect( export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProcess.Service> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
-3
View File
@@ -1,5 +1,4 @@
import path from "path" import path from "path"
import { serviceUse } from "@/effect/service-use"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Context, Option, Schema } from "effect" import { Effect, Layer, Context, Option, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -52,8 +51,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {} export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
export const use = serviceUse(Service)
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
-3
View File
@@ -1,5 +1,4 @@
import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai" import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
import { serviceUse } from "@/effect/service-use"
import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
@@ -265,8 +264,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/MCP") {} export class Service extends Context.Service<Service, Interface>()("@opencode/MCP") {}
export const use = serviceUse(Service)
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
+7 -18
View File
@@ -29,11 +29,7 @@ export type Rule = Schema.Schema.Type<typeof Rule>
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
export type Ruleset = Schema.Schema.Type<typeof Ruleset> export type Ruleset = Schema.Schema.Type<typeof Ruleset>
// Pure data; nothing checks class identity. As `Schema.Struct` + type alias, export class Request extends Schema.Class<Request>("PermissionRequest")({
// `Permission.ask` can trust its already-typed input and skip the inner
// `decodeUnknownSync` that would otherwise throw uncaught on any structural
// mismatch. Same pattern as `Question.Request` in PR #28570.
export const Request = Schema.Struct({
id: PermissionID, id: PermissionID,
sessionID: SessionID, sessionID: SessionID,
permission: Schema.String, permission: Schema.String,
@@ -46,8 +42,7 @@ export const Request = Schema.Struct({
callID: Schema.String, callID: Schema.String,
}), }),
), ),
}).annotate({ identifier: "PermissionRequest" }) }) {}
export type Request = Schema.Schema.Type<typeof Request>
export const Reply = Schema.Literals(["once", "always", "reject"]) export const Reply = Schema.Literals(["once", "always", "reject"])
export type Reply = Schema.Schema.Type<typeof Reply> export type Reply = Schema.Schema.Type<typeof Reply>
@@ -60,11 +55,10 @@ const reply = {
export const ReplyBody = Schema.Struct(reply).annotate({ identifier: "PermissionReplyBody" }) export const ReplyBody = Schema.Struct(reply).annotate({ identifier: "PermissionReplyBody" })
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody> export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
export const Approval = Schema.Struct({ export class Approval extends Schema.Class<Approval>("PermissionApproval")({
projectID: ProjectID, projectID: ProjectID,
patterns: Schema.Array(Schema.String), patterns: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionApproval" }) }) {}
export type Approval = Schema.Schema.Type<typeof Approval>
export const Event = { export const Event = {
Asked: BusEvent.define("permission.asked", Request), Asked: BusEvent.define("permission.asked", Request),
@@ -184,15 +178,10 @@ export const layer = Layer.effect(
if (!needsAsk) return if (!needsAsk) return
const id = request.id ?? PermissionID.ascending() const id = request.id ?? PermissionID.ascending()
const info: Request = { const info = Schema.decodeUnknownSync(Request)({
id, id,
sessionID: request.sessionID, ...request,
permission: request.permission, })
patterns: request.patterns,
metadata: request.metadata,
always: request.always,
tool: request.tool,
}
log.info("asking", { id, permission: info.permission, patterns: info.patterns }) log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>() const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
+19 -47
View File
@@ -118,11 +118,6 @@ interface TokenResponse {
expires_in?: number expires_in?: number
} }
interface CodexAuthPluginOptions {
issuer?: string
codexApiEndpoint?: string
}
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> { async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
const response = await fetch(`${ISSUER}/oauth/token`, { const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST", method: "POST",
@@ -141,8 +136,8 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk
return response.json() return response.json()
} }
async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promise<TokenResponse> { async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
const response = await fetch(`${issuer}/oauth/token`, { const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ body: new URLSearchParams({
@@ -369,10 +364,7 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
}) })
} }
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> { export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
const issuer = options.issuer ?? ISSUER
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
return { return {
provider: { provider: {
id: "openai", id: "openai",
@@ -413,13 +405,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
const auth = await getAuth() const auth = await getAuth()
if (auth.type !== "oauth") return {} if (auth.type !== "oauth") return {}
let refreshPromise:
| Promise<{
access: string
accountId: string | undefined
}>
| undefined
return { return {
apiKey: OAUTH_DUMMY_KEY, apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
@@ -444,34 +429,21 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
// Check if token needs refresh // Check if token needs refresh
if (!currentAuth.access || currentAuth.expires < Date.now()) { if (!currentAuth.access || currentAuth.expires < Date.now()) {
if (!refreshPromise) { log.info("refreshing codex access token")
log.info("refreshing codex access token") const tokens = await refreshAccessToken(currentAuth.refresh)
refreshPromise = refreshAccessToken(currentAuth.refresh, issuer) const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
.then(async (tokens) => { await input.client.auth.set({
const accountId = extractAccountId(tokens) || authWithAccount.accountId path: { id: "openai" },
await input.client.auth.set({ body: {
path: { id: "openai" }, type: "oauth",
body: { refresh: tokens.refresh_token,
type: "oauth", access: tokens.access_token,
refresh: tokens.refresh_token, expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
access: tokens.access_token, ...(newAccountId && { accountId: newAccountId }),
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, },
...(accountId && { accountId }), })
}, currentAuth.access = tokens.access_token
}) authWithAccount.accountId = newAccountId
return {
access: tokens.access_token,
accountId,
}
})
.finally(() => {
refreshPromise = undefined
})
}
const refreshed = await refreshPromise
currentAuth.access = refreshed.access
authWithAccount.accountId = refreshed.accountId
} }
// Build headers // Build headers
@@ -505,7 +477,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
: new URL(typeof requestInput === "string" ? requestInput : requestInput.url) : new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
const url = const url =
parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
? new URL(codexApiEndpoint) ? new URL(CODEX_API_ENDPOINT)
: parsed : parsed
return fetch(url, { return fetch(url, {
-2
View File
@@ -19,7 +19,6 @@ import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { AzureAuthPlugin } from "./azure" import { AzureAuthPlugin } from "./azure"
import { DigitalOceanAuthPlugin } from "./digitalocean" import { DigitalOceanAuthPlugin } from "./digitalocean"
import { XaiAuthPlugin } from "./xai"
import { Effect, Layer, Context, Stream } from "effect" import { Effect, Layer, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge" import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
@@ -67,7 +66,6 @@ const INTERNAL_PLUGINS: PluginInstance[] = [
CloudflareAIGatewayAuthPlugin, CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin, AzureAuthPlugin,
DigitalOceanAuthPlugin, DigitalOceanAuthPlugin,
XaiAuthPlugin,
] ]
function isServerPlugin(value: unknown): value is PluginInstance { function isServerPlugin(value: unknown): value is PluginInstance {
-742
View File
@@ -1,742 +0,0 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import * as Log from "@opencode-ai/core/util/log"
import { OAUTH_DUMMY_KEY } from "../auth"
import { createServer } from "http"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
const log = Log.create({ service: "plugin.xai" })
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
// for desktop OAuth flows. Source of truth: hermes-agent PR #26534.
const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
const AUTHORIZE_URL = "https://auth.x.ai/oauth2/authorize"
const TOKEN_URL = "https://auth.x.ai/oauth2/token"
// RFC 8628 device authorization grant. Confirmed exposed by xAI's
// /.well-known/openid-configuration as `device_authorization_endpoint`
// with the matching `urn:ietf:params:oauth:grant-type:device_code` grant
// in `grant_types_supported`. This is the headless / VPS path: no
// loopback callback server, no SSH port forwarding, no inbound firewall
// holes — the user opens the URL on any device with a browser, types
// the short user_code, and the CLI long-polls the token endpoint.
const DEVICE_AUTHORIZATION_URL = "https://auth.x.ai/oauth2/device/code"
const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
const SCOPE = "openid profile email offline_access grok-cli:access api:access"
// Bounds for the device-code poll loop. xAI returns `interval` (seconds)
// but we floor it to avoid hammering and we add the spec's slow_down
// increment when xAI explicitly asks us to back off.
const DEVICE_CODE_DEFAULT_INTERVAL_MS = 5_000
const DEVICE_CODE_MIN_INTERVAL_MS = 1_000
const DEVICE_CODE_SLOW_DOWN_INCREMENT_MS = 5_000
const DEVICE_CODE_DEFAULT_EXPIRES_MS = 5 * 60 * 1000
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3_000
// xAI rejects redirect_uris that don't match what was registered for the
// Grok-CLI client. The host:port pair is part of the registration, so we have
// to bind the loopback server to this exact port.
const OAUTH_HOST = "127.0.0.1"
const OAUTH_PORT = 56121
const OAUTH_REDIRECT_PATH = "/callback"
const REDIRECT_URI = `http://${OAUTH_HOST}:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}`
// Refresh the access token a little before it actually expires so a single
// long-running tool call doesn't have to recover from a mid-flight 401.
const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000
interface XaiAuthPluginOptions {
authorizeUrl?: string
tokenUrl?: string
deviceAuthorizationUrl?: string
}
interface PkceCodes {
verifier: string
challenge: string
}
async function generatePKCE(): Promise<PkceCodes> {
const verifier = generateRandomString(64)
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
return { verifier, challenge: base64UrlEncode(hash) }
}
function generateRandomString(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return Array.from(crypto.getRandomValues(new Uint8Array(length)))
.map((b) => chars[b % chars.length])
.join("")
}
function base64UrlEncode(buffer: ArrayBuffer): string {
const binary = String.fromCharCode(...new Uint8Array(buffer))
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function generateState(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
}
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) => {
switch (char) {
case "&":
return "&amp;"
case "<":
return "&lt;"
case ">":
return "&gt;"
case '"':
return "&quot;"
case "'":
return "&#39;"
default:
return char
}
})
}
interface TokenResponse {
access_token: string
refresh_token: string
id_token?: string
token_type?: string
expires_in?: number
scope?: string
}
function authHeaders() {
return {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
}
}
// Parse the `exp` claim out of a JWT access_token without verifying the
// signature. We only use this to decide whether to proactively refresh, never
// to make trust decisions, so unsigned decode is safe. Returns false for
// opaque tokens (no JWT shape), which conservatively skips the proactive
// refresh and lets the 401-on-call path drive the refresh instead.
export function accessTokenIsExpiring(
token: string | undefined,
skewMs: number = ACCESS_TOKEN_REFRESH_SKEW_MS,
): boolean {
if (!token || typeof token !== "string") return false
const parts = token.split(".")
if (parts.length < 2) return false
try {
let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/")
while (payload.length % 4 !== 0) payload += "="
const claims = JSON.parse(Buffer.from(payload, "base64").toString("utf8"))
if (typeof claims?.exp !== "number") return false
return claims.exp * 1000 <= Date.now() + Math.max(0, skewMs)
} catch {
return false
}
}
export function buildAuthorizeUrl(
pkce: PkceCodes,
state: string,
nonce: string,
options: XaiAuthPluginOptions = {},
): string {
// `plan=generic` opts the consent screen into xAI's generic OAuth plan tier;
// without it, accounts.x.ai rejects loopback OAuth from non-allowlisted
// clients. `referrer=opencode` lets xAI attribute opencode-originated
// logins in their OAuth server logs (best-effort attribution while we
// continue to reuse the Grok-CLI client_id).
const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPE,
code_challenge: pkce.challenge,
code_challenge_method: "S256",
state,
nonce,
plan: "generic",
referrer: "opencode",
})
return `${options.authorizeUrl ?? AUTHORIZE_URL}?${params.toString()}`
}
async function exchangeCodeForTokens(
code: string,
pkce: PkceCodes,
options: XaiAuthPluginOptions = {},
): Promise<TokenResponse> {
const response = await fetch(options.tokenUrl ?? TOKEN_URL, {
method: "POST",
headers: authHeaders(),
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: pkce.verifier,
}).toString(),
})
if (!response.ok) {
const detail = await response.text().catch(() => "")
throw new Error(`xAI token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)
}
return response.json() as Promise<TokenResponse>
}
async function refreshAccessToken(refreshToken: string, options: XaiAuthPluginOptions = {}): Promise<TokenResponse> {
const response = await fetch(options.tokenUrl ?? TOKEN_URL, {
method: "POST",
headers: authHeaders(),
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}).toString(),
})
if (!response.ok) {
const detail = await response.text().catch(() => "")
throw new Error(`xAI token refresh failed (${response.status})${detail ? `: ${detail}` : ""}`)
}
return response.json() as Promise<TokenResponse>
}
export interface DeviceCodeResponse {
device_code: string
user_code: string
verification_uri: string
verification_uri_complete?: string
expires_in?: number
interval?: number
}
interface DeviceTokenErrorBody {
error?: string
error_description?: string
}
export async function requestDeviceCode(options: XaiAuthPluginOptions = {}): Promise<DeviceCodeResponse> {
const response = await fetch(options.deviceAuthorizationUrl ?? DEVICE_AUTHORIZATION_URL, {
method: "POST",
headers: authHeaders(),
body: new URLSearchParams({
client_id: CLIENT_ID,
scope: SCOPE,
}).toString(),
})
if (!response.ok) {
const detail = await response.text().catch(() => "")
throw new Error(`xAI device code request failed (${response.status})${detail ? `: ${detail}` : ""}`)
}
const json = (await response.json()) as DeviceCodeResponse
if (!json.device_code || !json.user_code || !json.verification_uri) {
throw new Error("xAI device code response is missing device_code / user_code / verification_uri")
}
return json
}
// Default sleep used between device-code polls. Test-injectable so we can
// exercise authorization_pending / slow_down branches without real waits.
async function defaultSleep(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms))
}
// Normalize a server-supplied seconds value to milliseconds, falling back to
// the supplied default when the input is missing, non-positive, or not a
// finite number. Defends the polling loop against garbage like `NaN`, `"NaN"`,
// `null`, or `-5` from a misbehaving device-code endpoint — without this,
// a NaN interval would slip through `?? default` (NaN is typeof number),
// reach `setTimeout(_, NaN)` which is treated as 0, and busy-loop until the
// hard deadline. Matches the defensive normalization Codex uses for the same
// field (`parseInt(deviceData.interval) || 5`).
function positiveSecondsToMs(value: unknown, defaultMs: number): number {
const seconds = Number(value)
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : defaultMs
}
export async function pollDeviceCodeToken(
device: DeviceCodeResponse,
options: XaiAuthPluginOptions & { sleep?: (ms: number) => Promise<void>; now?: () => number } = {},
): Promise<TokenResponse> {
const sleep = options.sleep ?? defaultSleep
const now = options.now ?? (() => Date.now())
const expiresInMs = positiveSecondsToMs(device.expires_in, DEVICE_CODE_DEFAULT_EXPIRES_MS)
const deadline = now() + expiresInMs
let intervalMs = Math.max(
positiveSecondsToMs(device.interval, DEVICE_CODE_DEFAULT_INTERVAL_MS),
DEVICE_CODE_MIN_INTERVAL_MS,
)
while (now() < deadline) {
const response = await fetch(options.tokenUrl ?? TOKEN_URL, {
method: "POST",
headers: authHeaders(),
body: new URLSearchParams({
grant_type: DEVICE_CODE_GRANT_TYPE,
client_id: CLIENT_ID,
device_code: device.device_code,
}).toString(),
})
if (response.ok) return (await response.json()) as TokenResponse
const body = (await response.json().catch(() => ({}))) as DeviceTokenErrorBody
const remaining = Math.max(0, deadline - now())
// RFC 8628 §3.5: authorization_pending = keep polling at the same
// interval; slow_down = bump the interval by ≥5s and keep polling.
// Anything else is terminal.
if (body.error === "authorization_pending") {
await sleep(Math.min(intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS, remaining))
continue
}
if (body.error === "slow_down") {
intervalMs += DEVICE_CODE_SLOW_DOWN_INCREMENT_MS
await sleep(Math.min(intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS, remaining))
continue
}
if (body.error === "access_denied" || body.error === "authorization_denied") {
throw new Error("xAI device authorization was denied")
}
if (body.error === "expired_token") {
throw new Error("xAI device code expired - please re-run login")
}
const detail = body.error_description ?? body.error ?? ""
throw new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)
}
throw new Error("xAI device authorization timed out")
}
const HTML_SUCCESS = `<!doctype html>
<html>
<head>
<title>OpenCode - xAI Authorization Successful</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #f1ecec;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>
setTimeout(() => window.close(), 2000)
</script>
</body>
</html>`
const HTML_ERROR = (error: string) => `<!doctype html>
<html>
<head>
<title>OpenCode - xAI Authorization Failed</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #fc533a;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
.error {
color: #ff917b;
font-family: monospace;
margin-top: 1rem;
padding: 1rem;
background: #3c140d;
border-radius: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
// CORS allowlist for the loopback callback. The redirect_uri itself is
// already bound to 127.0.0.1 and gated by PKCE+state, so we only accept
// xAI's own auth origins for additional defense-in-depth on the OPTIONS
// preflight.
const CORS_ALLOWED_ORIGINS = new Set(["https://accounts.x.ai", "https://auth.x.ai"])
interface PendingOAuth {
pkce: PkceCodes
state: string
resolve: (tokens: TokenResponse) => void
reject: (error: Error) => void
}
let oauthServer: ReturnType<typeof createServer> | undefined
let pendingOAuth: PendingOAuth | undefined
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
if (oauthServer) return { port: OAUTH_PORT, redirectUri: REDIRECT_URI }
const server = createServer((req, res) => {
const reqUrl = req.url || "/"
const url = new URL(reqUrl, `http://${OAUTH_HOST}:${OAUTH_PORT}`)
const origin = req.headers["origin"]
const allowOrigin = typeof origin === "string" && CORS_ALLOWED_ORIGINS.has(origin) ? origin : ""
if (allowOrigin) {
res.setHeader("Access-Control-Allow-Origin", allowOrigin)
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
res.setHeader("Access-Control-Allow-Private-Network", "true")
res.setHeader("Vary", "Origin")
}
if (req.method === "OPTIONS") {
res.writeHead(204)
res.end()
return
}
if (url.pathname === OAUTH_REDIRECT_PATH) {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
if (error) {
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const current = pendingOAuth
pendingOAuth = undefined
exchangeCodeForTokens(code, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
return
}
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
pendingOAuth = undefined
res.writeHead(200)
res.end("Login cancelled")
return
}
res.writeHead(404)
res.end("Not found")
})
// listen() failures (e.g. EADDRINUSE because Grok-CLI is bound to the same
// pinned port) must clear `oauthServer` and remove our error listener,
// otherwise the next startOAuthServer() short-circuits on the truthy check
// and returns a redirect_uri pointing at nothing.
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
oauthServer = undefined
reject(err)
}
server.once("error", onError)
server.listen(OAUTH_PORT, OAUTH_HOST, () => {
server.removeListener("error", onError)
// After listen() succeeds, install a permanent log-only listener so
// that subsequent server errors (e.g. accept() failures, socket-level
// errors) don't trip Node's default "unhandled error event = throw"
// behavior and crash the entire opencode process. Matches the silent-
// swallow behavior the Codex plugin gets from its permanent
// `oauthServer!.on("error", reject)`.
server.on("error", (err) => log.warn("xai oauth server error", { error: err }))
log.info("xai oauth server started", { host: OAUTH_HOST, port: OAUTH_PORT })
resolve()
})
oauthServer = server
})
return { port: OAUTH_PORT, redirectUri: REDIRECT_URI }
}
function stopOAuthServer() {
if (oauthServer) {
oauthServer.close(() => log.info("xai oauth server stopped"))
oauthServer = undefined
}
}
function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
// A previous in-flight authorize() that the user abandoned (or that is
// being superseded by a fresh attempt) still owns `pendingOAuth`. Reject
// it eagerly so its caller stops waiting on a state value that can never
// match the next callback.
if (pendingOAuth) {
pendingOAuth.reject(new Error("Superseded by a newer xAI authorize request"))
pendingOAuth = undefined
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => {
if (pendingOAuth) {
pendingOAuth = undefined
reject(new Error("OAuth callback timeout - authorization took too long"))
}
},
5 * 60 * 1000,
)
pendingOAuth = {
pkce,
state,
resolve: (tokens) => {
clearTimeout(timeout)
resolve(tokens)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
},
}
})
}
interface RefreshResult {
access: string
refresh: string
expires: number
}
export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOptions = {}): Promise<Hooks> {
return {
auth: {
provider: "xai",
async loader(getAuth) {
const auth = await getAuth()
if (auth.type !== "oauth") return {}
// Single-flight refresh: collapse concurrent fetches from this loaded
// provider onto one HTTP call so we don't replay a rotating refresh_token.
let refreshPromise: Promise<RefreshResult> | undefined
return {
// Dummy bearer keeps the AI SDK from bailing on "missing apiKey"; the
// real OAuth token is injected by the fetch override below.
// We intentionally do NOT set baseURL — @ai-sdk/xai already defaults
// to https://api.x.ai/v1 and overriding here would silently route
// around a user-configured gateway.
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
let currentAuth = await getAuth()
// Auth can flip from oauth to api mid-session (user re-runs
// /connect with a pasted key). When that happens, pass the
// request through untouched so the AI SDK's own apiKey-based
// Authorization header reaches xAI unmodified.
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
// Refresh either when the stored expires timestamp is within the
// skew window, or — for JWT access tokens — when the JWT exp
// claim itself is. The stored expires field is best-effort
// (xAI doesn't always return expires_in) so the JWT check is the
// load-bearing one for tokens that lack a fresh stored deadline.
const expiresSoon =
!currentAuth.expires ||
currentAuth.expires - Date.now() <= ACCESS_TOKEN_REFRESH_SKEW_MS ||
accessTokenIsExpiring(currentAuth.access)
if (expiresSoon) {
if (!refreshPromise) {
const refreshToken = currentAuth.refresh
log.info("refreshing xai access token")
refreshPromise = refreshAccessToken(refreshToken, options)
.then(async (tokens) => {
const refreshedExpires = Date.now() + (tokens.expires_in ?? 3600) * 1000
const refreshedRefresh = tokens.refresh_token || refreshToken
// Persist the rotated pair as best-effort. xAI has already consumed the
// old refresh_token by the time we get here; an auth.set failure leaves
// the on-disk state stale but the in-memory result is still valid for
// this turn. The next live refresh against the stale disk state will
// 4xx and force re-login — a known cross-process limitation.
await input.client.auth
.set({
path: { id: "xai" },
body: {
type: "oauth",
access: tokens.access_token,
refresh: refreshedRefresh,
expires: refreshedExpires,
},
})
.catch((err) => log.warn("failed to persist refreshed xai tokens", { error: err }))
return { access: tokens.access_token, refresh: refreshedRefresh, expires: refreshedExpires }
})
.finally(() => {
refreshPromise = undefined
})
}
const refreshed = await refreshPromise
currentAuth = { ...currentAuth, ...refreshed }
}
// Copy the caller's headers into a fresh Headers (case-insensitive)
// so we never mutate the RequestInit the AI SDK may reuse on retry.
// Headers.set overwrites case-insensitively, which kills the dummy
// bearer the AI SDK injected from apiKey in a single line.
const headers = new Headers(requestInput instanceof Request ? requestInput.headers : undefined)
if (init?.headers) {
const entries =
init.headers instanceof Headers
? init.headers.entries()
: Array.isArray(init.headers)
? init.headers
: Object.entries(init.headers as Record<string, string | undefined>)
for (const [key, value] of entries) {
if (value !== undefined) headers.set(key, String(value))
}
}
headers.set("authorization", `Bearer ${currentAuth.access}`)
headers.set("User-Agent", `opencode/${InstallationVersion}`)
return fetch(requestInput, { ...init, headers })
},
}
},
methods: [
{
label: "xAI Grok OAuth (SuperGrok Subscription)",
type: "oauth",
authorize: async () => {
await startOAuthServer()
const pkce = await generatePKCE()
const state = generateState()
const nonce = generateState()
const authUrl = buildAuthorizeUrl(pkce, state, nonce, options)
const callbackPromise = waitForOAuthCallback(pkce, state)
return {
url: authUrl,
instructions: "Complete authorization in your browser. This window will close automatically.",
method: "auto" as const,
callback: async () => {
try {
const tokens = await callbackPromise
return {
type: "success" as const,
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
}
} catch (err) {
log.error("xai oauth callback failed", { error: err })
return { type: "failed" as const }
} finally {
stopOAuthServer()
}
},
}
},
},
{
// RFC 8628 device-code flow. The CLI prints a verification URL
// and a short user_code that the user enters in a browser on
// any device. No loopback callback server runs on the CLI host,
// so this works on VPS / SSH / Docker / CI / WSL / any
// environment where 127.0.0.1:56121 isn't reachable from the
// user's browser. Defends the only attack surface (the polling
// loop) with the standard authorization_pending / slow_down
// backoff and a hard deadline from xAI's `expires_in`.
label: "xAI Grok OAuth (Headless / Remote / VPS)",
type: "oauth",
authorize: async () => {
const device = await requestDeviceCode(options)
const browserUrl = device.verification_uri_complete ?? device.verification_uri
return {
url: browserUrl,
instructions: `Open ${device.verification_uri} on any device and enter code: ${device.user_code}`,
method: "auto" as const,
callback: async () => {
try {
const tokens = await pollDeviceCodeToken(device, options)
return {
type: "success" as const,
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
}
} catch (err) {
log.error("xai device code callback failed", { error: err })
return { type: "failed" as const }
}
},
}
},
},
{
label: "Manually enter API Key",
type: "api",
},
],
},
}
}
@@ -1,5 +1,4 @@
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { serviceUse } from "@/effect/service-use"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceRef } from "@/effect/instance-ref" import { InstanceRef } from "@/effect/instance-ref"
import { disposeInstance as runDisposers } from "@/effect/instance-registry" import { disposeInstance as runDisposers } from "@/effect/instance-registry"
@@ -25,8 +24,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {} export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
export const use = serviceUse(Service)
interface Entry { interface Entry {
readonly deferred: Deferred.Deferred<InstanceContext> readonly deferred: Deferred.Deferred<InstanceContext>
} }
-3
View File
@@ -1,5 +1,4 @@
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { serviceUse } from "@/effect/service-use"
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { optionalOmitUndefined } from "@opencode-ai/core/schema"
@@ -103,8 +102,6 @@ interface State {
export class Service extends Context.Service<Service, Interface>()("@opencode/ProviderAuth") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ProviderAuth") {}
export const use = serviceUse(Service)
export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> = Layer.effect( export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -7,7 +7,6 @@ import * as Log from "@opencode-ai/core/util/log"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash" import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { serviceUse } from "@/effect/service-use"
import { type LanguageModelV3 } from "@ai-sdk/provider" import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "@opencode-ai/core/models-dev" import * as ModelsDev from "@opencode-ai/core/models-dev"
import { Auth } from "../auth" import { Auth } from "../auth"
@@ -1011,8 +1010,6 @@ interface State {
export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {}
export const use = serviceUse(Service)
function cost(c: ModelsDev.Model["cost"]): Model["cost"] { function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
const result: Model["cost"] = { const result: Model["cost"] = {
input: c?.input ?? 0, input: c?.input ?? 0,
+18 -27
View File
@@ -8,20 +8,16 @@ import { QuestionID } from "./schema"
const log = Log.create({ service: "question" }) const log = Log.create({ service: "question" })
// Schemas — these are pure data; nothing checks class identity (see PR // Schemas
// description) so they're plain `Schema.Struct` + type alias. That lets
// `Question.ask` and other internal sites trust the type contract without a
// re-decode to coerce nested class instances.
export const Option = Schema.Struct({ export class Option extends Schema.Class<Option>("QuestionOption")({
label: Schema.String.annotate({ label: Schema.String.annotate({
description: "Display text (1-5 words, concise)", description: "Display text (1-5 words, concise)",
}), }),
description: Schema.String.annotate({ description: Schema.String.annotate({
description: "Explanation of choice", description: "Explanation of choice",
}), }),
}).annotate({ identifier: "QuestionOption" }) }) {}
export type Option = Schema.Schema.Type<typeof Option>
const base = { const base = {
question: Schema.String.annotate({ question: Schema.String.annotate({
@@ -38,53 +34,48 @@ const base = {
}), }),
} }
export const Info = Schema.Struct({ export class Info extends Schema.Class<Info>("QuestionInfo")({
...base, ...base,
custom: Schema.optional(Schema.Boolean).annotate({ custom: Schema.optional(Schema.Boolean).annotate({
description: "Allow typing a custom answer (default: true)", description: "Allow typing a custom answer (default: true)",
}), }),
}).annotate({ identifier: "QuestionInfo" }) }) {}
export type Info = Schema.Schema.Type<typeof Info>
export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" }) export class Prompt extends Schema.Class<Prompt>("QuestionPrompt")(base) {}
export type Prompt = Schema.Schema.Type<typeof Prompt>
export const Tool = Schema.Struct({ export class Tool extends Schema.Class<Tool>("QuestionTool")({
messageID: MessageID, messageID: MessageID,
callID: Schema.String, callID: Schema.String,
}).annotate({ identifier: "QuestionTool" }) }) {}
export type Tool = Schema.Schema.Type<typeof Tool>
export const Request = Schema.Struct({ export class Request extends Schema.Class<Request>("QuestionRequest")({
id: QuestionID, id: QuestionID,
sessionID: SessionID, sessionID: SessionID,
questions: Schema.Array(Info).annotate({ questions: Schema.Array(Info).annotate({
description: "Questions to ask", description: "Questions to ask",
}), }),
tool: Schema.optional(Tool), tool: Schema.optional(Tool),
}).annotate({ identifier: "QuestionRequest" }) }) {}
export type Request = Schema.Schema.Type<typeof Request>
export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" })
export type Answer = Schema.Schema.Type<typeof Answer> export type Answer = Schema.Schema.Type<typeof Answer>
export const Reply = Schema.Struct({ export class Reply extends Schema.Class<Reply>("QuestionReply")({
answers: Schema.Array(Answer).annotate({ answers: Schema.Array(Answer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)", description: "User answers in order of questions (each answer is an array of selected labels)",
}), }),
}).annotate({ identifier: "QuestionReply" }) }) {}
export type Reply = Schema.Schema.Type<typeof Reply>
const Replied = Schema.Struct({ class Replied extends Schema.Class<Replied>("QuestionReplied")({
sessionID: SessionID, sessionID: SessionID,
requestID: QuestionID, requestID: QuestionID,
answers: Schema.Array(Answer), answers: Schema.Array(Answer),
}).annotate({ identifier: "QuestionReplied" }) }) {}
const Rejected = Schema.Struct({ class Rejected extends Schema.Class<Rejected>("QuestionRejected")({
sessionID: SessionID, sessionID: SessionID,
requestID: QuestionID, requestID: QuestionID,
}).annotate({ identifier: "QuestionRejected" }) }) {}
export const Event = { export const Event = {
Asked: BusEvent.define("question.asked", Request), Asked: BusEvent.define("question.asked", Request),
@@ -155,12 +146,12 @@ export const layer = Layer.effect(
log.info("asking", { id, questions: input.questions.length }) log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>() const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
const info: Request = { const info = Schema.decodeUnknownSync(Request)({
id, id,
sessionID: input.sessionID, sessionID: input.sessionID,
questions: input.questions, questions: input.questions,
tool: input.tool, tool: input.tool,
} })
pending.set(id, { info, deferred }) pending.set(id, { info, deferred })
yield* bus.publish(Event.Asked, info) yield* bus.publish(Event.Asked, info)
@@ -2,7 +2,7 @@ import { SessionID } from "@/session/schema"
import { SessionMessage } from "@opencode-ai/core/session-message" import { SessionMessage } from "@opencode-ai/core/session-message"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors" import { InvalidCursorError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization" import { V2Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
@@ -36,7 +36,7 @@ export const MessageGroup = HttpApiGroup.make("v2.message")
next: Schema.String.pipe(Schema.optional), next: Schema.String.pipe(Schema.optional),
}), }),
}).annotate({ identifier: "V2SessionMessagesResponse" }), }).annotate({ identifier: "V2SessionMessagesResponse" }),
error: [InvalidCursorError, SessionNotFoundError, UnknownError], error: InvalidCursorError,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.messages", identifier: "v2.session.messages",
@@ -4,13 +4,7 @@ import { Prompt } from "@opencode-ai/core/session-prompt"
import { SessionV2 } from "@/v2/session" import { SessionV2 } from "@/v2/session"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { import { InvalidCursorError, InvalidRequestError } from "../../errors"
InvalidCursorError,
InvalidRequestError,
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
} from "../../errors"
import { V2Authorization } from "../../middleware/authorization" import { V2Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
import { QueryBoolean } from "../query" import { QueryBoolean } from "../query"
@@ -67,7 +61,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
delivery: SessionV2.Delivery.pipe(Schema.optional), delivery: SessionV2.Delivery.pipe(Schema.optional),
}), }),
success: SessionMessage.Message, success: SessionMessage.Message,
error: [SessionNotFoundError, ServiceUnavailableError],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.prompt", identifier: "v2.session.prompt",
@@ -81,7 +74,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery, query: WorkspaceRoutingQuery,
success: HttpApiSchema.NoContent, success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.compact", identifier: "v2.session.compact",
@@ -95,7 +87,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery, query: WorkspaceRoutingQuery,
success: HttpApiSchema.NoContent, success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.wait", identifier: "v2.session.wait",
@@ -109,7 +100,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery, query: WorkspaceRoutingQuery,
success: Schema.Array(SessionMessage.Message), success: Schema.Array(SessionMessage.Message),
error: [SessionNotFoundError, UnknownError],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.context", identifier: "v2.session.context",
@@ -4,7 +4,7 @@ import { Effect, Schema } from "effect"
import * as DateTime from "effect/DateTime" import * as DateTime from "effect/DateTime"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api" import { InstanceHttpApi } from "../../api"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors" import { InvalidCursorError } from "../../errors"
const DefaultMessagesLimit = 50 const DefaultMessagesLimit = 50
@@ -42,37 +42,12 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
catch: () => new InvalidCursorError({ message: "Invalid cursor" }), catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
}) })
const order = decoded?.order ?? ctx.query.order ?? "desc" const order = decoded?.order ?? ctx.query.order ?? "desc"
const messages = yield* session const messages = yield* session.messages({
.messages({ sessionID: ctx.params.sessionID,
sessionID: ctx.params.sessionID, limit: ctx.query.limit ?? DefaultMessagesLimit,
limit: ctx.query.limit ?? DefaultMessagesLimit, order,
order, cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined,
cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, })
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode v2 session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
const first = messages[0] const first = messages[0]
const last = messages.at(-1) const last = messages.at(-1)
return { return {
@@ -3,13 +3,7 @@ import { SessionV2 } from "@/v2/session"
import { DateTime, Effect, Option, Schema } from "effect" import { DateTime, Effect, Option, Schema } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api" import { InstanceHttpApi } from "../../api"
import { import { InvalidCursorError, InvalidRequestError } from "../../errors"
InvalidCursorError,
InvalidRequestError,
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
} from "../../errors"
const DefaultSessionsLimit = 50 const DefaultSessionsLimit = 50
@@ -139,107 +133,31 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
.handle( .handle(
"prompt", "prompt",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
return yield* session return yield* session.prompt({
.prompt({ sessionID: ctx.params.sessionID,
sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt,
prompt: ctx.payload.prompt, delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery,
delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery, })
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.OperationUnavailableError", (error) =>
Effect.fail(
new ServiceUnavailableError({
message: `V2 session ${error.operation} is not available yet`,
service: `v2.session.${error.operation}`,
}),
),
),
)
}), }),
) )
.handle( .handle(
"compact", "compact",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
yield* session.compact(ctx.params.sessionID).pipe( yield* session.compact(ctx.params.sessionID)
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.OperationUnavailableError", (error) =>
Effect.fail(
new ServiceUnavailableError({
message: `V2 session ${error.operation} is not available yet`,
service: `v2.session.${error.operation}`,
}),
),
),
)
return HttpApiSchema.NoContent.make() return HttpApiSchema.NoContent.make()
}), }),
) )
.handle( .handle(
"wait", "wait",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
yield* session.wait(ctx.params.sessionID).pipe( yield* session.wait(ctx.params.sessionID)
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.OperationUnavailableError", (error) =>
Effect.fail(
new ServiceUnavailableError({
message: `V2 session ${error.operation} is not available yet`,
service: `v2.session.${error.operation}`,
}),
),
),
)
return HttpApiSchema.NoContent.make() return HttpApiSchema.NoContent.make()
}), }),
) )
.handle( .handle(
"context", "context",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
return yield* session.context(ctx.params.sessionID).pipe( return yield* session.context(ctx.params.sessionID)
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode v2 session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
}), }),
) )
}), }),
@@ -1,5 +1,6 @@
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { ConfigError } from "@/config/error"
import { Cause, Effect } from "effect" import { Cause, Effect } from "effect"
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
@@ -18,15 +19,19 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
if (!defect) return Effect.failCause(cause) if (!defect) return Effect.failCause(cause)
const error = defect.defect const error = defect.defect
const ref = `err_${crypto.randomUUID().slice(0, 8)}` if (
error instanceof NamedError &&
(ConfigError.InvalidError.isInstance(error) || ConfigError.JsonError.isInstance(error))
) {
return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 400 }))
}
log.error("failed", { ref, error, cause: Cause.pretty(cause) }) log.error("failed", { error, cause: Cause.pretty(cause) })
return Effect.succeed( return Effect.succeed(
HttpServerResponse.jsonUnsafe( HttpServerResponse.jsonUnsafe(
new NamedError.Unknown({ new NamedError.Unknown({
message: "Unexpected server error. Check server logs for details.", message: "Unexpected server error. Check server logs for details.",
ref,
}).toObject(), }).toObject(),
{ status: 500 }, { status: 500 },
), ),
+1 -11
View File
@@ -1,5 +1,4 @@
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { serviceUse } from "@/effect/service-use"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
@@ -55,8 +54,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
export const use = serviceUse(Service)
const live: Layer.Layer< const live: Layer.Layer<
Service, Service,
never, never,
@@ -223,15 +220,8 @@ const live: Layer.Layer<
provider: item, provider: item,
auth: info, auth: info,
llmClient, llmClient,
messages: prepared.messages, request: prepared,
tools: prepared.tools,
toolChoice: input.toolChoice, toolChoice: input.toolChoice,
temperature: prepared.params.temperature,
topP: prepared.params.topP,
topK: prepared.params.topK,
maxOutputTokens: prepared.params.maxOutputTokens,
providerOptions: prepared.params.options,
headers: prepared.headers,
abort: input.abort, abort: input.abort,
}) })
if (native.type === "supported") { if (native.type === "supported") {
@@ -12,26 +12,25 @@ import {
import type { ModelMessage } from "ai" import type { ModelMessage } from "ai"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import type { Prepared as PreparedRequest } from "./request"
type ToolInput = { type ToolInput = {
readonly description?: string readonly description?: string
readonly inputSchema?: unknown readonly inputSchema?: unknown
} }
export type RequestInput = { type GenerationInput = Omit<PreparedRequest["params"], "options">
export type RequestInput = GenerationInput & {
readonly model: Provider.Model readonly model: Provider.Model
readonly apiKey?: string readonly apiKey?: string
readonly baseURL?: string readonly baseURL?: string
readonly system?: readonly string[] readonly system?: PreparedRequest["system"]
readonly messages: readonly ModelMessage[] readonly messages: PreparedRequest["messages"]
readonly tools?: Record<string, ToolInput> readonly tools?: Record<string, ToolInput>
readonly toolChoice?: "auto" | "required" | "none" readonly toolChoice?: "auto" | "required" | "none"
readonly temperature?: number
readonly topP?: number
readonly topK?: number
readonly maxOutputTokens?: number
readonly providerOptions?: LLMRequest["providerOptions"] readonly providerOptions?: LLMRequest["providerOptions"]
readonly headers?: Record<string, string> readonly headers?: PreparedRequest["headers"]
} }
const providerMetadata = (value: unknown): ProviderMetadata | undefined => { const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
@@ -3,13 +3,13 @@ import type { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform" import { ProviderTransform } from "@/provider/transform"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { asSchema, type ModelMessage, type Tool } from "ai" import { asSchema, type Tool } from "ai"
import { Effect } from "effect" import { Effect } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { FetchHttpClient } from "effect/unstable/http"
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm" import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
import type { LLMClientShape } from "@opencode-ai/llm/route" import type { LLMClientShape } from "@opencode-ai/llm/route"
import { LLMNative } from "./native-request" import { LLMNative } from "./native-request"
import type { Prepared as PreparedRequest } from "./request"
export type RuntimeStatus = export type RuntimeStatus =
| { readonly type: "supported"; readonly apiKey: string; readonly baseURL?: string } | { readonly type: "supported"; readonly apiKey: string; readonly baseURL?: string }
@@ -23,35 +23,24 @@ type StreamInput = {
readonly provider: Provider.Info readonly provider: Provider.Info
readonly auth: Auth.Info | undefined readonly auth: Auth.Info | undefined
readonly llmClient: LLMClientShape readonly llmClient: LLMClientShape
readonly messages: ModelMessage[] readonly request: PreparedRequest
readonly tools: Record<string, Tool>
readonly toolChoice?: "auto" | "required" | "none" readonly toolChoice?: "auto" | "required" | "none"
readonly temperature?: number readonly abort: AbortSignal
readonly topP?: number }
readonly topK?: number
readonly maxOutputTokens?: number type ToolContext = {
readonly providerOptions?: Record<string, any> readonly messages: PreparedRequest["messages"]
readonly headers: Record<string, string>
readonly abort: AbortSignal readonly abort: AbortSignal
} }
export function status(input: Pick<StreamInput, "model" | "provider" | "auth">): RuntimeStatus { export function status(input: Pick<StreamInput, "model" | "provider" | "auth">): RuntimeStatus {
return statusWithFetch(input, providerFetch(input))
}
function statusWithFetch(
input: Pick<StreamInput, "model" | "provider" | "auth">,
fetch: typeof globalThis.fetch | undefined,
): RuntimeStatus {
const providerID = input.model.providerID const providerID = input.model.providerID
if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode")) if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode"))
return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" } return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }
const npm = input.model.api.npm const npm = input.model.api.npm
if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic") if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic")
return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" } return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" }
if (input.auth?.type === "oauth" && !(input.provider.id === "openai" && fetch)) { if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OAuth auth is not supported" }
return { type: "unsupported", reason: "OAuth auth requires a provider fetch override" }
}
const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key
if (!apiKey) return { type: "unsupported", reason: "API key is not configured" } if (!apiKey) return { type: "unsupported", reason: "API key is not configured" }
@@ -64,42 +53,34 @@ function statusWithFetch(
} }
export function stream(input: StreamInput): StreamResult { export function stream(input: StreamInput): StreamResult {
const fetch = providerFetch(input) const current = status(input)
const current = statusWithFetch(input, fetch)
if (current.type === "unsupported") return current if (current.type === "unsupported") return current
// Integration point with @opencode-ai/llm: native-request lowers session data // Integration point with @opencode-ai/llm: native-request lowers session data
// into an LLMRequest, then LLMClient handles route selection and transport. // into an LLMRequest, then LLMClient handles route selection and transport.
const stream = input.llmClient.stream({
request: LLMNative.request({
model: input.model,
apiKey: current.apiKey,
baseURL: current.baseURL,
messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}),
toolChoice: input.toolChoice,
temperature: input.temperature,
topP: input.topP,
topK: input.topK,
maxOutputTokens: input.maxOutputTokens,
providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),
headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },
}),
tools: nativeTools(input.tools, input),
})
return { return {
...current, ...current,
stream: fetch ? stream.pipe(Stream.provideService(FetchHttpClient.Fetch, fetch)) : stream, stream: input.llmClient.stream({
request: LLMNative.request({
model: input.model,
apiKey: current.apiKey,
baseURL: current.baseURL,
system: [],
messages: ProviderTransform.message(input.request.messages, input.model, input.request.params.options),
tools: input.request.tools,
toolChoice: input.toolChoice,
temperature: input.request.params.temperature,
topP: input.request.params.topP,
topK: input.request.params.topK,
maxOutputTokens: input.request.params.maxOutputTokens,
providerOptions: ProviderTransform.providerOptions(input.model, input.request.params.options),
headers: { ...providerHeaders(input.provider.options.headers), ...input.request.headers },
}),
tools: nativeTools(input.request.tools, { messages: input.request.messages, abort: input.abort }),
}),
} }
} }
function providerFetch(input: Pick<StreamInput, "provider" | "auth">): typeof globalThis.fetch | undefined {
if (input.provider.id !== "openai" || input.auth?.type !== "oauth") return undefined
const value: unknown = input.provider.options.fetch
if (typeof value !== "function") return undefined
return value as typeof globalThis.fetch
}
function providerHeaders(value: unknown): Record<string, string> | undefined { function providerHeaders(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) return undefined if (!isRecord(value)) return undefined
return Object.fromEntries( return Object.fromEntries(
@@ -114,7 +95,7 @@ function nativeSchema(value: unknown): JsonSchema {
return asSchema(value as Parameters<typeof asSchema>[0]).jsonSchema as JsonSchema return asSchema(value as Parameters<typeof asSchema>[0]).jsonSchema as JsonSchema
} }
export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput, "messages" | "abort">) { export function nativeTools(tools: Record<string, Tool>, input: ToolContext) {
return Object.fromEntries( return Object.fromEntries(
Object.entries(tools).map(([name, item]) => [ Object.entries(tools).map(([name, item]) => [
name, name,
+2 -2
View File
@@ -122,7 +122,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
}, },
) )
const { headers } = yield* input.plugin.trigger( const { headers: pluginHeaders } = yield* input.plugin.trigger(
"chat.headers", "chat.headers",
{ {
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -180,7 +180,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
"User-Agent": USER_AGENT, "User-Agent": USER_AGENT,
}), }),
...input.model.headers, ...input.model.headers,
...headers, ...pluginHeaders,
}, },
} }
}) })
@@ -647,7 +647,6 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/anthropic") return true
if (model.api.npm === "@ai-sdk/openai") return true if (model.api.npm === "@ai-sdk/openai") return true
if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/") if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/")
if (model.api.npm === "@ai-sdk/xai") return attachment.mime.startsWith("image/")
if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true
if (model.api.npm === "@ai-sdk/google") { if (model.api.npm === "@ai-sdk/google") {
const id = model.api.id.toLowerCase() const id = model.api.id.toLowerCase()
-3
View File
@@ -1,5 +1,4 @@
import { Slug } from "@opencode-ai/core/util/slug" import { Slug } from "@opencode-ai/core/util/slug"
import { serviceUse } from "@/effect/service-use"
import path from "path" import path from "path"
import { BackgroundJob } from "@/background/job" import { BackgroundJob } from "@/background/job"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
@@ -501,8 +500,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {}
export const use = serviceUse(Service)
export type Patch = Types.DeepMutable<SyncEvent.Event<typeof Event.Updated>["data"]["info"]> export type Patch = Types.DeepMutable<SyncEvent.Event<typeof Event.Updated>["data"]["info"]>
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) => const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
@@ -1,5 +1,4 @@
import type * as SDK from "@opencode-ai/sdk/v2" import type * as SDK from "@opencode-ai/sdk/v2"
import { serviceUse } from "@/effect/service-use"
import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect" import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Account } from "@/account/account" import { Account } from "@/account/account"
@@ -77,8 +76,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ShareNext") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ShareNext") {}
export const use = serviceUse(Service)
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) => const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn)) Effect.sync(() => Database.use(fn))

Some files were not shown because too many files have changed in this diff Show More