feat(app): project current server state (#38459)

This commit is contained in:
Brendan Allan 2026-07-24 10:49:16 +08:00 committed by GitHub
commit 37c263e153
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2922 additions and 477 deletions

View file

@ -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,
@ -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,
},
},

View file

@ -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,

View file

@ -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[] = []
@ -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")
@ -171,6 +193,7 @@ beforeAll(async () => {
const sdk = {
scope: "local",
directory: "/repo/main",
api,
client: rootClient,
url: "http://localhost:4096",
createClient(opts: any) {
@ -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", () => {

View file

@ -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
@ -39,7 +41,7 @@ export type FollowupDraft = {
}
type FollowupSendInput = {
client: DirectorySDK["client"]
api: DirectorySDK["api"]["session"]
serverSync: ServerSync
sync: DirectorySync
draft: FollowupDraft
@ -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
@ -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) {
@ -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
@ -235,9 +263,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return Promise.resolve()
}
return sdk()
.client.session.abort({
sessionID,
})
.api.session.interrupt({ sessionID })
.catch(() => {})
}
@ -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"),
@ -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({
@ -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")),
@ -573,7 +605,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
void sendFollowupDraft({
client,
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
draft,

View file

@ -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", () => {

View file

@ -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")
)
}