Merge branch 'dev' into sqlite2
This commit is contained in:
commit
0c1c07467e
176 changed files with 5510 additions and 1726 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ async function waitForHealth(url: string) {
|
|||
const appDir = process.cwd()
|
||||
const repoDir = path.resolve(appDir, "../..")
|
||||
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
||||
const modelsJson = path.join(opencodeDir, "test", "tool", "fixtures", "models-api.json")
|
||||
|
||||
const extraArgs = (() => {
|
||||
const args = process.argv.slice(2)
|
||||
|
|
@ -59,8 +58,6 @@ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
|||
|
||||
const serverEnv = {
|
||||
...process.env,
|
||||
MODELS_DEV_API_JSON: modelsJson,
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true",
|
||||
OPENCODE_DISABLE_SHARE: "true",
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||
|
|
|
|||
|
|
@ -35,9 +35,10 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||
|
||||
return (
|
||||
<Dialog title={language.t("dialog.model.select.title")}>
|
||||
<div class="flex flex-col gap-3 px-2.5">
|
||||
<div class="flex flex-col gap-3 px-2.5 flex-1 min-h-0">
|
||||
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
||||
<List
|
||||
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
|
||||
ref={(ref) => (listRef = ref)}
|
||||
items={local.model.list}
|
||||
current={local.model.current()}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||
import { Component, ComponentProps, createMemo, createSignal, JSX, Show, ValidComponent } from "solid-js"
|
||||
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
|
|
@ -92,26 +93,118 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
|||
triggerAs?: T
|
||||
triggerProps?: ComponentProps<T>
|
||||
}) {
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [store, setStore] = createStore<{
|
||||
open: boolean
|
||||
dismiss: "escape" | "outside" | null
|
||||
trigger?: HTMLElement
|
||||
content?: HTMLElement
|
||||
}>({
|
||||
open: false,
|
||||
dismiss: null,
|
||||
trigger: undefined,
|
||||
content: undefined,
|
||||
})
|
||||
const dialog = useDialog()
|
||||
|
||||
const handleManage = () => {
|
||||
setOpen(false)
|
||||
setStore("open", false)
|
||||
dialog.show(() => <DialogManageModels />)
|
||||
}
|
||||
const language = useLanguage()
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.open) return
|
||||
|
||||
const inside = (node: Node | null | undefined) => {
|
||||
if (!node) return false
|
||||
const el = store.content
|
||||
if (el && el.contains(node)) return true
|
||||
const anchor = store.trigger
|
||||
if (anchor && anchor.contains(node)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return
|
||||
setStore("dismiss", "escape")
|
||||
setStore("open", false)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (inside(target)) return
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}
|
||||
|
||||
const onFocusIn = (event: FocusEvent) => {
|
||||
if (!store.content) return
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (inside(target)) return
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, true)
|
||||
window.addEventListener("pointerdown", onPointerDown, true)
|
||||
window.addEventListener("focusin", onFocusIn, true)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", onKeyDown, true)
|
||||
window.removeEventListener("pointerdown", onPointerDown, true)
|
||||
window.removeEventListener("focusin", onFocusIn, true)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
||||
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...(props.triggerProps as any)}>
|
||||
<Kobalte
|
||||
open={store.open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) setStore("dismiss", null)
|
||||
setStore("open", next)
|
||||
}}
|
||||
modal={false}
|
||||
placement="top-start"
|
||||
gutter={8}
|
||||
>
|
||||
<Kobalte.Trigger
|
||||
ref={(el) => setStore("trigger", el)}
|
||||
as={props.triggerAs ?? "div"}
|
||||
{...(props.triggerProps as any)}
|
||||
>
|
||||
{props.children}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
|
||||
<Kobalte.Content
|
||||
ref={(el) => setStore("content", el)}
|
||||
class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||
onEscapeKeyDown={(event) => {
|
||||
setStore("dismiss", "escape")
|
||||
setStore("open", false)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerDownOutside={() => {
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}}
|
||||
onFocusOutside={() => {
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (store.dismiss === "outside") event.preventDefault()
|
||||
setStore("dismiss", null)
|
||||
}}
|
||||
>
|
||||
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
|
||||
<ModelList
|
||||
provider={props.provider}
|
||||
onSelect={() => setOpen(false)}
|
||||
onSelect={() => setStore("open", false)}
|
||||
class="p-1"
|
||||
action={
|
||||
<IconButton
|
||||
|
|
|
|||
|
|
@ -1,23 +1,48 @@
|
|||
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||
import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
|
||||
type ServerStatus = { healthy: boolean; version?: string }
|
||||
|
||||
async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> {
|
||||
interface AddRowProps {
|
||||
value: string
|
||||
placeholder: string
|
||||
adding: boolean
|
||||
error: string
|
||||
status: boolean | undefined
|
||||
onChange: (value: string) => void
|
||||
onKeyDown: (event: KeyboardEvent) => void
|
||||
onBlur: () => void
|
||||
}
|
||||
|
||||
interface EditRowProps {
|
||||
value: string
|
||||
placeholder: string
|
||||
busy: boolean
|
||||
error: string
|
||||
status: boolean | undefined
|
||||
onChange: (value: string) => void
|
||||
onKeyDown: (event: KeyboardEvent) => void
|
||||
onBlur: () => void
|
||||
}
|
||||
|
||||
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: url,
|
||||
fetch,
|
||||
fetch: platform.fetch,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return sdk.global
|
||||
|
|
@ -26,21 +51,158 @@ async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promis
|
|||
.catch(() => ({ healthy: false }))
|
||||
}
|
||||
|
||||
function AddRow(props: AddRowProps) {
|
||||
return (
|
||||
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
||||
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full absolute left-3 z-10 pointer-events-none": true,
|
||||
"bg-icon-success-base": props.status === true,
|
||||
"bg-icon-critical-base": props.status === false,
|
||||
"bg-border-weak-base": props.status === undefined,
|
||||
}}
|
||||
style={{ top: "50%", transform: "translateY(-50%)" }}
|
||||
ref={(el) => {
|
||||
// Position relative to input-wrapper
|
||||
requestAnimationFrame(() => {
|
||||
const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]')
|
||||
if (wrapper instanceof HTMLElement) {
|
||||
wrapper.style.position = "relative"
|
||||
wrapper.appendChild(el)
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="text"
|
||||
hideLabel
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
autofocus
|
||||
validationState={props.error ? "invalid" : "valid"}
|
||||
error={props.error}
|
||||
disabled={props.adding}
|
||||
onChange={props.onChange}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onBlur={props.onBlur}
|
||||
class="pl-7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditRow(props: EditRowProps) {
|
||||
return (
|
||||
<div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": props.status === true,
|
||||
"bg-icon-critical-base": props.status === false,
|
||||
"bg-border-weak-base": props.status === undefined,
|
||||
}}
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<TextField
|
||||
type="text"
|
||||
hideLabel
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
autofocus
|
||||
validationState={props.error ? "invalid" : "valid"}
|
||||
error={props.error}
|
||||
disabled={props.busy}
|
||||
onChange={props.onChange}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onBlur={props.onBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogSelectServer() {
|
||||
const navigate = useNavigate()
|
||||
const dialog = useDialog()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
url: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
status: {} as Record<string, ServerStatus | undefined>,
|
||||
addServer: {
|
||||
url: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
editServer: {
|
||||
id: undefined as string | undefined,
|
||||
value: "",
|
||||
error: "",
|
||||
busy: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.())
|
||||
const [defaultUrl, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
const url = await platform.getDefaultServerUrl?.()
|
||||
if (!url) return null
|
||||
return normalizeServerUrl(url) ?? null
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
const isDesktop = platform.platform === "desktop"
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return false
|
||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||
return host.includes(".") || host.includes(":")
|
||||
}
|
||||
|
||||
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
||||
setStatus(undefined)
|
||||
if (!looksComplete(value)) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return
|
||||
const result = await checkHealth(normalized, platform)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
const resetAdd = () => {
|
||||
setStore("addServer", {
|
||||
url: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const resetEdit = () => {
|
||||
setStore("editServer", {
|
||||
id: undefined,
|
||||
value: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
busy: false,
|
||||
})
|
||||
}
|
||||
|
||||
const replaceServer = (original: string, next: string) => {
|
||||
const active = server.url
|
||||
const nextActive = active === original ? next : active
|
||||
|
||||
server.add(next)
|
||||
if (nextActive) server.setActive(nextActive)
|
||||
server.remove(original)
|
||||
}
|
||||
|
||||
const items = createMemo(() => {
|
||||
const current = server.url
|
||||
const list = server.list
|
||||
|
|
@ -74,7 +236,7 @@ export function DialogSelectServer() {
|
|||
const results: Record<string, ServerStatus> = {}
|
||||
await Promise.all(
|
||||
items().map(async (url) => {
|
||||
results[url] = await checkHealth(url, platform.fetch)
|
||||
results[url] = await checkHealth(url, platform)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
|
|
@ -87,7 +249,7 @@ export function DialogSelectServer() {
|
|||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
function select(value: string, persist?: boolean) {
|
||||
async function select(value: string, persist?: boolean) {
|
||||
if (!persist && store.status[value]?.healthy === false) return
|
||||
dialog.close()
|
||||
if (persist) {
|
||||
|
|
@ -99,24 +261,101 @@ export function DialogSelectServer() {
|
|||
navigate("/")
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const value = normalizeServerUrl(store.url)
|
||||
if (!value) return
|
||||
const handleAddChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
setStore("addServer", { url: value, error: "" })
|
||||
void previewStatus(value, (next) => setStore("addServer", { status: next }))
|
||||
}
|
||||
|
||||
setStore("adding", true)
|
||||
setStore("error", "")
|
||||
const scrollListToBottom = () => {
|
||||
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
|
||||
if (!scroll) return
|
||||
requestAnimationFrame(() => {
|
||||
scroll.scrollTop = scroll.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
const result = await checkHealth(value, platform.fetch)
|
||||
setStore("adding", false)
|
||||
const handleEditChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
setStore("editServer", { value, error: "" })
|
||||
void previewStatus(value, (next) => setStore("editServer", { status: next }))
|
||||
}
|
||||
|
||||
if (!result.healthy) {
|
||||
setStore("error", language.t("dialog.server.add.error"))
|
||||
async function handleAdd(value: string) {
|
||||
if (store.addServer.adding) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("url", "")
|
||||
select(value, true)
|
||||
setStore("addServer", { adding: true, error: "" })
|
||||
|
||||
const result = await checkHealth(normalized, platform)
|
||||
setStore("addServer", { adding: false })
|
||||
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
await select(normalized, true)
|
||||
}
|
||||
|
||||
async function handleEdit(original: string, value: string) {
|
||||
if (store.editServer.busy) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
if (normalized === original) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("editServer", { busy: true, error: "" })
|
||||
|
||||
const result = await checkHealth(normalized, platform)
|
||||
setStore("editServer", { busy: false })
|
||||
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
replaceServer(original, normalized)
|
||||
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
const handleAddKey = (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
handleAdd(store.addServer.url)
|
||||
}
|
||||
|
||||
const blurAdd = () => {
|
||||
if (!store.addServer.url.trim()) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
handleAdd(store.addServer.url)
|
||||
}
|
||||
|
||||
const handleEditKey = (event: KeyboardEvent, original: string) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
handleEdit(original, store.editServer.value)
|
||||
}
|
||||
|
||||
async function handleRemove(url: string) {
|
||||
|
|
@ -124,125 +363,203 @@ export function DialogSelectServer() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("dialog.server.title")} description={language.t("dialog.server.description")}>
|
||||
<div class="flex flex-col gap-4 pb-4">
|
||||
<Dialog title={language.t("dialog.server.title")}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<List
|
||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: true }}
|
||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={sortedItems}
|
||||
key={(x) => x}
|
||||
current={current()}
|
||||
onSelect={(x) => {
|
||||
if (x) select(x)
|
||||
}}
|
||||
onFilter={(value) => {
|
||||
if (value && store.addServer.showForm && !store.addServer.adding) {
|
||||
resetAdd()
|
||||
}
|
||||
}}
|
||||
divider={true}
|
||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
|
||||
add={
|
||||
store.addServer.showForm
|
||||
? {
|
||||
render: () => (
|
||||
<AddRow
|
||||
value={store.addServer.url}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
adding={store.addServer.adding}
|
||||
error={store.addServer.error}
|
||||
status={store.addServer.status}
|
||||
onChange={handleAddChange}
|
||||
onKeyDown={handleAddKey}
|
||||
onBlur={blurAdd}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1 group/item">
|
||||
<div
|
||||
class="flex items-center gap-2 min-w-0 flex-1"
|
||||
classList={{ "opacity-50": store.status[i]?.healthy === false }}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": store.status[i]?.healthy === true,
|
||||
"bg-icon-critical-base": store.status[i]?.healthy === false,
|
||||
"bg-border-weak-base": store.status[i] === undefined,
|
||||
}}
|
||||
/>
|
||||
<span class="truncate">{serverDisplayName(i)}</span>
|
||||
<span class="text-text-weak">{store.status[i]?.version}</span>
|
||||
{(i) => {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
|
||||
const check = () => {
|
||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
|
||||
setTruncated(nameTruncated || versionTruncated)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
check()
|
||||
window.addEventListener("resize", check)
|
||||
onCleanup(() => window.removeEventListener("resize", check))
|
||||
})
|
||||
|
||||
const tooltipValue = () => {
|
||||
const name = serverDisplayName(i)
|
||||
const version = store.status[i]?.version
|
||||
return (
|
||||
<span class="flex items-center gap-2">
|
||||
<span>{name}</span>
|
||||
<Show when={version}>
|
||||
<span class="text-text-invert-base">{version}</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||
<Show
|
||||
when={store.editServer.id !== i}
|
||||
fallback={
|
||||
<EditRow
|
||||
value={store.editServer.value}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={store.editServer.busy}
|
||||
error={store.editServer.error}
|
||||
status={store.editServer.status}
|
||||
onChange={handleEditChange}
|
||||
onKeyDown={(event) => handleEditKey(event, i)}
|
||||
onBlur={() => handleEdit(i, store.editServer.value)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||
classList={{ "opacity-50": store.status[i]?.healthy === false }}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": store.status[i]?.healthy === true,
|
||||
"bg-icon-critical-base": store.status[i]?.healthy === false,
|
||||
"bg-border-weak-base": store.status[i] === undefined,
|
||||
}}
|
||||
/>
|
||||
<span ref={nameRef} class="truncate">
|
||||
{serverDisplayName(i)}
|
||||
</span>
|
||||
<Show when={store.status[i]?.version}>
|
||||
<span ref={versionRef} class="text-text-weak text-14-regular truncate">
|
||||
{store.status[i]?.version}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={defaultUrl() === i}>
|
||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={store.editServer.id !== i}>
|
||||
<div class="flex items-center justify-center gap-5 pl-4">
|
||||
<Show when={current() === i}>
|
||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||
</Show>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setStore("editServer", {
|
||||
id: i,
|
||||
value: i,
|
||||
error: "",
|
||||
status: store.status[i]?.healthy,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={isDesktop && defaultUrl() !== i}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={async () => {
|
||||
await platform.setDefaultServerUrl?.(i)
|
||||
defaultUrlActions.mutate(i)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={isDesktop && defaultUrl() === i}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={async () => {
|
||||
await platform.setDefaultServerUrl?.(null)
|
||||
defaultUrlActions.mutate(null)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => handleRemove(i)}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={current() !== i && server.list.includes(i)}>
|
||||
<IconButton
|
||||
icon="circle-x"
|
||||
variant="ghost"
|
||||
class="bg-transparent transition-opacity shrink-0 hover:scale-110"
|
||||
aria-label={language.t("dialog.server.action.remove")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove(i)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
|
||||
<div class="mt-6 px-3 flex flex-col gap-1.5">
|
||||
<div class="px-3">
|
||||
<h3 class="text-14-regular text-text-weak">{language.t("dialog.server.add.title")}</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1 min-w-0 h-auto">
|
||||
<TextField
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.url")}
|
||||
hideLabel
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
value={store.url}
|
||||
onChange={(v) => {
|
||||
setStore("url", v)
|
||||
setStore("error", "")
|
||||
}}
|
||||
validationState={store.error ? "invalid" : "valid"}
|
||||
error={store.error}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
|
||||
{store.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="px-5 pb-5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
setStore("addServer", { showForm: true, url: "", error: "" })
|
||||
scrollListToBottom()
|
||||
}}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={isDesktop}>
|
||||
<div class="mt-6 px-3 flex flex-col gap-1.5">
|
||||
<div class="px-3">
|
||||
<h3 class="text-14-regular text-text-weak">{language.t("dialog.server.default.title")}</h3>
|
||||
<p class="text-12-regular text-text-weak mt-1">{language.t("dialog.server.default.description")}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-2">
|
||||
<Show
|
||||
when={defaultUrl()}
|
||||
fallback={
|
||||
<Show
|
||||
when={server.url}
|
||||
fallback={
|
||||
<span class="text-14-regular text-text-weak">{language.t("dialog.server.default.none")}</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
await platform.setDefaultServerUrl?.(server.url)
|
||||
defaultUrlActions.refetch(server.url)
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.server.default.set")}
|
||||
</Button>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
await platform.setDefaultServerUrl?.(null)
|
||||
defaultUrlActions.refetch()
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.server.default.clear")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider"
|
|||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
|
|
@ -123,6 +123,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const local = useLocal()
|
||||
const files = useFile()
|
||||
const prompt = usePrompt()
|
||||
const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length)
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
const params = useParams()
|
||||
|
|
@ -136,6 +137,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
let scrollRef!: HTMLDivElement
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
|
||||
const mirror = { input: false }
|
||||
|
||||
const scrollCursorIntoView = () => {
|
||||
const container = scrollRef
|
||||
const selection = window.getSelection()
|
||||
|
|
@ -170,6 +173,40 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
const commentInReview = (path: string) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return false
|
||||
|
||||
const diffs = sync.data.session_diff[sessionID]
|
||||
if (!diffs) return false
|
||||
return diffs.some((diff) => diff.file === path)
|
||||
}
|
||||
|
||||
const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => {
|
||||
if (!item.commentID) return
|
||||
|
||||
const focus = { file: item.path, id: item.commentID }
|
||||
comments.setActive(focus)
|
||||
view().reviewPanel.open()
|
||||
|
||||
if (item.commentOrigin === "review") {
|
||||
tabs().open("review")
|
||||
requestAnimationFrame(() => comments.setFocus(focus))
|
||||
return
|
||||
}
|
||||
|
||||
if (item.commentOrigin !== "file" && commentInReview(item.path)) {
|
||||
tabs().open("review")
|
||||
requestAnimationFrame(() => comments.setFocus(focus))
|
||||
return
|
||||
}
|
||||
|
||||
const tab = files.tab(item.path)
|
||||
tabs().open(tab)
|
||||
files.load(item.path)
|
||||
requestAnimationFrame(() => comments.setFocus(focus))
|
||||
}
|
||||
|
||||
const recent = createMemo(() => {
|
||||
const all = tabs().all()
|
||||
const active = tabs().active()
|
||||
|
|
@ -619,6 +656,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
() => prompt.current(),
|
||||
(currentParts) => {
|
||||
const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
|
||||
|
||||
if (mirror.input) {
|
||||
mirror.input = false
|
||||
if (isNormalizedEditor()) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
let cursorPosition: number | null = null
|
||||
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
|
||||
cursorPosition = getCursorPosition(editorRef)
|
||||
}
|
||||
|
||||
renderEditor(inputParts)
|
||||
|
||||
if (cursorPosition !== null) {
|
||||
setCursorPosition(editorRef, cursorPosition)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const domParts = parseFromDOM()
|
||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
||||
|
||||
|
|
@ -733,6 +789,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
setStore("savedPrompt", null)
|
||||
}
|
||||
if (prompt.dirty()) {
|
||||
mirror.input = true
|
||||
prompt.set(DEFAULT_PROMPT, 0)
|
||||
}
|
||||
queueScroll()
|
||||
|
|
@ -763,6 +820,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
setStore("savedPrompt", null)
|
||||
}
|
||||
|
||||
mirror.input = true
|
||||
prompt.set([...rawParts, ...images], cursorPosition)
|
||||
queueScroll()
|
||||
}
|
||||
|
|
@ -1481,6 +1539,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
})
|
||||
}
|
||||
|
|
@ -1547,6 +1606,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
})
|
||||
}
|
||||
|
|
@ -1661,7 +1721,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
classList={{
|
||||
"group/prompt-input": true,
|
||||
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
|
||||
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
|
||||
"rounded-[14px] overflow-clip focus-within:shadow-xs-border": true,
|
||||
"border-icon-info-active border-dashed": store.dragging,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
|
|
@ -1675,54 +1735,78 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
<Show when={prompt.context.items().length > 0}>
|
||||
<div class="flex flex-nowrap items-start gap-1.5 px-3 pt-3 overflow-x-auto no-scrollbar">
|
||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||
<For each={prompt.context.items()}>
|
||||
{(item) => {
|
||||
const active = () => {
|
||||
const a = comments.active()
|
||||
return !!item.commentID && item.commentID === a?.id && item.path === a?.file
|
||||
}
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]": true,
|
||||
"cursor-pointer hover:bg-surface-raised-base-hover": !!item.commentID,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!item.commentID) return
|
||||
comments.setFocus({ file: item.path, id: item.commentID })
|
||||
view().reviewPanel.open()
|
||||
tabs().open("review")
|
||||
}}
|
||||
<Tooltip
|
||||
value={
|
||||
<span class="flex max-w-[300px]">
|
||||
<span
|
||||
class="text-text-invert-base truncate min-w-0"
|
||||
style={{ direction: "rtl", "text-align": "left", "unicode-bidi": "plaintext" }}
|
||||
>
|
||||
{getDirectory(item.path)}
|
||||
</span>
|
||||
<span class="shrink-0">{getFilename(item.path)}</span>
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={2000}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
|
||||
<Show when={item.selection}>
|
||||
{(sel) => (
|
||||
<span class="text-text-weak whitespace-nowrap ml-1">
|
||||
{sel().startLine === sel().endLine
|
||||
? `:${sel().startLine}`
|
||||
: `:${sel().startLine}-${sel().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !active(),
|
||||
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
||||
active(),
|
||||
"bg-background-stronger": !active(),
|
||||
}}
|
||||
onClick={() => {
|
||||
openComment(item)
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div
|
||||
class="flex items-center text-11-regular min-w-0"
|
||||
style={{ "font-weight": "var(--font-weight-medium)" }}
|
||||
>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
||||
<Show when={item.selection}>
|
||||
{(sel) => (
|
||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||
{sel().startLine === sel().endLine
|
||||
? `:${sel().startLine}`
|
||||
: `:${sel().startLine}-${sel().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
aria-label={language.t("prompt.context.removeFile")}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="close"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
aria-label={language.t("prompt.context.removeFile")}
|
||||
/>
|
||||
<Show when={item.comment}>
|
||||
{(comment) => (
|
||||
<div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={item.comment}>
|
||||
{(comment) => <div class="text-11-regular text-text-strong">{comment()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
|
@ -1778,7 +1862,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
aria-label={
|
||||
store.mode === "shell"
|
||||
? language.t("prompt.placeholder.shell")
|
||||
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })
|
||||
: commentCount() > 1
|
||||
? "Summarize comments…"
|
||||
: commentCount() === 1
|
||||
? "Summarize comment…"
|
||||
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })
|
||||
}
|
||||
contenteditable="true"
|
||||
onInput={handleInput}
|
||||
|
|
@ -1788,17 +1876,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
/>
|
||||
<Show when={!prompt.dirty()}>
|
||||
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
||||
<div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
||||
{store.mode === "shell"
|
||||
? language.t("prompt.placeholder.shell")
|
||||
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
|
||||
: commentCount() > 1
|
||||
? "Summarize comments…"
|
||||
: commentCount() === 1
|
||||
? "Summarize comment…"
|
||||
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
@ -1905,7 +1997,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 absolute right-2 bottom-2">
|
||||
<div class="flex items-center gap-3 absolute right-3 bottom-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
|||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
|
@ -25,18 +26,22 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(language.locale(), {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}),
|
||||
)
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const locale = language.locale()
|
||||
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
return usd().format(total)
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
const locale = language.locale()
|
||||
const last = messages().findLast((x) => {
|
||||
const last = findLast(messages(), (x) => {
|
||||
if (x.role !== "assistant") return false
|
||||
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
|
||||
return total > 0
|
||||
|
|
@ -84,9 +89,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||
<span class="text-text-invert-strong">{cost()}</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.cost")}</span>
|
||||
</div>
|
||||
<Show when={variant() === "button"}>
|
||||
<div class="text-11-regular text-text-invert-base mt-1">{language.t("context.usage.clickToView")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import { createMemo, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
|
||||
export function SessionLspIndicator() {
|
||||
const sync = useSync()
|
||||
const language = useLanguage()
|
||||
|
||||
const lspStats = createMemo(() => {
|
||||
const lsp = sync.data.lsp ?? []
|
||||
const connected = lsp.filter((s) => s.status === "connected").length
|
||||
const hasError = lsp.some((s) => s.status === "error")
|
||||
const total = lsp.length
|
||||
return { connected, hasError, total }
|
||||
})
|
||||
|
||||
const tooltipContent = createMemo(() => {
|
||||
const lsp = sync.data.lsp ?? []
|
||||
if (lsp.length === 0) return language.t("lsp.tooltip.none")
|
||||
return lsp.map((s) => s.name).join(", ")
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={lspStats().total > 0}>
|
||||
<Tooltip placement="top" value={tooltipContent()}>
|
||||
<div class="flex items-center gap-1 px-2 cursor-default select-none">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-critical-base": lspStats().hasError,
|
||||
"bg-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
|
||||
}}
|
||||
/>
|
||||
<span class="text-12-regular text-text-weak">
|
||||
{language.t("lsp.label.connected", { count: lspStats().connected })}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { createMemo, Show } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||
|
||||
export function SessionMcpIndicator() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
|
||||
const mcpStats = createMemo(() => {
|
||||
const mcp = sync.data.mcp ?? {}
|
||||
const entries = Object.entries(mcp)
|
||||
const enabled = entries.filter(([, status]) => status.status === "connected").length
|
||||
const failed = entries.some(([, status]) => status.status === "failed")
|
||||
const total = entries.length
|
||||
return { enabled, failed, total }
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={mcpStats().total > 0}>
|
||||
<Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-critical-base": mcpStats().failed,
|
||||
"bg-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
|
||||
}}
|
||||
/>
|
||||
<span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
|
||||
</Button>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { DateTime } from "luxon"
|
|||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
|
|
@ -25,8 +26,16 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||
const sync = useSync()
|
||||
const language = useLanguage()
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(language.locale(), {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}),
|
||||
)
|
||||
|
||||
const ctx = createMemo(() => {
|
||||
const last = props.messages().findLast((x) => {
|
||||
const last = findLast(props.messages(), (x) => {
|
||||
if (x.role !== "assistant") return false
|
||||
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
|
||||
return total > 0
|
||||
|
|
@ -61,12 +70,8 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||
})
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const locale = language.locale()
|
||||
const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
return usd().format(total)
|
||||
})
|
||||
|
||||
const counts = createMemo(() => {
|
||||
|
|
@ -81,7 +86,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||
})
|
||||
|
||||
const systemPrompt = createMemo(() => {
|
||||
const msg = props.visibleUserMessages().findLast((m) => !!m.system)
|
||||
const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
|
||||
const system = msg?.system
|
||||
if (!system) return
|
||||
const trimmed = system.trim()
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import { useParams } from "@solidjs/router"
|
|||
import { useLayout } from "@/context/layout"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
// import { useServer } from "@/context/server"
|
||||
// import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
|
|
@ -20,14 +18,13 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
|||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
|
||||
export function SessionHeader() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const command = useCommand()
|
||||
// const server = useServer()
|
||||
// const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
|
|
@ -154,65 +151,104 @@ export function SessionHeader() {
|
|||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<div class="flex items-center gap-3">
|
||||
{/* <div class="hidden md:flex items-center gap-1"> */}
|
||||
{/* <Button */}
|
||||
{/* size="small" */}
|
||||
{/* variant="ghost" */}
|
||||
{/* onClick={() => { */}
|
||||
{/* dialog.show(() => <DialogSelectServer />) */}
|
||||
{/* }} */}
|
||||
{/* > */}
|
||||
{/* <div */}
|
||||
{/* classList={{ */}
|
||||
{/* "size-1.5 rounded-full": true, */}
|
||||
{/* "bg-icon-success-base": server.healthy() === true, */}
|
||||
{/* "bg-icon-critical-base": server.healthy() === false, */}
|
||||
{/* "bg-border-weak-base": server.healthy() === undefined, */}
|
||||
{/* }} */}
|
||||
{/* /> */}
|
||||
{/* <Icon name="server" size="small" class="text-icon-weak" /> */}
|
||||
{/* <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> */}
|
||||
{/* </Button> */}
|
||||
{/* <SessionLspIndicator /> */}
|
||||
{/* <SessionMcpIndicator /> */}
|
||||
{/* </div> */}
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="hidden md:block shrink-0">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.review.toggle")}
|
||||
keybind={command.keybind("review.toggle")}
|
||||
<StatusPopover />
|
||||
<Show when={showShare()}>
|
||||
<div class="flex items-center">
|
||||
<Popover
|
||||
title={language.t("session.share.popover.title")}
|
||||
description={
|
||||
shareUrl()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")
|
||||
}
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
shift={-64}
|
||||
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "secondary",
|
||||
class: "rounded-sm w-[60px] h-[24px]",
|
||||
classList: { "rounded-r-none": shareUrl() !== undefined },
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={language.t("session.share.action.share")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/review-toggle size-6 p-0"
|
||||
onClick={() => view().reviewPanel.toggle()}
|
||||
aria-label={language.t("command.review.toggle")}
|
||||
aria-expanded={view().reviewPanel.opened()}
|
||||
aria-controls="review-panel"
|
||||
tabIndex={showReview() ? 0 : -1}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-1/2"
|
||||
onClick={shareSession}
|
||||
disabled={state.share}
|
||||
>
|
||||
{state.share
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextField value={shareUrl() ?? ""} readOnly copyable tabIndex={-1} class="w-full" />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{state.unshare
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Popover>
|
||||
<Show when={shareUrl()} fallback={<div aria-hidden="true" />}>
|
||||
<Tooltip
|
||||
value={
|
||||
state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
placement="top"
|
||||
gutter={8}
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
|
||||
class="group-hover/review-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-right-partial"
|
||||
class="hidden group-hover/review-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
|
||||
class="hidden group-active/review-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<IconButton
|
||||
icon={state.copied ? "check" : "link"}
|
||||
variant="secondary"
|
||||
class="rounded-l-none"
|
||||
onClick={copyLink}
|
||||
disabled={state.unshare}
|
||||
aria-label={
|
||||
state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="hidden md:flex items-center gap-3 ml-2 shrink-0">
|
||||
<TooltipKeybind
|
||||
class="hidden md:block shrink-0"
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
>
|
||||
|
|
@ -244,96 +280,37 @@ export function SessionHeader() {
|
|||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<Show when={showShare()}>
|
||||
<div class="flex items-center">
|
||||
<Popover
|
||||
title={language.t("session.share.popover.title")}
|
||||
description={
|
||||
shareUrl()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")
|
||||
}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "secondary",
|
||||
classList: { "rounded-r-none": shareUrl() !== undefined },
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={language.t("session.share.action.share")}
|
||||
<div class="hidden md:block shrink-0">
|
||||
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/review-toggle size-6 p-0"
|
||||
onClick={() => view().reviewPanel.toggle()}
|
||||
aria-label={language.t("command.review.toggle")}
|
||||
aria-expanded={view().reviewPanel.opened()}
|
||||
aria-controls="review-panel"
|
||||
tabIndex={showReview() ? 0 : -1}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-1/2"
|
||||
onClick={shareSession}
|
||||
disabled={state.share}
|
||||
>
|
||||
{state.share
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2 w-72">
|
||||
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{state.unshare
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Popover>
|
||||
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
|
||||
<Tooltip
|
||||
value={
|
||||
state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
placement="top"
|
||||
gutter={8}
|
||||
>
|
||||
<IconButton
|
||||
icon={state.copied ? "check" : "copy"}
|
||||
variant="secondary"
|
||||
class="rounded-l-none"
|
||||
onClick={copyLink}
|
||||
disabled={state.unshare}
|
||||
aria-label={
|
||||
state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
|
||||
class="group-hover/review-toggle:hidden"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-right-partial"
|
||||
class="hidden group-hover/review-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
|
||||
class="hidden group-active/review-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useSync } from "@/context/sync"
|
|||
import { useLanguage } from "@/context/language"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
|
||||
const MAIN_WORKTREE = "main"
|
||||
const CREATE_WORKTREE = "create"
|
||||
|
|
@ -60,18 +59,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||
</div>
|
||||
<div class="flex justify-center items-center gap-1">
|
||||
<Icon name="branch" size="small" />
|
||||
<Select
|
||||
options={options()}
|
||||
current={current()}
|
||||
value={(x) => x}
|
||||
label={label}
|
||||
onSelect={(value) => {
|
||||
props.onWorktreeChange(value ?? MAIN_WORKTREE)
|
||||
}}
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
class="text-12-medium"
|
||||
/>
|
||||
<div class="text-12-medium text-text-weak select-text ml-2">{label(current())}</div>
|
||||
</div>
|
||||
<Show when={sync.project}>
|
||||
{(project) => (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,25 @@ import { useSettings, monoFontFamily } from "@/context/settings"
|
|||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
timeout: undefined as NodeJS.Timeout | undefined,
|
||||
}
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const playDemoSound = (src: string) => {
|
||||
if (demoSoundState.cleanup) {
|
||||
demoSoundState.cleanup()
|
||||
}
|
||||
|
||||
clearTimeout(demoSoundState.timeout)
|
||||
|
||||
demoSoundState.timeout = setTimeout(() => {
|
||||
demoSoundState.cleanup = playSound(src)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
export const SettingsGeneral: Component = () => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
|
|
@ -36,6 +55,7 @@ export const SettingsGeneral: Component = () => {
|
|||
{ value: "hack", label: "font.option.hack" },
|
||||
{ value: "inconsolata", label: "font.option.inconsolata" },
|
||||
{ value: "intel-one-mono", label: "font.option.intelOneMono" },
|
||||
{ value: "iosevka", label: "font.option.iosevka" },
|
||||
{ value: "jetbrains-mono", label: "font.option.jetbrainsMono" },
|
||||
{ value: "meslo-lgs", label: "font.option.mesloLgs" },
|
||||
{ value: "roboto-mono", label: "font.option.robotoMono" },
|
||||
|
|
@ -210,12 +230,12 @@ export const SettingsGeneral: Component = () => {
|
|||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playSound(option.src)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setAgent(option.id)
|
||||
playSound(option.src)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
|
|
@ -234,12 +254,12 @@ export const SettingsGeneral: Component = () => {
|
|||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playSound(option.src)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setPermissions(option.id)
|
||||
playSound(option.src)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
|
|
@ -258,12 +278,12 @@ export const SettingsGeneral: Component = () => {
|
|||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playSound(option.src)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setErrors(option.id)
|
||||
playSound(option.src)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
|
|
|
|||
405
packages/app/src/components/status-popover.tsx
Normal file
405
packages/app/src/components/status-popover.tsx
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
|
||||
type ServerStatus = { healthy: boolean; version?: string }
|
||||
|
||||
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: url,
|
||||
fetch: platform.fetch,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return sdk.global
|
||||
.health()
|
||||
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||
.catch(() => ({ healthy: false }))
|
||||
}
|
||||
|
||||
export function StatusPopover() {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<string, ServerStatus | undefined>,
|
||||
})
|
||||
|
||||
const servers = createMemo(() => {
|
||||
const current = server.url
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((x) => x !== current)]
|
||||
})
|
||||
|
||||
const sortedServers = createMemo(() => {
|
||||
const list = servers()
|
||||
if (!list.length) return list
|
||||
const active = server.url
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerStatus) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff = rank(store.status[a]) - rank(store.status[b])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
async function refreshHealth() {
|
||||
const results: Record<string, ServerStatus> = {}
|
||||
await Promise.all(
|
||||
servers().map(async (url) => {
|
||||
results[url] = await checkHealth(url, platform)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
servers()
|
||||
refreshHealth()
|
||||
const interval = setInterval(refreshHealth, 10_000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const mcpItems = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
.map(([name, status]) => ({ name, status: status.status }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
|
||||
|
||||
const toggleMcp = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
} else {
|
||||
await sdk.client.mcp.connect({ name })
|
||||
}
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
setLoading(null)
|
||||
}
|
||||
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
|
||||
const overallHealthy = createMemo(() => {
|
||||
const serverHealthy = server.healthy() === true
|
||||
const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled")
|
||||
return serverHealthy && !anyMcpIssue
|
||||
})
|
||||
|
||||
const serverCount = createMemo(() => sortedServers().length)
|
||||
|
||||
const [defaultServerUrl, setDefaultServerUrl] = createSignal<string | undefined>()
|
||||
|
||||
createEffect(() => {
|
||||
const result = platform.getDefaultServerUrl?.()
|
||||
if (result instanceof Promise) {
|
||||
result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined))
|
||||
return
|
||||
}
|
||||
if (result) setDefaultServerUrl(normalizeServerUrl(result))
|
||||
})
|
||||
|
||||
return (
|
||||
<Popover
|
||||
triggerAs={Button}
|
||||
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",
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
}}
|
||||
/>
|
||||
<span class="text-12-regular text-text-strong">Status</span>
|
||||
</div>
|
||||
}
|
||||
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
shift={-136}
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1 w-[360px] rounded-xl"
|
||||
style={{ "box-shadow": "var(--shadow-lg-border-base)" }}
|
||||
>
|
||||
<Tabs
|
||||
aria-label="Server Configurations"
|
||||
class="tabs"
|
||||
data-component="tabs"
|
||||
data-active="servers"
|
||||
defaultValue="servers"
|
||||
variant="alt"
|
||||
style={{
|
||||
"background-color": "var(--background-strong)",
|
||||
"border-radius": "12px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Tabs.List
|
||||
data-slot="tablist"
|
||||
style={{
|
||||
"background-color": "transparent",
|
||||
"border-bottom": "none",
|
||||
padding: "8px 16px 0",
|
||||
gap: "16px",
|
||||
height: "40px",
|
||||
}}
|
||||
>
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{serverCount() > 0 ? `${serverCount()} ` : ""}Servers
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}MCP
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}LSP
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}Plugins
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="servers">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<For each={sortedServers()}>
|
||||
{(url) => {
|
||||
const isActive = () => url === server.url
|
||||
const isDefault = () => url === defaultServerUrl()
|
||||
const status = () => store.status[url]
|
||||
const isBlocked = () => status()?.healthy === false
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
const check = () => {
|
||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
|
||||
setTruncated(nameTruncated || versionTruncated)
|
||||
}
|
||||
check()
|
||||
window.addEventListener("resize", check)
|
||||
onCleanup(() => window.removeEventListener("resize", check))
|
||||
})
|
||||
|
||||
const tooltipValue = () => {
|
||||
const name = serverDisplayName(url)
|
||||
const version = status()?.version
|
||||
return (
|
||||
<span class="flex items-center gap-2">
|
||||
<span>{name}</span>
|
||||
<Show when={version}>
|
||||
<span class="text-text-invert-base">{version}</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||
classList={{
|
||||
"opacity-50": isBlocked(),
|
||||
"hover:bg-surface-raised-base-hover": !isBlocked(),
|
||||
"cursor-not-allowed": isBlocked(),
|
||||
}}
|
||||
aria-disabled={isBlocked()}
|
||||
onClick={() => {
|
||||
if (isBlocked()) return
|
||||
server.setActive(url)
|
||||
navigate("/")
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": status()?.healthy === true,
|
||||
"bg-icon-critical-base": status()?.healthy === false,
|
||||
"bg-border-weak-base": status() === undefined,
|
||||
}}
|
||||
/>
|
||||
<span ref={nameRef} class="text-14-regular text-text-base truncate">
|
||||
{serverDisplayName(url)}
|
||||
</span>
|
||||
<Show when={status()?.version}>
|
||||
<span ref={versionRef} class="text-12-regular text-text-weak truncate">
|
||||
{status()?.version}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={isDefault()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
Default
|
||||
</span>
|
||||
</Show>
|
||||
<div class="flex-1" />
|
||||
<Show when={isActive()}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||
>
|
||||
Manage servers
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="mcp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={mcpItems().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">No MCP servers configured</div>
|
||||
}
|
||||
>
|
||||
<For each={mcpItems()}>
|
||||
{(item) => {
|
||||
const enabled = () => item.status === "connected"
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => toggleMcp(item.name)}
|
||||
disabled={loading() === item.name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": item.status === "connected",
|
||||
"bg-icon-critical-base": item.status === "failed",
|
||||
"bg-border-weak-base": item.status === "disabled",
|
||||
"bg-icon-warning-base":
|
||||
item.status === "needs_auth" || item.status === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate flex-1">{item.name}</span>
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={loading() === item.name}
|
||||
onChange={() => toggleMcp(item.name)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="lsp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={lspItems().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">
|
||||
LSPs auto-detected from file types
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={lspItems()}>
|
||||
{(item) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": item.status === "connected",
|
||||
"bg-icon-critical-base": item.status === "error",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={plugins().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">
|
||||
Plugins configured in{" "}
|
||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">opencode.json</code>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -111,6 +111,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||
const mod = await import("ghostty-web")
|
||||
ghostty = await mod.Ghostty.load()
|
||||
|
||||
const once = { value: false }
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
||||
if (window.__OPENCODE__?.serverPassword) {
|
||||
url.username = "opencode"
|
||||
|
|
@ -258,6 +260,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||
})
|
||||
socket.addEventListener("error", (error) => {
|
||||
if (disposed) return
|
||||
if (once.value) return
|
||||
once.value = true
|
||||
console.error("WebSocket error:", error)
|
||||
local.onConnectError?.(error)
|
||||
})
|
||||
|
|
@ -266,6 +270,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
|
||||
// For other codes (network issues, server restart), trigger error handler
|
||||
if (event.code !== 1000) {
|
||||
if (once.value) return
|
||||
once.value = true
|
||||
local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@ export function Titlebar() {
|
|||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
const reserve = createMemo(
|
||||
() => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"),
|
||||
)
|
||||
const web = createMemo(() => platform.platform === "web")
|
||||
|
||||
const getWin = () => {
|
||||
|
|
@ -81,7 +78,7 @@ export function Titlebar() {
|
|||
classList={{
|
||||
"flex items-center w-full min-w-0": true,
|
||||
"pl-2": !mac(),
|
||||
"pr-2": !windows(),
|
||||
"pr-6": !windows(),
|
||||
}}
|
||||
onMouseDown={drag}
|
||||
data-tauri-drag-region
|
||||
|
|
@ -145,6 +142,7 @@ export function Titlebar() {
|
|||
data-tauri-drag-region
|
||||
/>
|
||||
<Show when={windows()}>
|
||||
<div class="w-6 shrink-0" />
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ function createCommentSession(dir: string, id: string | undefined) {
|
|||
)
|
||||
|
||||
const [focus, setFocus] = createSignal<CommentFocus | null>(null)
|
||||
const [active, setActive] = createSignal<CommentFocus | null>(null)
|
||||
|
||||
const list = (file: string) => store.comments[file] ?? []
|
||||
|
||||
|
|
@ -76,6 +77,9 @@ function createCommentSession(dir: string, id: string | undefined) {
|
|||
focus: createMemo(() => focus()),
|
||||
setFocus,
|
||||
clearFocus: () => setFocus(null),
|
||||
active: createMemo(() => active()),
|
||||
setActive,
|
||||
clearActive: () => setActive(null),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +139,9 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
|||
focus: () => session().focus(),
|
||||
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
|
||||
clearFocus: () => session().clearFocus(),
|
||||
active: () => session().active(),
|
||||
setActive: (active: CommentFocus | null) => session().setActive(active),
|
||||
clearActive: () => session().clearActive(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||
type Queued = { directory: string; payload: Event }
|
||||
|
||||
let queue: Array<Queued | undefined> = []
|
||||
let buffer: Array<Queued | undefined> = []
|
||||
const coalesced = new Map<string, number>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
|
@ -41,10 +42,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
queue = []
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
coalesced.clear()
|
||||
if (events.length === 0) return
|
||||
|
||||
last = Date.now()
|
||||
batch(() => {
|
||||
|
|
@ -53,6 +57,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||
emitter.emit(event.directory, event.payload)
|
||||
}
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
|
|
@ -61,10 +67,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
flush()
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const events = await eventSdk.global.event()
|
||||
let yielded = Date.now()
|
||||
|
|
@ -87,12 +89,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
})()
|
||||
.finally(stop)
|
||||
.finally(flush)
|
||||
.catch(() => undefined)
|
||||
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
stop()
|
||||
flush()
|
||||
})
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
|
|
|
|||
|
|
@ -119,6 +119,16 @@ type ChildOptions = {
|
|||
bootstrap?: boolean
|
||||
}
|
||||
|
||||
function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||
return {
|
||||
...input,
|
||||
all: input.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
|
|
@ -129,6 +139,21 @@ function createGlobalSync() {
|
|||
const metaCache = new Map<string, MetaCache>()
|
||||
const iconCache = new Map<string, IconCache>()
|
||||
|
||||
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
|
||||
const sdkFor = (directory: string) => {
|
||||
const cached = sdkCache.get(directory)
|
||||
if (cached) return cached
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
sdkCache.set(directory, sdk)
|
||||
return sdk
|
||||
}
|
||||
|
||||
const [projectCache, setProjectCache, , projectCacheReady] = persisted(
|
||||
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
||||
createStore({ value: [] as Project[] }),
|
||||
|
|
@ -183,7 +208,7 @@ function createGlobalSync() {
|
|||
setProjectCache("value", projects.map(sanitizeProject))
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
createEffect(() => {
|
||||
if (globalStore.reload !== "complete") return
|
||||
if (bootstrapQueue.length) {
|
||||
for (const directory of bootstrapQueue) {
|
||||
|
|
@ -203,14 +228,16 @@ function createGlobalSync() {
|
|||
function ensureChild(directory: string) {
|
||||
if (!directory) console.error("No directory provided")
|
||||
if (!children[directory]) {
|
||||
const cache = runWithOwner(owner, () =>
|
||||
const vcs = runWithOwner(owner, () =>
|
||||
persisted(
|
||||
Persist.workspace(directory, "vcs", ["vcs.v1"]),
|
||||
createStore({ value: undefined as VcsInfo | undefined }),
|
||||
),
|
||||
)
|
||||
if (!cache) throw new Error("Failed to create persisted cache")
|
||||
vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
|
||||
if (!vcs) throw new Error("Failed to create persisted cache")
|
||||
const vcsStore = vcs[0]
|
||||
const vcsReady = vcs[3]
|
||||
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
|
||||
|
||||
const meta = runWithOwner(owner, () =>
|
||||
persisted(
|
||||
|
|
@ -250,7 +277,7 @@ function createGlobalSync() {
|
|||
question: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: cache[0].value,
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
|
|
@ -258,6 +285,13 @@ function createGlobalSync() {
|
|||
|
||||
children[directory] = child
|
||||
|
||||
createEffect(() => {
|
||||
if (!vcsReady()) return
|
||||
const cached = vcsStore.value
|
||||
if (!cached?.branch) return
|
||||
child[1]("vcs", (value) => value ?? cached)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
child[1]("projectMeta", meta[0].value)
|
||||
})
|
||||
|
|
@ -297,7 +331,6 @@ function createGlobalSync() {
|
|||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
// Read the current limit at resolve-time so callers that bump the limit while
|
||||
|
|
@ -348,38 +381,18 @@ function createGlobalSync() {
|
|||
if (!cache) return
|
||||
const meta = metaCache.get(directory)
|
||||
if (!meta) return
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
const sdk = sdkFor(directory)
|
||||
|
||||
setStore("status", "loading")
|
||||
|
||||
createEffect(() => {
|
||||
if (!cache.ready()) return
|
||||
const cached = cache.store.value
|
||||
if (!cached?.branch) return
|
||||
setStore("vcs", (value) => value ?? cached)
|
||||
})
|
||||
|
||||
// projectMeta is synced from persisted storage in ensureChild.
|
||||
// vcs is seeded from persisted storage in ensureChild.
|
||||
|
||||
const blockingRequests = {
|
||||
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
||||
provider: () =>
|
||||
sdk.provider.list().then((x) => {
|
||||
const data = x.data!
|
||||
setStore("provider", {
|
||||
...data,
|
||||
all: data.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
})),
|
||||
})
|
||||
setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
|
|
@ -432,10 +445,7 @@ function createGlobalSync() {
|
|||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
|
|
@ -464,10 +474,7 @@ function createGlobalSync() {
|
|||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions
|
||||
.filter((q) => !!q?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
|
|
@ -750,13 +757,9 @@ function createGlobalSync() {
|
|||
break
|
||||
}
|
||||
case "lsp.updated": {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
|
||||
sdkFor(directory)
|
||||
.lsp.status()
|
||||
.then((x) => setStore("lsp", x.data ?? []))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -796,16 +799,7 @@ function createGlobalSync() {
|
|||
),
|
||||
retry(() =>
|
||||
globalSDK.client.provider.list().then((x) => {
|
||||
const data = x.data!
|
||||
setGlobalStore("provider", {
|
||||
...data,
|
||||
all: data.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
})),
|
||||
})
|
||||
setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
})
|
||||
|
||||
const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
|
||||
const colorRequested = new Map<string, AvatarColorKey>()
|
||||
|
||||
function pickAvailableColor(used: Set<string>): AvatarColorKey {
|
||||
const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
|
||||
|
|
@ -267,17 +268,36 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
return map
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const rootFor = (directory: string) => {
|
||||
const map = roots()
|
||||
if (map.size === 0) return
|
||||
if (map.size === 0) return directory
|
||||
|
||||
const visited = new Set<string>()
|
||||
const chain = [directory]
|
||||
|
||||
while (chain.length) {
|
||||
const current = chain[chain.length - 1]
|
||||
if (!current) return directory
|
||||
|
||||
const next = map.get(current)
|
||||
if (!next) return current
|
||||
|
||||
if (visited.has(next)) return directory
|
||||
visited.add(next)
|
||||
chain.push(next)
|
||||
}
|
||||
|
||||
return directory
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const projects = server.projects.list()
|
||||
const seen = new Set(projects.map((project) => project.worktree))
|
||||
|
||||
batch(() => {
|
||||
for (const project of projects) {
|
||||
const root = map.get(project.worktree)
|
||||
if (!root) continue
|
||||
const root = rootFor(project.worktree)
|
||||
if (root === project.worktree) continue
|
||||
|
||||
server.projects.close(project.worktree)
|
||||
|
||||
|
|
@ -305,13 +325,21 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
createEffect(() => {
|
||||
const projects = enriched()
|
||||
if (projects.length === 0) return
|
||||
if (!globalSync.ready) return
|
||||
|
||||
if (globalSync.ready) {
|
||||
for (const project of projects) {
|
||||
if (!project.id) continue
|
||||
if (project.id === "global") continue
|
||||
globalSync.project.icon(project.worktree, project.icon?.override)
|
||||
}
|
||||
for (const project of projects) {
|
||||
if (!project.id) continue
|
||||
if (project.id === "global") continue
|
||||
globalSync.project.icon(project.worktree, project.icon?.override)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const projects = enriched()
|
||||
if (projects.length === 0) return
|
||||
|
||||
for (const project of projects) {
|
||||
if (project.icon?.color) colorRequested.delete(project.worktree)
|
||||
}
|
||||
|
||||
const used = new Set<string>()
|
||||
|
|
@ -322,18 +350,29 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
|
||||
for (const project of projects) {
|
||||
if (project.icon?.color) continue
|
||||
const existing = colors[project.worktree]
|
||||
const worktree = project.worktree
|
||||
const existing = colors[worktree]
|
||||
const color = existing ?? pickAvailableColor(used)
|
||||
if (!existing) {
|
||||
used.add(color)
|
||||
setColors(project.worktree, color)
|
||||
setColors(worktree, color)
|
||||
}
|
||||
if (!project.id) continue
|
||||
|
||||
const requested = colorRequested.get(worktree)
|
||||
if (requested === color) continue
|
||||
colorRequested.set(worktree, color)
|
||||
|
||||
if (project.id === "global") {
|
||||
globalSync.project.meta(project.worktree, { icon: { color } })
|
||||
globalSync.project.meta(worktree, { icon: { color } })
|
||||
continue
|
||||
}
|
||||
void globalSdk.client.project.update({ projectID: project.id, directory: project.worktree, icon: { color } })
|
||||
|
||||
void globalSdk.client.project
|
||||
.update({ projectID: project.id, directory: worktree, icon: { color } })
|
||||
.catch(() => {
|
||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -350,7 +389,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
projects: {
|
||||
list,
|
||||
open(directory: string) {
|
||||
const root = roots().get(directory) ?? directory
|
||||
const root = rootFor(directory)
|
||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||
globalSync.project.loadSessions(root)
|
||||
server.projects.open(root)
|
||||
|
|
@ -384,7 +423,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
setStore("sidebar", "width", width)
|
||||
},
|
||||
workspaces(directory: string) {
|
||||
return createMemo(() => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false)
|
||||
return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
|
||||
},
|
||||
setWorkspaces(directory: string, value: boolean) {
|
||||
setStore("sidebar", "workspaces", directory, value)
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
)
|
||||
|
||||
const [ephemeral, setEphemeral] = createStore<{
|
||||
model: Record<string, ModelKey>
|
||||
model: Record<string, ModelKey | undefined>
|
||||
}>({
|
||||
model: {},
|
||||
})
|
||||
|
|
@ -182,7 +182,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
|
||||
|
||||
const fallbackModel = createMemo(() => {
|
||||
const fallbackModel = createMemo<ModelKey | undefined>(() => {
|
||||
if (sync.data.config.model) {
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
if (isModelValid({ providerID, modelID })) {
|
||||
|
|
@ -199,16 +199,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
}
|
||||
}
|
||||
|
||||
const defaults = providers.default()
|
||||
for (const p of providers.connected()) {
|
||||
if (p.id in providers.default()) {
|
||||
return {
|
||||
providerID: p.id,
|
||||
modelID: providers.default()[p.id],
|
||||
}
|
||||
const configured = defaults[p.id]
|
||||
if (configured) {
|
||||
const key = { providerID: p.id, modelID: configured }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
|
||||
const first = Object.values(p.models)[0]
|
||||
if (!first) continue
|
||||
const key = { providerID: p.id, modelID: first.id }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
|
||||
throw new Error("No default model found")
|
||||
return undefined
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
|
|
@ -266,7 +271,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
const currentAgent = agent.current()
|
||||
if (currentAgent) setEphemeral("model", currentAgent.name, model ?? fallbackModel())
|
||||
const next = model ?? fallbackModel()
|
||||
if (currentAgent) setEphemeral("model", currentAgent.name, next)
|
||||
if (model) updateVisibility(model, "show")
|
||||
if (options?.recent && model) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export type FileContextItem = {
|
|||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const monoFonts: Record<string, string> = {
|
|||
hack: `"Hack Nerd Font", "Hack Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
inconsolata: `"Inconsolata Nerd Font", "Inconsolata Nerd Font Mono","IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"intel-one-mono": `"Intel One Mono Nerd Font", "IntoneMono Nerd Font", "IntoneMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
iosevka: `"Iosevka Nerd Font", "Iosevka Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"jetbrains-mono": `"JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMonoNL Nerd Font", "JetBrainsMonoNL Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"meslo-lgs": `"Meslo LGS Nerd Font", "MesloLGS Nerd Font", "MesloLGM Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||
const next = items
|
||||
.map((x) => x.info)
|
||||
.filter((m) => !!m?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
batch(() => {
|
||||
|
|
@ -83,10 +82,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||
"part",
|
||||
message.info.id,
|
||||
reconcile(
|
||||
message.parts
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
|
|
@ -146,10 +142,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, message)
|
||||
}
|
||||
draft.part[input.messageID] = input.parts
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id))
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
|
@ -291,7 +284,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||
await client.session.list().then((x) => {
|
||||
const sessions = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.slice(0, store.limit)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export type LocalPTY = {
|
|||
cols?: number
|
||||
buffer?: string
|
||||
scrollY?: number
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
|
|
@ -151,13 +150,18 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess
|
|||
return undefined
|
||||
})
|
||||
if (!clone?.data) return
|
||||
setStore("all", index, {
|
||||
...pty,
|
||||
...clone.data,
|
||||
|
||||
const active = store.active === pty.id
|
||||
|
||||
batch(() => {
|
||||
setStore("all", index, {
|
||||
...pty,
|
||||
...clone.data,
|
||||
})
|
||||
if (active) {
|
||||
setStore("active", clone.data.id)
|
||||
}
|
||||
})
|
||||
if (store.active === pty.id) {
|
||||
setStore("active", clone.data.id)
|
||||
}
|
||||
},
|
||||
open(id: string) {
|
||||
setStore("active", id)
|
||||
|
|
|
|||
|
|
@ -223,6 +223,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "{{enabled}} من {{total}} مفعل",
|
||||
"dialog.mcp.empty": "لم يتم تكوين MCPs",
|
||||
|
||||
"dialog.lsp.empty": "تم الكشف تلقائيًا عن LSPs من أنواع الملفات",
|
||||
"dialog.plugins.empty": "الإضافات المكونة في opencode.json",
|
||||
|
||||
"mcp.status.connected": "متصل",
|
||||
"mcp.status.failed": "فشل",
|
||||
"mcp.status.needs_auth": "يحتاج إلى مصادقة",
|
||||
|
|
@ -242,7 +245,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "تعذر الاتصال بالخادم",
|
||||
"dialog.server.add.checking": "جارٍ التحقق...",
|
||||
"dialog.server.add.button": "إضافة",
|
||||
"dialog.server.add.button": "إضافة خادم",
|
||||
"dialog.server.default.title": "الخادم الافتراضي",
|
||||
"dialog.server.default.description":
|
||||
"الاتصال بهذا الخادم عند بدء تشغيل التطبيق بدلاً من بدء خادم محلي. يتطلب إعادة التشغيل.",
|
||||
|
|
@ -251,6 +254,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "مسح",
|
||||
"dialog.server.action.remove": "إزالة الخادم",
|
||||
|
||||
"dialog.server.menu.edit": "تعديل",
|
||||
"dialog.server.menu.default": "تعيين كافتراضي",
|
||||
"dialog.server.menu.defaultRemove": "إزالة الافتراضي",
|
||||
"dialog.server.menu.delete": "حذف",
|
||||
"dialog.server.current": "الخادم الحالي",
|
||||
"dialog.server.status.default": "افتراضي",
|
||||
|
||||
"dialog.project.edit.title": "تحرير المشروع",
|
||||
"dialog.project.edit.name": "الاسم",
|
||||
"dialog.project.edit.icon": "أيقونة",
|
||||
|
|
@ -294,20 +304,20 @@ export const dict = {
|
|||
"context.usage.clickToView": "انقر لعرض السياق",
|
||||
"context.usage.view": "عرض استخدام السياق",
|
||||
|
||||
"language.en": "الإنجليزية",
|
||||
"language.zh": "الصينية (المبسطة)",
|
||||
"language.zht": "الصينية (التقليدية)",
|
||||
"language.ko": "الكورية",
|
||||
"language.de": "الألمانية",
|
||||
"language.es": "الإسبانية",
|
||||
"language.fr": "الفرنسية",
|
||||
"language.ja": "اليابانية",
|
||||
"language.da": "الدانماركية",
|
||||
"language.ru": "الروسية",
|
||||
"language.pl": "البولندية",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "النرويجية",
|
||||
"language.br": "البرتغالية (البرازيل)",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "لغة",
|
||||
"toast.language.description": "تم التبديل إلى {{language}}",
|
||||
|
|
|
|||
|
|
@ -298,19 +298,19 @@ export const dict = {
|
|||
"context.usage.clickToView": "Clique para ver o contexto",
|
||||
"context.usage.view": "Ver uso do contexto",
|
||||
|
||||
"language.en": "Inglês",
|
||||
"language.zh": "Chinês (Simplificado)",
|
||||
"language.zht": "Chinês (Tradicional)",
|
||||
"language.ko": "Coreano",
|
||||
"language.de": "Alemão",
|
||||
"language.es": "Espanhol",
|
||||
"language.fr": "Francês",
|
||||
"language.ja": "Japonês",
|
||||
"language.da": "Dinamarquês",
|
||||
"language.ru": "Russo",
|
||||
"language.pl": "Polonês",
|
||||
"language.ar": "Árabe",
|
||||
"language.no": "Norueguês",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "Idioma",
|
||||
|
|
|
|||
|
|
@ -205,6 +205,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "{{enabled}} af {{total}} aktiveret",
|
||||
"dialog.mcp.empty": "Ingen MCP'er konfigureret",
|
||||
|
||||
"dialog.lsp.empty": "LSP'er registreret automatisk fra filtyper",
|
||||
"dialog.plugins.empty": "Plugins konfigureret i opencode.json",
|
||||
|
||||
"mcp.status.connected": "forbundet",
|
||||
"mcp.status.failed": "mislykkedes",
|
||||
"mcp.status.needs_auth": "kræver godkendelse",
|
||||
|
|
@ -224,7 +227,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Kunne ikke forbinde til server",
|
||||
"dialog.server.add.checking": "Tjekker...",
|
||||
"dialog.server.add.button": "Tilføj",
|
||||
"dialog.server.add.button": "Tilføj server",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
"dialog.server.default.description":
|
||||
"Forbind til denne server ved start af app i stedet for at starte en lokal server. Kræver genstart.",
|
||||
|
|
@ -233,6 +236,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "Ryd",
|
||||
"dialog.server.action.remove": "Fjern server",
|
||||
|
||||
"dialog.server.menu.edit": "Rediger",
|
||||
"dialog.server.menu.default": "Sæt som standard",
|
||||
"dialog.server.menu.defaultRemove": "Fjern som standard",
|
||||
"dialog.server.menu.delete": "Slet",
|
||||
"dialog.server.current": "Nuværende server",
|
||||
"dialog.server.status.default": "Standard",
|
||||
|
||||
"dialog.project.edit.title": "Rediger projekt",
|
||||
"dialog.project.edit.name": "Navn",
|
||||
"dialog.project.edit.icon": "Ikon",
|
||||
|
|
@ -277,20 +287,20 @@ export const dict = {
|
|||
"context.usage.clickToView": "Klik for at se kontekst",
|
||||
"context.usage.view": "Se kontekstforbrug",
|
||||
|
||||
"language.en": "Engelsk",
|
||||
"language.zh": "Kinesisk (forenklet)",
|
||||
"language.zht": "Kinesisk (traditionelt)",
|
||||
"language.ko": "Koreansk",
|
||||
"language.de": "Tysk",
|
||||
"language.es": "Spansk",
|
||||
"language.fr": "Fransk",
|
||||
"language.ja": "Japansk",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ru": "Russisk",
|
||||
"language.pl": "Polsk",
|
||||
"language.ar": "Arabisk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Portugisisk (Brasilien)",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "Sprog",
|
||||
"toast.language.description": "Skiftede til {{language}}",
|
||||
|
|
|
|||
|
|
@ -210,6 +210,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "{{enabled}} von {{total}} aktiviert",
|
||||
"dialog.mcp.empty": "Keine MCPs konfiguriert",
|
||||
|
||||
"dialog.lsp.empty": "LSPs automatisch nach Dateityp erkannt",
|
||||
"dialog.plugins.empty": "In opencode.json konfigurierte Plugins",
|
||||
|
||||
"mcp.status.connected": "verbunden",
|
||||
"mcp.status.failed": "fehlgeschlagen",
|
||||
"mcp.status.needs_auth": "benötigt Authentifizierung",
|
||||
|
|
@ -229,7 +232,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Verbindung zum Server fehlgeschlagen",
|
||||
"dialog.server.add.checking": "Prüfen...",
|
||||
"dialog.server.add.button": "Hinzufügen",
|
||||
"dialog.server.add.button": "Server hinzufügen",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
"dialog.server.default.description":
|
||||
"Beim App-Start mit diesem Server verbinden, anstatt einen lokalen Server zu starten. Erfordert Neustart.",
|
||||
|
|
@ -238,6 +241,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "Löschen",
|
||||
"dialog.server.action.remove": "Server entfernen",
|
||||
|
||||
"dialog.server.menu.edit": "Bearbeiten",
|
||||
"dialog.server.menu.default": "Als Standard festlegen",
|
||||
"dialog.server.menu.defaultRemove": "Standard entfernen",
|
||||
"dialog.server.menu.delete": "Löschen",
|
||||
"dialog.server.current": "Aktueller Server",
|
||||
"dialog.server.status.default": "Standard",
|
||||
|
||||
"dialog.project.edit.title": "Projekt bearbeiten",
|
||||
"dialog.project.edit.name": "Name",
|
||||
"dialog.project.edit.icon": "Icon",
|
||||
|
|
@ -282,20 +292,20 @@ export const dict = {
|
|||
"context.usage.clickToView": "Klicken, um Kontext anzuzeigen",
|
||||
"context.usage.view": "Kontextnutzung anzeigen",
|
||||
|
||||
"language.en": "Englisch",
|
||||
"language.zh": "Chinesisch (Vereinfacht)",
|
||||
"language.zht": "Chinesisch (Traditionell)",
|
||||
"language.ko": "Koreanisch",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Spanisch",
|
||||
"language.fr": "Französisch",
|
||||
"language.ja": "Japanisch",
|
||||
"language.da": "Dänisch",
|
||||
"language.ru": "Russisch",
|
||||
"language.pl": "Polnisch",
|
||||
"language.ar": "Arabisch",
|
||||
"language.no": "Norwegisch",
|
||||
"language.br": "Portugiesisch (Brasilien)",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "Sprache",
|
||||
"toast.language.description": "Zu {{language}} gewechselt",
|
||||
|
|
|
|||
|
|
@ -223,6 +223,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
|
||||
"dialog.mcp.empty": "No MCPs configured",
|
||||
|
||||
"dialog.lsp.empty": "LSPs auto-detected from file types",
|
||||
"dialog.plugins.empty": "Plugins configured in opencode.json",
|
||||
|
||||
"mcp.status.connected": "connected",
|
||||
"mcp.status.failed": "failed",
|
||||
"mcp.status.needs_auth": "needs auth",
|
||||
|
|
@ -242,7 +245,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Could not connect to server",
|
||||
"dialog.server.add.checking": "Checking...",
|
||||
"dialog.server.add.button": "Add",
|
||||
"dialog.server.add.button": "Add server",
|
||||
"dialog.server.default.title": "Default server",
|
||||
"dialog.server.default.description":
|
||||
"Connect to this server on app launch instead of starting a local server. Requires restart.",
|
||||
|
|
@ -251,6 +254,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "Clear",
|
||||
"dialog.server.action.remove": "Remove server",
|
||||
|
||||
"dialog.server.menu.edit": "Edit",
|
||||
"dialog.server.menu.default": "Set as default",
|
||||
"dialog.server.menu.defaultRemove": "Remove default",
|
||||
"dialog.server.menu.delete": "Delete",
|
||||
"dialog.server.current": "Current Server",
|
||||
"dialog.server.status.default": "Default",
|
||||
|
||||
"dialog.project.edit.title": "Edit project",
|
||||
"dialog.project.edit.name": "Name",
|
||||
"dialog.project.edit.icon": "Icon",
|
||||
|
|
@ -298,19 +308,19 @@ export const dict = {
|
|||
"context.usage.view": "View context usage",
|
||||
|
||||
"language.en": "English",
|
||||
"language.zh": "Chinese (Simplified)",
|
||||
"language.zht": "Chinese (Traditional)",
|
||||
"language.ko": "Korean",
|
||||
"language.de": "German",
|
||||
"language.es": "Spanish",
|
||||
"language.fr": "French",
|
||||
"language.ja": "Japanese",
|
||||
"language.da": "Danish",
|
||||
"language.ru": "Russian",
|
||||
"language.pl": "Polish",
|
||||
"language.ar": "Arabic",
|
||||
"language.no": "Norwegian",
|
||||
"language.br": "Portuguese (Brazil)",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "Language",
|
||||
"toast.language.description": "Switched to {{language}}",
|
||||
|
|
@ -494,6 +504,7 @@ export const dict = {
|
|||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
|
|
|
|||
|
|
@ -205,6 +205,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "{{enabled}} de {{total}} habilitados",
|
||||
"dialog.mcp.empty": "No hay MCPs configurados",
|
||||
|
||||
"dialog.lsp.empty": "LSPs detectados automáticamente por tipo de archivo",
|
||||
"dialog.plugins.empty": "Plugins configurados en opencode.json",
|
||||
|
||||
"mcp.status.connected": "conectado",
|
||||
"mcp.status.failed": "fallido",
|
||||
"mcp.status.needs_auth": "necesita auth",
|
||||
|
|
@ -224,7 +227,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "No se pudo conectar al servidor",
|
||||
"dialog.server.add.checking": "Comprobando...",
|
||||
"dialog.server.add.button": "Añadir",
|
||||
"dialog.server.add.button": "Añadir servidor",
|
||||
"dialog.server.default.title": "Servidor predeterminado",
|
||||
"dialog.server.default.description":
|
||||
"Conectar a este servidor al iniciar la app en lugar de iniciar un servidor local. Requiere reinicio.",
|
||||
|
|
@ -233,6 +236,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "Limpiar",
|
||||
"dialog.server.action.remove": "Eliminar servidor",
|
||||
|
||||
"dialog.server.menu.edit": "Editar",
|
||||
"dialog.server.menu.default": "Establecer como predeterminado",
|
||||
"dialog.server.menu.defaultRemove": "Quitar predeterminado",
|
||||
"dialog.server.menu.delete": "Eliminar",
|
||||
"dialog.server.current": "Servidor actual",
|
||||
"dialog.server.status.default": "Predeterminado",
|
||||
|
||||
"dialog.project.edit.title": "Editar proyecto",
|
||||
"dialog.project.edit.name": "Nombre",
|
||||
"dialog.project.edit.icon": "Icono",
|
||||
|
|
@ -277,20 +287,20 @@ export const dict = {
|
|||
"context.usage.clickToView": "Haz clic para ver contexto",
|
||||
"context.usage.view": "Ver uso del contexto",
|
||||
|
||||
"language.en": "Inglés",
|
||||
"language.zh": "Chino (simplificado)",
|
||||
"language.zht": "Chino (tradicional)",
|
||||
"language.ko": "Coreano",
|
||||
"language.de": "Alemán",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Francés",
|
||||
"language.ja": "Japonés",
|
||||
"language.da": "Danés",
|
||||
"language.ru": "Ruso",
|
||||
"language.pl": "Polaco",
|
||||
"language.ar": "Árabe",
|
||||
"language.no": "Noruego",
|
||||
"language.br": "Portugués (Brasil)",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "Idioma",
|
||||
"toast.language.description": "Cambiado a {{language}}",
|
||||
|
|
|
|||
|
|
@ -205,6 +205,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "{{enabled}} sur {{total}} activés",
|
||||
"dialog.mcp.empty": "Aucun MCP configuré",
|
||||
|
||||
"dialog.lsp.empty": "LSPs détectés automatiquement par type de fichier",
|
||||
"dialog.plugins.empty": "Plugins configurés dans opencode.json",
|
||||
|
||||
"mcp.status.connected": "connecté",
|
||||
"mcp.status.failed": "échoué",
|
||||
"mcp.status.needs_auth": "nécessite auth",
|
||||
|
|
@ -224,7 +227,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Impossible de se connecter au serveur",
|
||||
"dialog.server.add.checking": "Vérification...",
|
||||
"dialog.server.add.button": "Ajouter",
|
||||
"dialog.server.add.button": "Ajouter un serveur",
|
||||
"dialog.server.default.title": "Serveur par défaut",
|
||||
"dialog.server.default.description":
|
||||
"Se connecter à ce serveur au lancement de l'application au lieu de démarrer un serveur local. Nécessite un redémarrage.",
|
||||
|
|
@ -233,6 +236,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "Effacer",
|
||||
"dialog.server.action.remove": "Supprimer le serveur",
|
||||
|
||||
"dialog.server.menu.edit": "Modifier",
|
||||
"dialog.server.menu.default": "Définir par défaut",
|
||||
"dialog.server.menu.defaultRemove": "Supprimer par défaut",
|
||||
"dialog.server.menu.delete": "Supprimer",
|
||||
"dialog.server.current": "Serveur actuel",
|
||||
"dialog.server.status.default": "Défaut",
|
||||
|
||||
"dialog.project.edit.title": "Modifier le projet",
|
||||
"dialog.project.edit.name": "Nom",
|
||||
"dialog.project.edit.icon": "Icône",
|
||||
|
|
@ -277,20 +287,20 @@ export const dict = {
|
|||
"context.usage.clickToView": "Cliquez pour voir le contexte",
|
||||
"context.usage.view": "Voir l'utilisation du contexte",
|
||||
|
||||
"language.en": "Anglais",
|
||||
"language.zh": "Chinois (simplifié)",
|
||||
"language.zht": "Chinois (traditionnel)",
|
||||
"language.ko": "Coréen",
|
||||
"language.de": "Allemand",
|
||||
"language.es": "Espagnol",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.ja": "Japonais",
|
||||
"language.da": "Danois",
|
||||
"language.ru": "Russe",
|
||||
"language.pl": "Polonais",
|
||||
"language.ar": "Arabe",
|
||||
"language.no": "Norvégien",
|
||||
"language.br": "Portugais (Brésil)",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "Langue",
|
||||
"toast.language.description": "Passé à {{language}}",
|
||||
|
|
|
|||
|
|
@ -204,6 +204,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "{{total}}個中{{enabled}}個が有効",
|
||||
"dialog.mcp.empty": "MCPが設定されていません",
|
||||
|
||||
"dialog.lsp.empty": "ファイルタイプから自動検出されたLSP",
|
||||
"dialog.plugins.empty": "opencode.jsonで設定されたプラグイン",
|
||||
|
||||
"mcp.status.connected": "接続済み",
|
||||
"mcp.status.failed": "失敗",
|
||||
"mcp.status.needs_auth": "認証が必要",
|
||||
|
|
@ -223,7 +226,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "サーバーに接続できませんでした",
|
||||
"dialog.server.add.checking": "確認中...",
|
||||
"dialog.server.add.button": "追加",
|
||||
"dialog.server.add.button": "サーバーを追加",
|
||||
"dialog.server.default.title": "デフォルトサーバー",
|
||||
"dialog.server.default.description":
|
||||
"ローカルサーバーを起動する代わりに、アプリ起動時にこのサーバーに接続します。再起動が必要です。",
|
||||
|
|
@ -232,6 +235,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "クリア",
|
||||
"dialog.server.action.remove": "サーバーを削除",
|
||||
|
||||
"dialog.server.menu.edit": "編集",
|
||||
"dialog.server.menu.default": "デフォルトに設定",
|
||||
"dialog.server.menu.defaultRemove": "デフォルト設定を解除",
|
||||
"dialog.server.menu.delete": "削除",
|
||||
"dialog.server.current": "現在のサーバー",
|
||||
"dialog.server.status.default": "デフォルト",
|
||||
|
||||
"dialog.project.edit.title": "プロジェクトを編集",
|
||||
"dialog.project.edit.name": "名前",
|
||||
"dialog.project.edit.icon": "アイコン",
|
||||
|
|
@ -275,20 +285,20 @@ export const dict = {
|
|||
"context.usage.clickToView": "クリックしてコンテキストを表示",
|
||||
"context.usage.view": "コンテキスト使用量を表示",
|
||||
|
||||
"language.en": "英語",
|
||||
"language.zh": "中国語(簡体字)",
|
||||
"language.zht": "中国語(繁体字)",
|
||||
"language.ko": "韓国語",
|
||||
"language.de": "ドイツ語",
|
||||
"language.es": "スペイン語",
|
||||
"language.fr": "フランス語",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.da": "デンマーク語",
|
||||
"language.ru": "ロシア語",
|
||||
"language.pl": "ポーランド語",
|
||||
"language.ar": "アラビア語",
|
||||
"language.no": "ノルウェー語",
|
||||
"language.br": "ポルトガル語(ブラジル)",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "言語",
|
||||
"toast.language.description": "{{language}}に切り替えました",
|
||||
|
|
|
|||
|
|
@ -208,6 +208,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "{{total}}개 중 {{enabled}}개 활성화됨",
|
||||
"dialog.mcp.empty": "구성된 MCP 없음",
|
||||
|
||||
"dialog.lsp.empty": "파일 유형에서 자동 감지된 LSP",
|
||||
"dialog.plugins.empty": "opencode.json에 구성된 플러그인",
|
||||
|
||||
"mcp.status.connected": "연결됨",
|
||||
"mcp.status.failed": "실패",
|
||||
"mcp.status.needs_auth": "인증 필요",
|
||||
|
|
@ -227,7 +230,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "서버에 연결할 수 없습니다",
|
||||
"dialog.server.add.checking": "확인 중...",
|
||||
"dialog.server.add.button": "추가",
|
||||
"dialog.server.add.button": "서버 추가",
|
||||
"dialog.server.default.title": "기본 서버",
|
||||
"dialog.server.default.description":
|
||||
"로컬 서버를 시작하는 대신 앱 실행 시 이 서버에 연결합니다. 다시 시작해야 합니다.",
|
||||
|
|
@ -236,6 +239,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "지우기",
|
||||
"dialog.server.action.remove": "서버 제거",
|
||||
|
||||
"dialog.server.menu.edit": "편집",
|
||||
"dialog.server.menu.default": "기본값으로 설정",
|
||||
"dialog.server.menu.defaultRemove": "기본값 제거",
|
||||
"dialog.server.menu.delete": "삭제",
|
||||
"dialog.server.current": "현재 서버",
|
||||
"dialog.server.status.default": "기본값",
|
||||
|
||||
"dialog.project.edit.title": "프로젝트 편집",
|
||||
"dialog.project.edit.name": "이름",
|
||||
"dialog.project.edit.icon": "아이콘",
|
||||
|
|
@ -279,20 +289,20 @@ export const dict = {
|
|||
"context.usage.clickToView": "컨텍스트를 보려면 클릭",
|
||||
"context.usage.view": "컨텍스트 사용량 보기",
|
||||
|
||||
"language.en": "영어",
|
||||
"language.zh": "중국어 (간체)",
|
||||
"language.zht": "중국어 (번체)",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "독일어",
|
||||
"language.es": "스페인어",
|
||||
"language.fr": "프랑스어",
|
||||
"language.ja": "일본어",
|
||||
"language.da": "덴마크어",
|
||||
"language.ru": "러시아어",
|
||||
"language.pl": "폴란드어",
|
||||
"language.ar": "아랍어",
|
||||
"language.no": "노르웨이어",
|
||||
"language.br": "포르투갈어 (브라질)",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "언어",
|
||||
"toast.language.description": "{{language}}(으)로 전환됨",
|
||||
|
|
|
|||
|
|
@ -226,6 +226,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "{{enabled}} av {{total}} aktivert",
|
||||
"dialog.mcp.empty": "Ingen MCP-er konfigurert",
|
||||
|
||||
"dialog.lsp.empty": "LSP-er automatisk oppdaget fra filtyper",
|
||||
"dialog.plugins.empty": "Plugins konfigurert i opencode.json",
|
||||
|
||||
"mcp.status.connected": "tilkoblet",
|
||||
"mcp.status.failed": "mislyktes",
|
||||
"mcp.status.needs_auth": "trenger autentisering",
|
||||
|
|
@ -245,7 +248,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Kunne ikke koble til server",
|
||||
"dialog.server.add.checking": "Sjekker...",
|
||||
"dialog.server.add.button": "Legg til",
|
||||
"dialog.server.add.button": "Legg til server",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
"dialog.server.default.description":
|
||||
"Koble til denne serveren ved oppstart i stedet for å starte en lokal server. Krever omstart.",
|
||||
|
|
@ -254,6 +257,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "Tøm",
|
||||
"dialog.server.action.remove": "Fjern server",
|
||||
|
||||
"dialog.server.menu.edit": "Rediger",
|
||||
"dialog.server.menu.default": "Sett som standard",
|
||||
"dialog.server.menu.defaultRemove": "Fjern standard",
|
||||
"dialog.server.menu.delete": "Slett",
|
||||
"dialog.server.current": "Gjeldende server",
|
||||
"dialog.server.status.default": "Standard",
|
||||
|
||||
"dialog.project.edit.title": "Rediger prosjekt",
|
||||
"dialog.project.edit.name": "Navn",
|
||||
"dialog.project.edit.icon": "Ikon",
|
||||
|
|
@ -297,20 +307,20 @@ export const dict = {
|
|||
"context.usage.clickToView": "Klikk for å se kontekst",
|
||||
"context.usage.view": "Se kontekstforbruk",
|
||||
|
||||
"language.en": "Engelsk",
|
||||
"language.zh": "Kinesisk (forenklet)",
|
||||
"language.zht": "Kinesisk (tradisjonell)",
|
||||
"language.ko": "Koreansk",
|
||||
"language.de": "Tysk",
|
||||
"language.es": "Spansk",
|
||||
"language.fr": "Fransk",
|
||||
"language.ja": "Japansk",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ru": "Russisk",
|
||||
"language.pl": "Polsk",
|
||||
"language.ar": "Arabisk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Portugisisk (Brasil)",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "Språk",
|
||||
"toast.language.description": "Byttet til {{language}}",
|
||||
|
|
|
|||
|
|
@ -223,6 +223,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "{{enabled}} z {{total}} włączone",
|
||||
"dialog.mcp.empty": "Brak skonfigurowanych MCP",
|
||||
|
||||
"dialog.lsp.empty": "LSP wykryte automatycznie na podstawie typów plików",
|
||||
"dialog.plugins.empty": "Wtyczki skonfigurowane w opencode.json",
|
||||
|
||||
"mcp.status.connected": "połączono",
|
||||
"mcp.status.failed": "niepowodzenie",
|
||||
"mcp.status.needs_auth": "wymaga autoryzacji",
|
||||
|
|
@ -242,7 +245,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Nie można połączyć się z serwerem",
|
||||
"dialog.server.add.checking": "Sprawdzanie...",
|
||||
"dialog.server.add.button": "Dodaj",
|
||||
"dialog.server.add.button": "Dodaj serwer",
|
||||
"dialog.server.default.title": "Domyślny serwer",
|
||||
"dialog.server.default.description":
|
||||
"Połącz z tym serwerem przy uruchomieniu aplikacji zamiast uruchamiać lokalny serwer. Wymaga restartu.",
|
||||
|
|
@ -251,6 +254,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "Wyczyść",
|
||||
"dialog.server.action.remove": "Usuń serwer",
|
||||
|
||||
"dialog.server.menu.edit": "Edytuj",
|
||||
"dialog.server.menu.default": "Ustaw jako domyślny",
|
||||
"dialog.server.menu.defaultRemove": "Usuń domyślny",
|
||||
"dialog.server.menu.delete": "Usuń",
|
||||
"dialog.server.current": "Obecny serwer",
|
||||
"dialog.server.status.default": "Domyślny",
|
||||
|
||||
"dialog.project.edit.title": "Edytuj projekt",
|
||||
"dialog.project.edit.name": "Nazwa",
|
||||
"dialog.project.edit.icon": "Ikona",
|
||||
|
|
@ -294,19 +304,20 @@ export const dict = {
|
|||
"context.usage.clickToView": "Kliknij, aby zobaczyć kontekst",
|
||||
"context.usage.view": "Pokaż użycie kontekstu",
|
||||
|
||||
"language.en": "Angielski",
|
||||
"language.zh": "Chiński",
|
||||
"language.ko": "Koreański",
|
||||
"language.de": "Niemiecki",
|
||||
"language.es": "Hiszpański",
|
||||
"language.fr": "Francuski",
|
||||
"language.ja": "Japoński",
|
||||
"language.da": "Duński",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Rosyjski",
|
||||
"language.ar": "Arabski",
|
||||
"language.no": "Norweski",
|
||||
"language.br": "Portugalski (Brazylia)",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "Język",
|
||||
"toast.language.description": "Przełączono na {{language}}",
|
||||
|
|
|
|||
|
|
@ -223,6 +223,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "{{enabled}} из {{total}} включено",
|
||||
"dialog.mcp.empty": "MCP не настроены",
|
||||
|
||||
"dialog.lsp.empty": "LSP автоматически обнаружены по типам файлов",
|
||||
"dialog.plugins.empty": "Плагины настроены в opencode.json",
|
||||
|
||||
"mcp.status.connected": "подключено",
|
||||
"mcp.status.failed": "ошибка",
|
||||
"mcp.status.needs_auth": "требуется авторизация",
|
||||
|
|
@ -242,7 +245,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Не удалось подключиться к серверу",
|
||||
"dialog.server.add.checking": "Проверка...",
|
||||
"dialog.server.add.button": "Добавить",
|
||||
"dialog.server.add.button": "Добавить сервер",
|
||||
"dialog.server.default.title": "Сервер по умолчанию",
|
||||
"dialog.server.default.description":
|
||||
"Подключаться к этому серверу при запуске приложения вместо запуска локального сервера. Требуется перезапуск.",
|
||||
|
|
@ -251,6 +254,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "Очистить",
|
||||
"dialog.server.action.remove": "Удалить сервер",
|
||||
|
||||
"dialog.server.menu.edit": "Редактировать",
|
||||
"dialog.server.menu.default": "Сделать по умолчанию",
|
||||
"dialog.server.menu.defaultRemove": "Удалить по умолчанию",
|
||||
"dialog.server.menu.delete": "Удалить",
|
||||
"dialog.server.current": "Текущий сервер",
|
||||
"dialog.server.status.default": "По умолч.",
|
||||
|
||||
"dialog.project.edit.title": "Редактировать проект",
|
||||
"dialog.project.edit.name": "Название",
|
||||
"dialog.project.edit.icon": "Иконка",
|
||||
|
|
@ -295,18 +305,20 @@ export const dict = {
|
|||
"context.usage.clickToView": "Нажмите для просмотра контекста",
|
||||
"context.usage.view": "Показать использование контекста",
|
||||
|
||||
"language.en": "Английский",
|
||||
"language.zh": "Китайский",
|
||||
"language.ko": "Корейский",
|
||||
"language.de": "Немецкий",
|
||||
"language.es": "Испанский",
|
||||
"language.fr": "Французский",
|
||||
"language.ja": "Японский",
|
||||
"language.da": "Датский",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "Арабский",
|
||||
"language.no": "Норвежский",
|
||||
"language.br": "Португальский (Бразилия)",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "Язык",
|
||||
"toast.language.description": "Переключено на {{language}}",
|
||||
|
|
|
|||
|
|
@ -205,6 +205,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "已启用 {{enabled}} / {{total}}",
|
||||
"dialog.mcp.empty": "未配置 MCPs",
|
||||
|
||||
"dialog.lsp.empty": "已从文件类型自动检测到 LSPs",
|
||||
"dialog.plugins.empty": "在 opencode.json 中配置的插件",
|
||||
|
||||
"mcp.status.connected": "已连接",
|
||||
"mcp.status.failed": "失败",
|
||||
"mcp.status.needs_auth": "需要授权",
|
||||
|
|
@ -224,7 +227,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "无法连接到服务器",
|
||||
"dialog.server.add.checking": "检查中...",
|
||||
"dialog.server.add.button": "添加",
|
||||
"dialog.server.add.button": "添加服务器",
|
||||
"dialog.server.default.title": "默认服务器",
|
||||
"dialog.server.default.description": "应用启动时连接此服务器,而不是启动本地服务器。需要重启。",
|
||||
"dialog.server.default.none": "未选择服务器",
|
||||
|
|
@ -232,6 +235,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "清除",
|
||||
"dialog.server.action.remove": "移除服务器",
|
||||
|
||||
"dialog.server.menu.edit": "编辑",
|
||||
"dialog.server.menu.default": "设为默认",
|
||||
"dialog.server.menu.defaultRemove": "取消默认",
|
||||
"dialog.server.menu.delete": "删除",
|
||||
"dialog.server.current": "当前服务器",
|
||||
"dialog.server.status.default": "默认",
|
||||
|
||||
"dialog.project.edit.title": "编辑项目",
|
||||
"dialog.project.edit.name": "名称",
|
||||
"dialog.project.edit.icon": "图标",
|
||||
|
|
@ -275,20 +285,20 @@ export const dict = {
|
|||
"context.usage.clickToView": "点击查看上下文",
|
||||
"context.usage.view": "查看上下文用量",
|
||||
|
||||
"language.en": "英语",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁体中文",
|
||||
"language.ko": "韩语",
|
||||
"language.de": "德语",
|
||||
"language.es": "西班牙语",
|
||||
"language.fr": "法语",
|
||||
"language.ja": "日语",
|
||||
"language.da": "丹麦语",
|
||||
"language.ru": "俄语",
|
||||
"language.pl": "波兰语",
|
||||
"language.ar": "阿拉伯语",
|
||||
"language.no": "挪威语",
|
||||
"language.br": "葡萄牙语(巴西)",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "语言",
|
||||
"toast.language.description": "已切换到{{language}}",
|
||||
|
|
|
|||
|
|
@ -207,6 +207,9 @@ export const dict = {
|
|||
"dialog.mcp.description": "已啟用 {{enabled}} / {{total}}",
|
||||
"dialog.mcp.empty": "未設定 MCP",
|
||||
|
||||
"dialog.lsp.empty": "已從檔案類型自動偵測到 LSPs",
|
||||
"dialog.plugins.empty": "在 opencode.json 中設定的外掛程式",
|
||||
|
||||
"mcp.status.connected": "已連線",
|
||||
"mcp.status.failed": "失敗",
|
||||
"mcp.status.needs_auth": "需要授權",
|
||||
|
|
@ -226,7 +229,7 @@ export const dict = {
|
|||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "無法連線到伺服器",
|
||||
"dialog.server.add.checking": "檢查中...",
|
||||
"dialog.server.add.button": "新增",
|
||||
"dialog.server.add.button": "新增伺服器",
|
||||
"dialog.server.default.title": "預設伺服器",
|
||||
"dialog.server.default.description": "應用程式啟動時連線此伺服器,而不是啟動本地伺服器。需要重新啟動。",
|
||||
"dialog.server.default.none": "未選擇伺服器",
|
||||
|
|
@ -234,6 +237,13 @@ export const dict = {
|
|||
"dialog.server.default.clear": "清除",
|
||||
"dialog.server.action.remove": "移除伺服器",
|
||||
|
||||
"dialog.server.menu.edit": "編輯",
|
||||
"dialog.server.menu.default": "設為預設",
|
||||
"dialog.server.menu.defaultRemove": "取消預設",
|
||||
"dialog.server.menu.delete": "刪除",
|
||||
"dialog.server.current": "目前伺服器",
|
||||
"dialog.server.status.default": "預設",
|
||||
|
||||
"dialog.project.edit.title": "編輯專案",
|
||||
"dialog.project.edit.name": "名稱",
|
||||
"dialog.project.edit.icon": "圖示",
|
||||
|
|
@ -277,14 +287,20 @@ export const dict = {
|
|||
"context.usage.clickToView": "點擊查看上下文",
|
||||
"context.usage.view": "檢視上下文用量",
|
||||
|
||||
"language.en": "英語",
|
||||
"language.zh": "簡體中文",
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "韓語",
|
||||
"language.ru": "俄語",
|
||||
"language.ar": "阿拉伯語",
|
||||
"language.no": "挪威語",
|
||||
"language.br": "葡萄牙語(巴西)",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
|
||||
"toast.language.title": "語言",
|
||||
"toast.language.description": "已切換到 {{language}}",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { createMemo, For, Match, Switch } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { useLayout } from "@/context/layout"
|
||||
|
|
|
|||
|
|
@ -89,11 +89,6 @@ export default function Layout(props: ParentProps) {
|
|||
const pageReady = createMemo(() => ready())
|
||||
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
const xlQuery = window.matchMedia("(min-width: 1280px)")
|
||||
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
|
||||
const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
|
||||
xlQuery.addEventListener("change", handleViewportChange)
|
||||
onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
|
||||
|
||||
const params = useParams()
|
||||
const [autoselect, setAutoselect] = createSignal(!params.dir)
|
||||
|
|
@ -550,11 +545,10 @@ export default function Layout(props: ParentProps) {
|
|||
const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
|
||||
workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
||||
|
||||
const isWorkspaceEditing = () => editor.active.startsWith("workspace:")
|
||||
|
||||
const workspaceSetting = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return false
|
||||
if (project.vcs !== "git") return false
|
||||
return layout.sidebar.workspaces(project.worktree)()
|
||||
})
|
||||
|
||||
|
|
@ -594,7 +588,7 @@ export default function Layout(props: ParentProps) {
|
|||
if (!expanded) continue
|
||||
const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
||||
if (!project) continue
|
||||
if (layout.sidebar.workspaces(project.worktree)()) continue
|
||||
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
|
||||
setStore("workspaceExpanded", directory, false)
|
||||
}
|
||||
})
|
||||
|
|
@ -724,7 +718,8 @@ export default function Layout(props: ParentProps) {
|
|||
if (!directory) return
|
||||
|
||||
const [store] = globalSync.child(directory)
|
||||
if (store.message[session.id] !== undefined) return
|
||||
const cached = untrack(() => store.message[session.id] !== undefined)
|
||||
if (cached) return
|
||||
|
||||
const q = queueFor(directory)
|
||||
if (q.inflight.has(session.id)) return
|
||||
|
|
@ -855,14 +850,34 @@ export default function Layout(props: ParentProps) {
|
|||
setStore(
|
||||
produce((draft) => {
|
||||
const removed = new Set<string>([session.id])
|
||||
const collect = (parentID: string) => {
|
||||
for (const item of draft.session) {
|
||||
if (item.parentID !== parentID) continue
|
||||
removed.add(item.id)
|
||||
collect(item.id)
|
||||
|
||||
const byParent = new Map<string, string[]>()
|
||||
for (const item of draft.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
const existing = byParent.get(parentID)
|
||||
if (existing) {
|
||||
existing.push(item.id)
|
||||
continue
|
||||
}
|
||||
byParent.set(parentID, [item.id])
|
||||
}
|
||||
|
||||
const stack = [session.id]
|
||||
while (stack.length) {
|
||||
const parentID = stack.pop()
|
||||
if (!parentID) continue
|
||||
|
||||
const children = byParent.get(parentID)
|
||||
if (!children) continue
|
||||
|
||||
for (const child of children) {
|
||||
if (removed.has(child)) continue
|
||||
removed.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
collect(session.id)
|
||||
|
||||
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
||||
}),
|
||||
)
|
||||
|
|
@ -2027,7 +2042,9 @@ export default function Layout(props: ParentProps) {
|
|||
})
|
||||
|
||||
const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2))
|
||||
const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)())
|
||||
const workspaceEnabled = createMemo(
|
||||
() => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(),
|
||||
)
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
const label = (directory: string) => {
|
||||
|
|
@ -2156,8 +2173,8 @@ export default function Layout(props: ParentProps) {
|
|||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => {
|
||||
layout.sidebar.open()
|
||||
setOpen(false)
|
||||
if (selected()) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
navigateToProject(props.project.worktree)
|
||||
|
|
@ -2256,13 +2273,23 @@ export default function Layout(props: ParentProps) {
|
|||
|
||||
if (!created?.directory) return
|
||||
|
||||
const local = current.worktree
|
||||
const key = workspaceKey(created.directory)
|
||||
const root = workspaceKey(local)
|
||||
|
||||
setBusy(created.directory, true)
|
||||
WorktreeState.pending(created.directory)
|
||||
setStore("workspaceExpanded", created.directory, true)
|
||||
setStore("workspaceExpanded", key, true)
|
||||
if (key !== created.directory) {
|
||||
setStore("workspaceExpanded", created.directory, true)
|
||||
}
|
||||
setStore("workspaceOrder", current.worktree, (prev) => {
|
||||
const existing = prev ?? []
|
||||
const local = current.worktree
|
||||
const next = existing.filter((d) => d !== local && d !== created.directory)
|
||||
const next = existing.filter((item) => {
|
||||
const id = workspaceKey(item)
|
||||
if (id === root) return false
|
||||
return id !== key
|
||||
})
|
||||
return [local, created.directory, ...next]
|
||||
})
|
||||
|
||||
|
|
@ -2277,7 +2304,7 @@ export default function Layout(props: ParentProps) {
|
|||
title: language.t("workspace.new"),
|
||||
category: language.t("command.category.workspace"),
|
||||
keybind: "mod+shift+w",
|
||||
disabled: !layout.sidebar.workspaces(project()?.worktree ?? "")(),
|
||||
disabled: !workspaceSetting(),
|
||||
onSelect: createWorkspace,
|
||||
},
|
||||
])
|
||||
|
|
@ -2405,7 +2432,18 @@ export default function Layout(props: ParentProps) {
|
|||
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => layout.sidebar.toggleWorkspaces(p.worktree)}>
|
||||
<DropdownMenu.Item
|
||||
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
|
||||
onSelect={() => {
|
||||
const enabled = layout.sidebar.workspaces(p.worktree)()
|
||||
if (enabled) {
|
||||
layout.sidebar.toggleWorkspaces(p.worktree)
|
||||
return
|
||||
}
|
||||
if (p.vcs !== "git") return
|
||||
layout.sidebar.toggleWorkspaces(p.worktree)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{layout.sidebar.workspaces(p.worktree)()
|
||||
? language.t("sidebar.workspaces.disable")
|
||||
|
|
@ -2423,7 +2461,7 @@ export default function Layout(props: ParentProps) {
|
|||
</div>
|
||||
|
||||
<Show
|
||||
when={layout.sidebar.workspaces(p.worktree)()}
|
||||
when={workspaceSetting()}
|
||||
fallback={
|
||||
<>
|
||||
<div class="py-4 px-3">
|
||||
|
|
|
|||
|
|
@ -1,16 +1,4 @@
|
|||
import {
|
||||
For,
|
||||
Index,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Match,
|
||||
Switch,
|
||||
createMemo,
|
||||
createEffect,
|
||||
on,
|
||||
createSignal,
|
||||
} from "solid-js"
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
|
@ -27,6 +15,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
|||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
|
|
@ -39,7 +28,7 @@ import { useTerminal, type LocalPTY } from "@/context/terminal"
|
|||
import { useLayout } from "@/context/layout"
|
||||
import { Terminal } from "@/components/terminal"
|
||||
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||
|
|
@ -65,7 +54,6 @@ import {
|
|||
SortableTerminalTab,
|
||||
NewSessionView,
|
||||
} from "@/components/session"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { navMark, navParams } from "@/utils/perf"
|
||||
import { same } from "@/utils/same"
|
||||
|
||||
|
|
@ -101,7 +89,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
|
|||
|
||||
const sdk = useSDK()
|
||||
|
||||
const readFile = (path: string) => {
|
||||
const readFile = async (path: string) => {
|
||||
return sdk.client.file
|
||||
.read({ path })
|
||||
.then((x) => x.data)
|
||||
|
|
@ -190,7 +178,6 @@ export default function Page() {
|
|||
const codeComponent = useCodeComponent()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
|
|
@ -316,12 +303,22 @@ export default function Page() {
|
|||
return sync.session.history.loading(id)
|
||||
})
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
|
||||
const visibleUserMessages = createMemo(() => {
|
||||
const revert = revertMessageID()
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
}, emptyUserMessages)
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
const visibleUserMessages = createMemo(
|
||||
() => {
|
||||
const revert = revertMessageID()
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{
|
||||
equals: same,
|
||||
},
|
||||
)
|
||||
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
|
||||
|
||||
createEffect(
|
||||
|
|
@ -347,13 +344,19 @@ export default function Page() {
|
|||
promptHeight: 0,
|
||||
})
|
||||
|
||||
const renderedUserMessages = createMemo(() => {
|
||||
const msgs = visibleUserMessages()
|
||||
const start = store.turnStart
|
||||
if (start <= 0) return msgs
|
||||
if (start >= msgs.length) return emptyUserMessages
|
||||
return msgs.slice(start)
|
||||
}, emptyUserMessages)
|
||||
const renderedUserMessages = createMemo(
|
||||
() => {
|
||||
const msgs = visibleUserMessages()
|
||||
const start = store.turnStart
|
||||
if (start <= 0) return msgs
|
||||
if (start >= msgs.length) return emptyUserMessages
|
||||
return msgs.slice(start)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{
|
||||
equals: same,
|
||||
},
|
||||
)
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
|
|
@ -519,6 +522,7 @@ export default function Page() {
|
|||
selection: SelectedLineRange
|
||||
comment: string
|
||||
preview?: string
|
||||
origin?: "review" | "file"
|
||||
}) => {
|
||||
const selection = selectionFromLines(input.selection)
|
||||
const preview = input.preview ?? selectionPreview(input.file, selection)
|
||||
|
|
@ -533,6 +537,7 @@ export default function Page() {
|
|||
selection,
|
||||
comment: input.comment,
|
||||
commentID: saved.id,
|
||||
commentOrigin: input.origin,
|
||||
preview,
|
||||
})
|
||||
}
|
||||
|
|
@ -729,7 +734,7 @@ export default function Page() {
|
|||
}
|
||||
const revert = info()?.revert?.messageID
|
||||
// Find the last user message that's not already reverted
|
||||
const message = userMessages().findLast((x) => !revert || x.id < revert)
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
// Restore the prompt from the reverted message
|
||||
|
|
@ -739,7 +744,7 @@ export default function Page() {
|
|||
prompt.set(restored)
|
||||
}
|
||||
// Navigate to the message before the reverted one (which will be the new last visible message)
|
||||
const priorMessage = userMessages().findLast((x) => x.id < message.id)
|
||||
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
|
||||
setActiveMessage(priorMessage)
|
||||
},
|
||||
},
|
||||
|
|
@ -761,14 +766,14 @@ export default function Page() {
|
|||
await sdk.client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
// Navigate to the last message (the one that was at the revert point)
|
||||
const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
|
||||
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(lastMsg)
|
||||
return
|
||||
}
|
||||
// Partial redo - move forward to next message
|
||||
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
// Navigate to the message before the new revert point
|
||||
const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
|
||||
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
|
||||
setActiveMessage(priorMsg)
|
||||
},
|
||||
},
|
||||
|
|
@ -1246,19 +1251,40 @@ export default function Page() {
|
|||
autoScroll.forceScrollToBottom()
|
||||
}
|
||||
|
||||
const closestMessage = (node: Element | null): HTMLElement | null => {
|
||||
if (!node) return null
|
||||
const match = node.closest?.("[data-message-id]") as HTMLElement | null
|
||||
if (match) return match
|
||||
const root = node.getRootNode?.()
|
||||
if (root instanceof ShadowRoot) return closestMessage(root.host)
|
||||
return null
|
||||
}
|
||||
|
||||
const getActiveMessageId = (container: HTMLDivElement) => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
|
||||
const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2))
|
||||
const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100))
|
||||
|
||||
const hit = document.elementFromPoint(x, y)
|
||||
const host = closestMessage(hit)
|
||||
const id = host?.dataset.messageId
|
||||
if (id) return id
|
||||
|
||||
// Fallback: DOM query (handles edge hit-testing cases)
|
||||
const cutoff = container.scrollTop + 100
|
||||
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
|
||||
let id: string | undefined
|
||||
let last: string | undefined
|
||||
|
||||
for (const node of nodes) {
|
||||
const next = node.dataset.messageId
|
||||
if (!next) continue
|
||||
if (node.offsetTop > cutoff) break
|
||||
id = next
|
||||
last = next
|
||||
}
|
||||
|
||||
return id
|
||||
return last
|
||||
}
|
||||
|
||||
const scheduleScrollSpy = (container: HTMLDivElement) => {
|
||||
|
|
@ -1401,7 +1427,7 @@ export default function Page() {
|
|||
classes={{ button: "w-full" }}
|
||||
onClick={() => setStore("mobileTab", "session")}
|
||||
>
|
||||
Session
|
||||
{language.t("session.tab.session")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
|
|
@ -1410,8 +1436,10 @@ export default function Page() {
|
|||
onClick={() => setStore("mobileTab", "review")}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={hasReview()}>{reviewCount()} Files Changed</Match>
|
||||
<Match when={true}>Review</Match>
|
||||
<Match when={hasReview()}>
|
||||
{language.t("session.review.filesChanged", { count: reviewCount() })}
|
||||
</Match>
|
||||
<Match when={true}>{language.t("session.tab.review")}</Match>
|
||||
</Switch>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
|
@ -1422,7 +1450,7 @@ export default function Page() {
|
|||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
|
||||
"flex-1 md:flex-none py-6 md:py-3": true,
|
||||
"flex-1 md:flex-none pt-6 md:pt-3": true,
|
||||
}}
|
||||
style={{
|
||||
width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
|
||||
|
|
@ -1441,13 +1469,17 @@ export default function Page() {
|
|||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
|
||||
fallback={
|
||||
<div class="px-4 py-4 text-text-weak">
|
||||
{language.t("session.review.loadingChanges")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle="unified"
|
||||
onLineComment={addCommentToContext}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
|
|
@ -1467,7 +1499,9 @@ export default function Page() {
|
|||
<Match when={true}>
|
||||
<div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
|
||||
<div class="text-13-regular text-text-weak max-w-56">
|
||||
{language.t("session.review.empty")}
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
@ -1509,9 +1543,9 @@ export default function Page() {
|
|||
}}
|
||||
onClick={autoScroll.handleInteraction}
|
||||
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
|
||||
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
|
||||
style={{ "--session-title-height": info()?.title || info()?.parentID ? "40px" : "0px" }}
|
||||
>
|
||||
<Show when={info()?.title}>
|
||||
<Show when={info()?.title || info()?.parentID}>
|
||||
<div
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-background-stronger": true,
|
||||
|
|
@ -1520,8 +1554,21 @@ export default function Page() {
|
|||
"md:max-w-200 md:mx-auto": !showTabs(),
|
||||
}}
|
||||
>
|
||||
<div class="h-10 flex items-center">
|
||||
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
|
||||
<div class="h-10 flex items-center gap-1">
|
||||
<Show when={info()?.parentID}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
navigate(`/${params.dir}/session/${info()?.parentID}`)
|
||||
}}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={info()?.title}>
|
||||
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -1635,11 +1682,11 @@ export default function Page() {
|
|||
{/* Prompt input */}
|
||||
<div
|
||||
ref={(el) => (promptDock = el)}
|
||||
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
|
||||
class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-full md:px-6 pointer-events-auto": true,
|
||||
"w-full px-4 pointer-events-auto": true,
|
||||
"md:max-w-200": !showTabs(),
|
||||
}}
|
||||
>
|
||||
|
|
@ -1699,7 +1746,7 @@ export default function Page() {
|
|||
<DiffChanges changes={diffs()} variant="bars" />
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>Review</div>
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={info()?.summary?.files}>
|
||||
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
||||
{info()?.summary?.files ?? 0}
|
||||
|
|
@ -1727,7 +1774,7 @@ export default function Page() {
|
|||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage variant="indicator" />
|
||||
<div>Context</div>
|
||||
<div>{language.t("session.tab.context")}</div>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
|
|
@ -1736,7 +1783,7 @@ export default function Page() {
|
|||
</SortableProvider>
|
||||
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
||||
<TooltipKeybind
|
||||
title="Open file"
|
||||
title={language.t("command.file.open")}
|
||||
keybind={command.keybind("file.open")}
|
||||
class="flex items-center"
|
||||
>
|
||||
|
|
@ -1759,14 +1806,18 @@ export default function Page() {
|
|||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
|
||||
fallback={
|
||||
<div class="px-6 py-4 text-text-weak">
|
||||
{language.t("session.review.loadingChanges")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle={layout.review.diffStyle()}
|
||||
onDiffStyleChange={layout.review.setDiffStyle}
|
||||
onLineComment={addCommentToContext}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
|
|
@ -1809,6 +1860,7 @@ export default function Page() {
|
|||
let scrollFrame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
let codeScroll: HTMLElement[] = []
|
||||
let focusToken = 0
|
||||
|
||||
const path = createMemo(() => file.pathFromTab(tab))
|
||||
const state = createMemo(() => {
|
||||
|
|
@ -1855,7 +1907,6 @@ export default function Page() {
|
|||
})
|
||||
|
||||
let wrap: HTMLDivElement | undefined
|
||||
let textarea: HTMLTextAreaElement | undefined
|
||||
|
||||
const fileComments = createMemo(() => {
|
||||
const p = path()
|
||||
|
|
@ -1871,6 +1922,8 @@ export default function Page() {
|
|||
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
||||
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
||||
|
||||
const empty = {} as Record<string, number>
|
||||
|
||||
const commentLabel = (range: SelectedLineRange) => {
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
|
|
@ -1904,12 +1957,22 @@ export default function Page() {
|
|||
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
||||
}
|
||||
|
||||
const equal = (a: Record<string, number>, b: Record<string, number>) => {
|
||||
const aKeys = Object.keys(a)
|
||||
const bKeys = Object.keys(b)
|
||||
if (aKeys.length !== bKeys.length) return false
|
||||
for (const key of aKeys) {
|
||||
if (a[key] !== b[key]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const updateComments = () => {
|
||||
const el = wrap
|
||||
const root = getRoot()
|
||||
if (!el || !root) {
|
||||
setPositions({})
|
||||
setDraftTop(undefined)
|
||||
setPositions((prev) => (Object.keys(prev).length === 0 ? prev : empty))
|
||||
setDraftTop((prev) => (prev === undefined ? prev : undefined))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1920,7 +1983,7 @@ export default function Page() {
|
|||
next[comment.id] = markerTop(el, marker)
|
||||
}
|
||||
|
||||
setPositions(next)
|
||||
setPositions((prev) => (equal(prev, next) ? prev : next))
|
||||
|
||||
const range = commenting()
|
||||
if (!range) {
|
||||
|
|
@ -1934,11 +1997,18 @@ export default function Page() {
|
|||
return
|
||||
}
|
||||
|
||||
setDraftTop(markerTop(el, marker))
|
||||
const nextTop = markerTop(el, marker)
|
||||
setDraftTop((prev) => (prev === nextTop ? prev : nextTop))
|
||||
}
|
||||
|
||||
let commentFrame: number | undefined
|
||||
|
||||
const scheduleComments = () => {
|
||||
requestAnimationFrame(updateComments)
|
||||
if (commentFrame !== undefined) return
|
||||
commentFrame = requestAnimationFrame(() => {
|
||||
commentFrame = undefined
|
||||
updateComments()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -1955,7 +2025,63 @@ export default function Page() {
|
|||
const range = commenting()
|
||||
if (!range) return
|
||||
setDraft("")
|
||||
requestAnimationFrame(() => textarea?.focus())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const focus = comments.focus()
|
||||
const p = path()
|
||||
if (!focus || !p) return
|
||||
if (focus.file !== p) return
|
||||
if (activeTab() !== tab) return
|
||||
|
||||
const target = fileComments().find((comment) => comment.id === focus.id)
|
||||
if (!target) return
|
||||
|
||||
focusToken++
|
||||
const token = focusToken
|
||||
|
||||
setOpenedComment(target.id)
|
||||
setCommenting(null)
|
||||
file.setSelectedLines(p, target.selection)
|
||||
|
||||
const scrollTo = (attempt: number) => {
|
||||
if (token !== focusToken) return
|
||||
|
||||
const root = scroll
|
||||
if (!root) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => scrollTo(attempt + 1))
|
||||
return
|
||||
}
|
||||
|
||||
const anchor = root.querySelector(`[data-comment-id="${target.id}"]`)
|
||||
const ready =
|
||||
anchor instanceof HTMLElement &&
|
||||
anchor.style.pointerEvents !== "none" &&
|
||||
anchor.style.opacity !== "0"
|
||||
|
||||
const shadow = getRoot()
|
||||
const marker = shadow ? findMarker(shadow, target.selection) : undefined
|
||||
const node = (ready ? anchor : (marker ?? wrap)) as HTMLElement | undefined
|
||||
if (!node) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => scrollTo(attempt + 1))
|
||||
return
|
||||
}
|
||||
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const targetRect = node.getBoundingClientRect()
|
||||
const offset = targetRect.top - rootRect.top
|
||||
const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
|
||||
root.scrollTop = Math.max(0, next)
|
||||
|
||||
if (ready || marker) return
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => scrollTo(attempt + 1))
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => scrollTo(0))
|
||||
requestAnimationFrame(() => comments.clearFocus())
|
||||
})
|
||||
|
||||
const renderCode = (source: string, wrapperClass: string) => (
|
||||
|
|
@ -2000,105 +2126,58 @@ export default function Page() {
|
|||
/>
|
||||
<For each={fileComments()}>
|
||||
{(comment) => (
|
||||
<div
|
||||
class="absolute right-6 z-30"
|
||||
style={{
|
||||
top: `${positions()[comment.id] ?? 0}px`,
|
||||
opacity: positions()[comment.id] === undefined ? 0 : 1,
|
||||
"pointer-events": positions()[comment.id] === undefined ? "none" : "auto",
|
||||
<LineCommentView
|
||||
id={comment.id}
|
||||
top={positions()[comment.id]}
|
||||
open={openedComment() === comment.id}
|
||||
onMouseEnter={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
|
||||
onMouseEnter={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
onClick={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
setCommenting(null)
|
||||
setOpenedComment((current) => (current === comment.id ? null : comment.id))
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
>
|
||||
<Icon name="speech-bubble" size="small" />
|
||||
</button>
|
||||
<Show when={openedComment() === comment.id}>
|
||||
<div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="text-12-medium text-text-strong whitespace-nowrap">
|
||||
{getFilename(comment.file)}:{commentLabel(comment.selection)}
|
||||
</div>
|
||||
<div class="text-12-regular text-text-base whitespace-pre-wrap">
|
||||
{comment.comment}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
onClick={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
setCommenting(null)
|
||||
setOpenedComment((current) => (current === comment.id ? null : comment.id))
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
comment={comment.comment}
|
||||
selection={commentLabel(comment.selection)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={commenting()}>
|
||||
{(range) => (
|
||||
<Show when={draftTop() !== undefined}>
|
||||
<div class="absolute right-6 z-30" style={{ top: `${draftTop() ?? 0}px` }}>
|
||||
<button
|
||||
type="button"
|
||||
class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
|
||||
onClick={() => textarea?.focus()}
|
||||
>
|
||||
<Icon name="speech-bubble" size="small" />
|
||||
</button>
|
||||
<div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-12-medium text-text-strong">
|
||||
Commenting on {getFilename(path() ?? "")}:{commentLabel(range())}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textarea}
|
||||
class="w-[320px] max-w-[calc(100vw-48px)] resize-vertical p-2 rounded-sm bg-surface-base border border-border-base text-text-strong text-12-regular leading-5 focus:outline-none focus:shadow-xs-border-focus"
|
||||
rows={3}
|
||||
placeholder="Add a comment"
|
||||
value={draft()}
|
||||
onInput={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return
|
||||
if (e.shiftKey) return
|
||||
e.preventDefault()
|
||||
const value = draft().trim()
|
||||
if (!value) return
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({ file: p, selection: range(), comment: value })
|
||||
setCommenting(null)
|
||||
}}
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button size="small" variant="ghost" onClick={() => setCommenting(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
disabled={draft().trim().length === 0}
|
||||
onClick={() => {
|
||||
const value = draft().trim()
|
||||
if (!value) return
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({ file: p, selection: range(), comment: value })
|
||||
setCommenting(null)
|
||||
}}
|
||||
>
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LineCommentEditor
|
||||
top={draftTop()}
|
||||
value={draft()}
|
||||
selection={commentLabel(range())}
|
||||
onInput={setDraft}
|
||||
onCancel={() => setCommenting(null)}
|
||||
onSubmit={(comment) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({
|
||||
file: p,
|
||||
selection: range(),
|
||||
comment,
|
||||
origin: "file",
|
||||
})
|
||||
setCommenting(null)
|
||||
}}
|
||||
onPopoverFocusOut={(e) => {
|
||||
const target = e.relatedTarget as Node | null
|
||||
if (target && e.currentTarget.contains(target)) return
|
||||
// Delay to allow click handlers to fire first
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement || !e.currentTarget.contains(document.activeElement)) {
|
||||
setCommenting(null)
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
|
|
@ -2228,6 +2307,7 @@ export default function Page() {
|
|||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (commentFrame !== undefined) cancelAnimationFrame(commentFrame)
|
||||
for (const item of codeScroll) {
|
||||
item.removeEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
|
|
@ -2384,66 +2464,23 @@ export default function Page() {
|
|||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<For each={terminal.all()}>
|
||||
{(pty) => {
|
||||
const [dismissed, setDismissed] = createSignal(false)
|
||||
return (
|
||||
<div
|
||||
id={`terminal-wrapper-${pty.id}`}
|
||||
class="absolute inset-0"
|
||||
style={{
|
||||
display: terminal.active() === pty.id ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
{(pty) => (
|
||||
<div
|
||||
id={`terminal-wrapper-${pty.id}`}
|
||||
class="absolute inset-0"
|
||||
style={{
|
||||
display: terminal.active() === pty.id ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
<Show when={pty.id} keyed>
|
||||
<Terminal
|
||||
pty={pty}
|
||||
onCleanup={(data) => terminal.update({ ...data, id: pty.id })}
|
||||
onConnect={() => {
|
||||
terminal.update({ id: pty.id, error: false })
|
||||
setDismissed(false)
|
||||
}}
|
||||
onConnectError={() => {
|
||||
setDismissed(false)
|
||||
terminal.update({ id: pty.id, error: true })
|
||||
}}
|
||||
onCleanup={terminal.update}
|
||||
onConnectError={() => terminal.clone(pty.id)}
|
||||
/>
|
||||
<Show when={pty.error && !dismissed()}>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-3"
|
||||
style={{ "background-color": "rgba(0, 0, 0, 0.6)" }}
|
||||
>
|
||||
<Icon
|
||||
name="circle-ban-sign"
|
||||
class="w-8 h-8"
|
||||
style={{ color: "rgba(239, 68, 68, 0.8)" }}
|
||||
/>
|
||||
<div class="text-center" style={{ color: "rgba(255, 255, 255, 0.7)" }}>
|
||||
<div class="text-14-semibold mb-1">{language.t("terminal.connectionLost.title")}</div>
|
||||
<div class="text-12-regular" style={{ color: "rgba(255, 255, 255, 0.5)" }}>
|
||||
{language.t("terminal.connectionLost.description")}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="mt-2 px-3 py-1.5 text-12-medium rounded-lg transition-colors"
|
||||
style={{
|
||||
"background-color": "rgba(255, 255, 255, 0.1)",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.15)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)")
|
||||
}
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
{language.t("common.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -106,5 +106,12 @@ export function soundSrc(id: string | undefined) {
|
|||
export function playSound(src: string | undefined) {
|
||||
if (typeof Audio === "undefined") return
|
||||
if (!src) return
|
||||
void new Audio(src).play().catch(() => undefined)
|
||||
const audio = new Audio(src)
|
||||
audio.play().catch(() => undefined)
|
||||
|
||||
// Return a cleanup function to pause the sound.
|
||||
return () => {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -170,22 +170,18 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
|
|||
</Switch>
|
||||
</li>
|
||||
<Show when={!props.hideGetStarted}>
|
||||
{" "}
|
||||
<li>
|
||||
{" "}
|
||||
<A href="/download" data-slot="cta-button">
|
||||
{" "}
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{" "}
|
||||
<path
|
||||
d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>{" "}
|
||||
</svg>{" "}
|
||||
Free{" "}
|
||||
</A>{" "}
|
||||
/>
|
||||
</svg>
|
||||
Free
|
||||
</A>
|
||||
</li>
|
||||
</Show>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -106,10 +106,13 @@
|
|||
[data-slot="cta-button"] {
|
||||
background: var(--color-background-strong);
|
||||
color: var(--color-text-inverted);
|
||||
padding: 8px 16px;
|
||||
padding: 8px 16px 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
@media (max-width: 55rem) {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export default function Changelog() {
|
|||
<Meta name="description" content="OpenCode release notes and changelog" />
|
||||
|
||||
<div data-component="container">
|
||||
<Header hideGetStarted />
|
||||
<Header />
|
||||
|
||||
<div data-component="content">
|
||||
<section data-component="changelog-hero">
|
||||
|
|
|
|||
|
|
@ -141,8 +141,6 @@ export async function POST(input: APIEvent) {
|
|||
return couponID
|
||||
})()
|
||||
|
||||
// get user
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
// look up current billing
|
||||
const billing = await Billing.get()
|
||||
|
|
@ -398,32 +396,22 @@ export async function POST(input: APIEvent) {
|
|||
}
|
||||
*/
|
||||
}
|
||||
if (body.type === "customer.subscription.updated" && body.data.object.status === "incomplete_expired") {
|
||||
const subscriptionID = body.data.object.id
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
|
||||
await Billing.unsubscribe({ subscriptionID })
|
||||
}
|
||||
if (body.type === "customer.subscription.deleted") {
|
||||
const subscriptionID = body.data.object.id
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
|
||||
const workspaceID = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.subscriptionID, subscriptionID))
|
||||
.then((rows) => rows[0]?.workspaceID),
|
||||
)
|
||||
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({ subscriptionID: null, subscription: null })
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
|
||||
})
|
||||
await Billing.unsubscribe({ subscriptionID })
|
||||
}
|
||||
if (body.type === "invoice.payment_succeeded") {
|
||||
if (
|
||||
body.data.object.billing_reason === "subscription_cycle" ||
|
||||
body.data.object.billing_reason === "subscription_create"
|
||||
body.data.object.billing_reason === "subscription_create" ||
|
||||
body.data.object.billing_reason === "subscription_cycle"
|
||||
) {
|
||||
const invoiceID = body.data.object.id as string
|
||||
const amountInCents = body.data.object.amount_paid
|
||||
|
|
@ -476,6 +464,70 @@ export async function POST(input: APIEvent) {
|
|||
},
|
||||
}),
|
||||
)
|
||||
} else if (body.data.object.billing_reason === "manual") {
|
||||
const workspaceID = body.data.object.metadata?.workspaceID
|
||||
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
|
||||
const invoiceID = body.data.object.id as string
|
||||
const customerID = body.data.object.customer as string
|
||||
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!amountInCents) throw new Error("Amount not found")
|
||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
// get payment id from invoice
|
||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||
expand: ["payments"],
|
||||
})
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
|
||||
reloadError: null,
|
||||
timeReloadError: null,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||
await tx.insert(PaymentTable).values({
|
||||
workspaceID: Actor.workspace(),
|
||||
id: Identifier.create("payment"),
|
||||
amount: centsToMicroCents(amountInCents),
|
||||
invoiceID,
|
||||
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
|
||||
customerID,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
if (body.type === "invoice.payment_failed" || body.type === "invoice.payment_action_required") {
|
||||
if (body.data.object.billing_reason === "manual") {
|
||||
const workspaceID = body.data.object.metadata?.workspaceID
|
||||
const invoiceID = body.data.object.id
|
||||
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||
|
||||
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(invoiceID)
|
||||
console.log(JSON.stringify(paymentIntent))
|
||||
const errorMessage =
|
||||
typeof paymentIntent === "object" && paymentIntent !== null
|
||||
? paymentIntent.last_payment_error?.message
|
||||
: undefined
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
reload: false,
|
||||
reloadError: errorMessage ?? "Payment failed.",
|
||||
timeReloadError: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace())),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (body.type === "charge.refunded") {
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ export function ReloadSection() {
|
|||
</div>
|
||||
</form>
|
||||
</Show>
|
||||
<Show when={billingInfo()?.reload && billingInfo()?.reloadError}>
|
||||
<Show when={billingInfo()?.reloadError}>
|
||||
<div data-slot="section-content">
|
||||
<div data-slot="reload-error">
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -78,8 +78,6 @@ export namespace Billing {
|
|||
const customerID = billing.customerID
|
||||
const paymentMethodID = billing.paymentMethodID
|
||||
const amountInCents = (billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
|
||||
const paymentID = Identifier.create("payment")
|
||||
let invoice
|
||||
try {
|
||||
const draft = await Billing.stripe().invoices.create({
|
||||
customer: customerID!,
|
||||
|
|
@ -87,6 +85,10 @@ export namespace Billing {
|
|||
default_payment_method: paymentMethodID!,
|
||||
collection_method: "charge_automatically",
|
||||
currency: "usd",
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
amount: amountInCents.toString(),
|
||||
},
|
||||
})
|
||||
await Billing.stripe().invoiceItems.create({
|
||||
amount: amountInCents,
|
||||
|
|
@ -103,19 +105,17 @@ export namespace Billing {
|
|||
description: ITEM_FEE_NAME,
|
||||
})
|
||||
await Billing.stripe().invoices.finalizeInvoice(draft.id!)
|
||||
invoice = await Billing.stripe().invoices.pay(draft.id!, {
|
||||
await Billing.stripe().invoices.pay(draft.id!, {
|
||||
off_session: true,
|
||||
payment_method: paymentMethodID!,
|
||||
expand: ["payments"],
|
||||
})
|
||||
if (invoice.status !== "paid" || invoice.payments?.data.length !== 1)
|
||||
throw new Error(invoice.last_finalization_error?.message)
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
reload: false,
|
||||
reloadError: e.message ?? "Payment failed.",
|
||||
timeReloadError: sql`now()`,
|
||||
})
|
||||
|
|
@ -123,25 +123,6 @@ export namespace Billing {
|
|||
)
|
||||
return
|
||||
}
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
|
||||
reloadError: null,
|
||||
timeReloadError: null,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||
await tx.insert(PaymentTable).values({
|
||||
workspaceID: Actor.workspace(),
|
||||
id: paymentID,
|
||||
amount: centsToMicroCents(amountInCents),
|
||||
invoiceID: invoice.id!,
|
||||
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
|
||||
customerID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const grantCredit = async (workspaceID: string, dollarAmount: number) => {
|
||||
|
|
@ -354,4 +335,29 @@ export namespace Billing {
|
|||
return subscription.id
|
||||
},
|
||||
)
|
||||
|
||||
export const unsubscribe = fn(
|
||||
z.object({
|
||||
subscriptionID: z.string(),
|
||||
}),
|
||||
async ({ subscriptionID }) => {
|
||||
const workspaceID = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.subscriptionID, subscriptionID))
|
||||
.then((rows) => rows[0]?.workspaceID),
|
||||
)
|
||||
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({ subscriptionID: null, subscription: null })
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ uuid = { version = "1.19.0", features = ["v4"] }
|
|||
tauri-plugin-decorum = "1.1.1"
|
||||
comrak = { version = "0.50", default-features = false }
|
||||
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18.2"
|
||||
webkit2gtk = "=2.0.1"
|
||||
|
|
|
|||
|
|
@ -525,4 +525,4 @@ async fn spawn_local_server(
|
|||
break Ok(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -370,18 +370,51 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
|
|||
}),
|
||||
)
|
||||
|
||||
const errorMessage = () => {
|
||||
const error = serverData.error
|
||||
if (!error) return "Unknown error"
|
||||
if (typeof error === "string") return error
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
const restartApp = async () => {
|
||||
await invoke("kill_sidecar").catch(() => undefined)
|
||||
await relaunch().catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
// Not using suspense as not all components are compatible with it (undefined refs)
|
||||
<Show
|
||||
when={serverData.state !== "pending" && serverData()}
|
||||
when={serverData.state === "errored"}
|
||||
fallback={
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
|
||||
</div>
|
||||
<Show
|
||||
when={serverData.state !== "pending" && serverData()}
|
||||
fallback={
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(data) => props.children(data)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(data) => props.children(data)}
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base gap-4 px-6">
|
||||
<div class="text-16-semibold">OpenCode failed to start</div>
|
||||
<div class="text-12-regular opacity-70 text-center max-w-xl">
|
||||
The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy)
|
||||
and try again.
|
||||
</div>
|
||||
<div class="w-full max-w-3xl rounded border border-border bg-background-base overflow-auto max-h-64">
|
||||
<pre class="p-3 whitespace-pre-wrap break-words text-11-regular">{errorMessage()}</pre>
|
||||
</div>
|
||||
<button class="px-3 py-2 rounded bg-primary text-primary-foreground" onClick={() => void restartApp()}>
|
||||
Restart App
|
||||
</button>
|
||||
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.34"
|
||||
version = "1.1.36"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
|
|
@ -11,26 +11,26 @@ name = "OpenCode"
|
|||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
1
packages/opencode/.gitignore
vendored
1
packages/opencode/.gitignore
vendored
|
|
@ -2,3 +2,4 @@ research
|
|||
dist
|
||||
gen
|
||||
app.log
|
||||
src/provider/models-snapshot.ts
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ process.chdir(dir)
|
|||
import pkg from "../package.json"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
|
||||
// Fetch and generate models.dev snapshot
|
||||
const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||
: await fetch(`https://models.dev/api.json`).then((x) => x.text())
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
|
||||
)
|
||||
console.log("Generated models-snapshot.ts")
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { PermissionNext } from "@/permission/next"
|
|||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||
import { Global } from "@/global"
|
||||
import path from "path"
|
||||
import { Plugin } from "@/plugin"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
|
|
@ -279,8 +280,8 @@ export namespace Agent {
|
|||
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
|
||||
const language = await Provider.getLanguage(model)
|
||||
|
||||
const system = SystemPrompt.header(defaultModel.providerID)
|
||||
system.push(PROMPT_GENERATE)
|
||||
const system = [PROMPT_GENERATE]
|
||||
await Plugin.trigger("experimental.chat.system.transform", { model }, { system })
|
||||
const existing = await list()
|
||||
|
||||
const params = {
|
||||
|
|
|
|||
|
|
@ -570,6 +570,15 @@ function App() {
|
|||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
|
||||
value: "app.toggle.animations",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("animations_enabled", !kv.get("animations_enabled", true))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
|
|
|
|||
|
|
@ -570,15 +570,6 @@ export function Session() {
|
|||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: animationsEnabled() ? "Disable animations" : "Enable animations",
|
||||
value: "session.toggle.animations",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
setAnimationsEnabled((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Page up",
|
||||
value: "session.page.up",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
|||
import { Keybind } from "@/util/keybind"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Global } from "@/global"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
|
|
@ -304,8 +305,11 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
|||
const textareaKeybindings = useTextareaKeybindings()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
const dialog = useDialog()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (dialog.stack.length > 0) return
|
||||
|
||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
||||
evt.preventDefault()
|
||||
props.onCancel()
|
||||
|
|
@ -384,8 +388,11 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
})
|
||||
const diffKey = Keybind.parse("ctrl+f")[0]
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
const dialog = useDialog()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (dialog.stack.length > 0) return
|
||||
|
||||
if (evt.name === "left" || evt.name == "h") {
|
||||
evt.preventDefault()
|
||||
const idx = keys.indexOf(store.selected)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { createMemo, For, Show } from "solid-js"
|
|||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { tint, useTheme } from "../../context/theme"
|
||||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
|
|
@ -272,7 +272,15 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||
backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
|
||||
onMouseUp={() => selectTab(index())}
|
||||
>
|
||||
<text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
|
||||
<text
|
||||
fg={
|
||||
isActive()
|
||||
? selectedForeground(theme, theme.accent)
|
||||
: isAnswered()
|
||||
? theme.text
|
||||
: theme.textMuted
|
||||
}
|
||||
>
|
||||
{q.header}
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -285,7 +293,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||
backgroundColor={confirm() ? theme.accent : theme.backgroundElement}
|
||||
onMouseUp={() => selectTab(questions().length)}
|
||||
>
|
||||
<text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
|
||||
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
|
@ -304,7 +312,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||
const active = () => i() === store.selected
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<box onMouseOver={() => moveTo(i())} onMouseUp={() => selectOption()}>
|
||||
<box
|
||||
onMouseOver={() => moveTo(i())}
|
||||
onMouseDown={() => moveTo(i())}
|
||||
onMouseUp={() => selectOption()}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
|
||||
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
|
||||
|
|
@ -329,7 +341,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||
}}
|
||||
</For>
|
||||
<Show when={custom()}>
|
||||
<box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
|
||||
<box
|
||||
onMouseOver={() => moveTo(options().length)}
|
||||
onMouseDown={() => moveTo(options().length)}
|
||||
onMouseUp={() => selectOption()}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
|
||||
<text fg={other() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
|
||||
|
|
@ -358,6 +374,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||
}}
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.primary}
|
||||
|
|
|
|||
|
|
@ -149,7 +149,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
|
||||
function moveTo(next: number, center = false) {
|
||||
setStore("selected", next)
|
||||
props.onMove?.(selected()!)
|
||||
const option = selected()
|
||||
if (option) props.onMove?.(option)
|
||||
if (!scroll) return
|
||||
const target = scroll.getChildren().find((child) => {
|
||||
return child.id === JSON.stringify(selected()?.value)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export namespace Flag {
|
|||
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
|
||||
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
|
||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
|
|
|
|||
|
|
@ -22,10 +22,6 @@ export namespace Global {
|
|||
cache,
|
||||
config,
|
||||
state,
|
||||
// Allow overriding models.dev URL for offline deployments
|
||||
get modelsDevUrl() {
|
||||
return process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +33,7 @@ await Promise.all([
|
|||
fs.mkdir(Global.Path.bin, { recursive: true }),
|
||||
])
|
||||
|
||||
const CACHE_VERSION = "18"
|
||||
const CACHE_VERSION = "19"
|
||||
|
||||
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
||||
.text()
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { CopilotAuthPlugin } from "./copilot"
|
|||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
||||
const BUILTIN = ["opencode-anthropic-auth@0.0.9", "@gitlab/opencode-gitlab-auth@1.3.2"]
|
||||
const BUILTIN = ["opencode-anthropic-auth@0.0.10", "@gitlab/opencode-gitlab-auth@1.3.2"]
|
||||
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin]
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import { Global } from "../global"
|
||||
|
||||
export async function data() {
|
||||
const path = Bun.env.MODELS_DEV_API_JSON
|
||||
if (path) {
|
||||
const file = Bun.file(path)
|
||||
if (await file.exists()) {
|
||||
return await file.text()
|
||||
}
|
||||
}
|
||||
const url = Global.Path.modelsDevUrl
|
||||
const json = await fetch(`${url}/api.json`).then((x) => x.text())
|
||||
return json
|
||||
}
|
||||
|
|
@ -2,9 +2,13 @@ import { Global } from "../global"
|
|||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { data } from "./models-macro" with { type: "macro" }
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { lazy } from "@/util/lazy"
|
||||
|
||||
// Try to import bundled snapshot (generated at build time)
|
||||
// Falls back to undefined in dev mode when snapshot doesn't exist
|
||||
/* @ts-ignore */
|
||||
|
||||
export namespace ModelsDev {
|
||||
const log = Log.create({ service: "models.dev" })
|
||||
|
|
@ -76,28 +80,32 @@ export namespace ModelsDev {
|
|||
|
||||
export type Provider = z.infer<typeof Provider>
|
||||
|
||||
export async function get() {
|
||||
refresh()
|
||||
function url() {
|
||||
return Flag.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
}
|
||||
|
||||
export const Data = lazy(async () => {
|
||||
const file = Bun.file(filepath)
|
||||
const result = await file.json().catch(() => {})
|
||||
if (result) return result as Record<string, Provider>
|
||||
if (typeof data === "function") {
|
||||
const json = await data()
|
||||
return JSON.parse(json) as Record<string, Provider>
|
||||
}
|
||||
const url = Global.Path.modelsDevUrl
|
||||
const json = await fetch(`${url}/api.json`).then((x) => x.text())
|
||||
return JSON.parse(json) as Record<string, Provider>
|
||||
if (result) return result
|
||||
// @ts-ignore
|
||||
const snapshot = await import("./models-snapshot")
|
||||
.then((m) => m.snapshot as Record<string, unknown>)
|
||||
.catch(() => undefined)
|
||||
if (snapshot) return snapshot
|
||||
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
|
||||
const json = await fetch(`${url()}/api.json`).then((x) => x.text())
|
||||
return JSON.parse(json)
|
||||
})
|
||||
|
||||
export async function get() {
|
||||
const result = await Data()
|
||||
return result as Record<string, Provider>
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return
|
||||
const file = Bun.file(filepath)
|
||||
log.info("refreshing", {
|
||||
file,
|
||||
})
|
||||
const url = Global.Path.modelsDevUrl
|
||||
const result = await fetch(`${url}/api.json`, {
|
||||
const result = await fetch(`${url()}/api.json`, {
|
||||
headers: {
|
||||
"User-Agent": Installation.USER_AGENT,
|
||||
},
|
||||
|
|
@ -107,8 +115,19 @@ export namespace ModelsDev {
|
|||
error: e,
|
||||
})
|
||||
})
|
||||
if (result && result.ok) await Bun.write(file, await result.text())
|
||||
if (result && result.ok) {
|
||||
await Bun.write(file, await result.text())
|
||||
ModelsDev.Data.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(() => ModelsDev.refresh(), 60 * 1000 * 60).unref()
|
||||
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH) {
|
||||
ModelsDev.refresh()
|
||||
setInterval(
|
||||
async () => {
|
||||
await ModelsDev.refresh()
|
||||
},
|
||||
60 * 1000 * 60,
|
||||
).unref()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { APICallError, ModelMessage } from "ai"
|
||||
import { unique } from "remeda"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import type { JSONSchema } from "zod/v4/core"
|
||||
import type { Provider } from "./provider"
|
||||
import type { ModelsDev } from "./models"
|
||||
|
|
@ -26,6 +26,7 @@ export namespace ProviderTransform {
|
|||
case "@ai-sdk/amazon-bedrock":
|
||||
return "bedrock"
|
||||
case "@ai-sdk/anthropic":
|
||||
case "@ai-sdk/google-vertex/anthropic":
|
||||
return "anthropic"
|
||||
case "@ai-sdk/google-vertex":
|
||||
case "@ai-sdk/google":
|
||||
|
|
@ -186,18 +187,12 @@ export namespace ProviderTransform {
|
|||
if (shouldUseContentOptions) {
|
||||
const lastContent = msg.content[msg.content.length - 1]
|
||||
if (lastContent && typeof lastContent === "object") {
|
||||
lastContent.providerOptions = {
|
||||
...lastContent.providerOptions,
|
||||
...providerOptions,
|
||||
}
|
||||
lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
msg.providerOptions = {
|
||||
...msg.providerOptions,
|
||||
...providerOptions,
|
||||
}
|
||||
msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions)
|
||||
}
|
||||
|
||||
return msgs
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export namespace Question {
|
|||
|
||||
export const Option = z
|
||||
.object({
|
||||
label: z.string().max(30).describe("Display text (1-5 words, concise)"),
|
||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||
description: z.string().describe("Explanation of choice"),
|
||||
})
|
||||
.meta({
|
||||
|
|
@ -21,7 +21,7 @@ export namespace Question {
|
|||
export const Info = z
|
||||
.object({
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().max(30).describe("Very short label (max 30 chars)"),
|
||||
header: z.string().describe("Very short label (max 30 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export namespace LLM {
|
|||
])
|
||||
const isCodex = provider.id === "openai" && auth?.type === "oauth"
|
||||
|
||||
const system = SystemPrompt.header(input.model.providerID)
|
||||
const system = []
|
||||
system.push(
|
||||
[
|
||||
// use agent prompt otherwise provider prompt
|
||||
|
|
@ -83,7 +83,11 @@ export namespace LLM {
|
|||
|
||||
const header = system[0]
|
||||
const original = clone(system)
|
||||
await Plugin.trigger("experimental.chat.system.transform", { sessionID: input.sessionID }, { system })
|
||||
await Plugin.trigger(
|
||||
"experimental.chat.system.transform",
|
||||
{ sessionID: input.sessionID, model: input.model },
|
||||
{ system },
|
||||
)
|
||||
if (system.length === 0) {
|
||||
system.push(...original)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -598,7 +598,7 @@ export namespace SessionPrompt {
|
|||
agent,
|
||||
abort,
|
||||
sessionID,
|
||||
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
|
||||
system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())],
|
||||
messages: [
|
||||
...MessageV2.toModelMessages(sessionMessages, model),
|
||||
...(isLastStep
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
You are Claude Code, Anthropic's official CLI for Claude.
|
||||
|
|
@ -34,11 +34,6 @@ async function resolveRelativeInstruction(instruction: string): Promise<string[]
|
|||
}
|
||||
|
||||
export namespace SystemPrompt {
|
||||
export function header(providerID: string) {
|
||||
if (providerID.includes("anthropic")) return [PROMPT_ANTHROPIC_SPOOF.trim()]
|
||||
return []
|
||||
}
|
||||
|
||||
export function instructions() {
|
||||
return PROMPT_CODEX.trim()
|
||||
}
|
||||
|
|
@ -52,10 +47,11 @@ export namespace SystemPrompt {
|
|||
return [PROMPT_ANTHROPIC_WITHOUT_TODO]
|
||||
}
|
||||
|
||||
export async function environment() {
|
||||
export async function environment(model: Provider.Model) {
|
||||
const project = Instance.project
|
||||
return [
|
||||
[
|
||||
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
|
||||
`Here is some useful information about the environment you are running in:`,
|
||||
`<env>`,
|
||||
` Working directory: ${Instance.directory}`,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export namespace Snapshot {
|
|||
const git = gitdir()
|
||||
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
|
||||
const result =
|
||||
await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
|
||||
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
|
|
|
|||
|
|
@ -158,6 +158,19 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
|||
}
|
||||
}
|
||||
|
||||
// Build per-file metadata for UI rendering (used for both permission and result)
|
||||
const files = fileChanges.map((change) => ({
|
||||
filePath: change.filePath,
|
||||
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath),
|
||||
type: change.type,
|
||||
diff: change.diff,
|
||||
before: change.oldContent,
|
||||
after: change.newContent,
|
||||
additions: change.additions,
|
||||
deletions: change.deletions,
|
||||
movePath: change.movePath,
|
||||
}))
|
||||
|
||||
// Check permissions if needed
|
||||
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath))
|
||||
await ctx.ask({
|
||||
|
|
@ -167,6 +180,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
|||
metadata: {
|
||||
filepath: relativePaths.join(", "),
|
||||
diff: totalDiff,
|
||||
files,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -253,19 +267,6 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
|||
}
|
||||
}
|
||||
|
||||
// Build per-file metadata for UI rendering
|
||||
const files = fileChanges.map((change) => ({
|
||||
filePath: change.filePath,
|
||||
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath),
|
||||
type: change.type,
|
||||
diff: change.diff,
|
||||
before: change.oldContent,
|
||||
after: change.newContent,
|
||||
additions: change.additions,
|
||||
deletions: change.deletions,
|
||||
movePath: change.movePath,
|
||||
}))
|
||||
|
||||
return {
|
||||
title: output,
|
||||
metadata: {
|
||||
|
|
|
|||
|
|
@ -245,6 +245,47 @@ export namespace Worktree {
|
|||
return $`bash -lc ${cmd}`.nothrow().cwd(directory)
|
||||
}
|
||||
|
||||
type StartKind = "project" | "worktree"
|
||||
|
||||
async function runStartScript(directory: string, cmd: string, kind: StartKind) {
|
||||
const text = cmd.trim()
|
||||
if (!text) return true
|
||||
|
||||
const ran = await runStartCommand(directory, text)
|
||||
if (ran.exitCode === 0) return true
|
||||
|
||||
log.error("worktree start command failed", {
|
||||
kind,
|
||||
directory,
|
||||
message: errorText(ran),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
async function runStartScripts(directory: string, input: { projectID: string; extra?: string }) {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get())
|
||||
const project = row ? Project.fromRow(row) : undefined
|
||||
const startup = project?.commands?.start?.trim() ?? ""
|
||||
const ok = await runStartScript(directory, startup, "project")
|
||||
if (!ok) return false
|
||||
|
||||
const extra = input.extra ?? ""
|
||||
await runStartScript(directory, extra, "worktree")
|
||||
return true
|
||||
}
|
||||
|
||||
function queueStartScripts(directory: string, input: { projectID: string; extra?: string }) {
|
||||
setTimeout(() => {
|
||||
const start = async () => {
|
||||
await runStartScripts(directory, input)
|
||||
}
|
||||
|
||||
void start().catch((error) => {
|
||||
log.error("worktree start task failed", { directory, error })
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export const create = fn(CreateInput.optional(), async (input) => {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
|
|
@ -319,28 +360,7 @@ export namespace Worktree {
|
|||
},
|
||||
})
|
||||
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())
|
||||
const project = row ? Project.fromRow(row) : undefined
|
||||
const startup = project?.commands?.start?.trim() ?? ""
|
||||
|
||||
const run = async (cmd: string, kind: "project" | "worktree") => {
|
||||
const ran = await runStartCommand(info.directory, cmd)
|
||||
if (ran.exitCode === 0) return true
|
||||
log.error("worktree start command failed", {
|
||||
kind,
|
||||
directory: info.directory,
|
||||
message: errorText(ran),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (startup) {
|
||||
const ok = await run(startup, "project")
|
||||
if (!ok) return
|
||||
}
|
||||
if (extra) {
|
||||
await run(extra, "worktree")
|
||||
}
|
||||
await runStartScripts(info.directory, { projectID, extra })
|
||||
}
|
||||
|
||||
void start().catch((error) => {
|
||||
|
|
@ -519,8 +539,13 @@ export namespace Worktree {
|
|||
}
|
||||
|
||||
const dirty = outputText(status.stdout)
|
||||
if (!dirty) return true
|
||||
if (dirty) {
|
||||
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
|
||||
}
|
||||
|
||||
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
|
||||
const projectID = Instance.project.id
|
||||
queueStartScripts(worktreePath, { projectID })
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,18 +27,10 @@ process.env["OPENCODE_TEST_HOME"] = testHome
|
|||
// Now safe to import Global (after XDG vars are set)
|
||||
const { Global } = await import("../src/global")
|
||||
|
||||
// Pre-fetch models.json so tests don't need the macro fallback
|
||||
// Also write the cache version file to prevent global/index.ts from clearing the cache
|
||||
// Write the cache version file to prevent global/index.ts from clearing the cache
|
||||
const cacheDir = path.join(dir, "cache", "opencode")
|
||||
await fs.mkdir(cacheDir, { recursive: true })
|
||||
await fs.writeFile(path.join(cacheDir, "version"), "14")
|
||||
const url = Global.Path.modelsDevUrl
|
||||
const response = await fetch(`${url}/api.json`)
|
||||
if (response.ok) {
|
||||
await fs.writeFile(path.join(cacheDir, "models.json"), await response.text())
|
||||
}
|
||||
// Disable models.dev refresh to avoid race conditions during tests
|
||||
process.env["OPENCODE_DISABLE_MODELS_FETCH"] = "true"
|
||||
|
||||
// Clear provider env vars to ensure clean test state
|
||||
delete process.env["ANTHROPIC_API_KEY"]
|
||||
|
|
|
|||
|
|
@ -266,23 +266,78 @@ test("unicode filenames", async () => {
|
|||
expect(before).toBeTruthy()
|
||||
|
||||
const unicodeFiles = [
|
||||
`${tmp.path}/文件.txt`,
|
||||
`${tmp.path}/🚀rocket.txt`,
|
||||
`${tmp.path}/café.txt`,
|
||||
`${tmp.path}/файл.txt`,
|
||||
{ path: `${tmp.path}/文件.txt`, content: "chinese content" },
|
||||
{ path: `${tmp.path}/🚀rocket.txt`, content: "emoji content" },
|
||||
{ path: `${tmp.path}/café.txt`, content: "accented content" },
|
||||
{ path: `${tmp.path}/файл.txt`, content: "cyrillic content" },
|
||||
]
|
||||
|
||||
for (const file of unicodeFiles) {
|
||||
await Bun.write(file, "unicode content")
|
||||
await Bun.write(file.path, file.content)
|
||||
}
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
// Note: git escapes unicode characters by default, so we just check that files are detected
|
||||
// The actual filenames will be escaped like "caf\303\251.txt" but functionality works
|
||||
expect(patch.files.length).toBe(4)
|
||||
|
||||
// Skip revert test due to git filename escaping issues
|
||||
// The functionality works but git uses escaped filenames internally
|
||||
for (const file of unicodeFiles) {
|
||||
expect(patch.files).toContain(file.path)
|
||||
}
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
|
||||
for (const file of unicodeFiles) {
|
||||
expect(await Bun.file(file.path).exists()).toBe(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("unicode filenames modification and restore", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const chineseFile = `${tmp.path}/文件.txt`
|
||||
const cyrillicFile = `${tmp.path}/файл.txt`
|
||||
|
||||
await Bun.write(chineseFile, "original chinese")
|
||||
await Bun.write(cyrillicFile, "original cyrillic")
|
||||
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(chineseFile, "modified chinese")
|
||||
await Bun.write(cyrillicFile, "modified cyrillic")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(chineseFile)
|
||||
expect(patch.files).toContain(cyrillicFile)
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
|
||||
expect(await Bun.file(chineseFile).text()).toBe("original chinese")
|
||||
expect(await Bun.file(cyrillicFile).text()).toBe("original cyrillic")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("unicode filenames in subdirectories", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet()
|
||||
const deepFile = `${tmp.path}/目录/подкаталог/文件.txt`
|
||||
await Bun.write(deepFile, "deep unicode content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(deepFile)
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
expect(await Bun.file(deepFile).exists()).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,7 +18,21 @@ type AskInput = {
|
|||
permission: string
|
||||
patterns: string[]
|
||||
always: string[]
|
||||
metadata: { diff: string }
|
||||
metadata: {
|
||||
diff: string
|
||||
filepath: string
|
||||
files: Array<{
|
||||
filePath: string
|
||||
relativePath: string
|
||||
type: "add" | "update" | "delete" | "move"
|
||||
diff: string
|
||||
before: string
|
||||
after: string
|
||||
additions: number
|
||||
deletions: number
|
||||
movePath?: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
type ToolCtx = typeof baseCtx & {
|
||||
|
|
@ -60,7 +74,7 @@ describe("tool.apply_patch freeform", () => {
|
|||
})
|
||||
|
||||
test("applies add/update/delete in one patch", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
await using fixture = await tmpdir({ git: true })
|
||||
const { ctx, calls } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
|
|
@ -81,6 +95,21 @@ describe("tool.apply_patch freeform", () => {
|
|||
expect(result.metadata.diff).toContain("Index:")
|
||||
expect(calls.length).toBe(1)
|
||||
|
||||
// Verify permission metadata includes files array for UI rendering
|
||||
const permissionCall = calls[0]
|
||||
expect(permissionCall.metadata.files).toHaveLength(3)
|
||||
expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"])
|
||||
|
||||
const addFile = permissionCall.metadata.files.find((f) => f.type === "add")
|
||||
expect(addFile).toBeDefined()
|
||||
expect(addFile!.relativePath).toBe("nested/new.txt")
|
||||
expect(addFile!.after).toBe("created\n")
|
||||
|
||||
const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
|
||||
expect(updateFile).toBeDefined()
|
||||
expect(updateFile!.before).toContain("line2")
|
||||
expect(updateFile!.after).toContain("changed")
|
||||
|
||||
const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
|
||||
expect(added).toBe("created\n")
|
||||
expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n")
|
||||
|
|
@ -89,6 +118,36 @@ describe("tool.apply_patch freeform", () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("permission metadata includes move file info", async () => {
|
||||
await using fixture = await tmpdir({ git: true })
|
||||
const { ctx, calls } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const original = path.join(fixture.path, "old", "name.txt")
|
||||
await fs.mkdir(path.dirname(original), { recursive: true })
|
||||
await fs.writeFile(original, "old content\n", "utf-8")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
const permissionCall = calls[0]
|
||||
expect(permissionCall.metadata.files).toHaveLength(1)
|
||||
|
||||
const moveFile = permissionCall.metadata.files[0]
|
||||
expect(moveFile.type).toBe("move")
|
||||
expect(moveFile.relativePath).toBe("renamed/dir/name.txt")
|
||||
expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt"))
|
||||
expect(moveFile.before).toBe("old content\n")
|
||||
expect(moveFile.after).toBe("new content\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("applies multiple hunks to one file", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
|
|
|||
|
|
@ -63,43 +63,44 @@ describe("tool.question", () => {
|
|||
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
|
||||
})
|
||||
|
||||
test("should throw an Error for header exceeding 30 characters", async () => {
|
||||
const tool = await QuestionTool.init()
|
||||
const questions = [
|
||||
{
|
||||
question: "What is your favorite animal?",
|
||||
header: "This Header is Definitely More Than Thirty Characters Long",
|
||||
options: [{ label: "Dog", description: "Man's best friend" }],
|
||||
},
|
||||
]
|
||||
try {
|
||||
await tool.execute({ questions }, ctx)
|
||||
// If it reaches here, the test should fail
|
||||
expect(true).toBe(false)
|
||||
} catch (e: any) {
|
||||
expect(e).toBeInstanceOf(Error)
|
||||
expect(e.cause).toBeInstanceOf(z.ZodError)
|
||||
}
|
||||
})
|
||||
// intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough
|
||||
// test("should throw an Error for header exceeding 30 characters", async () => {
|
||||
// const tool = await QuestionTool.init()
|
||||
// const questions = [
|
||||
// {
|
||||
// question: "What is your favorite animal?",
|
||||
// header: "This Header is Definitely More Than Thirty Characters Long",
|
||||
// options: [{ label: "Dog", description: "Man's best friend" }],
|
||||
// },
|
||||
// ]
|
||||
// try {
|
||||
// await tool.execute({ questions }, ctx)
|
||||
// // If it reaches here, the test should fail
|
||||
// expect(true).toBe(false)
|
||||
// } catch (e: any) {
|
||||
// expect(e).toBeInstanceOf(Error)
|
||||
// expect(e.cause).toBeInstanceOf(z.ZodError)
|
||||
// }
|
||||
// })
|
||||
|
||||
test("should throw an Error for label exceeding 30 characters", async () => {
|
||||
const tool = await QuestionTool.init()
|
||||
const questions = [
|
||||
{
|
||||
question: "A question with a very long label",
|
||||
header: "Long Label",
|
||||
options: [
|
||||
{ label: "This is a very, very, very long label that will exceed the limit", description: "A description" },
|
||||
],
|
||||
},
|
||||
]
|
||||
try {
|
||||
await tool.execute({ questions }, ctx)
|
||||
// If it reaches here, the test should fail
|
||||
expect(true).toBe(false)
|
||||
} catch (e: any) {
|
||||
expect(e).toBeInstanceOf(Error)
|
||||
expect(e.cause).toBeInstanceOf(z.ZodError)
|
||||
}
|
||||
})
|
||||
// test("should throw an Error for label exceeding 30 characters", async () => {
|
||||
// const tool = await QuestionTool.init()
|
||||
// const questions = [
|
||||
// {
|
||||
// question: "A question with a very long label",
|
||||
// header: "Long Label",
|
||||
// options: [
|
||||
// { label: "This is a very, very, very long label that will exceed the limit", description: "A description" },
|
||||
// ],
|
||||
// },
|
||||
// ]
|
||||
// try {
|
||||
// await tool.execute({ questions }, ctx)
|
||||
// // If it reaches here, the test should fail
|
||||
// expect(true).toBe(false)
|
||||
// } catch (e: any) {
|
||||
// expect(e).toBeInstanceOf(Error)
|
||||
// expect(e.cause).toBeInstanceOf(z.ZodError)
|
||||
// }
|
||||
// })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ export interface Hooks {
|
|||
},
|
||||
) => Promise<void>
|
||||
"experimental.chat.system.transform"?: (
|
||||
input: { sessionID: string },
|
||||
input: { sessionID?: string; model: Model },
|
||||
output: {
|
||||
system: string[]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
"dist"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.4",
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
|
|
|||
|
|
@ -2518,7 +2518,17 @@ export class Control extends HeyApiClient {
|
|||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }, { in: "body" }] }])
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ key: "body", map: "body" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<TuiControlResponseResponses, unknown, ThrowOnError>({
|
||||
url: "/tui/control/response",
|
||||
...options,
|
||||
|
|
@ -2770,7 +2780,17 @@ export class Tui extends HeyApiClient {
|
|||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }, { in: "body" }] }])
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ key: "body", map: "body" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<TuiPublishResponses, TuiPublishErrors, ThrowOnError>({
|
||||
url: "/tui/publish",
|
||||
...options,
|
||||
|
|
|
|||
|
|
@ -7319,8 +7319,7 @@
|
|||
"properties": {
|
||||
"label": {
|
||||
"description": "Display text (1-5 words, concise)",
|
||||
"type": "string",
|
||||
"maxLength": 30
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Explanation of choice",
|
||||
|
|
@ -7338,8 +7337,7 @@
|
|||
},
|
||||
"header": {
|
||||
"description": "Very short label (max 30 chars)",
|
||||
"type": "string",
|
||||
"maxLength": 30
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"description": "Available choices",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
|
@ -56,6 +56,7 @@
|
|||
"marked": "catalog:",
|
||||
"marked-katex-extension": "5.1.6",
|
||||
"marked-shiki": "catalog:",
|
||||
"morphdom": "2.7.8",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
|
|
|
|||
BIN
packages/ui/src/assets/fonts/iosevka-nerd-font-bold.woff2
Normal file
BIN
packages/ui/src/assets/fonts/iosevka-nerd-font-bold.woff2
Normal file
Binary file not shown.
BIN
packages/ui/src/assets/fonts/iosevka-nerd-font.woff2
Normal file
BIN
packages/ui/src/assets/fonts/iosevka-nerd-font.woff2
Normal file
Binary file not shown.
|
|
@ -12,7 +12,7 @@
|
|||
white-space: nowrap;
|
||||
|
||||
&[data-variant="primary"] {
|
||||
background-color: var(--icon-strong-base);
|
||||
background-color: var(--button-primary-base);
|
||||
border-color: var(--border-weak-base);
|
||||
color: var(--icon-invert-base);
|
||||
|
||||
|
|
|
|||
|
|
@ -66,28 +66,16 @@ export function Code<T>(props: CodeProps<T>) {
|
|||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onRendered",
|
||||
"onLineSelectionEnd",
|
||||
])
|
||||
|
||||
const [rendered, setRendered] = createSignal(0)
|
||||
|
||||
const handleLineClick: FileOptions<T>["onLineClick"] = (info) => {
|
||||
props.onLineClick?.(info)
|
||||
|
||||
if (props.enableLineSelection !== true) return
|
||||
if (info.numberColumn) return
|
||||
if (!local.selectedLines) return
|
||||
|
||||
file().setSelectedLines(null)
|
||||
}
|
||||
|
||||
const file = createMemo(
|
||||
() =>
|
||||
new File<T>(
|
||||
{
|
||||
...createDefaultOptions<T>("unified"),
|
||||
...others,
|
||||
onLineClick: props.enableLineSelection === true || props.onLineClick ? handleLineClick : undefined,
|
||||
},
|
||||
getWorkerPool("unified"),
|
||||
),
|
||||
|
|
@ -103,6 +91,19 @@ export function Code<T>(props: CodeProps<T>) {
|
|||
return root
|
||||
}
|
||||
|
||||
const applyScheme = () => {
|
||||
const host = container.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
|
||||
const scheme = document.documentElement.dataset.colorScheme
|
||||
if (scheme === "dark" || scheme === "light") {
|
||||
host.dataset.colorScheme = scheme
|
||||
return
|
||||
}
|
||||
|
||||
host.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
|
@ -332,12 +333,21 @@ export function Code<T>(props: CodeProps<T>) {
|
|||
if (props.enableLineSelection !== true) return
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if (dragMoved) {
|
||||
pendingSelectionEnd = true
|
||||
scheduleDragUpdate()
|
||||
scheduleSelectionUpdate()
|
||||
if (!dragMoved) {
|
||||
pendingSelectionEnd = false
|
||||
const line = dragStart
|
||||
setSelectedLines({ start: line, end: line })
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragMoved = false
|
||||
return
|
||||
}
|
||||
|
||||
pendingSelectionEnd = true
|
||||
scheduleDragUpdate()
|
||||
scheduleSelectionUpdate()
|
||||
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragMoved = false
|
||||
|
|
@ -372,10 +382,24 @@ export function Code<T>(props: CodeProps<T>) {
|
|||
containerWrapper: container,
|
||||
})
|
||||
|
||||
applyScheme()
|
||||
|
||||
setRendered((value) => value + 1)
|
||||
notifyRendered()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
if (typeof MutationObserver === "undefined") return
|
||||
|
||||
const root = document.documentElement
|
||||
const monitor = new MutationObserver(() => applyScheme())
|
||||
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
|
||||
applyScheme()
|
||||
|
||||
onCleanup(() => monitor.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
rendered()
|
||||
const ranges = local.commentedLines ?? []
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@
|
|||
|
||||
[data-slot="dialog-header"] {
|
||||
display: flex;
|
||||
padding: 16px 16px 16px 24px;
|
||||
padding: 20px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
|
|
|||
|
|
@ -28,11 +28,27 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
|||
|
||||
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
|
||||
|
||||
const applyScheme = () => {
|
||||
const scheme = document.documentElement.dataset.colorScheme
|
||||
if (scheme === "dark" || scheme === "light") {
|
||||
fileDiffRef.dataset.colorScheme = scheme
|
||||
return
|
||||
}
|
||||
|
||||
fileDiffRef.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
const findSide = (element: HTMLElement): "additions" | "deletions" => {
|
||||
const line = element.closest("[data-line], [data-alt-line]")
|
||||
if (line instanceof HTMLElement) {
|
||||
const type = line.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") return "additions"
|
||||
}
|
||||
|
||||
const code = element.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return "additions"
|
||||
if (code.hasAttribute("data-deletions")) return "deletions"
|
||||
return "additions"
|
||||
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
|
||||
|
|
@ -45,19 +61,69 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
|||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
|
||||
const diffs = root.querySelector("[data-diffs]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.type === "split"
|
||||
|
||||
const code = Array.from(diffs.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (code.length === 0) return
|
||||
|
||||
const lineIndex = (element: HTMLElement) => {
|
||||
const raw = element.dataset.lineIndex
|
||||
if (!raw) return
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((value) => parseInt(value, 10))
|
||||
.filter((value) => !Number.isNaN(value))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findSide(node) === targetSide) return lineIndex(node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
|
||||
}
|
||||
}
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = Math.max(1, Math.min(range.start, range.end))
|
||||
const end = Math.max(range.start, range.end)
|
||||
const start = rowIndex(range.start, range.side)
|
||||
if (start === undefined) continue
|
||||
|
||||
for (let line = start; line <= end; line++) {
|
||||
const expectedSide =
|
||||
line === end ? (range.endSide ?? range.side) : line === start ? range.side : (range.side ?? range.endSide)
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return rowIndex(range.end, range.endSide ?? range.side)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`))
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
if (expectedSide && findSide(node) !== expectedSide) continue
|
||||
node.setAttribute("data-comment-selected", "")
|
||||
const first = Math.min(start, end)
|
||||
const last = Math.max(start, end)
|
||||
|
||||
for (const block of code) {
|
||||
for (const element of Array.from(block.children)) {
|
||||
if (!(element instanceof HTMLElement)) continue
|
||||
const idx = lineIndex(element)
|
||||
if (idx === undefined) continue
|
||||
if (idx > last) break
|
||||
if (idx < first) continue
|
||||
element.setAttribute("data-comment-selected", "")
|
||||
const next = element.nextSibling
|
||||
if (next instanceof HTMLElement && next.hasAttribute("data-line-annotation")) {
|
||||
next.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +131,16 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
|||
|
||||
onMount(() => {
|
||||
if (isServer || !props.preloadedDiff) return
|
||||
|
||||
applyScheme()
|
||||
|
||||
if (typeof MutationObserver !== "undefined") {
|
||||
const root = document.documentElement
|
||||
const monitor = new MutationObserver(() => applyScheme())
|
||||
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
|
||||
onCleanup(() => monitor.disconnect())
|
||||
}
|
||||
|
||||
fileDiffInstance = new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue