mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 01:00:54 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa504c7e85 | |||
| 602f5ef465 | |||
| 76b318e990 | |||
| c0ab35c3c2 |
@@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression"
|
||||
const sessionID = "ses_context_resize_regression"
|
||||
const title = "Context resize regression"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
|
||||
const followingTextID = `${id("msg_assistant", 10)}:text:0`
|
||||
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
|
||||
const followingTextID = "prt_0104_text"
|
||||
|
||||
type Message = {
|
||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
@@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
|
||||
),
|
||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
||||
{
|
||||
id: "prt_0104_text",
|
||||
id: followingTextID,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "text",
|
||||
@@ -295,7 +295,7 @@ function contextTool(
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "tool",
|
||||
callID: partID,
|
||||
callID: `call_${partID}`,
|
||||
tool,
|
||||
state: {
|
||||
status,
|
||||
|
||||
@@ -136,7 +136,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
if (result.key) {
|
||||
await serverSDK().client.auth.set({
|
||||
await serverSDK().legacy.auth.set({
|
||||
providerID: result.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
|
||||
@@ -71,11 +71,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
() => (missingBase() ? true : undefined),
|
||||
async (): Promise<Path | undefined> => {
|
||||
if ((await sdk.protocol) === "v1")
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
return sdk.currentApi.location
|
||||
return sdk.legacy.path.get().catch(() => undefined)
|
||||
return sdk.api.location
|
||||
.get()
|
||||
.then((location) => ({
|
||||
state: "",
|
||||
|
||||
@@ -62,11 +62,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
() => (missingBase() ? true : undefined),
|
||||
async (): Promise<Path | undefined> => {
|
||||
if ((await sdk.protocol) === "v1")
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
return sdk.currentApi.location
|
||||
return sdk.legacy.path.get().catch(() => undefined)
|
||||
return sdk.api.location
|
||||
.get()
|
||||
.then((location) => ({
|
||||
state: "",
|
||||
|
||||
@@ -73,7 +73,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
if ((await serverCtx().sdk.protocol) !== "v1") return
|
||||
const project = await serverCtx()
|
||||
.sdk.client.project.update({
|
||||
.sdk.legacy.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
@@ -82,12 +82,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
})
|
||||
.then((result) => result.data)
|
||||
if (!project) return
|
||||
// const project = await serverCtx().sdk.api.project.update({
|
||||
// projectID: props.project.id,
|
||||
// name,
|
||||
// icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
// commands: { start },
|
||||
// })
|
||||
serverCtx().sync.set("project", (items) =>
|
||||
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "./updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
@@ -125,16 +125,11 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
const protocol = useServerProtocol()
|
||||
|
||||
const [shells] = createResource(
|
||||
async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
return (await sdk.client.pty.shells()).data ?? []
|
||||
}
|
||||
// return (await sdk.api.pty.shells()).data
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
() => (protocol() === "v1" ? serverSdk() : undefined),
|
||||
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
@@ -325,10 +320,11 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shell.title")}
|
||||
description={language.t("settings.general.row.shell.description")}
|
||||
>
|
||||
<Show when={protocol() === "v1"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shell.title")}
|
||||
description={language.t("settings.general.row.shell.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-shell"
|
||||
options={shellOptions()}
|
||||
@@ -345,7 +341,8 @@ export const SettingsGeneral: Component = () => {
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "min-width": "180px" }}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
|
||||
@@ -122,9 +122,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await serverSDK().legacy.auth.remove({ providerID }).catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
@@ -92,6 +92,7 @@ export const SettingsGeneralV2: Component<{
|
||||
const settings = useSettings()
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
const protocol = useServerProtocol()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
@@ -122,14 +123,8 @@ export const SettingsGeneralV2: Component<{
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const [shells] = createResource(
|
||||
async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
return (await sdk.client.pty.shells()).data ?? []
|
||||
}
|
||||
// return (await sdk.api.pty.shells()).data
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
() => (protocol() === "v1" ? serverSdk() : undefined),
|
||||
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
@@ -284,10 +279,11 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.shell.title")}
|
||||
description={language.t("settings.general.row.shell.description")}
|
||||
>
|
||||
<Show when={protocol() === "v1"}>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.shell.title")}
|
||||
description={language.t("settings.general.row.shell.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-shell"
|
||||
@@ -303,7 +299,8 @@ export const SettingsGeneralV2: Component<{
|
||||
serverSync().updateConfig({ shell: option.value })
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
</SettingsRowV2>
|
||||
</Show>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
|
||||
@@ -119,9 +119,7 @@ export const SettingsProvidersV2: Component<{
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await serverSdk().legacy.auth.remove({ providerID }).catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -318,10 +318,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||
{language.t("status.popover.tab.mcp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
@@ -459,7 +461,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="lsp">
|
||||
<Show when={protocol() === "v1"}>
|
||||
<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
|
||||
@@ -485,7 +488,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Tabs.Content value="plugins">
|
||||
|
||||
@@ -134,8 +134,7 @@ export const createDirSyncContext = (
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
archive: async (sessionID: string) => {
|
||||
if ((await serverSDK.protocol) !== "v1") return
|
||||
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
|
||||
await serverSDK.legacy.session.archive(sessionID, directory)
|
||||
current()[1](
|
||||
"session",
|
||||
produce((draft) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, Project } from "@/types"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { LegacyCapabilities } from "@/utils/server-compat"
|
||||
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import {
|
||||
@@ -111,36 +111,7 @@ describe("bootstrapDirectory", () => {
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: {
|
||||
list: async () => {
|
||||
mcpReads.push("command")
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: {
|
||||
status: async () => {
|
||||
mcpReads.push("status")
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
resource: {
|
||||
list: async () => {
|
||||
mcpReads.push("resource")
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient,
|
||||
legacy: { config: { directory: async () => ({}) } } as unknown as LegacyCapabilities,
|
||||
api: currentApi,
|
||||
store,
|
||||
setStore,
|
||||
@@ -163,16 +134,15 @@ describe("bootstrapDirectory", () => {
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const location = {} as Parameters<typeof loadPathQuery>[2]
|
||||
const client = {} as Parameters<typeof loadPathQuery>[3]
|
||||
const api = {} as CatalogApi
|
||||
const remote = "https://debian.example" as typeof ServerScope.local
|
||||
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", location, client).queryKey]).toEqual([
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual([
|
||||
"local",
|
||||
"/repo",
|
||||
"path",
|
||||
])
|
||||
expect([...loadPathQuery(remote, "/repo", location, client).queryKey]).toEqual([
|
||||
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual([
|
||||
"https://debian.example",
|
||||
"/repo",
|
||||
"path",
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
} from "@/types"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { LegacyCapabilities } from "@/utils/server-compat"
|
||||
import type {
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
@@ -107,10 +107,11 @@ function showErrors(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope, legacy: LegacyCapabilities, enabled = true) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "config"],
|
||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||
queryFn: () => retry(() => legacy.config.global()),
|
||||
enabled,
|
||||
})
|
||||
|
||||
type ProjectApi = {
|
||||
@@ -141,7 +142,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
})
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
serverSDK: OpencodeClient
|
||||
legacy: LegacyCapabilities
|
||||
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
|
||||
protocol?: Promise<ServerProtocol>
|
||||
scope: ServerScope
|
||||
@@ -151,21 +152,22 @@ export async function bootstrapGlobal(input: {
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
const protocol = await input.protocol
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||
protocol === "v1" && (() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.legacy))),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI),
|
||||
),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadPathQuery(input.scope, null, input.serverAPI.location, input.serverSDK, input.protocol),
|
||||
loadPathQuery(input.scope, null, input.serverAPI.location),
|
||||
),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||
.then((data) => input.setGlobalStore("project", data)),
|
||||
]
|
||||
].filter(Boolean) as Array<() => Promise<unknown>>
|
||||
await runAll(slow)
|
||||
// showErrors({
|
||||
// errors: errors(),
|
||||
@@ -273,22 +275,17 @@ export const loadPathQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string | null,
|
||||
api: LocationApi,
|
||||
sdk: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions<Path>({
|
||||
queryKey: [scope, directory, "path"],
|
||||
queryFn: async () => {
|
||||
if ((await protocol) === "v1")
|
||||
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
|
||||
return retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
|
||||
queryFn: () =>
|
||||
retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
}))
|
||||
},
|
||||
})),
|
||||
})
|
||||
|
||||
export const loadReferencesQuery = (
|
||||
@@ -307,7 +304,7 @@ export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
scope: ServerScope
|
||||
mcp: boolean
|
||||
sdk: OpencodeClient
|
||||
legacy: LegacyCapabilities
|
||||
api: CatalogApi & {
|
||||
readonly agent: AgentListApi
|
||||
readonly command: CommandListApi
|
||||
@@ -355,35 +352,13 @@ export async function bootstrapDirectory(input: {
|
||||
input.queryClient
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
() =>
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
if ((await input.protocol) !== "v1") return
|
||||
const x = await input.sdk.session.status()
|
||||
if (!input.session) {
|
||||
input.setStore("session_status", x.data!)
|
||||
return
|
||||
}
|
||||
const statuses = x.data ?? {}
|
||||
input.session.set(
|
||||
"session_status",
|
||||
produce((draft) => {
|
||||
for (const sessionID of Object.keys(draft)) {
|
||||
if (statuses[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
})(),
|
||||
),
|
||||
(await input.protocol) === "v1" &&
|
||||
(() =>
|
||||
retry(() =>
|
||||
input.legacy.config
|
||||
.directory(input.directory)
|
||||
.then((config) => input.setStore("config", reconcile(config, { merge: false }))),
|
||||
)),
|
||||
!seededProject &&
|
||||
(() =>
|
||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||
@@ -393,21 +368,12 @@ export async function bootstrapDirectory(input: {
|
||||
(() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(
|
||||
loadPathQuery(input.scope, input.directory, input.api.location, input.sdk, input.protocol),
|
||||
loadPathQuery(input.scope, input.directory, input.api.location),
|
||||
)
|
||||
.then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
() =>
|
||||
retry(async () => {
|
||||
if ((await input.protocol) !== "v1") return
|
||||
return input.sdk.vcs.get().then((result) => {
|
||||
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
|
||||
input.setStore("vcs", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
})
|
||||
}),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
loadCommands(input.directory, input.api.command).then((commands) =>
|
||||
@@ -419,12 +385,10 @@ export async function bootstrapDirectory(input: {
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
|
||||
return input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data.map(normalizePermissionRequest))
|
||||
})().then((permissions) => {
|
||||
input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data.map(normalizePermissionRequest))
|
||||
.then((permissions) => {
|
||||
const ids = permissions.map((permission) => permission.sessionID)
|
||||
const grouped = groupBySession(
|
||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||
@@ -455,12 +419,10 @@ export async function bootstrapDirectory(input: {
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
|
||||
return input.api.question.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
})().then((questions) => {
|
||||
input.api.question.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
.then((questions) => {
|
||||
const ids = questions.map((question) => question.sessionID)
|
||||
const grouped = groupBySession(
|
||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||
|
||||
@@ -191,7 +191,10 @@ export function createChildStoreManager(input: {
|
||||
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
||||
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
||||
const lspQuery = useQuery(() => {
|
||||
const options = input.queryOptions.lsp(key)
|
||||
return { ...options, enabled: options.enabled !== false && instanceQueriesEnabled() }
|
||||
})
|
||||
const providerQuery = useQuery(() => ({
|
||||
...input.queryOptions.providers(key),
|
||||
enabled: instanceQueriesEnabled(),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/promise"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
||||
const result = await input.api.list({
|
||||
@@ -16,16 +15,6 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
|
||||
} as const
|
||||
}
|
||||
|
||||
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
|
||||
try {
|
||||
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||
return { data: result.data, limit: input.limit, limited: true } as const
|
||||
} catch {
|
||||
const result = await input.client.session.list({ directory: input.directory, roots: true })
|
||||
return { data: result.data, limit: input.limit, limited: false } as const
|
||||
}
|
||||
}
|
||||
|
||||
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
|
||||
if (!input.limited) return input.count
|
||||
if (input.count < input.limit) return input.count
|
||||
|
||||
@@ -574,7 +574,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
void (async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
return sdk.client.project
|
||||
return sdk.legacy.project
|
||||
.update({ projectID, directory: worktree, icon: { color } })
|
||||
.then((response) => response.data)
|
||||
.then((result) => {
|
||||
|
||||
@@ -258,9 +258,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||
}
|
||||
|
||||
const list = async (directory: string) => {
|
||||
if ((await input.sdk.protocol) === "v1") {
|
||||
return (await input.sdk.client.permission.list({ directory })).data ?? []
|
||||
}
|
||||
return input.sdk.api.permission.request
|
||||
.list({ location: { directory } })
|
||||
.then((result) => result.data.map(normalizePermissionRequest))
|
||||
|
||||
@@ -12,7 +12,12 @@ import { createRefCountMap } from "@/utils/refcount"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
||||
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
||||
import {
|
||||
createCompatibleApi,
|
||||
createLegacyCapabilities,
|
||||
type CompatibleApi,
|
||||
type LegacyCapabilities,
|
||||
} from "@/utils/server-compat"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
@@ -166,6 +171,7 @@ type ServerSDKBase = {
|
||||
url: string
|
||||
client: ReturnType<typeof createSdkForServer>
|
||||
api: CompatibleApi
|
||||
legacy: LegacyCapabilities
|
||||
currentApi: ServerApi
|
||||
event: {
|
||||
on: ServerEventEmitter["on"]
|
||||
@@ -329,6 +335,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
directory,
|
||||
})
|
||||
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
|
||||
const capabilities = createLegacyCapabilities({ protocol, current: currentApi, legacy })
|
||||
|
||||
return {
|
||||
server,
|
||||
@@ -338,6 +345,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
url: server.http.url,
|
||||
client: sdk,
|
||||
api,
|
||||
legacy: capabilities,
|
||||
currentApi,
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
@@ -365,6 +373,7 @@ export type DirectorySDK = {
|
||||
client: OpencodeClient
|
||||
currentApi: ServerApi
|
||||
api: CompatibleApi
|
||||
legacy: LegacyCapabilities
|
||||
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
|
||||
readonly url: string
|
||||
createClient: ServerSDKBase["createClient"]
|
||||
@@ -428,6 +437,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): Direc
|
||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||
directory,
|
||||
}),
|
||||
legacy: createLegacyCapabilities({
|
||||
protocol: serverSDK.protocol,
|
||||
current: serverSDK.currentApi,
|
||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||
directory,
|
||||
}),
|
||||
event: emitter,
|
||||
get url() {
|
||||
return serverSDK.url
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
SessionStatus,
|
||||
Todo,
|
||||
} from "@/types"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { LegacyCapabilities } from "@/utils/server-compat"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
@@ -183,10 +183,14 @@ function reconcileFetched<T extends { id: string }>(
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
||||
type ServerSessionOptions = {
|
||||
retry?: typeof retry
|
||||
protocol?: Promise<"v1" | "v2">
|
||||
legacy?: LegacyCapabilities
|
||||
}
|
||||
|
||||
export function createServerSession(
|
||||
client: OpencodeClient,
|
||||
client: { session: Pick<LegacyCapabilities["session"], "get" | "messages" | "message"> },
|
||||
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
|
||||
messageApi?: MessageApi,
|
||||
currentOptions?: ServerSessionOptions,
|
||||
@@ -1389,14 +1393,16 @@ export function createServerSession(
|
||||
touch(sessionID)
|
||||
if (data.todo[sessionID] !== undefined && !request?.force) return
|
||||
if ((await options?.protocol) === "v2") {
|
||||
// TODO: Restore todos when the V2 API exposes a session todo snapshot.
|
||||
setData("todo", sessionID, [])
|
||||
return
|
||||
}
|
||||
return runInflight(inflightTodo, sessionID, () => {
|
||||
const active = generation(sessionID)
|
||||
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
|
||||
if (!options?.legacy) return Promise.resolve()
|
||||
return (options.retry ?? retry)(() => options.legacy!.session.todo(sessionID)).then((result) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||
setData("todo", sessionID, reconcile(result, { key: "id" }))
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
ProviderAuthResponse,
|
||||
SessionStatus,
|
||||
} from "@/types"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
@@ -60,6 +59,7 @@ import type {
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
import { usePlatform } from "./platform"
|
||||
import type { LegacyCapabilities } from "@/utils/server-compat"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -132,10 +132,11 @@ export const loadMcpResourcesQuery = (
|
||||
placeholderData: {},
|
||||
})
|
||||
|
||||
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
export const loadLspQuery = (scope: ServerScope, directory: string, legacy: LegacyCapabilities, enabled = true) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "lsp"] as const,
|
||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
||||
queryFn: () => legacy.lsp.status(directory),
|
||||
enabled,
|
||||
})
|
||||
|
||||
export const loadActiveSessionsQuery = (
|
||||
@@ -166,22 +167,20 @@ export function seedActiveSessionStatuses(
|
||||
|
||||
function makeQueryOptionsApi(
|
||||
scope: ServerScope,
|
||||
serverSDK: () => OpencodeClient,
|
||||
serverAPI: ServerApi,
|
||||
sdkFor: (dir: PathKey) => OpencodeClient,
|
||||
protocol: Promise<"v1" | "v2">,
|
||||
protocolKind: Accessor<"v1" | "v2" | undefined>,
|
||||
legacy: LegacyCapabilities,
|
||||
) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, legacy, protocolKind() === "v1"),
|
||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||
path: (directory: PathKey | null) =>
|
||||
loadPathQuery(scope, directory, serverAPI.location, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
|
||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, legacy, protocolKind() === "v1"),
|
||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||
}
|
||||
}
|
||||
@@ -193,30 +192,24 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||
|
||||
const sdkCache = new Map<string, OpencodeClient>()
|
||||
const booting = new Map<string, Promise<void>>()
|
||||
const sessionLoads = new Map<string, Promise<void>>()
|
||||
const sessionMeta = new Map<string, { limit: number }>()
|
||||
|
||||
const sdkFor = (directory: string) => {
|
||||
const key = directoryKey(directory)
|
||||
const cached = sdkCache.get(key)
|
||||
if (cached) return cached
|
||||
const sdk = serverSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
sdkCache.set(key, sdk)
|
||||
return sdk
|
||||
}
|
||||
|
||||
const session = createServerSession(serverSDK.client, serverSDK.currentApi.session, serverSDK.currentApi.message)
|
||||
const session = createServerSession(
|
||||
{ session: serverSDK.legacy.session },
|
||||
serverSDK.currentApi.session,
|
||||
serverSDK.currentApi.message,
|
||||
{
|
||||
protocol: serverSDK.protocol,
|
||||
legacy: serverSDK.legacy,
|
||||
},
|
||||
)
|
||||
const queryOptionsApi = makeQueryOptionsApi(
|
||||
serverSDK.scope,
|
||||
() => serverSDK.client,
|
||||
serverSDK.currentApi,
|
||||
sdkFor,
|
||||
serverSDK.protocol,
|
||||
serverSDK.protocolKind,
|
||||
serverSDK.legacy,
|
||||
)
|
||||
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
@@ -293,7 +286,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
queryKey: [serverSDK.scope, "bootstrap"],
|
||||
queryFn: async () => {
|
||||
await bootstrapGlobal({
|
||||
serverSDK: serverSDK.client,
|
||||
legacy: serverSDK.legacy,
|
||||
serverAPI: serverSDK.currentApi,
|
||||
protocol: serverSDK.protocol,
|
||||
scope: serverSDK.scope,
|
||||
@@ -349,7 +342,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const key = directoryKey(directory)
|
||||
queue.clear(key)
|
||||
sessionMeta.delete(key)
|
||||
sdkCache.delete(key)
|
||||
clearProviderRev(serverSDK.scope, key)
|
||||
},
|
||||
translate: language.t,
|
||||
@@ -446,7 +438,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const child = children.ensureChild(directory)
|
||||
const cache = children.vcsCache.get(key)
|
||||
if (!cache) return
|
||||
const sdk = sdkFor(directory)
|
||||
await bootstrapDirectory({
|
||||
directory,
|
||||
scope: serverSDK.scope,
|
||||
@@ -457,7 +448,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
project: globalStore.project,
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
sdk,
|
||||
legacy: serverSDK.legacy,
|
||||
api: serverSDK.currentApi,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
@@ -577,6 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
permission: session.data.permission,
|
||||
vcsCache: children.vcsCache.get(key),
|
||||
loadLsp: () => {
|
||||
if (serverSDK.protocolKind() !== "v1") return
|
||||
if (!children.active(key)) return
|
||||
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
||||
},
|
||||
@@ -625,7 +617,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
}
|
||||
|
||||
const updateConfigMutation = useMutation(() => ({
|
||||
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
|
||||
mutationFn: (config: Config) => serverSDK.legacy.config.update(config),
|
||||
onSuccess: () => {
|
||||
bootstrap.refetch()
|
||||
// Invalidate all provider queries so newly configured custom providers
|
||||
|
||||
@@ -109,6 +109,7 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
home.server.context(conn).projects.move(worktree, index)
|
||||
},
|
||||
canReveal: canRevealProject,
|
||||
canEdit: (conn: ServerConnection.Any) => home.server.context(conn).sdk.protocolKind() === "v1",
|
||||
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
if (!platform.openPath || !canRevealProject(conn)) return
|
||||
platform.openPath(project.worktree).catch((cause: unknown) =>
|
||||
|
||||
@@ -40,6 +40,7 @@ export type HomeProjectsViewProps = {
|
||||
canDefaultServer: Accessor<boolean>
|
||||
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
|
||||
canRevealProject: (server: ServerConnection.Any) => boolean
|
||||
canEditProject: (server: ServerConnection.Any) => boolean
|
||||
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
||||
onWheel: (event: WheelEvent) => void
|
||||
onChooseProject: (server: ServerConnection.Any) => void
|
||||
@@ -548,9 +549,11 @@ function HomeProjectRow(
|
||||
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
|
||||
{props.language.t("dialog.project.edit.title")}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.canEditProject(props.server)}>
|
||||
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
|
||||
{props.language.t("dialog.project.edit.title")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.canRevealProject(props.server)}>
|
||||
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
|
||||
{props.language.t(
|
||||
|
||||
@@ -17,6 +17,7 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
|
||||
canDefaultServer={props.projects.server.canDefault}
|
||||
defaultServerKey={props.projects.server.defaultKey}
|
||||
canRevealProject={props.projects.project.canReveal}
|
||||
canEditProject={props.projects.project.canEdit}
|
||||
unseenCount={props.projects.project.unseenCount}
|
||||
onWheel={props.scroll.viewport.containWheel}
|
||||
onChooseProject={props.projects.project.choose}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Session } from "@/types"
|
||||
import type { Session, V2SessionListResponse } from "@/types"
|
||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||
@@ -69,7 +69,10 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const cache = homeSessions()
|
||||
const eventSequence = cache.eventSequence()
|
||||
const index = await loadHomeSessionIndex(
|
||||
(input, options) => ctx.sdk.client.v2.session.list(input, options),
|
||||
(input, options) =>
|
||||
ctx.sdk.currentApi.session.list(input, options).then((data) => ({
|
||||
data: data as unknown as V2SessionListResponse,
|
||||
})),
|
||||
eventSequence,
|
||||
signal,
|
||||
)
|
||||
@@ -179,6 +182,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
showProjectName: () => !home.project.selected(),
|
||||
server: () => home.selection.value().server,
|
||||
canCreate: () => !!home.project.newSession(),
|
||||
canArchive: () => home.server.focusedContext()?.sdk.protocolKind() === "v1",
|
||||
create: home.project.openNewSession,
|
||||
open: (session: Session, options?: OpenSessionOptions) => {
|
||||
const directoryKey = pathKey(session.directory)
|
||||
@@ -211,16 +215,10 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!conn || !ctx) return
|
||||
const [, setStore] = ctx.sync.child(session.directory)
|
||||
if ((await ctx.sdk.protocol) !== "v1") return
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
session,
|
||||
archive: (sessionID) =>
|
||||
ctx.sdk.client.session.update({
|
||||
sessionID,
|
||||
directory: session.directory,
|
||||
time: { archived: Date.now() },
|
||||
}),
|
||||
archive: (sessionID) => ctx.sdk.legacy.session.archive(sessionID, session.directory),
|
||||
remove: () =>
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
|
||||
@@ -43,6 +43,7 @@ export type HomeSessionsViewProps = {
|
||||
showProjectName: Accessor<boolean>
|
||||
server: Accessor<ServerConnection.Key>
|
||||
canCreateSession: Accessor<boolean>
|
||||
canArchiveSession: Accessor<boolean>
|
||||
searchValue: Accessor<string>
|
||||
searchPlaceholder: Accessor<string>
|
||||
searchOpen: Accessor<boolean>
|
||||
@@ -460,7 +461,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
|
||||
group-hover/session:opacity-100 focus-within:opacity-100
|
||||
`}
|
||||
>
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
|
||||
<Show when={props.canArchiveSession()}>
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
@@ -473,7 +475,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
|
||||
void props.onArchiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ export function HomeSessions(props: {
|
||||
showProjectName={props.sessions.session.showProjectName}
|
||||
server={props.sessions.session.server}
|
||||
canCreateSession={props.sessions.session.canCreate}
|
||||
canArchiveSession={props.sessions.session.canArchive}
|
||||
searchValue={props.search.query.value}
|
||||
searchPlaceholder={props.search.query.placeholder}
|
||||
searchOpen={props.search.query.open}
|
||||
|
||||
@@ -872,17 +872,12 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
if ((await serverSDK().protocol) !== "v1") return
|
||||
const [store, setStore] = serverSync().child(session.directory)
|
||||
const sessions = store.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === session.id)
|
||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||
|
||||
await serverSDK().client.session.update({
|
||||
sessionID: session.id,
|
||||
directory: session.directory,
|
||||
time: { archived: Date.now() },
|
||||
})
|
||||
await serverSDK().legacy.session.archive(session.id, session.directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
||||
@@ -980,6 +975,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
title: language.t("command.session.archive"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "mod+shift+backspace",
|
||||
hidden: serverSDK().protocolKind() !== "v1",
|
||||
disabled: !params.dir || !params.id,
|
||||
onSelect: () => {
|
||||
const session = currentSessions().find((s) => s.id === params.id)
|
||||
@@ -1304,13 +1300,10 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const name = next === getFilename(project.worktree) ? "" : next
|
||||
|
||||
if (project.id && project.id !== "global") {
|
||||
const sdk = serverSDK()
|
||||
if ((await sdk.protocol) !== "v1") return
|
||||
const result = await sdk.client.project
|
||||
const result = await serverSDK().legacy.project
|
||||
.update({ projectID: project.id, directory: project.worktree, name })
|
||||
.then((response) => response.data)
|
||||
if (!result) return
|
||||
// const result = await serverSDK().api.project.update({ projectID: project.id, name })
|
||||
serverSync().set("project", (items) =>
|
||||
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
|
||||
)
|
||||
@@ -1477,12 +1470,8 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
platform,
|
||||
serverSDK().scope,
|
||||
)
|
||||
await serverSDK()
|
||||
.client.instance.dispose({ directory })
|
||||
.catch(() => undefined)
|
||||
|
||||
const result = await serverSDK()
|
||||
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
|
||||
.legacy.workspace.reset(root, directory)
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -1504,11 +1493,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
.filter((session) => session.time.archived === undefined)
|
||||
.map((session) =>
|
||||
serverSDK()
|
||||
.client.session.update({
|
||||
sessionID: session.id,
|
||||
directory: session.directory,
|
||||
time: { archived: Date.now() },
|
||||
})
|
||||
.legacy.session.archive(session.id, session.directory)
|
||||
.catch(() => undefined),
|
||||
),
|
||||
)
|
||||
@@ -1895,6 +1880,8 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
clearHoverProjectSoon,
|
||||
prefetchSession,
|
||||
archiveSession,
|
||||
canArchive: () => serverSDK().protocolKind() === "v1",
|
||||
canResetWorkspace: () => serverSDK().protocolKind() === "v1",
|
||||
workspaceName,
|
||||
renameWorkspace,
|
||||
editorOpen,
|
||||
@@ -1931,6 +1918,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
openSidebar: () => layout.sidebar.open(),
|
||||
closeProject,
|
||||
showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
|
||||
canEditProject: () => serverSDK().protocolKind() === "v1",
|
||||
toggleProjectWorkspaces,
|
||||
workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
|
||||
workspaceIds,
|
||||
@@ -1941,6 +1929,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
clearHoverProjectSoon,
|
||||
prefetchSession,
|
||||
archiveSession,
|
||||
canArchive: () => serverSDK().protocolKind() === "v1",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2031,16 +2020,19 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
<div class="shrink-0 pl-1 py-1">
|
||||
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<InlineEditor
|
||||
id={`project:${projectId()}`}
|
||||
value={projectName}
|
||||
onSave={(next) => {
|
||||
void renameProject(project, next)
|
||||
}}
|
||||
class="text-14-medium text-text-strong truncate"
|
||||
displayClass="text-14-medium text-text-strong truncate"
|
||||
stopPropagation
|
||||
/>
|
||||
<Show
|
||||
when={serverSDK().protocolKind() === "v1" || !project.id || project.id === "global"}
|
||||
fallback={<span class="text-14-medium text-text-strong truncate">{projectName()}</span>}
|
||||
>
|
||||
<InlineEditor
|
||||
id={`project:${projectId()}`}
|
||||
value={projectName}
|
||||
onSave={(next) => void renameProject(project, next)}
|
||||
class="text-14-medium text-text-strong truncate"
|
||||
displayClass="text-14-medium text-text-strong truncate"
|
||||
stopPropagation
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
@@ -2075,13 +2067,11 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
showEditProjectDialog(server.current!, project)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={serverSDK().protocolKind() === "v1"}>
|
||||
<DropdownMenu.Item onSelect={() => showEditProjectDialog(server.current!, project)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item
|
||||
data-action="project-workspaces-toggle"
|
||||
data-project={slug()}
|
||||
|
||||
@@ -87,6 +87,7 @@ export type SessionItemProps = {
|
||||
clearHoverProjectSoon: () => void
|
||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||
archiveSession: (session: Session) => Promise<void>
|
||||
canArchive: Accessor<boolean>
|
||||
}
|
||||
|
||||
const SessionRow = (props: {
|
||||
@@ -241,7 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={!props.level}>
|
||||
<Show when={!props.level && props.canArchive()}>
|
||||
<div
|
||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||
classList={{
|
||||
|
||||
@@ -27,6 +27,7 @@ export type ProjectSidebarContext = {
|
||||
openSidebar: () => void
|
||||
closeProject: (directory: string) => void
|
||||
showEditProjectDialog: (project: LocalProject) => void
|
||||
canEditProject: Accessor<boolean>
|
||||
toggleProjectWorkspaces: (project: LocalProject) => void
|
||||
workspacesEnabled: (project: LocalProject) => boolean
|
||||
workspaceIds: (project: LocalProject) => string[]
|
||||
@@ -65,6 +66,7 @@ const ProjectTile = (props: {
|
||||
onProjectFocus: (worktree: string) => void
|
||||
navigateToProject: (directory: string) => void
|
||||
showEditProjectDialog: (project: LocalProject) => void
|
||||
canEditProject: Accessor<boolean>
|
||||
toggleProjectWorkspaces: (project: LocalProject) => void
|
||||
workspacesEnabled: (project: LocalProject) => boolean
|
||||
closeProject: (directory: string) => void
|
||||
@@ -148,9 +150,11 @@ const ProjectTile = (props: {
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
||||
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<Show when={props.canEditProject()}>
|
||||
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
||||
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
</Show>
|
||||
<ContextMenu.Item
|
||||
data-action="project-workspaces-toggle"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
@@ -331,6 +335,7 @@ export const SortableProject = (props: {
|
||||
onProjectFocus={props.ctx.onProjectFocus}
|
||||
navigateToProject={props.ctx.navigateToProject}
|
||||
showEditProjectDialog={props.ctx.showEditProjectDialog}
|
||||
canEditProject={props.ctx.canEditProject}
|
||||
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
|
||||
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||
closeProject={props.ctx.closeProject}
|
||||
|
||||
@@ -42,6 +42,8 @@ export type WorkspaceSidebarContext = {
|
||||
clearHoverProjectSoon: () => void
|
||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||
archiveSession: (session: Session) => Promise<void>
|
||||
canArchive: Accessor<boolean>
|
||||
canResetWorkspace: Accessor<boolean>
|
||||
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
||||
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
||||
editorOpen: (id: string) => boolean
|
||||
@@ -151,6 +153,7 @@ const WorkspaceActions = (props: {
|
||||
workspaceValue: Accessor<string>
|
||||
openEditor: WorkspaceSidebarContext["openEditor"]
|
||||
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
||||
canResetWorkspace: WorkspaceSidebarContext["canResetWorkspace"]
|
||||
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
||||
root: string
|
||||
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
||||
@@ -199,12 +202,14 @@ const WorkspaceActions = (props: {
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={props.local() || props.busy()}
|
||||
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={props.canResetWorkspace()}>
|
||||
<DropdownMenu.Item
|
||||
disabled={props.local() || props.busy()}
|
||||
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item
|
||||
disabled={props.local() || props.busy()}
|
||||
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
|
||||
@@ -272,6 +277,7 @@ const WorkspaceSessionList = (props: {
|
||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||
prefetchSession={props.ctx.prefetchSession}
|
||||
archiveSession={props.ctx.archiveSession}
|
||||
canArchive={props.ctx.canArchive}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -416,6 +422,7 @@ export const SortableWorkspace = (props: {
|
||||
workspaceValue={workspaceValue}
|
||||
openEditor={props.ctx.openEditor}
|
||||
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
||||
canResetWorkspace={props.ctx.canResetWorkspace}
|
||||
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
||||
root={props.project.worktree}
|
||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||
|
||||
@@ -52,7 +52,7 @@ import { useNotification } from "@/context/notification"
|
||||
import { PromptProvider, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
@@ -361,6 +361,7 @@ export default function Page() {
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const protocol = useServerProtocol()
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
const prompt = usePrompt()
|
||||
@@ -847,7 +848,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const gitMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk().client.project.initGit(),
|
||||
mutationFn: () => sdk().legacy.project.initGit(sdk().directory),
|
||||
onSuccess: (x) => {
|
||||
if (!x.data) return
|
||||
upsert(x.data)
|
||||
@@ -896,17 +897,19 @@ export default function Page() {
|
||||
() => {
|
||||
const id = params.id
|
||||
return [
|
||||
protocol(),
|
||||
sdk().directory,
|
||||
id,
|
||||
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
||||
id ? composer.blocked() : false,
|
||||
] as const
|
||||
},
|
||||
([dir, id, status, blocked]) => {
|
||||
([serverProtocol, dir, id, status, blocked]) => {
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
todoFrame = undefined
|
||||
todoTimer = undefined
|
||||
if (serverProtocol !== "v1") return
|
||||
if (!id) return
|
||||
if (status === "idle" && !blocked) return
|
||||
const cached = untrack(() => sync().data.todo[id] !== undefined)
|
||||
@@ -1217,11 +1220,13 @@ export default function Page() {
|
||||
{language.t("session.review.noVcs.createGit.description")}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
||||
{gitMutation.isPending
|
||||
? language.t("session.review.noVcs.createGit.actionLoading")
|
||||
: language.t("session.review.noVcs.createGit.action")}
|
||||
</Button>
|
||||
<Show when={protocol() === "v1"}>
|
||||
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
||||
{gitMutation.isPending
|
||||
? language.t("session.review.noVcs.createGit.actionLoading")
|
||||
: language.t("session.review.noVcs.createGit.action")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1254,7 +1259,8 @@ export default function Page() {
|
||||
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
|
||||
}
|
||||
if (reviewMode() === "turn" && nogit()) {
|
||||
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
|
||||
if (protocol() === "v1") return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
|
||||
return empty(language.t("session.review.noVcs.createGit.description"))
|
||||
}
|
||||
return <SessionReviewEmptyChangesV2 />
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
@@ -302,7 +302,8 @@ export function MessageTimeline(props: {
|
||||
return displayLabel(session)
|
||||
})
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
const protocol = useServerProtocol()
|
||||
const shareEnabled = createMemo(() => protocol() === "v1" && sync().data.config.share !== "disabled")
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
@@ -665,14 +666,14 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
const shareMutation = useMutation(() => ({
|
||||
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
|
||||
mutationFn: (id: string) => serverSDK().legacy.session.share(id),
|
||||
onError: (err) => {
|
||||
console.error("Failed to share session", err)
|
||||
},
|
||||
}))
|
||||
|
||||
const unshareMutation = useMutation(() => ({
|
||||
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
|
||||
mutationFn: (id: string) => serverSDK().legacy.session.unshare(id),
|
||||
onError: (err) => {
|
||||
console.error("Failed to unshare session", err)
|
||||
},
|
||||
@@ -818,14 +819,12 @@ export function MessageTimeline(props: {
|
||||
const archiveSession = async (sessionID: string) => {
|
||||
const session = sync().session.get(sessionID)
|
||||
if (!session) return
|
||||
if ((await sdk().protocol) !== "v1") return
|
||||
|
||||
const sessions = sync().data.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
await sdk()
|
||||
.client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } })
|
||||
.legacy.session.archive(sessionID, sdk().directory)
|
||||
.then(() => {
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
@@ -1574,9 +1573,11 @@ export function MessageTimeline(props: {
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={protocol() === "v1"}>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
@@ -1645,9 +1646,11 @@ export function MessageTimeline(props: {
|
||||
{language.t("session.share.action.share")}...
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||
{language.t("common.archive")}
|
||||
</MenuV2.Item>
|
||||
<Show when={protocol() === "v1"}>
|
||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||
{language.t("common.archive")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
||||
{language.t("common.delete")}...
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { UserMessage } from "@/types"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useServerProtocol } from "@/context/server-sdk"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
@@ -43,6 +44,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const permission = usePermission()
|
||||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
const protocol = useServerProtocol()
|
||||
const settings = useSettings()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
@@ -194,7 +196,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const url = await sdk()
|
||||
.client.session.share({ sessionID })
|
||||
.legacy.session.share(sessionID)
|
||||
.then((res) => res.data?.share?.url)
|
||||
.catch(() => undefined)
|
||||
if (!url) {
|
||||
@@ -214,7 +216,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
if (!sessionID) return
|
||||
|
||||
await sdk()
|
||||
.client.session.unshare({ sessionID })
|
||||
.legacy.session.unshare(sessionID)
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
@@ -377,6 +379,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const shareCmds = () => {
|
||||
if (protocol() !== "v1") return []
|
||||
if (sync().data.config.share === "disabled") return []
|
||||
return [
|
||||
sessionCommand({
|
||||
|
||||
@@ -4,7 +4,10 @@ import { createCompatibleApi } from "./server-compat"
|
||||
|
||||
function setup(
|
||||
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
|
||||
responses?: { vcs?: { branch: string; default_branch: string } },
|
||||
responses?: {
|
||||
vcs?: { branch: string; default_branch: string }
|
||||
question?: { id: string; sessionID: string; questions: never[]; tool?: { messageID: string; callID: string } }[]
|
||||
},
|
||||
) {
|
||||
const requests: Request[] = []
|
||||
const fetcher = Object.assign(
|
||||
@@ -36,6 +39,8 @@ function setup(
|
||||
}
|
||||
if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
|
||||
return Response.json(responses?.vcs ?? {})
|
||||
if (request.method === "GET" && new URL(request.url).pathname === "/question")
|
||||
return Response.json(responses?.question ?? [])
|
||||
if (request.method === "GET") return Response.json([])
|
||||
return new Response(undefined, { status: 204 })
|
||||
},
|
||||
@@ -163,6 +168,21 @@ describe("createCompatibleApi", () => {
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
|
||||
})
|
||||
|
||||
test("translates V1 question tool call IDs", async () => {
|
||||
const { api } = setup("v1", {
|
||||
question: [
|
||||
{
|
||||
id: "que_1",
|
||||
sessionID: "ses_1",
|
||||
questions: [],
|
||||
tool: { messageID: "msg_1", callID: "call_1" },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect((await api.question.request.list()).data[0]?.tool).toEqual({ messageID: "msg_1", id: "call_1" })
|
||||
})
|
||||
|
||||
/*
|
||||
test("projects the V1 default branch", async () => {
|
||||
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ServerProtocol } from "./server-protocol"
|
||||
import type { AgentPartInput, FilePartInput, Session, TextPartInput } from "@/types"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
Project,
|
||||
ProjectCurrent,
|
||||
SessionApi,
|
||||
SessionCommandInput,
|
||||
@@ -28,7 +27,6 @@ type CompatibleSessionApi = Omit<
|
||||
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
|
||||
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
|
||||
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
|
||||
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
|
||||
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
|
||||
}
|
||||
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
|
||||
@@ -54,6 +52,99 @@ type CompatibleInput = {
|
||||
directory?: string
|
||||
}
|
||||
|
||||
export function createLegacyCapabilities(input: CompatibleInput) {
|
||||
const directory = (value?: string) => value ?? input.directory
|
||||
const client = (value?: string) => input.legacy(directory(value))
|
||||
const requireV1 = async () => {
|
||||
if ((await input.protocol) !== "v1") throw new Error("This capability is unavailable on V2 servers")
|
||||
}
|
||||
|
||||
return {
|
||||
config: {
|
||||
global: async () => {
|
||||
await requireV1()
|
||||
return (await client().global.config.get()).data ?? {}
|
||||
},
|
||||
directory: async (value?: string) => {
|
||||
await requireV1()
|
||||
return (await client(value).config.get()).data ?? {}
|
||||
},
|
||||
update: async (config: NonNullable<Parameters<LegacyClient["global"]["config"]["update"]>[0]>["config"]) => {
|
||||
await requireV1()
|
||||
return client().global.config.update({ config })
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
set: async (value: Parameters<LegacyClient["auth"]["set"]>[0]) => {
|
||||
await requireV1()
|
||||
return client().auth.set(value)
|
||||
},
|
||||
remove: async (value: Parameters<LegacyClient["auth"]["remove"]>[0]) => {
|
||||
await requireV1()
|
||||
return client().auth.remove(value)
|
||||
},
|
||||
},
|
||||
session: {
|
||||
get: (value: Parameters<LegacyClient["session"]["get"]>[0]) => client().session.get(value),
|
||||
messages: (value: Parameters<LegacyClient["session"]["messages"]>[0]) => client().session.messages(value),
|
||||
message: (value: Parameters<LegacyClient["session"]["message"]>[0]) => client().session.message(value),
|
||||
share: async (sessionID: string) => {
|
||||
await requireV1()
|
||||
return client().session.share({ sessionID })
|
||||
},
|
||||
unshare: async (sessionID: string) => {
|
||||
await requireV1()
|
||||
return client().session.unshare({ sessionID })
|
||||
},
|
||||
archive: async (sessionID: string, value?: string) => {
|
||||
await requireV1()
|
||||
return client(value).session.update({ sessionID, time: { archived: Date.now() } })
|
||||
},
|
||||
todo: async (sessionID: string, value?: string) => {
|
||||
await requireV1()
|
||||
return (await client(value).session.todo({ sessionID })).data ?? []
|
||||
},
|
||||
},
|
||||
project: {
|
||||
update: async (value: Parameters<LegacyClient["project"]["update"]>[0]) => {
|
||||
await requireV1()
|
||||
return client(value.directory).project.update(value)
|
||||
},
|
||||
initGit: async (value?: string) => {
|
||||
await requireV1()
|
||||
return client(value).project.initGit()
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
reset: async (root: string, value: string) => {
|
||||
await requireV1()
|
||||
await client(value).instance.dispose().catch(() => undefined)
|
||||
return client(root).worktree.reset({ worktreeResetInput: { directory: value } })
|
||||
},
|
||||
},
|
||||
pty: {
|
||||
shells: async () => {
|
||||
await requireV1()
|
||||
return (await client().pty.shells()).data ?? []
|
||||
},
|
||||
},
|
||||
path: {
|
||||
get: async (value?: string) => {
|
||||
await requireV1()
|
||||
return (await client(value).path.get()).data
|
||||
},
|
||||
},
|
||||
lsp: {
|
||||
status: async (value: string) => {
|
||||
await requireV1()
|
||||
return (await client(value).lsp.status()).data ?? []
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type LegacyCapabilities = ReturnType<typeof createLegacyCapabilities>
|
||||
|
||||
function mime(uri: string) {
|
||||
const match = /^data:([^;,]+)/.exec(uri)
|
||||
return match?.[1] ?? "application/octet-stream"
|
||||
@@ -184,9 +275,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
|
||||
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
|
||||
},
|
||||
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
|
||||
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
|
||||
// },
|
||||
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
|
||||
await legacy(value).session.delete(value)
|
||||
},
|
||||
@@ -313,34 +401,28 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
canonical: result.data.worktree,
|
||||
} satisfies ProjectCurrent
|
||||
},
|
||||
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
|
||||
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
|
||||
// const result = await legacy({ directory: project?.worktree }).project.update({
|
||||
// ...value,
|
||||
// directory: project?.worktree,
|
||||
// })
|
||||
// if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
|
||||
// return result.data as Project
|
||||
// },
|
||||
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
|
||||
const result = await legacy(value.location).worktree.list()
|
||||
return (result.data ?? []).map((item) => ({ directory: item }))
|
||||
},
|
||||
},
|
||||
// path: {
|
||||
// ...input.current.path,
|
||||
// async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
|
||||
// const result = await legacy(value?.location).path.get()
|
||||
// if (!result.data) throw new Error("Path unavailable")
|
||||
// return result.data
|
||||
// },
|
||||
// },
|
||||
location: {
|
||||
...input.current.location,
|
||||
async get(value?: Parameters<ServerApi["location"]["get"]>[0]) {
|
||||
const result = await legacy(value?.location).path.get()
|
||||
if (!result.data) throw new Error("Location unavailable")
|
||||
return {
|
||||
directory: result.data.directory,
|
||||
project: {
|
||||
id: "",
|
||||
directory: result.data.worktree,
|
||||
canonical: result.data.worktree,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
vcs: {
|
||||
...input.current.vcs,
|
||||
// async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
|
||||
// const result = await legacy(value?.location).vcs.get()
|
||||
// return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
|
||||
// },
|
||||
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
|
||||
const result = await legacy(value?.location).vcs.status()
|
||||
return located(result.data ?? [], value?.location)
|
||||
@@ -456,9 +538,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
},
|
||||
pty: {
|
||||
...input.current.pty,
|
||||
// async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
|
||||
// return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
|
||||
// },
|
||||
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
|
||||
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
|
||||
},
|
||||
@@ -490,14 +569,29 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
|
||||
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
|
||||
},
|
||||
// async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
|
||||
// const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
|
||||
// if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
|
||||
// return located(result.data, value.location)
|
||||
// },
|
||||
},
|
||||
permission: {
|
||||
...input.current.permission,
|
||||
request: {
|
||||
...input.current.permission.request,
|
||||
async list(value?: Parameters<ServerApi["permission"]["request"]["list"]>[0]) {
|
||||
const result = await legacy(value?.location).permission.list()
|
||||
return located(
|
||||
(result.data ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
sessionID: item.sessionID,
|
||||
action: item.permission,
|
||||
resources: item.patterns,
|
||||
metadata: item.metadata,
|
||||
save: item.always,
|
||||
source: item.tool
|
||||
? { type: "tool" as const, messageID: item.tool.messageID, callID: item.tool.callID }
|
||||
: undefined,
|
||||
})),
|
||||
value?.location,
|
||||
) as Awaited<ReturnType<ServerApi["permission"]["request"]["list"]>>
|
||||
},
|
||||
},
|
||||
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) {
|
||||
await legacy(value.location).permission.respond({
|
||||
sessionID: value.sessionID,
|
||||
@@ -509,6 +603,18 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
},
|
||||
question: {
|
||||
...input.current.question,
|
||||
request: {
|
||||
...input.current.question.request,
|
||||
async list(value?: Parameters<ServerApi["question"]["request"]["list"]>[0]) {
|
||||
return located(
|
||||
((await legacy(value?.location).question.list()).data ?? []).map((request) => ({
|
||||
...request,
|
||||
tool: request.tool && { messageID: request.tool.messageID, id: request.tool.callID },
|
||||
})),
|
||||
value?.location,
|
||||
)
|
||||
},
|
||||
},
|
||||
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
|
||||
await legacy().question.reply({
|
||||
requestID: value.requestID,
|
||||
|
||||
@@ -53,6 +53,10 @@ export interface RemoveResult {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Serialize a complete read/prepare/write mutation transaction by canonical target. */
|
||||
readonly withLock: (
|
||||
targets: ReadonlyArray<Target>,
|
||||
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
/** Create without replacing an existing target. */
|
||||
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
@@ -67,6 +71,9 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||
|
||||
/** Share transaction locks across Location graphs that address the same file. */
|
||||
const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
||||
|
||||
/**
|
||||
* Serialize file changes by canonical target. Conditional writes compare and
|
||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||
@@ -77,6 +84,10 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
const withLock: Interface["withLock"] = (targets) => (effect) =>
|
||||
[...new Set(targets.map((target) => target.canonical))]
|
||||
.sort()
|
||||
.reduceRight((result, target) => transactionLocks.withLock(target)(result), effect)
|
||||
const withTargetLock =
|
||||
(target: Target) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
@@ -169,7 +180,7 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove })
|
||||
return Service.of({ withLock, create, write, writeTextPreservingBom, writeIfUnchanged, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../../location"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { fileDiff } from "./file-diff"
|
||||
@@ -112,6 +114,7 @@ export const Plugin = {
|
||||
const files = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -125,7 +128,9 @@ export const Plugin = {
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
return files.withLock([
|
||||
{ canonical: FSUtil.resolve(path.resolve(location.directory, input.path)), resource: input.path },
|
||||
])(Effect.gen(function* () {
|
||||
const permissionSource = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
@@ -219,7 +224,7 @@ export const Plugin = {
|
||||
files: [fileDiff(result.resource, source, formatted)],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
})).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||
|
||||
@@ -4,12 +4,13 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Result, Schema } from "effect"
|
||||
import { PlatformError } from "effect/PlatformError"
|
||||
import path from "path"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Location } from "../../location"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -70,6 +71,7 @@ export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
@@ -85,13 +87,30 @@ export const Plugin = {
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const parsed = Patch.parse(input.patchText)
|
||||
const lockTargets = Result.isSuccess(parsed)
|
||||
? parsed.success.flatMap((hunk) => [
|
||||
{
|
||||
...resolveTarget(location, hunk.path),
|
||||
canonical: FSUtil.resolve(path.resolve(location.directory, hunk.path)),
|
||||
},
|
||||
...(hunk.type === "update" && hunk.movePath
|
||||
? [
|
||||
{
|
||||
...resolveTarget(location, hunk.movePath),
|
||||
canonical: FSUtil.resolve(path.resolve(location.directory, hunk.movePath)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
])
|
||||
: []
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
const completed = applied.map((item) => item.resource).join(", ")
|
||||
return new ToolFailure({
|
||||
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
||||
})
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
return mutation.withLock(lockTargets)(Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
@@ -322,7 +341,7 @@ export const Plugin = {
|
||||
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
||||
})
|
||||
return { applied, files }
|
||||
}).pipe(
|
||||
})).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output),
|
||||
|
||||
@@ -257,6 +257,59 @@ describe("FileMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("shares transaction locks across Location service instances", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const target = { canonical: path.join(directory, "shared.txt"), resource: "shared.txt" }
|
||||
const first = yield* Effect.gen(function* () {
|
||||
const files = yield* FileMutation.Service
|
||||
yield* files.withLock([target])(
|
||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||
)
|
||||
}).pipe(provide(directory), Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* Effect.gen(function* () {
|
||||
const files = yield* FileMutation.Service
|
||||
yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
|
||||
}).pipe(provide(directory), Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows transaction locks for distinct canonical targets to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondFinished = yield* Deferred.make<void>()
|
||||
const files = yield* FileMutation.Service
|
||||
const first = yield* files
|
||||
.withLock([{ canonical: path.join(directory, "first.txt"), resource: "first.txt" }])(
|
||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* files.withLock([{ canonical: path.join(directory, "second.txt"), resource: "second.txt" }])(
|
||||
Deferred.succeed(secondFinished, undefined),
|
||||
)
|
||||
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows only one concurrent conditional write based on the same bytes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const editToolNode = makeLocationNode({
|
||||
name: "test/edit-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_edit_tool_test")
|
||||
@@ -645,6 +645,43 @@ describe("EditTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent edit transactions", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "concurrent.txt")
|
||||
afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
|
||||
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.all(
|
||||
[
|
||||
executeTool(
|
||||
registry,
|
||||
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
|
||||
),
|
||||
executeTool(
|
||||
registry,
|
||||
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
|
||||
),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.andThen((results) =>
|
||||
Effect.gen(function* () {
|
||||
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies the edit when content changes after matching", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -22,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const patchToolNode = makeLocationNode({
|
||||
name: "test/patch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||
deps: [Tool.node, FileMutation.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_patch_tool_test")
|
||||
@@ -139,7 +140,7 @@ const withTool = <A, E, R>(
|
||||
return yield* body(yield* Tool.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
@@ -262,6 +263,45 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent patch transactions", () =>
|
||||
withTempTool((directory, registry) => {
|
||||
const target = path.join(directory, "concurrent.txt")
|
||||
afterEditApproval = () =>
|
||||
assertions.filter((input) => input.action === "edit").length === 1
|
||||
? Effect.sleep("50 millis")
|
||||
: Effect.void
|
||||
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
|
||||
Effect.andThen(
|
||||
Effect.all(
|
||||
[
|
||||
executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
|
||||
"call-patch-one",
|
||||
),
|
||||
),
|
||||
executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
|
||||
"call-patch-two",
|
||||
),
|
||||
),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
),
|
||||
Effect.andThen((results) =>
|
||||
Effect.gen(function* () {
|
||||
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns file diffs for final formatted content", () =>
|
||||
withTempTool((directory, registry) => {
|
||||
const target = path.join(directory, "formatted.txt")
|
||||
|
||||
@@ -1142,6 +1142,9 @@ export function ContextToolGroup(props: {
|
||||
const running = createMemo(
|
||||
() => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
|
||||
)
|
||||
const showDetails = createMemo(
|
||||
() => !running() || partAccessor().tool === "glob" || partAccessor().tool === "grep",
|
||||
)
|
||||
return (
|
||||
<div data-slot="context-tool-group-item">
|
||||
<div data-component="tool-trigger">
|
||||
@@ -1152,10 +1155,10 @@ export function ContextToolGroup(props: {
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={trigger().title} active={running()} />
|
||||
</span>
|
||||
<Show when={!running() && trigger().subtitle}>
|
||||
<Show when={showDetails() && trigger().subtitle}>
|
||||
<span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span>
|
||||
</Show>
|
||||
<Show when={!running() && trigger().args?.length}>
|
||||
<Show when={showDetails() && trigger().args?.length}>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
||||
</For>
|
||||
|
||||
Reference in New Issue
Block a user