Merge branch 'dev' into feat/canceled-prompts-in-history
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
[test]
|
||||
root = "./src"
|
||||
preload = ["./happydom.ts"]
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
|
||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||
await openSidebar(page)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
await openSidebar(page)
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (slug === root) return ""
|
||||
if (seen.includes(slug)) return ""
|
||||
return slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const directory = base64Decode(slug)
|
||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||
return { slug, directory }
|
||||
}
|
||||
|
||||
async function openWorkspaceNewSession(page: Page, slug: string) {
|
||||
await waitWorkspaceReady(page, slug)
|
||||
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
await item.hover()
|
||||
|
||||
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
||||
await expect(button).toBeVisible()
|
||||
await button.click({ force: true })
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
|
||||
}
|
||||
|
||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
||||
await openWorkspaceNewSession(page, slug)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await prompt.click()
|
||||
await page.keyboard.type(text)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
|
||||
|
||||
const sessionID = sessionIDFromUrl(page.url())
|
||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
return sessionID
|
||||
}
|
||||
|
||||
async function sessionDirectory(directory: string, sessionID: string) {
|
||||
const info = await createSdk(directory)
|
||||
.session.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!info) return ""
|
||||
return info.directory
|
||||
}
|
||||
|
||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ directory, slug: root }) => {
|
||||
const workspaces = [] as { slug: string; directory: string }[]
|
||||
const sessions = [] as string[]
|
||||
|
||||
try {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
|
||||
const first = await createWorkspace(page, root, [])
|
||||
workspaces.push(first)
|
||||
await waitWorkspaceReady(page, first.slug)
|
||||
|
||||
const second = await createWorkspace(page, root, [first.slug])
|
||||
workspaces.push(second)
|
||||
await waitWorkspaceReady(page, second.slug)
|
||||
|
||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||
sessions.push(firstSession)
|
||||
|
||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||
sessions.push(secondSession)
|
||||
|
||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||
sessions.push(thirdSession)
|
||||
|
||||
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
|
||||
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
|
||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
|
||||
} finally {
|
||||
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
|
||||
await Promise.all(
|
||||
sessions.map((sessionID) =>
|
||||
Promise.all(
|
||||
dirs.map((dir) =>
|
||||
createSdk(dir)
|
||||
.session.delete({ sessionID })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -48,6 +48,9 @@ export const workspaceItemSelector = (slug: string) =>
|
||||
export const workspaceMenuTriggerSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
|
||||
|
||||
export const workspaceNewSessionSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]`
|
||||
|
||||
export const listItemSelector = '[data-slot="list-item"]'
|
||||
|
||||
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { withSession } from "../actions"
|
||||
import { createSdk, modKey } from "../utils"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
async function seedConversation(input: {
|
||||
page: Page
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
sessionID: string
|
||||
token: string
|
||||
}) {
|
||||
const prompt = input.page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await prompt.click()
|
||||
await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
|
||||
await input.page.keyboard.press("Enter")
|
||||
|
||||
let userMessageID: string | undefined
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await input.sdk.session
|
||||
.messages({ sessionID: input.sessionID, limit: 50 })
|
||||
.then((r) => r.data ?? [])
|
||||
const users = messages.filter((m) => m.info.role === "user")
|
||||
if (users.length === 0) return false
|
||||
|
||||
const user = users.reduce((acc, item) => (item.info.id > acc.info.id ? item : acc))
|
||||
userMessageID = user.info.id
|
||||
|
||||
const assistantText = messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.flatMap((m) => m.parts)
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
|
||||
return assistantText.includes(input.token)
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
if (!userMessageID) throw new Error("Expected a user message id")
|
||||
return { prompt, userMessageID }
|
||||
}
|
||||
|
||||
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const token = `undo_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||
|
||||
await seeded.prompt.click()
|
||||
await page.keyboard.type("/undo")
|
||||
|
||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||
await expect(undo).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(seeded.userMessageID)
|
||||
|
||||
await expect(seeded.prompt).toContainText(token)
|
||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const token = `redo_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||
|
||||
await seeded.prompt.click()
|
||||
await page.keyboard.type("/undo")
|
||||
|
||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||
await expect(undo).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(seeded.userMessageID)
|
||||
|
||||
await seeded.prompt.click()
|
||||
await page.keyboard.press(`${modKey}+A`)
|
||||
await page.keyboard.press("Backspace")
|
||||
await page.keyboard.type("/redo")
|
||||
|
||||
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
||||
await expect(redo).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeUndefined()
|
||||
|
||||
await expect(seeded.prompt).not.toContainText(token)
|
||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSettings, closeDialog, withSession } from "../actions"
|
||||
import { keybindButtonSelector } from "../selectors"
|
||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
||||
@@ -267,11 +267,14 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await page.waitForTimeout(100)
|
||||
const terminal = page.locator(terminalSelector)
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
const pageStable = await page.evaluate(() => document.readyState === "complete")
|
||||
expect(pageStable).toBe(true)
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).toBeVisible()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("changing command palette keybind works", async ({ page, gotoSession }) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: true,
|
||||
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
|
||||
@@ -55,6 +55,7 @@ const extraArgs = (() => {
|
||||
const [serverPort, webPort] = await Promise.all([freePort(), freePort()])
|
||||
|
||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
||||
const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1"
|
||||
|
||||
const serverEnv = {
|
||||
...process.env,
|
||||
@@ -83,58 +84,95 @@ const runnerEnv = {
|
||||
PLAYWRIGHT_PORT: String(webPort),
|
||||
} satisfies Record<string, string>
|
||||
|
||||
const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
|
||||
cwd: opencodeDir,
|
||||
env: serverEnv,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
let seed: ReturnType<typeof Bun.spawn> | undefined
|
||||
let runner: ReturnType<typeof Bun.spawn> | undefined
|
||||
let server: { stop: () => Promise<void> | void } | undefined
|
||||
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
|
||||
let cleaned = false
|
||||
|
||||
const seedExit = await seed.exited
|
||||
if (seedExit !== 0) {
|
||||
process.exit(seedExit)
|
||||
const cleanup = async () => {
|
||||
if (cleaned) return
|
||||
cleaned = true
|
||||
|
||||
if (seed && seed.exitCode === null) seed.kill("SIGTERM")
|
||||
if (runner && runner.exitCode === null) runner.kill("SIGTERM")
|
||||
|
||||
const jobs = [
|
||||
inst?.Instance.disposeAll(),
|
||||
server?.stop(),
|
||||
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
|
||||
].filter(Boolean)
|
||||
await Promise.allSettled(jobs)
|
||||
}
|
||||
|
||||
Object.assign(process.env, serverEnv)
|
||||
process.env.AGENT = "1"
|
||||
process.env.OPENCODE = "1"
|
||||
const shutdown = (code: number, reason: string) => {
|
||||
process.exitCode = code
|
||||
void cleanup().finally(() => {
|
||||
console.error(`e2e-local shutdown: ${reason}`)
|
||||
process.exit(code)
|
||||
})
|
||||
}
|
||||
|
||||
const log = await import("../../opencode/src/util/log")
|
||||
const install = await import("../../opencode/src/installation")
|
||||
await log.Log.init({
|
||||
print: true,
|
||||
dev: install.Installation.isLocal(),
|
||||
level: "WARN",
|
||||
const reportInternalError = (reason: string, error: unknown) => {
|
||||
console.warn(`e2e-local ignored server error: ${reason}`)
|
||||
console.warn(error)
|
||||
}
|
||||
|
||||
process.once("SIGINT", () => shutdown(130, "SIGINT"))
|
||||
process.once("SIGTERM", () => shutdown(143, "SIGTERM"))
|
||||
process.once("SIGHUP", () => shutdown(129, "SIGHUP"))
|
||||
process.once("uncaughtException", (error) => {
|
||||
reportInternalError("uncaughtException", error)
|
||||
})
|
||||
process.once("unhandledRejection", (error) => {
|
||||
reportInternalError("unhandledRejection", error)
|
||||
})
|
||||
|
||||
const servermod = await import("../../opencode/src/server/server")
|
||||
const inst = await import("../../opencode/src/project/instance")
|
||||
const server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
||||
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
||||
let code = 1
|
||||
|
||||
try {
|
||||
seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
|
||||
cwd: opencodeDir,
|
||||
env: serverEnv,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
const seedExit = await seed.exited
|
||||
if (seedExit !== 0) {
|
||||
code = seedExit
|
||||
} else {
|
||||
Object.assign(process.env, serverEnv)
|
||||
process.env.AGENT = "1"
|
||||
process.env.OPENCODE = "1"
|
||||
|
||||
const log = await import("../../opencode/src/util/log")
|
||||
const install = await import("../../opencode/src/installation")
|
||||
await log.Log.init({
|
||||
print: true,
|
||||
dev: install.Installation.isLocal(),
|
||||
level: "WARN",
|
||||
})
|
||||
|
||||
const servermod = await import("../../opencode/src/server/server")
|
||||
inst = await import("../../opencode/src/project/instance")
|
||||
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
||||
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
||||
|
||||
const result = await (async () => {
|
||||
try {
|
||||
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
|
||||
|
||||
const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
|
||||
runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
|
||||
cwd: appDir,
|
||||
env: runnerEnv,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
return { code: await runner.exited }
|
||||
} catch (error) {
|
||||
return { error }
|
||||
} finally {
|
||||
await inst.Instance.disposeAll()
|
||||
await server.stop()
|
||||
code = await runner.exited
|
||||
}
|
||||
})()
|
||||
|
||||
if ("error" in result) {
|
||||
console.error(result.error)
|
||||
process.exit(1)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
code = 1
|
||||
} finally {
|
||||
await cleanup()
|
||||
}
|
||||
|
||||
process.exit(result.code)
|
||||
process.exit(code)
|
||||
|
||||
@@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element }) {
|
||||
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
||||
const platform = usePlatform()
|
||||
|
||||
const stored = (() => {
|
||||
@@ -106,7 +106,7 @@ export function AppInterface(props: { defaultUrl?: string; children?: JSX.Elemen
|
||||
}
|
||||
|
||||
return (
|
||||
<ServerProvider defaultUrl={defaultServerUrl()}>
|
||||
<ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
|
||||
@@ -6,6 +6,7 @@ let dirsToExpand: typeof import("./file-tree").dirsToExpand
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
mock.module("@/context/file", () => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useFile } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -20,11 +21,7 @@ import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
function pathToFileUrl(filepath: string): string {
|
||||
const encodedPath = filepath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/")
|
||||
return `file://${encodedPath}`
|
||||
return `file://${encodeFilePath(filepath)}`
|
||||
}
|
||||
|
||||
type Kind = "add" | "del" | "mix"
|
||||
@@ -223,12 +220,14 @@ export default function FileTree(props: {
|
||||
seen.add(item)
|
||||
}
|
||||
|
||||
return out.toSorted((a, b) => {
|
||||
out.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
const Node = (
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
||||
import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
|
||||
@@ -97,6 +98,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const command = useCommand()
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
let editorRef!: HTMLDivElement
|
||||
let fileInputRef!: HTMLInputElement
|
||||
let scrollRef!: HTMLDivElement
|
||||
@@ -205,7 +207,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
historyIndex: number
|
||||
savedPrompt: Prompt | null
|
||||
placeholder: number
|
||||
dragging: boolean
|
||||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
}>({
|
||||
@@ -213,7 +215,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
placeholder: Math.floor(Math.random() * EXAMPLES.length),
|
||||
dragging: false,
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
})
|
||||
@@ -413,7 +415,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
} = useFilteredList<SlashCommand>({
|
||||
items: slashCommands,
|
||||
key: (x) => x?.id,
|
||||
filterKeys: ["trigger", "title", "description"],
|
||||
filterKeys: ["trigger", "title"],
|
||||
onSelect: handleSlashSelect,
|
||||
})
|
||||
|
||||
@@ -760,8 +762,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
editor: () => editorRef,
|
||||
isFocused,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDragging: (value) => setStore("dragging", value),
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
focusEditor: () => {
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, promptLength(prompt.current()))
|
||||
},
|
||||
addPart,
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
})
|
||||
|
||||
const { abort, handleSubmit } = createPromptSubmit({
|
||||
@@ -780,7 +787,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
newSessionWorktree: props.newSessionWorktree,
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
onSubmit: props.onSubmit,
|
||||
})
|
||||
@@ -946,11 +953,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
"group/prompt-input": true,
|
||||
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
|
||||
"rounded-[14px] overflow-clip focus-within:shadow-xs-border": true,
|
||||
"border-icon-info-active border-dashed": store.dragging,
|
||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<PromptDragOverlay dragging={store.dragging} label={language.t("prompt.dropzone.label")} />
|
||||
<PromptDragOverlay
|
||||
type={store.draggingType}
|
||||
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
|
||||
/>
|
||||
<PromptContextItems
|
||||
items={prompt.context.items()}
|
||||
active={(item) => {
|
||||
@@ -983,6 +993,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
aria-multiline="true"
|
||||
aria-label={placeholder()}
|
||||
contenteditable="true"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
|
||||
@@ -11,8 +11,10 @@ type PromptAttachmentsInput = {
|
||||
editor: () => HTMLDivElement | undefined
|
||||
isFocused: () => boolean
|
||||
isDialogActive: () => boolean
|
||||
setDragging: (value: boolean) => void
|
||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||
focusEditor: () => void
|
||||
addPart: (part: ContentPart) => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
}
|
||||
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
@@ -29,7 +31,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const dataUrl = reader.result as string
|
||||
const attachment: ImageAttachmentPart = {
|
||||
type: "image",
|
||||
id: crypto.randomUUID(),
|
||||
id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
|
||||
filename: file.name,
|
||||
mime: file.type,
|
||||
dataUrl,
|
||||
@@ -75,6 +77,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
}
|
||||
|
||||
const plainText = clipboardData.getData("text/plain") ?? ""
|
||||
|
||||
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
|
||||
if (input.readClipboardImage && !plainText) {
|
||||
const file = await input.readClipboardImage()
|
||||
if (file) {
|
||||
await addImageAttachment(file)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!plainText) return
|
||||
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||
}
|
||||
@@ -84,15 +96,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
|
||||
event.preventDefault()
|
||||
const hasFiles = event.dataTransfer?.types.includes("Files")
|
||||
const hasText = event.dataTransfer?.types.includes("text/plain")
|
||||
if (hasFiles) {
|
||||
input.setDragging(true)
|
||||
input.setDraggingType("image")
|
||||
} else if (hasText) {
|
||||
input.setDraggingType("@mention")
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalDragLeave = (event: DragEvent) => {
|
||||
if (input.isDialogActive()) return
|
||||
if (!event.relatedTarget) {
|
||||
input.setDragging(false)
|
||||
input.setDraggingType(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +115,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
if (input.isDialogActive()) return
|
||||
|
||||
event.preventDefault()
|
||||
input.setDragging(false)
|
||||
input.setDraggingType(null)
|
||||
|
||||
const plainText = event.dataTransfer?.getData("text/plain")
|
||||
const filePrefix = "file:"
|
||||
if (plainText?.startsWith(filePrefix)) {
|
||||
const filePath = plainText.slice(filePrefix.length)
|
||||
input.focusEditor()
|
||||
input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 })
|
||||
return
|
||||
}
|
||||
|
||||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
|
||||
@@ -64,4 +64,214 @@ describe("buildRequestParts", () => {
|
||||
expect(fooFiles).toHaveLength(2)
|
||||
expect(synthetic).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src\\foo.ts",
|
||||
messageID: "msg_win_1",
|
||||
sessionID: "ses_win_1",
|
||||
sessionDirectory: "D:\\projects\\myapp", // Windows path
|
||||
})
|
||||
|
||||
// Should create valid file URLs
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should not have encoded backslashes in wrong place
|
||||
expect(filePart.url).not.toContain("%5C")
|
||||
// Should have normalized to forward slashes
|
||||
expect(filePart.url).toContain("/src/foo.ts")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles Windows absolute path with special characters", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@file#name.txt",
|
||||
messageID: "msg_win_2",
|
||||
sessionID: "ses_win_2",
|
||||
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Special chars should be encoded
|
||||
expect(filePart.url).toContain("file%23name.txt")
|
||||
// Should have Windows drive letter properly encoded
|
||||
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
|
||||
}
|
||||
})
|
||||
|
||||
test("handles Linux absolute paths correctly", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src/app.ts",
|
||||
messageID: "msg_linux_1",
|
||||
sessionID: "ses_linux_1",
|
||||
sessionDirectory: "/home/user/project",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should be a normal Unix path
|
||||
expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles macOS paths correctly", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@README.md",
|
||||
messageID: "msg_mac_1",
|
||||
sessionID: "ses_mac_1",
|
||||
sessionDirectory: "/Users/kelvin/Projects/opencode",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should be a normal Unix path
|
||||
expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles context files with Windows paths", () => {
|
||||
const prompt: Prompt = []
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [
|
||||
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
|
||||
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
|
||||
],
|
||||
images: [],
|
||||
text: "test",
|
||||
messageID: "msg_win_ctx",
|
||||
sessionID: "ses_win_ctx",
|
||||
sessionDirectory: "D:\\workspace\\app",
|
||||
})
|
||||
|
||||
const fileParts = result.requestParts.filter((part) => part.type === "file")
|
||||
expect(fileParts).toHaveLength(2)
|
||||
|
||||
// All file URLs should be valid
|
||||
fileParts.forEach((part) => {
|
||||
if (part.type === "file") {
|
||||
expect(() => new URL(part.url)).not.toThrow()
|
||||
expect(part.url).not.toContain("%5C") // No encoded backslashes
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("handles absolute Windows paths (user manually specifies full path)", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@D:\\other\\project\\file.ts",
|
||||
messageID: "msg_abs",
|
||||
sessionID: "ses_abs",
|
||||
sessionDirectory: "C:\\current\\project",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// Should handle absolute path that differs from sessionDirectory
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
expect(filePart.url).toContain("/D:/other/project/file.ts")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles selection with query parameters on Windows", () => {
|
||||
const prompt: Prompt = [
|
||||
{
|
||||
type: "file",
|
||||
path: "src\\App.tsx",
|
||||
content: "@src\\App.tsx",
|
||||
start: 0,
|
||||
end: 11,
|
||||
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
|
||||
},
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src\\App.tsx",
|
||||
messageID: "msg_sel",
|
||||
sessionID: "ses_sel",
|
||||
sessionDirectory: "C:\\project",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// Should have query parameters
|
||||
expect(filePart.url).toContain("?start=10&end=20")
|
||||
// Should be valid URL
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Query params should parse correctly
|
||||
const url = new URL(filePart.url)
|
||||
expect(url.searchParams.get("start")).toBe("10")
|
||||
expect(url.searchParams.get("end")).toBe("20")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles file paths with dots and special segments on Windows", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@..\\..\\shared\\util.ts",
|
||||
messageID: "msg_dots",
|
||||
sessionID: "ses_dots",
|
||||
sessionDirectory: "C:\\projects\\myapp\\src",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// Should be valid URL
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should preserve .. segments (backend normalizes)
|
||||
expect(filePart.url).toContain("/..")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { Identifier } from "@/utils/id"
|
||||
|
||||
@@ -27,14 +28,12 @@ type BuildRequestPartsInput = {
|
||||
sessionDirectory: string
|
||||
}
|
||||
|
||||
const absolute = (directory: string, path: string) =>
|
||||
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
|
||||
|
||||
const encodeFilePath = (filepath: string): string =>
|
||||
filepath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/")
|
||||
const absolute = (directory: string, path: string) => {
|
||||
if (path.startsWith("/")) return path
|
||||
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
|
||||
if (path.startsWith("\\\\") || path.startsWith("//")) return path
|
||||
return `${directory.replace(/[\\/]+$/, "")}/${path}`
|
||||
}
|
||||
|
||||
const fileQuery = (selection: FileSelection | undefined) =>
|
||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||
|
||||
@@ -2,16 +2,16 @@ import { Component, Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
|
||||
type PromptDragOverlayProps = {
|
||||
dragging: boolean
|
||||
type: "image" | "@mention" | null
|
||||
label: string
|
||||
}
|
||||
|
||||
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.dragging}>
|
||||
<Show when={props.type !== null}>
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||
<Icon name="photo" class="size-8" />
|
||||
<Icon name={props.type === "@mention" ? "link" : "photo"} class="size-8" />
|
||||
<span class="text-14-regular">{props.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const sentShell: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
|
||||
let selected = "/repo/worktree-a"
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
|
||||
const clientFor = (directory: string) => ({
|
||||
session: {
|
||||
create: async () => {
|
||||
createdSessions.push(directory)
|
||||
return { data: { id: `session-${createdSessions.length}` } }
|
||||
},
|
||||
shell: async () => {
|
||||
sentShell.push(directory)
|
||||
return { data: undefined }
|
||||
},
|
||||
prompt: async () => ({ data: undefined }),
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
worktree: {
|
||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||
},
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
const rootClient = clientFor("/repo/main")
|
||||
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: (input: { directory: string }) => {
|
||||
createdClients.push(input.directory)
|
||||
return clientFor(input.directory)
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
showToast: () => 0,
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/util/encode", () => ({
|
||||
base64Encode: (value: string) => value,
|
||||
}))
|
||||
|
||||
mock.module("@/context/local", () => ({
|
||||
useLocal: () => ({
|
||||
model: {
|
||||
current: () => ({ id: "model", provider: { id: "provider" } }),
|
||||
variant: { current: () => undefined },
|
||||
},
|
||||
agent: {
|
||||
current: () => ({ name: "agent" }),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/prompt", () => ({
|
||||
usePrompt: () => ({
|
||||
current: () => promptValue,
|
||||
reset: () => undefined,
|
||||
set: () => undefined,
|
||||
context: {
|
||||
add: () => undefined,
|
||||
remove: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/layout", () => ({
|
||||
useLayout: () => ({
|
||||
handoff: {
|
||||
setTabs: () => undefined,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/sdk", () => ({
|
||||
useSDK: () => ({
|
||||
directory: "/repo/main",
|
||||
client: rootClient,
|
||||
url: "http://localhost:4096",
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => ({
|
||||
data: { command: [] },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: () => undefined,
|
||||
remove: () => undefined,
|
||||
},
|
||||
},
|
||||
set: () => undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/global-sync", () => ({
|
||||
useGlobalSync: () => ({
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
return [{}, () => undefined]
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/platform", () => ({
|
||||
usePlatform: () => ({
|
||||
fetch: fetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/language", () => ({
|
||||
useLanguage: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mod = await import("./submit")
|
||||
createPromptSubmit = mod.createPromptSubmit
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
createdClients.length = 0
|
||||
createdSessions.length = 0
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
test("reads the latest worktree accessor value per submit", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
selected = "/repo/worktree-b"
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
})
|
||||
})
|
||||
@@ -37,7 +37,7 @@ type PromptSubmitInput = {
|
||||
resetHistoryNavigation: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
setPopover: (popover: "at" | "slash" | null) => void
|
||||
newSessionWorktree?: string
|
||||
newSessionWorktree?: Accessor<string | undefined>
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
onSubmit?: () => void
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
const projectDirectory = sdk.directory
|
||||
const isNewSession = !params.id
|
||||
const worktreeSelection = input.newSessionWorktree ?? "main"
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
let client = sdk.client
|
||||
@@ -200,7 +200,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
}
|
||||
}
|
||||
if (!session) return
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input.onSubmit?.()
|
||||
|
||||
|
||||
@@ -79,15 +79,16 @@ describe("getSessionContextMetrics", () => {
|
||||
expect(metrics.context?.usage).toBeNull()
|
||||
})
|
||||
|
||||
test("memoizes by message and provider array identity", () => {
|
||||
test("recomputes when message array is mutated in place", () => {
|
||||
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
|
||||
const providers = [{ id: "openai", models: {} }]
|
||||
|
||||
const one = getSessionContextMetrics(messages, providers)
|
||||
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
|
||||
const two = getSessionContextMetrics(messages, providers)
|
||||
const three = getSessionContextMetrics([...messages], providers)
|
||||
|
||||
expect(two).toBe(one)
|
||||
expect(three).not.toBe(one)
|
||||
expect(one.context?.message.id).toBe("a1")
|
||||
expect(two.context?.message.id).toBe("a2")
|
||||
expect(two.totalCost).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,8 +34,6 @@ type Metrics = {
|
||||
context: Context | undefined
|
||||
}
|
||||
|
||||
const cache = new WeakMap<Message[], WeakMap<Provider[], Metrics>>()
|
||||
|
||||
const tokenTotal = (msg: AssistantMessage) => {
|
||||
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||
}
|
||||
@@ -80,15 +78,5 @@ const build = (messages: Message[], providers: Provider[]): Metrics => {
|
||||
}
|
||||
|
||||
export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
|
||||
const byProvider = cache.get(messages)
|
||||
if (byProvider) {
|
||||
const hit = byProvider.get(providers)
|
||||
if (hit) return hit
|
||||
}
|
||||
|
||||
const value = build(messages, providers)
|
||||
const next = byProvider ?? new WeakMap<Provider[], Metrics>()
|
||||
next.set(providers, value)
|
||||
if (!byProvider) cache.set(messages, next)
|
||||
return value
|
||||
return build(messages, providers)
|
||||
}
|
||||
|
||||
@@ -112,21 +112,35 @@ export function SessionHeader() {
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
|
||||
|
||||
const apps = createMemo(() => {
|
||||
if (os() === "macos") return MAC_APPS
|
||||
if (os() === "windows") return WINDOWS_APPS
|
||||
return LINUX_APPS
|
||||
})
|
||||
|
||||
const fileManager = createMemo(() => {
|
||||
if (os() === "macos") return { label: "Finder", icon: "finder" as const }
|
||||
if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const }
|
||||
return { label: "File Manager", icon: "finder" as const }
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (platform.platform !== "desktop") return
|
||||
if (!platform.checkAppExists) return
|
||||
|
||||
const list = os()
|
||||
const apps = list === "macos" ? MAC_APPS : list === "windows" ? WINDOWS_APPS : list === "linux" ? LINUX_APPS : []
|
||||
if (apps.length === 0) return
|
||||
const list = apps()
|
||||
|
||||
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
|
||||
|
||||
void Promise.all(
|
||||
apps.map((app) =>
|
||||
Promise.resolve(platform.checkAppExists?.(app.openWith)).then((value) => {
|
||||
const ok = Boolean(value)
|
||||
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
|
||||
return [app.id, ok] as const
|
||||
}),
|
||||
list.map((app) =>
|
||||
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
||||
.then((value) => Boolean(value))
|
||||
.catch(() => false)
|
||||
.then((ok) => {
|
||||
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
|
||||
return [app.id, ok] as const
|
||||
}),
|
||||
),
|
||||
).then((entries) => {
|
||||
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||
@@ -134,23 +148,23 @@ export function SessionHeader() {
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
if (os() === "macos") {
|
||||
return [{ id: "finder", label: "Finder", icon: "finder" }, ...MAC_APPS.filter((app) => exists[app.id])] as const
|
||||
}
|
||||
|
||||
if (os() === "windows") {
|
||||
return [
|
||||
{ id: "finder", label: "File Explorer", icon: "file-explorer" },
|
||||
...WINDOWS_APPS.filter((app) => exists[app.id]),
|
||||
] as const
|
||||
}
|
||||
|
||||
return [
|
||||
{ id: "finder", label: "File Manager", icon: "finder" },
|
||||
...LINUX_APPS.filter((app) => exists[app.id]),
|
||||
{ id: "finder", label: fileManager().label, icon: fileManager().icon },
|
||||
...apps().filter((app) => exists[app.id]),
|
||||
] as const
|
||||
})
|
||||
|
||||
type OpenIcon = OpenApp | "file-explorer"
|
||||
const base = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
|
||||
const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]")
|
||||
|
||||
const checksReady = createMemo(() => {
|
||||
if (platform.platform !== "desktop") return true
|
||||
if (!platform.checkAppExists) return true
|
||||
const list = apps()
|
||||
return list.every((app) => exists[app.id] !== undefined)
|
||||
})
|
||||
|
||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
||||
|
||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||
@@ -158,6 +172,7 @@ export function SessionHeader() {
|
||||
|
||||
createEffect(() => {
|
||||
if (platform.platform !== "desktop") return
|
||||
if (!checksReady()) return
|
||||
const value = prefs.app
|
||||
if (options().some((o) => o.id === value)) return
|
||||
setPrefs("app", options()[0]?.id ?? "finder")
|
||||
@@ -283,7 +298,7 @@ export function SessionHeader() {
|
||||
<Portal mount={mount()}>
|
||||
<button
|
||||
type="button"
|
||||
class="hidden md:flex w-[320px] max-w-full min-w-0 p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
||||
class="hidden md:flex w-[320px] max-w-full min-w-0 h-[24px] px-2 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-base bg-surface-panel transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
||||
onClick={() => command.trigger("file.open")}
|
||||
aria-label={language.t("session.header.searchFiles")}
|
||||
>
|
||||
@@ -294,7 +309,11 @@ export function SessionHeader() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={hotkey()}>{(keybind) => <Keybind class="shrink-0">{keybind()}</Keybind>}</Show>
|
||||
<Show when={hotkey()}>
|
||||
{(keybind) => (
|
||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
</Portal>
|
||||
)}
|
||||
@@ -303,6 +322,7 @@ export function SessionHeader() {
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<div class="flex items-center gap-3">
|
||||
<StatusPopover />
|
||||
<Show when={projectDirectory()}>
|
||||
<div class="hidden xl:flex items-center">
|
||||
<Show
|
||||
@@ -322,62 +342,68 @@ export function SessionHeader() {
|
||||
}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none rounded-r-none"
|
||||
onClick={() => openDir(current().id)}
|
||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||
>
|
||||
<AppIcon id={current().icon} class="size-5" />
|
||||
<span class="text-12-regular text-text-strong">
|
||||
{language.t("session.header.open.action", { app: current().label })}
|
||||
</span>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="chevron-down"
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-sm h-[24px] w-auto px-1.5 border-none shadow-none rounded-l-none data-[expanded]:bg-surface-raised-base-active"
|
||||
aria-label={language.t("session.header.open.menu")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content placement="bottom-end" gutter={6}>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
|
||||
<DropdownMenu.RadioGroup
|
||||
value={prefs.app}
|
||||
onChange={(value) => {
|
||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||
setPrefs("app", value as OpenApp)
|
||||
}}
|
||||
>
|
||||
{options().map((o) => (
|
||||
<DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
|
||||
<AppIcon id={o.icon} class="size-5" />
|
||||
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemIndicator>
|
||||
<Icon name="check-small" size="small" class="text-icon-weak" />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>
|
||||
))}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={copyPath}>
|
||||
<Icon name="copy" size="small" class="text-icon-weak" />
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none"
|
||||
onClick={() => openDir(current().id)}
|
||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<AppIcon id={current().icon} class="size-4" />
|
||||
</div>
|
||||
<span class="text-12-regular text-text-strong">Open</span>
|
||||
</Button>
|
||||
<div class="self-stretch w-px bg-border-base/70" />
|
||||
<DropdownMenu gutter={6} placement="bottom-end">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="chevron-down"
|
||||
variant="ghost"
|
||||
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active"
|
||||
aria-label={language.t("session.header.open.menu")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
|
||||
<DropdownMenu.RadioGroup
|
||||
value={prefs.app}
|
||||
onChange={(value) => {
|
||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||
setPrefs("app", value as OpenApp)
|
||||
}}
|
||||
>
|
||||
{options().map((o) => (
|
||||
<DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<AppIcon id={o.icon} class={size(o.icon)} />
|
||||
</div>
|
||||
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemIndicator>
|
||||
<Icon name="check-small" size="small" class="text-icon-weak" />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>
|
||||
))}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={copyPath}>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<Icon name="copy" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<StatusPopover />
|
||||
<Show when={showShare()}>
|
||||
<div class="flex items-center">
|
||||
<Popover
|
||||
@@ -393,8 +419,9 @@ export function SessionHeader() {
|
||||
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "secondary",
|
||||
class: "rounded-sm h-[24px] px-3",
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
|
||||
classList: { "rounded-r-none": shareUrl() !== undefined },
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
@@ -420,7 +447,14 @@ export function SessionHeader() {
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextField value={shareUrl() ?? ""} readOnly copyable tabIndex={-1} class="w-full" />
|
||||
<TextField
|
||||
value={shareUrl() ?? ""}
|
||||
readOnly
|
||||
copyable
|
||||
copyKind="link"
|
||||
tabIndex={-1}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
@@ -459,8 +493,8 @@ export function SessionHeader() {
|
||||
>
|
||||
<IconButton
|
||||
icon={state.copied ? "check" : "link"}
|
||||
variant="secondary"
|
||||
class="rounded-l-none"
|
||||
variant="ghost"
|
||||
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
|
||||
onClick={copyLink}
|
||||
disabled={state.unshare}
|
||||
aria-label={
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
@@ -15,6 +16,7 @@ interface NewSessionViewProps {
|
||||
|
||||
export function NewSessionView(props: NewSessionViewProps) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
|
||||
@@ -24,11 +26,11 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
if (options().includes(selection)) return selection
|
||||
return MAIN_WORKTREE
|
||||
})
|
||||
const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory)
|
||||
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
|
||||
const isWorktree = createMemo(() => {
|
||||
const project = sync.project
|
||||
if (!project) return false
|
||||
return sync.data.path.directory !== project.worktree
|
||||
return sdk.directory !== project.worktree
|
||||
})
|
||||
|
||||
const label = (value: string) => {
|
||||
@@ -45,7 +47,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]">
|
||||
<div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]">
|
||||
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<Icon name="folder" size="small" />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Component, createMemo, type JSX } from "solid-js"
|
||||
import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -40,6 +42,8 @@ export const SettingsGeneral: Component = () => {
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
|
||||
const check = () => {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
@@ -410,13 +414,49 @@ export const SettingsGeneral: Component = () => {
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={linux()}>
|
||||
{(_) => {
|
||||
const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
|
||||
const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
|
||||
|
||||
const onChange = (checked: boolean) =>
|
||||
platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("settings.general.row.wayland.title")}</span>
|
||||
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
||||
<span class="text-text-weak">
|
||||
<Icon name="help" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
description={language.t("settings.general.row.wayland.description")}
|
||||
>
|
||||
<div data-action="settings-wayland">
|
||||
<Switch checked={value() === "wayland"} onChange={onChange} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsRowProps {
|
||||
title: string
|
||||
title: string | JSX.Element
|
||||
description: string | JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ export function StatusPopover() {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-sm w-[75px] h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none data-[expanded]:bg-surface-raised-base-active",
|
||||
"rounded-md h-[24px] px-3 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitPr
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||
import { parseKeybind, matchKeybind } from "@/context/command"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
import { LocalPTY } from "@/context/terminal"
|
||||
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
|
||||
@@ -10,6 +11,8 @@ import { useLanguage } from "@/context/language"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
|
||||
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
onSubmit?: () => void
|
||||
@@ -71,7 +74,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
let handleTextareaBlur: () => void
|
||||
let disposed = false
|
||||
const cleanups: VoidFunction[] = []
|
||||
let tail = local.pty.tail ?? ""
|
||||
const start =
|
||||
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
|
||||
let cursor = start ?? 0
|
||||
|
||||
const cleanup = () => {
|
||||
if (!cleanups.length) return
|
||||
@@ -161,13 +166,16 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
const once = { value: false }
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (window.__OPENCODE__?.serverPassword) {
|
||||
url.username = "opencode"
|
||||
url.password = window.__OPENCODE__?.serverPassword
|
||||
}
|
||||
const socket = new WebSocket(url)
|
||||
socket.binaryType = "arraybuffer"
|
||||
cleanups.push(() => {
|
||||
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
|
||||
})
|
||||
@@ -237,12 +245,11 @@ export const Terminal = (props: TerminalProps) => {
|
||||
return true
|
||||
}
|
||||
|
||||
// allow for ctrl-` to toggle terminal in parent
|
||||
if (event.ctrlKey && key === "`") {
|
||||
return true
|
||||
}
|
||||
// allow for toggle terminal keybinds in parent
|
||||
const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? DEFAULT_TOGGLE_TERMINAL_KEYBIND
|
||||
const keybinds = parseKeybind(config)
|
||||
|
||||
return false
|
||||
return matchKeybind(keybinds, event)
|
||||
})
|
||||
|
||||
const fit = new mod.FitAddon()
|
||||
@@ -287,26 +294,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
handleResize = () => fit.fit()
|
||||
window.addEventListener("resize", handleResize)
|
||||
cleanups.push(() => window.removeEventListener("resize", handleResize))
|
||||
const limit = 16_384
|
||||
const min = 32
|
||||
const windowMs = 750
|
||||
const seed = tail.length > limit ? tail.slice(-limit) : tail
|
||||
let sync = seed.length >= min
|
||||
let syncUntil = 0
|
||||
const stopSync = () => {
|
||||
sync = false
|
||||
syncUntil = 0
|
||||
}
|
||||
|
||||
const overlap = (data: string) => {
|
||||
if (!seed) return 0
|
||||
const max = Math.min(seed.length, data.length)
|
||||
if (max < min) return 0
|
||||
for (let i = max; i >= min; i--) {
|
||||
if (seed.slice(-i) === data.slice(0, i)) return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const onResize = t.onResize(async (size) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
@@ -323,7 +310,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
})
|
||||
cleanups.push(() => disposeIfDisposable(onResize))
|
||||
const onData = t.onData((data) => {
|
||||
if (data) stopSync()
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data)
|
||||
}
|
||||
@@ -341,7 +327,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
const handleOpen = () => {
|
||||
local.onConnect?.()
|
||||
if (sync) syncUntil = Date.now() + windowMs
|
||||
sdk.client.pty
|
||||
.update({
|
||||
ptyID: local.pty.id,
|
||||
@@ -355,31 +340,31 @@ export const Terminal = (props: TerminalProps) => {
|
||||
socket.addEventListener("open", handleOpen)
|
||||
cleanups.push(() => socket.removeEventListener("open", handleOpen))
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (disposed) return
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
|
||||
const bytes = new Uint8Array(event.data)
|
||||
if (bytes[0] !== 0) return
|
||||
const json = decoder.decode(bytes.subarray(1))
|
||||
try {
|
||||
const meta = JSON.parse(json) as { cursor?: unknown }
|
||||
const next = meta?.cursor
|
||||
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
|
||||
cursor = next
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const data = typeof event.data === "string" ? event.data : ""
|
||||
if (!data) return
|
||||
|
||||
const next = (() => {
|
||||
if (!sync) return data
|
||||
if (syncUntil && Date.now() > syncUntil) {
|
||||
stopSync()
|
||||
return data
|
||||
}
|
||||
const n = overlap(data)
|
||||
if (!n) {
|
||||
stopSync()
|
||||
return data
|
||||
}
|
||||
const trimmed = data.slice(n)
|
||||
if (trimmed) stopSync()
|
||||
return trimmed
|
||||
})()
|
||||
|
||||
if (!next) return
|
||||
|
||||
t.write(next)
|
||||
tail = next.length >= limit ? next.slice(-limit) : (tail + next).slice(-limit)
|
||||
t.write(data)
|
||||
cursor += data.length
|
||||
}
|
||||
socket.addEventListener("message", handleMessage)
|
||||
cleanups.push(() => socket.removeEventListener("message", handleMessage))
|
||||
@@ -433,7 +418,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
props.onCleanup({
|
||||
...local.pty,
|
||||
buffer,
|
||||
tail,
|
||||
cursor,
|
||||
rows: t.rows,
|
||||
cols: t.cols,
|
||||
scrollY: t.getViewportY(),
|
||||
|
||||
@@ -68,12 +68,14 @@ export function Titlebar() {
|
||||
id: "common.goBack",
|
||||
title: language.t("common.goBack"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "mod+[",
|
||||
onSelect: back,
|
||||
},
|
||||
{
|
||||
id: "common.goForward",
|
||||
title: language.t("common.goForward"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "mod+]",
|
||||
onSelect: forward,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -6,6 +6,7 @@ let createCommentSessionForTest: typeof import("./comments").createCommentSessio
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
|
||||
@@ -53,7 +53,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
|
||||
const add = (input: Omit<LineComment, "id" | "time">) => {
|
||||
const next: LineComment = {
|
||||
id: crypto.randomUUID(),
|
||||
id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
|
||||
time: Date.now(),
|
||||
...input,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
import {
|
||||
approxBytes,
|
||||
@@ -50,9 +51,11 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
useSync()
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
const layout = useLayout()
|
||||
|
||||
const scope = createMemo(() => sdk.directory)
|
||||
const path = createPathHelpers(scope)
|
||||
const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const [store, setStore] = createStore<{
|
||||
@@ -183,6 +186,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
invalidateFromWatcher(e.details, {
|
||||
normalize: path.normalize,
|
||||
hasFile: (file) => Boolean(store.file[file]),
|
||||
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
|
||||
loadFile: (file) => {
|
||||
void load(file, { force: true })
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createPathHelpers, stripQueryAndHash, unquoteGitPath } from "./path"
|
||||
import { createPathHelpers, stripQueryAndHash, unquoteGitPath, encodeFilePath } from "./path"
|
||||
|
||||
describe("file path helpers", () => {
|
||||
test("normalizes file inputs against workspace root", () => {
|
||||
@@ -25,3 +25,328 @@ describe("file path helpers", () => {
|
||||
expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("encodeFilePath", () => {
|
||||
describe("Linux/Unix paths", () => {
|
||||
test("should handle Linux absolute path", () => {
|
||||
const linuxPath = "/home/user/project/README.md"
|
||||
const result = encodeFilePath(linuxPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
// Should create a valid URL
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/home/user/project/README.md")
|
||||
|
||||
const url = new URL(fileUrl)
|
||||
expect(url.protocol).toBe("file:")
|
||||
expect(url.pathname).toBe("/home/user/project/README.md")
|
||||
})
|
||||
|
||||
test("should handle Linux path with special characters", () => {
|
||||
const linuxPath = "/home/user/file#name with spaces.txt"
|
||||
const result = encodeFilePath(linuxPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/home/user/file%23name%20with%20spaces.txt")
|
||||
})
|
||||
|
||||
test("should handle Linux relative path", () => {
|
||||
const relativePath = "src/components/App.tsx"
|
||||
const result = encodeFilePath(relativePath)
|
||||
|
||||
expect(result).toBe("src/components/App.tsx")
|
||||
})
|
||||
|
||||
test("should handle Linux root directory", () => {
|
||||
const result = encodeFilePath("/")
|
||||
expect(result).toBe("/")
|
||||
})
|
||||
|
||||
test("should handle Linux path with all special chars", () => {
|
||||
const path = "/path/to/file#with?special%chars&more.txt"
|
||||
const result = encodeFilePath(path)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toContain("%23") // #
|
||||
expect(result).toContain("%3F") // ?
|
||||
expect(result).toContain("%25") // %
|
||||
expect(result).toContain("%26") // &
|
||||
})
|
||||
})
|
||||
|
||||
describe("macOS paths", () => {
|
||||
test("should handle macOS absolute path", () => {
|
||||
const macPath = "/Users/kelvin/Projects/opencode/README.md"
|
||||
const result = encodeFilePath(macPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/Users/kelvin/Projects/opencode/README.md")
|
||||
})
|
||||
|
||||
test("should handle macOS path with spaces", () => {
|
||||
const macPath = "/Users/kelvin/My Documents/file.txt"
|
||||
const result = encodeFilePath(macPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toContain("My%20Documents")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Windows paths", () => {
|
||||
test("should handle Windows absolute path with backslashes", () => {
|
||||
const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
// Should create a valid, parseable URL
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
|
||||
const url = new URL(fileUrl)
|
||||
expect(url.protocol).toBe("file:")
|
||||
expect(url.pathname).toContain("README.bs.md")
|
||||
expect(result).toBe("/D:/dev/projects/opencode/README.bs.md")
|
||||
})
|
||||
|
||||
test("should handle mixed separator path (Windows + Unix)", () => {
|
||||
// This is what happens in build-request-parts.ts when concatenating paths
|
||||
const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
|
||||
const result = encodeFilePath(mixedPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/D:/dev/projects/opencode/README.bs.md")
|
||||
})
|
||||
|
||||
test("should handle Windows path with spaces", () => {
|
||||
const windowsPath = "C:\\Program Files\\MyApp\\file with spaces.txt"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toContain("Program%20Files")
|
||||
expect(result).toContain("file%20with%20spaces.txt")
|
||||
})
|
||||
|
||||
test("should handle Windows path with special chars in filename", () => {
|
||||
const windowsPath = "D:\\projects\\file#name with ?marks.txt"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toContain("file%23name%20with%20%3Fmarks.txt")
|
||||
})
|
||||
|
||||
test("should handle Windows root directory", () => {
|
||||
const windowsPath = "C:\\"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/C:/")
|
||||
})
|
||||
|
||||
test("should handle Windows relative path with backslashes", () => {
|
||||
const windowsPath = "src\\components\\App.tsx"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
|
||||
// Relative paths shouldn't get the leading slash
|
||||
expect(result).toBe("src/components/App.tsx")
|
||||
})
|
||||
|
||||
test("should NOT create invalid URL like the bug report", () => {
|
||||
// This is the exact scenario from bug report by @alexyaroshuk
|
||||
const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
// The bug was creating: file://D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md
|
||||
expect(result).not.toContain("%5C") // Should not have encoded backslashes
|
||||
expect(result).not.toBe("D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md")
|
||||
|
||||
// Should be valid
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
})
|
||||
|
||||
test("should handle lowercase drive letters", () => {
|
||||
const windowsPath = "c:\\users\\test\\file.txt"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/c:/users/test/file.txt")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Cross-platform compatibility", () => {
|
||||
test("should preserve Unix paths unchanged (except encoding)", () => {
|
||||
const unixPath = "/usr/local/bin/app"
|
||||
const result = encodeFilePath(unixPath)
|
||||
expect(result).toBe("/usr/local/bin/app")
|
||||
})
|
||||
|
||||
test("should normalize Windows paths for cross-platform use", () => {
|
||||
const windowsPath = "C:\\Users\\test\\file.txt"
|
||||
const result = encodeFilePath(windowsPath)
|
||||
// Should convert to forward slashes and add leading /
|
||||
expect(result).not.toContain("\\")
|
||||
expect(result).toMatch(/^\/[A-Za-z]:\//)
|
||||
})
|
||||
|
||||
test("should handle relative paths the same on all platforms", () => {
|
||||
const unixRelative = "src/app.ts"
|
||||
const windowsRelative = "src\\app.ts"
|
||||
|
||||
const unixResult = encodeFilePath(unixRelative)
|
||||
const windowsResult = encodeFilePath(windowsRelative)
|
||||
|
||||
// Both should normalize to forward slashes
|
||||
expect(unixResult).toBe("src/app.ts")
|
||||
expect(windowsResult).toBe("src/app.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
test("should handle empty path", () => {
|
||||
const result = encodeFilePath("")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
test("should handle path with multiple consecutive slashes", () => {
|
||||
const result = encodeFilePath("//path//to///file.txt")
|
||||
// Multiple slashes should be preserved (backend handles normalization)
|
||||
expect(result).toBe("//path//to///file.txt")
|
||||
})
|
||||
|
||||
test("should encode Unicode characters", () => {
|
||||
const unicodePath = "/home/user/文档/README.md"
|
||||
const result = encodeFilePath(unicodePath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
// Unicode should be encoded
|
||||
expect(result).toContain("%E6%96%87%E6%A1%A3")
|
||||
})
|
||||
|
||||
test("should handle already normalized Windows path", () => {
|
||||
// Path that's already been normalized (has / before drive letter)
|
||||
const alreadyNormalized = "/D:/path/file.txt"
|
||||
const result = encodeFilePath(alreadyNormalized)
|
||||
|
||||
// Should not add another leading slash
|
||||
expect(result).toBe("/D:/path/file.txt")
|
||||
expect(result).not.toContain("//D")
|
||||
})
|
||||
|
||||
test("should handle just drive letter", () => {
|
||||
const justDrive = "D:"
|
||||
const result = encodeFilePath(justDrive)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(result).toBe("/D:")
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
})
|
||||
|
||||
test("should handle Windows path with trailing backslash", () => {
|
||||
const trailingBackslash = "C:\\Users\\test\\"
|
||||
const result = encodeFilePath(trailingBackslash)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/C:/Users/test/")
|
||||
})
|
||||
|
||||
test("should handle very long paths", () => {
|
||||
const longPath = "C:\\Users\\test\\" + "verylongdirectoryname\\".repeat(20) + "file.txt"
|
||||
const result = encodeFilePath(longPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).not.toContain("\\")
|
||||
})
|
||||
|
||||
test("should handle paths with dots", () => {
|
||||
const pathWithDots = "C:\\Users\\..\\test\\.\\file.txt"
|
||||
const result = encodeFilePath(pathWithDots)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
// Dots should be preserved (backend normalizes)
|
||||
expect(result).toContain("..")
|
||||
expect(result).toContain("/./")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Regression tests for PR #12424", () => {
|
||||
test("should handle file with # in name", () => {
|
||||
const path = "/path/to/file#name.txt"
|
||||
const result = encodeFilePath(path)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/path/to/file%23name.txt")
|
||||
})
|
||||
|
||||
test("should handle file with ? in name", () => {
|
||||
const path = "/path/to/file?name.txt"
|
||||
const result = encodeFilePath(path)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/path/to/file%3Fname.txt")
|
||||
})
|
||||
|
||||
test("should handle file with % in name", () => {
|
||||
const path = "/path/to/file%name.txt"
|
||||
const result = encodeFilePath(path)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
expect(() => new URL(fileUrl)).not.toThrow()
|
||||
expect(result).toBe("/path/to/file%25name.txt")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Integration with file:// URL construction", () => {
|
||||
test("should work with query parameters (Linux)", () => {
|
||||
const path = "/home/user/file.txt"
|
||||
const encoded = encodeFilePath(path)
|
||||
const fileUrl = `file://${encoded}?start=10&end=20`
|
||||
|
||||
const url = new URL(fileUrl)
|
||||
expect(url.searchParams.get("start")).toBe("10")
|
||||
expect(url.searchParams.get("end")).toBe("20")
|
||||
expect(url.pathname).toBe("/home/user/file.txt")
|
||||
})
|
||||
|
||||
test("should work with query parameters (Windows)", () => {
|
||||
const path = "C:\\Users\\test\\file.txt"
|
||||
const encoded = encodeFilePath(path)
|
||||
const fileUrl = `file://${encoded}?start=10&end=20`
|
||||
|
||||
const url = new URL(fileUrl)
|
||||
expect(url.searchParams.get("start")).toBe("10")
|
||||
expect(url.searchParams.get("end")).toBe("20")
|
||||
})
|
||||
|
||||
test("should parse correctly in URL constructor (Linux)", () => {
|
||||
const path = "/var/log/app.log"
|
||||
const fileUrl = `file://${encodeFilePath(path)}`
|
||||
const url = new URL(fileUrl)
|
||||
|
||||
expect(url.protocol).toBe("file:")
|
||||
expect(url.pathname).toBe("/var/log/app.log")
|
||||
})
|
||||
|
||||
test("should parse correctly in URL constructor (Windows)", () => {
|
||||
const path = "D:\\logs\\app.log"
|
||||
const fileUrl = `file://${encodeFilePath(path)}`
|
||||
const url = new URL(fileUrl)
|
||||
|
||||
expect(url.protocol).toBe("file:")
|
||||
expect(url.pathname).toContain("app.log")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,9 +81,23 @@ export function decodeFilePath(input: string) {
|
||||
}
|
||||
|
||||
export function encodeFilePath(filepath: string): string {
|
||||
return filepath
|
||||
// Normalize Windows paths: convert backslashes to forward slashes
|
||||
let normalized = filepath.replace(/\\/g, "/")
|
||||
|
||||
// Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs)
|
||||
if (/^[A-Za-z]:/.test(normalized)) {
|
||||
normalized = "/" + normalized
|
||||
}
|
||||
|
||||
// Encode each path segment (preserving forward slashes as path separators)
|
||||
// Keep the colon in Windows drive letters (`/C:/...`) so downstream file URL parsers
|
||||
// can reliably detect drives.
|
||||
return normalized
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.map((segment, index) => {
|
||||
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment
|
||||
return encodeURIComponent(segment)
|
||||
})
|
||||
.join("/")
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,37 @@ describe("file watcher invalidation", () => {
|
||||
expect(refresh).toEqual(["src"])
|
||||
})
|
||||
|
||||
test("reloads files that are open in tabs", () => {
|
||||
const loads: string[] = []
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
properties: {
|
||||
file: "src/open.ts",
|
||||
event: "change",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
isOpen: (path) => path === "src/open.ts",
|
||||
loadFile: (path) => loads.push(path),
|
||||
node: () => ({
|
||||
path: "src/open.ts",
|
||||
type: "file",
|
||||
name: "open.ts",
|
||||
absolute: "/repo/src/open.ts",
|
||||
ignored: false,
|
||||
}),
|
||||
isDirLoaded: () => false,
|
||||
refreshDir: () => {},
|
||||
},
|
||||
)
|
||||
|
||||
expect(loads).toEqual(["src/open.ts"])
|
||||
})
|
||||
|
||||
test("refreshes only changed loaded directory nodes", () => {
|
||||
const refresh: string[] = []
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ type WatcherEvent = {
|
||||
type WatcherOps = {
|
||||
normalize: (input: string) => string
|
||||
hasFile: (path: string) => boolean
|
||||
isOpen?: (path: string) => boolean
|
||||
loadFile: (path: string) => void
|
||||
node: (path: string) => FileNode | undefined
|
||||
isDirLoaded: (path: string) => boolean
|
||||
@@ -27,7 +28,7 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
if (!path) return
|
||||
if (path.startsWith(".git/")) return
|
||||
|
||||
if (ops.hasFile(path)) {
|
||||
if (ops.hasFile(path) || ops.isOpen?.(path)) {
|
||||
ops.loadFile(path)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
// Prefer the WebView fetch implementation for streaming responses.
|
||||
// @tauri-apps/plugin-http 2.5.x has known issues with streaming/cancellation that can
|
||||
// retain native resources in the Rust process.
|
||||
const eventSdk = createOpencodeClient({
|
||||
baseUrl: server.url,
|
||||
signal: abort.signal,
|
||||
fetch: platform.fetch,
|
||||
})
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
|
||||
@@ -34,6 +34,29 @@ const textPart = (id: string, sessionID: string, messageID: string) =>
|
||||
text: id,
|
||||
}) as Part
|
||||
|
||||
const permissionRequest = (id: string, sessionID: string, title = id) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
permission: title,
|
||||
patterns: ["*"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
}) as PermissionRequest
|
||||
|
||||
const questionRequest = (id: string, sessionID: string, title = id) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
questions: [
|
||||
{
|
||||
question: title,
|
||||
header: title,
|
||||
options: [{ label: title, description: title }],
|
||||
},
|
||||
],
|
||||
}) as QuestionRequest
|
||||
|
||||
const baseState = (input: Partial<State> = {}) =>
|
||||
({
|
||||
status: "complete",
|
||||
@@ -164,6 +187,264 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
test("cleans session caches when deleted and decrements only root totals", () => {
|
||||
const cases = [
|
||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
|
||||
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 },
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const message = userMessage("msg_1", item.info.id)
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
session: [
|
||||
rootSession({ id: "ses_1" }),
|
||||
rootSession({ id: "ses_2", parentID: "ses_1" }),
|
||||
rootSession({ id: "ses_3" }),
|
||||
],
|
||||
sessionTotal: 2,
|
||||
message: { [item.info.id]: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] },
|
||||
session_diff: { [item.info.id]: [] },
|
||||
todo: { [item.info.id]: [] },
|
||||
permission: { [item.info.id]: [] },
|
||||
question: { [item.info.id]: [] },
|
||||
session_status: { [item.info.id]: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.deleted", properties: { info: item.info } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.session.find((x) => x.id === item.info.id)).toBeUndefined()
|
||||
expect(store.sessionTotal).toBe(item.expectedTotal)
|
||||
expect(store.message[item.info.id]).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff[item.info.id]).toBeUndefined()
|
||||
expect(store.todo[item.info.id]).toBeUndefined()
|
||||
expect(store.permission[item.info.id]).toBeUndefined()
|
||||
expect(store.question[item.info.id]).toBeUndefined()
|
||||
expect(store.session_status[item.info.id]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("upserts and removes messages while clearing orphaned parts", () => {
|
||||
const sessionID = "ses_1"
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_3", sessionID)] },
|
||||
part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] },
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
...userMessage("msg_2", sessionID),
|
||||
role: "assistant",
|
||||
} as Message,
|
||||
},
|
||||
},
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant")
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"])
|
||||
expect(store.part.msg_2).toBeUndefined()
|
||||
})
|
||||
|
||||
test("upserts and prunes message parts", () => {
|
||||
const sessionID = "ses_1"
|
||||
const messageID = "msg_1"
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
part: { [messageID]: [textPart("prt_1", sessionID, messageID), textPart("prt_3", sessionID, messageID)] },
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "message.part.updated", properties: { part: textPart("prt_2", sessionID, messageID) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.part[messageID]?.map((x) => x.id)).toEqual(["prt_1", "prt_2", "prt_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
...textPart("prt_2", sessionID, messageID),
|
||||
text: "changed",
|
||||
} as Part,
|
||||
},
|
||||
},
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
const updated = store.part[messageID]?.find((x) => x.id === "prt_2")
|
||||
expect(updated?.type).toBe("text")
|
||||
if (updated?.type === "text") expect(updated.text).toBe("changed")
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "message.part.removed", properties: { messageID, partID: "prt_1" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
applyDirectoryEvent({
|
||||
event: { type: "message.part.removed", properties: { messageID, partID: "prt_2" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
applyDirectoryEvent({
|
||||
event: { type: "message.part.removed", properties: { messageID, partID: "prt_3" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.part[messageID]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("tracks permission and question request lifecycles", () => {
|
||||
const sessionID = "ses_1"
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] },
|
||||
question: { [sessionID]: [questionRequest("q_1", sessionID), questionRequest("q_3", sessionID)] },
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID) },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_2", "perm_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID, "updated") },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated")
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "question.asked", properties: questionRequest("q_2", sessionID) },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated")
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"])
|
||||
})
|
||||
|
||||
test("updates vcs branch in store and cache", () => {
|
||||
const [store, setStore] = createStore(baseState())
|
||||
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
vcsCache: {
|
||||
store: cacheStore,
|
||||
setStore: setCacheStore,
|
||||
ready: () => true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.vcs).toEqual({ branch: "feature/test" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test" })
|
||||
})
|
||||
|
||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||
const [store, setStore] = createStore(baseState())
|
||||
const pushes: string[] = []
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect, test, vi } from "bun:test"
|
||||
import { createScrollPersistence } from "./layout-scroll"
|
||||
|
||||
describe("createScrollPersistence", () => {
|
||||
test("debounces persisted scroll writes", async () => {
|
||||
const snapshot = {
|
||||
session: {
|
||||
review: { x: 0, y: 0 },
|
||||
},
|
||||
} as Record<string, Record<string, { x: number; y: number }>>
|
||||
const writes: Array<Record<string, { x: number; y: number }>> = []
|
||||
const scroll = createScrollPersistence({
|
||||
debounceMs: 10,
|
||||
getSnapshot: (sessionKey) => snapshot[sessionKey],
|
||||
onFlush: (sessionKey, next) => {
|
||||
snapshot[sessionKey] = next
|
||||
writes.push(next)
|
||||
},
|
||||
})
|
||||
test("debounces persisted scroll writes", () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const snapshot = {
|
||||
session: {
|
||||
review: { x: 0, y: 0 },
|
||||
},
|
||||
} as Record<string, Record<string, { x: number; y: number }>>
|
||||
const writes: Array<Record<string, { x: number; y: number }>> = []
|
||||
const scroll = createScrollPersistence({
|
||||
debounceMs: 10,
|
||||
getSnapshot: (sessionKey) => snapshot[sessionKey],
|
||||
onFlush: (sessionKey, next) => {
|
||||
snapshot[sessionKey] = next
|
||||
writes.push(next)
|
||||
},
|
||||
})
|
||||
|
||||
for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) {
|
||||
scroll.setScroll("session", "review", { x: 0, y: i })
|
||||
for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) {
|
||||
scroll.setScroll("session", "review", { x: 0, y: i })
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(9)
|
||||
expect(writes).toHaveLength(0)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
|
||||
expect(writes).toHaveLength(1)
|
||||
expect(writes[0]?.review).toEqual({ x: 0, y: 30 })
|
||||
|
||||
scroll.setScroll("session", "review", { x: 0, y: 30 })
|
||||
vi.advanceTimersByTime(20)
|
||||
|
||||
expect(writes).toHaveLength(1)
|
||||
scroll.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
|
||||
expect(writes).toHaveLength(1)
|
||||
expect(writes[0]?.review).toEqual({ x: 0, y: 30 })
|
||||
|
||||
scroll.setScroll("session", "review", { x: 0, y: 30 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
expect(writes).toHaveLength(1)
|
||||
scroll.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSync } from "./sync"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useModels } from "@/context/models"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
@@ -184,11 +185,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
models.setVisibility(model, visible)
|
||||
},
|
||||
variant: {
|
||||
current() {
|
||||
configured() {
|
||||
const a = agent.current()
|
||||
const m = current()
|
||||
if (!a || !m) return undefined
|
||||
return getConfiguredAgentVariant({
|
||||
agent: { model: a.model, variant: a.variant },
|
||||
model: { providerID: m.provider.id, modelID: m.id, variants: m.variants },
|
||||
})
|
||||
},
|
||||
selected() {
|
||||
const m = current()
|
||||
if (!m) return undefined
|
||||
return models.variant.get({ providerID: m.provider.id, modelID: m.id })
|
||||
},
|
||||
current() {
|
||||
return resolveModelVariant({
|
||||
variants: this.list(),
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
})
|
||||
},
|
||||
list() {
|
||||
const m = current()
|
||||
if (!m) return []
|
||||
@@ -203,17 +220,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
if (variants.length === 0) return
|
||||
const currentVariant = this.current()
|
||||
if (!currentVariant) {
|
||||
this.set(variants[0])
|
||||
return
|
||||
}
|
||||
const index = variants.indexOf(currentVariant)
|
||||
if (index === -1 || index === variants.length - 1) {
|
||||
this.set(undefined)
|
||||
return
|
||||
}
|
||||
this.set(variants[index + 1])
|
||||
this.set(
|
||||
cycleModelVariant({
|
||||
variants,
|
||||
selected: this.selected(),
|
||||
configured: this.configured(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||
|
||||
describe("model variant", () => {
|
||||
test("resolves configured agent variant when model matches", () => {
|
||||
const value = getConfiguredAgentVariant({
|
||||
agent: {
|
||||
model: { providerID: "openai", modelID: "gpt-5.2" },
|
||||
variant: "xhigh",
|
||||
},
|
||||
model: {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.2",
|
||||
variants: { low: {}, high: {}, xhigh: {} },
|
||||
},
|
||||
})
|
||||
|
||||
expect(value).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("ignores configured variant when model does not match", () => {
|
||||
const value = getConfiguredAgentVariant({
|
||||
agent: {
|
||||
model: { providerID: "openai", modelID: "gpt-5.2" },
|
||||
variant: "xhigh",
|
||||
},
|
||||
model: {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4",
|
||||
variants: { low: {}, high: {}, xhigh: {} },
|
||||
},
|
||||
})
|
||||
|
||||
expect(value).toBeUndefined()
|
||||
})
|
||||
|
||||
test("prefers selected variant over configured variant", () => {
|
||||
const value = resolveModelVariant({
|
||||
variants: ["low", "high", "xhigh"],
|
||||
selected: "high",
|
||||
configured: "xhigh",
|
||||
})
|
||||
|
||||
expect(value).toBe("high")
|
||||
})
|
||||
|
||||
test("cycles from configured variant to next", () => {
|
||||
const value = cycleModelVariant({
|
||||
variants: ["low", "high", "xhigh"],
|
||||
selected: undefined,
|
||||
configured: "high",
|
||||
})
|
||||
|
||||
expect(value).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("wraps from configured last variant to first", () => {
|
||||
const value = cycleModelVariant({
|
||||
variants: ["low", "high", "xhigh"],
|
||||
selected: undefined,
|
||||
configured: "xhigh",
|
||||
})
|
||||
|
||||
expect(value).toBe("low")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
type AgentModel = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
type Agent = {
|
||||
model?: AgentModel
|
||||
variant?: string
|
||||
}
|
||||
|
||||
type Model = AgentModel & {
|
||||
variants?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type VariantInput = {
|
||||
variants: string[]
|
||||
selected: string | undefined
|
||||
configured: string | undefined
|
||||
}
|
||||
|
||||
export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
|
||||
if (!input.agent?.variant) return undefined
|
||||
if (!input.agent.model) return undefined
|
||||
if (!input.model?.variants) return undefined
|
||||
if (input.agent.model.providerID !== input.model.providerID) return undefined
|
||||
if (input.agent.model.modelID !== input.model.modelID) return undefined
|
||||
if (!(input.agent.variant in input.model.variants)) return undefined
|
||||
return input.agent.variant
|
||||
}
|
||||
|
||||
export function resolveModelVariant(input: VariantInput) {
|
||||
if (input.selected && input.variants.includes(input.selected)) return input.selected
|
||||
if (input.configured && input.variants.includes(input.configured)) return input.configured
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function cycleModelVariant(input: VariantInput) {
|
||||
if (input.variants.length === 0) return undefined
|
||||
if (input.selected && input.variants.includes(input.selected)) {
|
||||
const index = input.variants.indexOf(input.selected)
|
||||
if (index === input.variants.length - 1) return undefined
|
||||
return input.variants[index + 1]
|
||||
}
|
||||
if (input.configured && input.variants.includes(input.configured)) {
|
||||
const index = input.variants.indexOf(input.configured)
|
||||
if (index === input.variants.length - 1) return input.variants[0]
|
||||
return input.variants[index + 1]
|
||||
}
|
||||
return input.variants[0]
|
||||
}
|
||||
@@ -57,6 +57,12 @@ export type Platform = {
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
||||
|
||||
/** Get the preferred display backend (desktop only) */
|
||||
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
|
||||
|
||||
/** Set the preferred display backend (desktop only) */
|
||||
setDisplayBackend?(backend: DisplayBackend): Promise<void>
|
||||
|
||||
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
||||
parseMarkdown?(markdown: string): Promise<string>
|
||||
|
||||
@@ -65,8 +71,13 @@ export type Platform = {
|
||||
|
||||
/** Check if an editor app exists (desktop only) */
|
||||
checkAppExists?(appName: string): Promise<boolean>
|
||||
|
||||
/** Read image from clipboard (desktop only) */
|
||||
readClipboardImage?(): Promise<File | null>
|
||||
}
|
||||
|
||||
export type DisplayBackend = "auto" | "wayland"
|
||||
|
||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||
name: "Platform",
|
||||
init: (props: { value: Platform }) => {
|
||||
|
||||
@@ -28,13 +28,14 @@ function projectsKey(url: string) {
|
||||
|
||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||
name: "Server",
|
||||
init: (props: { defaultUrl: string }) => {
|
||||
init: (props: { defaultUrl: string; isSidecar?: boolean }) => {
|
||||
const platform = usePlatform()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("server", ["server.v3"]),
|
||||
createStore({
|
||||
list: [] as string[],
|
||||
currentSidecarUrl: "",
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
lastProject: {} as Record<string, string>,
|
||||
}),
|
||||
@@ -59,7 +60,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
|
||||
const fallback = normalizeServerUrl(props.defaultUrl)
|
||||
if (fallback && url === fallback) {
|
||||
setState("active", url)
|
||||
batch(() => {
|
||||
if (!store.list.includes(url)) {
|
||||
// Add the fallback url to the list if it's not already in the list
|
||||
setStore("list", store.list.length, url)
|
||||
}
|
||||
setState("active", url)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,7 +96,20 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
if (state.active) return
|
||||
const url = normalizeServerUrl(props.defaultUrl)
|
||||
if (!url) return
|
||||
setState("active", url)
|
||||
batch(() => {
|
||||
// Remove the previous startup sidecar url
|
||||
if (store.currentSidecarUrl) {
|
||||
remove(store.currentSidecarUrl)
|
||||
}
|
||||
|
||||
// Add the new sidecar url
|
||||
if (props.isSidecar && props.defaultUrl) {
|
||||
add(props.defaultUrl)
|
||||
setStore("currentSidecarUrl", props.defaultUrl)
|
||||
}
|
||||
|
||||
setState("active", url)
|
||||
})
|
||||
})
|
||||
|
||||
const isReady = createMemo(() => ready() && !!state.active)
|
||||
|
||||
@@ -5,6 +5,7 @@ let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => str
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
|
||||
@@ -13,7 +13,7 @@ export type LocalPTY = {
|
||||
cols?: number
|
||||
buffer?: string
|
||||
scrollY?: number
|
||||
tail?: string
|
||||
cursor?: number
|
||||
}
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
|
||||
@@ -44,8 +44,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "جلسة جديدة",
|
||||
"command.file.open": "فتح ملف",
|
||||
"command.tab.close": "إغلاق علامة التبويب",
|
||||
"command.context.addSelection": "إضافة التحديد إلى السياق",
|
||||
"command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي",
|
||||
"command.input.focus": "التركيز على حقل الإدخال",
|
||||
"command.terminal.toggle": "تبديل المحطة الطرفية",
|
||||
"command.fileTree.toggle": "تبديل شجرة الملفات",
|
||||
"command.review.toggle": "تبديل المراجعة",
|
||||
@@ -211,6 +213,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
|
||||
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
|
||||
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
|
||||
"prompt.dropzone.file.label": "أفلت لإشارة @ للملف",
|
||||
"prompt.slash.badge.custom": "مخصص",
|
||||
"prompt.slash.badge.skill": "مهارة",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -232,6 +235,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "فشل إرسال أمر shell",
|
||||
"prompt.toast.commandSendFailed.title": "فشل إرسال الأمر",
|
||||
"prompt.toast.promptSendFailed.title": "فشل إرسال الموجه",
|
||||
"prompt.toast.promptSendFailed.description": "تعذر استرداد الجلسة",
|
||||
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.description": "{{enabled}} من {{total}} مفعل",
|
||||
|
||||
@@ -44,8 +44,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Nova sessão",
|
||||
"command.file.open": "Abrir arquivo",
|
||||
"command.tab.close": "Fechar aba",
|
||||
"command.context.addSelection": "Adicionar seleção ao contexto",
|
||||
"command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual",
|
||||
"command.input.focus": "Focar entrada",
|
||||
"command.terminal.toggle": "Alternar terminal",
|
||||
"command.fileTree.toggle": "Alternar árvore de arquivos",
|
||||
"command.review.toggle": "Alternar revisão",
|
||||
@@ -211,6 +213,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "Nenhum resultado correspondente",
|
||||
"prompt.popover.emptyCommands": "Nenhum comando correspondente",
|
||||
"prompt.dropzone.label": "Solte imagens ou PDFs aqui",
|
||||
"prompt.dropzone.file.label": "Solte para @mencionar arquivo",
|
||||
"prompt.slash.badge.custom": "personalizado",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -232,6 +235,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "Falha ao enviar comando shell",
|
||||
"prompt.toast.commandSendFailed.title": "Falha ao enviar comando",
|
||||
"prompt.toast.promptSendFailed.title": "Falha ao enviar prompt",
|
||||
"prompt.toast.promptSendFailed.description": "Não foi possível recuperar a sessão",
|
||||
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.description": "{{enabled}} de {{total}} habilitados",
|
||||
|
||||
@@ -47,6 +47,7 @@ export const dict = {
|
||||
"command.tab.close": "Zatvori karticu",
|
||||
"command.context.addSelection": "Dodaj odabir u kontekst",
|
||||
"command.context.addSelection.description": "Dodaj odabrane linije iz trenutne datoteke",
|
||||
"command.input.focus": "Fokusiraj polje za unos",
|
||||
"command.terminal.toggle": "Prikaži/sakrij terminal",
|
||||
"command.fileTree.toggle": "Prikaži/sakrij stablo datoteka",
|
||||
"command.review.toggle": "Prikaži/sakrij pregled",
|
||||
@@ -219,6 +220,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "Nema rezultata",
|
||||
"prompt.popover.emptyCommands": "Nema komandi",
|
||||
"prompt.dropzone.label": "Spusti slike ili PDF-ove ovdje",
|
||||
"prompt.dropzone.file.label": "Spusti za @spominjanje datoteke",
|
||||
"prompt.slash.badge.custom": "prilagođeno",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -240,6 +242,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "Neuspješno slanje shell naredbe",
|
||||
"prompt.toast.commandSendFailed.title": "Neuspješno slanje komande",
|
||||
"prompt.toast.promptSendFailed.title": "Neuspješno slanje upita",
|
||||
"prompt.toast.promptSendFailed.description": "Nije moguće dohvatiti sesiju",
|
||||
|
||||
"dialog.mcp.title": "MCP-ovi",
|
||||
"dialog.mcp.description": "{{enabled}} od {{total}} omogućeno",
|
||||
|
||||
@@ -44,8 +44,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Ny session",
|
||||
"command.file.open": "Åbn fil",
|
||||
"command.tab.close": "Luk fane",
|
||||
"command.context.addSelection": "Tilføj markering til kontekst",
|
||||
"command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil",
|
||||
"command.input.focus": "Fokuser inputfelt",
|
||||
"command.terminal.toggle": "Skift terminal",
|
||||
"command.fileTree.toggle": "Skift filtræ",
|
||||
"command.review.toggle": "Skift gennemgang",
|
||||
@@ -211,6 +213,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "Ingen matchende resultater",
|
||||
"prompt.popover.emptyCommands": "Ingen matchende kommandoer",
|
||||
"prompt.dropzone.label": "Slip billeder eller PDF'er her",
|
||||
"prompt.dropzone.file.label": "Slip for at @nævne fil",
|
||||
"prompt.slash.badge.custom": "brugerdefineret",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -232,6 +235,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "Kunne ikke sende shell-kommando",
|
||||
"prompt.toast.commandSendFailed.title": "Kunne ikke sende kommando",
|
||||
"prompt.toast.promptSendFailed.title": "Kunne ikke sende forespørgsel",
|
||||
"prompt.toast.promptSendFailed.description": "Kunne ikke hente session",
|
||||
|
||||
"dialog.mcp.title": "MCP'er",
|
||||
"dialog.mcp.description": "{{enabled}} af {{total}} aktiveret",
|
||||
|
||||
@@ -48,8 +48,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Neue Sitzung",
|
||||
"command.file.open": "Datei öffnen",
|
||||
"command.tab.close": "Tab schließen",
|
||||
"command.context.addSelection": "Auswahl zum Kontext hinzufügen",
|
||||
"command.context.addSelection.description": "Ausgewählte Zeilen aus der aktuellen Datei hinzufügen",
|
||||
"command.input.focus": "Eingabefeld fokussieren",
|
||||
"command.terminal.toggle": "Terminal umschalten",
|
||||
"command.fileTree.toggle": "Dateibaum umschalten",
|
||||
"command.review.toggle": "Überprüfung umschalten",
|
||||
@@ -253,6 +255,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "Keine passenden Ergebnisse",
|
||||
"prompt.popover.emptyCommands": "Keine passenden Befehle",
|
||||
"prompt.dropzone.label": "Bilder oder PDFs hier ablegen",
|
||||
"prompt.dropzone.file.label": "Ablegen zum @Erwähnen der Datei",
|
||||
"prompt.slash.badge.custom": "benutzerdefiniert",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -275,6 +278,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "Shell-Befehl konnte nicht gesendet werden",
|
||||
"prompt.toast.commandSendFailed.title": "Befehl konnte nicht gesendet werden",
|
||||
"prompt.toast.promptSendFailed.title": "Eingabe konnte nicht gesendet werden",
|
||||
"prompt.toast.promptSendFailed.description": "Sitzung konnte nicht abgerufen werden",
|
||||
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.description": "{{enabled}} von {{total}} aktiviert",
|
||||
|
||||
@@ -47,6 +47,7 @@ export const dict = {
|
||||
"command.tab.close": "Close tab",
|
||||
"command.context.addSelection": "Add selection to context",
|
||||
"command.context.addSelection.description": "Add selected lines from the current file",
|
||||
"command.input.focus": "Focus input",
|
||||
"command.terminal.toggle": "Toggle terminal",
|
||||
"command.fileTree.toggle": "Toggle file tree",
|
||||
"command.review.toggle": "Toggle review",
|
||||
@@ -207,8 +208,8 @@ export const dict = {
|
||||
"model.tooltip.context": "Context limit {{limit}}",
|
||||
|
||||
"common.search.placeholder": "Search",
|
||||
"common.goBack": "Back",
|
||||
"common.goForward": "Forward",
|
||||
"common.goBack": "Navigate back",
|
||||
"common.goForward": "Navigate forward",
|
||||
"common.loading": "Loading",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "Cancel",
|
||||
@@ -256,6 +257,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "No matching results",
|
||||
"prompt.popover.emptyCommands": "No matching commands",
|
||||
"prompt.dropzone.label": "Drop images or PDFs here",
|
||||
"prompt.dropzone.file.label": "Drop to @mention file",
|
||||
"prompt.slash.badge.custom": "custom",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -277,6 +279,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "Failed to send shell command",
|
||||
"prompt.toast.commandSendFailed.title": "Failed to send command",
|
||||
"prompt.toast.promptSendFailed.title": "Failed to send prompt",
|
||||
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
|
||||
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
|
||||
@@ -585,6 +588,7 @@ export const dict = {
|
||||
"settings.general.section.notifications": "System notifications",
|
||||
"settings.general.section.updates": "Updates",
|
||||
"settings.general.section.sounds": "Sound effects",
|
||||
"settings.general.section.display": "Display",
|
||||
|
||||
"settings.general.row.language.title": "Language",
|
||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
||||
@@ -595,6 +599,11 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Font",
|
||||
"settings.general.row.font.description": "Customise the mono font used in code blocks",
|
||||
|
||||
"settings.general.row.wayland.title": "Use native Wayland",
|
||||
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
"On Linux with mixed refresh-rate monitors, native Wayland can be more stable.",
|
||||
|
||||
"settings.general.row.releaseNotes.title": "Release notes",
|
||||
"settings.general.row.releaseNotes.description": "Show What's New popups after updates",
|
||||
|
||||
|
||||
@@ -44,8 +44,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Nueva sesión",
|
||||
"command.file.open": "Abrir archivo",
|
||||
"command.tab.close": "Cerrar pestaña",
|
||||
"command.context.addSelection": "Añadir selección al contexto",
|
||||
"command.context.addSelection.description": "Añadir las líneas seleccionadas del archivo actual",
|
||||
"command.input.focus": "Enfocar entrada",
|
||||
"command.terminal.toggle": "Alternar terminal",
|
||||
"command.fileTree.toggle": "Alternar árbol de archivos",
|
||||
"command.review.toggle": "Alternar revisión",
|
||||
@@ -211,6 +213,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "Sin resultados coincidentes",
|
||||
"prompt.popover.emptyCommands": "Sin comandos coincidentes",
|
||||
"prompt.dropzone.label": "Suelta imágenes o PDFs aquí",
|
||||
"prompt.dropzone.file.label": "Suelta para @mencionar archivo",
|
||||
"prompt.slash.badge.custom": "personalizado",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -232,6 +235,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "Fallo al enviar comando de shell",
|
||||
"prompt.toast.commandSendFailed.title": "Fallo al enviar comando",
|
||||
"prompt.toast.promptSendFailed.title": "Fallo al enviar prompt",
|
||||
"prompt.toast.promptSendFailed.description": "No se pudo recuperar la sesión",
|
||||
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.description": "{{enabled}} de {{total}} habilitados",
|
||||
|
||||
@@ -44,8 +44,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Nouvelle session",
|
||||
"command.file.open": "Ouvrir un fichier",
|
||||
"command.tab.close": "Fermer l'onglet",
|
||||
"command.context.addSelection": "Ajouter la sélection au contexte",
|
||||
"command.context.addSelection.description": "Ajouter les lignes sélectionnées du fichier actuel",
|
||||
"command.input.focus": "Focus input",
|
||||
"command.terminal.toggle": "Basculer le terminal",
|
||||
"command.fileTree.toggle": "Basculer l'arborescence des fichiers",
|
||||
"command.review.toggle": "Basculer la revue",
|
||||
@@ -211,6 +213,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "Aucun résultat correspondant",
|
||||
"prompt.popover.emptyCommands": "Aucune commande correspondante",
|
||||
"prompt.dropzone.label": "Déposez des images ou des PDF ici",
|
||||
"prompt.dropzone.file.label": "Déposez pour @mentionner le fichier",
|
||||
"prompt.slash.badge.custom": "personnalisé",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -232,6 +235,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "Échec de l'envoi de la commande shell",
|
||||
"prompt.toast.commandSendFailed.title": "Échec de l'envoi de la commande",
|
||||
"prompt.toast.promptSendFailed.title": "Échec de l'envoi du message",
|
||||
"prompt.toast.promptSendFailed.description": "Impossible de récupérer la session",
|
||||
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.description": "{{enabled}} sur {{total}} activés",
|
||||
|
||||
@@ -44,8 +44,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "新しいセッション",
|
||||
"command.file.open": "ファイルを開く",
|
||||
"command.tab.close": "タブを閉じる",
|
||||
"command.context.addSelection": "選択範囲をコンテキストに追加",
|
||||
"command.context.addSelection.description": "現在のファイルから選択した行を追加",
|
||||
"command.input.focus": "入力欄にフォーカス",
|
||||
"command.terminal.toggle": "ターミナルの切り替え",
|
||||
"command.fileTree.toggle": "ファイルツリーを切り替え",
|
||||
"command.review.toggle": "レビューの切り替え",
|
||||
@@ -210,6 +212,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "一致する結果がありません",
|
||||
"prompt.popover.emptyCommands": "一致するコマンドがありません",
|
||||
"prompt.dropzone.label": "画像またはPDFをここにドロップ",
|
||||
"prompt.dropzone.file.label": "ドロップして@メンションファイルを追加",
|
||||
"prompt.slash.badge.custom": "カスタム",
|
||||
"prompt.slash.badge.skill": "スキル",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -231,6 +234,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "シェルコマンドの送信に失敗しました",
|
||||
"prompt.toast.commandSendFailed.title": "コマンドの送信に失敗しました",
|
||||
"prompt.toast.promptSendFailed.title": "プロンプトの送信に失敗しました",
|
||||
"prompt.toast.promptSendFailed.description": "セッションを取得できませんでした",
|
||||
|
||||
"dialog.mcp.title": "MCP",
|
||||
"dialog.mcp.description": "{{total}}個中{{enabled}}個が有効",
|
||||
|
||||
@@ -48,8 +48,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "새 세션",
|
||||
"command.file.open": "파일 열기",
|
||||
"command.tab.close": "탭 닫기",
|
||||
"command.context.addSelection": "선택 영역을 컨텍스트에 추가",
|
||||
"command.context.addSelection.description": "현재 파일에서 선택한 줄을 추가",
|
||||
"command.input.focus": "입력창 포커스",
|
||||
"command.terminal.toggle": "터미널 토글",
|
||||
"command.fileTree.toggle": "파일 트리 토글",
|
||||
"command.review.toggle": "검토 토글",
|
||||
@@ -214,6 +216,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "일치하는 결과 없음",
|
||||
"prompt.popover.emptyCommands": "일치하는 명령어 없음",
|
||||
"prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요",
|
||||
"prompt.dropzone.file.label": "드롭하여 파일 @멘션 추가",
|
||||
"prompt.slash.badge.custom": "사용자 지정",
|
||||
"prompt.slash.badge.skill": "스킬",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -235,6 +238,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "셸 명령 전송 실패",
|
||||
"prompt.toast.commandSendFailed.title": "명령 전송 실패",
|
||||
"prompt.toast.promptSendFailed.title": "프롬프트 전송 실패",
|
||||
"prompt.toast.promptSendFailed.description": "세션을 가져올 수 없습니다",
|
||||
|
||||
"dialog.mcp.title": "MCP",
|
||||
"dialog.mcp.description": "{{total}}개 중 {{enabled}}개 활성화됨",
|
||||
|
||||
@@ -47,8 +47,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Ny sesjon",
|
||||
"command.file.open": "Åpne fil",
|
||||
"command.tab.close": "Lukk fane",
|
||||
"command.context.addSelection": "Legg til markering i kontekst",
|
||||
"command.context.addSelection.description": "Legg til valgte linjer fra gjeldende fil",
|
||||
"command.input.focus": "Fokuser inndata",
|
||||
"command.terminal.toggle": "Veksle terminal",
|
||||
"command.fileTree.toggle": "Veksle filtre",
|
||||
"command.review.toggle": "Veksle gjennomgang",
|
||||
@@ -214,6 +216,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "Ingen matchende resultater",
|
||||
"prompt.popover.emptyCommands": "Ingen matchende kommandoer",
|
||||
"prompt.dropzone.label": "Slipp bilder eller PDF-er her",
|
||||
"prompt.dropzone.file.label": "Slipp for å @nevne fil",
|
||||
"prompt.slash.badge.custom": "egendefinert",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -235,6 +238,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "Kunne ikke sende shell-kommando",
|
||||
"prompt.toast.commandSendFailed.title": "Kunne ikke sende kommando",
|
||||
"prompt.toast.promptSendFailed.title": "Kunne ikke sende forespørsel",
|
||||
"prompt.toast.promptSendFailed.description": "Kunne ikke hente økt",
|
||||
|
||||
"dialog.mcp.title": "MCP-er",
|
||||
"dialog.mcp.description": "{{enabled}} av {{total}} aktivert",
|
||||
|
||||
@@ -44,8 +44,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Nowa sesja",
|
||||
"command.file.open": "Otwórz plik",
|
||||
"command.tab.close": "Zamknij kartę",
|
||||
"command.context.addSelection": "Dodaj zaznaczenie do kontekstu",
|
||||
"command.context.addSelection.description": "Dodaj zaznaczone linie z bieżącego pliku",
|
||||
"command.input.focus": "Fokus na pole wejściowe",
|
||||
"command.terminal.toggle": "Przełącz terminal",
|
||||
"command.fileTree.toggle": "Przełącz drzewo plików",
|
||||
"command.review.toggle": "Przełącz przegląd",
|
||||
@@ -211,6 +213,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "Brak pasujących wyników",
|
||||
"prompt.popover.emptyCommands": "Brak pasujących poleceń",
|
||||
"prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj",
|
||||
"prompt.dropzone.file.label": "Upuść, aby @wspomnieć plik",
|
||||
"prompt.slash.badge.custom": "własne",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -232,6 +235,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "Nie udało się wysłać polecenia powłoki",
|
||||
"prompt.toast.commandSendFailed.title": "Nie udało się wysłać polecenia",
|
||||
"prompt.toast.promptSendFailed.title": "Nie udało się wysłać zapytania",
|
||||
"prompt.toast.promptSendFailed.description": "Nie udało się pobrać sesji",
|
||||
|
||||
"dialog.mcp.title": "MCP",
|
||||
"dialog.mcp.description": "{{enabled}} z {{total}} włączone",
|
||||
|
||||
@@ -44,8 +44,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Новая сессия",
|
||||
"command.file.open": "Открыть файл",
|
||||
"command.tab.close": "Закрыть вкладку",
|
||||
"command.context.addSelection": "Добавить выделение в контекст",
|
||||
"command.context.addSelection.description": "Добавить выбранные строки из текущего файла",
|
||||
"command.input.focus": "Фокус на поле ввода",
|
||||
"command.terminal.toggle": "Переключить терминал",
|
||||
"command.fileTree.toggle": "Переключить дерево файлов",
|
||||
"command.review.toggle": "Переключить обзор",
|
||||
@@ -211,6 +213,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "Нет совпадений",
|
||||
"prompt.popover.emptyCommands": "Нет совпадающих команд",
|
||||
"prompt.dropzone.label": "Перетащите изображения или PDF сюда",
|
||||
"prompt.dropzone.file.label": "Отпустите для @упоминания файла",
|
||||
"prompt.slash.badge.custom": "своё",
|
||||
"prompt.slash.badge.skill": "навык",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -232,6 +235,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "Не удалось отправить команду оболочки",
|
||||
"prompt.toast.commandSendFailed.title": "Не удалось отправить команду",
|
||||
"prompt.toast.promptSendFailed.title": "Не удалось отправить запрос",
|
||||
"prompt.toast.promptSendFailed.description": "Не удалось получить сессию",
|
||||
|
||||
"dialog.mcp.title": "MCP",
|
||||
"dialog.mcp.description": "{{enabled}} из {{total}} включено",
|
||||
|
||||
@@ -44,8 +44,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "เซสชันใหม่",
|
||||
"command.file.open": "เปิดไฟล์",
|
||||
"command.tab.close": "ปิดแท็บ",
|
||||
"command.context.addSelection": "เพิ่มส่วนที่เลือกไปยังบริบท",
|
||||
"command.context.addSelection.description": "เพิ่มบรรทัดที่เลือกจากไฟล์ปัจจุบัน",
|
||||
"command.input.focus": "โฟกัสช่องป้อนข้อมูล",
|
||||
"command.terminal.toggle": "สลับเทอร์มินัล",
|
||||
"command.fileTree.toggle": "สลับต้นไม้ไฟล์",
|
||||
"command.review.toggle": "สลับการตรวจสอบ",
|
||||
@@ -216,6 +218,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "ไม่พบผลลัพธ์ที่ตรงกัน",
|
||||
"prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน",
|
||||
"prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่",
|
||||
"prompt.dropzone.file.label": "วางเพื่อ @กล่าวถึงไฟล์",
|
||||
"prompt.slash.badge.custom": "กำหนดเอง",
|
||||
"prompt.slash.badge.skill": "skill",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -237,6 +240,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "ไม่สามารถส่งคำสั่งเชลล์",
|
||||
"prompt.toast.commandSendFailed.title": "ไม่สามารถส่งคำสั่ง",
|
||||
"prompt.toast.promptSendFailed.title": "ไม่สามารถส่งพร้อมท์",
|
||||
"prompt.toast.promptSendFailed.description": "ไม่สามารถดึงเซสชันได้",
|
||||
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.description": "{{enabled}} จาก {{total}} ที่เปิดใช้งาน",
|
||||
|
||||
@@ -48,8 +48,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "新建会话",
|
||||
"command.file.open": "打开文件",
|
||||
"command.tab.close": "关闭标签页",
|
||||
"command.context.addSelection": "将所选内容添加到上下文",
|
||||
"command.context.addSelection.description": "添加当前文件中选中的行",
|
||||
"command.input.focus": "聚焦输入框",
|
||||
"command.terminal.toggle": "切换终端",
|
||||
"command.fileTree.toggle": "切换文件树",
|
||||
"command.review.toggle": "切换审查",
|
||||
@@ -252,6 +254,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "没有匹配的结果",
|
||||
"prompt.popover.emptyCommands": "没有匹配的命令",
|
||||
"prompt.dropzone.label": "将图片或 PDF 拖到这里",
|
||||
"prompt.dropzone.file.label": "拖放以 @提及文件",
|
||||
"prompt.slash.badge.custom": "自定义",
|
||||
"prompt.slash.badge.skill": "技能",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -273,6 +276,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "发送 shell 命令失败",
|
||||
"prompt.toast.commandSendFailed.title": "发送命令失败",
|
||||
"prompt.toast.promptSendFailed.title": "发送提示失败",
|
||||
"prompt.toast.promptSendFailed.description": "无法获取会话",
|
||||
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.description": "已启用 {{enabled}} / {{total}}",
|
||||
|
||||
@@ -48,8 +48,10 @@ export const dict = {
|
||||
|
||||
"command.session.new": "新增工作階段",
|
||||
"command.file.open": "開啟檔案",
|
||||
"command.tab.close": "關閉分頁",
|
||||
"command.context.addSelection": "將選取內容加入上下文",
|
||||
"command.context.addSelection.description": "加入目前檔案中選取的行",
|
||||
"command.input.focus": "聚焦輸入框",
|
||||
"command.terminal.toggle": "切換終端機",
|
||||
"command.fileTree.toggle": "切換檔案樹",
|
||||
"command.review.toggle": "切換審查",
|
||||
@@ -249,6 +251,7 @@ export const dict = {
|
||||
"prompt.popover.emptyResults": "沒有符合的結果",
|
||||
"prompt.popover.emptyCommands": "沒有符合的命令",
|
||||
"prompt.dropzone.label": "將圖片或 PDF 拖到這裡",
|
||||
"prompt.dropzone.file.label": "拖放以 @提及檔案",
|
||||
"prompt.slash.badge.custom": "自訂",
|
||||
"prompt.slash.badge.skill": "技能",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
@@ -270,6 +273,7 @@ export const dict = {
|
||||
"prompt.toast.shellSendFailed.title": "傳送 shell 命令失敗",
|
||||
"prompt.toast.commandSendFailed.title": "傳送命令失敗",
|
||||
"prompt.toast.promptSendFailed.title": "傳送提示失敗",
|
||||
"prompt.toast.promptSendFailed.description": "無法取得工作階段",
|
||||
|
||||
"dialog.mcp.title": "MCP",
|
||||
"dialog.mcp.description": "已啟用 {{enabled}} / {{total}}",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { PlatformProvider, type Platform } from "./context/platform"
|
||||
export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
|
||||
export { AppBaseProviders, AppInterface } from "./app"
|
||||
export { useCommand } from "./context/command"
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function Layout(props: ParentProps) {
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const language = useLanguage()
|
||||
let invalid = ""
|
||||
const directory = createMemo(() => {
|
||||
return decode64(params.dir) ?? ""
|
||||
})
|
||||
@@ -22,12 +23,14 @@ export default function Layout(props: ParentProps) {
|
||||
createEffect(() => {
|
||||
if (!params.dir) return
|
||||
if (directory()) return
|
||||
if (invalid === params.dir) return
|
||||
invalid = params.dir
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/")
|
||||
navigate("/", { replace: true })
|
||||
})
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
|
||||
@@ -25,7 +25,8 @@ export default function Home() {
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
const recent = createMemo(() => {
|
||||
return sync.data.project
|
||||
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.slice()
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
|
||||
@@ -1272,8 +1272,6 @@ export default function Layout(props: ParentProps) {
|
||||
),
|
||||
)
|
||||
|
||||
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||
|
||||
setBusy(directory, false)
|
||||
dismiss()
|
||||
|
||||
@@ -1938,7 +1936,7 @@ export default function Layout(props: ParentProps) {
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={window.innerWidth * 0.3 + 64}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={layout.sidebar.resize}
|
||||
onCollapse={layout.sidebar.close}
|
||||
|
||||
@@ -2,7 +2,15 @@ export const deepLinkEvent = "opencode:deep-link"
|
||||
|
||||
export const parseDeepLink = (input: string) => {
|
||||
if (!input.startsWith("opencode://")) return
|
||||
const url = new URL(input)
|
||||
if (typeof URL.canParse === "function" && !URL.canParse(input)) return
|
||||
const url = (() => {
|
||||
try {
|
||||
return new URL(input)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (!url) return
|
||||
if (url.hostname !== "open-project") return
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (!directory) return
|
||||
|
||||
@@ -12,6 +12,27 @@ describe("layout deep links", () => {
|
||||
expect(parseDeepLink("https://example.com")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores malformed deep links safely", () => {
|
||||
expect(() => parseDeepLink("opencode://open-project/%E0%A4%A%")).not.toThrow()
|
||||
expect(parseDeepLink("opencode://open-project/%E0%A4%A%")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("parses links when URL.canParse is unavailable", () => {
|
||||
const original = Object.getOwnPropertyDescriptor(URL, "canParse")
|
||||
Object.defineProperty(URL, "canParse", { configurable: true, value: undefined })
|
||||
try {
|
||||
expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo")
|
||||
} finally {
|
||||
if (original) Object.defineProperty(URL, "canParse", original)
|
||||
if (!original) Reflect.deleteProperty(URL, "canParse")
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores open-project deep links without directory", () => {
|
||||
expect(parseDeepLink("opencode://open-project")).toBeUndefined()
|
||||
expect(parseDeepLink("opencode://open-project?directory=")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("collects only valid open-project directories", () => {
|
||||
const result = collectOpenProjectDeepLinks([
|
||||
"opencode://open-project?directory=/a",
|
||||
@@ -39,6 +60,14 @@ describe("layout workspace helpers", () => {
|
||||
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo")
|
||||
})
|
||||
|
||||
test("preserves posix and drive roots in workspace key", () => {
|
||||
expect(workspaceKey("/")).toBe("/")
|
||||
expect(workspaceKey("///")).toBe("/")
|
||||
expect(workspaceKey("C:\\")).toBe("C:\\")
|
||||
expect(workspaceKey("C:\\\\\\")).toBe("C:\\")
|
||||
expect(workspaceKey("C:///")).toBe("C:/")
|
||||
})
|
||||
|
||||
test("keeps local first while preserving known order", () => {
|
||||
const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
|
||||
expect(result).toEqual(["/root", "/c", "/b"])
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
|
||||
export const workspaceKey = (directory: string) => {
|
||||
const drive = directory.match(/^([A-Za-z]:)[\\/]+$/)
|
||||
if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}`
|
||||
if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/"
|
||||
return directory.replace(/[\\/]+$/, "")
|
||||
}
|
||||
|
||||
export function sortSessions(now: number) {
|
||||
const oneMinuteAgo = now - 60 * 1000
|
||||
@@ -21,7 +26,7 @@ export const isRootVisibleSession = (session: Session, directory: string) =>
|
||||
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
|
||||
|
||||
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
|
||||
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now))
|
||||
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now))
|
||||
|
||||
export const childMapByParent = (sessions: Session[]) => {
|
||||
const map = new Map<string, string[]>()
|
||||
|
||||
@@ -21,8 +21,11 @@ const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
|
||||
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
|
||||
const notification = useNotification()
|
||||
const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree))
|
||||
const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree))
|
||||
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
|
||||
const unseenCount = createMemo(() =>
|
||||
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
||||
)
|
||||
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
|
||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||
return (
|
||||
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
||||
@@ -141,7 +144,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
|
||||
const item = (
|
||||
<A
|
||||
href={`${props.slug}/session/${props.session.id}`}
|
||||
href={`/${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onPointerEnter={scheduleHoverPrefetch}
|
||||
onPointerLeave={cancelHoverPrefetch}
|
||||
@@ -282,7 +285,7 @@ export const NewSessionItem = (props: {
|
||||
const tooltip = () => props.mobile || !props.sidebarExpanded()
|
||||
const item = (
|
||||
<A
|
||||
href={`${props.slug}/session`}
|
||||
href={`/${props.slug}/session`}
|
||||
end
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -86,6 +88,8 @@ export const SortableWorkspace = (props: {
|
||||
project: LocalProject
|
||||
mobile?: boolean
|
||||
}): JSX.Element => {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
const sortable = createSortable(props.directory)
|
||||
@@ -111,8 +115,10 @@ export const SortableWorkspace = (props: {
|
||||
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
||||
const wasBusy = createMemo((prev) => prev || busy(), false)
|
||||
const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy())
|
||||
const touch = createMediaQuery("(hover: none)")
|
||||
const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id)))
|
||||
const loadMore = async () => {
|
||||
setWorkspaceStore("limit", (limit) => limit + 5)
|
||||
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
||||
await globalSync.project.loadSessions(props.directory)
|
||||
}
|
||||
|
||||
@@ -163,11 +169,9 @@ export const SortableWorkspace = (props: {
|
||||
openOnDblClick={false}
|
||||
/>
|
||||
</Show>
|
||||
<Icon
|
||||
name={open() ? "chevron-down" : "chevron-right"}
|
||||
size="small"
|
||||
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
|
||||
/>
|
||||
<div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
|
||||
<Icon name={open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -192,7 +196,9 @@ export const SortableWorkspace = (props: {
|
||||
when={workspaceEditActive()}
|
||||
fallback={
|
||||
<Collapsible.Trigger
|
||||
class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover"
|
||||
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover transition-[padding] duration-200 ${
|
||||
menu.open ? "pr-16" : "pr-2"
|
||||
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
||||
data-action="workspace-toggle"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
>
|
||||
@@ -200,7 +206,13 @@ export const SortableWorkspace = (props: {
|
||||
</Collapsible.Trigger>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div>
|
||||
<div
|
||||
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md transition-[padding] duration-200 ${
|
||||
menu.open ? "pr-16" : "pr-2"
|
||||
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
||||
>
|
||||
{header()}
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
|
||||
@@ -260,6 +272,25 @@ export const SortableWorkspace = (props: {
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
<Show when={!touch()}>
|
||||
<Tooltip value={language.t("command.session.new")} placement="top">
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
|
||||
data-action="workspace-new-session"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
aria-label={language.t("command.session.new")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.ctx.setHoverSession(undefined)
|
||||
props.ctx.clearHoverProjectSoon()
|
||||
navigate(`/${slug()}/session`)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,13 +298,15 @@ export const SortableWorkspace = (props: {
|
||||
|
||||
<Collapsible.Content>
|
||||
<nav class="flex flex-col gap-1 px-2">
|
||||
<NewSessionItem
|
||||
slug={slug()}
|
||||
mobile={props.mobile}
|
||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||
setHoverSession={props.ctx.setHoverSession}
|
||||
/>
|
||||
<Show when={showNew()}>
|
||||
<NewSessionItem
|
||||
slug={slug()}
|
||||
mobile={props.mobile}
|
||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||
setHoverSession={props.ctx.setHoverSession}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={loading()}>
|
||||
<SessionSkeleton />
|
||||
</Show>
|
||||
@@ -335,7 +368,7 @@ export const LocalWorkspace = (props: {
|
||||
const loading = createMemo(() => !booted() && sessions().length === 0)
|
||||
const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
|
||||
const loadMore = async () => {
|
||||
workspace().setStore("limit", (limit) => limit + 5)
|
||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||
await globalSync.project.loadSessions(props.project.worktree)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
|
||||
import { navMark, navParams } from "@/utils/perf"
|
||||
import { same } from "@/utils/same"
|
||||
import { createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "@/pages/session/helpers"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
@@ -591,7 +591,7 @@ export default function Page() {
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
const project = sync.project
|
||||
if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory
|
||||
if (project && sdk.directory !== project.worktree) return sdk.directory
|
||||
return "main"
|
||||
})
|
||||
|
||||
@@ -844,11 +844,9 @@ export default function Page() {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const currentTabs = tabs().all()
|
||||
const fromIndex = currentTabs?.indexOf(draggable.id.toString())
|
||||
const toIndex = currentTabs?.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex && toIndex !== undefined) {
|
||||
tabs().move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
|
||||
if (toIndex === undefined) return
|
||||
tabs().move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,6 +915,8 @@ export default function Page() {
|
||||
setFileTreeTab("all")
|
||||
}
|
||||
|
||||
const focusInput = () => inputRef?.focus()
|
||||
|
||||
useSessionCommands({
|
||||
command,
|
||||
dialog,
|
||||
@@ -943,6 +943,7 @@ export default function Page() {
|
||||
setExpanded: (id, fn) => setStore("expanded", id, fn),
|
||||
setActiveMessage,
|
||||
addSelectionToContext,
|
||||
focusInput,
|
||||
})
|
||||
|
||||
const openReviewFile = createOpenReviewFile({
|
||||
@@ -1025,10 +1026,31 @@ export default function Page() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
|
||||
</div>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
@@ -1040,7 +1062,7 @@ export default function Page() {
|
||||
diffStyle: layout.review.diffStyle(),
|
||||
onDiffStyleChange: layout.review.setDiffStyle,
|
||||
loadingClass: "px-6 py-4 text-text-weak",
|
||||
emptyClass: "h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1568,7 +1590,7 @@ export default function Page() {
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
@@ -1646,7 +1668,7 @@ export default function Page() {
|
||||
|
||||
const target = value === "main" ? sync.project?.worktree : value
|
||||
if (!target) return
|
||||
if (target === sync.data.path.directory) return
|
||||
if (target === sdk.directory) return
|
||||
layout.projects.open(target)
|
||||
navigate(`/${base64Encode(target)}/session`)
|
||||
}}
|
||||
@@ -1682,7 +1704,7 @@ export default function Page() {
|
||||
direction="horizontal"
|
||||
size={layout.session.width()}
|
||||
min={450}
|
||||
max={window.innerWidth * 0.45}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
|
||||
onResize={layout.session.resize}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "./helpers"
|
||||
import { combineCommandSections, createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "./helpers"
|
||||
|
||||
describe("createOpenReviewFile", () => {
|
||||
test("opens and loads selected review file", () => {
|
||||
@@ -59,3 +59,13 @@ describe("combineCommandSections", () => {
|
||||
expect(result.map((item) => item.id)).toEqual(["a", "b", "c"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTabReorderIndex", () => {
|
||||
test("returns target index for valid drag reorder", () => {
|
||||
expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)
|
||||
})
|
||||
|
||||
test("returns undefined for unknown droppable id", () => {
|
||||
expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,3 +36,10 @@ export const createOpenReviewFile = (input: {
|
||||
export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => {
|
||||
return sections.flatMap((section) => section)
|
||||
}
|
||||
|
||||
export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => {
|
||||
const fromIndex = tabs.indexOf(from)
|
||||
const toIndex = tabs.indexOf(to)
|
||||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined
|
||||
return toIndex
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ export function MessageTimeline(props: {
|
||||
"sticky top-0 z-30 bg-background-stronger": true,
|
||||
"w-full": true,
|
||||
"px-4 md:px-6": true,
|
||||
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="h-10 w-full flex items-center justify-between gap-2">
|
||||
@@ -278,7 +278,7 @@ export function MessageTimeline(props: {
|
||||
class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
"mt-0.5": props.centered,
|
||||
"mt-0": !props.centered,
|
||||
}}
|
||||
@@ -321,7 +321,7 @@ export function MessageTimeline(props: {
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 3xl:max-w-[1200px]": props.centered,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<SessionTurn
|
||||
|
||||
@@ -228,6 +228,7 @@ export const createScrollSpy = (input: Input) => {
|
||||
node.delete(key)
|
||||
visible.delete(key)
|
||||
dirty = true
|
||||
schedule()
|
||||
}
|
||||
|
||||
const markDirty = () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SessionPromptDock(props: {
|
||||
<div
|
||||
classList={{
|
||||
"w-full px-4 pointer-events-auto": true,
|
||||
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={props.questionRequest()} keyed>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function TerminalPanel(props: {
|
||||
direction="vertical"
|
||||
size={props.height}
|
||||
min={100}
|
||||
max={window.innerHeight * 0.6}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={props.resize}
|
||||
onCollapse={props.close}
|
||||
|
||||
@@ -48,6 +48,7 @@ export const useSessionCommands = (input: {
|
||||
setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
addSelectionToContext: (path: string, selection: FileSelection) => void
|
||||
focusInput: () => void
|
||||
}) => {
|
||||
const sessionCommands = createMemo(() => [
|
||||
{
|
||||
@@ -142,6 +143,13 @@ export const useSessionCommands = (input: {
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => input.layout.fileTree.toggle(),
|
||||
},
|
||||
{
|
||||
id: "input.focus",
|
||||
title: input.language.t("command.input.focus"),
|
||||
category: input.language.t("command.category.view"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: () => input.focusInput(),
|
||||
},
|
||||
{
|
||||
id: "terminal.new",
|
||||
title: input.language.t("command.terminal.new"),
|
||||
@@ -357,37 +365,81 @@ export const useSessionCommands = (input: {
|
||||
return [
|
||||
{
|
||||
id: "session.share",
|
||||
title: input.language.t("command.session.share"),
|
||||
description: input.language.t("command.session.share.description"),
|
||||
title: input.info()?.share?.url
|
||||
? input.language.t("session.share.copy.copyLink")
|
||||
: input.language.t("command.session.share"),
|
||||
description: input.info()?.share?.url
|
||||
? input.language.t("toast.session.share.success.description")
|
||||
: input.language.t("command.session.share.description"),
|
||||
category: input.language.t("command.category.session"),
|
||||
slash: "share",
|
||||
disabled: !input.params.id || !!input.info()?.share?.url,
|
||||
disabled: !input.params.id,
|
||||
onSelect: async () => {
|
||||
if (!input.params.id) return
|
||||
await input.sdk.client.session
|
||||
.share({ sessionID: input.params.id })
|
||||
.then((res) => {
|
||||
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
|
||||
showToast({
|
||||
title: input.language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
})
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: input.language.t("toast.session.share.success.title"),
|
||||
description: input.language.t("toast.session.share.success.description"),
|
||||
variant: "success",
|
||||
}),
|
||||
|
||||
const write = (value: string) => {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
if (body) {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = value
|
||||
textarea.setAttribute("readonly", "")
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.style.pointerEvents = "none"
|
||||
body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand("copy")
|
||||
body.removeChild(textarea)
|
||||
if (copied) return Promise.resolve(true)
|
||||
}
|
||||
|
||||
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
|
||||
if (!clipboard?.writeText) return Promise.resolve(false)
|
||||
return clipboard.writeText(value).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
.catch(() =>
|
||||
}
|
||||
|
||||
const copy = async (url: string, existing: boolean) => {
|
||||
const ok = await write(url)
|
||||
if (!ok) {
|
||||
showToast({
|
||||
title: input.language.t("toast.session.share.failed.title"),
|
||||
description: input.language.t("toast.session.share.failed.description"),
|
||||
title: input.language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showToast({
|
||||
title: existing
|
||||
? input.language.t("session.share.copy.copied")
|
||||
: input.language.t("toast.session.share.success.title"),
|
||||
description: input.language.t("toast.session.share.success.description"),
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
const existing = input.info()?.share?.url
|
||||
if (existing) {
|
||||
await copy(existing, true)
|
||||
return
|
||||
}
|
||||
|
||||
const url = await input.sdk.client.session
|
||||
.share({ sessionID: input.params.id })
|
||||
.then((res) => res.data?.share?.url)
|
||||
.catch(() => undefined)
|
||||
if (!url) {
|
||||
showToast({
|
||||
title: input.language.t("toast.session.share.failed.title"),
|
||||
description: input.language.t("toast.session.share.failed.description"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await copy(url, false)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
type PersistTestingType = typeof import("./persist").PersistTesting
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private values = new Map<string, string>()
|
||||
readonly events: string[] = []
|
||||
readonly calls = { get: 0, set: 0, remove: 0 }
|
||||
|
||||
clear() {
|
||||
this.values.clear()
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.values.size
|
||||
}
|
||||
|
||||
key(index: number) {
|
||||
return Array.from(this.values.keys())[index] ?? null
|
||||
}
|
||||
|
||||
getItem(key: string) {
|
||||
this.calls.get += 1
|
||||
this.events.push(`get:${key}`)
|
||||
if (key.startsWith("opencode.throw")) throw new Error("storage get failed")
|
||||
return this.values.get(key) ?? null
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.calls.set += 1
|
||||
this.events.push(`set:${key}`)
|
||||
if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError")
|
||||
if (key.startsWith("opencode.throw")) throw new Error("storage set failed")
|
||||
this.values.set(key, value)
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.calls.remove += 1
|
||||
this.events.push(`remove:${key}`)
|
||||
if (key.startsWith("opencode.throw")) throw new Error("storage remove failed")
|
||||
this.values.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new MemoryStorage()
|
||||
|
||||
let persistTesting: PersistTestingType
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@/context/platform", () => ({
|
||||
usePlatform: () => ({ platform: "web" }),
|
||||
}))
|
||||
|
||||
const mod = await import("./persist")
|
||||
persistTesting = mod.PersistTesting
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
storage.events.length = 0
|
||||
storage.calls.get = 0
|
||||
storage.calls.set = 0
|
||||
storage.calls.remove = 0
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: storage,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe("persist localStorage resilience", () => {
|
||||
test("does not cache values as persisted when quota write and eviction fail", () => {
|
||||
const storageApi = persistTesting.localStorageWithPrefix("opencode.quota.scope")
|
||||
storageApi.setItem("value", '{"value":1}')
|
||||
|
||||
expect(storage.getItem("opencode.quota.scope:value")).toBeNull()
|
||||
expect(storageApi.getItem("value")).toBeNull()
|
||||
})
|
||||
|
||||
test("disables only the failing scope when storage throws", () => {
|
||||
const bad = persistTesting.localStorageWithPrefix("opencode.throw.scope")
|
||||
bad.setItem("value", '{"value":1}')
|
||||
|
||||
const before = storage.calls.set
|
||||
bad.setItem("value", '{"value":2}')
|
||||
expect(storage.calls.set).toBe(before)
|
||||
expect(bad.getItem("value")).toBeNull()
|
||||
|
||||
const healthy = persistTesting.localStorageWithPrefix("opencode.safe.scope")
|
||||
healthy.setItem("value", '{"value":3}')
|
||||
expect(storage.getItem("opencode.safe.scope:value")).toBe('{"value":3}')
|
||||
})
|
||||
|
||||
test("failing fallback scope does not poison direct storage scope", () => {
|
||||
const broken = persistTesting.localStorageWithPrefix("opencode.throw.scope2")
|
||||
broken.setItem("value", '{"value":1}')
|
||||
|
||||
const direct = persistTesting.localStorageDirect()
|
||||
direct.setItem("direct-value", '{"value":5}')
|
||||
|
||||
expect(storage.getItem("direct-value")).toBe('{"value":5}')
|
||||
})
|
||||
|
||||
test("normalizer rejects malformed JSON payloads", () => {
|
||||
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@ type PersistTarget = {
|
||||
const LEGACY_STORAGE = "default.dat"
|
||||
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||
const LOCAL_PREFIX = "opencode."
|
||||
const fallback = { disabled: false }
|
||||
const fallback = new Map<string, boolean>()
|
||||
|
||||
const CACHE_MAX_ENTRIES = 500
|
||||
const CACHE_MAX_BYTES = 8 * 1024 * 1024
|
||||
@@ -65,6 +65,14 @@ function cacheGet(key: string) {
|
||||
return entry.value
|
||||
}
|
||||
|
||||
function fallbackDisabled(scope: string) {
|
||||
return fallback.get(scope) === true
|
||||
}
|
||||
|
||||
function fallbackSet(scope: string) {
|
||||
fallback.set(scope, true)
|
||||
}
|
||||
|
||||
function quota(error: unknown) {
|
||||
if (error instanceof DOMException) {
|
||||
if (error.name === "QuotaExceededError") return true
|
||||
@@ -142,7 +150,6 @@ function write(storage: Storage, key: string, value: string) {
|
||||
}
|
||||
|
||||
const ok = evict(storage, key, value)
|
||||
if (!ok) cacheSet(key, value)
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -188,6 +195,14 @@ function parse(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) {
|
||||
const parsed = parse(raw)
|
||||
if (parsed === undefined) return
|
||||
const migrated = migrate ? migrate(parsed) : parsed
|
||||
const merged = merge(defaults, migrated)
|
||||
return JSON.stringify(merged)
|
||||
}
|
||||
|
||||
function workspaceStorage(dir: string) {
|
||||
const head = dir.slice(0, 12) || "workspace"
|
||||
const sum = checksum(dir) ?? "0"
|
||||
@@ -196,18 +211,19 @@ function workspaceStorage(dir: string) {
|
||||
|
||||
function localStorageWithPrefix(prefix: string): SyncStorage {
|
||||
const base = `${prefix}:`
|
||||
const scope = `prefix:${prefix}`
|
||||
const item = (key: string) => base + key
|
||||
return {
|
||||
getItem: (key) => {
|
||||
const name = item(key)
|
||||
const cached = cacheGet(name)
|
||||
if (fallback.disabled && cached !== undefined) return cached
|
||||
if (fallbackDisabled(scope)) return cached ?? null
|
||||
|
||||
const stored = (() => {
|
||||
try {
|
||||
return localStorage.getItem(name)
|
||||
} catch {
|
||||
fallback.disabled = true
|
||||
fallbackSet(scope)
|
||||
return null
|
||||
}
|
||||
})()
|
||||
@@ -217,40 +233,40 @@ function localStorageWithPrefix(prefix: string): SyncStorage {
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
const name = item(key)
|
||||
cacheSet(name, value)
|
||||
if (fallback.disabled) return
|
||||
if (fallbackDisabled(scope)) return
|
||||
try {
|
||||
if (write(localStorage, name, value)) return
|
||||
} catch {
|
||||
fallback.disabled = true
|
||||
fallbackSet(scope)
|
||||
return
|
||||
}
|
||||
fallback.disabled = true
|
||||
fallbackSet(scope)
|
||||
},
|
||||
removeItem: (key) => {
|
||||
const name = item(key)
|
||||
cacheDelete(name)
|
||||
if (fallback.disabled) return
|
||||
if (fallbackDisabled(scope)) return
|
||||
try {
|
||||
localStorage.removeItem(name)
|
||||
} catch {
|
||||
fallback.disabled = true
|
||||
fallbackSet(scope)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function localStorageDirect(): SyncStorage {
|
||||
const scope = "direct"
|
||||
return {
|
||||
getItem: (key) => {
|
||||
const cached = cacheGet(key)
|
||||
if (fallback.disabled && cached !== undefined) return cached
|
||||
if (fallbackDisabled(scope)) return cached ?? null
|
||||
|
||||
const stored = (() => {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
fallback.disabled = true
|
||||
fallbackSet(scope)
|
||||
return null
|
||||
}
|
||||
})()
|
||||
@@ -259,28 +275,33 @@ function localStorageDirect(): SyncStorage {
|
||||
return stored
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
cacheSet(key, value)
|
||||
if (fallback.disabled) return
|
||||
if (fallbackDisabled(scope)) return
|
||||
try {
|
||||
if (write(localStorage, key, value)) return
|
||||
} catch {
|
||||
fallback.disabled = true
|
||||
fallbackSet(scope)
|
||||
return
|
||||
}
|
||||
fallback.disabled = true
|
||||
fallbackSet(scope)
|
||||
},
|
||||
removeItem: (key) => {
|
||||
cacheDelete(key)
|
||||
if (fallback.disabled) return
|
||||
if (fallbackDisabled(scope)) return
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
fallback.disabled = true
|
||||
fallbackSet(scope)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const PersistTesting = {
|
||||
localStorageDirect,
|
||||
localStorageWithPrefix,
|
||||
normalize,
|
||||
}
|
||||
|
||||
export const Persist = {
|
||||
global(key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: GLOBAL_STORAGE, key, legacy }
|
||||
@@ -346,12 +367,11 @@ export function persisted<T>(
|
||||
getItem: (key) => {
|
||||
const raw = current.getItem(key)
|
||||
if (raw !== null) {
|
||||
const parsed = parse(raw)
|
||||
if (parsed === undefined) return raw
|
||||
|
||||
const migrated = config.migrate ? config.migrate(parsed) : parsed
|
||||
const merged = merge(defaults, migrated)
|
||||
const next = JSON.stringify(merged)
|
||||
const next = normalize(defaults, raw, config.migrate)
|
||||
if (next === undefined) {
|
||||
current.removeItem(key)
|
||||
return null
|
||||
}
|
||||
if (raw !== next) current.setItem(key, next)
|
||||
return next
|
||||
}
|
||||
@@ -360,16 +380,13 @@ export function persisted<T>(
|
||||
const legacyRaw = legacyStore.getItem(legacyKey)
|
||||
if (legacyRaw === null) continue
|
||||
|
||||
current.setItem(key, legacyRaw)
|
||||
const next = normalize(defaults, legacyRaw, config.migrate)
|
||||
if (next === undefined) {
|
||||
legacyStore.removeItem(legacyKey)
|
||||
continue
|
||||
}
|
||||
current.setItem(key, next)
|
||||
legacyStore.removeItem(legacyKey)
|
||||
|
||||
const parsed = parse(legacyRaw)
|
||||
if (parsed === undefined) return legacyRaw
|
||||
|
||||
const migrated = config.migrate ? config.migrate(parsed) : parsed
|
||||
const merged = merge(defaults, migrated)
|
||||
const next = JSON.stringify(merged)
|
||||
if (legacyRaw !== next) current.setItem(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -393,12 +410,11 @@ export function persisted<T>(
|
||||
getItem: async (key) => {
|
||||
const raw = await current.getItem(key)
|
||||
if (raw !== null) {
|
||||
const parsed = parse(raw)
|
||||
if (parsed === undefined) return raw
|
||||
|
||||
const migrated = config.migrate ? config.migrate(parsed) : parsed
|
||||
const merged = merge(defaults, migrated)
|
||||
const next = JSON.stringify(merged)
|
||||
const next = normalize(defaults, raw, config.migrate)
|
||||
if (next === undefined) {
|
||||
await current.removeItem(key).catch(() => undefined)
|
||||
return null
|
||||
}
|
||||
if (raw !== next) await current.setItem(key, next)
|
||||
return next
|
||||
}
|
||||
@@ -409,16 +425,13 @@ export function persisted<T>(
|
||||
const legacyRaw = await legacyStore.getItem(legacyKey)
|
||||
if (legacyRaw === null) continue
|
||||
|
||||
await current.setItem(key, legacyRaw)
|
||||
const next = normalize(defaults, legacyRaw, config.migrate)
|
||||
if (next === undefined) {
|
||||
await legacyStore.removeItem(legacyKey).catch(() => undefined)
|
||||
continue
|
||||
}
|
||||
await current.setItem(key, next)
|
||||
await legacyStore.removeItem(legacyKey)
|
||||
|
||||
const parsed = parse(legacyRaw)
|
||||
if (parsed === undefined) return legacyRaw
|
||||
|
||||
const migrated = config.migrate ? config.migrate(parsed) : parsed
|
||||
const merged = merge(defaults, migrated)
|
||||
const next = JSON.stringify(merged)
|
||||
if (legacyRaw !== next) await current.setItem(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { checkServerHealth } from "./server-health"
|
||||
|
||||
function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
|
||||
if (init?.signal) return init.signal
|
||||
if (input instanceof Request) return input.signal
|
||||
return undefined
|
||||
}
|
||||
|
||||
describe("checkServerHealth", () => {
|
||||
test("returns healthy response with version", async () => {
|
||||
const fetch = (async () =>
|
||||
@@ -24,10 +30,40 @@ describe("checkServerHealth", () => {
|
||||
expect(result).toEqual({ healthy: false })
|
||||
})
|
||||
|
||||
test("uses timeout fallback when AbortSignal.timeout is unavailable", async () => {
|
||||
const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout")
|
||||
Object.defineProperty(AbortSignal, "timeout", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
|
||||
let aborted = false
|
||||
const fetch = ((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
const signal = abortFromInput(input, init)
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
aborted = true
|
||||
reject(new DOMException("Aborted", "AbortError"))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})) as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await checkServerHealth("http://localhost:4096", fetch, { timeoutMs: 10 }).finally(() => {
|
||||
if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout)
|
||||
if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout")
|
||||
})
|
||||
|
||||
expect(aborted).toBe(true)
|
||||
expect(result).toEqual({ healthy: false })
|
||||
})
|
||||
|
||||
test("uses provided abort signal", async () => {
|
||||
let signal: AbortSignal | undefined
|
||||
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
signal = init?.signal ?? (input instanceof Request ? input.signal : undefined)
|
||||
signal = abortFromInput(input, init)
|
||||
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -39,4 +75,40 @@ describe("checkServerHealth", () => {
|
||||
|
||||
expect(signal).toBe(abort.signal)
|
||||
})
|
||||
|
||||
test("retries transient failures and eventually succeeds", async () => {
|
||||
let count = 0
|
||||
const fetch = (async () => {
|
||||
count += 1
|
||||
if (count < 3) throw new TypeError("network")
|
||||
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await checkServerHealth("http://localhost:4096", fetch, {
|
||||
retryCount: 2,
|
||||
retryDelayMs: 1,
|
||||
})
|
||||
|
||||
expect(count).toBe(3)
|
||||
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
||||
})
|
||||
|
||||
test("returns unhealthy when retries are exhausted", async () => {
|
||||
let count = 0
|
||||
const fetch = (async () => {
|
||||
count += 1
|
||||
throw new TypeError("network")
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await checkServerHealth("http://localhost:4096", fetch, {
|
||||
retryCount: 2,
|
||||
retryDelayMs: 1,
|
||||
})
|
||||
|
||||
expect(count).toBe(3)
|
||||
expect(result).toEqual({ healthy: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,50 @@ export type ServerHealth = { healthy: boolean; version?: string }
|
||||
interface CheckServerHealthOptions {
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
retryCount?: number
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
const defaultTimeoutMs = 3000
|
||||
const defaultRetryCount = 2
|
||||
const defaultRetryDelayMs = 100
|
||||
|
||||
function timeoutSignal(timeoutMs: number) {
|
||||
return (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(timeoutMs)
|
||||
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
|
||||
if (timeout) {
|
||||
try {
|
||||
return { signal: timeout.call(AbortSignal, timeoutMs), clear: undefined as (() => void) | undefined }
|
||||
} catch {}
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
return { signal: controller.signal, clear: () => clearTimeout(timer) }
|
||||
}
|
||||
|
||||
function wait(ms: number, signal?: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException("Aborted", "AbortError"))
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer)
|
||||
reject(new DOMException("Aborted", "AbortError"))
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function retryable(error: unknown, signal?: AbortSignal) {
|
||||
if (signal?.aborted) return false
|
||||
if (!(error instanceof Error)) return false
|
||||
if (error.name === "AbortError" || error.name === "TimeoutError") return false
|
||||
if (error instanceof TypeError) return true
|
||||
return /network|fetch|econnreset|econnrefused|enotfound|timedout/i.test(error.message)
|
||||
}
|
||||
|
||||
export async function checkServerHealth(
|
||||
@@ -16,14 +56,24 @@ export async function checkServerHealth(
|
||||
fetch: typeof globalThis.fetch,
|
||||
opts?: CheckServerHealthOptions,
|
||||
): Promise<ServerHealth> {
|
||||
const signal = opts?.signal ?? timeoutSignal(opts?.timeoutMs ?? 3000)
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: url,
|
||||
fetch,
|
||||
signal,
|
||||
})
|
||||
return sdk.global
|
||||
.health()
|
||||
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||
.catch(() => ({ healthy: false }))
|
||||
const timeout = opts?.signal ? undefined : timeoutSignal(opts?.timeoutMs ?? defaultTimeoutMs)
|
||||
const signal = opts?.signal ?? timeout?.signal
|
||||
const retryCount = opts?.retryCount ?? defaultRetryCount
|
||||
const retryDelayMs = opts?.retryDelayMs ?? defaultRetryDelayMs
|
||||
const next = (count: number, error: unknown) => {
|
||||
if (count >= retryCount || !retryable(error, signal)) return Promise.resolve({ healthy: false } as const)
|
||||
return wait(retryDelayMs * (count + 1), signal)
|
||||
.then(() => attempt(count + 1))
|
||||
.catch(() => ({ healthy: false }))
|
||||
}
|
||||
const attempt = (count: number): Promise<ServerHealth> =>
|
||||
createOpencodeClient({
|
||||
baseUrl: url,
|
||||
fetch,
|
||||
signal,
|
||||
})
|
||||
.global.health()
|
||||
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||
.catch((error) => next(count, error))
|
||||
return attempt(0).finally(() => timeout?.clear?.())
|
||||
}
|
||||
|
||||
@@ -351,8 +351,8 @@ export const dict = {
|
||||
"changelog.empty":
|
||||
"\u0644\u0645 \u064a\u062a\u0645 \u0627\u0644\u0639\u062b\u0648\u0631 \u0639\u0644\u0649 \u0623\u064a \u0625\u062f\u062e\u0627\u0644\u0627\u062a \u0641\u064a \u0633\u062c\u0644 \u0627\u0644\u062a\u063a\u064a\u064a\u0631\u0627\u062a.",
|
||||
"changelog.viewJson": "\u0639\u0631\u0636 JSON",
|
||||
"workspace.nav.zen": "زين",
|
||||
"workspace.nav.apiKeys": "API المفاتيح",
|
||||
"workspace.nav.zen": "Zen",
|
||||
"workspace.nav.apiKeys": "مفاتيح API",
|
||||
"workspace.nav.members": "أعضاء",
|
||||
"workspace.nav.billing": "الفواتير",
|
||||
"workspace.nav.settings": "إعدادات",
|
||||
@@ -365,14 +365,14 @@ export const dict = {
|
||||
"workspace.newUser.feature.quality.title": "أعلى جودة",
|
||||
"workspace.newUser.feature.quality.body":
|
||||
"الوصول إلى النماذج التي تم تكوينها لتحقيق الأداء الأمثل - لا يوجد تخفيضات أو توجيه إلى موفري الخدمة الأرخص.",
|
||||
"workspace.newUser.feature.lockin.title": "لا يوجد قفل",
|
||||
"workspace.newUser.feature.lockin.title": "بدون احتجاز بمزوّد واحد",
|
||||
"workspace.newUser.feature.lockin.body":
|
||||
"استخدم Zen مع أي وكيل ترميز، واستمر في استخدام موفري الخدمات الآخرين مع opencode وقتما تشاء.",
|
||||
"workspace.newUser.copyApiKey": "انسخ مفتاح API",
|
||||
"workspace.newUser.copyKey": "نسخ المفتاح",
|
||||
"workspace.newUser.copied": "منسوخ!",
|
||||
"workspace.newUser.step.enableBilling": "تمكين الفوترة",
|
||||
"workspace.newUser.step.login.before": "يجري",
|
||||
"workspace.newUser.step.login.before": "شغّل",
|
||||
"workspace.newUser.step.login.after": "وحدد opencode",
|
||||
"workspace.newUser.step.pasteKey": "الصق مفتاح API الخاص بك",
|
||||
"workspace.newUser.step.models.before": "ابدأ opencode ثم قم بالتشغيل",
|
||||
@@ -390,7 +390,7 @@ export const dict = {
|
||||
"workspace.providers.saving": "توفير...",
|
||||
"workspace.providers.save": "يحفظ",
|
||||
"workspace.providers.table.provider": "مزود",
|
||||
"workspace.providers.table.apiKey": "API المفتاح",
|
||||
"workspace.providers.table.apiKey": "مفتاح API",
|
||||
"workspace.usage.title": "تاريخ الاستخدام",
|
||||
"workspace.usage.subtitle": "استخدام وتكاليف API الأخيرة.",
|
||||
"workspace.usage.empty": "قم بإجراء أول مكالمة API للبدء.",
|
||||
@@ -398,25 +398,25 @@ export const dict = {
|
||||
"workspace.usage.table.model": "نموذج",
|
||||
"workspace.usage.table.input": "مدخل",
|
||||
"workspace.usage.table.output": "الإخراج",
|
||||
"workspace.usage.table.cost": "يكلف",
|
||||
"workspace.usage.table.cost": "التكلفة",
|
||||
"workspace.usage.breakdown.input": "مدخل",
|
||||
"workspace.usage.breakdown.cacheRead": "قراءة ذاكرة التخزين المؤقت",
|
||||
"workspace.usage.breakdown.cacheWrite": "كتابة ذاكرة التخزين المؤقت",
|
||||
"workspace.usage.breakdown.output": "الإخراج",
|
||||
"workspace.usage.breakdown.reasoning": "المنطق",
|
||||
"workspace.usage.subscription": "الاشتراك (${{amount}})",
|
||||
"workspace.cost.title": "يكلف",
|
||||
"workspace.cost.title": "التكلفة",
|
||||
"workspace.cost.subtitle": "تكاليف الاستخدام مقسمة حسب النموذج.",
|
||||
"workspace.cost.allModels": "جميع الموديلات",
|
||||
"workspace.cost.allModels": "جميع النماذج",
|
||||
"workspace.cost.allKeys": "جميع المفاتيح",
|
||||
"workspace.cost.deletedSuffix": "(محذوف)",
|
||||
"workspace.cost.empty": "لا توجد بيانات استخدام متاحة للفترة المحددة.",
|
||||
"workspace.cost.subscriptionShort": "الفرعية",
|
||||
"workspace.keys.title": "API المفاتيح",
|
||||
"workspace.cost.subscriptionShort": "اشتراك",
|
||||
"workspace.keys.title": "مفاتيح API",
|
||||
"workspace.keys.subtitle": "إدارة مفاتيح API الخاصة بك للوصول إلى خدمات opencode.",
|
||||
"workspace.keys.create": "قم بإنشاء مفتاح API",
|
||||
"workspace.keys.placeholder": "أدخل اسم المفتاح",
|
||||
"workspace.keys.empty": "قم بإنشاء مفتاح opencode للبوابة API",
|
||||
"workspace.keys.empty": "أنشئ مفتاح API لبوابة opencode",
|
||||
"workspace.keys.table.name": "اسم",
|
||||
"workspace.keys.table.key": "مفتاح",
|
||||
"workspace.keys.table.createdBy": "تم الإنشاء بواسطة",
|
||||
@@ -442,14 +442,14 @@ export const dict = {
|
||||
"workspace.members.table.email": "بريد إلكتروني",
|
||||
"workspace.members.table.role": "دور",
|
||||
"workspace.members.table.monthLimit": "حد الشهر",
|
||||
"workspace.members.role.admin": "مسؤل",
|
||||
"workspace.members.role.admin": "مسؤول",
|
||||
"workspace.members.role.adminDescription": "يمكن إدارة النماذج، والأعضاء، والفواتير",
|
||||
"workspace.members.role.member": "عضو",
|
||||
"workspace.members.role.memberDescription": "يمكنهم فقط إنشاء مفاتيح API لأنفسهم",
|
||||
"workspace.settings.title": "إعدادات",
|
||||
"workspace.settings.subtitle": "قم بتحديث اسم مساحة العمل الخاصة بك وتفضيلاتك.",
|
||||
"workspace.settings.workspaceName": "اسم مساحة العمل",
|
||||
"workspace.settings.defaultName": "تقصير",
|
||||
"workspace.settings.defaultName": "الافتراضي",
|
||||
"workspace.settings.updating": "جارٍ التحديث...",
|
||||
"workspace.settings.save": "يحفظ",
|
||||
"workspace.settings.edit": "يحرر",
|
||||
@@ -461,37 +461,37 @@ export const dict = {
|
||||
"workspace.billing.add": "أضف $",
|
||||
"workspace.billing.enterAmount": "أدخل المبلغ",
|
||||
"workspace.billing.loading": "تحميل...",
|
||||
"workspace.billing.addAction": "يضيف",
|
||||
"workspace.billing.addAction": "إضافة",
|
||||
"workspace.billing.addBalance": "إضافة الرصيد",
|
||||
"workspace.billing.linkedToStripe": "مرتبطة بالشريط",
|
||||
"workspace.billing.manage": "يدير",
|
||||
"workspace.billing.linkedToStripe": "مرتبط بـ Stripe",
|
||||
"workspace.billing.manage": "إدارة",
|
||||
"workspace.billing.enable": "تمكين الفوترة",
|
||||
"workspace.monthlyLimit.title": "الحد الشهري",
|
||||
"workspace.monthlyLimit.subtitle": "قم بتعيين حد الاستخدام الشهري لحسابك.",
|
||||
"workspace.monthlyLimit.placeholder": "50",
|
||||
"workspace.monthlyLimit.setting": "جلسة...",
|
||||
"workspace.monthlyLimit.setting": "جارٍ التعيين...",
|
||||
"workspace.monthlyLimit.set": "تعيين",
|
||||
"workspace.monthlyLimit.edit": "تحرير الحد",
|
||||
"workspace.monthlyLimit.noLimit": "لم يتم تعيين حد الاستخدام.",
|
||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "الاستخدام الحالي ل",
|
||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "هو $",
|
||||
"workspace.reload.title": "إعادة التحميل التلقائي",
|
||||
"workspace.reload.disabled.before": "إعادة التحميل التلقائي هو",
|
||||
"workspace.reload.disabled.state": "عاجز",
|
||||
"workspace.reload.disabled.after": "تمكين إعادة التحميل تلقائيًا عندما يكون الرصيد منخفضًا.",
|
||||
"workspace.reload.enabled.before": "إعادة التحميل التلقائي هو",
|
||||
"workspace.reload.title": "إعادة الشحن التلقائي",
|
||||
"workspace.reload.disabled.before": "إعادة الشحن التلقائي",
|
||||
"workspace.reload.disabled.state": "معطّل",
|
||||
"workspace.reload.disabled.after": "فعّلها لإعادة شحن الرصيد تلقائيًا عندما يكون منخفضًا.",
|
||||
"workspace.reload.enabled.before": "إعادة الشحن التلقائي",
|
||||
"workspace.reload.enabled.state": "ممكّن",
|
||||
"workspace.reload.enabled.middle": "سنقوم بإعادة التحميل",
|
||||
"workspace.reload.enabled.middle": "سنعيد شحن رصيدك بمبلغ",
|
||||
"workspace.reload.processingFee": "رسوم المعالجة",
|
||||
"workspace.reload.enabled.after": "عندما يصل التوازن",
|
||||
"workspace.reload.enabled.after": "عندما يصل الرصيد إلى",
|
||||
"workspace.reload.edit": "يحرر",
|
||||
"workspace.reload.enable": "يُمكَِن",
|
||||
"workspace.reload.enableAutoReload": "تمكين إعادة التحميل التلقائي",
|
||||
"workspace.reload.reloadAmount": "إعادة تحميل $",
|
||||
"workspace.reload.enable": "تفعيل",
|
||||
"workspace.reload.enableAutoReload": "تفعيل إعادة الشحن التلقائي",
|
||||
"workspace.reload.reloadAmount": "مبلغ إعادة الشحن $",
|
||||
"workspace.reload.whenBalanceReaches": "عندما يصل الرصيد إلى $",
|
||||
"workspace.reload.saving": "توفير...",
|
||||
"workspace.reload.save": "يحفظ",
|
||||
"workspace.reload.failedAt": "فشلت عملية إعادة التحميل عند",
|
||||
"workspace.reload.failedAt": "فشلت إعادة الشحن في",
|
||||
"workspace.reload.reason": "سبب:",
|
||||
"workspace.reload.updatePaymentMethod": "يرجى تحديث طريقة الدفع الخاصة بك والمحاولة مرة أخرى.",
|
||||
"workspace.reload.retrying": "جارٍ إعادة المحاولة...",
|
||||
@@ -500,11 +500,11 @@ export const dict = {
|
||||
"workspace.payments.subtitle": "معاملات الدفع الأخيرة.",
|
||||
"workspace.payments.table.date": "تاريخ",
|
||||
"workspace.payments.table.paymentId": "معرف الدفع",
|
||||
"workspace.payments.table.amount": "كمية",
|
||||
"workspace.payments.table.amount": "المبلغ",
|
||||
"workspace.payments.table.receipt": "إيصال",
|
||||
"workspace.payments.type.credit": "ائتمان",
|
||||
"workspace.payments.type.subscription": "الاشتراك",
|
||||
"workspace.payments.view": "منظر",
|
||||
"workspace.payments.view": "عرض",
|
||||
"workspace.black.loading": "تحميل...",
|
||||
"workspace.black.time.day": "يوم",
|
||||
"workspace.black.time.days": "أيام",
|
||||
@@ -521,8 +521,8 @@ export const dict = {
|
||||
"workspace.black.subscription.resetsIn": "إعادة تعيين في",
|
||||
"workspace.black.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام",
|
||||
"workspace.black.waitlist.title": "قائمة الانتظار",
|
||||
"workspace.black.waitlist.joined": "أنت على قائمة الانتظار للخطة السوداء {{plan}} دولار شهريًا OpenCode.",
|
||||
"workspace.black.waitlist.ready": "نحن على استعداد لتسجيلك في خطة Black {{plan}} الشهرية OpenCode.",
|
||||
"workspace.black.waitlist.joined": "أنت على قائمة الانتظار لخطة OpenCode Black بقيمة ${{plan}} شهريًا.",
|
||||
"workspace.black.waitlist.ready": "نحن مستعدون لتسجيلك في خطة OpenCode Black بقيمة ${{plan}} شهريًا.",
|
||||
"workspace.black.waitlist.leave": "ترك قائمة الانتظار",
|
||||
"workspace.black.waitlist.leaving": "مغادرة...",
|
||||
"workspace.black.waitlist.left": "غادر",
|
||||
|
||||
@@ -294,18 +294,18 @@ export const dict = {
|
||||
"workspace.home.billing.currentBalance": "Nuværende saldo",
|
||||
"workspace.newUser.feature.tested.title": "Testede og verificerede modeller",
|
||||
"workspace.newUser.feature.tested.body":
|
||||
"Vi har benchmarket og testet modeller specifikt til kodningsmidler for at sikre den bedste ydeevne.",
|
||||
"Vi har benchmarket og testet modeller specifikt til kodningsagenter for at sikre den bedste ydeevne.",
|
||||
"workspace.newUser.feature.quality.title": "Højeste kvalitet",
|
||||
"workspace.newUser.feature.quality.body":
|
||||
"Få adgang til modeller konfigureret til optimal ydeevne - ingen nedgraderinger eller routing til billigere udbydere.",
|
||||
"workspace.newUser.feature.lockin.title": "Ingen indlåsning",
|
||||
"workspace.newUser.feature.lockin.body":
|
||||
"Brug Zen med en hvilken som helst kodningsagent, og fortsæt med at bruge andre udbydere med opencode, når du vil.",
|
||||
"workspace.newUser.copyApiKey": "Kopiér nøglen API",
|
||||
"workspace.newUser.copyApiKey": "Kopiér API-nøgle",
|
||||
"workspace.newUser.copyKey": "Kopier nøgle",
|
||||
"workspace.newUser.copied": "Kopieret!",
|
||||
"workspace.newUser.step.enableBilling": "Aktiver fakturering",
|
||||
"workspace.newUser.step.login.before": "Løbe",
|
||||
"workspace.newUser.step.login.before": "Kør",
|
||||
"workspace.newUser.step.login.after": "og vælg opencode",
|
||||
"workspace.newUser.step.pasteKey": "Indsæt din API nøgle",
|
||||
"workspace.newUser.step.models.before": "Start opencode og kør",
|
||||
@@ -316,12 +316,12 @@ export const dict = {
|
||||
"workspace.models.table.enabled": "Aktiveret",
|
||||
"workspace.providers.title": "Medbring din egen nøgle",
|
||||
"workspace.providers.subtitle": "Konfigurer dine egne API nøgler fra AI-udbydere.",
|
||||
"workspace.providers.placeholder": "Indtast nøglen {{provider}} API ({{prefix}}...)",
|
||||
"workspace.providers.placeholder": "Indtast {{provider}} API-nøgle ({{prefix}}...)",
|
||||
"workspace.providers.configure": "Konfigurer",
|
||||
"workspace.providers.edit": "Redigere",
|
||||
"workspace.providers.edit": "Rediger",
|
||||
"workspace.providers.delete": "Slet",
|
||||
"workspace.providers.saving": "Gemmer...",
|
||||
"workspace.providers.save": "Spare",
|
||||
"workspace.providers.save": "Gem",
|
||||
"workspace.providers.table.provider": "Udbyder",
|
||||
"workspace.providers.table.apiKey": "API Nøgle",
|
||||
"workspace.usage.title": "Brugshistorik",
|
||||
@@ -330,15 +330,15 @@ export const dict = {
|
||||
"workspace.usage.table.date": "Dato",
|
||||
"workspace.usage.table.model": "Model",
|
||||
"workspace.usage.table.input": "Input",
|
||||
"workspace.usage.table.output": "Produktion",
|
||||
"workspace.usage.table.cost": "Koste",
|
||||
"workspace.usage.table.output": "Output",
|
||||
"workspace.usage.table.cost": "Omkostning",
|
||||
"workspace.usage.breakdown.input": "Input",
|
||||
"workspace.usage.breakdown.cacheRead": "Cache læst",
|
||||
"workspace.usage.breakdown.cacheWrite": "Cache skriv",
|
||||
"workspace.usage.breakdown.output": "Produktion",
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Ræsonnement",
|
||||
"workspace.usage.subscription": "abonnement (${{amount}})",
|
||||
"workspace.cost.title": "Koste",
|
||||
"workspace.cost.title": "Omkostninger",
|
||||
"workspace.cost.subtitle": "Brugsomkostninger opdelt efter model.",
|
||||
"workspace.cost.allModels": "Alle modeller",
|
||||
"workspace.cost.allKeys": "Alle nøgler",
|
||||
@@ -354,7 +354,7 @@ export const dict = {
|
||||
"workspace.keys.table.key": "Nøgle",
|
||||
"workspace.keys.table.createdBy": "Skabt af",
|
||||
"workspace.keys.table.lastUsed": "Sidst brugt",
|
||||
"workspace.keys.copyApiKey": "Kopiér nøglen API",
|
||||
"workspace.keys.copyApiKey": "Kopiér API-nøgle",
|
||||
"workspace.keys.delete": "Slet",
|
||||
"workspace.members.title": "Medlemmer",
|
||||
"workspace.members.subtitle": "Administrer arbejdsområdemedlemmer og deres tilladelser.",
|
||||
@@ -368,10 +368,10 @@ export const dict = {
|
||||
"workspace.members.noLimit": "Ingen grænse",
|
||||
"workspace.members.noLimitLowercase": "ingen grænse",
|
||||
"workspace.members.invited": "inviteret",
|
||||
"workspace.members.edit": "Redigere",
|
||||
"workspace.members.edit": "Rediger",
|
||||
"workspace.members.delete": "Slet",
|
||||
"workspace.members.saving": "Gemmer...",
|
||||
"workspace.members.save": "Spare",
|
||||
"workspace.members.save": "Gem",
|
||||
"workspace.members.table.email": "E-mail",
|
||||
"workspace.members.table.role": "Rolle",
|
||||
"workspace.members.table.monthLimit": "Månedsgrænse",
|
||||
@@ -382,10 +382,10 @@ export const dict = {
|
||||
"workspace.settings.title": "Indstillinger",
|
||||
"workspace.settings.subtitle": "Opdater dit arbejdsområdes navn og præferencer.",
|
||||
"workspace.settings.workspaceName": "Arbejdsområdets navn",
|
||||
"workspace.settings.defaultName": "Misligholdelse",
|
||||
"workspace.settings.defaultName": "Standard",
|
||||
"workspace.settings.updating": "Opdaterer...",
|
||||
"workspace.settings.save": "Spare",
|
||||
"workspace.settings.edit": "Redigere",
|
||||
"workspace.settings.save": "Gem",
|
||||
"workspace.settings.edit": "Rediger",
|
||||
"workspace.billing.title": "Fakturering",
|
||||
"workspace.billing.subtitle.beforeLink": "Administrer betalingsmetoder.",
|
||||
"workspace.billing.contactUs": "Kontakt os",
|
||||
@@ -394,10 +394,10 @@ export const dict = {
|
||||
"workspace.billing.add": "Tilføj $",
|
||||
"workspace.billing.enterAmount": "Indtast beløb",
|
||||
"workspace.billing.loading": "Indlæser...",
|
||||
"workspace.billing.addAction": "Tilføje",
|
||||
"workspace.billing.addAction": "Tilføj",
|
||||
"workspace.billing.addBalance": "Tilføj balance",
|
||||
"workspace.billing.linkedToStripe": "Forbundet til Stripe",
|
||||
"workspace.billing.manage": "Styre",
|
||||
"workspace.billing.manage": "Administrer",
|
||||
"workspace.billing.enable": "Aktiver fakturering",
|
||||
"workspace.monthlyLimit.title": "Månedlig grænse",
|
||||
"workspace.monthlyLimit.subtitle": "Indstil en månedlig forbrugsgrænse for din konto.",
|
||||
@@ -408,23 +408,23 @@ export const dict = {
|
||||
"workspace.monthlyLimit.noLimit": "Ingen forbrugsgrænse angivet.",
|
||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Nuværende brug for",
|
||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
|
||||
"workspace.reload.title": "Automatisk genindlæsning",
|
||||
"workspace.reload.disabled.before": "Automatisk genindlæsning er",
|
||||
"workspace.reload.disabled.state": "handicappet",
|
||||
"workspace.reload.disabled.after": "Aktiver for automatisk at genindlæse, når balancen er lav.",
|
||||
"workspace.reload.enabled.before": "Automatisk genindlæsning er",
|
||||
"workspace.reload.title": "Automatisk genopfyldning",
|
||||
"workspace.reload.disabled.before": "Automatisk genopfyldning er",
|
||||
"workspace.reload.disabled.state": "deaktiveret",
|
||||
"workspace.reload.disabled.after": "Aktiver for automatisk at genopfylde, når saldoen er lav.",
|
||||
"workspace.reload.enabled.before": "Automatisk genopfyldning er",
|
||||
"workspace.reload.enabled.state": "aktiveret",
|
||||
"workspace.reload.enabled.middle": "Vi genindlæser",
|
||||
"workspace.reload.enabled.middle": "Vi genopfylder",
|
||||
"workspace.reload.processingFee": "ekspeditionsgebyr",
|
||||
"workspace.reload.enabled.after": "når balancen er nået",
|
||||
"workspace.reload.edit": "Redigere",
|
||||
"workspace.reload.edit": "Rediger",
|
||||
"workspace.reload.enable": "Aktiver",
|
||||
"workspace.reload.enableAutoReload": "Aktiver automatisk genindlæsning",
|
||||
"workspace.reload.reloadAmount": "Genindlæs $",
|
||||
"workspace.reload.enableAutoReload": "Aktiver automatisk genopfyldning",
|
||||
"workspace.reload.reloadAmount": "Genopfyld $",
|
||||
"workspace.reload.whenBalanceReaches": "Når saldoen når $",
|
||||
"workspace.reload.saving": "Gemmer...",
|
||||
"workspace.reload.save": "Spare",
|
||||
"workspace.reload.failedAt": "Genindlæsning mislykkedes kl",
|
||||
"workspace.reload.save": "Gem",
|
||||
"workspace.reload.failedAt": "Genopfyldning mislykkedes kl",
|
||||
"workspace.reload.reason": "Årsag:",
|
||||
"workspace.reload.updatePaymentMethod": "Opdater din betalingsmetode, og prøv igen.",
|
||||
"workspace.reload.retrying": "Prøver igen...",
|
||||
@@ -434,10 +434,10 @@ export const dict = {
|
||||
"workspace.payments.table.date": "Dato",
|
||||
"workspace.payments.table.paymentId": "Betalings-id",
|
||||
"workspace.payments.table.amount": "Beløb",
|
||||
"workspace.payments.table.receipt": "Modtagelse",
|
||||
"workspace.payments.table.receipt": "Kvittering",
|
||||
"workspace.payments.type.credit": "kredit",
|
||||
"workspace.payments.type.subscription": "abonnement",
|
||||
"workspace.payments.view": "Udsigt",
|
||||
"workspace.payments.view": "Vis",
|
||||
"workspace.black.loading": "Indlæser...",
|
||||
"workspace.black.time.day": "dag",
|
||||
"workspace.black.time.days": "dage",
|
||||
@@ -458,8 +458,8 @@ export const dict = {
|
||||
"workspace.black.waitlist.ready": "Vi er klar til at tilmelde dig ${{plan}} per måned OpenCode Black plan.",
|
||||
"workspace.black.waitlist.leave": "Forlad venteliste",
|
||||
"workspace.black.waitlist.leaving": "Forlader...",
|
||||
"workspace.black.waitlist.left": "Venstre",
|
||||
"workspace.black.waitlist.enroll": "Indskrive",
|
||||
"workspace.black.waitlist.left": "Forladt",
|
||||
"workspace.black.waitlist.enroll": "Tilmeld",
|
||||
"workspace.black.waitlist.enrolling": "Tilmelder...",
|
||||
"workspace.black.waitlist.enrolled": "Tilmeldt",
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
|
||||
@@ -306,27 +306,27 @@ export const dict = {
|
||||
"workspace.newUser.feature.lockin.title": "Kein Lock-in",
|
||||
"workspace.newUser.feature.lockin.body":
|
||||
"Verwenden Sie Zen mit einem beliebigen Codierungsagenten und nutzen Sie weiterhin andere Anbieter mit opencode, wann immer Sie möchten.",
|
||||
"workspace.newUser.copyApiKey": "Kopieren Sie den Schlüssel API",
|
||||
"workspace.newUser.copyApiKey": "API-Schlüssel kopieren",
|
||||
"workspace.newUser.copyKey": "Schlüssel kopieren",
|
||||
"workspace.newUser.copied": "Kopiert!",
|
||||
"workspace.newUser.step.enableBilling": "Abrechnung aktivieren",
|
||||
"workspace.newUser.step.login.before": "Laufen",
|
||||
"workspace.newUser.step.login.before": "Führe",
|
||||
"workspace.newUser.step.login.after": "und wählen Sie opencode",
|
||||
"workspace.newUser.step.pasteKey": "Fügen Sie Ihren API-Schlüssel ein",
|
||||
"workspace.newUser.step.models.before": "Starten Sie opencode und führen Sie es aus",
|
||||
"workspace.newUser.step.models.before": "Starte opencode und führe",
|
||||
"workspace.newUser.step.models.after": "um ein Modell auszuwählen",
|
||||
"workspace.models.title": "Modelle",
|
||||
"workspace.models.subtitle.beforeLink":
|
||||
"Verwalten Sie, auf welche Modelle Arbeitsbereichsmitglieder zugreifen können.",
|
||||
"workspace.models.table.model": "Modell",
|
||||
"workspace.models.table.enabled": "Ermöglicht",
|
||||
"workspace.models.table.enabled": "Aktiviert",
|
||||
"workspace.providers.title": "Bringen Sie Ihren eigenen Schlüssel mit",
|
||||
"workspace.providers.subtitle": "Konfigurieren Sie Ihre eigenen API-Schlüssel von KI-Anbietern.",
|
||||
"workspace.providers.placeholder": "Geben Sie den Schlüssel {{provider}} API ein ({{prefix}}...)",
|
||||
"workspace.providers.configure": "Konfigurieren",
|
||||
"workspace.providers.edit": "Bearbeiten",
|
||||
"workspace.providers.delete": "Löschen",
|
||||
"workspace.providers.saving": "Sparen...",
|
||||
"workspace.providers.saving": "Wird gespeichert...",
|
||||
"workspace.providers.save": "Speichern",
|
||||
"workspace.providers.table.provider": "Anbieter",
|
||||
"workspace.providers.table.apiKey": "API-Schlüssel",
|
||||
@@ -335,14 +335,14 @@ export const dict = {
|
||||
"workspace.usage.empty": "Machen Sie Ihren ersten API-Aufruf, um loszulegen.",
|
||||
"workspace.usage.table.date": "Datum",
|
||||
"workspace.usage.table.model": "Modell",
|
||||
"workspace.usage.table.input": "Eingang",
|
||||
"workspace.usage.table.output": "Ausgabe",
|
||||
"workspace.usage.table.input": "Input",
|
||||
"workspace.usage.table.output": "Output",
|
||||
"workspace.usage.table.cost": "Kosten",
|
||||
"workspace.usage.breakdown.input": "Eingang",
|
||||
"workspace.usage.breakdown.input": "Input",
|
||||
"workspace.usage.breakdown.cacheRead": "Cache-Lesen",
|
||||
"workspace.usage.breakdown.cacheWrite": "Cache-Schreiben",
|
||||
"workspace.usage.breakdown.output": "Ausgabe",
|
||||
"workspace.usage.breakdown.reasoning": "Argumentation",
|
||||
"workspace.usage.breakdown.output": "Output",
|
||||
"workspace.usage.breakdown.reasoning": "Reasoning",
|
||||
"workspace.usage.subscription": "Abonnement (${{amount}})",
|
||||
"workspace.cost.title": "Kosten",
|
||||
"workspace.cost.subtitle": "Nutzungskosten aufgeschlüsselt nach Modell.",
|
||||
@@ -360,12 +360,12 @@ export const dict = {
|
||||
"workspace.keys.table.key": "Schlüssel",
|
||||
"workspace.keys.table.createdBy": "Erstellt von",
|
||||
"workspace.keys.table.lastUsed": "Zuletzt verwendet",
|
||||
"workspace.keys.copyApiKey": "Kopieren Sie den Schlüssel API",
|
||||
"workspace.keys.copyApiKey": "API-Schlüssel kopieren",
|
||||
"workspace.keys.delete": "Löschen",
|
||||
"workspace.members.title": "Mitglieder",
|
||||
"workspace.members.subtitle": "Verwalten Sie Arbeitsbereichsmitglieder und ihre Berechtigungen.",
|
||||
"workspace.members.invite": "Mitglied einladen",
|
||||
"workspace.members.inviting": "Einladend...",
|
||||
"workspace.members.inviting": "Wird eingeladen...",
|
||||
"workspace.members.beta.beforeLink": "Während der Betaversion sind Arbeitsbereiche für Teams kostenlos.",
|
||||
"workspace.members.form.invitee": "Eingeladen",
|
||||
"workspace.members.form.emailPlaceholder": "Geben Sie Ihre E-Mail-Adresse ein",
|
||||
@@ -376,7 +376,7 @@ export const dict = {
|
||||
"workspace.members.invited": "eingeladen",
|
||||
"workspace.members.edit": "Bearbeiten",
|
||||
"workspace.members.delete": "Löschen",
|
||||
"workspace.members.saving": "Sparen...",
|
||||
"workspace.members.saving": "Wird gespeichert...",
|
||||
"workspace.members.save": "Speichern",
|
||||
"workspace.members.table.email": "E-Mail",
|
||||
"workspace.members.table.role": "Rolle",
|
||||
@@ -408,30 +408,30 @@ export const dict = {
|
||||
"workspace.monthlyLimit.title": "Monatliches Limit",
|
||||
"workspace.monthlyLimit.subtitle": "Legen Sie ein monatliches Nutzungslimit für Ihr Konto fest.",
|
||||
"workspace.monthlyLimit.placeholder": "50",
|
||||
"workspace.monthlyLimit.setting": "Einstellung...",
|
||||
"workspace.monthlyLimit.set": "Satz",
|
||||
"workspace.monthlyLimit.setting": "Wird gesetzt...",
|
||||
"workspace.monthlyLimit.set": "Festlegen",
|
||||
"workspace.monthlyLimit.edit": "Limit bearbeiten",
|
||||
"workspace.monthlyLimit.noLimit": "Kein Nutzungslimit festgelegt.",
|
||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktuelle Nutzung für",
|
||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "ist $",
|
||||
"workspace.reload.title": "Automatisches Neuladen",
|
||||
"workspace.reload.disabled.before": "Automatisches Nachladen ist",
|
||||
"workspace.reload.title": "Automatische Aufladung",
|
||||
"workspace.reload.disabled.before": "Automatische Aufladung ist",
|
||||
"workspace.reload.disabled.state": "deaktiviert",
|
||||
"workspace.reload.disabled.after":
|
||||
"Aktivieren Sie diese Option, um das Guthaben automatisch neu zu laden, wenn das Guthaben niedrig ist.",
|
||||
"workspace.reload.enabled.before": "Automatisches Nachladen ist",
|
||||
"workspace.reload.enabled.state": "ermöglicht",
|
||||
"workspace.reload.enabled.middle": "Wir laden nach",
|
||||
"Aktivieren Sie diese Option, damit bei niedrigem Kontostand automatisch aufgeladen wird.",
|
||||
"workspace.reload.enabled.before": "Automatische Aufladung ist",
|
||||
"workspace.reload.enabled.state": "aktiviert",
|
||||
"workspace.reload.enabled.middle": "Wir laden auf",
|
||||
"workspace.reload.processingFee": "Bearbeitungsgebühr",
|
||||
"workspace.reload.enabled.after": "wenn das Gleichgewicht erreicht ist",
|
||||
"workspace.reload.enabled.after": "sobald der Kontostand",
|
||||
"workspace.reload.edit": "Bearbeiten",
|
||||
"workspace.reload.enable": "Aktivieren",
|
||||
"workspace.reload.enableAutoReload": "Aktivieren Sie das automatische Neuladen",
|
||||
"workspace.reload.reloadAmount": "$ neu laden",
|
||||
"workspace.reload.whenBalanceReaches": "Wenn der Saldo $ erreicht",
|
||||
"workspace.reload.saving": "Sparen...",
|
||||
"workspace.reload.enableAutoReload": "Automatische Aufladung aktivieren",
|
||||
"workspace.reload.reloadAmount": "Aufladebetrag $",
|
||||
"workspace.reload.whenBalanceReaches": "Wenn der Kontostand $ erreicht",
|
||||
"workspace.reload.saving": "Wird gespeichert...",
|
||||
"workspace.reload.save": "Speichern",
|
||||
"workspace.reload.failedAt": "Neuladen fehlgeschlagen bei",
|
||||
"workspace.reload.failedAt": "Aufladung fehlgeschlagen am",
|
||||
"workspace.reload.reason": "Grund:",
|
||||
"workspace.reload.updatePaymentMethod": "Bitte aktualisieren Sie Ihre Zahlungsmethode und versuchen Sie es erneut.",
|
||||
"workspace.reload.retrying": "Erneuter Versuch...",
|
||||
@@ -440,11 +440,11 @@ export const dict = {
|
||||
"workspace.payments.subtitle": "Letzte Zahlungsvorgänge.",
|
||||
"workspace.payments.table.date": "Datum",
|
||||
"workspace.payments.table.paymentId": "Zahlungs-ID",
|
||||
"workspace.payments.table.amount": "Menge",
|
||||
"workspace.payments.table.amount": "Betrag",
|
||||
"workspace.payments.table.receipt": "Quittung",
|
||||
"workspace.payments.type.credit": "Kredit",
|
||||
"workspace.payments.type.subscription": "Abonnement",
|
||||
"workspace.payments.view": "Sicht",
|
||||
"workspace.payments.view": "Anzeigen",
|
||||
"workspace.black.loading": "Laden...",
|
||||
"workspace.black.time.day": "Tag",
|
||||
"workspace.black.time.days": "Tage",
|
||||
@@ -454,21 +454,21 @@ export const dict = {
|
||||
"workspace.black.time.minutes": "Minuten",
|
||||
"workspace.black.time.fewSeconds": "ein paar Sekunden",
|
||||
"workspace.black.subscription.title": "Abonnement",
|
||||
"workspace.black.subscription.message": "Sie haben OpenCode Black für {{plan}} pro Monat abonniert.",
|
||||
"workspace.black.subscription.message": "Sie haben OpenCode Black für ${{plan}} pro Monat abonniert.",
|
||||
"workspace.black.subscription.manage": "Abonnement verwalten",
|
||||
"workspace.black.subscription.rollingUsage": "5-stündige Nutzung",
|
||||
"workspace.black.subscription.weeklyUsage": "Wöchentliche Nutzung",
|
||||
"workspace.black.subscription.resetsIn": "Wird zurückgesetzt",
|
||||
"workspace.black.subscription.resetsIn": "Zurückgesetzt in",
|
||||
"workspace.black.subscription.useBalance":
|
||||
"Nutzen Sie Ihr verfügbares Guthaben, nachdem Sie die Nutzungslimits erreicht haben",
|
||||
"workspace.black.waitlist.title": "Warteliste",
|
||||
"workspace.black.waitlist.joined":
|
||||
"Sie stehen auf der Warteliste für den Black-Plan im Wert von ${{plan}} pro Monat OpenCode.",
|
||||
"Sie stehen auf der Warteliste für den OpenCode Black Tarif für ${{plan}} pro Monat.",
|
||||
"workspace.black.waitlist.ready":
|
||||
"Wir sind bereit, Sie für den Black-Plan im Wert von ${{plan}} pro Monat OpenCode anzumelden.",
|
||||
"Wir können Sie jetzt in den OpenCode Black Tarif für ${{plan}} pro Monat aufnehmen.",
|
||||
"workspace.black.waitlist.leave": "Warteliste verlassen",
|
||||
"workspace.black.waitlist.leaving": "Verlassen...",
|
||||
"workspace.black.waitlist.left": "Links",
|
||||
"workspace.black.waitlist.left": "Verlassen",
|
||||
"workspace.black.waitlist.enroll": "Einschreiben",
|
||||
"workspace.black.waitlist.enrolling": "Anmeldung...",
|
||||
"workspace.black.waitlist.enrolled": "Eingeschrieben",
|
||||
|
||||
@@ -394,7 +394,7 @@ export const dict = {
|
||||
"workspace.settings.edit": "Edit",
|
||||
|
||||
"workspace.billing.title": "Billing",
|
||||
"workspace.billing.subtitle.beforeLink": "Manage payments methods.",
|
||||
"workspace.billing.subtitle.beforeLink": "Manage payment methods.",
|
||||
"workspace.billing.contactUs": "Contact us",
|
||||
"workspace.billing.subtitle.afterLink": "if you have any questions.",
|
||||
"workspace.billing.currentBalance": "Current Balance",
|
||||
|
||||
@@ -284,8 +284,8 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "Nuovi aggiornamenti e miglioramenti per OpenCode",
|
||||
"changelog.empty": "Nessuna voce di changelog trovata.",
|
||||
"changelog.viewJson": "Visualizza JSON",
|
||||
"workspace.nav.zen": "zen",
|
||||
"workspace.nav.apiKeys": "API Chiavi",
|
||||
"workspace.nav.zen": "Zen",
|
||||
"workspace.nav.apiKeys": "Chiavi API",
|
||||
"workspace.nav.members": "Membri",
|
||||
"workspace.nav.billing": "Fatturazione",
|
||||
"workspace.nav.settings": "Impostazioni",
|
||||
@@ -299,14 +299,14 @@ export const dict = {
|
||||
"workspace.newUser.feature.quality.title": "Massima qualità",
|
||||
"workspace.newUser.feature.quality.body":
|
||||
"Modelli di accesso configurati per prestazioni ottimali: senza downgrade o instradamento verso fornitori più economici.",
|
||||
"workspace.newUser.feature.lockin.title": "Nessun blocco",
|
||||
"workspace.newUser.feature.lockin.title": "Nessun lock-in",
|
||||
"workspace.newUser.feature.lockin.body":
|
||||
"Utilizza Zen con qualsiasi agente di codifica e continua a utilizzare altri provider con opencode ogni volta che vuoi.",
|
||||
"workspace.newUser.copyApiKey": "Copia la chiave API",
|
||||
"workspace.newUser.copyKey": "Copia chiave",
|
||||
"workspace.newUser.copied": "Copiato!",
|
||||
"workspace.newUser.step.enableBilling": "Abilita fatturazione",
|
||||
"workspace.newUser.step.login.before": "Correre",
|
||||
"workspace.newUser.step.login.before": "Esegui",
|
||||
"workspace.newUser.step.login.after": "e seleziona opencode",
|
||||
"workspace.newUser.step.pasteKey": "Incolla la tua chiave API",
|
||||
"workspace.newUser.step.models.before": "Avvia opencode ed esegui",
|
||||
@@ -315,16 +315,16 @@ export const dict = {
|
||||
"workspace.models.subtitle.beforeLink": "Gestire i modelli a cui possono accedere i membri dell'area di lavoro.",
|
||||
"workspace.models.table.model": "Modello",
|
||||
"workspace.models.table.enabled": "Abilitato",
|
||||
"workspace.providers.title": "Porta la tua chiave",
|
||||
"workspace.providers.title": "Bring Your Own Key (BYOK)",
|
||||
"workspace.providers.subtitle": "Configura le tue chiavi API dai fornitori di intelligenza artificiale.",
|
||||
"workspace.providers.placeholder": "Inserisci la chiave {{provider}} API ({{prefix}}...)",
|
||||
"workspace.providers.configure": "Configura",
|
||||
"workspace.providers.edit": "Modificare",
|
||||
"workspace.providers.delete": "Eliminare",
|
||||
"workspace.providers.saving": "Risparmio...",
|
||||
"workspace.providers.saving": "Salvataggio in corso...",
|
||||
"workspace.providers.save": "Salva",
|
||||
"workspace.providers.table.provider": "Fornitore",
|
||||
"workspace.providers.table.apiKey": "API Chiave",
|
||||
"workspace.providers.table.apiKey": "Chiave API",
|
||||
"workspace.usage.title": "Cronologia dell'utilizzo",
|
||||
"workspace.usage.subtitle": "Utilizzo e costi recenti di API.",
|
||||
"workspace.usage.empty": "Effettua la tua prima chiamata API per iniziare.",
|
||||
@@ -346,7 +346,7 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(eliminato)",
|
||||
"workspace.cost.empty": "Nessun dato di utilizzo disponibile per il periodo selezionato.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.keys.title": "API Chiavi",
|
||||
"workspace.keys.title": "Chiavi API",
|
||||
"workspace.keys.subtitle": "Gestisci le tue chiavi API per accedere ai servizi opencode.",
|
||||
"workspace.keys.create": "Crea chiave API",
|
||||
"workspace.keys.placeholder": "Inserisci il nome della chiave",
|
||||
@@ -360,7 +360,7 @@ export const dict = {
|
||||
"workspace.members.title": "Membri",
|
||||
"workspace.members.subtitle": "Gestire i membri dell'area di lavoro e le relative autorizzazioni.",
|
||||
"workspace.members.invite": "Invita membro",
|
||||
"workspace.members.inviting": "Invitante...",
|
||||
"workspace.members.inviting": "Invito in corso...",
|
||||
"workspace.members.beta.beforeLink": "Gli spazi di lavoro sono gratuiti per i team durante la beta.",
|
||||
"workspace.members.form.invitee": "Invitato",
|
||||
"workspace.members.form.emailPlaceholder": "Inserisci l'e-mail",
|
||||
@@ -371,12 +371,12 @@ export const dict = {
|
||||
"workspace.members.invited": "invitato",
|
||||
"workspace.members.edit": "Modificare",
|
||||
"workspace.members.delete": "Eliminare",
|
||||
"workspace.members.saving": "Risparmio...",
|
||||
"workspace.members.saving": "Salvataggio in corso...",
|
||||
"workspace.members.save": "Salva",
|
||||
"workspace.members.table.email": "E-mail",
|
||||
"workspace.members.table.role": "Ruolo",
|
||||
"workspace.members.table.monthLimit": "Limite mensile",
|
||||
"workspace.members.role.admin": "Ammin",
|
||||
"workspace.members.role.admin": "Admin",
|
||||
"workspace.members.role.adminDescription": "Può gestire modelli, membri e fatturazione",
|
||||
"workspace.members.role.member": "Membro",
|
||||
"workspace.members.role.memberDescription": "Possono generare chiavi API solo per se stessi",
|
||||
@@ -388,42 +388,42 @@ export const dict = {
|
||||
"workspace.settings.save": "Salva",
|
||||
"workspace.settings.edit": "Modificare",
|
||||
"workspace.billing.title": "Fatturazione",
|
||||
"workspace.billing.subtitle.beforeLink": "Gestire i metodi di pagamento.",
|
||||
"workspace.billing.subtitle.beforeLink": "Gestisci i metodi di pagamento.",
|
||||
"workspace.billing.contactUs": "Contattaci",
|
||||
"workspace.billing.subtitle.afterLink": "se hai qualche domanda",
|
||||
"workspace.billing.currentBalance": "Saldo attuale",
|
||||
"workspace.billing.add": "Aggiungi $",
|
||||
"workspace.billing.enterAmount": "Inserisci l'importo",
|
||||
"workspace.billing.loading": "Caricamento...",
|
||||
"workspace.billing.addAction": "Aggiungere",
|
||||
"workspace.billing.addAction": "Aggiungi",
|
||||
"workspace.billing.addBalance": "Aggiungi saldo",
|
||||
"workspace.billing.linkedToStripe": "Collegato a Stripe",
|
||||
"workspace.billing.manage": "Maneggio",
|
||||
"workspace.billing.manage": "Gestisci",
|
||||
"workspace.billing.enable": "Abilita fatturazione",
|
||||
"workspace.monthlyLimit.title": "Limite mensile",
|
||||
"workspace.monthlyLimit.subtitle": "Imposta un limite di utilizzo mensile per il tuo account.",
|
||||
"workspace.monthlyLimit.placeholder": "50",
|
||||
"workspace.monthlyLimit.setting": "Collocamento...",
|
||||
"workspace.monthlyLimit.setting": "Impostazione in corso...",
|
||||
"workspace.monthlyLimit.set": "Impostato",
|
||||
"workspace.monthlyLimit.edit": "Modifica limite",
|
||||
"workspace.monthlyLimit.noLimit": "Nessun limite di utilizzo impostato.",
|
||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Utilizzo attuale per",
|
||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "è $",
|
||||
"workspace.reload.title": "Ricarica automatica",
|
||||
"workspace.reload.disabled.before": "La ricarica automatica lo è",
|
||||
"workspace.reload.disabled.before": "La ricarica automatica è",
|
||||
"workspace.reload.disabled.state": "disabilitato",
|
||||
"workspace.reload.disabled.after": "Abilita la ricarica automatica quando il saldo è basso.",
|
||||
"workspace.reload.enabled.before": "La ricarica automatica lo è",
|
||||
"workspace.reload.enabled.before": "La ricarica automatica è",
|
||||
"workspace.reload.enabled.state": "abilitato",
|
||||
"workspace.reload.enabled.middle": "Ricaricheremo",
|
||||
"workspace.reload.processingFee": "tassa di elaborazione",
|
||||
"workspace.reload.enabled.after": "quando l'equilibrio raggiunge",
|
||||
"workspace.reload.enabled.after": "quando il saldo raggiunge",
|
||||
"workspace.reload.edit": "Modificare",
|
||||
"workspace.reload.enable": "Abilitare",
|
||||
"workspace.reload.enableAutoReload": "Abilita ricarica automatica",
|
||||
"workspace.reload.reloadAmount": "Ricarica $",
|
||||
"workspace.reload.whenBalanceReaches": "Quando il saldo raggiunge $",
|
||||
"workspace.reload.saving": "Risparmio...",
|
||||
"workspace.reload.saving": "Salvataggio in corso...",
|
||||
"workspace.reload.save": "Salva",
|
||||
"workspace.reload.failedAt": "Ricarica non riuscita a",
|
||||
"workspace.reload.reason": "Motivo:",
|
||||
@@ -434,11 +434,11 @@ export const dict = {
|
||||
"workspace.payments.subtitle": "Transazioni di pagamento recenti.",
|
||||
"workspace.payments.table.date": "Data",
|
||||
"workspace.payments.table.paymentId": "ID pagamento",
|
||||
"workspace.payments.table.amount": "Quantità",
|
||||
"workspace.payments.table.amount": "Importo",
|
||||
"workspace.payments.table.receipt": "Ricevuta",
|
||||
"workspace.payments.type.credit": "credito",
|
||||
"workspace.payments.type.subscription": "sottoscrizione",
|
||||
"workspace.payments.view": "Visualizzazione",
|
||||
"workspace.payments.view": "Visualizza",
|
||||
"workspace.black.loading": "Caricamento...",
|
||||
"workspace.black.time.day": "giorno",
|
||||
"workspace.black.time.days": "giorni",
|
||||
@@ -452,14 +452,14 @@ export const dict = {
|
||||
"workspace.black.subscription.manage": "Gestisci abbonamento",
|
||||
"workspace.black.subscription.rollingUsage": "Utilizzo di 5 ore",
|
||||
"workspace.black.subscription.weeklyUsage": "Utilizzo settimanale",
|
||||
"workspace.black.subscription.resetsIn": "Si reimposta",
|
||||
"workspace.black.subscription.resetsIn": "Si reimposta tra",
|
||||
"workspace.black.subscription.useBalance": "Utilizza il saldo disponibile dopo aver raggiunto i limiti di utilizzo",
|
||||
"workspace.black.waitlist.title": "Lista d'attesa",
|
||||
"workspace.black.waitlist.joined": "Sei in lista d'attesa per il piano nero ${{plan}} al mese OpenCode.",
|
||||
"workspace.black.waitlist.joined": "Sei in lista d'attesa per il piano OpenCode Black da ${{plan}} al mese.",
|
||||
"workspace.black.waitlist.ready": "Siamo pronti per iscriverti al piano OpenCode Black da ${{plan}} al mese.",
|
||||
"workspace.black.waitlist.leave": "Lascia la lista d'attesa",
|
||||
"workspace.black.waitlist.leaving": "In partenza...",
|
||||
"workspace.black.waitlist.left": "Sinistra",
|
||||
"workspace.black.waitlist.leaving": "Uscita in corso...",
|
||||
"workspace.black.waitlist.left": "Uscito dalla lista d'attesa",
|
||||
"workspace.black.waitlist.enroll": "Iscriversi",
|
||||
"workspace.black.waitlist.enrolling": "Iscrizione...",
|
||||
"workspace.black.waitlist.enrolled": "Iscritto",
|
||||
|
||||
@@ -203,7 +203,7 @@ export const dict = {
|
||||
"zen.how.step2.link": "betale per forespørsel",
|
||||
"zen.how.step2.afterLink": "med null markeringer",
|
||||
"zen.how.step3.title": "Automatisk påfylling",
|
||||
"zen.how.step3.body": "når saldoen din når $5, legger vi automatisk til $20",
|
||||
"zen.how.step3.body": "når saldoen din når $5, fyller vi automatisk på $20",
|
||||
"zen.privacy.title": "Personvernet ditt er viktig for oss",
|
||||
"zen.privacy.beforeExceptions":
|
||||
"Alle Zen-modeller er vert i USA. Leverandører følger en nulloppbevaringspolicy og bruker ikke dataene dine til modelltrening, med",
|
||||
@@ -283,7 +283,7 @@ export const dict = {
|
||||
"changelog.empty": "Ingen endringsloggoppforinger funnet.",
|
||||
"changelog.viewJson": "Vis JSON",
|
||||
"workspace.nav.zen": "Zen",
|
||||
"workspace.nav.apiKeys": "API Taster",
|
||||
"workspace.nav.apiKeys": "API Nøkler",
|
||||
"workspace.nav.members": "Medlemmer",
|
||||
"workspace.nav.billing": "Fakturering",
|
||||
"workspace.nav.settings": "Innstillinger",
|
||||
@@ -320,7 +320,7 @@ export const dict = {
|
||||
"workspace.providers.edit": "Redigere",
|
||||
"workspace.providers.delete": "Slett",
|
||||
"workspace.providers.saving": "Lagrer...",
|
||||
"workspace.providers.save": "Spare",
|
||||
"workspace.providers.save": "Lagre",
|
||||
"workspace.providers.table.provider": "Leverandør",
|
||||
"workspace.providers.table.apiKey": "API nøkkel",
|
||||
"workspace.usage.title": "Brukshistorikk",
|
||||
@@ -330,21 +330,21 @@ export const dict = {
|
||||
"workspace.usage.table.model": "Modell",
|
||||
"workspace.usage.table.input": "Inndata",
|
||||
"workspace.usage.table.output": "Produksjon",
|
||||
"workspace.usage.table.cost": "Koste",
|
||||
"workspace.usage.table.cost": "Kostnad",
|
||||
"workspace.usage.breakdown.input": "Inndata",
|
||||
"workspace.usage.breakdown.cacheRead": "Cache lest",
|
||||
"workspace.usage.breakdown.cacheWrite": "Cache-skriving",
|
||||
"workspace.usage.breakdown.output": "Produksjon",
|
||||
"workspace.usage.breakdown.reasoning": "Argumentasjon",
|
||||
"workspace.usage.subscription": "abonnement (${{amount}})",
|
||||
"workspace.cost.title": "Koste",
|
||||
"workspace.cost.title": "Kostnad",
|
||||
"workspace.cost.subtitle": "Brukskostnader fordelt på modell.",
|
||||
"workspace.cost.allModels": "Alle modeller",
|
||||
"workspace.cost.allKeys": "Alle nøkler",
|
||||
"workspace.cost.deletedSuffix": "(slettet)",
|
||||
"workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.keys.title": "API Taster",
|
||||
"workspace.keys.title": "API Nøkler",
|
||||
"workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.",
|
||||
"workspace.keys.create": "Opprett API-nøkkel",
|
||||
"workspace.keys.placeholder": "Skriv inn nøkkelnavn",
|
||||
@@ -370,7 +370,7 @@ export const dict = {
|
||||
"workspace.members.edit": "Redigere",
|
||||
"workspace.members.delete": "Slett",
|
||||
"workspace.members.saving": "Lagrer...",
|
||||
"workspace.members.save": "Spare",
|
||||
"workspace.members.save": "Lagre",
|
||||
"workspace.members.table.email": "E-post",
|
||||
"workspace.members.table.role": "Rolle",
|
||||
"workspace.members.table.monthLimit": "Månedsgrense",
|
||||
@@ -383,7 +383,7 @@ export const dict = {
|
||||
"workspace.settings.workspaceName": "Navn på arbeidsområde",
|
||||
"workspace.settings.defaultName": "Misligholde",
|
||||
"workspace.settings.updating": "Oppdaterer...",
|
||||
"workspace.settings.save": "Spare",
|
||||
"workspace.settings.save": "Lagre",
|
||||
"workspace.settings.edit": "Redigere",
|
||||
"workspace.billing.title": "Fakturering",
|
||||
"workspace.billing.subtitle.beforeLink": "Administrer betalingsmåter.",
|
||||
@@ -407,22 +407,22 @@ export const dict = {
|
||||
"workspace.monthlyLimit.noLimit": "Ingen bruksgrense satt.",
|
||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Gjeldende bruk for",
|
||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
|
||||
"workspace.reload.title": "Last inn automatisk",
|
||||
"workspace.reload.disabled.before": "Automatisk reload er",
|
||||
"workspace.reload.disabled.state": "funksjonshemmet",
|
||||
"workspace.reload.disabled.after": "Aktiver for å laste automatisk på nytt når balansen er lav.",
|
||||
"workspace.reload.enabled.before": "Automatisk reload er",
|
||||
"workspace.reload.title": "Automatisk påfylling",
|
||||
"workspace.reload.disabled.before": "Automatisk påfylling er",
|
||||
"workspace.reload.disabled.state": "deaktivert",
|
||||
"workspace.reload.disabled.after": "Aktiver for å automatisk påfylle på nytt når saldoen er lav.",
|
||||
"workspace.reload.enabled.before": "Automatisk påfylling er",
|
||||
"workspace.reload.enabled.state": "aktivert",
|
||||
"workspace.reload.enabled.middle": "Vi laster på nytt",
|
||||
"workspace.reload.enabled.middle": "Vi fyller på",
|
||||
"workspace.reload.processingFee": "behandlingsgebyr",
|
||||
"workspace.reload.enabled.after": "når balansen når",
|
||||
"workspace.reload.enabled.after": "når saldoen når",
|
||||
"workspace.reload.edit": "Redigere",
|
||||
"workspace.reload.enable": "Aktiver",
|
||||
"workspace.reload.enableAutoReload": "Aktiver automatisk reload",
|
||||
"workspace.reload.enableAutoReload": "Aktiver automatisk påfylling",
|
||||
"workspace.reload.reloadAmount": "Last inn $",
|
||||
"workspace.reload.whenBalanceReaches": "Når saldoen når $",
|
||||
"workspace.reload.saving": "Lagrer...",
|
||||
"workspace.reload.save": "Spare",
|
||||
"workspace.reload.save": "Lagre",
|
||||
"workspace.reload.failedAt": "Omlasting mislyktes kl",
|
||||
"workspace.reload.reason": "Grunn:",
|
||||
"workspace.reload.updatePaymentMethod": "Oppdater betalingsmåten og prøv på nytt.",
|
||||
@@ -436,7 +436,7 @@ export const dict = {
|
||||
"workspace.payments.table.receipt": "Kvittering",
|
||||
"workspace.payments.type.credit": "kreditt",
|
||||
"workspace.payments.type.subscription": "abonnement",
|
||||
"workspace.payments.view": "Utsikt",
|
||||
"workspace.payments.view": "Vis",
|
||||
"workspace.black.loading": "Laster inn...",
|
||||
"workspace.black.time.day": "dag",
|
||||
"workspace.black.time.days": "dager",
|
||||
|
||||
@@ -293,9 +293,9 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "OpenCode \u7684\u65b0\u66f4\u65b0\u4e0e\u6539\u8fdb",
|
||||
"changelog.empty": "\u672a\u627e\u5230\u66f4\u65b0\u65e5\u5fd7\u6761\u76ee\u3002",
|
||||
"changelog.viewJson": "\u67e5\u770b JSON",
|
||||
"workspace.nav.zen": "禅",
|
||||
"workspace.nav.zen": "Zen",
|
||||
"workspace.nav.apiKeys": "API 键",
|
||||
"workspace.nav.members": "会员",
|
||||
"workspace.nav.members": "成员",
|
||||
"workspace.nav.billing": "计费",
|
||||
"workspace.nav.settings": "设置",
|
||||
"workspace.home.banner.beforeLink": "编码代理的可靠优化模型。",
|
||||
@@ -310,26 +310,26 @@ export const dict = {
|
||||
"workspace.newUser.feature.lockin.body":
|
||||
"将 Zen 与任何编码代理结合使用,并在需要时继续将其他提供程序与 opencode 结合使用。",
|
||||
"workspace.newUser.copyApiKey": "复制 API 密钥",
|
||||
"workspace.newUser.copyKey": "复制钥匙",
|
||||
"workspace.newUser.copied": "复制了!",
|
||||
"workspace.newUser.copyKey": "复制密钥",
|
||||
"workspace.newUser.copied": "已复制!",
|
||||
"workspace.newUser.step.enableBilling": "启用计费",
|
||||
"workspace.newUser.step.login.before": "跑步",
|
||||
"workspace.newUser.step.login.before": "运行",
|
||||
"workspace.newUser.step.login.after": "并选择 opencode",
|
||||
"workspace.newUser.step.pasteKey": "粘贴您的 API 密钥",
|
||||
"workspace.newUser.step.models.before": "启动 opencode 并运行",
|
||||
"workspace.newUser.step.models.after": "选择型号",
|
||||
"workspace.models.title": "型号",
|
||||
"workspace.newUser.step.models.after": "选择模型",
|
||||
"workspace.models.title": "模型",
|
||||
"workspace.models.subtitle.beforeLink": "管理工作区成员可以访问哪些模型。",
|
||||
"workspace.models.table.model": "模型",
|
||||
"workspace.models.table.enabled": "启用",
|
||||
"workspace.providers.title": "带上你自己的钥匙",
|
||||
"workspace.providers.title": "自带密钥",
|
||||
"workspace.providers.subtitle": "从 AI 提供商处配置您自己的 API 密钥。",
|
||||
"workspace.providers.placeholder": "输入 {{provider}} API 密钥({{prefix}}...)",
|
||||
"workspace.providers.configure": "配置",
|
||||
"workspace.providers.edit": "编辑",
|
||||
"workspace.providers.delete": "删除",
|
||||
"workspace.providers.saving": "保存...",
|
||||
"workspace.providers.save": "节省",
|
||||
"workspace.providers.save": "保存",
|
||||
"workspace.providers.table.provider": "提供者",
|
||||
"workspace.providers.table.apiKey": "API 密钥",
|
||||
"workspace.usage.title": "使用历史",
|
||||
@@ -348,25 +348,25 @@ export const dict = {
|
||||
"workspace.usage.subscription": "订阅 (${{amount}})",
|
||||
"workspace.cost.title": "成本",
|
||||
"workspace.cost.subtitle": "按型号细分的使用成本。",
|
||||
"workspace.cost.allModels": "所有型号",
|
||||
"workspace.cost.allKeys": "所有按键",
|
||||
"workspace.cost.allModels": "所有模型",
|
||||
"workspace.cost.allKeys": "所有密钥",
|
||||
"workspace.cost.deletedSuffix": "(已删除)",
|
||||
"workspace.cost.empty": "所选期间没有可用的使用数据。",
|
||||
"workspace.cost.subscriptionShort": "子",
|
||||
"workspace.cost.subscriptionShort": "订",
|
||||
"workspace.keys.title": "API 键",
|
||||
"workspace.keys.subtitle": "管理您的 API 密钥以访问 opencode 服务。",
|
||||
"workspace.keys.create": "创建 API 密钥",
|
||||
"workspace.keys.placeholder": "输入按键名称",
|
||||
"workspace.keys.placeholder": "输入密钥名称",
|
||||
"workspace.keys.empty": "创建 opencode 网关 API 密钥",
|
||||
"workspace.keys.table.name": "姓名",
|
||||
"workspace.keys.table.key": "钥匙",
|
||||
"workspace.keys.table.name": "名称",
|
||||
"workspace.keys.table.key": "密钥",
|
||||
"workspace.keys.table.createdBy": "创建者",
|
||||
"workspace.keys.table.lastUsed": "最后使用",
|
||||
"workspace.keys.copyApiKey": "复制 API 密钥",
|
||||
"workspace.keys.delete": "删除",
|
||||
"workspace.members.title": "会员",
|
||||
"workspace.members.title": "成员",
|
||||
"workspace.members.subtitle": "管理工作区成员及其权限。",
|
||||
"workspace.members.invite": "邀请会员",
|
||||
"workspace.members.invite": "邀请成员",
|
||||
"workspace.members.inviting": "邀请...",
|
||||
"workspace.members.beta.beforeLink": "测试期间,工作空间对团队免费。",
|
||||
"workspace.members.form.invitee": "受邀者",
|
||||
@@ -379,11 +379,11 @@ export const dict = {
|
||||
"workspace.members.edit": "编辑",
|
||||
"workspace.members.delete": "删除",
|
||||
"workspace.members.saving": "保存...",
|
||||
"workspace.members.save": "节省",
|
||||
"workspace.members.save": "保存",
|
||||
"workspace.members.table.email": "电子邮件",
|
||||
"workspace.members.table.role": "角色",
|
||||
"workspace.members.table.monthLimit": "月份限制",
|
||||
"workspace.members.role.admin": "行政",
|
||||
"workspace.members.table.monthLimit": "月限额",
|
||||
"workspace.members.role.admin": "管理员",
|
||||
"workspace.members.role.adminDescription": "可以管理模型、成员和计费",
|
||||
"workspace.members.role.member": "成员",
|
||||
"workspace.members.role.memberDescription": "只能为自己生成 API 密钥",
|
||||
@@ -392,7 +392,7 @@ export const dict = {
|
||||
"workspace.settings.workspaceName": "工作区名称",
|
||||
"workspace.settings.defaultName": "默认",
|
||||
"workspace.settings.updating": "更新中...",
|
||||
"workspace.settings.save": "节省",
|
||||
"workspace.settings.save": "保存",
|
||||
"workspace.settings.edit": "编辑",
|
||||
"workspace.billing.title": "计费",
|
||||
"workspace.billing.subtitle.beforeLink": "管理付款方式。",
|
||||
@@ -404,35 +404,35 @@ export const dict = {
|
||||
"workspace.billing.loading": "加载中...",
|
||||
"workspace.billing.addAction": "添加",
|
||||
"workspace.billing.addBalance": "添加余额",
|
||||
"workspace.billing.linkedToStripe": "链接到条纹",
|
||||
"workspace.billing.linkedToStripe": "已绑定 Stripe",
|
||||
"workspace.billing.manage": "管理",
|
||||
"workspace.billing.enable": "启用计费",
|
||||
"workspace.monthlyLimit.title": "每月限额",
|
||||
"workspace.monthlyLimit.subtitle": "为您的帐户设置每月使用限额。",
|
||||
"workspace.monthlyLimit.placeholder": "50",
|
||||
"workspace.monthlyLimit.setting": "环境...",
|
||||
"workspace.monthlyLimit.set": "放",
|
||||
"workspace.monthlyLimit.setting": "设置中...",
|
||||
"workspace.monthlyLimit.set": "设置",
|
||||
"workspace.monthlyLimit.edit": "编辑限制",
|
||||
"workspace.monthlyLimit.noLimit": "没有设置使用限制。",
|
||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "当前使用情况为",
|
||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "是 $",
|
||||
"workspace.reload.title": "自动重新加载",
|
||||
"workspace.reload.disabled.before": "自动重新加载是",
|
||||
"workspace.reload.disabled.state": "残疾人",
|
||||
"workspace.reload.disabled.after": "启用余额不足时自动充值。",
|
||||
"workspace.reload.enabled.before": "自动重新加载是",
|
||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "当前",
|
||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量为 $",
|
||||
"workspace.reload.title": "自动充值",
|
||||
"workspace.reload.disabled.before": "自动充值已",
|
||||
"workspace.reload.disabled.state": "停用",
|
||||
"workspace.reload.disabled.after": "启用后将在余额较低时自动充值。",
|
||||
"workspace.reload.enabled.before": "自动充值已",
|
||||
"workspace.reload.enabled.state": "已启用",
|
||||
"workspace.reload.enabled.middle": "我们将重新加载",
|
||||
"workspace.reload.processingFee": "加工费",
|
||||
"workspace.reload.enabled.middle": "我们将自动充值",
|
||||
"workspace.reload.processingFee": "手续费",
|
||||
"workspace.reload.enabled.after": "当余额达到",
|
||||
"workspace.reload.edit": "编辑",
|
||||
"workspace.reload.enable": "使能够",
|
||||
"workspace.reload.enableAutoReload": "启用自动重新加载",
|
||||
"workspace.reload.reloadAmount": "重新加载 $",
|
||||
"workspace.reload.enable": "启用",
|
||||
"workspace.reload.enableAutoReload": "启用自动充值",
|
||||
"workspace.reload.reloadAmount": "充值 $",
|
||||
"workspace.reload.whenBalanceReaches": "当余额达到 $",
|
||||
"workspace.reload.saving": "保存...",
|
||||
"workspace.reload.save": "节省",
|
||||
"workspace.reload.failedAt": "重新加载失败于",
|
||||
"workspace.reload.save": "保存",
|
||||
"workspace.reload.failedAt": "充值失败于",
|
||||
"workspace.reload.reason": "原因:",
|
||||
"workspace.reload.updatePaymentMethod": "请更新您的付款方式并重试。",
|
||||
"workspace.reload.retrying": "正在重试...",
|
||||
@@ -441,11 +441,11 @@ export const dict = {
|
||||
"workspace.payments.subtitle": "最近的付款交易。",
|
||||
"workspace.payments.table.date": "日期",
|
||||
"workspace.payments.table.paymentId": "付款ID",
|
||||
"workspace.payments.table.amount": "数量",
|
||||
"workspace.payments.table.amount": "金额",
|
||||
"workspace.payments.table.receipt": "收据",
|
||||
"workspace.payments.type.credit": "信用",
|
||||
"workspace.payments.type.subscription": "订阅",
|
||||
"workspace.payments.view": "看法",
|
||||
"workspace.payments.view": "查看",
|
||||
"workspace.black.loading": "加载中...",
|
||||
"workspace.black.time.day": "天",
|
||||
"workspace.black.time.days": "天",
|
||||
@@ -455,20 +455,20 @@ export const dict = {
|
||||
"workspace.black.time.minutes": "分钟",
|
||||
"workspace.black.time.fewSeconds": "几秒钟",
|
||||
"workspace.black.subscription.title": "订阅",
|
||||
"workspace.black.subscription.message": "您已订阅 OpenCode Black,每月费用为 {{plan}} 美元。",
|
||||
"workspace.black.subscription.message": "您已订阅 OpenCode Black,费用为每月 ${{plan}}。",
|
||||
"workspace.black.subscription.manage": "管理订阅",
|
||||
"workspace.black.subscription.rollingUsage": "5小时使用",
|
||||
"workspace.black.subscription.weeklyUsage": "每周使用量",
|
||||
"workspace.black.subscription.resetsIn": "重置于",
|
||||
"workspace.black.subscription.useBalance": "达到使用限额后使用您的可用余额",
|
||||
"workspace.black.waitlist.title": "候补名单",
|
||||
"workspace.black.waitlist.joined": "您正在等待每月 ${{plan}} OpenCode 黑色计划。",
|
||||
"workspace.black.waitlist.ready": "我们已准备好让您加入每月 {{plan}} 美元的 OpenCode 黑色计划。",
|
||||
"workspace.black.waitlist.joined": "您已加入每月 ${{plan}} 的 OpenCode Black 方案候补名单。",
|
||||
"workspace.black.waitlist.ready": "我们已准备好将您加入每月 ${{plan}} 的 OpenCode Black 方案。",
|
||||
"workspace.black.waitlist.leave": "离开候补名单",
|
||||
"workspace.black.waitlist.leaving": "离开...",
|
||||
"workspace.black.waitlist.left": "左边",
|
||||
"workspace.black.waitlist.enroll": "注册",
|
||||
"workspace.black.waitlist.enrolling": "正在报名...",
|
||||
"workspace.black.waitlist.enrolled": "已注册",
|
||||
"workspace.black.waitlist.left": "已退出",
|
||||
"workspace.black.waitlist.enroll": "加入",
|
||||
"workspace.black.waitlist.enrolling": "加入中...",
|
||||
"workspace.black.waitlist.enrolled": "已加入",
|
||||
"workspace.black.waitlist.enrollNote": "单击“注册”后,您的订阅将立即开始,并且将从您的卡中扣费。",
|
||||
} satisfies Dict
|
||||
|
||||
@@ -293,9 +293,9 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "OpenCode \u7684\u65b0\u66f4\u65b0\u8207\u6539\u5584",
|
||||
"changelog.empty": "\u627e\u4e0d\u5230\u66f4\u65b0\u65e5\u8a8c\u9805\u76ee\u3002",
|
||||
"changelog.viewJson": "\u6aa2\u8996 JSON",
|
||||
"workspace.nav.zen": "禪",
|
||||
"workspace.nav.zen": "Zen",
|
||||
"workspace.nav.apiKeys": "API 鍵",
|
||||
"workspace.nav.members": "會員",
|
||||
"workspace.nav.members": "成員",
|
||||
"workspace.nav.billing": "計費",
|
||||
"workspace.nav.settings": "設定",
|
||||
"workspace.home.banner.beforeLink": "編碼代理的可靠優化模型。",
|
||||
@@ -310,26 +310,26 @@ export const dict = {
|
||||
"workspace.newUser.feature.lockin.body":
|
||||
"將 Zen 與任何編碼代理結合使用,並在需要時繼續將其他提供程序與 opencode 結合使用。",
|
||||
"workspace.newUser.copyApiKey": "複製 API 密鑰",
|
||||
"workspace.newUser.copyKey": "複製鑰匙",
|
||||
"workspace.newUser.copied": "複製了!",
|
||||
"workspace.newUser.copyKey": "複製密鑰",
|
||||
"workspace.newUser.copied": "已複製!",
|
||||
"workspace.newUser.step.enableBilling": "啟用計費",
|
||||
"workspace.newUser.step.login.before": "跑步",
|
||||
"workspace.newUser.step.login.before": "執行",
|
||||
"workspace.newUser.step.login.after": "並選擇 opencode",
|
||||
"workspace.newUser.step.pasteKey": "粘貼您的 API 密鑰",
|
||||
"workspace.newUser.step.models.before": "啟動 opencode 並運行",
|
||||
"workspace.newUser.step.models.after": "選擇型號",
|
||||
"workspace.models.title": "型號",
|
||||
"workspace.newUser.step.models.after": "選擇模型",
|
||||
"workspace.models.title": "模型",
|
||||
"workspace.models.subtitle.beforeLink": "管理工作區成員可以訪問哪些模型。",
|
||||
"workspace.models.table.model": "模型",
|
||||
"workspace.models.table.enabled": "啟用",
|
||||
"workspace.providers.title": "帶上你自己的鑰匙",
|
||||
"workspace.providers.title": "自帶密鑰",
|
||||
"workspace.providers.subtitle": "從 AI 提供商處配置您自己的 API 密鑰。",
|
||||
"workspace.providers.placeholder": "輸入 {{provider}} API 密鑰({{prefix}}...)",
|
||||
"workspace.providers.configure": "配置",
|
||||
"workspace.providers.edit": "編輯",
|
||||
"workspace.providers.delete": "刪除",
|
||||
"workspace.providers.saving": "保存...",
|
||||
"workspace.providers.save": "節省",
|
||||
"workspace.providers.save": "儲存",
|
||||
"workspace.providers.table.provider": "提供者",
|
||||
"workspace.providers.table.apiKey": "API 密鑰",
|
||||
"workspace.usage.title": "使用歷史",
|
||||
@@ -348,25 +348,25 @@ export const dict = {
|
||||
"workspace.usage.subscription": "訂閱 (${{amount}})",
|
||||
"workspace.cost.title": "成本",
|
||||
"workspace.cost.subtitle": "按型號細分的使用成本。",
|
||||
"workspace.cost.allModels": "所有型號",
|
||||
"workspace.cost.allKeys": "所有按鍵",
|
||||
"workspace.cost.allModels": "所有模型",
|
||||
"workspace.cost.allKeys": "所有密鑰",
|
||||
"workspace.cost.deletedSuffix": "(已刪除)",
|
||||
"workspace.cost.empty": "所選期間沒有可用的使用數據。",
|
||||
"workspace.cost.subscriptionShort": "子",
|
||||
"workspace.cost.subscriptionShort": "訂",
|
||||
"workspace.keys.title": "API 鍵",
|
||||
"workspace.keys.subtitle": "管理您的 API 密鑰以訪問 opencode 服務。",
|
||||
"workspace.keys.create": "創建 API 密鑰",
|
||||
"workspace.keys.placeholder": "輸入按鍵名稱",
|
||||
"workspace.keys.placeholder": "輸入密鑰名稱",
|
||||
"workspace.keys.empty": "創建 opencode 網關 API 密鑰",
|
||||
"workspace.keys.table.name": "姓名",
|
||||
"workspace.keys.table.key": "鑰匙",
|
||||
"workspace.keys.table.name": "名稱",
|
||||
"workspace.keys.table.key": "密鑰",
|
||||
"workspace.keys.table.createdBy": "創建者",
|
||||
"workspace.keys.table.lastUsed": "最後使用",
|
||||
"workspace.keys.copyApiKey": "複製 API 密鑰",
|
||||
"workspace.keys.delete": "刪除",
|
||||
"workspace.members.title": "會員",
|
||||
"workspace.members.title": "成員",
|
||||
"workspace.members.subtitle": "管理工作區成員及其權限。",
|
||||
"workspace.members.invite": "邀請會員",
|
||||
"workspace.members.invite": "邀請成員",
|
||||
"workspace.members.inviting": "邀請...",
|
||||
"workspace.members.beta.beforeLink": "測試期間,工作空間對團隊免費。",
|
||||
"workspace.members.form.invitee": "受邀者",
|
||||
@@ -379,11 +379,11 @@ export const dict = {
|
||||
"workspace.members.edit": "編輯",
|
||||
"workspace.members.delete": "刪除",
|
||||
"workspace.members.saving": "保存...",
|
||||
"workspace.members.save": "節省",
|
||||
"workspace.members.save": "儲存",
|
||||
"workspace.members.table.email": "電子郵件",
|
||||
"workspace.members.table.role": "角色",
|
||||
"workspace.members.table.monthLimit": "月份限制",
|
||||
"workspace.members.role.admin": "行政",
|
||||
"workspace.members.table.monthLimit": "月限額",
|
||||
"workspace.members.role.admin": "管理員",
|
||||
"workspace.members.role.adminDescription": "可以管理模型、成員和計費",
|
||||
"workspace.members.role.member": "成員",
|
||||
"workspace.members.role.memberDescription": "只能為自己生成 API 密鑰",
|
||||
@@ -392,7 +392,7 @@ export const dict = {
|
||||
"workspace.settings.workspaceName": "工作區名稱",
|
||||
"workspace.settings.defaultName": "預設",
|
||||
"workspace.settings.updating": "更新中...",
|
||||
"workspace.settings.save": "節省",
|
||||
"workspace.settings.save": "儲存",
|
||||
"workspace.settings.edit": "編輯",
|
||||
"workspace.billing.title": "計費",
|
||||
"workspace.billing.subtitle.beforeLink": "管理付款方式。",
|
||||
@@ -404,35 +404,35 @@ export const dict = {
|
||||
"workspace.billing.loading": "載入中...",
|
||||
"workspace.billing.addAction": "添加",
|
||||
"workspace.billing.addBalance": "添加餘額",
|
||||
"workspace.billing.linkedToStripe": "鏈接到條紋",
|
||||
"workspace.billing.linkedToStripe": "已連結 Stripe",
|
||||
"workspace.billing.manage": "管理",
|
||||
"workspace.billing.enable": "啟用計費",
|
||||
"workspace.monthlyLimit.title": "每月限額",
|
||||
"workspace.monthlyLimit.subtitle": "為您的帳戶設置每月使用限額。",
|
||||
"workspace.monthlyLimit.placeholder": "50",
|
||||
"workspace.monthlyLimit.setting": "環境...",
|
||||
"workspace.monthlyLimit.set": "放",
|
||||
"workspace.monthlyLimit.setting": "設定中...",
|
||||
"workspace.monthlyLimit.set": "設定",
|
||||
"workspace.monthlyLimit.edit": "編輯限制",
|
||||
"workspace.monthlyLimit.noLimit": "沒有設置使用限制。",
|
||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "當前使用情況為",
|
||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "是 $",
|
||||
"workspace.reload.title": "自動重新加載",
|
||||
"workspace.reload.disabled.before": "自動重新加載是",
|
||||
"workspace.reload.disabled.state": "殘疾人",
|
||||
"workspace.reload.disabled.after": "啟用餘額不足時自動充值。",
|
||||
"workspace.reload.enabled.before": "自動重新加載是",
|
||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "當前",
|
||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量為 $",
|
||||
"workspace.reload.title": "自動儲值",
|
||||
"workspace.reload.disabled.before": "自動儲值已",
|
||||
"workspace.reload.disabled.state": "停用",
|
||||
"workspace.reload.disabled.after": "啟用後會在餘額偏低時自動儲值。",
|
||||
"workspace.reload.enabled.before": "自動儲值已",
|
||||
"workspace.reload.enabled.state": "已啟用",
|
||||
"workspace.reload.enabled.middle": "我們將重新加載",
|
||||
"workspace.reload.processingFee": "加工費",
|
||||
"workspace.reload.enabled.middle": "我們將自動儲值",
|
||||
"workspace.reload.processingFee": "手續費",
|
||||
"workspace.reload.enabled.after": "當餘額達到",
|
||||
"workspace.reload.edit": "編輯",
|
||||
"workspace.reload.enable": "使能夠",
|
||||
"workspace.reload.enableAutoReload": "啟用自動重新加載",
|
||||
"workspace.reload.reloadAmount": "重新加載 $",
|
||||
"workspace.reload.enable": "啟用",
|
||||
"workspace.reload.enableAutoReload": "啟用自動儲值",
|
||||
"workspace.reload.reloadAmount": "儲值 $",
|
||||
"workspace.reload.whenBalanceReaches": "當餘額達到 $",
|
||||
"workspace.reload.saving": "保存...",
|
||||
"workspace.reload.save": "節省",
|
||||
"workspace.reload.failedAt": "重新加載失敗於",
|
||||
"workspace.reload.save": "儲存",
|
||||
"workspace.reload.failedAt": "儲值失敗於",
|
||||
"workspace.reload.reason": "原因:",
|
||||
"workspace.reload.updatePaymentMethod": "請更新您的付款方式並重試。",
|
||||
"workspace.reload.retrying": "正在重試...",
|
||||
@@ -441,11 +441,11 @@ export const dict = {
|
||||
"workspace.payments.subtitle": "最近的付款交易。",
|
||||
"workspace.payments.table.date": "日期",
|
||||
"workspace.payments.table.paymentId": "付款ID",
|
||||
"workspace.payments.table.amount": "數量",
|
||||
"workspace.payments.table.amount": "金額",
|
||||
"workspace.payments.table.receipt": "收據",
|
||||
"workspace.payments.type.credit": "信用",
|
||||
"workspace.payments.type.subscription": "訂閱",
|
||||
"workspace.payments.view": "看法",
|
||||
"workspace.payments.view": "查看",
|
||||
"workspace.black.loading": "載入中...",
|
||||
"workspace.black.time.day": "天",
|
||||
"workspace.black.time.days": "天",
|
||||
@@ -455,20 +455,20 @@ export const dict = {
|
||||
"workspace.black.time.minutes": "分鐘",
|
||||
"workspace.black.time.fewSeconds": "幾秒鐘",
|
||||
"workspace.black.subscription.title": "訂閱",
|
||||
"workspace.black.subscription.message": "您已訂閱 OpenCode Black,每月費用為 {{plan}} 美元。",
|
||||
"workspace.black.subscription.message": "您已訂閱 OpenCode Black,費用為每月 ${{plan}}。",
|
||||
"workspace.black.subscription.manage": "管理訂閱",
|
||||
"workspace.black.subscription.rollingUsage": "5小時使用",
|
||||
"workspace.black.subscription.weeklyUsage": "每週使用量",
|
||||
"workspace.black.subscription.resetsIn": "重置於",
|
||||
"workspace.black.subscription.useBalance": "達到使用限額後使用您的可用餘額",
|
||||
"workspace.black.waitlist.title": "候補名單",
|
||||
"workspace.black.waitlist.joined": "您正在等待每月 ${{plan}} OpenCode 黑色計劃。",
|
||||
"workspace.black.waitlist.ready": "我們已準備好讓您加入每月 {{plan}} 美元的 OpenCode 黑色計劃。",
|
||||
"workspace.black.waitlist.joined": "您已加入每月 ${{plan}} 的 OpenCode Black 方案候補名單。",
|
||||
"workspace.black.waitlist.ready": "我們已準備好將您加入每月 ${{plan}} 的 OpenCode Black 方案。",
|
||||
"workspace.black.waitlist.leave": "離開候補名單",
|
||||
"workspace.black.waitlist.leaving": "離開...",
|
||||
"workspace.black.waitlist.left": "左邊",
|
||||
"workspace.black.waitlist.enroll": "註冊",
|
||||
"workspace.black.waitlist.enrolling": "正在報名...",
|
||||
"workspace.black.waitlist.enrolled": "已註冊",
|
||||
"workspace.black.waitlist.left": "已退出",
|
||||
"workspace.black.waitlist.enroll": "加入",
|
||||
"workspace.black.waitlist.enrolling": "加入中...",
|
||||
"workspace.black.waitlist.enrolled": "已加入",
|
||||
"workspace.black.waitlist.enrollNote": "單擊“註冊”後,您的訂閱將立即開始,並且將從您的卡中扣費。",
|
||||
} satisfies Dict
|
||||
|
||||
@@ -68,6 +68,82 @@ const TAG = {
|
||||
tr: "tr",
|
||||
} satisfies Record<Locale, string>
|
||||
|
||||
const DOCS = {
|
||||
en: "root",
|
||||
zh: "zh-cn",
|
||||
zht: "zh-tw",
|
||||
ko: "ko",
|
||||
de: "de",
|
||||
es: "es",
|
||||
fr: "fr",
|
||||
it: "it",
|
||||
da: "da",
|
||||
ja: "ja",
|
||||
pl: "pl",
|
||||
ru: "ru",
|
||||
ar: "ar",
|
||||
no: "nb",
|
||||
br: "pt-br",
|
||||
th: "th",
|
||||
tr: "tr",
|
||||
} satisfies Record<Locale, string>
|
||||
|
||||
const DOCS_SEGMENT = new Set([
|
||||
"ar",
|
||||
"bs",
|
||||
"da",
|
||||
"de",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"nb",
|
||||
"pl",
|
||||
"pt-br",
|
||||
"ru",
|
||||
"th",
|
||||
"tr",
|
||||
"zh-cn",
|
||||
"zh-tw",
|
||||
])
|
||||
|
||||
function suffix(pathname: string) {
|
||||
const index = pathname.search(/[?#]/)
|
||||
if (index === -1) {
|
||||
return {
|
||||
path: fix(pathname),
|
||||
suffix: "",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: fix(pathname.slice(0, index)),
|
||||
suffix: pathname.slice(index),
|
||||
}
|
||||
}
|
||||
|
||||
export function docs(locale: Locale, pathname: string) {
|
||||
const value = DOCS[locale]
|
||||
const next = suffix(pathname)
|
||||
if (next.path !== "/docs" && next.path !== "/docs/" && !next.path.startsWith("/docs/")) {
|
||||
return `${next.path}${next.suffix}`
|
||||
}
|
||||
|
||||
if (value === "root") return `${next.path}${next.suffix}`
|
||||
|
||||
if (next.path === "/docs") return `/docs/${value}${next.suffix}`
|
||||
if (next.path === "/docs/") return `/docs/${value}/${next.suffix}`
|
||||
|
||||
const head = next.path.slice("/docs/".length).split("/")[0] ?? ""
|
||||
if (!head) return `/docs/${value}/${next.suffix}`
|
||||
if (DOCS_SEGMENT.has(head)) return `${next.path}${next.suffix}`
|
||||
if (head.startsWith("_")) return `${next.path}${next.suffix}`
|
||||
if (head.includes(".")) return `${next.path}${next.suffix}`
|
||||
|
||||
return `/docs/${value}${next.path.slice("/docs".length)}${next.suffix}`
|
||||
}
|
||||
|
||||
export function parseLocale(value: unknown): Locale | null {
|
||||
if (typeof value !== "string") return null
|
||||
if ((LOCALES as readonly string[]).includes(value)) return value as Locale
|
||||
@@ -90,7 +166,7 @@ export function strip(pathname: string) {
|
||||
|
||||
export function route(locale: Locale, pathname: string) {
|
||||
const next = strip(pathname)
|
||||
if (next.startsWith("/docs")) return next
|
||||
if (next.startsWith("/docs")) return docs(locale, next)
|
||||
if (next.startsWith("/auth")) return next
|
||||
if (next.startsWith("/workspace")) return next
|
||||
if (locale === "en") return next
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { LOCALE_HEADER, localeFromCookieHeader, parseLocale, tag } from "~/lib/language"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { docs, localeFromRequest, tag } from "~/lib/language"
|
||||
|
||||
async function handler(evt: APIEvent) {
|
||||
const req = evt.request.clone()
|
||||
const url = new URL(req.url)
|
||||
const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}`
|
||||
const locale = localeFromRequest(req)
|
||||
const host = Resource.App.stage === "production" ? "docs.opencode.ai" : "docs.dev.opencode.ai"
|
||||
const targetUrl = `https://${host}${docs(locale, url.pathname)}${url.search}`
|
||||
|
||||
const headers = new Headers(req.headers)
|
||||
const locale = parseLocale(req.headers.get(LOCALE_HEADER)) ?? localeFromCookieHeader(req.headers.get("cookie"))
|
||||
if (locale) headers.set("accept-language", tag(locale))
|
||||
headers.set("accept-language", tag(locale))
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { LOCALE_HEADER, localeFromCookieHeader, parseLocale, tag } from "~/lib/language"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { docs, localeFromRequest, tag } from "~/lib/language"
|
||||
|
||||
async function handler(evt: APIEvent) {
|
||||
const req = evt.request.clone()
|
||||
const url = new URL(req.url)
|
||||
const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}`
|
||||
const locale = localeFromRequest(req)
|
||||
const host = Resource.App.stage === "production" ? "docs.opencode.ai" : "docs.dev.opencode.ai"
|
||||
const targetUrl = `https://${host}${docs(locale, url.pathname)}${url.search}`
|
||||
|
||||
const headers = new Headers(req.headers)
|
||||
const locale = parseLocale(req.headers.get(LOCALE_HEADER)) ?? localeFromCookieHeader(req.headers.get("cookie"))
|
||||
if (locale) headers.set("accept-language", tag(locale))
|
||||
headers.set("accept-language", tag(locale))
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
|
||||
@@ -294,7 +294,7 @@ export default function Download() {
|
||||
</span>
|
||||
<span>VS Code</span>
|
||||
</div>
|
||||
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
|
||||
<a href={language.route("/docs/ide/")} data-component="action-button">
|
||||
{i18n.t("download.action.install")}
|
||||
</a>
|
||||
</div>
|
||||
@@ -318,7 +318,7 @@ export default function Download() {
|
||||
</span>
|
||||
<span>Cursor</span>
|
||||
</div>
|
||||
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
|
||||
<a href={language.route("/docs/ide/")} data-component="action-button">
|
||||
{i18n.t("download.action.install")}
|
||||
</a>
|
||||
</div>
|
||||
@@ -335,7 +335,7 @@ export default function Download() {
|
||||
</span>
|
||||
<span>Zed</span>
|
||||
</div>
|
||||
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
|
||||
<a href={language.route("/docs/ide/")} data-component="action-button">
|
||||
{i18n.t("download.action.install")}
|
||||
</a>
|
||||
</div>
|
||||
@@ -352,7 +352,7 @@ export default function Download() {
|
||||
</span>
|
||||
<span>Windsurf</span>
|
||||
</div>
|
||||
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
|
||||
<a href={language.route("/docs/ide/")} data-component="action-button">
|
||||
{i18n.t("download.action.install")}
|
||||
</a>
|
||||
</div>
|
||||
@@ -369,7 +369,7 @@ export default function Download() {
|
||||
</span>
|
||||
<span>VSCodium</span>
|
||||
</div>
|
||||
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
|
||||
<a href={language.route("/docs/ide/")} data-component="action-button">
|
||||
{i18n.t("download.action.install")}
|
||||
</a>
|
||||
</div>
|
||||
@@ -393,7 +393,7 @@ export default function Download() {
|
||||
</span>
|
||||
<span>GitHub</span>
|
||||
</div>
|
||||
<a href="https://opencode.ai/docs/github/" data-component="action-button">
|
||||
<a href={language.route("/docs/github/")} data-component="action-button">
|
||||
{i18n.t("download.action.install")}
|
||||
</a>
|
||||
</div>
|
||||
@@ -410,7 +410,7 @@ export default function Download() {
|
||||
</span>
|
||||
<span>GitLab</span>
|
||||
</div>
|
||||
<a href="https://opencode.ai/docs/gitlab/" data-component="action-button">
|
||||
<a href={language.route("/docs/gitlab/")} data-component="action-button">
|
||||
{i18n.t("download.action.install")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { LOCALE_HEADER, localeFromCookieHeader, parseLocale, tag } from "~/lib/language"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { docs, localeFromRequest, tag } from "~/lib/language"
|
||||
|
||||
async function handler(evt: APIEvent) {
|
||||
const req = evt.request.clone()
|
||||
const url = new URL(req.url)
|
||||
const targetUrl = `https://docs.opencode.ai/docs${url.pathname}${url.search}`
|
||||
const locale = localeFromRequest(req)
|
||||
const host = Resource.App.stage === "production" ? "docs.opencode.ai" : "docs.dev.opencode.ai"
|
||||
const targetUrl = `https://${host}${docs(locale, `/docs${url.pathname}`)}${url.search}`
|
||||
|
||||
const headers = new Headers(req.headers)
|
||||
const locale = parseLocale(req.headers.get(LOCALE_HEADER)) ?? localeFromCookieHeader(req.headers.get("cookie"))
|
||||
if (locale) headers.set("accept-language", tag(locale))
|
||||
headers.set("accept-language", tag(locale))
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
|
||||
@@ -9,10 +9,12 @@ import { GraphSection } from "./graph-section"
|
||||
import { IconLogo } from "~/component/icon"
|
||||
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
|
||||
export default function () {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const userInfo = createAsync(() => querySessionInfo(params.id!))
|
||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
||||
const checkoutAction = useAction(createCheckoutUrl)
|
||||
@@ -38,7 +40,7 @@ export default function () {
|
||||
<p>
|
||||
<span>
|
||||
{i18n.t("workspace.home.banner.beforeLink")}{" "}
|
||||
<a target="_blank" href="/docs/zen">
|
||||
<a target="_blank" href={language.route("/docs/zen")}>
|
||||
{i18n.t("common.learnMore")}
|
||||
</a>
|
||||
.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export class AuthError extends Error {}
|
||||
export class CreditsError extends Error {}
|
||||
export class MonthlyLimitError extends Error {}
|
||||
export class SubscriptionError extends Error {
|
||||
export class UserLimitError extends Error {}
|
||||
export class ModelError extends Error {}
|
||||
export class FreeUsageLimitError extends Error {}
|
||||
export class SubscriptionUsageLimitError extends Error {
|
||||
retryAfter?: number
|
||||
constructor(message: string, retryAfter?: number) {
|
||||
super(message)
|
||||
this.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
export class UserLimitError extends Error {}
|
||||
export class ModelError extends Error {}
|
||||
export class RateLimitError extends Error {}
|
||||
|
||||
@@ -18,10 +18,10 @@ import {
|
||||
AuthError,
|
||||
CreditsError,
|
||||
MonthlyLimitError,
|
||||
SubscriptionError,
|
||||
UserLimitError,
|
||||
ModelError,
|
||||
RateLimitError,
|
||||
FreeUsageLimitError,
|
||||
SubscriptionUsageLimitError,
|
||||
} from "./error"
|
||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
||||
import { anthropicHelper } from "./provider/anthropic"
|
||||
@@ -52,7 +52,8 @@ export async function handler(
|
||||
type ModelInfo = Awaited<ReturnType<typeof validateModel>>
|
||||
type ProviderInfo = Awaited<ReturnType<typeof selectProvider>>
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const MAX_FAILOVER_RETRIES = 3
|
||||
const MAX_429_RETRIES = 3
|
||||
const FREE_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
|
||||
@@ -111,7 +112,7 @@ export async function handler(
|
||||
)
|
||||
logger.debug("REQUEST URL: " + reqUrl)
|
||||
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
|
||||
const res = await fetch(reqUrl, {
|
||||
const res = await fetchWith429Retry(reqUrl, {
|
||||
method: "POST",
|
||||
headers: (() => {
|
||||
const headers = new Headers(input.request.headers)
|
||||
@@ -119,6 +120,9 @@ export async function handler(
|
||||
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
||||
headers.set(k, headers.get(v)!)
|
||||
})
|
||||
Object.entries(providerInfo.headers ?? {}).forEach(([k, v]) => {
|
||||
headers.set(k, v)
|
||||
})
|
||||
headers.delete("host")
|
||||
headers.delete("content-length")
|
||||
headers.delete("x-opencode-request")
|
||||
@@ -250,13 +254,18 @@ export async function handler(
|
||||
part = part.trim()
|
||||
usageParser.parse(part)
|
||||
|
||||
if (providerInfo.format !== opts.format) {
|
||||
if (providerInfo.bodyModifier) {
|
||||
for (const [k, v] of Object.entries(providerInfo.bodyModifier)) {
|
||||
part = part.replace(k, v)
|
||||
}
|
||||
c.enqueue(encoder.encode(part + "\n\n"))
|
||||
} else if (providerInfo.format !== opts.format) {
|
||||
part = streamConverter(part)
|
||||
c.enqueue(encoder.encode(part + "\n\n"))
|
||||
}
|
||||
}
|
||||
|
||||
if (providerInfo.format === opts.format) {
|
||||
if (!providerInfo.bodyModifier && providerInfo.format === opts.format) {
|
||||
c.enqueue(value)
|
||||
}
|
||||
|
||||
@@ -296,9 +305,9 @@ export async function handler(
|
||||
{ status: 401 },
|
||||
)
|
||||
|
||||
if (error instanceof RateLimitError || error instanceof SubscriptionError) {
|
||||
if (error instanceof FreeUsageLimitError || error instanceof SubscriptionUsageLimitError) {
|
||||
const headers = new Headers()
|
||||
if (error instanceof SubscriptionError && error.retryAfter) {
|
||||
if (error instanceof SubscriptionUsageLimitError && error.retryAfter) {
|
||||
headers.set("retry-after", String(error.retryAfter))
|
||||
}
|
||||
return new Response(
|
||||
@@ -361,7 +370,7 @@ export async function handler(
|
||||
if (provider) return provider
|
||||
}
|
||||
|
||||
if (retry.retryCount === MAX_RETRIES) {
|
||||
if (retry.retryCount === MAX_FAILOVER_RETRIES) {
|
||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
|
||||
}
|
||||
|
||||
@@ -512,7 +521,7 @@ export async function handler(
|
||||
timeUpdated: sub.timeFixedUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionError(
|
||||
throw new SubscriptionUsageLimitError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
@@ -526,7 +535,7 @@ export async function handler(
|
||||
timeUpdated: sub.timeRollingUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionError(
|
||||
throw new SubscriptionUsageLimitError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
@@ -589,6 +598,15 @@ export async function handler(
|
||||
providerInfo.apiKey = authInfo.provider.credentials
|
||||
}
|
||||
|
||||
async function fetchWith429Retry(url: string, options: RequestInit, retry = { count: 0 }) {
|
||||
const res = await fetch(url, options)
|
||||
if (res.status === 429 && retry.count < MAX_429_RETRIES) {
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, retry.count) * 500))
|
||||
return fetchWith429Retry(url, options, { count: retry.count + 1 })
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async function trackUsage(
|
||||
authInfo: AuthInfo,
|
||||
modelInfo: ModelInfo,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { RateLimitError } from "./error"
|
||||
import { FreeUsageLimitError } from "./error"
|
||||
import { logger } from "./logger"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
|
||||
@@ -34,7 +34,7 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s
|
||||
)
|
||||
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
||||
logger.debug(`rate limit total: ${total}`)
|
||||
if (total >= limitValue) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
|
||||
if (total >= limitValue) throw new FreeUsageLimitError(`Rate limit exceeded. Please try again later.`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const stage = process.argv[2]
|
||||
if (!stage) throw new Error("Stage is required")
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const PARTS = 10
|
||||
const PARTS = 20
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||
@@ -29,5 +29,5 @@ ZenData.validate(JSON.parse(values.join("")))
|
||||
|
||||
// update the secret
|
||||
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
|
||||
await envFile.write(values.map((v, i) => `ZEN_MODELS${i + 1}=${v}`).join("\n"))
|
||||
await envFile.write(values.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
|
||||
await $`bun sst secret load ${envFile.name} --stage ${stage}`.cwd(root)
|
||||
|
||||
@@ -9,7 +9,7 @@ const stage = process.argv[2]
|
||||
if (!stage) throw new Error("Stage is required")
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const PARTS = 10
|
||||
const PARTS = 20
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list --stage ${stage}`.cwd(root).text()
|
||||
@@ -29,5 +29,5 @@ ZenData.validate(JSON.parse(values.join("")))
|
||||
|
||||
// update the secret
|
||||
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
|
||||
await envFile.write(values.map((v, i) => `ZEN_MODELS${i + 1}=${v}`).join("\n"))
|
||||
await envFile.write(values.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
|
||||
await $`bun sst secret load ${envFile.name}`.cwd(root)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ZenData } from "../src/model"
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const models = await $`bun sst secret list`.cwd(root).text()
|
||||
const PARTS = 10
|
||||
const PARTS = 20
|
||||
|
||||
// read the line starting with "ZEN_MODELS"
|
||||
const lines = models.split("\n")
|
||||
@@ -39,5 +39,5 @@ const newValues = Array.from({ length: PARTS }, (_, i) =>
|
||||
)
|
||||
|
||||
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
|
||||
await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}=${v}`).join("\n"))
|
||||
await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
|
||||
await $`bun sst secret load ${envFile.name}`.cwd(root)
|
||||
|
||||
@@ -53,6 +53,8 @@ export namespace ZenData {
|
||||
weight: z.number().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
storeModel: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
bodyModifier: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
@@ -84,7 +86,17 @@ export namespace ZenData {
|
||||
Resource.ZEN_MODELS7.value +
|
||||
Resource.ZEN_MODELS8.value +
|
||||
Resource.ZEN_MODELS9.value +
|
||||
Resource.ZEN_MODELS10.value,
|
||||
Resource.ZEN_MODELS10.value +
|
||||
Resource.ZEN_MODELS11.value +
|
||||
Resource.ZEN_MODELS12.value +
|
||||
Resource.ZEN_MODELS13.value +
|
||||
Resource.ZEN_MODELS14.value +
|
||||
Resource.ZEN_MODELS15.value +
|
||||
Resource.ZEN_MODELS16.value +
|
||||
Resource.ZEN_MODELS17.value +
|
||||
Resource.ZEN_MODELS18.value +
|
||||
Resource.ZEN_MODELS19.value +
|
||||
Resource.ZEN_MODELS20.value,
|
||||
)
|
||||
return ModelsSchema.parse(json)
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user