Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ecf27f389 | ||
|
|
548446a990 |
@@ -255,7 +255,6 @@
|
|||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.15.6",
|
"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",
|
||||||
@@ -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
@@ -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
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,158 +1284,6 @@ 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>
|
|
||||||
<Match when={USE_V2_INPUT}>
|
|
||||||
<DockShellForm
|
|
||||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
classList={{
|
|
||||||
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": 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
|
|
||||||
class="relative min-h-[52px]"
|
|
||||||
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-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
|
||||||
<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 class="flex h-11 items-center px-2">
|
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-0">
|
|
||||||
{fileAttachmentInput()}
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
title={language.t("prompt.action.attachFile")}
|
|
||||||
keybind={command.keybind("file.attach")}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
data-action="prompt-attach"
|
|
||||||
type="button"
|
|
||||||
icon="plus"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
|
|
||||||
style={buttons()}
|
|
||||||
onClick={pick}
|
|
||||||
disabled={store.mode !== "normal"}
|
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
|
||||||
aria-label={language.t("prompt.action.attachFile")}
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
<Show when={newSession()}>
|
|
||||||
<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="sliders" size="small" />
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
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()}>
|
|
||||||
<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-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
|
||||||
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")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</DockShellForm>
|
|
||||||
</Match>
|
|
||||||
<Match when>
|
|
||||||
<DockShellForm
|
<DockShellForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
classList={{
|
classList={{
|
||||||
@@ -1570,9 +1295,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<PromptDragOverlay
|
<PromptDragOverlay
|
||||||
type={store.draggingType}
|
type={store.draggingType}
|
||||||
label={language.t(
|
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
|
||||||
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<PromptContextItems
|
<PromptContextItems
|
||||||
items={contextItems()}
|
items={contextItems()}
|
||||||
@@ -1887,8 +1610,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</DockTray>
|
</DockTray>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,9 +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"
|
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
startDragging?: () => Promise<void>
|
startDragging?: () => Promise<void>
|
||||||
@@ -44,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()
|
||||||
@@ -71,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`
|
||||||
@@ -197,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[]>(
|
||||||
@@ -247,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),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -289,15 +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 tabsEnriched = iife(() => {
|
const tabsEnriched = iife(() => {
|
||||||
const base = mapArray(
|
const base = mapArray(
|
||||||
() => tabsStore,
|
() => tabsStore,
|
||||||
@@ -312,40 +267,36 @@ 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}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path
|
||||||
|
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"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-miterlimit="10"
|
||||||
|
stroke-linecap="square"
|
||||||
/>
|
/>
|
||||||
|
</svg>
|
||||||
<div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden">
|
</IconButtonV2>
|
||||||
<div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<For each={tabsEnriched()}>
|
<For each={tabsEnriched()}>
|
||||||
{(tab, i) => (
|
{(tab, i) => (
|
||||||
<>
|
<>
|
||||||
{i() !== 0 && (
|
{i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
|
||||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
|
||||||
)}
|
|
||||||
<TabNavItem
|
<TabNavItem
|
||||||
href={tab.href}
|
href={tab.href}
|
||||||
title={tab.info.title}
|
title={tab.info.title}
|
||||||
project={projectForSession(tab.info, projects(), projectByID())}
|
|
||||||
directory={tab.dir}
|
|
||||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||||
hideClose={tabsEnriched().length < 2}
|
hideClose={tabsEnriched().length < 2}
|
||||||
/>
|
/>
|
||||||
@@ -353,32 +304,41 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<button>
|
||||||
when={creating() && params.dir}
|
<div class="p-1.5">
|
||||||
fallback={
|
<svg
|
||||||
<IconButtonV2
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
type="button"
|
width="16"
|
||||||
variant="ghost-muted"
|
height="16"
|
||||||
size="large"
|
viewBox="0 0 16 16"
|
||||||
class="shrink-0"
|
fill="none"
|
||||||
icon={<IconV2 name="plus" />}
|
class="size-4"
|
||||||
onClick={openNewSession}
|
|
||||||
aria-label={language.t("command.session.new")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<NewSessionTabItem
|
<path
|
||||||
href={`/${params.dir}/session`}
|
d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
|
||||||
title={language.t("command.session.new")}
|
stroke="#808080"
|
||||||
onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
|
stroke-linejoin="round"
|
||||||
/>
|
/>
|
||||||
</Show>
|
</svg>
|
||||||
<div class="min-w-0 flex-1" />
|
|
||||||
</div>
|
</div>
|
||||||
<TitlebarUpdatePill update={props.update} />
|
</button>
|
||||||
<Show when={windows() && !electronWindows()}>
|
|
||||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
<div class="flex-1" />
|
||||||
</Show>
|
{/*<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>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -398,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"
|
||||||
@@ -542,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 pr-8 [--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 leading-none tracking-[-0.04px] 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="truncate">{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">
|
||||||
@@ -602,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
|
||||||
</div>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</div>
|
width="16"
|
||||||
)
|
height="16"
|
||||||
}
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
function ProjectTabAvatar(props: { project?: LocalProject; directory: string }) {
|
class="size-4"
|
||||||
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 w-[135px] min-w-24 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">
|
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
|
||||||
<IconV2 name="edit" />
|
</svg>
|
||||||
</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>
|
||||||
</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,22 +45,6 @@ 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" ? (
|
|
||||||
<div
|
|
||||||
data-component="desktop-icon-button"
|
|
||||||
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
|
|
||||||
>
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButtonV2}
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="large"
|
|
||||||
icon={<IconV2 name="menu" />}
|
|
||||||
aria-label="OpenCode menu"
|
|
||||||
onPointerDown={rememberFocus}
|
|
||||||
onKeyDown={rememberFocus}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
icon="menu"
|
icon="menu"
|
||||||
@@ -73,7 +54,6 @@ export function WindowsAppMenu(props: {
|
|||||||
onPointerDown={rememberFocus}
|
onPointerDown={rememberFocus}
|
||||||
onKeyDown={rememberFocus}
|
onKeyDown={rememberFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content class="desktop-app-menu">
|
<DropdownMenu.Content class="desktop-app-menu">
|
||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -101,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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "审查",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
+37
-605
@@ -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">
|
|
||||||
<Logo class="w-48 opacity-15" />
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
size="normal"
|
size="large"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="text-12-regular text-text-weak px-3"
|
class="mt-4 mx-auto text-14-regular text-text-weak"
|
||||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"size-1.5 rounded-full mr-2": true,
|
"size-2 rounded-full": true,
|
||||||
[serverDotClass()]: true,
|
[serverDotClass()]: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{server.name}
|
{server.name}
|
||||||
</Button>
|
</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"
|
|
||||||
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>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
<For each={recent()}>
|
<For each={recent()}>
|
||||||
{(project) => (
|
{(project) => (
|
||||||
<button
|
<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"
|
size="large"
|
||||||
onClick={() => {
|
variant="ghost"
|
||||||
openProject(project.worktree)
|
class="text-14-mono text-left justify-between px-3"
|
||||||
setShowProjectsDropdown(false)
|
onClick={() => openProject(project.worktree)}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span class="truncate">{getFilename(project.worktree)}</span>
|
{project.worktree.replace(homedir(), "~")}
|
||||||
<span class="text-10-regular text-text-weak shrink-0 pl-2">
|
<div class="text-14-regular text-text-weak">
|
||||||
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
||||||
</span>
|
</div>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<div class="border-t border-border-weak-base my-1" />
|
</ul>
|
||||||
<button
|
</div>
|
||||||
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"
|
</Match>
|
||||||
onClick={() => {
|
<Match when={!sync.ready}>
|
||||||
setShowProjectsDropdown(false)
|
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||||
chooseProject()
|
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||||
}}
|
<Button class="px-3" onClick={chooseProject}>
|
||||||
>
|
|
||||||
<Icon name="folder-add-left" size="small" />
|
|
||||||
{language.t("command.project.open")}
|
{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>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
<Button class="px-3 mt-1" onClick={chooseProject}>
|
||||||
<div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
|
{language.t("command.project.open")}
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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-bar"
|
data-component="session-progress"
|
||||||
|
data-state={workingStatus()}
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
background: tint() ?? "var(--icon-interactive-base)",
|
"--session-progress-color": tint() ?? "var(--icon-interactive-base)",
|
||||||
animation: `session-progress-whip ${bar.ms}ms infinite`,
|
"--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">
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,10 +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 { exportDebugLogs, write as writeLog } from "./logging"
|
|
||||||
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")
|
||||||
@@ -13,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([
|
||||||
{
|
{
|
||||||
@@ -28,17 +24,9 @@ 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 titlebarHeight = 40
|
const titlebarHeight = 40
|
||||||
|
|
||||||
export function setRelaunchHandler(handler: () => void) {
|
|
||||||
relaunchHandler = handler
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setBackgroundColor(color: string) {
|
export function setBackgroundColor(color: string) {
|
||||||
backgroundColor = color
|
backgroundColor = color
|
||||||
}
|
}
|
||||||
@@ -124,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
|
||||||
@@ -134,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 })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -177,7 +165,6 @@ export function createLoadingWindow() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
allowRendererPermissions(win)
|
allowRendererPermissions(win)
|
||||||
wireWindowRecovery(win, "loading")
|
|
||||||
|
|
||||||
loadWindow(win, "loading.html")
|
loadWindow(win, "loading.html")
|
||||||
|
|
||||||
@@ -187,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 })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,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(
|
||||||
@@ -372,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
|
||||||
|
|||||||
@@ -67,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)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -86,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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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* () {
|
||||||
|
|||||||
@@ -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* () {
|
||||||
|
|||||||
@@ -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* () {
|
||||||
|
|||||||
@@ -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* () {
|
||||||
|
|||||||
@@ -543,22 +543,8 @@ export function Autocomplete(props: {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
function insertSlashCommand(name: string) {
|
|
||||||
const newText = `/${name} `
|
|
||||||
const cursor = props.input().logicalCursor
|
|
||||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
|
||||||
props.input().insertText(newText)
|
|
||||||
props.input().cursorOffset = Bun.stringWidth(newText)
|
|
||||||
}
|
|
||||||
|
|
||||||
const commands = createMemo((): AutocompleteOption[] => {
|
const commands = createMemo((): AutocompleteOption[] => {
|
||||||
const results: AutocompleteOption[] = slashes().map((command) => {
|
const results: AutocompleteOption[] = [...slashes()]
|
||||||
if (!command.input) return command
|
|
||||||
return {
|
|
||||||
...command,
|
|
||||||
onSelect: () => insertSlashCommand(command.name),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const serverCommand of sync.data.command) {
|
for (const serverCommand of sync.data.command) {
|
||||||
if (serverCommand.source === "skill") continue
|
if (serverCommand.source === "skill") continue
|
||||||
@@ -566,7 +552,13 @@ export function Autocomplete(props: {
|
|||||||
results.push({
|
results.push({
|
||||||
display: "/" + serverCommand.name + label,
|
display: "/" + serverCommand.name + label,
|
||||||
description: serverCommand.description,
|
description: serverCommand.description,
|
||||||
onSelect: () => insertSlashCommand(serverCommand.name),
|
onSelect: () => {
|
||||||
|
const newText = "/" + serverCommand.name + " "
|
||||||
|
const cursor = props.input().logicalCursor
|
||||||
|
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||||
|
props.input().insertText(newText)
|
||||||
|
props.input().cursorOffset = Bun.stringWidth(newText)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,14 +59,7 @@ import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
|||||||
import { useArgs } from "@tui/context/args"
|
import { useArgs } from "@tui/context/args"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { type WorkspaceStatus } from "../workspace-label"
|
import { type WorkspaceStatus } from "../workspace-label"
|
||||||
import {
|
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
|
||||||
OPENCODE_BASE_MODE,
|
|
||||||
useBindings,
|
|
||||||
useCommandShortcut,
|
|
||||||
useCommandSlashes,
|
|
||||||
useLeaderActive,
|
|
||||||
useOpencodeKeymap,
|
|
||||||
} from "../../keymap"
|
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
@@ -158,7 +151,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
const history = usePromptHistory()
|
const history = usePromptHistory()
|
||||||
const stash = usePromptStash()
|
const stash = usePromptStash()
|
||||||
const keymap = useOpencodeKeymap()
|
const keymap = useOpencodeKeymap()
|
||||||
const slashCommands = useCommandSlashes()
|
|
||||||
const agentShortcut = useCommandShortcut("agent.cycle")
|
const agentShortcut = useCommandShortcut("agent.cycle")
|
||||||
const paletteShortcut = useCommandShortcut("command.palette.show")
|
const paletteShortcut = useCommandShortcut("command.palette.show")
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
@@ -1140,8 +1132,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const slash = parseSlashCommand(inputText)
|
|
||||||
|
|
||||||
if (store.mode === "shell") {
|
if (store.mode === "shell") {
|
||||||
void sdk.client.session.shell({
|
void sdk.client.session.shell({
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -1153,13 +1143,25 @@ export function Prompt(props: PromptProps) {
|
|||||||
command: inputText,
|
command: inputText,
|
||||||
})
|
})
|
||||||
setStore("mode", "normal")
|
setStore("mode", "normal")
|
||||||
} else if (slash && slashCommand(slash.name)?.input) {
|
} else if (
|
||||||
slashCommand(slash.name)?.onSelect()
|
inputText.startsWith("/") &&
|
||||||
} else if (slash && sync.data.command.some((x) => x.name === slash.name)) {
|
iife(() => {
|
||||||
|
const firstLine = inputText.split("\n")[0]
|
||||||
|
const command = firstLine.split(" ")[0].slice(1)
|
||||||
|
return sync.data.command.some((x) => x.name === command)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
// Parse command from first line, preserve multi-line content in arguments
|
||||||
|
const firstLineEnd = inputText.indexOf("\n")
|
||||||
|
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
|
||||||
|
const [command, ...firstLineArgs] = firstLine.split(" ")
|
||||||
|
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
||||||
|
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
||||||
|
|
||||||
void sdk.client.session.command({
|
void sdk.client.session.command({
|
||||||
sessionID,
|
sessionID,
|
||||||
command: slash.name,
|
command: command.slice(1),
|
||||||
arguments: slash.arguments,
|
arguments: args,
|
||||||
agent: agent.name,
|
agent: agent.name,
|
||||||
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
||||||
messageID,
|
messageID,
|
||||||
@@ -1218,22 +1220,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
input.clear()
|
input.clear()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSlashCommand(text: string) {
|
|
||||||
if (!text.startsWith("/")) return
|
|
||||||
const firstLineEnd = text.indexOf("\n")
|
|
||||||
const firstLine = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd)
|
|
||||||
const [command, ...firstLineArgs] = firstLine.split(" ")
|
|
||||||
const restOfInput = firstLineEnd === -1 ? "" : text.slice(firstLineEnd + 1)
|
|
||||||
return {
|
|
||||||
name: command.slice(1),
|
|
||||||
arguments: firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : ""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function slashCommand(name: string) {
|
|
||||||
return slashCommands().find((item) => item.name === name || item.aliases?.includes(`/${name}`))
|
|
||||||
}
|
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
|
|
||||||
function pasteText(text: string, virtualText: string) {
|
function pasteText(text: string, virtualText: string) {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ export { useBindings, useKeymapSelector }
|
|||||||
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
||||||
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
|
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
|
||||||
type CommandSlashEntry = {
|
type CommandSlashEntry = {
|
||||||
name: string
|
|
||||||
display: string
|
display: string
|
||||||
description?: string
|
description?: string
|
||||||
aliases?: string[]
|
aliases?: string[]
|
||||||
input?: boolean
|
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
}
|
}
|
||||||
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
|
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
|
||||||
@@ -258,7 +256,6 @@ export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
|
|||||||
if (typeof slashName !== "string" || !slashName) return []
|
if (typeof slashName !== "string" || !slashName) return []
|
||||||
const slashAliases = entry.command.slashAliases
|
const slashAliases = entry.command.slashAliases
|
||||||
return {
|
return {
|
||||||
name: slashName,
|
|
||||||
display: `/${slashName}`,
|
display: `/${slashName}`,
|
||||||
description:
|
description:
|
||||||
typeof entry.command.desc === "string"
|
typeof entry.command.desc === "string"
|
||||||
@@ -269,7 +266,6 @@ export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
|
|||||||
aliases: Array.isArray(slashAliases)
|
aliases: Array.isArray(slashAliases)
|
||||||
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
|
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
|
||||||
: undefined,
|
: undefined,
|
||||||
input: entry.command.slashInput === true,
|
|
||||||
onSelect: () => keymap.dispatchCommand(entry.command.name),
|
onSelect: () => keymap.dispatchCommand(entry.command.name),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -314,17 +314,6 @@ export function Session() {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
||||||
function slashArguments(name: string) {
|
|
||||||
const input = prompt?.current.input
|
|
||||||
if (!input?.startsWith("/")) return
|
|
||||||
const firstLineEnd = input.indexOf("\n")
|
|
||||||
const firstLine = firstLineEnd === -1 ? input : input.slice(0, firstLineEnd)
|
|
||||||
const [command, ...firstLineArgs] = firstLine.split(" ")
|
|
||||||
if (command !== `/${name}`) return
|
|
||||||
const restOfInput = firstLineEnd === -1 ? "" : input.slice(firstLineEnd + 1)
|
|
||||||
return firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
|
||||||
}
|
|
||||||
|
|
||||||
event.on("session.status", (evt) => {
|
event.on("session.status", (evt) => {
|
||||||
if (evt.properties.sessionID !== route.sessionID) return
|
if (evt.properties.sessionID !== route.sessionID) return
|
||||||
if (evt.properties.status.type !== "retry") return
|
if (evt.properties.status.type !== "retry") return
|
||||||
@@ -557,7 +546,6 @@ export function Session() {
|
|||||||
slash: {
|
slash: {
|
||||||
name: "compact",
|
name: "compact",
|
||||||
aliases: ["summarize"],
|
aliases: ["summarize"],
|
||||||
input: true,
|
|
||||||
},
|
},
|
||||||
run: () => {
|
run: () => {
|
||||||
const selectedModel = local.model.current()
|
const selectedModel = local.model.current()
|
||||||
@@ -569,14 +557,11 @@ export function Session() {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const instructions = slashArguments("compact") ?? slashArguments("summarize")
|
void sdk.client.session.summarize({
|
||||||
const payload = {
|
|
||||||
sessionID: route.sessionID,
|
sessionID: route.sessionID,
|
||||||
modelID: selectedModel.modelID,
|
modelID: selectedModel.modelID,
|
||||||
providerID: selectedModel.providerID,
|
providerID: selectedModel.providerID,
|
||||||
...(instructions?.trim() ? { $body_instructions: instructions.trim() } : {}),
|
})
|
||||||
} satisfies Parameters<typeof sdk.client.session.summarize>[0] & { $body_instructions?: string }
|
|
||||||
void sdk.client.session.summarize(payload)
|
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1061,7 +1046,6 @@ export function Session() {
|
|||||||
desc: "description" in command ? command.description : undefined,
|
desc: "description" in command ? command.description : undefined,
|
||||||
slashName: "slash" in command ? command.slash?.name : undefined,
|
slashName: "slash" in command ? command.slash?.name : undefined,
|
||||||
slashAliases: "slash" in command ? command.slash?.aliases : undefined,
|
slashAliases: "slash" in command ? command.slash?.aliases : undefined,
|
||||||
slashInput: "slash" in command ? command.slash?.input : undefined,
|
|
||||||
...command,
|
...command,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
@@ -1581,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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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* () {
|
||||||
|
|||||||
Vendored
-3
@@ -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* () {
|
||||||
|
|||||||
@@ -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* () {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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* () {
|
||||||
|
|||||||
@@ -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* () {
|
||||||
|
|||||||
@@ -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* () {
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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,11 +429,9 @@ 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")
|
||||||
refreshPromise = refreshAccessToken(currentAuth.refresh, issuer)
|
const tokens = await refreshAccessToken(currentAuth.refresh)
|
||||||
.then(async (tokens) => {
|
const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
|
||||||
const accountId = extractAccountId(tokens) || authWithAccount.accountId
|
|
||||||
await input.client.auth.set({
|
await input.client.auth.set({
|
||||||
path: { id: "openai" },
|
path: { id: "openai" },
|
||||||
body: {
|
body: {
|
||||||
@@ -456,22 +439,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
|||||||
refresh: tokens.refresh_token,
|
refresh: tokens.refresh_token,
|
||||||
access: tokens.access_token,
|
access: tokens.access_token,
|
||||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||||
...(accountId && { accountId }),
|
...(newAccountId && { accountId: newAccountId }),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
currentAuth.access = tokens.access_token
|
||||||
access: tokens.access_token,
|
authWithAccount.accountId = newAccountId
|
||||||
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, {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 "&"
|
|
||||||
case "<":
|
|
||||||
return "<"
|
|
||||||
case ">":
|
|
||||||
return ">"
|
|
||||||
case '"':
|
|
||||||
return """
|
|
||||||
case "'":
|
|
||||||
return "'"
|
|
||||||
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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export const SummarizePayload = Schema.Struct({
|
|||||||
providerID: ProviderID,
|
providerID: ProviderID,
|
||||||
modelID: ModelID,
|
modelID: ModelID,
|
||||||
auto: Schema.optional(Schema.Boolean),
|
auto: Schema.optional(Schema.Boolean),
|
||||||
instructions: Schema.optional(Schema.String),
|
|
||||||
})
|
})
|
||||||
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
|
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
|
||||||
export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"]))
|
export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"]))
|
||||||
|
|||||||
@@ -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 } 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],
|
error: InvalidCursorError,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "v2.session.messages",
|
identifier: "v2.session.messages",
|
||||||
|
|||||||
@@ -4,7 +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 { InvalidCursorError, InvalidRequestError, ServiceUnavailableError, SessionNotFoundError } from "../../errors"
|
import { InvalidCursorError, InvalidRequestError } 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"
|
||||||
@@ -61,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",
|
||||||
@@ -75,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",
|
||||||
@@ -89,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",
|
||||||
@@ -103,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,
|
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "v2.session.context",
|
identifier: "v2.session.context",
|
||||||
|
|||||||
@@ -277,7 +277,6 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
|||||||
modelID: ctx.payload.modelID,
|
modelID: ctx.payload.modelID,
|
||||||
},
|
},
|
||||||
auto: ctx.payload.auto ?? false,
|
auto: ctx.payload.auto ?? false,
|
||||||
instructions: ctx.payload.instructions,
|
|
||||||
})
|
})
|
||||||
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
|
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -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 } from "../../errors"
|
import { InvalidCursorError } from "../../errors"
|
||||||
|
|
||||||
const DefaultMessagesLimit = 50
|
const DefaultMessagesLimit = 50
|
||||||
|
|
||||||
@@ -42,23 +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}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const first = messages[0]
|
const first = messages[0]
|
||||||
const last = messages.at(-1)
|
const last = messages.at(-1)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,7 +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 { InvalidCursorError, InvalidRequestError, ServiceUnavailableError, SessionNotFoundError } from "../../errors"
|
import { InvalidCursorError, InvalidRequestError } from "../../errors"
|
||||||
|
|
||||||
const DefaultSessionsLimit = 50
|
const DefaultSessionsLimit = 50
|
||||||
|
|
||||||
@@ -133,93 +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}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -133,12 +133,6 @@ function buildPrompt(input: { previousSummary?: string; context: string[] }) {
|
|||||||
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
|
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendInstructions(prompt: string, instructions: string | undefined) {
|
|
||||||
const trimmed = instructions?.trim()
|
|
||||||
if (!trimmed) return prompt
|
|
||||||
return [prompt, "Additional user instructions for this compaction:", trimmed].join("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
|
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
|
||||||
return (
|
return (
|
||||||
input.cfg.compaction?.preserve_recent_tokens ??
|
input.cfg.compaction?.preserve_recent_tokens ??
|
||||||
@@ -208,7 +202,6 @@ export interface Interface {
|
|||||||
model: { providerID: ProviderID; modelID: ModelID }
|
model: { providerID: ProviderID; modelID: ModelID }
|
||||||
auto: boolean
|
auto: boolean
|
||||||
overflow?: boolean
|
overflow?: boolean
|
||||||
instructions?: string
|
|
||||||
}) => Effect.Effect<void>
|
}) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,10 +400,7 @@ export const layer = Layer.effect(
|
|||||||
{ sessionID: input.sessionID },
|
{ sessionID: input.sessionID },
|
||||||
{ context: [], prompt: undefined },
|
{ context: [], prompt: undefined },
|
||||||
)
|
)
|
||||||
const nextPrompt = appendInstructions(
|
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
|
||||||
compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }),
|
|
||||||
compactionPart?.instructions,
|
|
||||||
)
|
|
||||||
const msgs = structuredClone(selected.head)
|
const msgs = structuredClone(selected.head)
|
||||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||||
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
|
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
|
||||||
@@ -597,7 +587,6 @@ export const layer = Layer.effect(
|
|||||||
model: { providerID: ProviderID; modelID: ModelID }
|
model: { providerID: ProviderID; modelID: ModelID }
|
||||||
auto: boolean
|
auto: boolean
|
||||||
overflow?: boolean
|
overflow?: boolean
|
||||||
instructions?: string
|
|
||||||
}) {
|
}) {
|
||||||
const msg = yield* session.updateMessage({
|
const msg = yield* session.updateMessage({
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
@@ -614,7 +603,6 @@ export const layer = Layer.effect(
|
|||||||
type: "compaction",
|
type: "compaction",
|
||||||
auto: input.auto,
|
auto: input.auto,
|
||||||
overflow: input.overflow,
|
overflow: input.overflow,
|
||||||
instructions: input.instructions,
|
|
||||||
})
|
})
|
||||||
if (flags.experimentalEventSystem) {
|
if (flags.experimentalEventSystem) {
|
||||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||||
|
|||||||
@@ -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({
|
return {
|
||||||
|
...current,
|
||||||
|
stream: input.llmClient.stream({
|
||||||
request: LLMNative.request({
|
request: LLMNative.request({
|
||||||
model: input.model,
|
model: input.model,
|
||||||
apiKey: current.apiKey,
|
apiKey: current.apiKey,
|
||||||
baseURL: current.baseURL,
|
baseURL: current.baseURL,
|
||||||
messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}),
|
system: [],
|
||||||
|
messages: ProviderTransform.message(input.request.messages, input.model, input.request.params.options),
|
||||||
|
tools: input.request.tools,
|
||||||
toolChoice: input.toolChoice,
|
toolChoice: input.toolChoice,
|
||||||
temperature: input.temperature,
|
temperature: input.request.params.temperature,
|
||||||
topP: input.topP,
|
topP: input.request.params.topP,
|
||||||
topK: input.topK,
|
topK: input.request.params.topK,
|
||||||
maxOutputTokens: input.maxOutputTokens,
|
maxOutputTokens: input.request.params.maxOutputTokens,
|
||||||
providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),
|
providerOptions: ProviderTransform.providerOptions(input.model, input.request.params.options),
|
||||||
headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },
|
headers: { ...providerHeaders(input.provider.options.headers), ...input.request.headers },
|
||||||
|
}),
|
||||||
|
tools: nativeTools(input.request.tools, { messages: input.request.messages, abort: input.abort }),
|
||||||
}),
|
}),
|
||||||
tools: nativeTools(input.tools, input),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
stream: fetch ? stream.pipe(Stream.provideService(FetchHttpClient.Fetch, fetch)) : stream,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -186,7 +186,6 @@ export const CompactionPart = Schema.Struct({
|
|||||||
type: Schema.Literal("compaction"),
|
type: Schema.Literal("compaction"),
|
||||||
auto: Schema.Boolean,
|
auto: Schema.Boolean,
|
||||||
overflow: Schema.optional(Schema.Boolean),
|
overflow: Schema.optional(Schema.Boolean),
|
||||||
instructions: Schema.optional(Schema.String),
|
|
||||||
tail_start_id: Schema.optional(MessageID),
|
tail_start_id: Schema.optional(MessageID),
|
||||||
}).annotate({ identifier: "CompactionPart" })
|
}).annotate({ identifier: "CompactionPart" })
|
||||||
export type CompactionPart = Types.DeepMutable<Schema.Schema.Type<typeof CompactionPart>>
|
export type CompactionPart = Types.DeepMutable<Schema.Schema.Type<typeof CompactionPart>>
|
||||||
@@ -648,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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -13,24 +13,6 @@ interface Metadata {
|
|||||||
// TODO: remove this hack
|
// TODO: remove this hack
|
||||||
export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
|
export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
|
||||||
|
|
||||||
/**
|
|
||||||
* Raised when the LLM calls a tool with arguments that fail the parameter
|
|
||||||
* schema. This is the canonical "rewrite the input" tool error: the typed
|
|
||||||
* error class makes it matchable upstream, and its `message` getter produces
|
|
||||||
* the model-facing prose that the AI SDK feeds back as the tool result.
|
|
||||||
*/
|
|
||||||
export class InvalidArgumentsError extends Schema.TaggedErrorClass<InvalidArgumentsError>()(
|
|
||||||
"ToolInvalidArgumentsError",
|
|
||||||
{
|
|
||||||
tool: Schema.String,
|
|
||||||
detail: Schema.String,
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
override get message() {
|
|
||||||
return `The ${this.tool} tool was called with invalid arguments: ${this.detail}.\nPlease rewrite the input so it satisfies the expected schema.`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Context<M extends Metadata = Metadata> = {
|
export type Context<M extends Metadata = Metadata> = {
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
messageID: MessageID
|
messageID: MessageID
|
||||||
@@ -117,12 +99,13 @@ function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadat
|
|||||||
}
|
}
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const decoded = yield* decode(args).pipe(
|
const decoded = yield* decode(args).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError((error) =>
|
||||||
(error) =>
|
toolInfo.formatValidationError
|
||||||
new InvalidArgumentsError({
|
? new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||||
tool: id,
|
: new Error(
|
||||||
detail: toolInfo.formatValidationError ? toolInfo.formatValidationError(error) : String(error),
|
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||||
}),
|
{ cause: error },
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)
|
const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)
|
||||||
|
|||||||
@@ -65,13 +65,6 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
|
|||||||
sessionID: SessionID,
|
sessionID: SessionID,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
|
|
||||||
"Session.OperationUnavailableError",
|
|
||||||
{
|
|
||||||
operation: Schema.Literals(["prompt", "compact", "wait"]),
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly create: (input?: {
|
readonly create: (input?: {
|
||||||
agent?: string
|
agent?: string
|
||||||
@@ -104,14 +97,14 @@ export interface Interface {
|
|||||||
time: number
|
time: number
|
||||||
direction: "previous" | "next"
|
direction: "previous" | "next"
|
||||||
}
|
}
|
||||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError>
|
}) => Effect.Effect<SessionMessage.Message[], never>
|
||||||
readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[], NotFoundError>
|
readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[], never>
|
||||||
readonly prompt: (input: {
|
readonly prompt: (input: {
|
||||||
id?: EventV2.ID
|
id?: EventV2.ID
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
prompt: Prompt
|
prompt: Prompt
|
||||||
delivery?: Delivery
|
delivery?: Delivery
|
||||||
}) => Effect.Effect<SessionMessage.User, NotFoundError | OperationUnavailableError>
|
}) => Effect.Effect<SessionMessage.User, never>
|
||||||
readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never>
|
readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never>
|
||||||
readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never>
|
readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never>
|
||||||
readonly subagent: (input: {
|
readonly subagent: (input: {
|
||||||
@@ -120,11 +113,11 @@ export interface Interface {
|
|||||||
prompt: Prompt
|
prompt: Prompt
|
||||||
agent: string
|
agent: string
|
||||||
model?: ModelV2.Ref
|
model?: ModelV2.Ref
|
||||||
}) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
}) => Effect.Effect<void, NotFoundError>
|
||||||
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
|
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
|
||||||
readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect<void, never>
|
readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect<void, never>
|
||||||
readonly compact: (sessionID: SessionID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
readonly compact: (sessionID: SessionID) => Effect.Effect<void, never>
|
||||||
readonly wait: (sessionID: SessionID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
readonly wait: (sessionID: SessionID) => Effect.Effect<void, never>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
||||||
@@ -223,7 +216,6 @@ export const layer = Layer.effect(
|
|||||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||||
}),
|
}),
|
||||||
messages: Effect.fn("V2Session.messages")(function* (input) {
|
messages: Effect.fn("V2Session.messages")(function* (input) {
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
const direction = input.cursor?.direction ?? "next"
|
const direction = input.cursor?.direction ?? "next"
|
||||||
let order = input.order ?? "desc"
|
let order = input.order ?? "desc"
|
||||||
// Query the adjacent rows in reverse, then flip them back into the requested order below.
|
// Query the adjacent rows in reverse, then flip them back into the requested order below.
|
||||||
@@ -265,7 +257,6 @@ export const layer = Layer.effect(
|
|||||||
return rows.map((row) => decode(row))
|
return rows.map((row) => decode(row))
|
||||||
}),
|
}),
|
||||||
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
||||||
yield* result.get(sessionID)
|
|
||||||
const rows = Database.use((db) => {
|
const rows = Database.use((db) => {
|
||||||
const compaction = db
|
const compaction = db
|
||||||
.select()
|
.select()
|
||||||
@@ -297,9 +288,8 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
return rows.map((row) => decode(row))
|
return rows.map((row) => decode(row))
|
||||||
}),
|
}),
|
||||||
prompt: Effect.fn("V2Session.prompt")(function* (input) {
|
prompt: Effect.fn("V2Session.prompt")(function* (_input) {
|
||||||
yield* result.get(input.sessionID)
|
return {} as any
|
||||||
return yield* new OperationUnavailableError({ operation: "prompt" })
|
|
||||||
}),
|
}),
|
||||||
shell: Effect.fn("V2Session.shell")(function* (_input) {}),
|
shell: Effect.fn("V2Session.shell")(function* (_input) {}),
|
||||||
skill: Effect.fn("V2Session.skill")(function* (_input) {}),
|
skill: Effect.fn("V2Session.skill")(function* (_input) {}),
|
||||||
@@ -338,14 +328,8 @@ export const layer = Layer.effect(
|
|||||||
if (!text) return
|
if (!text) return
|
||||||
}).pipe(Effect.forkChild())
|
}).pipe(Effect.forkChild())
|
||||||
}),
|
}),
|
||||||
compact: Effect.fn("V2Session.compact")(function* (sessionID) {
|
compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}),
|
||||||
yield* result.get(sessionID)
|
wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}),
|
||||||
return yield* new OperationUnavailableError({ operation: "compact" })
|
|
||||||
}),
|
|
||||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
|
||||||
yield* result.get(sessionID)
|
|
||||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
|||||||
|
|
||||||
it.live("list returns empty when no accounts exist", () =>
|
it.live("list returns empty when no accounts exist", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const accounts = yield* AccountRepo.use.list()
|
const accounts = yield* AccountRepo.Service.use((r) => r.list())
|
||||||
expect(accounts).toEqual([])
|
expect(accounts).toEqual([])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("active returns none when no accounts exist", () =>
|
it.live("active returns none when no accounts exist", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const active = yield* AccountRepo.use.active()
|
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||||
expect(Option.isNone(active)).toBe(true)
|
expect(Option.isNone(active)).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -45,13 +45,13 @@ it.live("persistAccount inserts and getRow retrieves", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const row = yield* AccountRepo.use.getRow(id)
|
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||||
expect(Option.isSome(row)).toBe(true)
|
expect(Option.isSome(row)).toBe(true)
|
||||||
const value = Option.getOrThrow(row)
|
const value = Option.getOrThrow(row)
|
||||||
expect(value.id).toBe(AccountID.make("user-1"))
|
expect(value.id).toBe(AccountID.make("user-1"))
|
||||||
expect(value.email).toBe("test@example.com")
|
expect(value.email).toBe("test@example.com")
|
||||||
|
|
||||||
const active = yield* AccountRepo.use.active()
|
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
|
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -72,9 +72,9 @@ it.live("persistAccount normalizes trailing slashes in stored server URLs", () =
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const row = yield* AccountRepo.use.getRow(id)
|
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||||
const active = yield* AccountRepo.use.active()
|
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||||
const list = yield* AccountRepo.use.list()
|
const list = yield* AccountRepo.Service.use((r) => r.list())
|
||||||
|
|
||||||
expect(Option.getOrThrow(row).url).toBe("https://control.example.com")
|
expect(Option.getOrThrow(row).url).toBe("https://control.example.com")
|
||||||
expect(Option.getOrThrow(active).url).toBe("https://control.example.com")
|
expect(Option.getOrThrow(active).url).toBe("https://control.example.com")
|
||||||
@@ -112,7 +112,7 @@ it.live("persistAccount sets the active account and org", () =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Last persisted account is active with its org
|
// Last persisted account is active with its org
|
||||||
const active = yield* AccountRepo.use.active()
|
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||||
expect(Option.isSome(active)).toBe(true)
|
expect(Option.isSome(active)).toBe(true)
|
||||||
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
|
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
|
||||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
|
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
|
||||||
@@ -148,7 +148,7 @@ it.live("list returns all accounts", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const accounts = yield* AccountRepo.use.list()
|
const accounts = yield* AccountRepo.Service.use((r) => r.list())
|
||||||
expect(accounts.length).toBe(2)
|
expect(accounts.length).toBe(2)
|
||||||
expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
|
expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
|
||||||
}),
|
}),
|
||||||
@@ -170,9 +170,9 @@ it.live("remove deletes an account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* AccountRepo.use.remove(id)
|
yield* AccountRepo.Service.use((r) => r.remove(id))
|
||||||
|
|
||||||
const row = yield* AccountRepo.use.getRow(id)
|
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||||
expect(Option.isNone(row)).toBe(true)
|
expect(Option.isNone(row)).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -207,12 +207,12 @@ it.live("use stores the selected org and marks the account active", () =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
yield* AccountRepo.Service.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
|
yield* AccountRepo.Service.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
|
||||||
const active1 = yield* AccountRepo.use.active()
|
const active1 = yield* AccountRepo.Service.use((r) => r.active())
|
||||||
expect(Option.getOrThrow(active1).id).toBe(id1)
|
expect(Option.getOrThrow(active1).id).toBe(id1)
|
||||||
expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
|
expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
|
||||||
|
|
||||||
yield* AccountRepo.Service.use((r) => r.use(id1, Option.none()))
|
yield* AccountRepo.Service.use((r) => r.use(id1, Option.none()))
|
||||||
const active2 = yield* AccountRepo.use.active()
|
const active2 = yield* AccountRepo.Service.use((r) => r.active())
|
||||||
expect(Option.getOrThrow(active2).active_org_id).toBeNull()
|
expect(Option.getOrThrow(active2).active_org_id).toBeNull()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -243,7 +243,7 @@ it.live("persistToken updates token fields", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const row = yield* AccountRepo.use.getRow(id)
|
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||||
const value = Option.getOrThrow(row)
|
const value = Option.getOrThrow(row)
|
||||||
expect(value.access_token).toBe(AccessToken.make("new_token"))
|
expect(value.access_token).toBe(AccessToken.make("new_token"))
|
||||||
expect(value.refresh_token).toBe(RefreshToken.make("new_refresh"))
|
expect(value.refresh_token).toBe(RefreshToken.make("new_refresh"))
|
||||||
@@ -276,7 +276,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const row = yield* AccountRepo.use.getRow(id)
|
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||||
expect(Option.getOrThrow(row).token_expiry).toBeNull()
|
expect(Option.getOrThrow(row).token_expiry).toBeNull()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -309,14 +309,14 @@ it.live("persistAccount upserts on conflict", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const accounts = yield* AccountRepo.use.list()
|
const accounts = yield* AccountRepo.Service.use((r) => r.list())
|
||||||
expect(accounts.length).toBe(1)
|
expect(accounts.length).toBe(1)
|
||||||
|
|
||||||
const row = yield* AccountRepo.use.getRow(id)
|
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||||
const value = Option.getOrThrow(row)
|
const value = Option.getOrThrow(row)
|
||||||
expect(value.access_token).toBe(AccessToken.make("at_v2"))
|
expect(value.access_token).toBe(AccessToken.make("at_v2"))
|
||||||
|
|
||||||
const active = yield* AccountRepo.use.active()
|
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
|
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -337,9 +337,9 @@ it.live("remove clears active state when deleting the active account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* AccountRepo.use.remove(id)
|
yield* AccountRepo.Service.use((r) => r.remove(id))
|
||||||
|
|
||||||
const active = yield* AccountRepo.use.active()
|
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||||
expect(Option.isNone(active)).toBe(true)
|
expect(Option.isNone(active)).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ it.live("login normalizes trailing slashes in the provided server URL", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = yield* Account.use.login("https://one.example.com/").pipe(Effect.provide(live(client)))
|
const result = yield* Account.Service.use((s) => s.login("https://one.example.com/")).pipe(
|
||||||
|
Effect.provide(live(client)),
|
||||||
|
)
|
||||||
|
|
||||||
expect(seen).toEqual(["POST https://one.example.com/auth/device/code"])
|
expect(seen).toEqual(["POST https://one.example.com/auth/device/code"])
|
||||||
expect(result.server).toBe("https://one.example.com")
|
expect(result.server).toBe("https://one.example.com")
|
||||||
@@ -106,7 +108,9 @@ it.live("login maps transport failures to account transport errors", () =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const error = yield* Effect.flip(Account.use.login("https://one.example.com").pipe(Effect.provide(live(client))))
|
const error = yield* Effect.flip(
|
||||||
|
Account.Service.use((s) => s.login("https://one.example.com")).pipe(Effect.provide(live(client))),
|
||||||
|
)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(AccountTransportError)
|
expect(error).toBeInstanceOf(AccountTransportError)
|
||||||
if (error instanceof AccountTransportError) {
|
if (error instanceof AccountTransportError) {
|
||||||
@@ -159,7 +163,7 @@ it.live("orgsByAccount groups orgs per account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const rows = yield* Account.use.orgsByAccount().pipe(Effect.provide(live(client)))
|
const rows = yield* Account.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
|
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
|
||||||
[AccountID.make("user-1"), [OrgID.make("org-1")]],
|
[AccountID.make("user-1"), [OrgID.make("org-1")]],
|
||||||
@@ -197,12 +201,12 @@ it.live("token refresh persists the new token", () =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const token = yield* Account.use.token(id).pipe(Effect.provide(live(client)))
|
const token = yield* Account.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(Option.getOrThrow(token)).toBeDefined()
|
expect(Option.getOrThrow(token)).toBeDefined()
|
||||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||||
|
|
||||||
const row = yield* AccountRepo.use.getRow(id)
|
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||||
const value = Option.getOrThrow(row)
|
const value = Option.getOrThrow(row)
|
||||||
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
||||||
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
||||||
@@ -242,12 +246,12 @@ it.live("token refreshes before expiry when inside the eager refresh window", ()
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const token = yield* Account.use.token(id).pipe(Effect.provide(live(client)))
|
const token = yield* Account.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||||
expect(refreshCalls).toBe(1)
|
expect(refreshCalls).toBe(1)
|
||||||
|
|
||||||
const row = yield* AccountRepo.use.getRow(id)
|
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||||
const value = Option.getOrThrow(row)
|
const value = Option.getOrThrow(row)
|
||||||
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
||||||
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
||||||
@@ -311,7 +315,7 @@ it.live("concurrent config and token requests coalesce token refresh", () =>
|
|||||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||||
expect(refreshCalls).toBe(1)
|
expect(refreshCalls).toBe(1)
|
||||||
|
|
||||||
const row = yield* AccountRepo.use.getRow(id)
|
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||||
const value = Option.getOrThrow(row)
|
const value = Option.getOrThrow(row)
|
||||||
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
||||||
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
||||||
@@ -384,7 +388,7 @@ it.live("poll stores the account and first org on success", () =>
|
|||||||
expect(res.email).toBe("user@example.com")
|
expect(res.email).toBe("user@example.com")
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = yield* AccountRepo.use.active()
|
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||||
expect(Option.getOrThrow(active)).toEqual(
|
expect(Option.getOrThrow(active)).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "user-1",
|
id: "user-1",
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ function testAgent(input: {
|
|||||||
|
|
||||||
it.instance("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", () =>
|
it.instance("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const planAgent = yield* Agent.use.get("plan")
|
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
|
||||||
const generalAgent = yield* Agent.use.get("general")
|
const generalAgent = yield* Agent.Service.use((svc) => svc.get("general"))
|
||||||
|
|
||||||
expect(planAgent).toBeDefined()
|
expect(planAgent).toBeDefined()
|
||||||
expect(generalAgent).toBeDefined()
|
expect(generalAgent).toBeDefined()
|
||||||
@@ -83,8 +83,8 @@ it.instance("[#26514] explore subagent launched from plan mode also stays read-o
|
|||||||
// should propagate the parent **agent** permissions, not just deny edit
|
// should propagate the parent **agent** permissions, not just deny edit
|
||||||
// when the subagent happens to already deny it.
|
// when the subagent happens to already deny it.
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const planAgent = yield* Agent.use.get("plan")
|
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
|
||||||
const explore = yield* Agent.use.get("explore")
|
const explore = yield* Agent.Service.use((svc) => svc.get("explore"))
|
||||||
expect(planAgent).toBeDefined()
|
expect(planAgent).toBeDefined()
|
||||||
expect(explore).toBeDefined()
|
expect(explore).toBeDefined()
|
||||||
|
|
||||||
@@ -108,8 +108,8 @@ it.instance(
|
|||||||
// be able to edit when the parent agent is `plan`.
|
// be able to edit when the parent agent is `plan`.
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const planAgent = yield* Agent.use.get("plan")
|
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
|
||||||
const my = yield* Agent.use.get("my_subagent")
|
const my = yield* Agent.Service.use((svc) => svc.get("my_subagent"))
|
||||||
expect(planAgent).toBeDefined()
|
expect(planAgent).toBeDefined()
|
||||||
expect(my).toBeDefined()
|
expect(my).toBeDefined()
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ it.instance(
|
|||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Plugin.Service.use((p) => p.init())
|
yield* Plugin.Service.use((p) => p.init())
|
||||||
const agents = yield* Agent.use.list()
|
const agents = yield* Agent.Service.use((svc) => svc.list())
|
||||||
const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name)
|
const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name)
|
||||||
expect(added?.description).toBe(PLUGIN_AGENT.description)
|
expect(added?.description).toBe(PLUGIN_AGENT.description)
|
||||||
expect(added?.mode).toBe(PLUGIN_AGENT.mode)
|
expect(added?.mode).toBe(PLUGIN_AGENT.mode)
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ function globalSse(stream: GlobalEventStream) {
|
|||||||
function wrapGlobalStream(stream: EventStream): GlobalEventStream {
|
function wrapGlobalStream(stream: EventStream): GlobalEventStream {
|
||||||
return (async function* (): GlobalEventStream {
|
return (async function* (): GlobalEventStream {
|
||||||
for await (const event of stream) {
|
for await (const event of stream) {
|
||||||
yield globalEvent(event)
|
yield globalEvent(event as GlobalEvent["payload"])
|
||||||
}
|
}
|
||||||
return StreamClosed
|
return StreamClosed
|
||||||
})()
|
})()
|
||||||
@@ -339,11 +339,11 @@ function child(id: string): SessionChild {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function globalEvent(payload: GlobalEvent["payload"]): GlobalEvent {
|
function globalEvent(payload: SdkEvent | GlobalEvent["payload"]): GlobalEvent {
|
||||||
return {
|
return {
|
||||||
directory: "/tmp",
|
directory: "/tmp",
|
||||||
project: "project-1",
|
project: "project-1",
|
||||||
payload,
|
payload: payload as GlobalEvent["payload"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
import { onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
|
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
|
||||||
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
||||||
@@ -17,7 +17,10 @@ async function wait(fn: () => boolean, timeout = 2000) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent {
|
function event(
|
||||||
|
payload: GlobalEvent["payload"],
|
||||||
|
input: { directory: string; project?: string; workspace?: string },
|
||||||
|
): GlobalEvent {
|
||||||
return {
|
return {
|
||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
project: input.project,
|
project: input.project,
|
||||||
@@ -26,7 +29,7 @@ function event(payload: Event, input: { directory: string; project?: string; wor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function vcs(branch: string): Event {
|
function vcs(branch: string): GlobalEvent["payload"] {
|
||||||
return {
|
return {
|
||||||
id: `evt_vcs_${branch}`,
|
id: `evt_vcs_${branch}`,
|
||||||
type: "vcs.branch.updated",
|
type: "vcs.branch.updated",
|
||||||
@@ -36,7 +39,7 @@ function vcs(branch: string): Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function update(version: string): Event {
|
function update(version: string): GlobalEvent["payload"] {
|
||||||
return {
|
return {
|
||||||
id: `evt_update_${version}`,
|
id: `evt_update_${version}`,
|
||||||
type: "installation.update-available",
|
type: "installation.update-available",
|
||||||
@@ -67,7 +70,7 @@ function createSource() {
|
|||||||
|
|
||||||
async function mount() {
|
async function mount() {
|
||||||
const source = createSource()
|
const source = createSource()
|
||||||
const seen: Event[] = []
|
const seen: GlobalEvent["payload"][] = []
|
||||||
const workspaces: Array<string | undefined> = []
|
const workspaces: Array<string | undefined> = []
|
||||||
const fetch = (async (input: RequestInfo | URL) => {
|
const fetch = (async (input: RequestInfo | URL) => {
|
||||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||||
@@ -102,7 +105,7 @@ async function mount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Probe(props: {
|
function Probe(props: {
|
||||||
seen: Event[]
|
seen: GlobalEvent["payload"][]
|
||||||
workspaces: Array<string | undefined>
|
workspaces: Array<string | undefined>
|
||||||
onReady: (ctx: { project: ReturnType<typeof useProject> }) => void
|
onReady: (ctx: { project: ReturnType<typeof useProject> }) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -111,7 +114,7 @@ function Probe(props: {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
event.subscribe((evt, { workspace }) => {
|
event.subscribe((evt, { workspace }) => {
|
||||||
props.seen.push(evt)
|
props.seen.push(evt as GlobalEvent["payload"])
|
||||||
props.workspaces.push(workspace)
|
props.workspaces.push(workspace)
|
||||||
})
|
})
|
||||||
props.onReady({ project })
|
props.onReady({ project })
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ it.instance(
|
|||||||
"agent color parsed from project config",
|
"agent color parsed from project config",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cfg = yield* Config.use.get()
|
const cfg = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
|
expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
|
||||||
expect(cfg.agent?.["plan"]?.color).toBe("primary")
|
expect(cfg.agent?.["plan"]?.color).toBe("primary")
|
||||||
}),
|
}),
|
||||||
@@ -30,9 +30,9 @@ it.instance(
|
|||||||
"Agent.get includes color from config",
|
"Agent.get includes color from config",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plan = yield* AgentSvc.use.get("plan")
|
const plan = yield* AgentSvc.Service.use((svc) => svc.get("plan"))
|
||||||
expect(plan?.color).toBe("#A855F7")
|
expect(plan?.color).toBe("#A855F7")
|
||||||
const build = yield* AgentSvc.use.get("build")
|
const build = yield* AgentSvc.Service.use((svc) => svc.get("build"))
|
||||||
expect(build?.color).toBe("accent")
|
expect(build?.color).toBe("accent")
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ const load = (ctx: InstanceContext) =>
|
|||||||
)
|
)
|
||||||
const saveGlobal = (config: Config.Info) =>
|
const saveGlobal = (config: Config.Info) =>
|
||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
Config.use.updateGlobal(config).pipe(
|
Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
|
||||||
Effect.map((result) => result.info),
|
Effect.map((result) => result.info),
|
||||||
Effect.scoped,
|
Effect.scoped,
|
||||||
Effect.provide(layer),
|
Effect.provide(layer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const clear = async (wait = false) => {
|
const clear = async (wait = false) => {
|
||||||
await Effect.runPromise(Config.use.invalidate().pipe(Effect.scoped, Effect.provide(layer)))
|
await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||||
if (wait) await InstanceRuntime.disposeAllInstances()
|
if (wait) await InstanceRuntime.disposeAllInstances()
|
||||||
}
|
}
|
||||||
const listDirs = (ctx: InstanceContext) =>
|
const listDirs = (ctx: InstanceContext) =>
|
||||||
@@ -162,7 +162,7 @@ async function check(map: (dir: string) => string) {
|
|||||||
|
|
||||||
it.instance("loads config with defaults when no files exist", () =>
|
it.instance("loads config with defaults when no files exist", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.username).toBeDefined()
|
expect(config.username).toBeDefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -218,7 +218,7 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () =
|
|||||||
it.instance(
|
it.instance(
|
||||||
"loads JSON config file",
|
"loads JSON config file",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.model).toBe("test/model")
|
expect(config.model).toBe("test/model")
|
||||||
expect(config.username).toBe("testuser")
|
expect(config.username).toBe("testuser")
|
||||||
}),
|
}),
|
||||||
@@ -228,7 +228,7 @@ it.instance(
|
|||||||
it.instance(
|
it.instance(
|
||||||
"loads shell config field",
|
"loads shell config field",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.shell).toBe("bash")
|
expect(config.shell).toBe("bash")
|
||||||
}),
|
}),
|
||||||
{ config: { shell: "bash" } },
|
{ config: { shell: "bash" } },
|
||||||
@@ -313,7 +313,7 @@ test("updates global config and omits empty shell key in jsonc", async () => {
|
|||||||
it.instance(
|
it.instance(
|
||||||
"loads formatter boolean config",
|
"loads formatter boolean config",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.formatter).toBe(true)
|
expect(config.formatter).toBe(true)
|
||||||
}),
|
}),
|
||||||
{ config: { formatter: true } },
|
{ config: { formatter: true } },
|
||||||
@@ -322,7 +322,7 @@ it.instance(
|
|||||||
it.instance(
|
it.instance(
|
||||||
"loads lsp boolean config",
|
"loads lsp boolean config",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.lsp).toBe(true)
|
expect(config.lsp).toBe(true)
|
||||||
}),
|
}),
|
||||||
{ config: { lsp: true } },
|
{ config: { lsp: true } },
|
||||||
@@ -355,7 +355,7 @@ it.instance("ignores legacy tui keys in opencode config", () =>
|
|||||||
tui: { scroll_speed: 4 },
|
tui: { scroll_speed: 4 },
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.model).toBe("test/model")
|
expect(config.model).toBe("test/model")
|
||||||
expect((config as Record<string, unknown>).theme).toBeUndefined()
|
expect((config as Record<string, unknown>).theme).toBeUndefined()
|
||||||
expect((config as Record<string, unknown>).tui).toBeUndefined()
|
expect((config as Record<string, unknown>).tui).toBeUndefined()
|
||||||
@@ -376,7 +376,7 @@ it.instance("loads JSONC config file", () =>
|
|||||||
}`,
|
}`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.model).toBe("test/model")
|
expect(config.model).toBe("test/model")
|
||||||
expect(config.username).toBe("testuser")
|
expect(config.username).toBe("testuser")
|
||||||
}),
|
}),
|
||||||
@@ -398,7 +398,7 @@ it.instance("jsonc overrides json in the same directory", () =>
|
|||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
model: "override",
|
model: "override",
|
||||||
})
|
})
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.model).toBe("base")
|
expect(config.model).toBe("base")
|
||||||
expect(config.username).toBe("base")
|
expect(config.username).toBe("base")
|
||||||
}),
|
}),
|
||||||
@@ -414,7 +414,7 @@ it.instance("handles environment variable substitution", () =>
|
|||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
username: "{env:TEST_VAR}",
|
username: "{env:TEST_VAR}",
|
||||||
})
|
})
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.username).toBe("test-user")
|
expect(config.username).toBe("test-user")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -435,7 +435,7 @@ it.instance("preserves env variables when adding $schema to config", () =>
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.username).toBe("secret_value")
|
expect(config.username).toBe("secret_value")
|
||||||
|
|
||||||
// Read the file to verify the env variable was preserved
|
// Read the file to verify the env variable was preserved
|
||||||
@@ -455,7 +455,7 @@ it.instance("handles file inclusion substitution", () =>
|
|||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
username: "{file:included.txt}",
|
username: "{file:included.txt}",
|
||||||
})
|
})
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.username).toBe("test-user")
|
expect(config.username).toBe("test-user")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -470,7 +470,7 @@ it.instance("handles file inclusion with replacement tokens", () =>
|
|||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
username: "{file:included.md}",
|
username: "{file:included.md}",
|
||||||
})
|
})
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.username).toBe("const out = await Bun.$`echo hi`")
|
expect(config.username).toBe("const out = await Bun.$`echo hi`")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -547,7 +547,7 @@ it.instance("validates config schema and throws on invalid fields", () =>
|
|||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
invalid_field: "should cause error",
|
invalid_field: "should cause error",
|
||||||
})
|
})
|
||||||
const exit = yield* Config.use.get().pipe(Effect.exit)
|
const exit = yield* Config.Service.use((svc) => svc.get()).pipe(Effect.exit)
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -556,7 +556,7 @@ it.instance("throws error for invalid JSON", () =>
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestInstance
|
const test = yield* TestInstance
|
||||||
yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "opencode.json"), "{ invalid json }"))
|
yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "opencode.json"), "{ invalid json }"))
|
||||||
const exit = yield* Config.use.get().pipe(Effect.exit)
|
const exit = yield* Config.Service.use((svc) => svc.get()).pipe(Effect.exit)
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -574,7 +574,7 @@ it.instance("handles agent configuration", () =>
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.agent?.["test_agent"]).toEqual(
|
expect(config.agent?.["test_agent"]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
model: "test/model",
|
model: "test/model",
|
||||||
@@ -598,7 +598,7 @@ it.instance("treats agent variant as model-scoped setting (not provider option)"
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
const agent = config.agent?.["test_agent"]
|
const agent = config.agent?.["test_agent"]
|
||||||
|
|
||||||
expect(agent?.variant).toBe("xhigh")
|
expect(agent?.variant).toBe("xhigh")
|
||||||
@@ -622,7 +622,7 @@ it.instance("handles command configuration", () =>
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.command?.["test_command"]).toEqual({
|
expect(config.command?.["test_command"]).toEqual({
|
||||||
template: "test template",
|
template: "test template",
|
||||||
description: "test command",
|
description: "test command",
|
||||||
@@ -638,7 +638,7 @@ it.instance("migrates autoshare to share field", () =>
|
|||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
autoshare: true,
|
autoshare: true,
|
||||||
})
|
})
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.share).toBe("auto")
|
expect(config.share).toBe("auto")
|
||||||
expect(config.autoshare).toBe(true)
|
expect(config.autoshare).toBe(true)
|
||||||
}),
|
}),
|
||||||
@@ -656,7 +656,7 @@ it.instance("migrates mode field to agent field", () =>
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.agent?.["test_mode"]).toEqual({
|
expect(config.agent?.["test_mode"]).toEqual({
|
||||||
model: "test/model",
|
model: "test/model",
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
@@ -679,7 +679,7 @@ model: test/model
|
|||||||
Test agent prompt`,
|
Test agent prompt`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.agent?.["test"]).toEqual(
|
expect(config.agent?.["test"]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: "test",
|
name: "test",
|
||||||
@@ -705,7 +705,7 @@ permission:
|
|||||||
Ordered permissions`,
|
Ordered permissions`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"])
|
expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -732,7 +732,7 @@ mode: subagent
|
|||||||
Nested agent prompt`,
|
Nested agent prompt`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
|
|
||||||
expect(config.agent?.["helper"]).toMatchObject({
|
expect(config.agent?.["helper"]).toMatchObject({
|
||||||
name: "helper",
|
name: "helper",
|
||||||
@@ -770,7 +770,7 @@ description: Nested command
|
|||||||
Nested command template`,
|
Nested command template`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
|
|
||||||
expect(config.command?.["hello"]).toEqual({
|
expect(config.command?.["hello"]).toEqual({
|
||||||
description: "Test command",
|
description: "Test command",
|
||||||
@@ -804,7 +804,7 @@ description: Nested command
|
|||||||
Nested command template`,
|
Nested command template`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
|
|
||||||
expect(config.command?.["hello"]).toEqual({
|
expect(config.command?.["hello"]).toEqual({
|
||||||
description: "Test command",
|
description: "Test command",
|
||||||
@@ -834,7 +834,7 @@ it.instance("updates config and writes to file", () =>
|
|||||||
|
|
||||||
it.instance("gets config directories", () =>
|
it.instance("gets config directories", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const dirs = yield* Config.use.directories()
|
const dirs = yield* Config.Service.use((svc) => svc.directories())
|
||||||
expect(dirs.length).toBeGreaterThanOrEqual(1)
|
expect(dirs.length).toBeGreaterThanOrEqual(1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -950,7 +950,7 @@ it.instance("resolves scoped npm plugins in config", () =>
|
|||||||
yield* writeTextEffect(path.join(pluginDir, "index.js"), "export default {}\n")
|
yield* writeTextEffect(path.join(pluginDir, "index.js"), "export default {}\n")
|
||||||
yield* writeConfigEffect(test.directory, { plugin: ["@scope/plugin"] })
|
yield* writeConfigEffect(test.directory, { plugin: ["@scope/plugin"] })
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.plugin ?? []).toContain("@scope/plugin")
|
expect(config.plugin ?? []).toContain("@scope/plugin")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1014,7 +1014,7 @@ mode: subagent
|
|||||||
Helper subagent prompt`,
|
Helper subagent prompt`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.agent?.["helper"]).toMatchObject({
|
expect(config.agent?.["helper"]).toMatchObject({
|
||||||
name: "helper",
|
name: "helper",
|
||||||
model: "test/model",
|
model: "test/model",
|
||||||
@@ -1212,7 +1212,7 @@ it.instance("migrates legacy tools config to permissions - allow", () =>
|
|||||||
agent: { test: { tools: { bash: true, read: true } } },
|
agent: { test: { tools: { bash: true, read: true } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.agent?.["test"]?.permission).toEqual({
|
expect(config.agent?.["test"]?.permission).toEqual({
|
||||||
bash: "allow",
|
bash: "allow",
|
||||||
read: "allow",
|
read: "allow",
|
||||||
@@ -1228,7 +1228,7 @@ it.instance("migrates legacy tools config to permissions - deny", () =>
|
|||||||
agent: { test: { tools: { bash: false, webfetch: false } } },
|
agent: { test: { tools: { bash: false, webfetch: false } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.agent?.["test"]?.permission).toEqual({
|
expect(config.agent?.["test"]?.permission).toEqual({
|
||||||
bash: "deny",
|
bash: "deny",
|
||||||
webfetch: "deny",
|
webfetch: "deny",
|
||||||
@@ -1244,7 +1244,7 @@ it.instance("migrates legacy write tool to edit permission", () =>
|
|||||||
agent: { test: { tools: { write: true } } },
|
agent: { test: { tools: { write: true } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow" })
|
expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1261,7 +1261,7 @@ it.instance(
|
|||||||
share: "disabled",
|
share: "disabled",
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.model).toBe("managed/model")
|
expect(config.model).toBe("managed/model")
|
||||||
expect(config.share).toBe("disabled")
|
expect(config.share).toBe("disabled")
|
||||||
expect(config.username).toBe("testuser")
|
expect(config.username).toBe("testuser")
|
||||||
@@ -1278,7 +1278,7 @@ it.instance(
|
|||||||
disabled_providers: ["openai"],
|
disabled_providers: ["openai"],
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.autoupdate).toBe(false)
|
expect(config.autoupdate).toBe(false)
|
||||||
expect(config.disabled_providers).toEqual(["openai"])
|
expect(config.disabled_providers).toEqual(["openai"])
|
||||||
}),
|
}),
|
||||||
@@ -1288,7 +1288,7 @@ it.instance(
|
|||||||
it.instance(
|
it.instance(
|
||||||
"missing managed settings file is not an error",
|
"missing managed settings file is not an error",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.model).toBe("user/model")
|
expect(config.model).toBe("user/model")
|
||||||
}),
|
}),
|
||||||
{ config: { model: "user/model" } },
|
{ config: { model: "user/model" } },
|
||||||
@@ -1302,7 +1302,7 @@ it.instance("migrates legacy edit tool to edit permission", () =>
|
|||||||
agent: { test: { tools: { edit: false } } },
|
agent: { test: { tools: { edit: false } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny" })
|
expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1315,7 +1315,7 @@ it.instance("migrates legacy patch tool to edit permission", () =>
|
|||||||
agent: { test: { tools: { patch: true } } },
|
agent: { test: { tools: { patch: true } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow" })
|
expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1328,7 +1328,7 @@ it.instance("migrates mixed legacy tools config", () =>
|
|||||||
agent: { test: { tools: { bash: true, write: true, read: false, webfetch: true } } },
|
agent: { test: { tools: { bash: true, write: true, read: false, webfetch: true } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.agent?.["test"]?.permission).toEqual({
|
expect(config.agent?.["test"]?.permission).toEqual({
|
||||||
bash: "allow",
|
bash: "allow",
|
||||||
edit: "allow",
|
edit: "allow",
|
||||||
@@ -1346,7 +1346,7 @@ it.instance("merges legacy tools with existing permission config", () =>
|
|||||||
agent: { test: { permission: { glob: "allow" }, tools: { bash: true } } },
|
agent: { test: { permission: { glob: "allow" }, tools: { bash: true } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.agent?.["test"]?.permission).toEqual({
|
expect(config.agent?.["test"]?.permission).toEqual({
|
||||||
glob: "allow",
|
glob: "allow",
|
||||||
bash: "allow",
|
bash: "allow",
|
||||||
@@ -1375,7 +1375,7 @@ it.instance("permission config preserves user key order", () =>
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(Object.keys(config.permission!)).toEqual([
|
expect(Object.keys(config.permission!)).toEqual([
|
||||||
"*",
|
"*",
|
||||||
"edit",
|
"edit",
|
||||||
@@ -1451,7 +1451,7 @@ it.instance("project config can override MCP server enabled status", () =>
|
|||||||
"opencode.jsonc",
|
"opencode.jsonc",
|
||||||
)
|
)
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.mcp?.jira).toEqual({
|
expect(config.mcp?.jira).toEqual({
|
||||||
type: "remote",
|
type: "remote",
|
||||||
url: "https://jira.example.com/mcp",
|
url: "https://jira.example.com/mcp",
|
||||||
@@ -1496,7 +1496,7 @@ it.instance("MCP config deep merges preserving base config properties", () =>
|
|||||||
"opencode.jsonc",
|
"opencode.jsonc",
|
||||||
)
|
)
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.mcp?.myserver).toEqual({
|
expect(config.mcp?.myserver).toEqual({
|
||||||
type: "remote",
|
type: "remote",
|
||||||
url: "https://myserver.example.com/mcp",
|
url: "https://myserver.example.com/mcp",
|
||||||
@@ -1537,7 +1537,7 @@ it.instance("local .opencode config can override MCP from project config", () =>
|
|||||||
"opencode.json",
|
"opencode.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
const config = yield* Config.use.get()
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
expect(config.mcp?.docs?.enabled).toBe(true)
|
expect(config.mcp?.docs?.enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ it.instance("keeps server and tui plugin merge semantics aligned", () =>
|
|||||||
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
|
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
|
||||||
})
|
})
|
||||||
|
|
||||||
const server = yield* Config.use.get()
|
const server = yield* Config.Service.use((svc) => svc.get())
|
||||||
const tui = yield* getTuiConfig(test.directory)
|
const tui = yield* getTuiConfig(test.directory)
|
||||||
const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
|
const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
|
||||||
const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
|
const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ afterEach(async () => {
|
|||||||
|
|
||||||
async function withInstance<T>(fn: (ctx: InstanceContext) => T | Promise<T>) {
|
async function withInstance<T>(fn: (ctx: InstanceContext) => T | Promise<T>) {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
const ctx = await AppRuntime.runPromise(InstanceStore.use.load({ directory: tmp.path }))
|
const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: tmp.path })))
|
||||||
return await context.provide(ctx, () => fn(ctx))
|
return await context.provide(ctx, () => fn(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,29 +151,34 @@ const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => {
|
|||||||
const ctx = currentInstance()
|
const ctx = currentInstance()
|
||||||
return AppRuntime.runPromise(ctx ? effect.pipe(Effect.provideService(InstanceRef, ctx)) : effect)
|
return AppRuntime.runPromise(ctx ? effect.pipe(Effect.provideService(InstanceRef, ctx)) : effect)
|
||||||
}
|
}
|
||||||
const createWorkspace = (input: Workspace.CreateInput) => runWorkspace(Workspace.use.create(input))
|
const createWorkspace = (input: Workspace.CreateInput) =>
|
||||||
const warpWorkspaceSession = (input: Workspace.SessionWarpInput) => runWorkspace(Workspace.use.sessionWarp(input))
|
runWorkspace(Workspace.Service.use((workspace) => workspace.create(input)))
|
||||||
|
const warpWorkspaceSession = (input: Workspace.SessionWarpInput) =>
|
||||||
|
runWorkspace(Workspace.Service.use((workspace) => workspace.sessionWarp(input)))
|
||||||
const listWorkspaces = (project: Parameters<Workspace.Interface["list"]>[0]) =>
|
const listWorkspaces = (project: Parameters<Workspace.Interface["list"]>[0]) =>
|
||||||
runWorkspace(Workspace.use.list(project))
|
runWorkspace(Workspace.Service.use((workspace) => workspace.list(project)))
|
||||||
const syncListWorkspaces = (project: Parameters<Workspace.Interface["syncList"]>[0]) =>
|
const syncListWorkspaces = (project: Parameters<Workspace.Interface["syncList"]>[0]) =>
|
||||||
runWorkspace(Workspace.use.syncList(project))
|
runWorkspace(Workspace.Service.use((workspace) => workspace.syncList(project)))
|
||||||
const getWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.use.get(id))
|
const getWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.get(id)))
|
||||||
const removeWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.use.remove(id))
|
const removeWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.remove(id)))
|
||||||
const workspaceStatus = () => runWorkspace(Workspace.use.status())
|
const workspaceStatus = () => runWorkspace(Workspace.Service.use((workspace) => workspace.status()))
|
||||||
const isWorkspaceSyncing = (id: WorkspaceID) => runWorkspace(Workspace.use.isSyncing(id))
|
const isWorkspaceSyncing = (id: WorkspaceID) =>
|
||||||
|
runWorkspace(Workspace.Service.use((workspace) => workspace.isSyncing(id)))
|
||||||
const startWorkspaceSyncing = (projectID: ProjectID) => {
|
const startWorkspaceSyncing = (projectID: ProjectID) => {
|
||||||
void runWorkspace(Workspace.use.startWorkspaceSyncing(projectID))
|
void runWorkspace(Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)))
|
||||||
}
|
}
|
||||||
const startWorkspaceSyncingWithFlag = (projectID: ProjectID, experimentalWorkspaces: boolean) =>
|
const startWorkspaceSyncingWithFlag = (projectID: ProjectID, experimentalWorkspaces: boolean) =>
|
||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
Workspace.use.startWorkspaceSyncing(projectID).pipe(Effect.provide(workspaceLayer(experimentalWorkspaces))),
|
Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)).pipe(
|
||||||
|
Effect.provide(workspaceLayer(experimentalWorkspaces)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
const waitForWorkspaceSync = (
|
const waitForWorkspaceSync = (
|
||||||
workspaceID: WorkspaceID,
|
workspaceID: WorkspaceID,
|
||||||
state: Record<string, number>,
|
state: Record<string, number>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
) => runWorkspace(Workspace.use.waitForSync(workspaceID, state, signal, timeout))
|
) => runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal, timeout)))
|
||||||
|
|
||||||
function captureGlobalEvents() {
|
function captureGlobalEvents() {
|
||||||
const events: GlobalEvent[] = []
|
const events: GlobalEvent[] = []
|
||||||
@@ -928,11 +933,13 @@ describe("workspace CRUD", () => {
|
|||||||
insertWorkspace(previous)
|
insertWorkspace(previous)
|
||||||
registerAdapter(projectID, previousType, localAdapter(workspaceTmp.path, { createDir: false }).adapter)
|
registerAdapter(projectID, previousType, localAdapter(workspaceTmp.path, { createDir: false }).adapter)
|
||||||
const session = await AppRuntime.runPromise(
|
const session = await AppRuntime.runPromise(
|
||||||
SessionNs.use.create({}).pipe(Effect.provideService(InstanceRef, instance)),
|
SessionNs.Service.use((svc) => svc.create({})).pipe(Effect.provideService(InstanceRef, instance)),
|
||||||
)
|
)
|
||||||
attachSessionToWorkspace(session.id, previous.id)
|
attachSessionToWorkspace(session.id, previous.id)
|
||||||
|
|
||||||
const workspaceCtx = await AppRuntime.runPromise(InstanceStore.use.load({ directory: workspaceTmp.path }))
|
const workspaceCtx = await AppRuntime.runPromise(
|
||||||
|
InstanceStore.Service.use((store) => store.load({ directory: workspaceTmp.path })),
|
||||||
|
)
|
||||||
const workspaceProjectID = await context.provide(workspaceCtx, async () => {
|
const workspaceProjectID = await context.provide(workspaceCtx, async () => {
|
||||||
const id = workspaceCtx.project.id
|
const id = workspaceCtx.project.id
|
||||||
expect(id).not.toBe(projectID)
|
expect(id).not.toBe(projectID)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user