Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/app/src/components/prompt-input-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
<div class="flex flex-col gap-3">
<PromptInputV2
controller={props.controller}
disabled={!props.controller.model.ready}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.controller.model.loading}
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/components/prompt-input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function PromptInputExample() {
},
model: {
selection: model,
ready: true,
paid: true,
loading: false,
},
Expand Down Expand Up @@ -140,6 +141,7 @@ function PromptInputWithOpenDock() {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
ready: true,
paid: true,
loading: false,
},
Expand Down
8 changes: 5 additions & 3 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const submitDisabled = createMemo(() => !stopping() && (!props.controls.model.ready || blank()))
const tip = () => {
if (stopping()) {
return (
Expand Down Expand Up @@ -1378,6 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (event.repeat) return
if (!props.controls.model.ready) return
if (
working() &&
prompt
Expand Down Expand Up @@ -1518,7 +1520,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
role="textbox"
aria-multiline="true"
aria-label={placeholder()}
contenteditable="true"
contenteditable={props.controls.model.ready}
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
Expand Down Expand Up @@ -1575,11 +1577,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>

<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<Tooltip placement="top" inactive={submitDisabled()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
disabled={submitDisabled()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/prompt-input/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type PromptInputControls = {
}
model: {
selection: ReturnType<typeof useLocal>["model"]
ready: boolean
paid: boolean
loading: boolean
}
Expand Down
119 changes: 119 additions & 0 deletions packages/app/src/components/prompt-input/selection-transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, test } from "bun:test"
import { ClientError } from "@opencode-ai/client/promise"
import { createApiForServer } from "@/utils/server"
import { createCompatibleApi } from "@/utils/server-compat"

function setup(protocol: "v1" | "v2", promptResponse?: () => Response) {
const requests: Request[] = []
const legacyInputs: unknown[] = []
const fetcher = Object.assign(
async (input: string | URL | Request, init?: RequestInit) => {
const request = new Request(input, init)
requests.push(request)
if (request.url.endsWith("/prompt_async")) return new Response(undefined, { status: 204 })
if (request.url.endsWith("/prompt")) {
if (promptResponse) return promptResponse()
return Response.json({
data: {
admittedSeq: 1,
id: "msg_1",
sessionID: "ses_1",
timeCreated: 1,
prompt: { text: "hello" },
delivery: "steer",
},
})
}
return new Response(undefined, { status: 204 })
},
{ preconnect: globalThis.fetch.preconnect },
)
const server = { url: "http://localhost:4096" }
const api = createCompatibleApi({
protocol: Promise.resolve(protocol),
current: createApiForServer({ server, fetch: fetcher }),
legacy: (() => ({
session: { promptAsync: async (input: unknown) => legacyInputs.push(input) },
})) as unknown as Parameters<typeof createCompatibleApi>[0]["legacy"],
directory: "/repo",
})
const send = async (variant?: string) => {
await api.session.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "hello",
selection: {
agent: "build",
model: { providerID: "anthropic", id: "sonnet", variant },
},
agent: "build",
model: { providerID: "anthropic", modelID: "sonnet" },
variant,
})
}
return { legacyInputs, requests, send }
}

describe("follow-up selection transport", () => {
test("admits V2 selection atomically with the prompt", async () => {
const fixture = setup("v2")

await fixture.send("high")

expect(fixture.requests.map((request) => new URL(request.url).pathname)).toEqual(["/api/session/ses_1/prompt"])
expect(await fixture.requests[0]!.json()).toMatchObject({
prompt: { text: "hello" },
selection: {
agent: "build",
model: { providerID: "anthropic", id: "sonnet", variant: "high" },
},
})
})

test("forwards V1 selection through the legacy prompt fields", async () => {
const fixture = setup("v1")

await fixture.send("high")

expect(fixture.requests).toEqual([])
expect(fixture.legacyInputs[0]).toMatchObject({
agent: "build",
model: { providerID: "anthropic", modelID: "sonnet" },
variant: "high",
})
})

test("rejects unsupported current-protocol error content", async () => {
const fixture = setup("v2", () => new Response("unauthorized", { status: 401 }))

const error = await fixture.send().catch((error) => error)

expect(error).toBeInstanceOf(ClientError)
expect(error.reason).toBe("UnsupportedContentType")
})

test("rejects malformed current-protocol success JSON", async () => {
const fixture = setup(
"v2",
() => new Response("not json", { status: 200, headers: { "content-type": "application/json" } }),
)

const error = await fixture.send().catch((error) => error)

expect(error).toBeInstanceOf(ClientError)
expect(error.reason).toBe("MalformedResponse")
})

test("wraps current-protocol response read failures", async () => {
const fixture = setup("v2", () => {
const response = Response.json({ data: {} })
Object.defineProperty(response, "text", { value: () => Promise.reject(new Error("read failed")) })
return response
})

const error = await fixture.send().catch((error) => error)

expect(error).toBeInstanceOf(ClientError)
expect(error.reason).toBe("Transport")
})
})
Loading
Loading