Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/app/e2e/regression/remote-tab-busy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,9 @@ function json(route: Route, body: unknown, status = 200) {
}

function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `data: ${JSON.stringify({ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } })}\n\n`,
})
}
2 changes: 1 addition & 1 deletion packages/app/e2e/regression/review-open-file.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await panel.getByRole("tab", { name: /Review/ }).click()
await panel.locator("#session-side-panel-review-tab").click()
await expect(sidebarToggle).toBeEnabled()
await panel.getByRole("tab", { name: "Open file" }).click()
await page.keyboard.press("Control+w")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {

async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).click()
await page.getByRole("option", { name: next }).dispatchEvent("click")
}

async function selectFile(page: Page, file: string) {
Expand Down
7 changes: 6 additions & 1 deletion packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
route,
path === "/api/event"
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
: events,
: [
...(path === "/global/event"
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
: []),
...(events ?? []),
],
config.eventRetry,
)
}
Expand Down
8 changes: 8 additions & 0 deletions packages/app/e2e/utils/sse-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,14 @@ export async function installSseTransport<T>(
controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
if (url.pathname === "/global/event")
controller.enqueue(
encoder.encode(
frame({
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
}),
),
)
request.signal.addEventListener(
"abort",
() => {
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/components/prompt-input-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
)
const resources = createMemo(() =>
Object.values(sync().data.mcp_resource).map((resource) => ({
id: `resource:${resource.client}:${resource.uri}`,
id: `resource:${resource.server}:${resource.uri}`,
kind: "resource" as const,
label: `@${resource.name}`,
path: resource.uri,
Expand All @@ -327,7 +327,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
source: {
type: "resource" as const,
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
clientName: resource.client,
clientName: resource.server,
uri: resource.uri,
},
},
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "resource",
name: resource.name,
uri: resource.uri,
client: resource.client,
client: resource.server,
display: resource.name,
description: resource.description,
mime: resource.mimeType,
Expand Down
24 changes: 24 additions & 0 deletions packages/app/src/components/prompt-input/submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const optimistic: Array<{
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sessionDirectories: Record<string, string> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: string[] = []
const syncedDirectories: string[] = []
Expand Down Expand Up @@ -89,6 +90,27 @@ const clientFor = (directory: string) => {
}
}

const api = {
session: {
async create(input: { location: { directory: string } }) {
await createSessionGate
createdSessions.push(input.location.directory)
const session = {
id: `session-${createdSessions.length}`,
title: `New session ${createdSessions.length}`,
}
sessionDirectories[session.id] = input.location.directory
return session
},
async shell(input: { sessionID: string }) {
sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main")
},
async prompt() {},
async command() {},
async interrupt() {},
},
}

beforeAll(async () => {
const rootClient = clientFor("/repo/main")

Expand Down Expand Up @@ -171,6 +193,7 @@ beforeAll(async () => {
const sdk = {
scope: "local",
directory: "/repo/main",
api,
client: rootClient,
url: "http://localhost:4096",
createClient(opts: any) {
Expand Down Expand Up @@ -265,6 +288,7 @@ beforeEach(() => {
permissionServer = "server-a"
createSessionGate = undefined
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
})

describe("prompt submit worktree selection", () => {
Expand Down
98 changes: 65 additions & 33 deletions packages/app/src/components/prompt-input/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event"

type PendingPrompt = {
abort: AbortController
Expand All @@ -39,7 +41,7 @@ export type FollowupDraft = {
}

type FollowupSendInput = {
client: DirectorySDK["client"]
api: DirectorySDK["api"]["session"]
serverSync: ServerSync
sync: DirectorySync
draft: FollowupDraft
Expand Down Expand Up @@ -81,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}

await input.client.session.command({
const messageID = Identifier.ascending("message")
await input.api.command({
sessionID: input.draft.sessionID,
id: messageID,
command: cmd,
arguments: tail.join(" "),
agent: input.draft.agent,
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
variant: input.draft.variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
model: {
id: input.draft.model.modelID,
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
files: images.map((attachment) => ({
uri: attachment.dataUrl,
name: attachment.filename,
})),
})
return true
Expand Down Expand Up @@ -152,13 +156,36 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}

await input.client.session.promptAsync({
await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
agent: input.draft.agent,
model: input.draft.model,
messageID,
parts: requestParts,
variant: input.draft.variant,
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
files: requestParts.flatMap((part) => {
if (part.type !== "file") return []
const text = part.source?.text
return [
{
uri: part.url,
name: part.filename,
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
},
]
}),
agents: requestParts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
mention: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
),
})
return true
} catch (err) {
Expand Down Expand Up @@ -210,6 +237,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)

const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
Expand All @@ -235,9 +263,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return Promise.resolve()
}
return sdk()
.client.session.abort({
sessionID,
})
.api.session.interrupt({ sessionID })
.catch(() => {})
}

Expand Down Expand Up @@ -364,9 +390,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {

let session = input.info()
if (!session && isNewSession) {
const created = await client.session
.create()
.then((x) => x.data ?? undefined)
const created = await sdk()
.api.session.create({
agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
})
.then(normalizeSessionInfo)
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
Expand Down Expand Up @@ -450,12 +480,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {

if (mode === "shell") {
clearInput()
client.session
.shell({
const eventID = Event.ID.create()
sdk()
.api.session.shell({
sessionID: session.id,
id: eventID,
command: text,
agent,
model,
command: text,
})
.catch((err) => {
showToast({
Expand All @@ -473,23 +505,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const customCommand = sync().data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
client.session
.command({
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
sdk()
.api.session.command({
sessionID: session.id,
id: messageID,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
model: { id: model.modelID, providerID: model.providerID, variant },
files: images.map((attachment) => ({
uri: attachment.dataUrl,
name: attachment.filename,
})),
})
.catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
Expand Down Expand Up @@ -573,7 +605,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}

void sendFollowupDraft({
client,
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
draft,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe("hasNonBlockingServiceIssue", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
})

test("detects LSP failures that do not block chatting", () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/app/src/components/status-popover-indicator.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
import type { McpServer } from "@opencode-ai/client/promise"

export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpStatus["status"]>
mcp: Array<McpServer["status"]["status"]>
lsp: Array<LspStatus["status"]>
}) {
return (
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
input.lsp.some((status) => status === "error")
)
}
Expand Down
11 changes: 6 additions & 5 deletions packages/app/src/context/directory-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync"
import type { State } from "./global-sync/types"
import { normalizeSessionInfo } from "@/utils/session"

const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const sessionFields = new Set([
Expand All @@ -15,6 +16,7 @@ const sessionFields = new Set([
"permission",
"question",
"message",
"session_message",
"part",
"part_text_accum_delta",
])
Expand Down Expand Up @@ -114,7 +116,6 @@ export const createDirSyncContext = (
await serverSync.session.sync(sessionID, options)
index(sessionID)
},
diff: serverSync.session.diff,
todo: serverSync.session.todo,
history: serverSync.session.history,
evict(sessionID: string) {
Expand All @@ -123,17 +124,17 @@ export const createDirSyncContext = (
fetch: async (count = 10) => {
const [store, setStore] = current()
setStore("limit", (value) => value + count)
const response = await client.session.list()
const sessions = (response.data ?? [])
.filter((session) => !!session?.id)
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
const sessions = response.data
.map(normalizeSessionInfo)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
sessions.forEach(serverSync.session.remember)
setStore("session", reconcile(sessions, { key: "id" }))
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
await serverSDK.api.session.archive({ sessionID, directory })
current()[1](
"session",
produce((draft) => {
Expand Down
Loading
Loading