mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 17:16:14 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c706f34ab5 | ||
|
|
583544fa6b | ||
|
|
b731b11184 | ||
|
|
8676dcf705 | ||
|
|
2636797c65 | ||
|
|
e673807e39 | ||
|
|
9a3a1732f1 | ||
|
|
e03a147b71 | ||
|
|
e461fdc2d0 | ||
|
|
e4178886fa | ||
|
|
1e6bfaf3d7 | ||
|
|
876a4a2586 | ||
|
|
5e77c494c7 | ||
|
|
9be9dd737c | ||
|
|
4d22d4e75f | ||
|
|
8b93bc395d | ||
|
|
e756e497c2 | ||
|
|
0d2684b673 | ||
|
|
858caa6848 | ||
|
|
9a89851cea | ||
|
|
876459788f | ||
|
|
b0ab1e2992 | ||
|
|
d158f2cd39 | ||
|
|
9be3aa92b5 | ||
|
|
d19f58c5df | ||
|
|
b7343edaf3 | ||
|
|
b84f5ad2fb | ||
|
|
d993f1b8ed | ||
|
|
f6fcbaad5e | ||
|
|
5c4f6ef1e3 |
@@ -380,6 +380,8 @@
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"htmlparser2": "8.0.2",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-PuNZrtSgh5F3KpXSM+bd+rYQuyzwWd+wCOnMJSDS2Z0=",
|
||||
"aarch64-linux": "sha256-RYy8ZRf59FE/3+gICjvsZv3ekQvn+DTZaT9jefbK+0g=",
|
||||
"aarch64-darwin": "sha256-1AsDK8xNj3RlzX2efbuEDEwaOLAgjFYaEvk7EkQkh4w=",
|
||||
"x86_64-darwin": "sha256-8ONeOu9UmM0GRxVeOO3Uhk1yAOuW6R8tqBYswOVEkME="
|
||||
"x86_64-linux": "sha256-8pRvkbUX2aZhFTFtFuUM6mPqZZhfC4mFd1+BXVMzEJk=",
|
||||
"aarch64-linux": "sha256-df25TWdjjLKeLZJEfrDpgVaV8ZAZhHWPoV2IPIQ4U2w=",
|
||||
"aarch64-darwin": "sha256-VjbOx7Zi9eTiPxqpKN3+EQWweBfJHf7y36sGSN1peg0=",
|
||||
"x86_64-darwin": "sha256-q7nW4AR2OnepnDcPDtYECgcsXI+JRHOCWhPsAX8t7q0="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"translate:app": "bun run script/translate-app.ts",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
},
|
||||
"workspaces": {
|
||||
|
||||
@@ -33,6 +33,18 @@ export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
||||
export const PATH = "/messages"
|
||||
export const DEFAULT_MAX_TOKENS = 32_000
|
||||
|
||||
const SSE_EVENTS = new Set([
|
||||
"message",
|
||||
"message_start",
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"error",
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
|
||||
export type ThinkingInput =
|
||||
| {
|
||||
readonly type: "adaptive"
|
||||
@@ -362,16 +374,18 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
tool: (name) => ({ type: "tool" as const, name }),
|
||||
})
|
||||
|
||||
const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({
|
||||
type: "tool_use",
|
||||
id: part.id,
|
||||
id: scrubToolCallID(part.id),
|
||||
name: part.name,
|
||||
input: part.input,
|
||||
})
|
||||
|
||||
const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({
|
||||
type: "server_tool_use",
|
||||
id: part.id,
|
||||
id: scrubToolCallID(part.id),
|
||||
name: part.name,
|
||||
input: part.input,
|
||||
})
|
||||
@@ -393,7 +407,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
||||
// Prefer the provider-owned replay payload; fall back to the result value for
|
||||
// histories constructed directly from provider events.
|
||||
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
|
||||
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
|
||||
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
|
||||
})
|
||||
|
||||
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
|
||||
@@ -575,7 +589,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"])
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: part.id,
|
||||
tool_use_id: scrubToolCallID(part.id),
|
||||
content: yield* lowerToolResultContent(part),
|
||||
is_error: part.result.type === "error" ? true : undefined,
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
@@ -1039,7 +1053,7 @@ export const route = Route.make({
|
||||
protocol,
|
||||
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
||||
auth: Auth.none,
|
||||
framing: Framing.sse,
|
||||
framing,
|
||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||
})
|
||||
|
||||
|
||||
@@ -379,7 +379,12 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
},
|
||||
})
|
||||
}
|
||||
contents.push({ role: "user", parts })
|
||||
// Gemini requires every response to a parallel call batch in one user turn,
|
||||
// so consecutive tool results join the open function-response turn.
|
||||
const previous = contents.at(-1)
|
||||
if (previous?.role === "user" && previous.parts.some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, ...parts] }
|
||||
else contents.push({ role: "user", parts })
|
||||
}
|
||||
|
||||
return contents
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface Options {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly rotateAfterMs?: number
|
||||
readonly enabled?: (url: string) => boolean
|
||||
readonly url?: (url: string) => string
|
||||
readonly headers?: (headers: Headers.Headers) => Headers.Headers
|
||||
readonly driver?: (input: {
|
||||
readonly request: Readonly<Record<string, unknown>>
|
||||
@@ -147,18 +149,19 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* HttpTransport.jsonRequestParts(input)
|
||||
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
|
||||
const channel = input.webSocket
|
||||
? yield* Effect.gen(function* () {
|
||||
const create = yield* message(parts.jsonBody)
|
||||
const base = driver(options, create.message)
|
||||
return {
|
||||
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
|
||||
headers,
|
||||
rotateAfterMs: options.rotateAfterMs,
|
||||
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
const channel =
|
||||
input.webSocket && (options.enabled?.(parts.url) ?? true)
|
||||
? yield* Effect.gen(function* () {
|
||||
const create = yield* message(parts.jsonBody)
|
||||
const base = driver(options, create.message)
|
||||
return {
|
||||
url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
|
||||
headers,
|
||||
rotateAfterMs: options.rotateAfterMs,
|
||||
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
http: {
|
||||
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
|
||||
@@ -11,7 +11,7 @@ import { optionalArray, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { OpenResponsesChannel, type Options } from "./open-responses-channel.js"
|
||||
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
@@ -247,12 +247,16 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
|
||||
const auth = Auth.none
|
||||
|
||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
||||
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
|
||||
export const channelTransport = (options: Omit<Options, "driver">) =>
|
||||
OpenResponsesChannel.transport<OpenAIResponsesBody>({
|
||||
...options,
|
||||
driver: (input) => OpenAIResponsesChannel.driver({ id: options.id, name: options.name, ...input }),
|
||||
})
|
||||
export const transport = channelTransport({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
|
||||
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
|
||||
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
|
||||
@@ -197,19 +197,28 @@ export const errorText = (error: unknown) => {
|
||||
|
||||
/**
|
||||
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
|
||||
* decoder, and drops empty / `[DONE]` keep-alive events so the protocol event
|
||||
* schema sees one JSON string per element. The SSE channel emits a
|
||||
* decoder, optionally filters named events, and drops empty / `[DONE]`
|
||||
* keep-alive events so the protocol event schema sees one JSON string per
|
||||
* element. The SSE channel emits a
|
||||
* `Retry` control event on its error channel; we drop it here (we don't
|
||||
* implement client-driven retries). Decoder failures become provider output
|
||||
* errors so the public error channel stays `AIError`.
|
||||
*/
|
||||
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
|
||||
export const sseFraming = (
|
||||
bytes: Stream.Stream<Uint8Array, AIError>,
|
||||
events?: ReadonlySet<string>,
|
||||
): Stream.Stream<string, AIError> =>
|
||||
bytes.pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.pipeThroughChannel(Sse.decode()),
|
||||
Stream.catchTag("Retry", () => Stream.empty),
|
||||
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
|
||||
Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
(events === undefined || events.has(event.event)) &&
|
||||
event.data.length > 0 &&
|
||||
(event.data !== "[DONE]" || (events !== undefined && event.event !== "message")),
|
||||
),
|
||||
Stream.map((event) => event.data),
|
||||
)
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
|
||||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
@@ -127,6 +128,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalReason({ ...common, status: input.status })
|
||||
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
||||
@@ -10,6 +11,7 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
|
||||
|
||||
export const id = ProviderID.make("azure")
|
||||
const routeAuth = Auth.remove("authorization")
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
|
||||
|
||||
// Azure needs the customer's resource URL; supply either `resourceName`
|
||||
// (helper builds the URL) or `baseURL` directly.
|
||||
@@ -40,6 +42,30 @@ const responsesRoute = OpenAIResponses.route.with({
|
||||
id: "azure-openai-responses",
|
||||
provider: id,
|
||||
auth: routeAuth,
|
||||
transport: OpenAIResponses.channelTransport({
|
||||
id: "azure-openai-responses",
|
||||
name: "Azure OpenAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
enabled: (value) => {
|
||||
const url = new URL(value)
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname.endsWith(".openai.azure.com") &&
|
||||
url.pathname.endsWith("/openai/v1/responses") &&
|
||||
url.searchParams.get("api-version") === "v1"
|
||||
)
|
||||
},
|
||||
url: (value) => {
|
||||
const url = new URL(value)
|
||||
url.searchParams.delete("api-version")
|
||||
return url.toString()
|
||||
},
|
||||
headers: (headers) => {
|
||||
const apiKey = headers["api-key"]
|
||||
if (!apiKey) return headers
|
||||
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
@@ -14,6 +13,7 @@ export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInp
|
||||
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
||||
|
||||
const VERSION = "vertex-2023-10-16" as const
|
||||
const HEADER_VERSION = "2023-06-01" as const
|
||||
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
@@ -57,7 +57,8 @@ const route = Route.make({
|
||||
}),
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
framing: Framing.sse,
|
||||
framing: AnthropicMessages.framing,
|
||||
headers: () => ({ "anthropic-version": HEADER_VERSION }),
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -28,13 +28,19 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
|
||||
export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
transport: OpenAIResponses.channelTransport({
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
}),
|
||||
defaults: { providerOptions: { store: false } },
|
||||
})
|
||||
|
||||
|
||||
@@ -24,4 +24,10 @@ export interface Definition<Frame> {
|
||||
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
|
||||
export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming }
|
||||
|
||||
/** SSE framing restricted to protocol-recognized event names. */
|
||||
export const sseEvents = (events: ReadonlySet<string>): Definition<string> => ({
|
||||
id: "sse",
|
||||
frame: (bytes) => ProviderShared.sseFraming(bytes, events),
|
||||
})
|
||||
|
||||
export * as Framing from "./framing.js"
|
||||
|
||||
@@ -10,6 +10,9 @@ export const sseEvents = (...chunks: ReadonlyArray<unknown>): string =>
|
||||
|
||||
const formatChunk = (chunk: unknown) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n`
|
||||
|
||||
export const sseNamedEvent = (event: string, data: unknown): string =>
|
||||
`event: ${event}\ndata: ${typeof data === "string" ? data : JSON.stringify(data)}`
|
||||
|
||||
/**
|
||||
* Build an SSE body from already-serialized strings (used when the chunk shape
|
||||
* itself is part of what's being tested, e.g. malformed chunks).
|
||||
|
||||
@@ -82,6 +82,14 @@ describe("provider error classification", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("classifies network error text as provider internal", () => {
|
||||
expect(
|
||||
["network error", "network-error", "network_error"].map(
|
||||
(message) => classifyProviderFailure({ message })._tag,
|
||||
),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
expect(
|
||||
[
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
|
||||
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
import { sseEvents, sseNamedEvent, sseRaw } from "../lib/sse.js"
|
||||
|
||||
const model = AnthropicMessages.route
|
||||
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
|
||||
@@ -327,6 +327,29 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("scrubs outbound tool call IDs without truncating them", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = `functions.lookup:1|${"x".repeat(64)}`
|
||||
const scrubbed = `functions_lookup_1_${"x".repeat(64)}`
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
|
||||
Message.tool({ id, name: "lookup", result: "done" }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: scrubbed, name: "lookup", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: scrubbed }] },
|
||||
])
|
||||
expect(scrubbed.length).toBeGreaterThan(64)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches parallel tool results into one Anthropic user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -640,6 +663,59 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown named SSE events", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseRaw(
|
||||
sseNamedEvent("message_start", {
|
||||
type: "message_start",
|
||||
message: { usage: { input_tokens: 5 } },
|
||||
}),
|
||||
sseNamedEvent("proxy.stats", "not json"),
|
||||
sseNamedEvent("content_block_start", {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
}),
|
||||
sseNamedEvent("content_block_delta", {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "Hello" },
|
||||
}),
|
||||
sseNamedEvent("content_block_stop", { type: "content_block_stop", index: 0 }),
|
||||
sseNamedEvent("message_delta", {
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: { output_tokens: 1 },
|
||||
}),
|
||||
sseNamedEvent("message_stop", { type: "message_stop" }),
|
||||
sseNamedEvent("proxy.done", "still not json"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([{ type: "text", text: "Hello" }])
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed recognized SSE events", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseRaw(sseNamedEvent("message_start", "[DONE]")))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps nullable input tokens and preserves unknown Anthropic usage fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -1340,14 +1416,14 @@ describe("Anthropic Messages route", () => {
|
||||
Message.assistant([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "srvtoolu_abc",
|
||||
id: "srvtoolu.abc",
|
||||
name: "web_search",
|
||||
input: { query: "effect 4" },
|
||||
providerExecuted: true,
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "srvtoolu_abc",
|
||||
id: "srvtoolu.abc",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: [{ url: "https://example.com" }] },
|
||||
providerExecuted: true,
|
||||
|
||||
@@ -181,6 +181,53 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges parallel tool results into one function-response turn", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }),
|
||||
ToolCallPart.make({ id: "call_2", name: "lookup", input: { query: "time" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
|
||||
Message.tool({ id: "call_2", name: "lookup", result: "noon", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { id: undefined, name: "lookup", args: { query: "time" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "noon" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares multimodal user input and tool history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("Google Vertex providers", () => {
|
||||
"https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
|
||||
)
|
||||
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
|
||||
expect(request.headers.get("anthropic-version")).toBeNull()
|
||||
expect(request.headers.get("anthropic-version")).toBe("2023-06-01")
|
||||
const body = yield* Effect.promise(() => request.json())
|
||||
expect(body).toMatchObject({
|
||||
anthropic_version: "vertex-2023-10-16",
|
||||
|
||||
@@ -691,6 +691,134 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds xAI WebSocket requests without OpenAI handshake headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const deps = Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
)
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: xaiModel, prompt: "Say hello." }), {
|
||||
webSocket: {
|
||||
execute: (exchange) =>
|
||||
Effect.gen(function* () {
|
||||
expect(exchange.connect.url).toBe("wss://api.x.ai/v1/responses")
|
||||
expect(exchange.connect.rotateAfterMs).toBe(24 * 60 * 1000)
|
||||
expect(exchange.connect.headers.authorization).toBe("Bearer test")
|
||||
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
|
||||
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
|
||||
type: "response.create",
|
||||
model: "grok-4.5",
|
||||
store: false,
|
||||
})
|
||||
return {
|
||||
frames: Stream.make(
|
||||
JSON.stringify({ type: "response.created", response: { id: "resp_xai" } }),
|
||||
JSON.stringify({ type: "response.completed", response: { id: "resp_xai" } }),
|
||||
),
|
||||
complete: Effect.void,
|
||||
}
|
||||
}),
|
||||
},
|
||||
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
||||
|
||||
expect(response.finishReason.normalized).toBe("stop")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const deps = Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
)
|
||||
const cases = [
|
||||
{
|
||||
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
|
||||
authorization: "Bearer azure-key",
|
||||
},
|
||||
{
|
||||
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
|
||||
"deployment",
|
||||
),
|
||||
authorization: "Bearer entra-token",
|
||||
},
|
||||
]
|
||||
|
||||
yield* Effect.forEach(cases, (item) =>
|
||||
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
|
||||
webSocket: {
|
||||
execute: (exchange) =>
|
||||
Effect.gen(function* () {
|
||||
expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses")
|
||||
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
|
||||
expect(exchange.connect.headers.authorization).toBe(item.authorization)
|
||||
expect(exchange.connect.headers["api-key"]).toBeUndefined()
|
||||
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
|
||||
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
|
||||
type: "response.create",
|
||||
model: "deployment",
|
||||
store: false,
|
||||
})
|
||||
return {
|
||||
frames: Stream.make(
|
||||
JSON.stringify({ type: "response.created", response: { id: "resp_azure" } }),
|
||||
JSON.stringify({ type: "response.completed", response: { id: "resp_azure" } }),
|
||||
),
|
||||
complete: Effect.void,
|
||||
}
|
||||
}),
|
||||
},
|
||||
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps)))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps unsupported Azure endpoints and API versions on HTTP", () =>
|
||||
Effect.gen(function* () {
|
||||
const cases = [
|
||||
{
|
||||
model: Azure.configure({
|
||||
resourceName: "opencode-test",
|
||||
apiKey: "azure-key",
|
||||
apiVersion: "2025-04-01-preview",
|
||||
}).responses("deployment"),
|
||||
url: "https://opencode-test.openai.azure.com/openai/v1/responses?api-version=2025-04-01-preview",
|
||||
},
|
||||
{
|
||||
model: Azure.configure({
|
||||
resourceName: "opencode-test",
|
||||
apiKey: "azure-key",
|
||||
useDeploymentBasedUrls: true,
|
||||
}).responses("deployment"),
|
||||
url: "https://opencode-test.openai.azure.com/openai/deployments/deployment/responses?api-version=v1",
|
||||
},
|
||||
{
|
||||
model: Azure.configure({ baseURL: "https://gateway.example/azure", apiKey: "azure-key" }).responses(
|
||||
"deployment",
|
||||
),
|
||||
url: "https://gateway.example/azure/responses",
|
||||
},
|
||||
]
|
||||
|
||||
yield* Effect.forEach(cases, (item) =>
|
||||
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
|
||||
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
expect(input.request.url).toBe(item.url)
|
||||
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
|
||||
Effect.gen(function* () {
|
||||
const attempts = yield* Ref.make(0)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "./timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
|
||||
const contentSelector = '[data-message-id], [data-component="composer-editor"]'
|
||||
const draftID = "draft_first_navigation"
|
||||
|
||||
benchmark.describe("performance: first navigation paint", () => {
|
||||
@@ -41,11 +41,11 @@ benchmark.describe("performance: first navigation paint", () => {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||
destinationSelector: '[data-component="prompt-input"]',
|
||||
destinationSelector: '[data-component="composer-editor"]',
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
|
||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeVisible()
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
|
||||
@@ -5,9 +5,11 @@ import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
test("applies message latency after a list response gate is released", async () => {
|
||||
const events: string[] = []
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const started = Promise.withResolvers<void>()
|
||||
let handler: ((route: Route) => Promise<void>) | undefined
|
||||
const page = {
|
||||
addInitScript: () => Promise.resolve(),
|
||||
on: () => page,
|
||||
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
||||
handler = callback
|
||||
return Promise.resolve()
|
||||
@@ -21,6 +23,7 @@ test("applies message latency after a list response gate is released", async ()
|
||||
messageDelay: 25,
|
||||
beforeMessagesResponse: () => {
|
||||
events.push("before")
|
||||
started.resolve()
|
||||
return gate.promise
|
||||
},
|
||||
onMessages: (request) => events.push(request.phase),
|
||||
@@ -31,12 +34,18 @@ test("applies message latency after a list response gate is released", async ()
|
||||
})
|
||||
|
||||
const response = handler!({
|
||||
request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
|
||||
request: () => ({
|
||||
url: () => "http://127.0.0.1:4096/api/session/session/message",
|
||||
method: () => "GET",
|
||||
headers: () => ({}),
|
||||
postDataBuffer: () => null,
|
||||
}),
|
||||
fulfill: () => {
|
||||
events.push("fulfill")
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as unknown as Route)
|
||||
await started.promise
|
||||
expect(events).toEqual(["start", "before"])
|
||||
|
||||
const released = performance.now()
|
||||
@@ -45,3 +54,42 @@ test("applies message latency after a list response gate is released", async ()
|
||||
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
|
||||
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
|
||||
})
|
||||
|
||||
test("routes requests through the HttpApi contract", async () => {
|
||||
const connected = Promise.withResolvers<{ integrationID: string; body: unknown }>()
|
||||
let handler: ((route: Route) => Promise<void>) | undefined
|
||||
const page = {
|
||||
addInitScript: () => Promise.resolve(),
|
||||
on: () => page,
|
||||
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
||||
handler = callback
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as unknown as Page
|
||||
await mockOpenCodeServer(page, {
|
||||
provider: {},
|
||||
directory: "C:/OpenCode",
|
||||
project: {},
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onConnectKey: connected.resolve,
|
||||
})
|
||||
|
||||
const body = Buffer.from(JSON.stringify({ key: "secret" }))
|
||||
let status: number | undefined
|
||||
await handler!({
|
||||
request: () => ({
|
||||
url: () => "http://127.0.0.1:4096/api/integration/anthropic/connect/key",
|
||||
method: () => "POST",
|
||||
headers: () => ({ "content-type": "application/json" }),
|
||||
postDataBuffer: () => body,
|
||||
}),
|
||||
fulfill: (response: Parameters<Route["fulfill"]>[0]) => {
|
||||
status = response?.status
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as unknown as Route)
|
||||
|
||||
expect(status).toBe(204)
|
||||
expect(await connected.promise).toEqual({ integrationID: "anthropic", body: { key: "secret" } })
|
||||
})
|
||||
|
||||
+11
-11
@@ -3,9 +3,9 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/PromptInputV2Editing"
|
||||
const projectID = "proj_prompt_input_v2_editing"
|
||||
const sessionID = "ses_prompt_input_v2_editing"
|
||||
const directory = "C:/OpenCode/ComposerEditing"
|
||||
const projectID = "proj_composer_editing"
|
||||
const sessionID = "ses_composer_editing"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
||||
@@ -15,7 +15,7 @@ test("preserves the draft when a populated command menu triggers a built-in", as
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "prompt-input-v2-editing",
|
||||
name: "composer-editing",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
@@ -23,10 +23,10 @@ test("preserves the draft when a populated command menu triggers a built-in", as
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "prompt-input-v2-editing",
|
||||
slug: "composer-editing",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Prompt input V2 editing",
|
||||
title: "Composer editing",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
@@ -34,11 +34,11 @@ test("preserves the draft when a populated command menu triggers a built-in", as
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||
const input = composer.locator('[data-component="prompt-input"]')
|
||||
await expect.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content)).toBe(
|
||||
`"${String.fromCodePoint(0x200b)}"`,
|
||||
)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const input = composer.locator('[data-component="composer-editor"]')
|
||||
await expect
|
||||
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
|
||||
.toBe(`"${String.fromCodePoint(0x200b)}"`)
|
||||
await expectAppVisible(composer)
|
||||
|
||||
await input.fill("keep me")
|
||||
@@ -46,7 +46,7 @@ test("matches the rounded panel corners to the dark new-session background", asy
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]')
|
||||
await expect(panel).toHaveCount(1)
|
||||
|
||||
@@ -60,7 +60,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
if (path) return []
|
||||
return [
|
||||
{
|
||||
name: "frontend",
|
||||
name: "",
|
||||
path: "frontend\\",
|
||||
absolute: `${directory}/frontend`,
|
||||
type: "directory" as const,
|
||||
@@ -116,6 +116,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
|
||||
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
|
||||
await expect(frontendRow).toBeVisible()
|
||||
await expect(frontendRow.getByText("frontend", { exact: true })).toBeVisible()
|
||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
|
||||
await frontendRow.click()
|
||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -8,7 +8,7 @@ const projectID = "proj_prompt_thinking_level_regression"
|
||||
const sessionID = "ses_prompt_thinking_level_regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
||||
test("shows the thinking level control while relevant", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -51,8 +51,8 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||
const input = composer.locator('[data-component="prompt-input"]')
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const input = composer.locator('[data-component="composer-editor"]')
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
await expectAppVisible(composer)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
|
||||
const dialog = page.locator(".settings-v2-dialog")
|
||||
const dialog = page.locator(".settings-dialog")
|
||||
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
||||
const input = autoAccept.getByRole("switch")
|
||||
await expect(autoAccept).toBeVisible()
|
||||
@@ -63,7 +63,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||
await expect
|
||||
@@ -224,6 +224,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
|
||||
return json(route, { data: [] })
|
||||
if (url.pathname === "/api/location") return json(route, { directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/SessionMessageRevert"
|
||||
const projectID = "proj_session_message_revert"
|
||||
const sessionID = "ses_session_message_revert"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const messages = [
|
||||
{ id: "msg_first", type: "user", text: "First prompt", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_first_reply",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "test", providerID: "opencode" },
|
||||
content: [{ type: "text", text: "First reply" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
test("reverts directly to the selected user message", async ({ page }) => {
|
||||
const staged: { sessionID: string; messageID: string }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
canonical: directory,
|
||||
vcs: "git",
|
||||
name: "session-message-revert",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "session-message-revert",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Session message revert",
|
||||
agent: "build",
|
||||
model: { id: "test", providerID: "opencode" },
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 4 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: messages }),
|
||||
onRevertStage: (input) => staged.push(input),
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Session message revert")
|
||||
|
||||
const message = page.locator('[data-message-id="msg_second"]')
|
||||
await message.hover()
|
||||
const response = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname === `/api/session/${sessionID}/revert/stage`,
|
||||
)
|
||||
await message.getByRole("button", { name: "Revert message" }).click()
|
||||
expect((await response).ok()).toBe(true)
|
||||
|
||||
await expect(page.getByRole("textbox", { name: "Prompt" })).toHaveText("Second prompt")
|
||||
expect(staged).toEqual([{ sessionID, messageID: "msg_second" }])
|
||||
})
|
||||
@@ -120,7 +120,7 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
await transport.waitForConnection()
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||
const editor = page.locator('[data-component="composer-editor"][contenteditable="true"]')
|
||||
const draft = "keep the caret at the end"
|
||||
await editor.fill(draft)
|
||||
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
|
||||
|
||||
@@ -47,5 +47,10 @@ test("renders a completed single-file patch", async ({ page }) => {
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
const file = wrapper.locator('[data-scope="apply-patch"]')
|
||||
await expect(file.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
|
||||
await file.getByRole("button").click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
|
||||
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
|
||||
test("keeps patch file disclosures independent", async ({ page }) => {
|
||||
const patchID = "prt_nested_patch"
|
||||
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
|
||||
await setupTimeline(page, {
|
||||
@@ -21,15 +21,17 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
|
||||
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
|
||||
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
|
||||
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
|
||||
await deleted.getByRole("button").click()
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await outer.click()
|
||||
await expect(outer).toHaveAttribute("aria-expanded", "false")
|
||||
await outer.click()
|
||||
await expect(outer).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await modified.getByRole("button").click()
|
||||
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await deleted.getByRole("button").click()
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
|
||||
@@ -69,9 +69,43 @@ test.describe("session timeline projection", () => {
|
||||
]) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||
}
|
||||
const patch = page.locator('[data-timeline-part-id="prt_patch"]')
|
||||
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
|
||||
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
|
||||
await expect(patch.getByRole("button")).toHaveCount(1)
|
||||
await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1)
|
||||
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
|
||||
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("combines adjacent patch calls into one file group", async ({ page }) => {
|
||||
const first = "prt_patch_first"
|
||||
const second = "prt_patch_second"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(first, "patch", "completed", { patchText: "Update src/first.ts" }, {
|
||||
metadata: { files: [patchFile("src/first.ts", "modified")] },
|
||||
}),
|
||||
toolPart(second, "patch", "completed", { patchText: "Update src/second.ts" }, {
|
||||
metadata: { files: [patchFile("src/second.ts", "added")] },
|
||||
}),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.getByRole("button", { name: "Patch 2 files" })).toHaveCount(0)
|
||||
await expect(group.getByRole("button")).toHaveCount(2)
|
||||
await expect(group.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts", "second.ts"])
|
||||
await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
const firstUser = userMessage(
|
||||
[
|
||||
@@ -196,11 +230,7 @@ function patchPart(id: string) {
|
||||
{ patchText: "Update the projected files" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
patchFile("src/a.ts", "modified"),
|
||||
patchFile("src/b.ts", "added"),
|
||||
patchFile("src/old.ts", "deleted"),
|
||||
],
|
||||
files: [patchFile("src/a.ts", "modified")],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -103,7 +103,7 @@ test("labels completed searches with result counts", async ({ page }) => {
|
||||
await expect(rows.nth(1)).toContainText("(12 matches)")
|
||||
})
|
||||
|
||||
test("labels V2 read tools from their path input", async ({ page }) => {
|
||||
test("labels read tools from their path input", async ({ page }) => {
|
||||
const id = "prt_read_path"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
|
||||
@@ -114,7 +114,7 @@ test("labels V2 read tools from their path input", async ({ page }) => {
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
|
||||
})
|
||||
|
||||
test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
|
||||
test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
const pending = "prt_skill_id"
|
||||
const completed = "prt_skill_name"
|
||||
await setupTimeline(page, {
|
||||
@@ -131,9 +131,10 @@ test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
|
||||
"aria-label",
|
||||
"sample-skill",
|
||||
)
|
||||
await expect(
|
||||
page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`),
|
||||
).toHaveAttribute("aria-label", "OpenCode")
|
||||
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"OpenCode",
|
||||
)
|
||||
for (const id of [pending, completed]) {
|
||||
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
await expect(skill.locator('[data-slot="skill-tool-label"]')).toHaveText("Skill")
|
||||
@@ -152,8 +153,7 @@ function errorInput(tool: string) {
|
||||
if (tool === "patch") return { patchText: "Update src/error.ts" }
|
||||
if (tool === "webfetch") return { url: "https://example.com" }
|
||||
if (tool === "websearch") return { query: "failure" }
|
||||
if (tool === "subagent")
|
||||
return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
|
||||
if (tool === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
|
||||
if (tool === "skill") return { name: "failure" }
|
||||
return { target: "failure" }
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||
})
|
||||
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
test("does not request replay when reconnecting the volatile event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const events = partUpdated(textPart("prt_transport_id", "event with id"))
|
||||
const first = (
|
||||
|
||||
@@ -77,7 +77,7 @@ test("routes typing to the composer unless the open terminal is focused", async
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const composer = page.locator('[data-component="composer-editor"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible()
|
||||
@@ -116,7 +116,7 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`, { waitUntil: "commit" })
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const composer = page.locator('[data-component="composer-editor"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal).toBeVisible()
|
||||
expect(created.count).toBe(0)
|
||||
@@ -142,7 +142,7 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const composer = page.locator('[data-component="composer-editor"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible()
|
||||
@@ -187,7 +187,7 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const composer = page.locator('[data-component="composer-editor"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
|
||||
@@ -78,9 +78,9 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
await selectFolder.click()
|
||||
|
||||
await page.locator('[data-action="home-new-session"]').click()
|
||||
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
|
||||
await expectAppVisible(page.locator('[data-component="composer"]'))
|
||||
|
||||
const modelControl = page.locator('[data-action="prompt-model"]')
|
||||
const modelControl = page.locator('[data-action="composer-model"]')
|
||||
await modelControl.click()
|
||||
await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
|
||||
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
|
||||
const Json = Schema.Json.pipe(
|
||||
Schema.decodeTo(Schema.Unknown, {
|
||||
decode: SchemaGetter.passthrough(),
|
||||
encode: SchemaGetter.transform(jsonValue),
|
||||
}),
|
||||
HttpApiSchema.asJson(),
|
||||
)
|
||||
const JsonPayload = Schema.Unknown.pipe(HttpApiSchema.asJson())
|
||||
const Query = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
parentID: Schema.optional(Schema.String),
|
||||
search: Schema.optional(Schema.String),
|
||||
order: Schema.optional(Schema.String),
|
||||
cursor: Schema.optional(Schema.String),
|
||||
limit: Schema.optional(Schema.NumberFromString),
|
||||
path: Schema.optional(Schema.String),
|
||||
query: Schema.optional(Schema.String),
|
||||
type: Schema.optional(Schema.String),
|
||||
})
|
||||
const SessionParams = { sessionID: Schema.String }
|
||||
const NoContent = HttpApiSchema.NoContent
|
||||
|
||||
export class MockNotFound extends Schema.TaggedError<MockNotFound>()("MockNotFound", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBadRequest", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
const Group = HttpApiGroup.make("mock")
|
||||
.add(HttpApiEndpoint.get("health", "/api/health", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("event", "/api/event", {
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("modelDefault", "/api/model/default", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("integrationList", "/api/integration", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("integrationGet", "/api/integration/:integrationID", {
|
||||
params: { integrationID: Schema.String },
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("integrationConnect", "/api/integration/:integrationID/connect/key", {
|
||||
params: { integrationID: Schema.String },
|
||||
payload: JsonPayload,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", {
|
||||
params: { credentialID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("command", "/api/command", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("skill", "/api/skill", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("plugin", "/api/plugin", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
payload: JsonPayload,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
|
||||
params: { projectID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("fsRead", "/api/fs/read/*", {
|
||||
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("fsFind", "/api/fs/find", { query: Query, success: Json }))
|
||||
.add(HttpApiEndpoint.get("shell", "/api/shell", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("ptyConnectToken", "/api/pty/:ptyID/connect-token", {
|
||||
params: { ptyID: Schema.String },
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionList", "/api/session", {
|
||||
query: Query,
|
||||
success: Json,
|
||||
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.post("sessionCreate", "/api/session", { payload: JsonPayload, success: Json }))
|
||||
.add(HttpApiEndpoint.get("sessionActive", "/api/session/active", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionGet", "/api/session/:sessionID", {
|
||||
params: SessionParams,
|
||||
success: Json,
|
||||
error: MockNotFound.pipe(HttpApiSchema.status(404)),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("sessionRemove", "/api/session/:sessionID", {
|
||||
params: SessionParams,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionShell", "/api/session/:sessionID/shell", {
|
||||
params: SessionParams,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionForm", "/api/session/:sessionID/form", {
|
||||
params: SessionParams,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionFormReply", "/api/session/:sessionID/form/:formID/reply", {
|
||||
params: { ...SessionParams, formID: Schema.String },
|
||||
payload: JsonPayload,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
|
||||
params: { ...SessionParams, formID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionBackground", "/api/session/:sessionID/background", {
|
||||
params: SessionParams,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionInbox", "/api/session/:sessionID/inbox", {
|
||||
params: SessionParams,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", {
|
||||
params: SessionParams,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionPermissionReply", "/api/session/:sessionID/permission/:permissionID/reply", {
|
||||
params: { ...SessionParams, permissionID: Schema.String },
|
||||
payload: JsonPayload,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionRename", "/api/session/:sessionID/rename", {
|
||||
params: SessionParams,
|
||||
payload: JsonPayload,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionInterrupt", "/api/session/:sessionID/interrupt", {
|
||||
params: SessionParams,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionRevertStage", "/api/session/:sessionID/revert/stage", {
|
||||
params: SessionParams,
|
||||
payload: JsonPayload,
|
||||
success: Json,
|
||||
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionRevertClear", "/api/session/:sessionID/revert/clear", {
|
||||
params: SessionParams,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionRevertCommit", "/api/session/:sessionID/revert/commit", {
|
||||
params: SessionParams,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("messageGet", "/api/session/:sessionID/message/:messageID", {
|
||||
params: { ...SessionParams, messageID: Schema.String },
|
||||
success: Json,
|
||||
error: MockNotFound.pipe(HttpApiSchema.status(404)),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("messageList", "/api/session/:sessionID/message", {
|
||||
params: SessionParams,
|
||||
query: Query,
|
||||
success: Json,
|
||||
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
|
||||
}),
|
||||
)
|
||||
|
||||
export const MockApi = HttpApi.make("mock").add(Group)
|
||||
|
||||
function jsonValue(value: unknown): Schema.Json {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
||||
if (Array.isArray(value)) return value.map(jsonValue)
|
||||
if (!value || typeof value !== "object") return null
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, jsonValue(item)]])),
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import type { Page } from "@playwright/test"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { Duration, Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
|
||||
|
||||
export interface MockServerConfig {
|
||||
provider: unknown | (() => unknown)
|
||||
@@ -22,6 +26,7 @@ export interface MockServerConfig {
|
||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||
message?: (sessionID: string, messageID: string) => SessionMessageInfo | undefined
|
||||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||
onRevertStage?: (input: { sessionID: string; messageID: string }) => void
|
||||
events?: () => OpenCodeEvent[]
|
||||
eventRetry?: number
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
@@ -38,9 +43,8 @@ type MockStreamWindow = Window & {
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const cursors = new Map<string, string>()
|
||||
const state = { cursors: new Map<string, string>(), nextCursor: 0 }
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
let nextCursor = 0
|
||||
|
||||
await page.addInitScript(
|
||||
({ server, retry }) => {
|
||||
@@ -127,307 +131,331 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
}, 50)
|
||||
page.on("close", () => clearInterval(timer))
|
||||
}
|
||||
const transport = HttpRouter.toWebHandler(
|
||||
HttpApiBuilder.layer(MockApi).pipe(
|
||||
Layer.provide(mockHandlers(config, state)),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
)
|
||||
page.on("close", () => void transport.dispose())
|
||||
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const appPort = new URL(
|
||||
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
|
||||
).port
|
||||
if (url.origin !== server && url.port !== appPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/api/event") {
|
||||
const events = config.events?.()
|
||||
return sse(
|
||||
route,
|
||||
[{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])],
|
||||
config.eventRetry,
|
||||
)
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
return route.fulfill({ status: 204, headers: corsHeaders })
|
||||
}
|
||||
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||
if (path === "/api/reference")
|
||||
return json(route, {
|
||||
location: {
|
||||
directory: config.directory,
|
||||
project: {
|
||||
|
||||
const body = route.request().postDataBuffer()
|
||||
const response = await transport.handler(
|
||||
new Request(url, {
|
||||
method: route.request().method(),
|
||||
headers: route.request().headers(),
|
||||
body: body ? Uint8Array.from(body) : undefined,
|
||||
}),
|
||||
)
|
||||
if (response.status === 404 && url.origin !== server) return route.fallback()
|
||||
return route.fulfill({
|
||||
status: response.status,
|
||||
headers: { ...Object.fromEntries(response.headers), ...corsHeaders },
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const corsHeaders = {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-headers": "*",
|
||||
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"access-control-expose-headers": "x-next-cursor",
|
||||
}
|
||||
|
||||
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
|
||||
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
|
||||
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
|
||||
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
|
||||
handlers
|
||||
.handleRaw("event", () => {
|
||||
const events = config.events?.()
|
||||
const retry = config.eventRetry === undefined ? "" : `retry: ${config.eventRetry}\n\n`
|
||||
const body = [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])]
|
||||
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
|
||||
.join("")
|
||||
return Effect.succeed(HttpServerResponse.text(retry + body, { contentType: "text/event-stream" }))
|
||||
})
|
||||
.handleRaw("fsRead", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const path = decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))
|
||||
const value = yield* Effect.promise(() => Promise.resolve(config.fileContent?.(path)))
|
||||
const content =
|
||||
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
||||
return HttpServerResponse.uint8Array(new TextEncoder().encode(content))
|
||||
}),
|
||||
)
|
||||
.handleAll({
|
||||
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
|
||||
reference: () =>
|
||||
Effect.succeed({
|
||||
location: {
|
||||
directory: config.directory,
|
||||
project: {
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
},
|
||||
},
|
||||
data: [],
|
||||
}),
|
||||
agent: () =>
|
||||
Effect.succeed({
|
||||
location: location(config),
|
||||
data: [
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
permissions: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
provider: () => Effect.succeed({ location: location(config), data: currentProviders(providerConfig(config)) }),
|
||||
model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }),
|
||||
modelDefault: () =>
|
||||
Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }),
|
||||
integrationList: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
integrationGet: (ctx) =>
|
||||
Effect.succeed({
|
||||
location: location(config),
|
||||
data: {
|
||||
id: ctx.params.integrationID,
|
||||
name: ctx.params.integrationID,
|
||||
methods: config.integrationMethods?.[ctx.params.integrationID] ?? [{ type: "key", label: "API key" }],
|
||||
connections: [],
|
||||
},
|
||||
}),
|
||||
integrationConnect: (ctx) =>
|
||||
Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe(
|
||||
Effect.andThen(noContent),
|
||||
),
|
||||
credentialRemove: () => noContent,
|
||||
command: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
skill: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
plugin: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
mcp: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
mcpResource: () => Effect.succeed({ location: location(config), data: { resources: [], templates: [] } }),
|
||||
projectList: () => {
|
||||
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
|
||||
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
|
||||
},
|
||||
projectCurrent: () =>
|
||||
Effect.succeed({
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
},
|
||||
}),
|
||||
worktreeList: () =>
|
||||
Effect.succeed([
|
||||
{ directory: config.directory },
|
||||
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
|
||||
directory,
|
||||
strategy: "git",
|
||||
})),
|
||||
]),
|
||||
worktreeCreate: (ctx) => {
|
||||
const input = record(ctx.payload) ? ctx.payload : {}
|
||||
return Effect.succeed({
|
||||
directory: `${typeof input.directory === "string" ? input.directory : config.directory}/${
|
||||
typeof input.name === "string" ? input.name : "copy"
|
||||
}`,
|
||||
})
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
if (path === "/api/agent")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: [
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
permissions: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (path === "/api/provider")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: currentProviders(providerConfig(config)),
|
||||
})
|
||||
if (path === "/api/model")
|
||||
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
|
||||
if (path === "/api/model/default")
|
||||
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
|
||||
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/skill") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp/resource")
|
||||
return json(route, { location: location(config), data: { resources: [], templates: [] } })
|
||||
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
|
||||
if (integration && route.request().method() === "GET")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: {
|
||||
id: integration,
|
||||
name: integration,
|
||||
methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
|
||||
connections: [],
|
||||
},
|
||||
})
|
||||
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
|
||||
if (integrationConnect && route.request().method() === "POST") {
|
||||
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (path === "/api/project") {
|
||||
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
|
||||
return json(route, [
|
||||
{
|
||||
...project,
|
||||
canonical: project.canonical ?? project.worktree ?? config.directory,
|
||||
},
|
||||
])
|
||||
}
|
||||
if (path === "/api/project/current")
|
||||
return json(route, {
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
})
|
||||
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
|
||||
if (worktree && route.request().method() === "GET")
|
||||
return json(route, [
|
||||
{ directory: config.directory },
|
||||
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
|
||||
directory,
|
||||
strategy: "git",
|
||||
})),
|
||||
])
|
||||
if (path === "/api/location") return json(route, location(config))
|
||||
if (worktree && route.request().method() === "POST") {
|
||||
const input = route.request().postDataJSON() as { directory: string; name?: string }
|
||||
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
|
||||
}
|
||||
if (worktree && route.request().method() === "DELETE")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (path === "/api/permission/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
|
||||
currentPermission,
|
||||
),
|
||||
})
|
||||
if (path === "/api/form/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
|
||||
})
|
||||
if (path === "/api/vcs")
|
||||
return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } })
|
||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
||||
if (path === "/api/fs/list" && config.fileList)
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: await config.fileList(url.searchParams.get("path") ?? ""),
|
||||
})
|
||||
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
|
||||
if (fileRead && config.fileContent) {
|
||||
const value = await config.fileContent(decodeURIComponent(fileRead))
|
||||
const content =
|
||||
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
||||
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
|
||||
}
|
||||
if (path === "/api/fs/find" && config.findFiles) {
|
||||
const entries = await config.findFiles({
|
||||
query: url.searchParams.get("query") ?? "",
|
||||
dirs: url.searchParams.get("type") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||
})
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: Array.isArray(entries)
|
||||
? entries.map((entry) =>
|
||||
typeof entry === "string"
|
||||
? {
|
||||
name: entry.split(/[\\/]/).at(-1) ?? entry,
|
||||
path: entry,
|
||||
absolute: `${config.directory}/${entry}`,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
}
|
||||
: entry,
|
||||
)
|
||||
: entries,
|
||||
})
|
||||
}
|
||||
if (path === "/api/shell" && route.request().method() === "GET")
|
||||
return json(route, { location: location(config), data: [] })
|
||||
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
||||
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
||||
if (path === "/api/session") {
|
||||
if (route.request().method() === "POST") {
|
||||
const payload = route.request().postDataJSON() as Record<string, unknown>
|
||||
const created = currentSession(
|
||||
{
|
||||
id: "ses_mock_created",
|
||||
projectID: (config.project as { id?: string }).id,
|
||||
title: typeof payload.title === "string" ? payload.title : "New session",
|
||||
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
|
||||
},
|
||||
config.directory,
|
||||
)
|
||||
config.sessions.push(created)
|
||||
return json(route, { data: created })
|
||||
}
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
const directory = url.searchParams.get("directory")
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||
const sessions = config.sessions
|
||||
.filter((session) => {
|
||||
const location = session.location as { directory?: string } | undefined
|
||||
return !directory || location?.directory === directory || session.directory === directory
|
||||
})
|
||||
.filter((session) => {
|
||||
if (parentID === null) return true
|
||||
if (parentID === "null") return session.parentID === undefined
|
||||
return session.parentID === parentID
|
||||
})
|
||||
.filter((session) => {
|
||||
const search = url.searchParams.get("search")?.toLowerCase()
|
||||
return (
|
||||
!search ||
|
||||
String(session.title ?? "")
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
)
|
||||
})
|
||||
const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed()
|
||||
const data = ordered.slice(offset, offset + limit)
|
||||
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
|
||||
return json(route, {
|
||||
data: data.map((session) => currentSession(session, config.directory)),
|
||||
cursor: { next },
|
||||
})
|
||||
}
|
||||
if (path === "/api/session/active") {
|
||||
const statuses = (
|
||||
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
|
||||
) as Record<string, { type?: string }>
|
||||
return json(route, {
|
||||
data: Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([id, status]) =>
|
||||
status.type === "idle" ? [] : [[id, { type: "running" }]],
|
||||
worktreeRemove: () => noContent,
|
||||
worktreeRefresh: () => noContent,
|
||||
location: () => Effect.succeed(location(config)),
|
||||
permissionRequests: () =>
|
||||
Effect.succeed({
|
||||
location: location(config),
|
||||
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
|
||||
currentPermission,
|
||||
),
|
||||
}),
|
||||
formRequests: () =>
|
||||
Effect.succeed({
|
||||
location: location(config),
|
||||
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
|
||||
}),
|
||||
vcs: () =>
|
||||
Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }),
|
||||
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }),
|
||||
fsList: (ctx) =>
|
||||
Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe(
|
||||
Effect.map((data) => ({ location: location(config), data })),
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
|
||||
if (sessionForm && route.request().method() === "GET") {
|
||||
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
|
||||
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
|
||||
return json(route, { data: [] })
|
||||
const sessionPermission = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1]
|
||||
if (sessionPermission && route.request().method() === "GET") {
|
||||
const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
|
||||
return json(route, {
|
||||
data: permissions.map(currentPermission).filter((permission) => permission.sessionID === sessionPermission),
|
||||
})
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (
|
||||
/^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
route.request().method() === "POST"
|
||||
) {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
||||
if (currentSessionMatch) {
|
||||
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
|
||||
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
|
||||
return json(route, {
|
||||
data: currentSession(session, config.directory),
|
||||
})
|
||||
}
|
||||
|
||||
const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (messageMatch) {
|
||||
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const message =
|
||||
config.message?.(messageMatch[1]!, messageMatch[2]!) ??
|
||||
config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2])
|
||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||
return json(route, { data: message })
|
||||
}
|
||||
|
||||
const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const token = url.searchParams.get("cursor") ?? undefined
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
||||
if (cursor) cursors.set(cursor, pageData.cursor!)
|
||||
return json(route, {
|
||||
data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(),
|
||||
cursor: { next: cursor },
|
||||
})
|
||||
}
|
||||
|
||||
if (url.port === targetPort && targetPort !== appPort)
|
||||
return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404)
|
||||
return route.fallback()
|
||||
})
|
||||
fsFind: (ctx) =>
|
||||
Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
config.findFiles?.({ query: ctx.query.query ?? "", dirs: ctx.query.type, limit: ctx.query.limit }),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((entries) => ({
|
||||
location: location(config),
|
||||
data: Array.isArray(entries)
|
||||
? entries.map((entry) =>
|
||||
typeof entry === "string"
|
||||
? {
|
||||
name: entry.split(/[\\/]/).at(-1) ?? entry,
|
||||
path: entry,
|
||||
absolute: `${config.directory}/${entry}`,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
}
|
||||
: entry,
|
||||
)
|
||||
: entries,
|
||||
})),
|
||||
),
|
||||
shell: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
ptyConnectToken: () =>
|
||||
Effect.succeed({ location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||
sessionList: (ctx) => {
|
||||
const sessions = config.sessions
|
||||
.filter((session) => {
|
||||
const location = session.location as { directory?: string } | undefined
|
||||
return (
|
||||
!ctx.query.directory ||
|
||||
location?.directory === ctx.query.directory ||
|
||||
session.directory === ctx.query.directory
|
||||
)
|
||||
})
|
||||
.filter((session) => {
|
||||
if (ctx.query.parentID === undefined) return true
|
||||
if (ctx.query.parentID === "null") return session.parentID === undefined
|
||||
return session.parentID === ctx.query.parentID
|
||||
})
|
||||
.filter((session) =>
|
||||
ctx.query.search === undefined
|
||||
? true
|
||||
: String(session.title ?? "")
|
||||
.toLowerCase()
|
||||
.includes(ctx.query.search.toLowerCase()),
|
||||
)
|
||||
const ordered = ctx.query.order === "asc" ? sessions : sessions.toReversed()
|
||||
const offset = Number(ctx.query.cursor ?? 0)
|
||||
const limit = ctx.query.limit ?? 50
|
||||
const data = ordered.slice(offset, offset + limit)
|
||||
return Effect.succeed({
|
||||
data: data.map((session) => currentSession(session, config.directory)),
|
||||
cursor: { next: offset + limit < ordered.length ? String(offset + limit) : undefined },
|
||||
})
|
||||
},
|
||||
sessionCreate: (ctx) => {
|
||||
const payload = record(ctx.payload) ? ctx.payload : {}
|
||||
const created = currentSession(
|
||||
{
|
||||
id: "ses_mock_created",
|
||||
projectID: (config.project as { id?: string }).id,
|
||||
title: typeof payload.title === "string" ? payload.title : "New session",
|
||||
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
|
||||
},
|
||||
config.directory,
|
||||
)
|
||||
return Effect.sync(() => config.sessions.push(created)).pipe(Effect.as({ data: created }))
|
||||
},
|
||||
sessionActive: () => {
|
||||
const statuses = (
|
||||
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
|
||||
) as Record<string, { type?: string }>
|
||||
return Effect.succeed({
|
||||
data: Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([id, status]) =>
|
||||
status.type === "idle" ? [] : [[id, { type: "running" }]],
|
||||
),
|
||||
),
|
||||
})
|
||||
},
|
||||
sessionGet: (ctx) => {
|
||||
const session = config.sessions.find((item) => item.id === ctx.params.sessionID)
|
||||
return session
|
||||
? Effect.succeed({ data: currentSession(session, config.directory) })
|
||||
: Effect.fail(new MockNotFound({ message: "Session not found" }))
|
||||
},
|
||||
sessionRemove: () => noContent,
|
||||
sessionShell: () => noContent,
|
||||
sessionForm: (ctx) => {
|
||||
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
|
||||
return Effect.succeed({
|
||||
data: forms.filter((form) => (form as { sessionID?: string }).sessionID === ctx.params.sessionID),
|
||||
})
|
||||
},
|
||||
sessionFormReply: () => noContent,
|
||||
sessionFormCancel: () => noContent,
|
||||
sessionBackground: () => noContent,
|
||||
sessionInbox: () => Effect.succeed({ data: [] }),
|
||||
sessionPermission: (ctx) => {
|
||||
const permissions =
|
||||
typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
|
||||
return Effect.succeed({
|
||||
data: permissions
|
||||
.map(currentPermission)
|
||||
.filter((permission) => permission.sessionID === ctx.params.sessionID),
|
||||
})
|
||||
},
|
||||
sessionPermissionReply: () => noContent,
|
||||
sessionRename: () => noContent,
|
||||
sessionInterrupt: () => noContent,
|
||||
sessionRevertStage: (ctx) => {
|
||||
const payload = record(ctx.payload) ? ctx.payload : {}
|
||||
const messageID = payload.messageID
|
||||
if (typeof messageID !== "string") {
|
||||
return Effect.fail(new MockBadRequest({ message: "Invalid revert request" }))
|
||||
}
|
||||
return Effect.sync(() => config.onRevertStage?.({ sessionID: ctx.params.sessionID, messageID })).pipe(
|
||||
Effect.as({ data: { messageID } }),
|
||||
)
|
||||
},
|
||||
sessionRevertClear: () => noContent,
|
||||
sessionRevertCommit: () => noContent,
|
||||
messageGet: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
config.onMessage?.({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })
|
||||
yield* delay
|
||||
const message =
|
||||
config.message?.(ctx.params.sessionID, ctx.params.messageID) ??
|
||||
config
|
||||
.pageMessages(ctx.params.sessionID, Number.MAX_SAFE_INTEGER)
|
||||
.items.find((item) => item.id === ctx.params.messageID)
|
||||
if (!message) return yield* new MockNotFound({ message: "Message not found" })
|
||||
return { data: message }
|
||||
}),
|
||||
messageList: (ctx) => {
|
||||
const token = ctx.query.cursor
|
||||
const before = token ? state.cursors.get(token) : undefined
|
||||
if (token && !before) return Effect.fail(new MockBadRequest({ message: "Invalid cursor" }))
|
||||
return Effect.gen(function* () {
|
||||
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "start" })
|
||||
if (config.beforeMessagesResponse) {
|
||||
yield* Effect.promise(() => config.beforeMessagesResponse!({ sessionID: ctx.params.sessionID, before }))
|
||||
}
|
||||
yield* delay
|
||||
const pageData = config.pageMessages(ctx.params.sessionID, ctx.query.limit ?? 50, before)
|
||||
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "end" })
|
||||
const cursor = pageData.cursor ? `cursor_${++state.nextCursor}` : undefined
|
||||
if (cursor) state.cursors.set(cursor, pageData.cursor!)
|
||||
return {
|
||||
data: ctx.query.order === "asc" ? pageData.items : pageData.items.toReversed(),
|
||||
cursor: { next: cursor },
|
||||
}
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function location(config: MockServerConfig) {
|
||||
@@ -585,24 +613,3 @@ function jsonValue(value: unknown): JsonValue | undefined {
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-expose-headers": "x-next-cursor",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body ?? null),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route, events?: unknown[], retry?: number) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./desktop": "./src/desktop.ts",
|
||||
"./desktop-menu": "./src/desktop-menu.ts",
|
||||
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
|
||||
"./updater": "./src/updater.ts",
|
||||
"./wsl/types": "./src/wsl/types.ts",
|
||||
"./desktop-menu": "./src/shell/commands/desktop-menu.ts",
|
||||
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
|
||||
"./updater": "./src/shell/updates/types.ts",
|
||||
"./wsl/types": "./src/servers/wsl/types.ts",
|
||||
"./vite": "./vite.js",
|
||||
"./index.css": "./src/index.css"
|
||||
},
|
||||
|
||||
+15
-105
@@ -4,72 +4,26 @@ import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Route, Router, useParams } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Router } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import {
|
||||
type Component,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
ErrorBoundary,
|
||||
type JSX,
|
||||
lazy,
|
||||
type ParentProps,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection, ServersProvider } from "@/context/servers"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TabsProvider } from "@/context/tabs"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { requireServerKey } from "./utils/session-route"
|
||||
import { CommandProvider } from "@/shell/commands/command"
|
||||
import { DesktopCommands } from "@/shell/commands/desktop"
|
||||
import { GlobalProvider } from "@/runtime/server/runtime"
|
||||
import { HighlightsProvider } from "@/shell/updates/highlights"
|
||||
import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
|
||||
import { SettingsProvider } from "@/settings/model"
|
||||
import { TabsProvider } from "@/shell/tabs/tabs"
|
||||
import { WslServersProvider } from "@/servers/wsl/context"
|
||||
import { ErrorPage } from "@/shell/errors/error"
|
||||
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
|
||||
|
||||
import { Home } from "@/pages/home"
|
||||
import { ServerProvider } from "./context/server"
|
||||
|
||||
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
|
||||
const loadDraftRoute = () => Promise.all([import("@/pages/draft-route"), File.preload()]).then(([module]) => module)
|
||||
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
|
||||
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
|
||||
export function preloadRoute(url: string) {
|
||||
const pathname = url.split(/[?#]/, 1)[0]
|
||||
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
|
||||
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
|
||||
return TargetSessionRouteContent.preload().then(() => undefined)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string }>()
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() =>
|
||||
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
|
||||
)
|
||||
|
||||
return (
|
||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||
<Show when={conn()} keyed>
|
||||
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
export { preloadRoute }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: {
|
||||
deepLinks?: string[]
|
||||
}
|
||||
api?: {
|
||||
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
|
||||
exportDebugLogs?: () => Promise<string>
|
||||
@@ -100,39 +54,6 @@ function BodyTypography() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Server-agnostic providers shared across every route. These live in the shared
|
||||
// shell (router root) so they stay mounted regardless of the active server/route.
|
||||
function DesktopCommands() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
command.register("desktop", () => {
|
||||
const commands: CommandOption[] = []
|
||||
if (platform.platform === "desktop" && platform.exportDebugLogs) {
|
||||
commands.push({
|
||||
id: "logs.export",
|
||||
title: language.t("command.logs.export"),
|
||||
category: language.t("command.category.settings"),
|
||||
onSelect: () => {
|
||||
void platform.exportDebugLogs?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
return commands
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function AppLayout(props: ParentProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</LayoutProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppBaseProviders(
|
||||
props: ParentProps<{
|
||||
locale?: Locale
|
||||
@@ -204,18 +125,7 @@ export function AppInterface(props: {
|
||||
<SettingsProvider>
|
||||
<GlobalProvider>
|
||||
<Dynamic component={props.router ?? Router} root={Root}>
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
)}
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
<AppRoutes />
|
||||
</Dynamic>
|
||||
</GlobalProvider>
|
||||
</SettingsProvider>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
|
||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Badge } from "@opencode-ai/ui/badge"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { type Component, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
||||
const local = useLocal()
|
||||
const model = props.model ?? local.model
|
||||
const dialog = useDialog()
|
||||
const directory = () => decode64(local.slug())
|
||||
const providers = useProviders(directory)
|
||||
const language = useLanguage()
|
||||
|
||||
const openProviders = (provider?: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select(provider)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
|
||||
})
|
||||
}
|
||||
|
||||
const connect = (provider: string) => openProviders(provider)
|
||||
const all = () => openProviders()
|
||||
|
||||
let listRef: ListRef | undefined
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") return
|
||||
listRef?.onKeyDown(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
|
||||
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
||||
<List
|
||||
class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
|
||||
ref={(ref) => (listRef = ref)}
|
||||
items={model.list}
|
||||
current={model.current()}
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
itemWrapper={(item, node) => (
|
||||
<Tooltip
|
||||
appearance="standard"
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
latest={item.latest}
|
||||
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{node}
|
||||
</Tooltip>
|
||||
)}
|
||||
onSelect={(x) => {
|
||||
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
recent: true,
|
||||
})
|
||||
dialog.close()
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2.5">
|
||||
<span>{i.name}</span>
|
||||
<Badge appearance="standard">{language.t("model.tag.free")}</Badge>
|
||||
<Show when={i.latest}>
|
||||
<Badge appearance="standard">{language.t("model.tag.latest")}</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</div>
|
||||
<div class="px-1.5 pb-1.5">
|
||||
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
|
||||
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
|
||||
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
|
||||
<div class="w-full">
|
||||
<List
|
||||
class="w-full px-3"
|
||||
key={(p) => p.id}
|
||||
items={providers.popular}
|
||||
activeIcon="plus-small"
|
||||
sortBy={(a, b) => {
|
||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||
return a.name.localeCompare(b.name)
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
connect(x.id)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-3">
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
||||
<span>{i.name}</span>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{language.t("dialog.provider.opencode.tagline")}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<Badge appearance="standard">{language.t("dialog.provider.tag.recommended")}</Badge>
|
||||
</Show>
|
||||
<Show when={i.id === "opencode-go"}>
|
||||
<>
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{language.t("dialog.provider.opencodeGo.tagline")}
|
||||
</div>
|
||||
<Badge appearance="standard">{language.t("dialog.provider.tag.recommended")}</Badge>
|
||||
</>
|
||||
</Show>
|
||||
<Show when={i.id === "anthropic"}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
||||
icon="dot-grid"
|
||||
onClick={all}
|
||||
>
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Show } from "solid-js"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { ServerCollectionController } from "@/components/server/server-management-controller"
|
||||
|
||||
type ServerConnectionFormController = {
|
||||
state: {
|
||||
adding: () => boolean
|
||||
busy: () => boolean
|
||||
value: () => string
|
||||
name: () => string
|
||||
username: () => string
|
||||
password: () => string
|
||||
error: () => string
|
||||
status: () => boolean | undefined
|
||||
}
|
||||
change: {
|
||||
value: (value: string) => void
|
||||
name: (value: string) => void
|
||||
username: (value: string) => void
|
||||
password: (value: string) => void
|
||||
}
|
||||
reset: () => void
|
||||
submit: () => void
|
||||
}
|
||||
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
name: string
|
||||
username: string
|
||||
password: string
|
||||
placeholder: string
|
||||
busy: boolean
|
||||
error: string
|
||||
status: boolean | undefined
|
||||
onChange: (value: string) => void
|
||||
onNameChange: (value: string) => void
|
||||
onUsernameChange: (value: string) => void
|
||||
onPasswordChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function ServerForm(props: ServerFormProps) {
|
||||
const language = useLanguage()
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.onBack()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
props.onSubmit()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
|
||||
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
||||
<TextField
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.url")}
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
autofocus
|
||||
validationState={props.error ? "invalid" : "valid"}
|
||||
error={props.error}
|
||||
disabled={props.busy}
|
||||
onChange={props.onChange}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.name")}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
defaultValue={props.name}
|
||||
disabled={props.busy}
|
||||
onChange={props.onNameChange}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2 min-w-0">
|
||||
<TextField
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.username")}
|
||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||
defaultValue={props.username}
|
||||
disabled={props.busy}
|
||||
onChange={props.onUsernameChange}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<TextField
|
||||
type="password"
|
||||
label={language.t("dialog.server.add.password")}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
defaultValue={props.password}
|
||||
disabled={props.busy}
|
||||
onChange={props.onPasswordChange}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerConnectionList(props: {
|
||||
domain: ServerCollectionController
|
||||
onAdd: () => void
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||
<List
|
||||
class="flex-1 min-h-0 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
||||
search={{
|
||||
placeholder: language.t("dialog.server.search.placeholder"),
|
||||
autofocus: false,
|
||||
}}
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={props.domain.collection.items}
|
||||
key={(x) => x.http.url}
|
||||
divider={true}
|
||||
>
|
||||
{(i) => {
|
||||
const key = ServerConnection.key(i)
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||
<div class="flex flex-col h-full items-center w-5">
|
||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||
</div>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
dimmed={props.domain.collection.health()[key]?.healthy === false}
|
||||
status={props.domain.collection.health()[key]}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
showCredentials
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-4 pl-4">
|
||||
<Show when={i.type === "http"}>
|
||||
<Menu appearance="standard">
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="dot-grid" />}
|
||||
variant="ghost"
|
||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="mt-1">
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
if (i.type !== "http") return
|
||||
props.onEdit(i)
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.server.menu.edit")}
|
||||
</Menu.Item>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||
<Menu.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<Menu.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.connection.canRemove(key)}>
|
||||
<Menu.Separator />
|
||||
<Menu.Item
|
||||
onSelect={() => props.domain.connection.remove(key)}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
|
||||
<div class="shrink-0 pb-5">
|
||||
<Button
|
||||
variant="neutral"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={props.onAdd}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerConnectionForm(props: { form: ServerConnectionFormController }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||
<ServerForm
|
||||
value={props.form.state.value()}
|
||||
name={props.form.state.name()}
|
||||
username={props.form.state.username()}
|
||||
password={props.form.state.password()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={props.form.state.busy()}
|
||||
error={props.form.state.error()}
|
||||
status={props.form.state.status()}
|
||||
onChange={props.form.change.value}
|
||||
onNameChange={props.form.change.name}
|
||||
onUsernameChange={props.form.change.username}
|
||||
onPasswordChange={props.form.change.password}
|
||||
onSubmit={props.form.submit}
|
||||
onBack={props.form.reset}
|
||||
/>
|
||||
<div class="shrink-0 pb-5">
|
||||
<Button
|
||||
variant="contrast"
|
||||
size="large"
|
||||
onClick={props.form.submit}
|
||||
disabled={props.form.state.busy()}
|
||||
class="px-3 py-1.5"
|
||||
>
|
||||
{props.form.state.busy()
|
||||
? language.t("dialog.server.add.checking")
|
||||
: props.form.state.adding()
|
||||
? language.t("dialog.server.add.button")
|
||||
: language.t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { PromptInputV2Composer, usePromptInputV2Controller } from "./prompt-input-v2"
|
||||
import { createPromptInputHistory, type PromptInputHistory } from "./prompt-input/history-store"
|
||||
import type {
|
||||
PromptInputControls,
|
||||
PromptInputProps,
|
||||
PromptInputState,
|
||||
PromptInputSubmission,
|
||||
} from "./prompt-input/contracts"
|
||||
|
||||
export { createPromptInputHistory }
|
||||
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const controller = usePromptInputV2Controller(props)
|
||||
return <PromptInputV2Composer class={props.class} controller={controller} />
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { attachmentMime, pickAttachmentFiles } from "./files"
|
||||
import { pasteMode } from "./paste"
|
||||
|
||||
describe("attachmentMime", () => {
|
||||
test("keeps PDFs when the browser reports the mime", async () => {
|
||||
const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
|
||||
expect(await attachmentMime(file)).toBe("application/pdf")
|
||||
})
|
||||
|
||||
test("normalizes structured text types to text/plain", async () => {
|
||||
const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
|
||||
expect(await attachmentMime(file)).toBe("text/plain")
|
||||
})
|
||||
|
||||
test("accepts text files even with a misleading browser mime", async () => {
|
||||
const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
|
||||
expect(await attachmentMime(file)).toBe("text/plain")
|
||||
})
|
||||
|
||||
test("rejects binary files", async () => {
|
||||
const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
|
||||
expect(await attachmentMime(file)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickAttachmentFiles", () => {
|
||||
test("reads the current project directory for every native picker invocation", async () => {
|
||||
const paths: string[] = []
|
||||
const files: File[] = []
|
||||
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
|
||||
let directory = "C:\\Projects\\LoremIpsum"
|
||||
const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
|
||||
paths.push(options?.defaultPath ?? "")
|
||||
await onFile?.(file)
|
||||
}
|
||||
|
||||
pickAttachmentFiles({
|
||||
picker,
|
||||
directory: () => directory,
|
||||
fallback: () => undefined,
|
||||
onFile: async (selected) => files.push(selected),
|
||||
onError: () => undefined,
|
||||
})
|
||||
await Promise.resolve()
|
||||
directory = "C:\\Projects\\DolorSit"
|
||||
pickAttachmentFiles({
|
||||
picker,
|
||||
directory: () => directory,
|
||||
fallback: () => undefined,
|
||||
onFile: async (selected) => files.push(selected),
|
||||
onError: () => undefined,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(files).toEqual([file, file])
|
||||
expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
|
||||
})
|
||||
|
||||
test("uses the browser file input when no native picker exists", async () => {
|
||||
let fallback = 0
|
||||
pickAttachmentFiles({
|
||||
directory: () => "/projects/consectetur-adipiscing",
|
||||
fallback: () => {
|
||||
fallback += 1
|
||||
},
|
||||
onFile: async () => undefined,
|
||||
onError: () => undefined,
|
||||
})
|
||||
expect(fallback).toBe(1)
|
||||
})
|
||||
|
||||
test("reports native picker failures without rejecting", async () => {
|
||||
const error = new Error("picker unavailable")
|
||||
const errors: unknown[] = []
|
||||
const handled = Promise.withResolvers<void>()
|
||||
pickAttachmentFiles({
|
||||
picker: async () => Promise.reject(error),
|
||||
directory: () => "C:\\Projects\\LoremIpsum",
|
||||
fallback: () => undefined,
|
||||
onFile: async () => undefined,
|
||||
onError: (cause) => {
|
||||
errors.push(cause)
|
||||
handled.resolve()
|
||||
},
|
||||
})
|
||||
await handled.promise
|
||||
expect(errors).toEqual([error])
|
||||
})
|
||||
})
|
||||
|
||||
describe("pasteMode", () => {
|
||||
test("uses native paste for short single-line text", () => {
|
||||
expect(pasteMode("hello world")).toBe("native")
|
||||
})
|
||||
|
||||
test("uses manual paste for multiline text", () => {
|
||||
expect(
|
||||
pasteMode(`{
|
||||
"ok": true
|
||||
}`),
|
||||
).toBe("manual")
|
||||
expect(pasteMode("a\r\nb")).toBe("manual")
|
||||
})
|
||||
|
||||
test("uses manual paste for large text", () => {
|
||||
expect(pasteMode("x".repeat(8000))).toBe("manual")
|
||||
})
|
||||
})
|
||||
@@ -1,213 +0,0 @@
|
||||
import { onMount } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { getCursorPosition } from "./editor-dom"
|
||||
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
|
||||
import { attachmentMime } from "./files"
|
||||
import { normalizePaste, pasteMode } from "./paste"
|
||||
|
||||
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
|
||||
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
|
||||
|
||||
type PromptAttachmentsCoreInput = {
|
||||
capture: () => PromptTarget
|
||||
editor: () => HTMLDivElement | undefined
|
||||
focusEditor?: () => void
|
||||
addPart?: (part: ContentPart) => boolean
|
||||
warn?: () => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
draftStore?: DraftStore
|
||||
}
|
||||
|
||||
export type PromptAttachmentsInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
isDialogActive: () => boolean
|
||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||
focusEditor: () => void
|
||||
addPart: (part: ContentPart) => boolean
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
}
|
||||
|
||||
export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
const capture = (): AttachmentTarget | undefined => {
|
||||
const prompt = input.capture()
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
|
||||
}
|
||||
|
||||
const add = async (file: File, toast = true, target = capture()) => {
|
||||
if (!target) return false
|
||||
const mime = await attachmentMime(file)
|
||||
if (!mime) {
|
||||
if (toast) input.warn?.()
|
||||
return false
|
||||
}
|
||||
|
||||
const attachment: ImageAttachmentPart = {
|
||||
type: "image",
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
sourcePath: input.getPathForFile?.(file) || undefined,
|
||||
mime,
|
||||
blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file),
|
||||
}
|
||||
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||
return true
|
||||
}
|
||||
|
||||
const addAttachment = (file: File) => add(file)
|
||||
|
||||
const addAttachments = async (files: File[], toast = true, target = capture()) => {
|
||||
let found = false
|
||||
|
||||
for (const file of files) {
|
||||
const ok = await add(file, false, target)
|
||||
if (ok) found = true
|
||||
}
|
||||
|
||||
if (!found && files.length > 0 && toast) input.warn?.()
|
||||
return found
|
||||
}
|
||||
|
||||
const addClipboardAttachment = async (pending: Promise<File | null>, target = capture()) => {
|
||||
const file = await pending
|
||||
if (!file) return false
|
||||
return add(file, true, target)
|
||||
}
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
const target = input.capture()
|
||||
const current = target.current()
|
||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||
target.set(next, target.cursor())
|
||||
}
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) return
|
||||
const target = capture()
|
||||
if (!target) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const files = Array.from(clipboardData.items).flatMap((item) => {
|
||||
if (item.kind !== "file") return []
|
||||
const file = item.getAsFile()
|
||||
return file ? [file] : []
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
await addAttachments(files, true, target)
|
||||
return
|
||||
}
|
||||
|
||||
const plainText = clipboardData.getData("text/plain") ?? ""
|
||||
|
||||
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
|
||||
if (input.readClipboardImage && !plainText) {
|
||||
if (await addClipboardAttachment(input.readClipboardImage(), target)) return
|
||||
}
|
||||
|
||||
if (!plainText) return
|
||||
|
||||
const text = normalizePaste(plainText)
|
||||
|
||||
const put = () => {
|
||||
if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||
input.focusEditor?.()
|
||||
return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false
|
||||
}
|
||||
|
||||
if (pasteMode(text) === "manual") {
|
||||
put()
|
||||
return
|
||||
}
|
||||
|
||||
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
|
||||
if (inserted) return
|
||||
|
||||
put()
|
||||
}
|
||||
|
||||
return {
|
||||
addAttachment,
|
||||
addAttachments,
|
||||
addClipboardAttachment,
|
||||
removeAttachment,
|
||||
handlePaste,
|
||||
}
|
||||
}
|
||||
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const attachments = createPromptAttachmentsCore({
|
||||
...input,
|
||||
draftStore: platform.draftStore,
|
||||
capture: input.prompt.capture,
|
||||
warn: () => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleGlobalDragOver = (event: DragEvent) => {
|
||||
if (input.isDialogActive()) return
|
||||
|
||||
event.preventDefault()
|
||||
const hasFiles = event.dataTransfer?.types.includes("Files")
|
||||
const hasText = event.dataTransfer?.types.includes("text/plain")
|
||||
if (hasFiles) {
|
||||
input.setDraggingType("image")
|
||||
} else if (hasText) {
|
||||
input.setDraggingType("@mention")
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalDragLeave = (event: DragEvent) => {
|
||||
if (input.isDialogActive()) return
|
||||
if (!event.relatedTarget) {
|
||||
input.setDraggingType(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalDrop = async (event: DragEvent) => {
|
||||
if (input.isDialogActive()) return
|
||||
|
||||
event.preventDefault()
|
||||
input.setDraggingType(null)
|
||||
|
||||
const plainText = event.dataTransfer?.getData("text/plain")
|
||||
const filePrefix = "file:"
|
||||
if (plainText?.startsWith(filePrefix)) {
|
||||
const filePath = plainText.slice(filePrefix.length)
|
||||
input.focusEditor()
|
||||
input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 })
|
||||
return
|
||||
}
|
||||
|
||||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
|
||||
await attachments.addAttachments(Array.from(dropped))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(document, "dragover", handleGlobalDragOver)
|
||||
makeEventListener(document, "dragleave", handleGlobalDragLeave)
|
||||
makeEventListener(document, "drop", handleGlobalDrop)
|
||||
})
|
||||
|
||||
return attachments
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { useLocal } from "@/context/local"
|
||||
import type { Prompt, usePrompt } from "@/context/prompt"
|
||||
import type { PromptInputHistory } from "./history-store"
|
||||
import type { FollowupDraft } from "./submit"
|
||||
|
||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
|
||||
export type PromptInputSubmission = {
|
||||
abort: () => Promise<void> | void
|
||||
handleSubmit: (event: Event) => Promise<void> | void
|
||||
}
|
||||
|
||||
export type PromptInputControls = {
|
||||
agents: {
|
||||
available: { name: string; hidden?: boolean; mode: string }[]
|
||||
options: string[]
|
||||
current: string
|
||||
loading: boolean
|
||||
visible: boolean
|
||||
select: (name: string | undefined) => void
|
||||
}
|
||||
model: {
|
||||
selection: ReturnType<typeof useLocal>["model"]
|
||||
paid: boolean
|
||||
loading: boolean
|
||||
}
|
||||
session: {
|
||||
id?: string
|
||||
tabs: {
|
||||
active: () => string | undefined
|
||||
all: () => string[]
|
||||
open: (tab: string) => void | Promise<void>
|
||||
setActive: (tab: string) => void
|
||||
}
|
||||
reviewPanel: {
|
||||
opened: () => boolean
|
||||
open: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PromptInputProps {
|
||||
class?: string
|
||||
state?: PromptInputState
|
||||
history?: PromptInputHistory
|
||||
submission?: PromptInputSubmission
|
||||
controls: PromptInputControls
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
newSessionWorktree?: string
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||
onEditLoaded?: () => void
|
||||
shouldQueue?: () => boolean
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
|
||||
|
||||
describe("prompt-input editor dom", () => {
|
||||
test("createTextFragment preserves newlines with consecutive br nodes", () => {
|
||||
const fragment = createTextFragment("foo\n\nbar")
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(fragment)
|
||||
|
||||
expect(container.childNodes.length).toBe(4)
|
||||
expect(container.childNodes[0]?.textContent).toBe("foo")
|
||||
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||
expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR")
|
||||
expect(container.childNodes[3]?.textContent).toBe("bar")
|
||||
})
|
||||
|
||||
test("createTextFragment keeps trailing newline as terminal break", () => {
|
||||
const fragment = createTextFragment("foo\n")
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(fragment)
|
||||
|
||||
expect(container.childNodes.length).toBe(2)
|
||||
expect(container.childNodes[0]?.textContent).toBe("foo")
|
||||
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||
})
|
||||
|
||||
test("createTextFragment avoids break-node explosion for large multiline content", () => {
|
||||
const content = Array.from({ length: 220 }, () => "line").join("\n")
|
||||
const fragment = createTextFragment(content)
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(fragment)
|
||||
|
||||
expect(container.childNodes.length).toBe(1)
|
||||
expect(container.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
|
||||
expect(container.textContent).toBe(content)
|
||||
})
|
||||
|
||||
test("createTextFragment keeps terminal break in large multiline fallback", () => {
|
||||
const content = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
|
||||
const fragment = createTextFragment(content)
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(fragment)
|
||||
|
||||
expect(container.childNodes.length).toBe(2)
|
||||
expect(container.childNodes[0]?.textContent).toBe(content.slice(0, -1))
|
||||
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||
})
|
||||
|
||||
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(document.createTextNode("ab\u200B"))
|
||||
container.appendChild(document.createElement("br"))
|
||||
container.appendChild(document.createTextNode("cd"))
|
||||
|
||||
expect(getNodeLength(container.childNodes[0]!)).toBe(2)
|
||||
expect(getNodeLength(container.childNodes[1]!)).toBe(1)
|
||||
expect(getTextLength(container)).toBe(5)
|
||||
})
|
||||
|
||||
test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
|
||||
const container = document.createElement("div")
|
||||
const pill = document.createElement("span")
|
||||
pill.dataset.type = "file"
|
||||
pill.textContent = "@file"
|
||||
container.appendChild(document.createTextNode("ab"))
|
||||
container.appendChild(pill)
|
||||
container.appendChild(document.createElement("br"))
|
||||
container.appendChild(document.createTextNode("cd"))
|
||||
document.body.appendChild(container)
|
||||
|
||||
setCursorPosition(container, 2)
|
||||
expect(getCursorPosition(container)).toBe(2)
|
||||
|
||||
setCursorPosition(container, 7)
|
||||
expect(getCursorPosition(container)).toBe(7)
|
||||
|
||||
setCursorPosition(container, 8)
|
||||
expect(getCursorPosition(container)).toBe(8)
|
||||
|
||||
container.remove()
|
||||
})
|
||||
|
||||
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(document.createTextNode("a"))
|
||||
container.appendChild(document.createElement("br"))
|
||||
container.appendChild(document.createElement("br"))
|
||||
container.appendChild(document.createTextNode("b"))
|
||||
document.body.appendChild(container)
|
||||
|
||||
setCursorPosition(container, 2)
|
||||
expect(getCursorPosition(container)).toBe(2)
|
||||
|
||||
setCursorPosition(container, 3)
|
||||
expect(getCursorPosition(container)).toBe(3)
|
||||
|
||||
container.remove()
|
||||
})
|
||||
})
|
||||
@@ -1,98 +0,0 @@
|
||||
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
|
||||
|
||||
export { ACCEPTED_FILE_TYPES }
|
||||
|
||||
type AttachmentPicker = (
|
||||
options: {
|
||||
defaultPath?: string
|
||||
multiple?: boolean
|
||||
accept?: string[]
|
||||
},
|
||||
onFile: (file: File) => Promise<unknown>,
|
||||
) => Promise<void>
|
||||
|
||||
export function pickAttachmentFiles(input: {
|
||||
picker?: AttachmentPicker
|
||||
directory: () => string
|
||||
fallback: () => void
|
||||
onFile: (file: File) => Promise<unknown>
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
if (!input.picker) {
|
||||
input.fallback()
|
||||
return
|
||||
}
|
||||
void input
|
||||
.picker(
|
||||
{
|
||||
defaultPath: input.directory(),
|
||||
multiple: true,
|
||||
accept: ACCEPTED_FILE_TYPES,
|
||||
},
|
||||
input.onFile,
|
||||
)
|
||||
.catch(input.onError)
|
||||
}
|
||||
|
||||
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||
const IMAGE_EXTS = new Map([
|
||||
["gif", "image/gif"],
|
||||
["jpeg", "image/jpeg"],
|
||||
["jpg", "image/jpeg"],
|
||||
["png", "image/png"],
|
||||
["webp", "image/webp"],
|
||||
])
|
||||
const TEXT_MIMES = new Set([
|
||||
"application/json",
|
||||
"application/ld+json",
|
||||
"application/toml",
|
||||
"application/x-toml",
|
||||
"application/x-yaml",
|
||||
"application/xml",
|
||||
"application/yaml",
|
||||
])
|
||||
|
||||
const SAMPLE = 4096
|
||||
|
||||
function kind(type: string) {
|
||||
return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||
}
|
||||
|
||||
function ext(name: string) {
|
||||
const idx = name.lastIndexOf(".")
|
||||
if (idx === -1) return ""
|
||||
return name.slice(idx + 1).toLowerCase()
|
||||
}
|
||||
|
||||
function textMime(type: string) {
|
||||
if (!type) return false
|
||||
if (type.startsWith("text/")) return true
|
||||
if (TEXT_MIMES.has(type)) return true
|
||||
if (type.endsWith("+json")) return true
|
||||
return type.endsWith("+xml")
|
||||
}
|
||||
|
||||
function textBytes(bytes: Uint8Array) {
|
||||
if (bytes.length === 0) return true
|
||||
let count = 0
|
||||
for (const byte of bytes) {
|
||||
if (byte === 0) return false
|
||||
if (byte < 9 || (byte > 13 && byte < 32)) count += 1
|
||||
}
|
||||
return count / bytes.length <= 0.3
|
||||
}
|
||||
|
||||
export async function attachmentMime(file: File) {
|
||||
const type = kind(file.type)
|
||||
if (IMAGE_MIMES.has(type)) return type
|
||||
if (type === "application/pdf") return type
|
||||
|
||||
const suffix = ext(file.name)
|
||||
const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
|
||||
if ((!type || type === "application/octet-stream") && fallback) return fallback
|
||||
|
||||
if (textMime(type)) return "text/plain"
|
||||
const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
|
||||
if (!textBytes(bytes)) return
|
||||
return "text/plain"
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
clonePromptParts,
|
||||
navigatePromptHistory,
|
||||
prependHistoryEntry,
|
||||
promptLength,
|
||||
type PromptHistoryComment,
|
||||
} from "./history"
|
||||
import { upgradeHistoryState } from "./history-store"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
|
||||
const entry = (value: string) => ({ prompt: text(value), comments: [] })
|
||||
const comment = (id: string, value = "note"): PromptHistoryComment => ({
|
||||
id,
|
||||
path: "src/a.ts",
|
||||
selection: { start: 2, end: 4 },
|
||||
comment: value,
|
||||
time: 1,
|
||||
origin: "review",
|
||||
preview: "const a = 1",
|
||||
})
|
||||
|
||||
describe("prompt-input history", () => {
|
||||
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
|
||||
const first = prependHistoryEntry([], DEFAULT_PROMPT)
|
||||
expect(first).toEqual([])
|
||||
|
||||
const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")])
|
||||
expect(commentsOnly).toHaveLength(1)
|
||||
|
||||
const withOne = prependHistoryEntry([], text("hello"))
|
||||
expect(withOne).toHaveLength(1)
|
||||
|
||||
const deduped = prependHistoryEntry(withOne, text("hello"))
|
||||
expect(deduped).toBe(withOne)
|
||||
|
||||
const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")])
|
||||
expect(dedupedComments).toBe(commentsOnly)
|
||||
})
|
||||
|
||||
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
|
||||
const entries = [entry("third"), entry("second"), entry("first")]
|
||||
const up = navigatePromptHistory({
|
||||
direction: "up",
|
||||
entries,
|
||||
historyIndex: -1,
|
||||
currentPrompt: text("draft"),
|
||||
currentComments: [comment("draft")],
|
||||
savedPrompt: null,
|
||||
})
|
||||
expect(up.handled).toBe(true)
|
||||
if (!up.handled) throw new Error("expected handled")
|
||||
expect(up.historyIndex).toBe(0)
|
||||
expect(up.cursor).toBe("start")
|
||||
expect(up.entry.comments).toEqual([])
|
||||
|
||||
const down = navigatePromptHistory({
|
||||
direction: "down",
|
||||
entries,
|
||||
historyIndex: up.historyIndex,
|
||||
currentPrompt: text("ignored"),
|
||||
currentComments: [],
|
||||
savedPrompt: up.savedPrompt,
|
||||
})
|
||||
expect(down.handled).toBe(true)
|
||||
if (!down.handled) throw new Error("expected handled")
|
||||
expect(down.historyIndex).toBe(-1)
|
||||
expect(down.entry.prompt[0]?.type === "text" ? down.entry.prompt[0].content : "").toBe("draft")
|
||||
expect(down.entry.comments).toEqual([comment("draft")])
|
||||
})
|
||||
|
||||
test("navigatePromptHistory keeps entry comments when moving through history", () => {
|
||||
const entries = [
|
||||
{
|
||||
prompt: text("with comment"),
|
||||
comments: [comment("c1")],
|
||||
},
|
||||
]
|
||||
|
||||
const up = navigatePromptHistory({
|
||||
direction: "up",
|
||||
entries,
|
||||
historyIndex: -1,
|
||||
currentPrompt: text("draft"),
|
||||
currentComments: [],
|
||||
savedPrompt: null,
|
||||
})
|
||||
|
||||
expect(up.handled).toBe(true)
|
||||
if (!up.handled) throw new Error("expected handled")
|
||||
expect(up.entry.prompt[0]?.type === "text" ? up.entry.prompt[0].content : "").toBe("with comment")
|
||||
expect(up.entry.comments).toEqual([comment("c1")])
|
||||
})
|
||||
|
||||
test("upgrades stored prompt arrays once at the persistence boundary", () => {
|
||||
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
|
||||
entries: [{ prompt: text("stored"), comments: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
test("helpers clone prompt and count text content length", () => {
|
||||
const original: Prompt = [
|
||||
{ type: "text", content: "one", start: 0, end: 3 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/a.ts",
|
||||
content: "@src/a.ts",
|
||||
start: 3,
|
||||
end: 12,
|
||||
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
||||
},
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
|
||||
]
|
||||
const copy = clonePromptParts(original)
|
||||
expect(copy).not.toBe(original)
|
||||
expect(promptLength(copy)).toBe(12)
|
||||
if (copy[1]?.type !== "file") throw new Error("expected file")
|
||||
copy[1].selection!.startLine = 9
|
||||
if (original[1]?.type !== "file") throw new Error("expected file")
|
||||
expect(original[1].selection?.startLine).toBe(1)
|
||||
})
|
||||
|
||||
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
||||
const value = "a\nb\nc"
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 3, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 1, true)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 1, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
const LARGE_PASTE_CHARS = 8000
|
||||
const LARGE_PASTE_BREAKS = 120
|
||||
|
||||
function largePaste(text: string) {
|
||||
if (text.length >= LARGE_PASTE_CHARS) return true
|
||||
let breaks = 0
|
||||
for (const char of text) {
|
||||
if (char !== "\n") continue
|
||||
breaks += 1
|
||||
if (breaks >= LARGE_PASTE_BREAKS) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function normalizePaste(text: string) {
|
||||
if (!text.includes("\r")) return text
|
||||
return text.replace(/\r\n?/g, "\n")
|
||||
}
|
||||
|
||||
export function pasteMode(text: string) {
|
||||
if (largePaste(text)) return "manual"
|
||||
if (text.includes("\n") || text.includes("\r")) return "manual"
|
||||
return "native"
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { promptDesignPlaceholder, promptPlaceholder } from "./placeholder"
|
||||
|
||||
describe("promptPlaceholder", () => {
|
||||
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
|
||||
|
||||
test("returns shell placeholder in shell mode", () => {
|
||||
const value = promptPlaceholder({
|
||||
mode: "shell",
|
||||
commentCount: 0,
|
||||
example: "example",
|
||||
suggest: true,
|
||||
t,
|
||||
})
|
||||
expect(value).toBe("prompt.placeholder.shell:example")
|
||||
})
|
||||
|
||||
test("returns summarize placeholders for comment context", () => {
|
||||
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe(
|
||||
"prompt.placeholder.summarizeComment",
|
||||
)
|
||||
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe(
|
||||
"prompt.placeholder.summarizeComments",
|
||||
)
|
||||
})
|
||||
|
||||
test("returns default placeholder with example when suggestions enabled", () => {
|
||||
const value = promptPlaceholder({
|
||||
mode: "normal",
|
||||
commentCount: 0,
|
||||
example: "translated-example",
|
||||
suggest: true,
|
||||
t,
|
||||
})
|
||||
expect(value).toBe("prompt.placeholder.normal:translated-example")
|
||||
})
|
||||
|
||||
test("returns simple placeholder when suggestions disabled", () => {
|
||||
const value = promptPlaceholder({
|
||||
mode: "normal",
|
||||
commentCount: 0,
|
||||
example: "translated-example",
|
||||
suggest: false,
|
||||
t,
|
||||
})
|
||||
expect(value).toBe("prompt.placeholder.simple")
|
||||
})
|
||||
})
|
||||
|
||||
describe("promptDesignPlaceholder", () => {
|
||||
const t = (key: string, params?: Record<string, string>) => {
|
||||
if (key !== "ui.promptInput.placeholder.normal") return key
|
||||
return `Ask anything, ${params?.slash} for commands, ${params?.at} for context...`
|
||||
}
|
||||
|
||||
test("composes the design placeholder from localized fragments", () => {
|
||||
expect(promptDesignPlaceholder("normal", "fallback", t)).toBe("Ask anything, / for commands, @ for context...")
|
||||
})
|
||||
|
||||
test("preserves the shell placeholder", () => {
|
||||
expect(promptDesignPlaceholder("shell", "Enter shell command...", t)).toBe("Enter shell command...")
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
type PromptPlaceholderInput = {
|
||||
mode: "normal" | "shell"
|
||||
commentCount: number
|
||||
example: string
|
||||
suggest: boolean
|
||||
t: (key: string, params?: Record<string, string>) => string
|
||||
}
|
||||
|
||||
export function promptPlaceholder(input: PromptPlaceholderInput) {
|
||||
if (input.mode === "shell") return input.t("prompt.placeholder.shell", { example: input.example })
|
||||
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
|
||||
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
|
||||
if (!input.suggest) return input.t("prompt.placeholder.simple")
|
||||
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||
}
|
||||
|
||||
export function promptDesignPlaceholder(
|
||||
mode: PromptPlaceholderInput["mode"],
|
||||
placeholder: string,
|
||||
t: PromptPlaceholderInput["t"],
|
||||
) {
|
||||
if (mode === "shell") return placeholder
|
||||
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdSessions: string[] = []
|
||||
type SessionCreateInput = {
|
||||
agent?: string
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
location?: { directory: string }
|
||||
}
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const sentShellDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
const sentPrompts: string[] = []
|
||||
const promptInputs: unknown[] = []
|
||||
const sentCommands: unknown[] = []
|
||||
const switchedAgents: Array<{ sessionID: string; agent: string }> = []
|
||||
const switchedModels: Array<{
|
||||
sessionID: string
|
||||
model: { id: string; providerID: string; variant?: string }
|
||||
}> = []
|
||||
const sessionRequestOrder: string[] = []
|
||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||
const navigations: string[] = []
|
||||
let serverSessionSyncs = 0
|
||||
let restoredPrompts = 0
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
let createWorktreeGate: Promise<void> | undefined
|
||||
let worktreeFailure: Error | undefined
|
||||
let locationFailure: Error | undefined
|
||||
let promptFailure: Error | undefined
|
||||
let worktreeCreates = 0
|
||||
let activeSDK = "server-a"
|
||||
let activeServer = "server-a"
|
||||
let commands: Array<{ name: string }> = []
|
||||
let worktreeDirectory = "/repo/new-0"
|
||||
let worktreeID = 0
|
||||
const sessionDirectories: Record<string, string> = {}
|
||||
|
||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
prompt: promptValue,
|
||||
cursor: 0,
|
||||
context: { items: [] },
|
||||
})
|
||||
const prompt = {
|
||||
store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
|
||||
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||
current: () => promptValue,
|
||||
cursor: () => 0,
|
||||
dirty: () => true,
|
||||
model: {
|
||||
current: () => undefined,
|
||||
set: () => undefined,
|
||||
},
|
||||
reset: () => undefined,
|
||||
set: () => restoredPrompts++,
|
||||
context: {
|
||||
add: () => undefined,
|
||||
remove: () => undefined,
|
||||
removeComment: () => undefined,
|
||||
updateComment: () => undefined,
|
||||
replaceComments: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
capture: (scope?: unknown, target?: unknown) => {
|
||||
promptCaptures.push({ scope, target })
|
||||
return prompt
|
||||
},
|
||||
}
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const clientFor = (directory: string) => {
|
||||
return {
|
||||
api: {
|
||||
session: {
|
||||
create: async (input: SessionCreateInput) => {
|
||||
await createSessionGate
|
||||
const location = input.location?.directory ?? directory
|
||||
createdSessions.push(location)
|
||||
const id = `session-${createdSessions.length}`
|
||||
sessionDirectories[id] = location
|
||||
return {
|
||||
id,
|
||||
projectID: "project",
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
title: `New session ${createdSessions.length}`,
|
||||
location: { directory: location },
|
||||
}
|
||||
},
|
||||
prompt: async (input: unknown) => {
|
||||
sessionRequestOrder.push("prompt")
|
||||
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
|
||||
promptInputs.push(input)
|
||||
if (promptFailure) throw promptFailure
|
||||
const prompt = input as { sessionID: string; id: string; text: string }
|
||||
return {
|
||||
id: prompt.id,
|
||||
sessionID: prompt.sessionID,
|
||||
timeCreated: 1,
|
||||
type: "user" as const,
|
||||
delivery: "steer" as const,
|
||||
payload: { text: prompt.text },
|
||||
}
|
||||
},
|
||||
switchAgent: async (input: { sessionID: string; agent: string }) => {
|
||||
sessionRequestOrder.push("agent")
|
||||
switchedAgents.push(input)
|
||||
},
|
||||
switchModel: async (input: {
|
||||
sessionID: string
|
||||
model: { id: string; providerID: string; variant?: string }
|
||||
}) => {
|
||||
sessionRequestOrder.push("model")
|
||||
switchedModels.push(input)
|
||||
},
|
||||
command: async (input: unknown) => {
|
||||
sentCommands.push(input)
|
||||
},
|
||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||
sentShell.push(input)
|
||||
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
|
||||
},
|
||||
},
|
||||
worktree: {
|
||||
create: async (_input: unknown) => {
|
||||
worktreeCreates++
|
||||
await createWorktreeGate
|
||||
if (worktreeFailure) throw worktreeFailure
|
||||
return { directory: worktreeDirectory }
|
||||
},
|
||||
},
|
||||
location: {
|
||||
get: async () => {
|
||||
if (locationFailure) throw locationFailure
|
||||
return { directory: worktreeDirectory }
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const rootClient = clientFor("/repo/main")
|
||||
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => (href: string) => navigations.push(href),
|
||||
useParams: () => params,
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [search, () => undefined],
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
Toast: { Region: () => null },
|
||||
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
|
||||
showToast: () => 0,
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/util/encode", () => ({
|
||||
base64Decode: (value: string) => value,
|
||||
base64Encode: (value: string) => value,
|
||||
checksum: (value: string) => value,
|
||||
sampledChecksum: (value: string) => value,
|
||||
}))
|
||||
|
||||
mock.module("@/context/local", () => ({
|
||||
useLocal: () => ({
|
||||
model: {
|
||||
current: () => ({ id: "model", provider: { id: "provider" } }),
|
||||
variant: { current: () => variant },
|
||||
},
|
||||
agent: {
|
||||
current: () => ({ name: "agent" }),
|
||||
},
|
||||
session: {
|
||||
promote: () => undefined,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/permission", () => {
|
||||
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
|
||||
})
|
||||
|
||||
mock.module("@/context/tabs", () => ({
|
||||
useTabs: () => ({
|
||||
updateDraft: (draftID: string, draft: { worktree?: string }) => {
|
||||
updatedDrafts.push({ draftID, ...draft })
|
||||
},
|
||||
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||
promotedDrafts.push({ draftID, ...session })
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/prompt", () => ({
|
||||
usePrompt: () => prompt,
|
||||
}))
|
||||
|
||||
mock.module("@/context/location", () => ({
|
||||
useWorkspaceLocation: () => {
|
||||
return () => ({
|
||||
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/server-sdk", () => ({
|
||||
useServerSDK: () => ({
|
||||
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||
api: rootClient.api,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/server", () => ({
|
||||
useServer: () => ({ key: activeServer }),
|
||||
useData: () => ({
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
setStatus: () => undefined,
|
||||
// Delegates straight to the API client; optimistic admission and
|
||||
// rollback are covered by the data-layer tests in packages/tui.
|
||||
prompt: (input: unknown) => rootClient.api.session.prompt(input as never),
|
||||
},
|
||||
location: {
|
||||
info: () => ({ project: { id: "project", directory: "/repo/main" } }),
|
||||
command: {
|
||||
list: () => commands,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/platform", () => ({
|
||||
usePlatform: () => ({
|
||||
fetch: fetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/language", () => ({
|
||||
useLanguage: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mod = await import("./submit")
|
||||
createPromptSubmit = mod.createPromptSubmit
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
createdSessions.length = 0
|
||||
promotedDrafts.length = 0
|
||||
updatedDrafts.length = 0
|
||||
sentCommands.length = 0
|
||||
sentPrompts.length = 0
|
||||
promptInputs.length = 0
|
||||
switchedAgents.length = 0
|
||||
switchedModels.length = 0
|
||||
sessionRequestOrder.length = 0
|
||||
promptCaptures.length = 0
|
||||
navigations.length = 0
|
||||
restoredPrompts = 0
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
sentShellDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
activeSDK = "server-a"
|
||||
activeServer = "server-a"
|
||||
commands = []
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
worktreeDirectory = `/repo/new-${++worktreeID}`
|
||||
createSessionGate = undefined
|
||||
serverSessionSyncs = 0
|
||||
createWorktreeGate = undefined
|
||||
worktreeFailure = undefined
|
||||
locationFailure = undefined
|
||||
promptFailure = undefined
|
||||
worktreeCreates = 0
|
||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
|
||||
createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
test("admits only one concurrent new-workspace submission", async () => {
|
||||
selected = "create"
|
||||
let release = () => {}
|
||||
createWorktreeGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const submit = makeSubmit()
|
||||
|
||||
const first = submit.handleSubmit(event)
|
||||
const duplicate = submit.handleSubmit(event)
|
||||
expect(worktreeCreates).toBe(1)
|
||||
|
||||
release()
|
||||
await Promise.all([first, duplicate])
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
await settle()
|
||||
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
expect(navigations).toEqual(["/server/server-a/session/session-1"])
|
||||
})
|
||||
|
||||
test("stops when the created workspace cannot initialize", async () => {
|
||||
selected = "create"
|
||||
locationFailure = new Error("initialization failed")
|
||||
|
||||
await makeSubmit().handleSubmit(event)
|
||||
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toEqual([])
|
||||
expect(sentPrompts).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps async submission effects bound to the initiating context", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
let release = () => {}
|
||||
createSessionGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
let submitted = 0
|
||||
const submit = makeSubmit({
|
||||
onSubmit: () => submitted++,
|
||||
})
|
||||
|
||||
const result = submit.handleSubmit(event)
|
||||
activeSDK = "server-b"
|
||||
activeServer = "server-b"
|
||||
search.draftId = "draft-2"
|
||||
release()
|
||||
await result
|
||||
await settle()
|
||||
|
||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "server-a", sessionId: "session-1" }])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "server-a", scope: ServerScope.local })
|
||||
expect(submitted).toBe(0)
|
||||
})
|
||||
|
||||
test("switches the selected agent and model before prompting", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
|
||||
const submit = makeSubmit({
|
||||
info: () => ({
|
||||
id: "session-1",
|
||||
agent: "old-agent",
|
||||
model: { id: "old-model", providerID: "old-provider" },
|
||||
}),
|
||||
})
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(sentPrompts).toEqual(["/repo/main"])
|
||||
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
|
||||
expect(switchedModels).toEqual([
|
||||
{
|
||||
sessionID: "session-1",
|
||||
model: { id: "model", providerID: "provider", variant: "high" },
|
||||
},
|
||||
])
|
||||
expect(sessionRequestOrder).toEqual(["agent", "model", "prompt"])
|
||||
expect(promptInputs[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
text: "ls",
|
||||
files: [],
|
||||
agents: [],
|
||||
metadata: {
|
||||
displayText: "ls",
|
||||
comments: [],
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
},
|
||||
})
|
||||
// ID minting is delegated to the data layer, which mints a client ID when
|
||||
// none is supplied (covered by the data-layer tests in packages/tui).
|
||||
expect((promptInputs[0] as { id?: string }).id).toBeUndefined()
|
||||
})
|
||||
|
||||
test("restores the prompt when sending fails", async () => {
|
||||
params = { id: "session-1" }
|
||||
promptFailure = new Error("connection lost")
|
||||
const submit = makeSubmit({
|
||||
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
|
||||
})
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(restoredPrompts).toBe(1)
|
||||
})
|
||||
|
||||
test("submits slash commands through the current session API", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
commands.push({ name: "review" })
|
||||
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||
|
||||
const submit = makeSubmit({
|
||||
info: () => ({ id: "session-1" }),
|
||||
})
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(sentCommands).toEqual([
|
||||
{
|
||||
sessionID: "session-1",
|
||||
id: expect.stringMatching(/^msg_/),
|
||||
command: "review",
|
||||
arguments: "staged changes",
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: "high" },
|
||||
files: [],
|
||||
},
|
||||
])
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
})
|
||||
|
||||
test("sends an initial shell after synchronous workspace creation", async () => {
|
||||
selected = "create"
|
||||
const submit = makeSubmit({
|
||||
mode: () => "shell",
|
||||
})
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(sentShellDirectories).toEqual([worktreeDirectory])
|
||||
expect(sentShell[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
command: "ls",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,505 +0,0 @@
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { startTransition, type Accessor } from "solid-js"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useData } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLocal, type ModelSelection } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
import { buildPromptRequest } from "./build-prompt-request"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { blobDataUrl } from "@/utils/draft-store"
|
||||
import { useServer } from "@/context/server"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
|
||||
const submitting = new Set<string>()
|
||||
|
||||
export type FollowupDraft = {
|
||||
sessionID: string
|
||||
sessionDirectory: string
|
||||
prompt: Prompt
|
||||
context: (ContextItem & { key: string })[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
api: ServerSDK["api"]["session"]
|
||||
data: Data
|
||||
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
draft: FollowupDraft
|
||||
optimisticBusy?: boolean
|
||||
}
|
||||
|
||||
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
|
||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
|
||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const text = draftText(input.draft.prompt)
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const setBusy = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
input.data.session.setStatus(input.draft.sessionID, "running")
|
||||
}
|
||||
|
||||
const setIdle = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
input.data.session.setStatus(input.draft.sessionID, "idle")
|
||||
}
|
||||
|
||||
const [head, ...tail] = text.split(" ")
|
||||
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||
if (
|
||||
cmd &&
|
||||
input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)
|
||||
) {
|
||||
setBusy()
|
||||
try {
|
||||
await input.api.command({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: SessionMessage.ID.create(),
|
||||
command: cmd,
|
||||
arguments: tail.join(" "),
|
||||
agent: input.draft.agent,
|
||||
model: {
|
||||
id: input.draft.model.modelID,
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
setIdle()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const encodedImages = await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
...attachment,
|
||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
})),
|
||||
)
|
||||
const request = buildPromptRequest({
|
||||
prompt: input.draft.prompt,
|
||||
context: input.draft.context,
|
||||
images: encodedImages,
|
||||
text,
|
||||
sessionDirectory: input.draft.sessionDirectory,
|
||||
})
|
||||
|
||||
setBusy()
|
||||
|
||||
try {
|
||||
const session = input.session()
|
||||
if (session?.agent !== input.draft.agent) {
|
||||
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
|
||||
}
|
||||
if (
|
||||
session?.model?.providerID !== input.draft.model.providerID ||
|
||||
session.model.id !== input.draft.model.modelID ||
|
||||
(session.model.variant ?? "default") !== (input.draft.variant ?? "default")
|
||||
) {
|
||||
await input.api.switchModel({
|
||||
sessionID: input.draft.sessionID,
|
||||
model: {
|
||||
id: input.draft.model.modelID,
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The data layer admits optimistically under a client-minted ID: the
|
||||
// prompt renders immediately and rolls back if the server rejects it.
|
||||
await input.data.session.prompt({
|
||||
sessionID: input.draft.sessionID,
|
||||
text: request.text,
|
||||
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
agents: request.agents,
|
||||
metadata: {
|
||||
displayText: request.displayText,
|
||||
comments: request.comments,
|
||||
agent: input.draft.agent,
|
||||
model: {
|
||||
...input.draft.model,
|
||||
...(input.draft.variant ? { variant: input.draft.variant } : {}),
|
||||
},
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
setIdle()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
type PromptSubmitInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
info: Accessor<
|
||||
{ id: string; agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined
|
||||
>
|
||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||
commentCount: Accessor<number>
|
||||
autoAccept: Accessor<boolean>
|
||||
mode: Accessor<"normal" | "shell">
|
||||
working: Accessor<boolean>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
promptLength: (prompt: Prompt) => number
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
resetHistoryNavigation: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
setPopover: (popover: "at" | "slash" | null) => void
|
||||
newSessionWorktree?: Accessor<string | undefined>
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
shouldQueue?: Accessor<boolean>
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
model?: ModelSelection
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = input.prompt
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
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
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
input.onAbort?.()
|
||||
|
||||
return serverSDK.api.session.interrupt({ sessionID }).catch(() => {})
|
||||
}
|
||||
|
||||
const restoreCommentItems = (
|
||||
target: ReturnType<ReturnType<typeof usePrompt>["capture"]>,
|
||||
items: (ContextItem & { key: string })[],
|
||||
) => {
|
||||
for (const item of items) {
|
||||
target.context.add({
|
||||
type: "file",
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => {
|
||||
for (const item of target.context.items()) {
|
||||
target.context.remove(item.key)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
const target = prompt.capture()
|
||||
const submission = createPromptSubmissionState({
|
||||
target,
|
||||
prompt: target.current(),
|
||||
context: target.context.items().slice(),
|
||||
})
|
||||
const currentPrompt = submission.prompt
|
||||
const context = submission.context
|
||||
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
const images = input.imageAttachments().slice()
|
||||
const mode = input.mode()
|
||||
|
||||
if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) {
|
||||
if (input.working()) void abort()
|
||||
return
|
||||
}
|
||||
const modelSelection = input.model ?? local.model
|
||||
const currentModel = modelSelection.current()
|
||||
const currentAgent = local.agent.current()
|
||||
const variant = modelSelection.variant.current()
|
||||
if (!currentModel || !currentAgent) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||
description: language.t("prompt.toast.modelAgentRequired.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const submissionSDK = sdk()
|
||||
const submissionServerSDK = serverSDK
|
||||
const submissionData = data
|
||||
const submissionScope = submissionServerSDK.scope
|
||||
const submissionServer = server.key
|
||||
const projectDirectory = submissionSDK.directory
|
||||
const sessionID = params.id
|
||||
const isNewSession = !sessionID
|
||||
const currentSession = input.info()
|
||||
const draftID = search.draftId
|
||||
const capturePrompt = prompt.capture
|
||||
const localSession = local.session
|
||||
const resetWorktree = input.onNewSessionWorktreeReset
|
||||
const onSubmit = input.onSubmit
|
||||
const permissionState = permission
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
const submissionKey = ScopedKey.from(
|
||||
submissionScope,
|
||||
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
|
||||
)
|
||||
if (submitting.has(submissionKey)) return
|
||||
submitting.add(submissionKey)
|
||||
|
||||
try {
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await submissionServerSDK.api.worktree
|
||||
.create({
|
||||
projectID: submissionData.location.info({ directory: projectDirectory })?.project.id ?? "",
|
||||
strategy: "git",
|
||||
directory: getDirectory(
|
||||
submissionData.location.info({ directory: projectDirectory })?.project.directory ?? projectDirectory,
|
||||
),
|
||||
})
|
||||
.then(async (created) => {
|
||||
await submissionServerSDK.api.location.get({ location: { directory: created.directory } })
|
||||
return created
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
})
|
||||
|
||||
if (!createdWorktree) return
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
}
|
||||
|
||||
let session = currentSession
|
||||
if (!session && isNewSession) {
|
||||
const created = await submissionServerSDK.api.session
|
||||
.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (created) {
|
||||
submissionData.session.remember(created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
|
||||
if (!draftID) resetWorktree?.()
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
localSession.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: submissionServer, sessionId: session.id })
|
||||
else navigate(sessionHref(submissionServer, session.id))
|
||||
submission.retarget(
|
||||
capturePrompt(
|
||||
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||
{ server: submissionServer, scope: submissionScope },
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
|
||||
if (!draftID || search.draftId === draftID) onSubmit?.()
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
void submissionServerSDK.api.session
|
||||
.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = submissionData.location.command
|
||||
.list({ directory: sessionDirectory })
|
||||
?.find((command) => command.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
submissionData.session.setStatus(session.id, "running")
|
||||
void submissionServerSDK.api.session
|
||||
.command({
|
||||
sessionID: session.id,
|
||||
id: SessionMessage.ID.create(),
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
})
|
||||
.catch((err) => {
|
||||
submissionData.session.setStatus(session.id, "idle")
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
void sendFollowupDraft({
|
||||
api: submissionServerSDK.api.session,
|
||||
data: submissionData,
|
||||
session: () => session,
|
||||
draft,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
}).catch((err) => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionData.session.setStatus(session.id, "idle")
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
} finally {
|
||||
submitting.delete(submissionKey)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
abort,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { type Component, type JSX } from "solid-js"
|
||||
|
||||
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
|
||||
return <div class="bg-surface-base px-4 rounded-lg">{props.children}</div>
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { DialogSettings } from "./dialog-settings-v2"
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import "../settings-v2.css"
|
||||
|
||||
export const SettingsListV2: Component<{ children: JSX.Element }> = (props) => {
|
||||
return <div data-component="settings-v2-list">{props.children}</div>
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import "../settings-v2.css"
|
||||
|
||||
export interface SettingsRowV2Props {
|
||||
title: string | JSX.Element
|
||||
description: string | JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export const SettingsRowV2: Component<SettingsRowV2Props> = (props) => {
|
||||
return (
|
||||
<div data-component="settings-v2-row">
|
||||
<div data-slot="settings-v2-row-copy">
|
||||
<div data-slot="settings-v2-row-title">{props.title}</div>
|
||||
<div data-slot="settings-v2-row-description">{props.description}</div>
|
||||
</div>
|
||||
<div data-slot="settings-v2-row-control">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Taken from https://www.solid-ui.com/docs/components/drawer
|
||||
* Only used in one place hence not a v2 component yet... can be promoted to ui/v2 later
|
||||
*/
|
||||
|
||||
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ContentProps, DescriptionProps, DynamicProps, LabelProps, OverlayProps } from "@corvu/drawer"
|
||||
import DrawerPrimitive from "@corvu/drawer"
|
||||
|
||||
const Drawer = DrawerPrimitive
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
type DrawerOverlayProps<T extends ValidComponent = "div"> = OverlayProps<T> & { class?: string }
|
||||
|
||||
const DrawerOverlay = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerOverlayProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerOverlayProps, ["class"])
|
||||
const drawerContext = DrawerPrimitive.useContext()
|
||||
const overlayStyle = () => {
|
||||
const state = drawerContext.transitionState()
|
||||
if (state === "opening" || state === "closing") return undefined
|
||||
const open = drawerContext.openPercentage()
|
||||
return {
|
||||
opacity: open,
|
||||
"backdrop-filter": `blur(${4 * open}px)`,
|
||||
}
|
||||
}
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
class={props.class}
|
||||
classList={{
|
||||
"fixed inset-0 z-[100] bg-v2-overlay-simple-overlay-scrim opacity-0 backdrop-blur-none transition-[opacity,backdrop-filter] duration-300 data-[opening]:opacity-100 data-[opening]:backdrop-blur-[4px] data-[closing]:opacity-0 data-[closing]:backdrop-blur-none": true,
|
||||
}}
|
||||
style={overlayStyle()}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DrawerContentProps<T extends ValidComponent = "div"> = ContentProps<T> & {
|
||||
class?: string
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerContentProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerContentProps, ["class", "children"])
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
class={props.class}
|
||||
classList={{
|
||||
"group/drawer-content fixed inset-y-[6px] end-[6px] start-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{props.children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
const DrawerHeader: Component<ComponentProps<"div">> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return <div class={props.class} classList={{ "grid gap-1.5 p-4 text-center sm:text-left": true }} {...rest} />
|
||||
}
|
||||
|
||||
const DrawerFooter: Component<ComponentProps<"div">> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return <div class={props.class} classList={{ "mt-auto flex flex-col gap-2 p-4": true }} {...rest} />
|
||||
}
|
||||
|
||||
type DrawerTitleProps<T extends ValidComponent = "div"> = LabelProps<T> & { class?: string }
|
||||
|
||||
const DrawerTitle = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerTitleProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerTitleProps, ["class"])
|
||||
return (
|
||||
<DrawerPrimitive.Label
|
||||
class={props.class}
|
||||
classList={{ "text-base font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base": true }}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DrawerDescriptionProps<T extends ValidComponent = "div"> = DescriptionProps<T> & {
|
||||
class?: string
|
||||
}
|
||||
|
||||
const DrawerDescription = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerDescriptionProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerDescriptionProps, ["class"])
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
class={props.class}
|
||||
classList={{
|
||||
"text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-v2-text-text-muted": true,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
import type { ComposerStateTarget } from "./submission-state"
|
||||
import type { createComposerSubmission } from "./submission-state"
|
||||
|
||||
export type ComposerControls = {
|
||||
agents: {
|
||||
available: { name: string; hidden?: boolean; mode: string }[]
|
||||
options: string[]
|
||||
current: string
|
||||
visible: boolean
|
||||
select: (name: string | undefined) => void
|
||||
}
|
||||
model: {
|
||||
selection: ModelSelection
|
||||
paid: boolean
|
||||
loading: boolean
|
||||
}
|
||||
session: {
|
||||
tabs: {
|
||||
active: () => string | undefined
|
||||
all: () => string[]
|
||||
open: (tab: string) => void | Promise<void>
|
||||
setActive: (tab: string) => void
|
||||
}
|
||||
reviewPanel: {
|
||||
opened: () => boolean
|
||||
open: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ComposerSelection = {
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
|
||||
export type ComposerSession = {
|
||||
id: string
|
||||
directory: string
|
||||
api: {
|
||||
command: (input: Parameters<ServerSDK["api"]["session"]["command"]>[0]) => Promise<unknown>
|
||||
shell: (input: Parameters<ServerSDK["api"]["session"]["shell"]>[0]) => Promise<unknown>
|
||||
switchAgent: (input: Parameters<ServerSDK["api"]["session"]["switchAgent"]>[0]) => Promise<unknown>
|
||||
switchModel: (input: Parameters<ServerSDK["api"]["session"]["switchModel"]>[0]) => Promise<unknown>
|
||||
}
|
||||
data: {
|
||||
location: { command: Pick<Data["location"]["command"], "list"> }
|
||||
session: {
|
||||
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
admitted: (messageID: string) => boolean
|
||||
}
|
||||
|
||||
type ComposerAdapterBase = {
|
||||
state: ComposerStateTarget
|
||||
ready: Accessor<boolean>
|
||||
controls: Accessor<ComposerControls>
|
||||
working: Accessor<boolean>
|
||||
submitted: () => void
|
||||
}
|
||||
|
||||
export type ActiveComposerAdapter = ComposerAdapterBase & {
|
||||
kind: "active-session"
|
||||
session: () => ComposerSession
|
||||
interrupt: () => Promise<void>
|
||||
setEditor: (element: HTMLDivElement) => void
|
||||
}
|
||||
|
||||
export type NewSessionComposerAdapter = ComposerAdapterBase & {
|
||||
kind: "new-session"
|
||||
start: (
|
||||
selection: ComposerSelection,
|
||||
submission: ReturnType<typeof createComposerSubmission>,
|
||||
) => Promise<ComposerSession | undefined>
|
||||
}
|
||||
|
||||
export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter
|
||||
@@ -0,0 +1,43 @@
|
||||
@keyframes composer-attachments-fade-left {
|
||||
from {
|
||||
visibility: hidden;
|
||||
}
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes composer-attachments-fade-right {
|
||||
from {
|
||||
visibility: visible;
|
||||
}
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="composer-attachments"] {
|
||||
timeline-scope: --composer-attachments-scroll;
|
||||
|
||||
[data-slot^="composer-attachments-fade-"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: --composer-attachments-scroll) and (timeline-scope: --composer-attachments-scroll) {
|
||||
[data-component="composer-attachments"] [data-slot="composer-attachments-scroll"] {
|
||||
scroll-timeline: --composer-attachments-scroll x;
|
||||
}
|
||||
|
||||
[data-component="composer-attachments"] [data-slot="composer-attachments-fade-left"] {
|
||||
animation: composer-attachments-fade-left linear both;
|
||||
animation-timeline: --composer-attachments-scroll;
|
||||
animation-range: 0 0.1px;
|
||||
}
|
||||
|
||||
[data-component="composer-attachments"] [data-slot="composer-attachments-fade-right"] {
|
||||
animation: composer-attachments-fade-right linear both;
|
||||
animation-timeline: --composer-attachments-scroll;
|
||||
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -1,6 +1,6 @@
|
||||
import { onMount } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types"
|
||||
import type { ComposerAttachment, ComposerPrompt } from "../types"
|
||||
|
||||
const accepted = [
|
||||
"image/png",
|
||||
@@ -61,12 +61,12 @@ const accepted = [
|
||||
]
|
||||
|
||||
type PromptTarget = {
|
||||
current: () => PromptInputV2Prompt
|
||||
current: () => ComposerPrompt
|
||||
cursor: () => number | undefined
|
||||
set: (prompt: PromptInputV2Prompt, cursor?: number) => void
|
||||
set: (prompt: ComposerPrompt, cursor?: number) => void
|
||||
}
|
||||
|
||||
export type PromptInputV2AttachmentConfig = {
|
||||
export type ComposerAttachmentConfig = {
|
||||
picker?: (
|
||||
options: { defaultPath?: string; multiple?: boolean; accept?: string[] },
|
||||
onFile: (file: File) => Promise<unknown>,
|
||||
@@ -81,12 +81,12 @@ export type PromptInputV2AttachmentConfig = {
|
||||
store?: (file: File) => Promise<{ id: string; url: string }>
|
||||
}
|
||||
|
||||
export function createPromptInputV2Attachments(
|
||||
input: PromptInputV2AttachmentConfig & {
|
||||
export function createComposerAttachments(
|
||||
input: ComposerAttachmentConfig & {
|
||||
capture: () => PromptTarget
|
||||
editor: () => HTMLElement | undefined
|
||||
focusEditor: () => void
|
||||
addPart: (part: PromptInputV2Prompt[number]) => boolean
|
||||
addPart: (part: ComposerPrompt[number]) => boolean
|
||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||
},
|
||||
) {
|
||||
@@ -121,9 +121,9 @@ export function createPromptInputV2Attachments(
|
||||
input.duplicate()
|
||||
return true
|
||||
}
|
||||
const attachment: PromptInputV2Attachment = {
|
||||
const attachment: ComposerAttachment = {
|
||||
type: "image",
|
||||
id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2),
|
||||
id: crypto.randomUUID(),
|
||||
filename: file.name,
|
||||
sourcePath,
|
||||
mime,
|
||||
+7
-7
@@ -1,8 +1,8 @@
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLocal, type ModelSelection } from "@/context/local"
|
||||
import { useCommand, type CommandOption } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLocal, type ModelSelection } from "@/providers/models/selection"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||
import { getCursorPosition, setCursorPosition } from "./editor/dom"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createSessionOwnership } from "@/session/session-ownership"
|
||||
|
||||
@@ -26,7 +26,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
|
||||
const chooseModel = async () => {
|
||||
const owner = sessionOwnership.capture()
|
||||
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
|
||||
const editor = document.querySelector<HTMLElement>('[data-component="composer-editor"]')
|
||||
const selection = window.getSelection()
|
||||
const cursor =
|
||||
editor && selection?.rangeCount && editor.contains(selection.anchorNode) ? getCursorPosition(editor) : null
|
||||
@@ -34,13 +34,13 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
// Kobalte restores focus during its teardown effect; defer past it so the
|
||||
// composer keeps focus and the caret returns to where the user left it.
|
||||
requestAnimationFrame(() => {
|
||||
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
|
||||
const editor = document.querySelector<HTMLElement>('[data-component="composer-editor"]')
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
if (cursor !== null) setCursorPosition(editor, cursor)
|
||||
})
|
||||
}
|
||||
const { DialogSelectModel } = await import("@/components/dialog-select-model")
|
||||
const { DialogSelectModel } = await import("@/providers/models/select-dialog")
|
||||
owner.run(() => {
|
||||
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
|
||||
export type PromptComment = {
|
||||
path: string
|
||||
@@ -3,13 +3,13 @@ import { createStore, reconcile, type SetStoreFunction, type Store } from "solid
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createScopedCache } from "@/utils/scoped-cache"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
import { useWorkspaceLocation } from "./location"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { createScopedCache } from "@/runtime/server/scoped-cache"
|
||||
import { uuid } from "@/runtime/persistence/uuid"
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
|
||||
export type LineComment = {
|
||||
id: string
|
||||
@@ -0,0 +1,474 @@
|
||||
import { Show, createMemo, onMount, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import { STORY_MODEL, emptySessionDocument, pendingAndQueuedDocument } from "@opencode-ai/session-ui/storybook"
|
||||
import { Composer } from "./composer"
|
||||
import type { ComposerModel } from "./model"
|
||||
import { createComposerEditor } from "./editor/interaction"
|
||||
import type { ComposerPersistedState, ComposerSuggestion } from "./types"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { SessionPreview } from "@/session/story-model"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { resolveSessionComposerSelection } from "@/session/composer/selection"
|
||||
|
||||
const selectedModel = {
|
||||
id: STORY_MODEL.id,
|
||||
providerID: STORY_MODEL.providerID,
|
||||
api: { id: STORY_MODEL.id, url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" },
|
||||
name: "Claude Sonnet 4",
|
||||
family: "claude-sonnet",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: true, video: false, pdf: true },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: true,
|
||||
},
|
||||
cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
|
||||
limit: { context: 200_000, output: 64_000 },
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2025-05-22",
|
||||
variants: { balanced: {}, high: {} },
|
||||
provider: {
|
||||
id: STORY_MODEL.providerID,
|
||||
name: "Anthropic",
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {},
|
||||
},
|
||||
latest: true,
|
||||
} satisfies NonNullable<ReturnType<ModelSelection["current"]>>
|
||||
|
||||
function ComposerStory(props: {
|
||||
prompt?: ComposerPersistedState["prompt"]
|
||||
comments?: ComposerPersistedState["context"]["items"]
|
||||
working?: boolean
|
||||
stopping?: boolean
|
||||
suggestions?: "command" | "context"
|
||||
failure?: boolean
|
||||
label?: string
|
||||
inspectRequest?: boolean
|
||||
continueOnStop?: boolean
|
||||
}) {
|
||||
const [draft, setDraft] = createStore<ComposerPersistedState>({
|
||||
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
|
||||
cursor: props.prompt?.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) ?? 0,
|
||||
model: { providerID: STORY_MODEL.providerID, modelID: STORY_MODEL.id, variant: STORY_MODEL.variant },
|
||||
context: { items: props.comments ?? [] },
|
||||
})
|
||||
const [story, setStory] = createStore({
|
||||
activity: props.label ?? "Ready",
|
||||
variant: STORY_MODEL.variant,
|
||||
})
|
||||
const modelSelection = {
|
||||
ready: Object.assign(() => true, { promise: undefined }),
|
||||
current: () => selectedModel,
|
||||
recent: () => [selectedModel],
|
||||
list: () => [selectedModel],
|
||||
cycle() {},
|
||||
set() {},
|
||||
visible: () => true,
|
||||
setVisibility() {},
|
||||
variant: {
|
||||
configured: () => STORY_MODEL.variant,
|
||||
selected: () => story.variant,
|
||||
current: () => story.variant,
|
||||
list: () => ["balanced", "high"],
|
||||
set: (variant: string | undefined) => setStory("variant", variant ?? "balanced"),
|
||||
cycle() {},
|
||||
},
|
||||
} satisfies ModelSelection
|
||||
const commands: ComposerSuggestion[] = [
|
||||
{ id: "command.test", kind: "command", label: "/test", trigger: "test", title: "Run tests" },
|
||||
{ id: "command.review", kind: "command", label: "/review", trigger: "review", title: "Review changes" },
|
||||
]
|
||||
const context: ComposerSuggestion[] = [
|
||||
{
|
||||
id: "file:src/app.tsx",
|
||||
kind: "file",
|
||||
label: "src/app.tsx",
|
||||
path: "src/app.tsx",
|
||||
mention: { type: "file", path: "src/app.tsx", content: "@src/app.tsx", start: 0, end: 0 },
|
||||
},
|
||||
{
|
||||
id: "agent:review",
|
||||
kind: "agent",
|
||||
label: "@review",
|
||||
mention: { type: "agent", name: "review", content: "@review", start: 0, end: 0 },
|
||||
},
|
||||
{
|
||||
id: "skill:effect",
|
||||
kind: "skill",
|
||||
label: "@effect",
|
||||
description: "Build Effect applications",
|
||||
mention: {
|
||||
type: "skill",
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
content: "@effect",
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
},
|
||||
]
|
||||
const editor = createComposerEditor({
|
||||
store: [draft, setDraft],
|
||||
commands: () => commands,
|
||||
context: () => context,
|
||||
searchContextFiles: () => [],
|
||||
view: {
|
||||
placeholder: () => "Ask anything, / for commands, @ for context...",
|
||||
agent: {
|
||||
options: () => [
|
||||
{ id: "build", label: "build" },
|
||||
{ id: "review", label: "review" },
|
||||
],
|
||||
current: () => "build",
|
||||
onSelect: (agent) => setStory("activity", `Selected ${agent}`),
|
||||
},
|
||||
variant: {
|
||||
options: () => [
|
||||
{ id: "balanced", label: "balanced" },
|
||||
{ id: "high", label: "high" },
|
||||
],
|
||||
current: () => story.variant,
|
||||
onSelect: (variant) => setStory("variant", variant),
|
||||
},
|
||||
submit: {
|
||||
stopping: () => !!props.stopping,
|
||||
working: () => !!props.working,
|
||||
onSubmit: () => {
|
||||
const value = draft.prompt.map((part) => ("content" in part ? part.content : `[${part.filename}]`)).join("")
|
||||
const request = props.inspectRequest
|
||||
? buildPromptRequest({
|
||||
prompt: draft.prompt,
|
||||
context: draft.context.items,
|
||||
images: [],
|
||||
text: value,
|
||||
sessionDirectory: "C:/repo",
|
||||
})
|
||||
: undefined
|
||||
setDraft("prompt", [{ type: "text", content: "", start: 0, end: 0 }])
|
||||
setDraft("cursor", 0)
|
||||
if (props.failure) {
|
||||
setDraft("prompt", props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }])
|
||||
setStory("activity", "Submission failed; draft restored")
|
||||
return
|
||||
}
|
||||
setStory(
|
||||
"activity",
|
||||
request
|
||||
? JSON.stringify({ files: request.files, agents: request.agents, skills: request.skills })
|
||||
: `Submitted: ${value}`,
|
||||
)
|
||||
},
|
||||
onStop: () =>
|
||||
setStory("activity", props.continueOnStop ? "POST /interrupt · continue: true" : "Stop requested"),
|
||||
},
|
||||
},
|
||||
})
|
||||
const model = {
|
||||
...editor,
|
||||
model: { selection: modelSelection, paid: true, loading: false },
|
||||
} satisfies ComposerModel
|
||||
|
||||
onMount(() => {
|
||||
if (props.suggestions === "command") model.openCommands()
|
||||
if (props.suggestions === "context") model.openContext()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="mx-auto flex min-h-80 w-full max-w-200 flex-col justify-end gap-3 rounded-xl bg-v2-background-bg-deep p-6">
|
||||
<output class="text-12-regular text-text-weak" aria-live="polite">
|
||||
{story.activity}
|
||||
</output>
|
||||
<Composer model={model} borderUnderlay />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const text = (content: string): ComposerPersistedState["prompt"] => [
|
||||
{ type: "text", content, start: 0, end: content.length },
|
||||
]
|
||||
|
||||
export default {
|
||||
title: "OpenCode/Composer/Flow",
|
||||
component: Composer,
|
||||
parameters: { layout: "centered" },
|
||||
}
|
||||
|
||||
export const EmptyDraft = { render: () => <ComposerStory /> }
|
||||
|
||||
export const TextDraft = { render: () => <ComposerStory prompt={text("Explain this change")} /> }
|
||||
|
||||
export const MultilineDraft = {
|
||||
render: () => <ComposerStory prompt={text("Review the implementation\nThen run the focused tests")} />,
|
||||
}
|
||||
|
||||
export const MixedAttachments = {
|
||||
render: () => (
|
||||
<ComposerStory
|
||||
prompt={[
|
||||
{ type: "text", content: "Review ", start: 0, end: 7 },
|
||||
{ type: "file", path: "src/app.tsx", content: "@src/app.tsx", start: 7, end: 19 },
|
||||
{ type: "text", content: " with ", start: 19, end: 25 },
|
||||
{ type: "agent", name: "review", content: "@review", start: 25, end: 32 },
|
||||
{ type: "text", content: " and ", start: 32, end: 37 },
|
||||
{
|
||||
type: "skill",
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
content: "@effect",
|
||||
start: 37,
|
||||
end: 44,
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
id: "image-story",
|
||||
filename: "layout.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "image-story", url: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" },
|
||||
},
|
||||
]}
|
||||
comments={[
|
||||
{
|
||||
type: "file",
|
||||
key: "comment:src/app.tsx",
|
||||
path: "src/app.tsx",
|
||||
selection: { startLine: 12, startChar: 0, endLine: 14, endChar: 0 },
|
||||
comment: "Keep the normal flow flat",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const ModelAndVariant = { render: () => <ComposerStory prompt={text("Compare both variants")} /> }
|
||||
|
||||
export const SlashSuggestions = { render: () => <ComposerStory suggestions="command" /> }
|
||||
|
||||
export const ContextSuggestions = { render: () => <ComposerStory suggestions="context" /> }
|
||||
|
||||
export const RunningAndStopping = { render: () => <ComposerStory working stopping label="Session is running" /> }
|
||||
|
||||
export const SteeringFollowUp = {
|
||||
render: () => <ComposerStory prompt={text("Use this correction at the next boundary")} working />,
|
||||
}
|
||||
|
||||
export const FailedSubmissionRestoration = {
|
||||
render: () => <ComposerStory prompt={text("Preserve this draft on failure")} failure />,
|
||||
}
|
||||
|
||||
export const NewSessionFirstPrompt = {
|
||||
render: () => (
|
||||
<ComposerStory prompt={text("Create the Session and implement the change")} label="New Session draft" />
|
||||
),
|
||||
}
|
||||
|
||||
export const ActiveSessionFollowUp = {
|
||||
render: () => <ComposerStory prompt={text("Now add focused coverage")} label="Active Session follow-up" />,
|
||||
}
|
||||
|
||||
export const RightToLeft = {
|
||||
globals: { direction: "rtl" },
|
||||
render: () => <ComposerStory prompt={text("راجع src/app.tsx ثم شغّل bun test")} />,
|
||||
}
|
||||
|
||||
export const NarrowLayout = {
|
||||
parameters: { viewport: { defaultViewport: "mobile1" } },
|
||||
render: () => (
|
||||
<div class="w-[340px]">
|
||||
<ComposerStory prompt={text("Verify the narrow Composer")} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DemoFirstClassSkillIDs = {
|
||||
name: "Demo: First-class skill IDs",
|
||||
render: () => (
|
||||
<DemoFrame
|
||||
title="First-class skill IDs"
|
||||
description="Choose @effect, then Send. The output shows the durable skill ID sent to the prompt API."
|
||||
>
|
||||
<ComposerStory suggestions="context" inspectRequest label="Select a skill from the context menu" />
|
||||
</DemoFrame>
|
||||
),
|
||||
}
|
||||
|
||||
export const DemoStructuredCustomCommand = {
|
||||
name: "Demo: Structured custom command",
|
||||
render: () => (
|
||||
<DemoFrame
|
||||
title="Structured custom-command input"
|
||||
description="Send the draft. Files, agents, and skills remain structured instead of becoming plain command text."
|
||||
>
|
||||
<ComposerStory
|
||||
inspectRequest
|
||||
prompt={[
|
||||
{ type: "text", content: "/review ", start: 0, end: 8 },
|
||||
{ type: "file", path: "src/app.tsx", content: "@src/app.tsx", start: 8, end: 20 },
|
||||
{ type: "text", content: " ", start: 20, end: 21 },
|
||||
{ type: "agent", name: "review", content: "@review", start: 21, end: 28 },
|
||||
{ type: "text", content: " ", start: 28, end: 29 },
|
||||
{
|
||||
type: "skill",
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
content: "@effect",
|
||||
start: 29,
|
||||
end: 36,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DemoFrame>
|
||||
),
|
||||
}
|
||||
|
||||
export const DemoPendingInboxHydration = {
|
||||
name: "Demo: Pending inbox hydration",
|
||||
render: () => <PendingInboxDemo />,
|
||||
}
|
||||
|
||||
export const DemoServerOwnedExecutionStatus = {
|
||||
name: "Demo: Server-owned execution status",
|
||||
render: () => <ServerStatusDemo />,
|
||||
}
|
||||
|
||||
export const DemoDurableSelectionPrecedence = {
|
||||
name: "Demo: Durable selection precedence",
|
||||
render: () => <SelectionPrecedenceDemo />,
|
||||
}
|
||||
|
||||
export const DemoContinueOnStop = {
|
||||
name: "Demo: Continue on Stop",
|
||||
render: () => (
|
||||
<DemoFrame
|
||||
title="Continue admitted work after Stop"
|
||||
description="Press Stop. The output shows the interrupt request used by the active Session adapter."
|
||||
>
|
||||
<ComposerStory working stopping continueOnStop label="Session execution is running" />
|
||||
</DemoFrame>
|
||||
),
|
||||
}
|
||||
|
||||
function DemoFrame(props: { title: string; description: string; children: JSX.Element }) {
|
||||
return (
|
||||
<section class="flex w-[min(920px,calc(100vw-32px))] flex-col gap-3 rounded-xl bg-v2-background-bg-deep p-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-16-medium text-text-strong">{props.title}</h2>
|
||||
<p class="text-13-regular text-text-weak">{props.description}</p>
|
||||
</div>
|
||||
{props.children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingInboxDemo() {
|
||||
const [store, setStore] = createStore({ hydrated: false })
|
||||
return (
|
||||
<DemoFrame
|
||||
title="Active pending-inbox hydration"
|
||||
description="Toggle hydration to simulate the active Session loading durable pending inbox rows with its messages."
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="self-start rounded-md bg-background-base px-3 py-2 text-13-medium text-text-strong"
|
||||
onClick={() => setStore("hydrated", (value) => !value)}
|
||||
>
|
||||
{store.hydrated ? "Clear pending data" : "Hydrate pending data"}
|
||||
</button>
|
||||
<Show
|
||||
when={store.hydrated}
|
||||
fallback={<SessionPreview title="Pending inbox" description="Not hydrated" document={emptySessionDocument} />}
|
||||
>
|
||||
<SessionPreview
|
||||
title="Pending inbox"
|
||||
description="Hydrated from Client Data"
|
||||
document={pendingAndQueuedDocument}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</DemoFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerStatusDemo() {
|
||||
const [store, setStore] = createStore({ running: false, activity: "Idle from server projection" })
|
||||
const document = createMemo(() => ({
|
||||
...emptySessionDocument,
|
||||
status: store.running ? ({ type: "busy" } as const) : ({ type: "idle" } as const),
|
||||
}))
|
||||
return (
|
||||
<DemoFrame
|
||||
title="Server-owned execution status"
|
||||
description="Submitting does not force running or idle. Only the simulated execution event changes status."
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-background-base px-3 py-2 text-13-medium text-text-strong"
|
||||
onClick={() => setStore("activity", "Prompt admitted; status unchanged")}
|
||||
>
|
||||
Admit prompt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-background-base px-3 py-2 text-13-medium text-text-strong"
|
||||
onClick={() => {
|
||||
setStore("running", (value) => !value)
|
||||
setStore("activity", store.running ? "execution.started" : "execution.succeeded")
|
||||
}}
|
||||
>
|
||||
Toggle execution event
|
||||
</button>
|
||||
</div>
|
||||
<output class="text-12-regular text-text-weak">{store.activity}</output>
|
||||
<SessionPreview title="Execution status" description={store.activity} document={document()} />
|
||||
</div>
|
||||
</DemoFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionPrecedenceDemo() {
|
||||
const [store, setStore] = createStore({ durable: true })
|
||||
const selection = createMemo(() =>
|
||||
resolveSessionComposerSelection(
|
||||
store.durable ? { agent: "build", model: { id: "claude-sonnet-4", providerID: "anthropic" } } : undefined,
|
||||
{ agent: "review", model: { modelID: "gpt-5", providerID: "openai" } },
|
||||
),
|
||||
)
|
||||
return (
|
||||
<DemoFrame
|
||||
title="Durable Session selection precedence"
|
||||
description="The current Session model wins over historical message metadata. Clear it to see the history fallback."
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-13-regular">
|
||||
<span class="text-text-weak">SessionInfo.model</span>
|
||||
<strong class="text-text-strong">{store.durable ? "anthropic/claude-sonnet-4" : "Unavailable"}</strong>
|
||||
<span class="text-text-weak">Last message metadata</span>
|
||||
<strong class="text-text-strong">openai/gpt-5</strong>
|
||||
<span class="text-text-weak">Resolved selection</span>
|
||||
<strong class="text-text-strong">
|
||||
{selection().model ? `${selection().model?.providerID}/${selection().model?.modelID}` : "Unavailable"}
|
||||
</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="self-start rounded-md bg-background-base px-3 py-2 text-13-medium text-text-strong"
|
||||
onClick={() => setStore("durable", (value) => !value)}
|
||||
>
|
||||
{store.durable ? "Remove durable Session state" : "Restore durable Session state"}
|
||||
</button>
|
||||
<ComposerStory prompt={text("Continue with the resolved Session selection")} label="Composer is ready" />
|
||||
</div>
|
||||
</DemoFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ComposerEditor } from "./editor/editor"
|
||||
import { ModelSelectorPopover } from "@/providers/models/select-dialog"
|
||||
import { DialogSelectModelUnpaid } from "@/providers/models/unpaid"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { ComposerModel } from "./model"
|
||||
|
||||
export function Composer(props: {
|
||||
class?: string
|
||||
model: ComposerModel
|
||||
borderUnderlay?: boolean
|
||||
accentSubmit?: boolean
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
<ComposerEditor
|
||||
controller={props.model}
|
||||
accentSubmit={props.accentSubmit}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
variantControlVisible={!props.model.model.loading}
|
||||
attachKeybind={command.keybindParts("file.attach")}
|
||||
attachShortcut={command.keybind("file.attach")}
|
||||
modelControl={
|
||||
<ComposerModelControl
|
||||
loading={props.model.model.loading}
|
||||
paid={props.model.model.paid}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybindParts("model.choose")}
|
||||
model={props.model.model.selection}
|
||||
providerID={props.model.model.selection.current()?.provider?.id}
|
||||
modelName={props.model.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
onClose={props.model.restoreFocus}
|
||||
onUnpaidClick={() => dialog.show(() => <DialogSelectModelUnpaid model={props.model.model.selection} />)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerModelControl(props: {
|
||||
loading: boolean
|
||||
paid: boolean
|
||||
title: string
|
||||
keybind: string[]
|
||||
model: ComposerModel["model"]["selection"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
onClose: () => void
|
||||
onUnpaidClick: () => void
|
||||
}) {
|
||||
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
|
||||
const content = () => (
|
||||
<>
|
||||
<Show when={props.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate leading-4">{props.modelName}</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
<Icon name="chevron-down" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<Show when={!props.loading}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.title}
|
||||
<Keybind keys={props.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.paid}
|
||||
fallback={
|
||||
<Button
|
||||
data-action="composer-model"
|
||||
data-control-type="dialog"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||
style={{ height: "28px" }}
|
||||
onClick={props.onUnpaidClick}
|
||||
>
|
||||
{content()}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={props.model}
|
||||
trigger={(triggerProps) => (
|
||||
<Button
|
||||
{...triggerProps}
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
style={{ height: "28px" }}
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||
data-action="composer-model"
|
||||
data-control-type="popover"
|
||||
>
|
||||
{content()}
|
||||
</Button>
|
||||
)}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
</Show>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
+15
-31
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PromptInputV2PersistedState } from "./types"
|
||||
import { createPromptInputV2Store } from "./store"
|
||||
import type { ComposerPersistedState } from "../types"
|
||||
import { createComposerEditorActions } from "./actions"
|
||||
|
||||
const context = { key: "file:src/index.ts", type: "file" as const, path: "src/index.ts" }
|
||||
|
||||
function createPromptStore() {
|
||||
return createPromptInputV2Store(
|
||||
createStore<PromptInputV2PersistedState>({
|
||||
return createComposerEditorActions(
|
||||
createStore<ComposerPersistedState>({
|
||||
prompt: [
|
||||
{ type: "text", content: "old", start: 0, end: 3 },
|
||||
{
|
||||
@@ -18,19 +20,19 @@ function createPromptStore() {
|
||||
],
|
||||
cursor: 3,
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null },
|
||||
context: { items: [] },
|
||||
context: { items: [context] },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("prompt input v2 store", () => {
|
||||
describe("Composer store", () => {
|
||||
test("accepts an accessor for the backing store", () => {
|
||||
const [state, setState] = createStore<PromptInputV2PersistedState>({
|
||||
const [state, setState] = createStore<ComposerPersistedState>({
|
||||
prompt: [{ type: "text", content: "", start: 0, end: 0 }],
|
||||
cursor: 0,
|
||||
context: { items: [] },
|
||||
})
|
||||
const prompt = createPromptInputV2Store([() => state, setState])
|
||||
const prompt = createComposerEditorActions([() => state, setState])
|
||||
|
||||
prompt.setText("accessed")
|
||||
|
||||
@@ -57,7 +59,7 @@ describe("prompt input v2 store", () => {
|
||||
})
|
||||
|
||||
test("inserts text without flattening structured mentions", () => {
|
||||
const [state, setState] = createStore<PromptInputV2PersistedState>({
|
||||
const [state, setState] = createStore<ComposerPersistedState>({
|
||||
prompt: [
|
||||
{ type: "text", content: "A ", start: 0, end: 2 },
|
||||
{ type: "file", path: "one", content: "@one", start: 2, end: 6 },
|
||||
@@ -66,7 +68,7 @@ describe("prompt input v2 store", () => {
|
||||
cursor: 2,
|
||||
context: { items: [] },
|
||||
})
|
||||
const prompt = createPromptInputV2Store([state, setState])
|
||||
const prompt = createComposerEditorActions([state, setState])
|
||||
|
||||
prompt.addText("X\nY")
|
||||
|
||||
@@ -78,39 +80,21 @@ describe("prompt input v2 store", () => {
|
||||
expect(prompt.state.cursor).toBe(5)
|
||||
})
|
||||
|
||||
test("mutates context, attachments, and model through shared actions", () => {
|
||||
test("mutates mentions, attachments, and context through editor actions", () => {
|
||||
const prompt = createPromptStore()
|
||||
const context = { key: "file:src/index.ts", type: "file" as const, path: "src/index.ts" }
|
||||
|
||||
prompt.addContext(context)
|
||||
prompt.addContext(context)
|
||||
prompt.addMention({ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 0 })
|
||||
prompt.removeAttachment("attachment-1")
|
||||
prompt.setVariant("thinking")
|
||||
prompt.removeContext(context.key)
|
||||
|
||||
expect(prompt.state.context.items).toEqual([context])
|
||||
expect(prompt.state.context.items).toEqual([])
|
||||
expect(prompt.state.prompt).toEqual([
|
||||
{ type: "text", content: "old", start: 0, end: 3 },
|
||||
{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 3, end: 14 },
|
||||
{ type: "text", content: " ", start: 14, end: 15 },
|
||||
])
|
||||
expect(prompt.state.model?.variant).toBe("thinking")
|
||||
|
||||
prompt.removeContext(context.key)
|
||||
prompt.setPrompt([{ type: "text", content: "old", start: 0, end: 3 }], 3)
|
||||
prompt.setModel(undefined)
|
||||
|
||||
expect(prompt.state.context.items).toEqual([])
|
||||
expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
|
||||
expect(prompt.state.model).toBeUndefined()
|
||||
})
|
||||
|
||||
test("resets the prompt and cursor", () => {
|
||||
const prompt = createPromptStore()
|
||||
|
||||
prompt.reset()
|
||||
|
||||
expect(prompt.state.prompt).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(prompt.state.cursor).toBe(0)
|
||||
})
|
||||
})
|
||||
+33
-45
@@ -1,50 +1,55 @@
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
import type {
|
||||
PromptInputV2AgentPart,
|
||||
PromptInputV2Attachment,
|
||||
PromptInputV2Comment,
|
||||
PromptInputV2FilePart,
|
||||
PromptInputV2Model,
|
||||
PromptInputV2PersistedState,
|
||||
PromptInputV2Prompt,
|
||||
} from "./types"
|
||||
ComposerAgentPart,
|
||||
ComposerFilePart,
|
||||
ComposerSkillPart,
|
||||
ComposerPersistedState,
|
||||
ComposerPrompt,
|
||||
} from "../types"
|
||||
|
||||
export type PromptInputV2StoreTuple = [
|
||||
Store<PromptInputV2PersistedState> | Accessor<Store<PromptInputV2PersistedState>>,
|
||||
SetStoreFunction<PromptInputV2PersistedState>,
|
||||
export type ComposerStateStore = [
|
||||
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
|
||||
SetStoreFunction<ComposerPersistedState>,
|
||||
]
|
||||
|
||||
export type PromptInputV2StoreInput = PromptInputV2StoreTuple | Accessor<PromptInputV2StoreTuple>
|
||||
export type ComposerStateStoreInput = ComposerStateStore | Accessor<ComposerStateStore>
|
||||
|
||||
export function createPromptInputV2Store(input: PromptInputV2StoreInput) {
|
||||
export function createComposerEditorActions(input: ComposerStateStoreInput) {
|
||||
const tuple = () => (typeof input === "function" ? input() : input)
|
||||
const store = () => {
|
||||
const value = tuple()[0]
|
||||
return typeof value === "function" ? value() : value
|
||||
}
|
||||
const setStore = () => tuple()[1]
|
||||
const clearRetry = () => setStore()("retry", undefined)
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return store()
|
||||
},
|
||||
setPrompt(prompt: PromptInputV2Prompt, cursor?: number) {
|
||||
setPrompt(prompt: ComposerPrompt, cursor?: number) {
|
||||
batch(() => {
|
||||
setStore()("prompt", prompt)
|
||||
if (cursor !== undefined) setStore()("cursor", cursor)
|
||||
clearRetry()
|
||||
})
|
||||
},
|
||||
setCursor(cursor: number) {
|
||||
setStore()("cursor", cursor)
|
||||
},
|
||||
setMode(mode: "normal" | "shell") {
|
||||
setStore()("mode", mode)
|
||||
clearRetry()
|
||||
},
|
||||
setText(content: string) {
|
||||
batch(() => {
|
||||
setStore()("prompt", (prompt) => [
|
||||
{ type: "text", content, start: 0, end: content.length },
|
||||
...prompt.filter((part) => part.type !== "text"),
|
||||
...prompt.filter((part) => part.type === "image"),
|
||||
])
|
||||
setStore()("cursor", content.length)
|
||||
clearRetry()
|
||||
})
|
||||
},
|
||||
addText(content: string) {
|
||||
@@ -52,28 +57,14 @@ export function createPromptInputV2Store(input: PromptInputV2StoreInput) {
|
||||
batch(() => {
|
||||
setStore()("prompt", (prompt) => insertText(prompt, cursor, content))
|
||||
setStore()("cursor", cursor + content.length)
|
||||
clearRetry()
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
batch(() => {
|
||||
setStore()("prompt", [{ type: "text", content: "", start: 0, end: 0 }])
|
||||
setStore()("cursor", 0)
|
||||
})
|
||||
},
|
||||
setModel(model: PromptInputV2Model | undefined) {
|
||||
setStore()("model", model)
|
||||
},
|
||||
setVariant(variant: string | null) {
|
||||
if (store().model) setStore()("model", "variant", variant)
|
||||
},
|
||||
addContext(item: PromptInputV2Comment) {
|
||||
if (store().context.items.some((entry) => entry.key === item.key)) return
|
||||
setStore()("context", "items", (items) => [...items, item])
|
||||
},
|
||||
removeContext(key: string) {
|
||||
setStore()("context", "items", (items) => items.filter((item) => item.key !== key))
|
||||
clearRetry()
|
||||
},
|
||||
addMention(mention: PromptInputV2FilePart | PromptInputV2AgentPart) {
|
||||
addMention(mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart) {
|
||||
const text = store()
|
||||
.prompt.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
@@ -81,22 +72,19 @@ export function createPromptInputV2Store(input: PromptInputV2StoreInput) {
|
||||
const start = text.slice(0, end).lastIndexOf("@")
|
||||
setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention))
|
||||
setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1)
|
||||
},
|
||||
addAttachment(attachment: PromptInputV2Attachment) {
|
||||
setStore()("prompt", (prompt) => [...prompt, attachment])
|
||||
clearRetry()
|
||||
},
|
||||
removeAttachment(id: string) {
|
||||
setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id))
|
||||
clearRetry()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptInputV2Store = ReturnType<typeof createPromptInputV2Store>
|
||||
|
||||
function insertText(prompt: PromptInputV2Prompt, cursor: number, content: string): PromptInputV2Prompt {
|
||||
function insertText(prompt: ComposerPrompt, cursor: number, content: string): ComposerPrompt {
|
||||
let position = 0
|
||||
let inserted = false
|
||||
const parts = prompt.flatMap<PromptInputV2Prompt[number]>((part) => {
|
||||
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
|
||||
if (part.type === "image") return [part]
|
||||
const start = position
|
||||
position += part.content.length
|
||||
@@ -115,13 +103,13 @@ function insertText(prompt: PromptInputV2Prompt, cursor: number, content: string
|
||||
}
|
||||
|
||||
function insertMention(
|
||||
prompt: PromptInputV2Prompt,
|
||||
prompt: ComposerPrompt,
|
||||
start: number,
|
||||
end: number,
|
||||
mention: PromptInputV2FilePart | PromptInputV2AgentPart,
|
||||
): PromptInputV2Prompt {
|
||||
mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart,
|
||||
): ComposerPrompt {
|
||||
let position = 0
|
||||
const parts = prompt.flatMap<PromptInputV2Prompt[number]>((part) => {
|
||||
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
|
||||
if (part.type === "image") return [part]
|
||||
const partStart = position
|
||||
position += part.content.length
|
||||
@@ -137,7 +125,7 @@ function insertMention(
|
||||
return withOffsets(parts)
|
||||
}
|
||||
|
||||
function withOffsets(prompt: PromptInputV2Prompt): PromptInputV2Prompt {
|
||||
function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
|
||||
let offset = 0
|
||||
return prompt.map((part) => {
|
||||
if (part.type === "image") return part
|
||||
@@ -147,6 +135,6 @@ function withOffsets(prompt: PromptInputV2Prompt): PromptInputV2Prompt {
|
||||
})
|
||||
}
|
||||
|
||||
function promptLength(prompt: PromptInputV2Prompt) {
|
||||
function promptLength(prompt: ComposerPrompt) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./dom"
|
||||
|
||||
describe("Composer editor DOM", () => {
|
||||
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(document.createTextNode("ab\u200B"))
|
||||
container.appendChild(document.createElement("br"))
|
||||
container.appendChild(document.createTextNode("cd"))
|
||||
|
||||
expect(getNodeLength(container.childNodes[0]!)).toBe(2)
|
||||
expect(getNodeLength(container.childNodes[1]!)).toBe(1)
|
||||
expect(getTextLength(container)).toBe(5)
|
||||
})
|
||||
|
||||
test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
|
||||
const container = document.createElement("div")
|
||||
const pill = document.createElement("span")
|
||||
pill.dataset.mention = "file"
|
||||
pill.textContent = "@file"
|
||||
container.appendChild(document.createTextNode("ab"))
|
||||
container.appendChild(pill)
|
||||
container.appendChild(document.createElement("br"))
|
||||
container.appendChild(document.createTextNode("cd"))
|
||||
document.body.appendChild(container)
|
||||
|
||||
setCursorPosition(container, 2)
|
||||
expect(getCursorPosition(container)).toBe(2)
|
||||
|
||||
setCursorPosition(container, 7)
|
||||
expect(getCursorPosition(container)).toBe(7)
|
||||
|
||||
setCursorPosition(container, 8)
|
||||
expect(getCursorPosition(container)).toBe(8)
|
||||
|
||||
container.remove()
|
||||
})
|
||||
|
||||
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(document.createTextNode("a"))
|
||||
container.appendChild(document.createElement("br"))
|
||||
container.appendChild(document.createElement("br"))
|
||||
container.appendChild(document.createTextNode("b"))
|
||||
document.body.appendChild(container)
|
||||
|
||||
setCursorPosition(container, 2)
|
||||
expect(getCursorPosition(container)).toBe(2)
|
||||
|
||||
setCursorPosition(container, 3)
|
||||
expect(getCursorPosition(container)).toBe(3)
|
||||
|
||||
container.remove()
|
||||
})
|
||||
})
|
||||
+1
-62
@@ -1,32 +1,3 @@
|
||||
const MAX_BREAKS = 200
|
||||
|
||||
export function createTextFragment(content: string): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment()
|
||||
let breaks = 0
|
||||
for (const char of content) {
|
||||
if (char !== "\n") continue
|
||||
breaks += 1
|
||||
if (breaks > MAX_BREAKS) {
|
||||
const tail = content.endsWith("\n")
|
||||
const text = tail ? content.slice(0, -1) : content
|
||||
if (text) fragment.appendChild(document.createTextNode(text))
|
||||
if (tail) fragment.appendChild(document.createElement("br"))
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
|
||||
const segments = content.split("\n")
|
||||
segments.forEach((segment, index) => {
|
||||
if (segment) {
|
||||
fragment.appendChild(document.createTextNode(segment))
|
||||
}
|
||||
if (index < segments.length - 1) {
|
||||
fragment.appendChild(document.createElement("br"))
|
||||
}
|
||||
})
|
||||
return fragment
|
||||
}
|
||||
|
||||
export function getNodeLength(node: Node): number {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
|
||||
return (node.textContent ?? "").replace(/\u200B/g, "").length
|
||||
@@ -59,9 +30,7 @@ export function setCursorPosition(parent: HTMLElement, position: number) {
|
||||
while (node) {
|
||||
const length = getNodeLength(node)
|
||||
const isText = node.nodeType === Node.TEXT_NODE
|
||||
const isPill =
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
|
||||
const isPill = node.nodeType === Node.ELEMENT_NODE && !!(node as HTMLElement).dataset.mention
|
||||
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
||||
|
||||
if (isText && remaining <= length) {
|
||||
@@ -116,33 +85,3 @@ export function setCursorPosition(parent: HTMLElement, position: number) {
|
||||
fallbackSelection?.removeAllRanges()
|
||||
fallbackSelection?.addRange(fallbackRange)
|
||||
}
|
||||
|
||||
export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) {
|
||||
let remaining = offset
|
||||
const nodes = Array.from(parent.childNodes)
|
||||
|
||||
for (const node of nodes) {
|
||||
const length = getNodeLength(node)
|
||||
const isText = node.nodeType === Node.TEXT_NODE
|
||||
const isPill =
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
|
||||
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
||||
|
||||
if (isText && remaining <= length) {
|
||||
if (edge === "start") range.setStart(node, remaining)
|
||||
if (edge === "end") range.setEnd(node, remaining)
|
||||
return
|
||||
}
|
||||
|
||||
if ((isPill || isBreak) && remaining <= length) {
|
||||
if (edge === "start" && remaining === 0) range.setStartBefore(node)
|
||||
if (edge === "start" && remaining > 0) range.setStartAfter(node)
|
||||
if (edge === "end" && remaining === 0) range.setEndBefore(node)
|
||||
if (edge === "end" && remaining > 0) range.setEndAfter(node)
|
||||
return
|
||||
}
|
||||
|
||||
remaining -= length
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[data-component="composer-editor"]:empty::before {
|
||||
content: "\200B";
|
||||
}
|
||||
+105
-90
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, For, Show, type JSX } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -8,33 +8,34 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { AttachmentCardV2 } from "../attachment-card-v2"
|
||||
import { CommentCardV2 } from "../comment-card-v2"
|
||||
import { typeLabel } from "../../../components/message-file"
|
||||
import { AttachmentCard } from "@opencode-ai/session-ui/attachment-card"
|
||||
import { CommentCard } from "@opencode-ai/session-ui/comment-card"
|
||||
import { typeLabel } from "@opencode-ai/session-ui/message-file"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type {
|
||||
PromptInputV2Attachment,
|
||||
PromptInputV2Comment,
|
||||
PromptInputV2Option,
|
||||
PromptInputV2PersistedState,
|
||||
PromptInputV2Prompt,
|
||||
PromptInputV2Suggestion,
|
||||
} from "./types"
|
||||
import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction"
|
||||
import "./attachments.css"
|
||||
ComposerAttachment,
|
||||
ComposerComment,
|
||||
ComposerOption,
|
||||
ComposerPersistedState,
|
||||
ComposerPrompt,
|
||||
ComposerSuggestion,
|
||||
} from "../types"
|
||||
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
|
||||
import "../attachments/attachments.css"
|
||||
import "./editor.css"
|
||||
|
||||
export type {
|
||||
PromptInputV2Attachment,
|
||||
PromptInputV2Comment,
|
||||
PromptInputV2Option,
|
||||
PromptInputV2PersistedState,
|
||||
PromptInputV2Suggestion,
|
||||
} from "./types"
|
||||
ComposerAttachment,
|
||||
ComposerComment,
|
||||
ComposerOption,
|
||||
ComposerPersistedState,
|
||||
ComposerSuggestion,
|
||||
} from "../types"
|
||||
|
||||
export type PromptInputV2Mode = "normal" | "shell"
|
||||
export type ComposerMode = "normal" | "shell"
|
||||
|
||||
export type PromptInputV2Props = {
|
||||
controller: PromptInputV2Interaction
|
||||
export type ComposerEditorProps = {
|
||||
controller: ComposerEditorModel
|
||||
accentSubmit?: boolean
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
@@ -46,7 +47,7 @@ export type PromptInputV2Props = {
|
||||
attachShortcut?: string
|
||||
}
|
||||
|
||||
export function PromptInputV2(props: PromptInputV2Props) {
|
||||
export function ComposerEditor(props: ComposerEditorProps) {
|
||||
const i18n = useI18n()
|
||||
const state = props.controller.state
|
||||
const view = props.controller.view
|
||||
@@ -54,7 +55,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
let localInput = false
|
||||
const updateCursor = () => {
|
||||
if (!editor || !window.getSelection()?.isCollapsed) return
|
||||
props.controller.onCursor(promptInputV2Cursor(editor))
|
||||
props.controller.onCursor(composerCursor(editor))
|
||||
}
|
||||
const mode = createMemo(() => state.mode)
|
||||
const buttons = createMemo(() => ({
|
||||
@@ -70,7 +71,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
localInput = false
|
||||
return
|
||||
}
|
||||
renderPromptInputV2Editor(editor, parts)
|
||||
renderComposerEditor(editor, parts)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -88,7 +89,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
}}
|
||||
/>
|
||||
<Show when={state.popover.type !== "closed"}>
|
||||
<PromptInputV2Popover
|
||||
<ComposerEditorPopover
|
||||
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
|
||||
items={props.controller.suggestions()}
|
||||
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
|
||||
@@ -108,9 +109,9 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
/>
|
||||
</Show>
|
||||
<form
|
||||
data-component="prompt-input-v2"
|
||||
data-dock-border-underlay={props.borderUnderlay ? "v2" : undefined}
|
||||
class="group/prompt-input relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
data-component="composer"
|
||||
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
|
||||
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
|
||||
@@ -131,7 +132,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
</Show>
|
||||
|
||||
<Show when={state.mode === "normal"}>
|
||||
<PromptInputV2Attachments
|
||||
<ComposerAttachments
|
||||
attachments={props.controller.attachments()}
|
||||
comments={props.controller.comments()}
|
||||
activeCommentID={state.activeContextID}
|
||||
@@ -148,9 +149,9 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
ref={(element) => {
|
||||
editor = element
|
||||
props.controller.setEditor(element)
|
||||
renderPromptInputV2Editor(element, props.controller.parts())
|
||||
renderComposerEditor(element, props.controller.parts())
|
||||
}}
|
||||
data-component="prompt-input"
|
||||
data-component="composer-editor"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={i18n.t("ui.promptInput.label")}
|
||||
@@ -168,8 +169,8 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
"text-align": "start",
|
||||
}}
|
||||
onInput={(event) => {
|
||||
const cursor = promptInputV2Cursor(event.currentTarget)
|
||||
const prompt = parsePromptInputV2Editor(event.currentTarget)
|
||||
const cursor = composerCursor(event.currentTarget)
|
||||
const prompt = parseComposerEditor(event.currentTarget)
|
||||
const images = props.controller.parts().filter((part) => part.type === "image")
|
||||
localInput = true
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
|
||||
@@ -189,8 +190,10 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
/>
|
||||
<Show when={!props.controller.value()}>
|
||||
<div
|
||||
dir={state.mode === "normal" ? "auto" : "ltr"}
|
||||
class="pointer-events-none absolute inset-x-0 top-0 px-4 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
||||
classList={{ "font-mono!": state.mode === "shell" }}
|
||||
style={{ "unicode-bidi": state.mode === "normal" ? "plaintext" : undefined, "text-align": "start" }}
|
||||
>
|
||||
{view.placeholder?.() ??
|
||||
(state.mode === "shell"
|
||||
@@ -207,7 +210,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
inert={state.mode === "shell" ? true : undefined}
|
||||
style={buttons()}
|
||||
>
|
||||
<PromptInputV2AddMenu
|
||||
<ComposerEditorAddMenu
|
||||
disabled={state.mode === "shell"}
|
||||
title={i18n.t("ui.promptInput.add")}
|
||||
keybind={props.attachKeybind ?? ["Mod", "U"]}
|
||||
@@ -223,34 +226,18 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
/>
|
||||
<Show when={view.agent} keyed>
|
||||
{(control) => (
|
||||
<PromptInputV2ConfiguredSelect
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseAgent")}
|
||||
keybind={["Mod", "."]}
|
||||
control={control}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={props.modelControl}
|
||||
fallback={
|
||||
<Show when={view.model} keyed>
|
||||
{(control) => (
|
||||
<PromptInputV2ConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseModel")}
|
||||
keybind={["Mod", "M"]}
|
||||
control={control}
|
||||
model
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{props.modelControl}
|
||||
</Show>
|
||||
{props.modelControl}
|
||||
<Show when={(props.variantControlVisible ?? true) && view.variant} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<PromptInputV2ConfiguredSelect
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
@@ -259,7 +246,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<PromptInputV2SubmitButton
|
||||
<ComposerEditorSubmitButton
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
disabled={!props.controller.canSubmit()}
|
||||
@@ -275,18 +262,27 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
)
|
||||
}
|
||||
|
||||
function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2Prompt) {
|
||||
const mentionParts = new WeakMap<HTMLElement, Exclude<ComposerPrompt[number], ComposerAttachment | { type: "text" }>>()
|
||||
|
||||
function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
|
||||
const active = document.activeElement === editor
|
||||
editor.replaceChildren(
|
||||
...prompt.flatMap<Node>((part) => {
|
||||
if (part.type === "image") return []
|
||||
if (part.type === "text") return [document.createTextNode(part.content)]
|
||||
const mention = document.createElement("span")
|
||||
mentionParts.set(mention, part)
|
||||
mention.textContent = part.content
|
||||
mention.contentEditable = "false"
|
||||
mention.dir = "auto"
|
||||
mention.style.unicodeBidi = "isolate"
|
||||
mention.dataset.mention =
|
||||
part.type === "file" && part.mime === "application/x-directory" ? "reference" : part.type
|
||||
if (part.type === "agent") mention.dataset.name = part.name
|
||||
if (part.type === "skill") {
|
||||
mention.dataset.id = part.id
|
||||
mention.dataset.name = part.name
|
||||
}
|
||||
if (part.type === "file") {
|
||||
mention.dataset.path = part.path
|
||||
if (part.mime) mention.dataset.mime = part.mime
|
||||
@@ -304,8 +300,8 @@ function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function parsePromptInputV2Editor(editor: HTMLDivElement) {
|
||||
const parts: Exclude<PromptInputV2Prompt[number], PromptInputV2Attachment>[] = []
|
||||
function parseComposerEditor(editor: HTMLDivElement) {
|
||||
const parts: Exclude<ComposerPrompt[number], ComposerAttachment>[] = []
|
||||
let buffer = ""
|
||||
let position = 0
|
||||
|
||||
@@ -318,8 +314,10 @@ function parsePromptInputV2Editor(editor: HTMLDivElement) {
|
||||
const mention = (element: HTMLElement) => {
|
||||
flush()
|
||||
const content = element.textContent ?? ""
|
||||
const original = mentionParts.get(element)
|
||||
if (element.dataset.mention === "agent") {
|
||||
parts.push({
|
||||
...(original?.type === "agent" ? original : {}),
|
||||
type: "agent",
|
||||
name: element.dataset.name ?? content.slice(1),
|
||||
content,
|
||||
@@ -329,7 +327,21 @@ function parsePromptInputV2Editor(editor: HTMLDivElement) {
|
||||
position += content.length
|
||||
return
|
||||
}
|
||||
if (element.dataset.mention === "skill") {
|
||||
parts.push({
|
||||
...(original?.type === "skill" ? original : {}),
|
||||
type: "skill",
|
||||
id: Skill.ID.make(element.dataset.id ?? content.slice(1)),
|
||||
name: Skill.Name.make(element.dataset.name ?? content.slice(1)),
|
||||
content,
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
})
|
||||
position += content.length
|
||||
return
|
||||
}
|
||||
parts.push({
|
||||
...(original?.type === "file" ? original : {}),
|
||||
type: "file",
|
||||
path: element.dataset.path ?? content.slice(1),
|
||||
content,
|
||||
@@ -372,7 +384,7 @@ function parsePromptInputV2Editor(editor: HTMLDivElement) {
|
||||
return [{ type: "text" as const, content: "", start: 0, end: 0 }]
|
||||
}
|
||||
|
||||
function promptInputV2Cursor(editor: HTMLDivElement) {
|
||||
function composerCursor(editor: HTMLDivElement) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
|
||||
const range = selection.getRangeAt(0).cloneRange()
|
||||
@@ -381,22 +393,22 @@ function promptInputV2Cursor(editor: HTMLDivElement) {
|
||||
return range.toString().length
|
||||
}
|
||||
|
||||
export function PromptInputV2Attachments(props: {
|
||||
attachments: PromptInputV2Attachment[]
|
||||
comments?: PromptInputV2Comment[]
|
||||
export function ComposerAttachments(props: {
|
||||
attachments: ComposerAttachment[]
|
||||
comments?: ComposerComment[]
|
||||
activeCommentID?: string
|
||||
removeLabel: string
|
||||
onAttachmentClick?: (attachment: PromptInputV2Attachment) => void
|
||||
onAttachmentRemove: (attachment: PromptInputV2Attachment) => void
|
||||
onCommentClick?: (comment: PromptInputV2Comment) => void
|
||||
onCommentRemove?: (comment: PromptInputV2Comment) => void
|
||||
onAttachmentClick?: (attachment: ComposerAttachment) => void
|
||||
onAttachmentRemove: (attachment: ComposerAttachment) => void
|
||||
onCommentClick?: (comment: ComposerComment) => void
|
||||
onCommentRemove?: (comment: ComposerComment) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
|
||||
<div data-component="prompt-input-v2-attachments" data-slot="prompt-attachments" class="relative">
|
||||
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
|
||||
<div
|
||||
data-slot="prompt-attachments-scroll"
|
||||
data-slot="composer-attachments-scroll"
|
||||
class="flex flex-nowrap gap-2 overflow-x-auto no-scrollbar px-2 pt-2 pb-1"
|
||||
>
|
||||
<For each={props.comments ?? []}>
|
||||
@@ -408,7 +420,7 @@ export function PromptInputV2Attachments(props: {
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<CommentCardV2
|
||||
<CommentCard
|
||||
comment={comment.comment ?? ""}
|
||||
path={comment.path}
|
||||
selection={comment.selection}
|
||||
@@ -434,9 +446,9 @@ export function PromptInputV2Attachments(props: {
|
||||
<Show
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<AttachmentCardV2 title={attachment.filename}>
|
||||
<AttachmentCard title={attachment.filename}>
|
||||
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
|
||||
</AttachmentCardV2>
|
||||
</AttachmentCard>
|
||||
}
|
||||
>
|
||||
<img
|
||||
@@ -461,11 +473,11 @@ export function PromptInputV2Attachments(props: {
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
data-slot="prompt-attachments-fade-left"
|
||||
data-slot="composer-attachments-fade-left"
|
||||
class="pointer-events-none absolute inset-y-0 start-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)] rtl:bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
|
||||
/>
|
||||
<div
|
||||
data-slot="prompt-attachments-fade-right"
|
||||
data-slot="composer-attachments-fade-right"
|
||||
class="pointer-events-none absolute inset-y-0 end-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)] rtl:bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
|
||||
/>
|
||||
</div>
|
||||
@@ -473,7 +485,7 @@ export function PromptInputV2Attachments(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptInputV2AddMenu(props: {
|
||||
export function ComposerEditorAddMenu(props: {
|
||||
disabled?: boolean
|
||||
title: string
|
||||
keybind?: string[]
|
||||
@@ -500,7 +512,7 @@ export function PromptInputV2AddMenu(props: {
|
||||
<Menu gutter={6} modal={false} placement="top-start">
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
data-action="prompt-attach"
|
||||
data-action="composer-attach"
|
||||
type="button"
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
@@ -530,16 +542,16 @@ export function PromptInputV2AddMenu(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function PromptInputV2ConfiguredSelect(props: {
|
||||
function ComposerEditorConfiguredSelect(props: {
|
||||
title: string
|
||||
keybind?: string[]
|
||||
control: PromptInputV2SelectControl
|
||||
control: ComposerSelectControl
|
||||
model?: boolean
|
||||
}) {
|
||||
const current = () => props.control.current()
|
||||
const providerID = () => props.control.options().find((option) => option.id === current())?.providerID
|
||||
return (
|
||||
<PromptInputV2Select
|
||||
<ComposerEditorSelect
|
||||
title={props.title}
|
||||
keybind={props.control.keybind?.() ?? props.keybind}
|
||||
options={props.control.options()}
|
||||
@@ -554,10 +566,10 @@ function PromptInputV2ConfiguredSelect(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptInputV2Select(props: {
|
||||
export function ComposerEditorSelect(props: {
|
||||
title: string
|
||||
keybind?: string[]
|
||||
options: PromptInputV2Option[]
|
||||
options: ComposerOption[]
|
||||
current: string
|
||||
currentIcon?: JSX.Element
|
||||
class?: string
|
||||
@@ -608,9 +620,9 @@ export function PromptInputV2Select(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptInputV2Popover(props: {
|
||||
export function ComposerEditorPopover(props: {
|
||||
emptyLabel: string
|
||||
items: PromptInputV2Suggestion[]
|
||||
items: ComposerSuggestion[]
|
||||
activeID?: string
|
||||
search?: {
|
||||
value: string
|
||||
@@ -619,8 +631,8 @@ export function PromptInputV2Popover(props: {
|
||||
onValueChange: (value: string) => void
|
||||
onKeyDown: (event: KeyboardEvent) => void
|
||||
}
|
||||
onActiveChange: (item: PromptInputV2Suggestion) => void
|
||||
onSelect: (item: PromptInputV2Suggestion) => void
|
||||
onActiveChange: (item: ComposerSuggestion) => void
|
||||
onSelect: (item: ComposerSuggestion) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -658,8 +670,10 @@ export function PromptInputV2Popover(props: {
|
||||
onClick={() => props.onSelect(item)}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<PromptInputV2SuggestionIcon item={item} />
|
||||
<span class="shrink-0 text-v2-text-text-base">{item.label}</span>
|
||||
<ComposerSuggestionIcon item={item} />
|
||||
<bdi dir="auto" class="shrink-0 text-v2-text-text-base">
|
||||
{item.label}
|
||||
</bdi>
|
||||
<Show when={item.description}>
|
||||
<span class="min-w-0 truncate text-v2-text-text-muted">{item.description}</span>
|
||||
</Show>
|
||||
@@ -675,8 +689,8 @@ export function PromptInputV2Popover(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptInputV2SubmitButton(props: {
|
||||
mode: PromptInputV2Mode
|
||||
export function ComposerEditorSubmitButton(props: {
|
||||
mode: ComposerMode
|
||||
stopping: boolean
|
||||
disabled: boolean
|
||||
accent?: boolean
|
||||
@@ -692,7 +706,7 @@ export function PromptInputV2SubmitButton(props: {
|
||||
value={props.stopping ? props.stopLabel : props.sendLabel}
|
||||
>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
data-action="composer-submit"
|
||||
type="button"
|
||||
disabled={!props.stopping && props.disabled}
|
||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||
@@ -724,8 +738,9 @@ export function PromptInputV2SubmitButton(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function PromptInputV2SuggestionIcon(props: { item: PromptInputV2Suggestion }) {
|
||||
function ComposerSuggestionIcon(props: { item: ComposerSuggestion }) {
|
||||
if (props.item.kind === "agent") return <Icon name="brain" size="small" class="shrink-0 text-icon-info-active" />
|
||||
if (props.item.kind === "skill") return <Icon name="post-skill" size="small" class="shrink-0" />
|
||||
if (props.item.kind === "command") return null
|
||||
return (
|
||||
<FileIcon
|
||||
+65
-72
@@ -1,39 +1,38 @@
|
||||
import { createEffect, on, type Accessor } from "solid-js"
|
||||
import { createEffect, type Accessor } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { createPromptInputV2Attachments, type PromptInputV2AttachmentConfig } from "./attachments"
|
||||
import { createPromptInputV2Store, type PromptInputV2StoreInput } from "./store"
|
||||
import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments"
|
||||
import { createComposerEditorActions, type ComposerStateStoreInput } from "./actions"
|
||||
import type {
|
||||
PromptInputV2Attachment,
|
||||
PromptInputV2Comment,
|
||||
PromptInputV2History,
|
||||
PromptInputV2HistoryEntry,
|
||||
PromptInputV2Option,
|
||||
PromptInputV2PersistedState,
|
||||
PromptInputV2Suggestion,
|
||||
} from "./types"
|
||||
ComposerAttachment,
|
||||
ComposerComment,
|
||||
ComposerHistory,
|
||||
ComposerHistoryEntry,
|
||||
ComposerOption,
|
||||
ComposerPersistedState,
|
||||
ComposerSuggestion,
|
||||
} from "../types"
|
||||
import {
|
||||
createPromptInputV2InteractionState,
|
||||
transitionPromptInputV2,
|
||||
type PromptInputV2InteractionCommand,
|
||||
type PromptInputV2InteractionEvent,
|
||||
} from "./machine"
|
||||
createComposerInteractionState,
|
||||
transitionComposer,
|
||||
type ComposerInteractionCommand,
|
||||
type ComposerInteractionEvent,
|
||||
} from "../suggestions/machine"
|
||||
|
||||
export type PromptInputV2SelectControl = {
|
||||
options: Accessor<PromptInputV2Option[]>
|
||||
export type ComposerSelectControl = {
|
||||
options: Accessor<ComposerOption[]>
|
||||
current: Accessor<string>
|
||||
onSelect: (id: string) => void
|
||||
keybind?: Accessor<string[]>
|
||||
}
|
||||
|
||||
export type PromptInputV2ViewConfig = {
|
||||
export type ComposerEditorView = {
|
||||
placeholder?: Accessor<string>
|
||||
add?: {
|
||||
onAttach: () => void
|
||||
}
|
||||
agent?: PromptInputV2SelectControl
|
||||
model?: PromptInputV2SelectControl
|
||||
variant?: PromptInputV2SelectControl
|
||||
agent?: ComposerSelectControl
|
||||
variant?: ComposerSelectControl
|
||||
submit: {
|
||||
stopping: Accessor<boolean>
|
||||
working?: Accessor<boolean>
|
||||
@@ -44,39 +43,32 @@ export type PromptInputV2ViewConfig = {
|
||||
onOpen: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
onKeyDown?: (event: KeyboardEvent) => void
|
||||
onPaste?: (event: ClipboardEvent) => void
|
||||
onDrop?: (event: DragEvent) => void
|
||||
}
|
||||
|
||||
export function createPromptInputV2State() {
|
||||
return createStore(createPromptInputV2InteractionState())
|
||||
export function createComposerEditorState(mode: "normal" | "shell" = "normal") {
|
||||
return createStore({ ...createComposerInteractionState(), mode })
|
||||
}
|
||||
|
||||
export function createPromptInputV2Controller(input: {
|
||||
store: PromptInputV2StoreInput
|
||||
state?: ReturnType<typeof createPromptInputV2State>
|
||||
identity?: Accessor<unknown>
|
||||
history?: PromptInputV2History
|
||||
commands: Accessor<PromptInputV2Suggestion[]>
|
||||
context: Accessor<PromptInputV2Suggestion[]>
|
||||
searchContextFiles: (query: string) => PromptInputV2Suggestion[] | Promise<PromptInputV2Suggestion[]>
|
||||
openAttachment?: (attachment: PromptInputV2Attachment) => void
|
||||
export function createComposerEditor(input: {
|
||||
store: ComposerStateStoreInput
|
||||
state?: ReturnType<typeof createComposerEditorState>
|
||||
history?: ComposerHistory
|
||||
commands: Accessor<ComposerSuggestion[]>
|
||||
context: Accessor<ComposerSuggestion[]>
|
||||
searchContextFiles: (query: string) => ComposerSuggestion[] | Promise<ComposerSuggestion[]>
|
||||
openAttachment?: (attachment: ComposerAttachment) => void
|
||||
openContext?: (key: string) => void
|
||||
onContextRemove?: (item: PromptInputV2Comment) => void
|
||||
onContextRemove?: (item: ComposerComment) => void
|
||||
onEditor?: (element: HTMLElement) => void
|
||||
onSuggestionSelect?: (item: PromptInputV2Suggestion) => (() => void) | void
|
||||
view: PromptInputV2ViewConfig
|
||||
attachments?: PromptInputV2AttachmentConfig
|
||||
onSuggestionSelect?: (item: ComposerSuggestion) => (() => void) | void
|
||||
view: ComposerEditorView
|
||||
attachments?: ComposerAttachmentConfig
|
||||
}) {
|
||||
let editor: HTMLElement | undefined
|
||||
let fileInput: HTMLInputElement | undefined
|
||||
const draft = createPromptInputV2Store(input.store)
|
||||
const [state, setState] = input.state ?? createPromptInputV2State()
|
||||
if (input.identity) {
|
||||
createEffect(on(input.identity, () => setState(reconcile(createPromptInputV2InteractionState())), { defer: true }))
|
||||
}
|
||||
function addPart(part: PromptInputV2PersistedState["prompt"][number]) {
|
||||
const draft = createComposerEditorActions(input.store)
|
||||
const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode)
|
||||
function addPart(part: ComposerPersistedState["prompt"][number]) {
|
||||
if (part.type === "image") return false
|
||||
if (part.type === "file" || part.type === "agent") {
|
||||
draft.addMention(part)
|
||||
@@ -86,12 +78,12 @@ export function createPromptInputV2Controller(input: {
|
||||
return true
|
||||
}
|
||||
const attachments = input.attachments
|
||||
? createPromptInputV2Attachments({
|
||||
? createComposerAttachments({
|
||||
...input.attachments,
|
||||
capture: () => ({
|
||||
current: () => draft.state.prompt,
|
||||
cursor: () => draft.state.cursor,
|
||||
set: draft.setPrompt,
|
||||
set: (prompt, cursor) => draft.setPrompt(prompt, cursor),
|
||||
}),
|
||||
editor: () => editor,
|
||||
focusEditor: () => editor?.focus(),
|
||||
@@ -106,7 +98,7 @@ export function createPromptInputV2Controller(input: {
|
||||
}
|
||||
attachments.pick(() => fileInput?.click())
|
||||
}
|
||||
const contextList = useFilteredList<PromptInputV2Suggestion>({
|
||||
const contextList = useFilteredList<ComposerSuggestion>({
|
||||
items: async (query) => {
|
||||
const fixed = input.context().filter((item) => item.kind !== "file")
|
||||
const recent = input.context().filter((item) => item.kind === "file" && item.recent)
|
||||
@@ -120,17 +112,18 @@ export function createPromptInputV2Controller(input: {
|
||||
skipFilter: (item) => item.kind === "file" && !item.recent,
|
||||
groupBy: (item) => {
|
||||
if (item.kind === "reference") return "reference"
|
||||
if (item.kind === "skill") return "skill"
|
||||
if (item.kind === "agent") return "agent"
|
||||
if (item.kind === "resource") return "resource"
|
||||
if (item.recent) return "recent"
|
||||
return "file"
|
||||
},
|
||||
sortGroupsBy: (a, b) => {
|
||||
const order = ["reference", "agent", "resource", "recent", "file"]
|
||||
const order = ["reference", "skill", "agent", "resource", "recent", "file"]
|
||||
return order.indexOf(a.category) - order.indexOf(b.category)
|
||||
},
|
||||
})
|
||||
const commandList = useFilteredList<PromptInputV2Suggestion>({
|
||||
const commandList = useFilteredList<ComposerSuggestion>({
|
||||
items: () => input.commands(),
|
||||
key: (item) => item.id,
|
||||
filterKeys: ["trigger", "title"],
|
||||
@@ -138,11 +131,15 @@ export function createPromptInputV2Controller(input: {
|
||||
const list = () => (state.popover.type === "context" ? contextList : commandList)
|
||||
const suggestions = () => list().flat()
|
||||
|
||||
const execute = (command: PromptInputV2InteractionCommand) => {
|
||||
const execute = (command: ComposerInteractionCommand) => {
|
||||
if (command.type === "draft.setText") {
|
||||
draft.setText(command.value)
|
||||
return
|
||||
}
|
||||
if (command.type === "draft.addText") {
|
||||
draft.addText(command.value)
|
||||
return
|
||||
}
|
||||
if (command.type === "mention.add") {
|
||||
if (command.item.mention) draft.addMention(command.item.mention)
|
||||
return
|
||||
@@ -159,20 +156,21 @@ export function createPromptInputV2Controller(input: {
|
||||
if (command.type === "focus.editor") requestAnimationFrame(() => editor?.focus())
|
||||
}
|
||||
|
||||
function dispatch(event: PromptInputV2InteractionEvent) {
|
||||
function dispatch(event: ComposerInteractionEvent) {
|
||||
const mode = state.mode
|
||||
const result = transitionPromptInputV2(state, event, draft.state)
|
||||
const result = transitionComposer(state, event, draft.state)
|
||||
const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined
|
||||
if (event.type === "popover.select") {
|
||||
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
|
||||
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
|
||||
draft.setPrompt(
|
||||
draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image"),
|
||||
draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image"),
|
||||
0,
|
||||
)
|
||||
}
|
||||
}
|
||||
setState(reconcile(result.state))
|
||||
if (mode !== result.state.mode) draft.setMode(result.state.mode)
|
||||
if (event.type !== "popover.select") result.commands.forEach(execute)
|
||||
if (mode !== result.state.mode) {
|
||||
if (result.state.mode === "shell") input.view.shell?.onOpen()
|
||||
@@ -232,7 +230,6 @@ export function createPromptInputV2Controller(input: {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
input.view.onKeyDown?.(event)
|
||||
return event.defaultPrevented
|
||||
}
|
||||
|
||||
@@ -250,7 +247,7 @@ export function createPromptInputV2Controller(input: {
|
||||
})
|
||||
}
|
||||
|
||||
const applyHistory = (entry: PromptInputV2HistoryEntry, position: "start" | "end") => {
|
||||
const applyHistory = (entry: ComposerHistoryEntry, position: "start" | "end") => {
|
||||
input.history?.restore?.(entry.metadata)
|
||||
const cursor = position === "start" ? 0 : promptLength(entry.prompt)
|
||||
draft.setPrompt(clonePrompt(entry.prompt), cursor)
|
||||
@@ -301,15 +298,14 @@ export function createPromptInputV2Controller(input: {
|
||||
parts() {
|
||||
return draft.state.prompt
|
||||
},
|
||||
addPart,
|
||||
contextItem(id: string) {
|
||||
return draft.state.context.items.find((item) => item.key === id)
|
||||
},
|
||||
comments() {
|
||||
return draft.state.context.items.filter((item) => !!item.comment?.trim())
|
||||
},
|
||||
attachments(): PromptInputV2Attachment[] {
|
||||
return draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image")
|
||||
attachments(): ComposerAttachment[] {
|
||||
return draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image")
|
||||
},
|
||||
toggleContext(id: string) {
|
||||
dispatch({ type: "context.active", id })
|
||||
@@ -321,7 +317,7 @@ export function createPromptInputV2Controller(input: {
|
||||
draft.removeContext(id)
|
||||
if (state.activeContextID === id) dispatch({ type: "context.active", id })
|
||||
},
|
||||
openAttachment(attachment: PromptInputV2Attachment) {
|
||||
openAttachment(attachment: ComposerAttachment) {
|
||||
input.openAttachment?.(attachment)
|
||||
},
|
||||
removeAttachment(id: string) {
|
||||
@@ -329,6 +325,9 @@ export function createPromptInputV2Controller(input: {
|
||||
},
|
||||
canSubmit() {
|
||||
const persisted = draft.state
|
||||
if (state.mode === "shell") {
|
||||
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
|
||||
}
|
||||
if (persisted.prompt.some((part) => part.type === "image")) return true
|
||||
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
|
||||
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
|
||||
@@ -338,7 +337,7 @@ export function createPromptInputV2Controller(input: {
|
||||
input.onEditor?.(element)
|
||||
},
|
||||
restoreFocus,
|
||||
onInput(value: string, prompt?: PromptInputV2PersistedState["prompt"], cursor?: number) {
|
||||
onInput(value: string, prompt?: ComposerPersistedState["prompt"], cursor?: number) {
|
||||
if (prompt) draft.setPrompt(prompt, cursor)
|
||||
dispatch({ type: "input.changed", value, persist: !prompt })
|
||||
},
|
||||
@@ -354,9 +353,6 @@ export function createPromptInputV2Controller(input: {
|
||||
openShell() {
|
||||
dispatch({ type: "mode.shell" })
|
||||
},
|
||||
closeShell() {
|
||||
dispatch({ type: "mode.normal" })
|
||||
},
|
||||
submit() {
|
||||
input.view.submit.onSubmit()
|
||||
dispatch({ type: "popover.close" })
|
||||
@@ -364,7 +360,7 @@ export function createPromptInputV2Controller(input: {
|
||||
stop() {
|
||||
input.view.submit.onStop()
|
||||
},
|
||||
addHistory(prompt: PromptInputV2PersistedState["prompt"], mode: "normal" | "shell") {
|
||||
addHistory(prompt: ComposerPersistedState["prompt"], mode: "normal" | "shell") {
|
||||
input.history?.add(prompt, mode)
|
||||
setState({ historyIndex: -1, savedHistory: undefined })
|
||||
},
|
||||
@@ -380,8 +376,6 @@ export function createPromptInputV2Controller(input: {
|
||||
void attachments.handlePaste(event)
|
||||
return
|
||||
}
|
||||
input.view.onPaste?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
const text = clipboard?.getData("text/plain")
|
||||
if (!text) return
|
||||
event.preventDefault()
|
||||
@@ -417,7 +411,6 @@ export function createPromptInputV2Controller(input: {
|
||||
void attachments.handleDrop(event)
|
||||
return
|
||||
}
|
||||
input.view.onDrop?.(event)
|
||||
},
|
||||
attach,
|
||||
setFileInput(element: HTMLInputElement) {
|
||||
@@ -432,7 +425,7 @@ export function createPromptInputV2Controller(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptInputV2Interaction = ReturnType<typeof createPromptInputV2Controller>
|
||||
export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
|
||||
|
||||
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
|
||||
const position = Math.max(0, Math.min(cursor, text.length))
|
||||
@@ -441,13 +434,13 @@ function canNavigateHistory(direction: "up" | "down", text: string, cursor: numb
|
||||
return position === text.length
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: PromptInputV2PersistedState["prompt"]): PromptInputV2PersistedState["prompt"] {
|
||||
function clonePrompt(prompt: ComposerPersistedState["prompt"]): ComposerPersistedState["prompt"] {
|
||||
return prompt.map((part) =>
|
||||
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
|
||||
)
|
||||
}
|
||||
|
||||
function promptLength(prompt: PromptInputV2PersistedState["prompt"]) {
|
||||
function promptLength(prompt: ComposerPersistedState["prompt"]) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { clonePromptParts, prependHistoryEntry, promptLength, type PromptHistoryComment } from "./entry"
|
||||
import { upgradeHistoryState } from "./store"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
|
||||
const comment = (id: string, value = "note"): PromptHistoryComment => ({
|
||||
id,
|
||||
path: "src/a.ts",
|
||||
selection: { start: 2, end: 4 },
|
||||
comment: value,
|
||||
time: 1,
|
||||
origin: "review",
|
||||
preview: "const a = 1",
|
||||
})
|
||||
|
||||
describe("Composer history", () => {
|
||||
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
|
||||
const first = prependHistoryEntry([], DEFAULT_PROMPT)
|
||||
expect(first).toEqual([])
|
||||
|
||||
const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")])
|
||||
expect(commentsOnly).toHaveLength(1)
|
||||
|
||||
const withOne = prependHistoryEntry([], text("hello"))
|
||||
expect(withOne).toHaveLength(1)
|
||||
|
||||
const deduped = prependHistoryEntry(withOne, text("hello"))
|
||||
expect(deduped).toBe(withOne)
|
||||
|
||||
const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")])
|
||||
expect(dedupedComments).toBe(commentsOnly)
|
||||
})
|
||||
|
||||
test("upgrades stored prompt arrays once at the persistence boundary", () => {
|
||||
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
|
||||
entries: [{ prompt: text("stored"), comments: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
test("helpers clone prompt and count text content length", () => {
|
||||
const original: Prompt = [
|
||||
{ type: "text", content: "one", start: 0, end: 3 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/a.ts",
|
||||
content: "@src/a.ts",
|
||||
start: 3,
|
||||
end: 12,
|
||||
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
||||
},
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
|
||||
]
|
||||
const copy = clonePromptParts(original)
|
||||
expect(copy).not.toBe(original)
|
||||
expect(promptLength(copy)).toBe(12)
|
||||
if (copy[1]?.type !== "file") throw new Error("expected file")
|
||||
copy[1].selection!.startLine = 9
|
||||
if (original[1]?.type !== "file") throw new Error("expected file")
|
||||
expect(original[1].selection?.startLine).toBe(1)
|
||||
})
|
||||
})
|
||||
+6
-121
@@ -1,7 +1,5 @@
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
|
||||
export const MAX_HISTORY = 100
|
||||
|
||||
@@ -22,20 +20,12 @@ export type PromptHistoryEntry = {
|
||||
|
||||
export type PromptHistoryStoredEntry = PromptHistoryEntry
|
||||
|
||||
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
||||
const position = Math.max(0, Math.min(cursor, text.length))
|
||||
const atStart = position === 0
|
||||
const atEnd = position === text.length
|
||||
if (inHistory) return atStart || atEnd
|
||||
if (direction === "up") return position === 0 && text.length === 0
|
||||
return position === text.length
|
||||
}
|
||||
|
||||
export function clonePromptParts(prompt: Prompt): Prompt {
|
||||
return prompt.map((part) => {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
if (part.type === "skill") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: part.selection ? { ...part.selection } : undefined,
|
||||
@@ -130,6 +120,9 @@ function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistory
|
||||
if (!sameSelection) return false
|
||||
}
|
||||
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
|
||||
if (partA.type === "skill") {
|
||||
if (partB.type !== "skill" || partA.id !== partB.id || partA.name !== partB.name) return false
|
||||
}
|
||||
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
|
||||
}
|
||||
if (entryA.comments.length !== entryB.comments.length) return false
|
||||
@@ -140,111 +133,3 @@ function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistory
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type HistoryNavInput = {
|
||||
direction: "up" | "down"
|
||||
entries: PromptHistoryStoredEntry[]
|
||||
historyIndex: number
|
||||
currentPrompt: Prompt
|
||||
currentComments: PromptHistoryComment[]
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
}
|
||||
|
||||
type HistoryNavResult =
|
||||
| {
|
||||
handled: false
|
||||
historyIndex: number
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
}
|
||||
| {
|
||||
handled: true
|
||||
historyIndex: number
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
entry: PromptHistoryEntry
|
||||
cursor: "start" | "end"
|
||||
}
|
||||
|
||||
export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult {
|
||||
if (input.direction === "up") {
|
||||
if (input.entries.length === 0) {
|
||||
return {
|
||||
handled: false,
|
||||
historyIndex: input.historyIndex,
|
||||
savedPrompt: input.savedPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex === -1) {
|
||||
const entry = normalizePromptHistoryEntry(input.entries[0])
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: 0,
|
||||
savedPrompt: {
|
||||
prompt: clonePromptParts(input.currentPrompt),
|
||||
comments: clonePromptHistoryComments(input.currentComments),
|
||||
},
|
||||
entry,
|
||||
cursor: "start",
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex < input.entries.length - 1) {
|
||||
const next = input.historyIndex + 1
|
||||
const entry = normalizePromptHistoryEntry(input.entries[next])
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: next,
|
||||
savedPrompt: input.savedPrompt,
|
||||
entry,
|
||||
cursor: "start",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handled: false,
|
||||
historyIndex: input.historyIndex,
|
||||
savedPrompt: input.savedPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex > 0) {
|
||||
const next = input.historyIndex - 1
|
||||
const entry = normalizePromptHistoryEntry(input.entries[next])
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: next,
|
||||
savedPrompt: input.savedPrompt,
|
||||
entry,
|
||||
cursor: "end",
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex === 0) {
|
||||
if (input.savedPrompt) {
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
entry: input.savedPrompt,
|
||||
cursor: "end",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
entry: {
|
||||
prompt: DEFAULT_PROMPT,
|
||||
comments: [],
|
||||
},
|
||||
cursor: "end",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handled: false,
|
||||
historyIndex: input.historyIndex,
|
||||
savedPrompt: input.savedPrompt,
|
||||
}
|
||||
}
|
||||
+8
-14
@@ -1,15 +1,15 @@
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import {
|
||||
clonePromptHistoryComments,
|
||||
clonePromptParts,
|
||||
prependHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./history"
|
||||
} from "./entry"
|
||||
|
||||
export type PromptInputHistory = {
|
||||
export type ComposerHistoryStore = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
}
|
||||
@@ -31,12 +31,12 @@ export function upgradeHistoryState(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptInputHistoryStore(
|
||||
function createComposerHistoryStore(
|
||||
normal: Store<PromptHistoryState>,
|
||||
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||
shell: Store<PromptHistoryState>,
|
||||
setShell: SetStoreFunction<PromptHistoryState>,
|
||||
): PromptInputHistory {
|
||||
): ComposerHistoryStore {
|
||||
return {
|
||||
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
|
||||
add(prompt, mode, comments) {
|
||||
@@ -49,13 +49,7 @@ function createPromptInputHistoryStore(
|
||||
}
|
||||
}
|
||||
|
||||
export function createPromptInputHistory(): PromptInputHistory {
|
||||
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
|
||||
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
|
||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
}
|
||||
|
||||
export function createPersistedPromptInputHistory() {
|
||||
export function createComposerHistory() {
|
||||
const [normal, setNormal, normalInit] = persisted(
|
||||
{ ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState },
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
@@ -64,7 +58,7 @@ export function createPersistedPromptInputHistory() {
|
||||
{ ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState },
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
const history = createComposerHistoryStore(normal, setNormal, shell, setShell)
|
||||
return {
|
||||
...history,
|
||||
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
|
||||
+125
-267
@@ -1,86 +1,33 @@
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import type { ReferenceInfo } from "@opencode-ai/client/promise"
|
||||
import { createEffect, createMemo, on, Show } from "solid-js"
|
||||
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||
import type { PromptInputProps } from "@/components/prompt-input/contracts"
|
||||
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
|
||||
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
|
||||
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
|
||||
import { createPromptSubmit } from "@/components/prompt-input/submit"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { createComponent, createEffect, createMemo, on } from "solid-js"
|
||||
import type { ComposerSuggestion } from "./types"
|
||||
import { createComposerEditor, createComposerEditorState, type ComposerEditorModel } from "./editor/interaction"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/workspaces/files/model"
|
||||
import { useComments } from "@/composer/comments"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
|
||||
import {
|
||||
createPromptInputV2Controller,
|
||||
createPromptInputV2State,
|
||||
type PromptInputV2Interaction,
|
||||
} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { ComposerAdapter, ComposerControls } from "./adapter"
|
||||
import type { ImageAttachmentPart } from "./state"
|
||||
import { normalizePromptHistoryEntry, type PromptHistoryComment } from "./history/entry"
|
||||
import { createComposerHistory } from "./history/store"
|
||||
import { composerPlaceholder } from "./placeholder"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
|
||||
export type PromptInputV2ComposerProps = {
|
||||
class?: string
|
||||
controller: PromptInputV2ComposerController
|
||||
borderUnderlay?: boolean
|
||||
accentSubmit?: boolean
|
||||
export type ComposerModel = ComposerEditorModel & {
|
||||
readonly model: ComposerControls["model"]
|
||||
}
|
||||
|
||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class">
|
||||
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
||||
readonly model: PromptInputProps["controls"]["model"]
|
||||
}
|
||||
|
||||
export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInputV2
|
||||
controller={props.controller}
|
||||
accentSubmit={props.accentSubmit}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
variantControlVisible={!props.controller.model.loading}
|
||||
attachKeybind={command.keybindParts("file.attach")}
|
||||
attachShortcut={command.keybind("file.attach")}
|
||||
modelControl={
|
||||
<PromptInputV2ModelControl
|
||||
loading={props.controller.model.loading}
|
||||
paid={props.controller.model.paid}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybindParts("model.choose")}
|
||||
model={props.controller.model.selection}
|
||||
providerID={props.controller.model.selection.current()?.provider?.id}
|
||||
modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
onClose={props.controller.restoreFocus}
|
||||
onUnpaidClick={() =>
|
||||
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||
export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const files = useFile()
|
||||
@@ -88,16 +35,20 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
const comments = useComments()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const prompt = props.state ?? usePrompt()
|
||||
const prompt = adapter.state
|
||||
let editor: HTMLDivElement | undefined
|
||||
|
||||
const interaction = createPromptInputV2State()
|
||||
const interaction = createComposerEditorState(prompt.mode.current())
|
||||
createEffect(
|
||||
on(adapter.ready, (ready) => {
|
||||
if (ready) interaction[1]("mode", prompt.mode.current())
|
||||
}),
|
||||
)
|
||||
const mode = () => interaction[0].mode
|
||||
const history = props.history ?? createPersistedPromptInputHistory()
|
||||
const tabs = () => props.controls.session.tabs
|
||||
const history = createComposerHistory()
|
||||
const tabs = () => adapter.controls().session.tabs
|
||||
const activeFileTab = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: files.pathFromTab,
|
||||
@@ -113,8 +64,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
return [...result, path]
|
||||
}, [])
|
||||
})
|
||||
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
|
||||
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
|
||||
const attachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
@@ -129,20 +78,9 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
.join("")
|
||||
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
|
||||
})
|
||||
const stopping = createMemo(() => working() && blank())
|
||||
const placeholder = createMemo(() =>
|
||||
promptPlaceholder({
|
||||
mode: mode(),
|
||||
commentCount: commentCount(),
|
||||
example: mode() === "shell" ? "git status" : "",
|
||||
suggest: false,
|
||||
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||
}),
|
||||
)
|
||||
const designPlaceholder = () =>
|
||||
promptDesignPlaceholder(mode(), placeholder(), (key, params) =>
|
||||
language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||
)
|
||||
const stopping = createMemo(() => adapter.working() && blank())
|
||||
const placeholder = () =>
|
||||
composerPlaceholder(mode(), (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never))
|
||||
|
||||
const historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
@@ -192,39 +130,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
)
|
||||
}
|
||||
|
||||
const accepting = createMemo(() => {
|
||||
const id = props.controls.session.id
|
||||
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
||||
return permission.isAutoAccepting(id, sdk().directory)
|
||||
})
|
||||
const submission =
|
||||
props.submission ??
|
||||
createPromptSubmit({
|
||||
prompt,
|
||||
info,
|
||||
imageAttachments: attachments,
|
||||
commentCount,
|
||||
autoAccept: accepting,
|
||||
mode,
|
||||
working,
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
promptLength,
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
resetHistoryNavigation: () => controller.resetHistory(),
|
||||
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||
setPopover: (popover) => {
|
||||
if (!popover) controller.dispatch({ type: "popover.close" })
|
||||
},
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
model: props.controls.model.selection,
|
||||
})
|
||||
|
||||
const referenceDescription = (reference: ReferenceInfo) =>
|
||||
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||
const references = createMemo(() =>
|
||||
@@ -273,10 +178,26 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
resource,
|
||||
})),
|
||||
)
|
||||
const context = createMemo<PromptInputV2Suggestion[]>(() => [
|
||||
const skills = createMemo(() => data.location.skill.list({ directory: sdk().directory }) ?? [])
|
||||
const context = createMemo<ComposerSuggestion[]>(() => [
|
||||
...references(),
|
||||
...props.controls.agents.available
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
...skills().map((skill) => ({
|
||||
id: `skill:${skill.id}`,
|
||||
kind: "skill" as const,
|
||||
label: `@${skill.id}`,
|
||||
description: skill.description,
|
||||
mention: {
|
||||
type: "skill" as const,
|
||||
id: Skill.ID.make(skill.id),
|
||||
name: Skill.Name.make(skill.name),
|
||||
content: `@${skill.id}`,
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
})),
|
||||
...adapter
|
||||
.controls()
|
||||
.agents.available.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
.map((agent) => ({
|
||||
id: `agent:${agent.name}`,
|
||||
kind: "agent" as const,
|
||||
@@ -311,7 +232,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
type: "builtin" as const,
|
||||
})),
|
||||
])
|
||||
const commands = createMemo<PromptInputV2Suggestion[]>(() =>
|
||||
const commands = createMemo<ComposerSuggestion[]>(() =>
|
||||
slashCommands().map((item) => ({
|
||||
id: item.id,
|
||||
kind: "command",
|
||||
@@ -322,11 +243,46 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
keybind: command.keybindParts(item.id),
|
||||
})),
|
||||
)
|
||||
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
||||
const controller = createPromptInputV2Controller({
|
||||
store: () => prompt.capture().store,
|
||||
const variants = createMemo(() => ["default", ...adapter.controls().model.selection.variant.list()])
|
||||
const submission = createComposerSubmit({
|
||||
adapter,
|
||||
mode,
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
resetHistory: () => controller.resetHistory(),
|
||||
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||
closePopover: () => controller.dispatch({ type: "popover.close" }),
|
||||
notify: {
|
||||
missingSelection: () =>
|
||||
showToast({
|
||||
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||
description: language.t("prompt.toast.modelAgentRequired.description"),
|
||||
}),
|
||||
failed: (kind, error) =>
|
||||
showToast({
|
||||
title: language.t(
|
||||
kind === "shell"
|
||||
? "prompt.toast.shellSendFailed.title"
|
||||
: kind === "command"
|
||||
? "prompt.toast.commandSendFailed.title"
|
||||
: "prompt.toast.promptSendFailed.title",
|
||||
),
|
||||
description:
|
||||
kind === "command"
|
||||
? formatServerError(error, language.t, language.t("common.requestFailed"))
|
||||
: composerErrorMessage(language, error),
|
||||
}),
|
||||
},
|
||||
comments: {
|
||||
capture: historyComments,
|
||||
clear: comments.clear,
|
||||
restore: restoreHistoryComments,
|
||||
},
|
||||
})
|
||||
const controller = createComposerEditor({
|
||||
store: prompt.store,
|
||||
state: interaction,
|
||||
identity: () => prompt.capture(),
|
||||
history: {
|
||||
entries: (mode) =>
|
||||
history.entries(mode).map((value) => {
|
||||
@@ -351,14 +307,14 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||
},
|
||||
openAttachment: (attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
|
||||
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })),
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item) openComment(item, props, layout, files, comments)
|
||||
if (item) openComment(item, adapter.controls(), layout, files, comments)
|
||||
},
|
||||
onEditor(element) {
|
||||
editor = element as HTMLDivElement
|
||||
props.ref?.(editor)
|
||||
if (adapter.kind === "active-session") adapter.setEditor(editor)
|
||||
},
|
||||
onSuggestionSelect(item) {
|
||||
if (item.kind !== "command") return
|
||||
@@ -387,34 +343,35 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
store: platform.draftStore?.putBlob,
|
||||
},
|
||||
view: {
|
||||
placeholder: designPlaceholder,
|
||||
placeholder,
|
||||
get agent() {
|
||||
return props.controls.agents.visible && props.controls.agents.options.length > 0
|
||||
const agents = adapter.controls().agents
|
||||
return agents.visible && agents.options.length > 0
|
||||
? {
|
||||
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
|
||||
current: () => props.controls.agents.current,
|
||||
onSelect: (value: string) => props.controls.agents.select(value),
|
||||
options: () => adapter.controls().agents.options.map((name) => ({ id: name, label: name })),
|
||||
current: () => adapter.controls().agents.current,
|
||||
onSelect: (value: string) => adapter.controls().agents.select(value),
|
||||
keybind: () => command.keybindParts("agent.cycle"),
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
variant: {
|
||||
options: () => variants().map((value) => ({ id: value, label: value })),
|
||||
current: () => props.controls.model.selection.variant.current() ?? "default",
|
||||
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
|
||||
current: () => adapter.controls().model.selection.variant.current() ?? "default",
|
||||
onSelect: (value) => adapter.controls().model.selection.variant.set(value === "default" ? undefined : value),
|
||||
keybind: () => command.keybindParts("model.variant.cycle"),
|
||||
},
|
||||
submit: {
|
||||
stopping,
|
||||
working,
|
||||
onSubmit: () => void submission.handleSubmit(new Event("submit")),
|
||||
onStop: () => void submission.abort(),
|
||||
working: adapter.working,
|
||||
onSubmit: () => void submission.submit(new Event("submit")),
|
||||
onStop: () => void submission.stop(),
|
||||
},
|
||||
},
|
||||
})
|
||||
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
||||
Object.defineProperty(controller, "model", { get: () => adapter.controls().model })
|
||||
|
||||
command.register("prompt-input", () => [
|
||||
command.register("composer-editor", () => [
|
||||
{
|
||||
id: "file.attach",
|
||||
title: language.t("prompt.action.attachFile"),
|
||||
@@ -441,122 +398,23 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.edit?.id,
|
||||
(id) => {
|
||||
const edit = props.edit
|
||||
if (!id || !edit) return
|
||||
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
||||
edit.context.forEach((item) =>
|
||||
prompt.context.add({
|
||||
type: item.type,
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
}),
|
||||
)
|
||||
controller.dispatch({ type: "mode.normal" })
|
||||
controller.resetHistory()
|
||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||
controller.restoreFocus()
|
||||
props.onEditLoaded?.()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return controller as PromptInputV2ComposerController
|
||||
return controller as ComposerModel
|
||||
}
|
||||
|
||||
function PromptInputV2ModelControl(props: {
|
||||
loading: boolean
|
||||
paid: boolean
|
||||
title: string
|
||||
keybind: string[]
|
||||
model: PromptInputV2ComposerController["model"]["selection"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
onClose: () => void
|
||||
onUnpaidClick: () => void
|
||||
}) {
|
||||
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
|
||||
const content = () => (
|
||||
<>
|
||||
<Show when={props.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate leading-4">{props.modelName}</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
<Icon name="chevron-down" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<Show when={!props.loading}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.title}
|
||||
<Keybind keys={props.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.paid}
|
||||
fallback={
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
data-control-type="dialog"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||
style={{ height: "28px" }}
|
||||
onClick={props.onUnpaidClick}
|
||||
>
|
||||
{content()}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ModelSelectorPopoverV2
|
||||
model={props.model}
|
||||
trigger={(triggerProps) => (
|
||||
<Button
|
||||
{...triggerProps}
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
style={{ height: "28px" }}
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||
data-action="prompt-model"
|
||||
data-control-type="popover"
|
||||
>
|
||||
{content()}
|
||||
</Button>
|
||||
)}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
</Show>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
)
|
||||
function composerErrorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
|
||||
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
|
||||
return error.message
|
||||
}
|
||||
if (error && typeof error === "object" && "data" in error) {
|
||||
const data = (error as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
|
||||
function openComment(
|
||||
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
||||
props: PromptInputV2ControllerProps,
|
||||
controls: ComposerControls,
|
||||
layout: ReturnType<typeof useLayout>,
|
||||
files: ReturnType<typeof useFile>,
|
||||
comments: ReturnType<typeof useComments>,
|
||||
@@ -575,16 +433,16 @@ function openComment(
|
||||
})
|
||||
}
|
||||
const review = item.commentOrigin === "review"
|
||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||
if (!controls.session.reviewPanel.opened()) controls.session.reviewPanel.open()
|
||||
if (review) {
|
||||
layout.fileTree.setTab("changes")
|
||||
props.controls.session.tabs.setActive("review")
|
||||
controls.session.tabs.setActive("review")
|
||||
queueFocus()
|
||||
return
|
||||
}
|
||||
layout.fileTree.setTab("all")
|
||||
const tab = files.tab(item.path)
|
||||
void props.controls.session.tabs.open(tab)
|
||||
props.controls.session.tabs.setActive(tab)
|
||||
void controls.session.tabs.open(tab)
|
||||
controls.session.tabs.setActive(tab)
|
||||
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
|
||||
}
|
||||
@@ -2,31 +2,30 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { ServerConnection } from "./servers"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { useWorkspaceLocation } from "./location"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { requireServerKey } from "@/shell/routes/session"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useTabs, type Tab } from "@/shell/tabs/tabs"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import {
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
createComposerReady,
|
||||
createComposerState,
|
||||
type ContextItem,
|
||||
type FileContextItem,
|
||||
type Prompt,
|
||||
type PromptModel,
|
||||
type PromptScope,
|
||||
type PromptSession,
|
||||
} from "./prompt-state"
|
||||
type ComposerState,
|
||||
} from "./state"
|
||||
|
||||
export {
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
createPromptState,
|
||||
createComposerReady,
|
||||
createComposerState,
|
||||
createMemoryComposerState,
|
||||
DEFAULT_PROMPT,
|
||||
isCommentItem,
|
||||
isPromptEqual,
|
||||
} from "./prompt-state"
|
||||
} from "./state"
|
||||
export type {
|
||||
AgentPart,
|
||||
ContentPart,
|
||||
@@ -36,11 +35,11 @@ export type {
|
||||
ImageAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
PromptStore,
|
||||
ComposerStore,
|
||||
PromptScope,
|
||||
PromptSession,
|
||||
ComposerState,
|
||||
TextPart,
|
||||
} from "./prompt-state"
|
||||
} from "./state"
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_PROMPT_SESSIONS = 20
|
||||
@@ -59,19 +58,19 @@ function scopeKey(scope: PromptScope) {
|
||||
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
||||
}
|
||||
|
||||
type PromptCacheEntry = {
|
||||
value: PromptSession
|
||||
type ComposerCacheEntry = {
|
||||
value: ComposerState
|
||||
dispose: VoidFunction
|
||||
}
|
||||
|
||||
export const createTabPromptState = (
|
||||
export const createTabComposerState = (
|
||||
tabs: ReturnType<typeof useTabs>,
|
||||
tab: Tab,
|
||||
...args: Parameters<typeof createPromptSession>
|
||||
) => tabs.state(tab, "prompt", () => createPromptSession(...args))
|
||||
...args: Parameters<typeof createComposerState>
|
||||
) => tabs.state(tab, "prompt", () => createComposerState(...args))
|
||||
|
||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||
name: "Prompt",
|
||||
export const { use: useComposerState, provider: ComposerPersistenceProvider } = createSimpleContext({
|
||||
name: "ComposerState",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams<{ serverKey?: string; id?: string }>()
|
||||
@@ -79,7 +78,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const serverSDK = useServerSDK()
|
||||
const tabs = useTabs()
|
||||
const cache = new Map<string, PromptCacheEntry>()
|
||||
const cache = new Map<string, ComposerCacheEntry>()
|
||||
|
||||
const disposeAll = () => {
|
||||
for (const entry of cache.values()) entry.dispose()
|
||||
@@ -105,7 +104,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
|
||||
const current = selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
|
||||
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK.scope, scope)
|
||||
if (current) return createTabComposerState(tabs, current, target?.scope ?? serverSDK.scope, scope)
|
||||
|
||||
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
|
||||
const existing = cache.get(key)
|
||||
@@ -117,7 +116,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
|
||||
const entry = createRoot(
|
||||
(dispose) => ({
|
||||
value: createPromptSession(target?.scope ?? serverSDK.scope, scope),
|
||||
value: createComposerState(target?.scope ?? serverSDK.scope, scope),
|
||||
dispose,
|
||||
}),
|
||||
owner,
|
||||
@@ -131,7 +130,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
const session = createMemo(() => load(scope()))
|
||||
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||
scope ? load(scope, target) : session()
|
||||
const ready = createPromptReady(session)
|
||||
const ready = createComposerReady(session)
|
||||
|
||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||
createResource(
|
||||
@@ -150,7 +149,6 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
pick(scope, target).capture(),
|
||||
current: withSuspense(() => session().current()),
|
||||
cursor: withSuspense(() => session().cursor()),
|
||||
dirty: withSuspense(() => session().dirty()),
|
||||
model: {
|
||||
current: withSuspense(() => session().model.current()),
|
||||
set: (model: PromptModel | undefined) => session().model.set(model),
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { composerPlaceholder } from "./placeholder"
|
||||
|
||||
describe("Composer placeholder", () => {
|
||||
const t = (key: string, params?: Record<string, string>) =>
|
||||
`${key}${params?.example ? `:${params.example}` : ""}${params?.slash ?? ""}${params?.at ?? ""}`
|
||||
|
||||
test("uses the shell command placeholder in shell mode", () => {
|
||||
expect(composerPlaceholder("shell", t)).toBe("prompt.placeholder.shell:git status")
|
||||
})
|
||||
|
||||
test("uses the command and context hint in normal mode", () => {
|
||||
expect(composerPlaceholder("normal", t)).toBe("ui.promptInput.placeholder.normal/@")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export function composerPlaceholder(
|
||||
mode: "normal" | "shell",
|
||||
t: (key: string, params?: Record<string, string>) => string,
|
||||
) {
|
||||
if (mode === "shell") return t("prompt.placeholder.shell", { example: "git status" })
|
||||
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
|
||||
}
|
||||
@@ -105,4 +105,24 @@ describe("extractPromptFromMessage", () => {
|
||||
|
||||
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
|
||||
})
|
||||
|
||||
test("restores skill mentions as structured Composer parts", () => {
|
||||
const message = {
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "Use @review",
|
||||
skills: [{ id: "review", name: "Review", mention: { text: "@review", start: 4, end: 11 } }],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
|
||||
expect(extractPromptFromMessage(message)).toMatchObject([
|
||||
{ type: "text", content: "Use " },
|
||||
{
|
||||
type: "skill",
|
||||
id: "review",
|
||||
name: "Review",
|
||||
content: "@review",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { createLegacyBlobReference } from "@/utils/draft-store"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { readPromptPresentation } from "./comment-note"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
|
||||
type Inline =
|
||||
| {
|
||||
@@ -16,6 +17,8 @@ type Inline =
|
||||
startChar: number
|
||||
endChar: number
|
||||
}
|
||||
mime?: string
|
||||
filename?: string
|
||||
}
|
||||
| {
|
||||
type: "agent"
|
||||
@@ -24,6 +27,14 @@ type Inline =
|
||||
value: string
|
||||
name: string
|
||||
}
|
||||
| {
|
||||
type: "skill"
|
||||
start: number
|
||||
end: number
|
||||
value: string
|
||||
id: Skill.ID
|
||||
name: Skill.Name
|
||||
}
|
||||
|
||||
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
|
||||
const queryIndex = url.indexOf("?")
|
||||
@@ -95,6 +106,18 @@ export function extractPromptFromMessage(
|
||||
name: agent.name,
|
||||
})
|
||||
}
|
||||
for (const attached of message.skills ?? []) {
|
||||
const mention = attached.mention
|
||||
if (!mention) continue
|
||||
inline.push({
|
||||
type: "skill",
|
||||
start: mention.start,
|
||||
end: mention.end,
|
||||
value: mention.text,
|
||||
id: Skill.ID.make(attached.id),
|
||||
name: Skill.Name.make(attached.name),
|
||||
})
|
||||
}
|
||||
return buildPrompt(text, inline, images)
|
||||
}
|
||||
|
||||
@@ -132,6 +155,8 @@ function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
selection: item.selection,
|
||||
mime: item.mime,
|
||||
filename: item.filename,
|
||||
}
|
||||
result.push(attachment)
|
||||
position += content.length
|
||||
@@ -150,6 +175,20 @@ function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart
|
||||
position += content.length
|
||||
}
|
||||
|
||||
const pushSkill = (item: Extract<Inline, { type: "skill" }>) => {
|
||||
const content = item.value
|
||||
const skill: SkillPart = {
|
||||
type: "skill",
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
content,
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
}
|
||||
result.push(skill)
|
||||
position += content.length
|
||||
}
|
||||
|
||||
for (const item of inline) {
|
||||
if (item.start < 0 || item.end < item.start) continue
|
||||
|
||||
@@ -165,6 +204,7 @@ function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart
|
||||
|
||||
if (item.type === "file") pushFile(item)
|
||||
if (item.type === "agent") pushAgent(item)
|
||||
if (item.type === "skill") pushSkill(item)
|
||||
|
||||
cursor = end
|
||||
}
|
||||
+29
-2
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { buildPromptRequest } from "./build-prompt-request"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
|
||||
describe("buildPromptRequest", () => {
|
||||
test("builds text, files, and agents from the prompt", () => {
|
||||
@@ -318,4 +319,30 @@ describe("buildPromptRequest", () => {
|
||||
// Should preserve .. segments (backend normalizes)
|
||||
expect(file!.uri).toContain("/..")
|
||||
})
|
||||
|
||||
test("keeps skill mentions out of file attachments", () => {
|
||||
const skill = {
|
||||
id: "skill-review",
|
||||
name: "review",
|
||||
}
|
||||
const result = buildPromptRequest({
|
||||
prompt: [
|
||||
{
|
||||
type: "skill",
|
||||
id: Skill.ID.make(skill.id),
|
||||
name: Skill.Name.make(skill.name),
|
||||
content: "@review",
|
||||
start: 0,
|
||||
end: 7,
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@review",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
expect(result.files).toEqual([])
|
||||
expect(result.skills).toEqual([{ id: skill.id, name: skill.name, mention: { start: 0, end: 7, text: "@review" } }])
|
||||
})
|
||||
})
|
||||
+12
-4
@@ -1,8 +1,8 @@
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { formatCommentNote, type PromptComment } from "@/utils/comment-note"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
import { encodeFilePath } from "@/workspaces/files/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import { formatCommentNote, type PromptComment } from "@/composer/comment-note"
|
||||
|
||||
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
|
||||
type PromptRequest = {
|
||||
@@ -10,6 +10,7 @@ type PromptRequest = {
|
||||
displayText: string
|
||||
files: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
|
||||
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
|
||||
skills: { id: string; name: string; mention?: { start: number; end: number; text: string } }[]
|
||||
comments: PromptComment[]
|
||||
}
|
||||
|
||||
@@ -54,8 +55,14 @@ const parseCommentMentions = (comment: string) => {
|
||||
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type === "skill"
|
||||
|
||||
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
|
||||
const skills = input.prompt.filter(isSkillAttachment).map((attachment) => ({
|
||||
id: attachment.id,
|
||||
name: attachment.name,
|
||||
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
|
||||
}))
|
||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||
const path = absolute(input.sessionDirectory, attachment.path)
|
||||
return {
|
||||
@@ -110,6 +117,7 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
|
||||
displayText: input.text,
|
||||
files: [...files, ...context, ...images],
|
||||
agents,
|
||||
skills,
|
||||
comments,
|
||||
}
|
||||
}
|
||||
+53
-25
@@ -1,15 +1,53 @@
|
||||
import { batch, createMemo, startTransition } from "solid-js"
|
||||
import { useModels } from "@/context/models"
|
||||
import type { ModelKey, ModelSelection } from "@/context/local"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
|
||||
import type { ComposerControls } from "./adapter"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
|
||||
import { useModels } from "@/providers/models/models"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
|
||||
import { useComposerState } from "./persistence"
|
||||
|
||||
export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) {
|
||||
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const view = layout.view(input.sessionKey)
|
||||
|
||||
return createMemo<ComposerControls>(() => {
|
||||
return {
|
||||
agents: {
|
||||
available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []),
|
||||
options: local.agent.list().map((agent) => agent.name),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
visible: local.agent.visible(),
|
||||
select: local.agent.set,
|
||||
},
|
||||
model: {
|
||||
selection: input.model ?? local.model,
|
||||
paid: providers.paid().length > 0,
|
||||
loading:
|
||||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
|
||||
!providers.ready(),
|
||||
},
|
||||
session: {
|
||||
tabs: layout.tabs(input.sessionKey),
|
||||
reviewPanel: view.reviewPanel,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createComposerModelSelection(input: {
|
||||
agent: () => { model?: ModelKey; variant?: string } | undefined
|
||||
}) {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const models = useModels()
|
||||
const prompt = usePrompt()
|
||||
const prompt = useComposerState()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
@@ -17,26 +55,17 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
|
||||
const provider = providers.all().get(model.providerID)
|
||||
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||
}
|
||||
|
||||
const configured = () => {
|
||||
// TODO: Restore the configured model fallback when current location data exposes config.
|
||||
return undefined
|
||||
}
|
||||
|
||||
const recent = () => models.recent.list().find(valid)
|
||||
const fallback = () => {
|
||||
return providers.connected().flatMap((provider) => {
|
||||
const fallback = () =>
|
||||
providers.connected().flatMap((provider) => {
|
||||
const modelID = Object.values(provider.models)[0]?.id
|
||||
return modelID ? [{ providerID: provider.id, modelID }] : []
|
||||
})[0]
|
||||
}
|
||||
|
||||
const current = () => {
|
||||
const key = [prompt.model.current(), input.agent()?.model, configured(), recent(), fallback()].find(
|
||||
const key = [prompt.model.current(), input.agent()?.model, recent(), fallback()].find(
|
||||
(item): item is ModelKey => !!item && valid(item),
|
||||
)
|
||||
if (!key) return
|
||||
return models.find(key)
|
||||
return key ? models.find(key) : undefined
|
||||
}
|
||||
const recentModels = createMemo(() =>
|
||||
models.recent
|
||||
@@ -44,7 +73,6 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
|
||||
.map(models.find)
|
||||
.filter((item): item is NonNullable<typeof item> => !!item),
|
||||
)
|
||||
|
||||
const selection = {
|
||||
ready: models.ready,
|
||||
current,
|
||||
@@ -60,7 +88,7 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
|
||||
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
|
||||
},
|
||||
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
startTransition(() =>
|
||||
void startTransition(() =>
|
||||
batch(() => {
|
||||
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
|
||||
if (!item) return
|
||||
@@ -100,7 +128,7 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
|
||||
return Object.keys(current()?.variants ?? {})
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
startTransition(() =>
|
||||
void startTransition(() =>
|
||||
batch(() => {
|
||||
const model = current()
|
||||
if (!model) return
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { createMemoryComposerState, DEFAULT_PROMPT, parseComposerStore } from "./state"
|
||||
|
||||
describe("prompt state initialization", () => {
|
||||
test("initializes prompt text, cursor, and model together", () => {
|
||||
createRoot((dispose) => {
|
||||
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
|
||||
const prompt = createMemoryComposerState({ prompt: "hello", model })
|
||||
|
||||
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
|
||||
expect(prompt.cursor()).toBe(5)
|
||||
expect(prompt.model.current()).toEqual(model)
|
||||
expect(prompt.model.current()).not.toBe(model)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("uses the default prompt without initial values", () => {
|
||||
createRoot((dispose) => {
|
||||
const prompt = createMemoryComposerState()
|
||||
|
||||
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
|
||||
expect(prompt.cursor()).toBeUndefined()
|
||||
expect(prompt.model.current()).toBeUndefined()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("parses persisted state into one trusted current shape", () => {
|
||||
const parsed = parseComposerStore({
|
||||
prompt: [
|
||||
{ type: "text", content: "hello", start: 0, end: 5 },
|
||||
{ type: "skill", id: "effect", name: "Effect", content: "@effect", start: 5, end: 12 },
|
||||
{ type: "image", id: "broken", filename: "broken.png", mime: "image/png", blob: { id: 42 } },
|
||||
{
|
||||
type: "image",
|
||||
id: "missing-blob",
|
||||
filename: "missing.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "content-hash-without-a-url" },
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
id: "invalid-url",
|
||||
filename: "invalid.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "hash", url: "relative-url" },
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
id: "legacy",
|
||||
filename: "legacy.png",
|
||||
mime: "image/png",
|
||||
dataUrl: "data:image/png;base64,AAA",
|
||||
},
|
||||
],
|
||||
cursor: -2,
|
||||
model: { providerID: "anthropic", modelID: "claude", variant: "high" },
|
||||
retry: { id: "invalid", agent: "build", providerID: "anthropic", modelID: "claude" },
|
||||
context: {
|
||||
items: [
|
||||
{
|
||||
type: "file",
|
||||
path: "src/app.ts",
|
||||
selection: { startLine: 1, startChar: 0, endLine: 2, endChar: 3 },
|
||||
comment: "Check this",
|
||||
key: "untrusted",
|
||||
},
|
||||
{ type: "file", path: 42 },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(parsed).toEqual({
|
||||
prompt: [
|
||||
{ type: "text", content: "hello", start: 0, end: 5 },
|
||||
{
|
||||
type: "skill",
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
content: "@effect",
|
||||
start: 5,
|
||||
end: 12,
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
id: "legacy",
|
||||
filename: "legacy.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "data:image/png;base64,AAA", url: "data:image/png;base64,AAA" },
|
||||
},
|
||||
],
|
||||
cursor: 0,
|
||||
model: { providerID: "anthropic", modelID: "claude", variant: "high" },
|
||||
context: {
|
||||
items: [
|
||||
{
|
||||
type: "file",
|
||||
path: "src/app.ts",
|
||||
selection: { startLine: 1, startChar: 0, endLine: 2, endChar: 3 },
|
||||
comment: "Check this",
|
||||
key: expect.stringMatching(/^file:src\/app\.ts:1:2:c=/),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(parseComposerStore("not an object")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,506 @@
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import type { BlobReference } from "@/runtime/persistence/drafts"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
type FilePartSourceText = { value: string; start: number; end: number }
|
||||
type FilePartSource =
|
||||
| { text: FilePartSourceText; type: "file"; path: string }
|
||||
| {
|
||||
text: FilePartSourceText
|
||||
type: "symbol"
|
||||
path: string
|
||||
range: { start: { line: number; character: number }; end: { line: number; character: number } }
|
||||
name: string
|
||||
kind: number
|
||||
}
|
||||
| { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
mime?: string
|
||||
filename?: string
|
||||
url?: string
|
||||
source?: FilePartSource
|
||||
}
|
||||
|
||||
export interface AgentPart extends PartBase {
|
||||
type: "agent"
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SkillPart extends PartBase {
|
||||
type: "skill"
|
||||
id: Skill.ID
|
||||
name: Skill.Name
|
||||
}
|
||||
|
||||
export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
blob: BlobReference
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | SkillPart | ImageAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export type PromptModel = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string | null
|
||||
}
|
||||
|
||||
export type FileContextItem = {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type ContextItem = FileContextItem
|
||||
export type PromptScope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
export type ComposerStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
model?: PromptModel
|
||||
mode?: "normal" | "shell"
|
||||
retry?: {
|
||||
id: SessionMessage.ID
|
||||
agent: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
}
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}
|
||||
|
||||
type InitialPrompt = {
|
||||
prompt?: string
|
||||
model?: PromptModel
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: FileSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
if (part.type === "skill") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
function contextItemKey(item: ContextItem) {
|
||||
if (item.type !== "file") return item.type
|
||||
const start = item.selection?.startLine
|
||||
const end = item.selection?.endLine
|
||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||
|
||||
if (item.commentID) return `${key}:c=${item.commentID}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return key
|
||||
const digest = checksum(comment) ?? comment
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||
return item.type === "file" && !!item.comment?.trim()
|
||||
}
|
||||
|
||||
function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
|
||||
return {
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
const next = clonePrompt(prompt)
|
||||
batch(() => {
|
||||
setStore("prompt", next)
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
setStore("retry", undefined)
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
batch(() => {
|
||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||
setStore("cursor", 0)
|
||||
setStore("retry", undefined)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function composerTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||
const target =
|
||||
"draftID" in scope
|
||||
? Persist.prompt(Persist.draft(scope.draftID, "prompt"))
|
||||
: Persist.prompt({
|
||||
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
|
||||
...(serverScope === ServerScope.local
|
||||
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
|
||||
: {}),
|
||||
})
|
||||
return { ...target, migrate: parseComposerStore }
|
||||
}
|
||||
|
||||
function initialComposerStore(initial?: InitialPrompt): ComposerStore {
|
||||
const text = initial?.prompt
|
||||
return {
|
||||
prompt:
|
||||
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
|
||||
cursor: text === undefined ? undefined : text.length,
|
||||
model: initial?.model ? { ...initial.model } : undefined,
|
||||
context: {
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function parseComposerStore(value: unknown): ComposerStore | undefined {
|
||||
if (!record(value)) return
|
||||
const prompt = Array.isArray(value.prompt) ? value.prompt.flatMap(parsePart) : []
|
||||
const context = record(value.context) && Array.isArray(value.context.items) ? value.context.items : []
|
||||
const model = parseModel(value.model)
|
||||
const retry = parseRetry(value.retry)
|
||||
return {
|
||||
prompt: prompt.length ? prompt : clonePrompt(DEFAULT_PROMPT),
|
||||
...(typeof value.cursor === "number" && Number.isFinite(value.cursor) ? { cursor: Math.max(0, value.cursor) } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(value.mode === "normal" || value.mode === "shell" ? { mode: value.mode } : {}),
|
||||
...(retry ? { retry } : {}),
|
||||
context: {
|
||||
items: context.flatMap((item) => {
|
||||
const parsed = parseContextItem(item)
|
||||
return parsed ? [{ ...parsed, key: contextItemKey(parsed) }] : []
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetry(value: unknown): ComposerStore["retry"] {
|
||||
if (
|
||||
!record(value) ||
|
||||
typeof value.id !== "string" ||
|
||||
!value.id.startsWith("msg_") ||
|
||||
typeof value.agent !== "string" ||
|
||||
typeof value.providerID !== "string" ||
|
||||
typeof value.modelID !== "string"
|
||||
) {
|
||||
return
|
||||
}
|
||||
return {
|
||||
id: SessionMessage.ID.make(value.id),
|
||||
agent: value.agent,
|
||||
providerID: value.providerID,
|
||||
modelID: value.modelID,
|
||||
...(typeof value.variant === "string" ? { variant: value.variant } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePart(value: unknown): ContentPart[] {
|
||||
if (!record(value) || typeof value.type !== "string") return []
|
||||
if (value.type === "image") {
|
||||
const legacy = typeof value.dataUrl === "string" ? value.dataUrl : undefined
|
||||
const blobID = record(value.blob) && typeof value.blob.id === "string" ? value.blob.id : legacy
|
||||
const hydrated = record(value.blob) && typeof value.blob.url === "string" ? value.blob.url : undefined
|
||||
const blobURL =
|
||||
hydrated?.startsWith("blob:") || hydrated?.startsWith("data:")
|
||||
? hydrated
|
||||
: blobID?.startsWith("data:")
|
||||
? blobID
|
||||
: undefined
|
||||
if (
|
||||
typeof value.id !== "string" ||
|
||||
typeof value.filename !== "string" ||
|
||||
typeof value.mime !== "string" ||
|
||||
!blobID ||
|
||||
!blobURL
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: "image",
|
||||
id: value.id,
|
||||
filename: value.filename,
|
||||
mime: value.mime,
|
||||
blob: { id: blobID, url: blobURL },
|
||||
...(typeof value.sourcePath === "string" ? { sourcePath: value.sourcePath } : {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
if (typeof value.content !== "string" || typeof value.start !== "number" || typeof value.end !== "number") return []
|
||||
if (value.type === "text") return [{ type: "text", content: value.content, start: value.start, end: value.end }]
|
||||
if (value.type === "agent" && typeof value.name === "string") {
|
||||
return [{ type: "agent", name: value.name, content: value.content, start: value.start, end: value.end }]
|
||||
}
|
||||
if (value.type === "skill" && typeof value.id === "string" && typeof value.name === "string") {
|
||||
return [
|
||||
{
|
||||
type: "skill",
|
||||
id: Skill.ID.make(value.id),
|
||||
name: Skill.Name.make(value.name),
|
||||
content: value.content,
|
||||
start: value.start,
|
||||
end: value.end,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (value.type !== "file" || typeof value.path !== "string") return []
|
||||
const selection = parseSelection(value.selection)
|
||||
const source = parseSource(value.source)
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
path: value.path,
|
||||
content: value.content,
|
||||
start: value.start,
|
||||
end: value.end,
|
||||
...(typeof value.mime === "string" ? { mime: value.mime } : {}),
|
||||
...(typeof value.filename === "string" ? { filename: value.filename } : {}),
|
||||
...(typeof value.url === "string" ? { url: value.url } : {}),
|
||||
...(selection ? { selection } : {}),
|
||||
...(source ? { source } : {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function parseContextItem(value: unknown): ContextItem | undefined {
|
||||
if (!record(value) || value.type !== "file" || typeof value.path !== "string") return
|
||||
const selection = parseSelection(value.selection)
|
||||
const origin = value.commentOrigin === "review" || value.commentOrigin === "file" ? value.commentOrigin : undefined
|
||||
return {
|
||||
type: "file",
|
||||
path: value.path,
|
||||
...(selection ? { selection } : {}),
|
||||
...(typeof value.comment === "string" ? { comment: value.comment } : {}),
|
||||
...(typeof value.commentID === "string" ? { commentID: value.commentID } : {}),
|
||||
...(origin ? { commentOrigin: origin } : {}),
|
||||
...(typeof value.preview === "string" ? { preview: value.preview } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function parseModel(value: unknown): PromptModel | undefined {
|
||||
if (!record(value) || typeof value.providerID !== "string" || typeof value.modelID !== "string") return
|
||||
return {
|
||||
providerID: value.providerID,
|
||||
modelID: value.modelID,
|
||||
...(typeof value.variant === "string" || value.variant === null ? { variant: value.variant } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function parseSelection(value: unknown): FileSelection | undefined {
|
||||
if (!record(value)) return
|
||||
if (
|
||||
typeof value.startLine !== "number" ||
|
||||
typeof value.startChar !== "number" ||
|
||||
typeof value.endLine !== "number" ||
|
||||
typeof value.endChar !== "number"
|
||||
) {
|
||||
return
|
||||
}
|
||||
return {
|
||||
startLine: value.startLine,
|
||||
startChar: value.startChar,
|
||||
endLine: value.endLine,
|
||||
endChar: value.endChar,
|
||||
}
|
||||
}
|
||||
|
||||
function parseSource(value: unknown): FilePartSource | undefined {
|
||||
if (!record(value) || !record(value.text)) return
|
||||
if (
|
||||
typeof value.text.value !== "string" ||
|
||||
typeof value.text.start !== "number" ||
|
||||
typeof value.text.end !== "number"
|
||||
) {
|
||||
return
|
||||
}
|
||||
const text = { value: value.text.value, start: value.text.start, end: value.text.end }
|
||||
if (value.type === "file" && typeof value.path === "string") return { type: "file", path: value.path, text }
|
||||
if (value.type === "resource" && typeof value.clientName === "string" && typeof value.uri === "string") {
|
||||
return { type: "resource", clientName: value.clientName, uri: value.uri, text }
|
||||
}
|
||||
if (
|
||||
value.type !== "symbol" ||
|
||||
typeof value.path !== "string" ||
|
||||
typeof value.name !== "string" ||
|
||||
typeof value.kind !== "number" ||
|
||||
!record(value.range) ||
|
||||
!record(value.range.start) ||
|
||||
!record(value.range.end) ||
|
||||
typeof value.range.start.line !== "number" ||
|
||||
typeof value.range.start.character !== "number" ||
|
||||
typeof value.range.end.line !== "number" ||
|
||||
typeof value.range.end.character !== "number"
|
||||
) {
|
||||
return
|
||||
}
|
||||
return {
|
||||
type: "symbol",
|
||||
path: value.path,
|
||||
name: value.name,
|
||||
kind: value.kind,
|
||||
text,
|
||||
range: {
|
||||
start: { line: value.range.start.line, character: value.range.start.character },
|
||||
end: { line: value.range.end.line, character: value.range.end.character },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function createComposerStateValue(store: ComposerStore, setStore: SetStoreFunction<ComposerStore>) {
|
||||
const actions = createComposerActions(setStore)
|
||||
const clearRetry = () => setStore("retry", undefined)
|
||||
const value = {
|
||||
store: [() => store, setStore] as [Accessor<ComposerStore>, SetStoreFunction<ComposerStore>],
|
||||
current: () => store.prompt,
|
||||
cursor: () => store.cursor,
|
||||
model: {
|
||||
current: () => store.model,
|
||||
set: (model: PromptModel | undefined) => {
|
||||
setStore("model", model)
|
||||
clearRetry()
|
||||
},
|
||||
},
|
||||
mode: {
|
||||
current: () => store.mode ?? "normal",
|
||||
set: (mode: "normal" | "shell") => {
|
||||
setStore("mode", mode)
|
||||
clearRetry()
|
||||
},
|
||||
},
|
||||
retry: {
|
||||
current: () => store.retry,
|
||||
set: (retry: NonNullable<ComposerStore["retry"]>) => setStore("retry", retry),
|
||||
},
|
||||
context: {
|
||||
items: () => store.context.items,
|
||||
add(item: ContextItem) {
|
||||
const key = contextItemKey(item)
|
||||
if (store.context.items.find((x) => x.key === key)) return
|
||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||
clearRetry()
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||
clearRetry()
|
||||
},
|
||||
removeComment(path: string, commentID: string) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
||||
)
|
||||
clearRetry()
|
||||
},
|
||||
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.map((item) => {
|
||||
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
||||
const value = { ...item, ...next }
|
||||
return { ...value, key: contextItemKey(value) }
|
||||
}),
|
||||
)
|
||||
clearRetry()
|
||||
},
|
||||
replaceComments(items: FileContextItem[]) {
|
||||
setStore("context", "items", (current) => [
|
||||
...current.filter((item) => !isCommentItem(item)),
|
||||
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
])
|
||||
clearRetry()
|
||||
},
|
||||
},
|
||||
set: (prompt: Prompt, cursorPosition?: number) => actions.set(prompt, cursorPosition),
|
||||
reset: () => actions.reset(),
|
||||
capture: () => value,
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function createPersistedComposer(
|
||||
target: ReturnType<typeof composerTarget>,
|
||||
initial?: InitialPrompt,
|
||||
platform?: Platform,
|
||||
) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
target,
|
||||
createStore<ComposerStore>(initialComposerStore(initial)),
|
||||
platform,
|
||||
)
|
||||
return { ready, ...createComposerStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
export function createComposerState(
|
||||
serverScope: ServerScope,
|
||||
scope: PromptScope,
|
||||
initial?: InitialPrompt,
|
||||
platform?: Platform,
|
||||
) {
|
||||
return createPersistedComposer(composerTarget(serverScope, scope), initial, platform)
|
||||
}
|
||||
|
||||
export function createDraftComposerState(draftID: string, initial?: InitialPrompt) {
|
||||
return createPersistedComposer(
|
||||
{
|
||||
...Persist.prompt(Persist.draft(draftID, "prompt")),
|
||||
migrate: parseComposerStore,
|
||||
},
|
||||
initial,
|
||||
)
|
||||
}
|
||||
|
||||
export type ComposerState = ReturnType<typeof createComposerState>
|
||||
|
||||
export function createComposerReady(session: Accessor<ComposerState>) {
|
||||
return Object.defineProperty(() => session().ready(), "promise", {
|
||||
get: () => session().ready.promise,
|
||||
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
||||
}
|
||||
|
||||
export function createMemoryComposerState(initial?: InitialPrompt) {
|
||||
const [store, setStore] = createStore<ComposerStore>(initialComposerStore(initial))
|
||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||
return {
|
||||
ready,
|
||||
...createComposerStateValue(store, setStore),
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -1,9 +1,9 @@
|
||||
import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
import type { ComposerState, ContextItem, Prompt } from "./state"
|
||||
|
||||
type PromptTarget = ReturnType<ReturnType<typeof usePrompt>["capture"]>
|
||||
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
|
||||
|
||||
export function createPromptSubmissionState(input: {
|
||||
target: PromptTarget
|
||||
export function createComposerSubmission(input: {
|
||||
target: ComposerStateTarget
|
||||
prompt: Prompt
|
||||
context: (ContextItem & { key: string })[]
|
||||
}) {
|
||||
@@ -20,11 +20,11 @@ export function createPromptSubmissionState(input: {
|
||||
target.reset()
|
||||
cleared = target.current()
|
||||
},
|
||||
retarget(next: PromptTarget) {
|
||||
input.context.forEach(next.context.add)
|
||||
retarget(next: ComposerStateTarget) {
|
||||
input.context.forEach((item) => next.context.add(item))
|
||||
target = next
|
||||
},
|
||||
current: (value: PromptTarget) => target === value,
|
||||
current: (value: ComposerStateTarget) => target === value,
|
||||
restore() {
|
||||
if (cleared !== undefined && target.current() !== cleared) return
|
||||
return { target, prompt: input.prompt, context: input.context }
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
|
||||
import { createMemoryComposerState } from "./state"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
|
||||
const selectedModel = {
|
||||
id: "model-1",
|
||||
name: "Model 1",
|
||||
provider: { id: "provider-1" },
|
||||
} as NonNullable<ReturnType<ModelSelection["current"]>>
|
||||
|
||||
const selection = {
|
||||
ready: Object.assign(() => true, { promise: undefined }),
|
||||
current: () => selectedModel,
|
||||
recent: () => [selectedModel],
|
||||
list: () => [selectedModel],
|
||||
cycle() {},
|
||||
set() {},
|
||||
visible: () => true,
|
||||
setVisibility() {},
|
||||
variant: {
|
||||
configured: () => undefined,
|
||||
selected: () => "balanced",
|
||||
current: () => "balanced",
|
||||
list: () => ["balanced"],
|
||||
set() {},
|
||||
cycle() {},
|
||||
},
|
||||
} satisfies ModelSelection
|
||||
|
||||
function controls(): ComposerControls {
|
||||
return {
|
||||
agents: {
|
||||
available: [{ name: "build", mode: "primary" }],
|
||||
options: ["build"],
|
||||
current: "build",
|
||||
visible: true,
|
||||
select() {},
|
||||
},
|
||||
model: { selection, paid: true, loading: false },
|
||||
session: {
|
||||
tabs: { active: () => undefined, all: () => [], open() {}, setActive() {} },
|
||||
reviewPanel: { opened: () => false, open() {} },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function submitInput(
|
||||
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
mode: "normal" | "shell" = "normal",
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
mode: () => mode,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory() {},
|
||||
resetHistory() {},
|
||||
setMode() {},
|
||||
closePopover() {},
|
||||
notify,
|
||||
comments: { capture: () => [], clear() {}, restore() {} },
|
||||
})
|
||||
}
|
||||
|
||||
function session(input: {
|
||||
calls: string[]
|
||||
prompt: (value: Parameters<ComposerSession["data"]["session"]["prompt"]>[0]) => Promise<void>
|
||||
current?: ComposerSession["current"]
|
||||
admitted?: (messageID: string) => boolean
|
||||
shell?: () => Promise<unknown>
|
||||
command?: ComposerSession["api"]["command"]
|
||||
}): ComposerSession {
|
||||
return {
|
||||
id: "session-1",
|
||||
directory: "C:/repo",
|
||||
current: input.current ?? (() => undefined),
|
||||
admitted: input.admitted ?? (() => false),
|
||||
api: {
|
||||
switchAgent: async () => {
|
||||
input.calls.push("switch-agent")
|
||||
},
|
||||
switchModel: async () => {
|
||||
input.calls.push("switch-model")
|
||||
},
|
||||
shell: input.shell ?? (async () => undefined),
|
||||
command: input.command ?? (async () => undefined),
|
||||
},
|
||||
data: {
|
||||
location: { command: { list: () => [] } },
|
||||
session: {
|
||||
prompt: async (value) => {
|
||||
input.calls.push("prompt")
|
||||
await input.prompt(value)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("Composer submission", () => {
|
||||
test("sends one captured value with explicit delivery after selection switches", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
|
||||
const calls: string[] = []
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const target = session({
|
||||
calls,
|
||||
current: () => ({ agent: "plan", model: { id: "old", providerID: "old" } }),
|
||||
prompt: async (value) => admitted.resolve(value),
|
||||
})
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
|
||||
expect(calls).toEqual(["switch-agent", "switch-model", "prompt"])
|
||||
expect(request.delivery).toBe("steer")
|
||||
expect(request.text).toBe("ship it")
|
||||
expect(request.id).toMatch(/^msg_/)
|
||||
expect(request.metadata).toMatchObject({
|
||||
displayText: "ship it",
|
||||
agent: "build",
|
||||
model: { providerID: "provider-1", modelID: "model-1", variant: "balanced" },
|
||||
})
|
||||
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
})
|
||||
|
||||
test("starts and promotes a New Session once before admitting its first prompt", async () => {
|
||||
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
|
||||
const promoted = createMemoryComposerState().capture()
|
||||
const calls: string[] = []
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const target = session({ calls, prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state: draft,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {
|
||||
calls.push("submitted")
|
||||
},
|
||||
async start(_selection, submission) {
|
||||
calls.push("start")
|
||||
submission.retarget(promoted)
|
||||
return target
|
||||
},
|
||||
}
|
||||
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
|
||||
expect(calls).toEqual(["start", "submitted", "switch-agent", "switch-model", "prompt"])
|
||||
expect(request.delivery).toBe("steer")
|
||||
expect(request.text).toBe("first prompt")
|
||||
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
})
|
||||
|
||||
test("does not restore a prompt already acknowledged by the durable inbox", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "admitted prompt" }).capture()
|
||||
const checked = Promise.withResolvers<void>()
|
||||
const attempts: string[] = []
|
||||
const target = session({
|
||||
calls: [],
|
||||
admitted: () => {
|
||||
checked.resolve()
|
||||
return true
|
||||
},
|
||||
prompt: async (value) => {
|
||||
attempts.push(value.id ?? "")
|
||||
throw new Error("response lost")
|
||||
},
|
||||
})
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
await checked.promise
|
||||
|
||||
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(attempts).toHaveLength(2)
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
})
|
||||
|
||||
test("restores first-prompt comments into the promoted Session", async () => {
|
||||
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
|
||||
draft.store[1]("context", "items", [
|
||||
{
|
||||
key: "file:src/app.ts:1:1:comment",
|
||||
type: "file",
|
||||
path: "src/app.ts",
|
||||
comment: "Keep this comment",
|
||||
selection: { startLine: 1, startChar: 0, endLine: 1, endChar: 4 },
|
||||
},
|
||||
])
|
||||
expect(draft.context.items()).toHaveLength(1)
|
||||
const promoted = createMemoryComposerState().capture()
|
||||
const failed = Promise.withResolvers<void>()
|
||||
const target = session({
|
||||
calls: [],
|
||||
prompt: async () => undefined,
|
||||
shell: async () => Promise.reject(new Error("send failed")),
|
||||
})
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state: draft,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start(_selection, submission) {
|
||||
submission.retarget(promoted)
|
||||
return target
|
||||
},
|
||||
}
|
||||
|
||||
await submitInput(adapter, { missingSelection() {}, failed: () => failed.resolve() }, "shell").submit(
|
||||
new Event("submit"),
|
||||
)
|
||||
await failed.promise
|
||||
|
||||
expect(promoted.current()).toMatchObject([{ type: "text", content: "first prompt" }])
|
||||
expect(promoted.context.items()).toMatchObject([{ type: "file", path: "src/app.ts", comment: "Keep this comment" }])
|
||||
expect(promoted.mode.current()).toBe("shell")
|
||||
})
|
||||
|
||||
test("reuses the message ID when an unacknowledged admission is retried", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
|
||||
const attempts: string[] = []
|
||||
const first = Promise.withResolvers<void>()
|
||||
const second = Promise.withResolvers<void>()
|
||||
const target = session({
|
||||
calls: [],
|
||||
prompt: async (value) => {
|
||||
attempts.push(value.id ?? "")
|
||||
throw new Error("network unavailable")
|
||||
},
|
||||
})
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
const notify = {
|
||||
missingSelection() {},
|
||||
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
|
||||
}
|
||||
const submission = submitInput(adapter, notify)
|
||||
|
||||
await submission.submit(new Event("submit"))
|
||||
await first.promise
|
||||
await submission.submit(new Event("submit"))
|
||||
await second.promise
|
||||
|
||||
expect(attempts).toHaveLength(4)
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
|
||||
})
|
||||
|
||||
test("forwards structured mentions to custom commands", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
state.set([
|
||||
{ type: "text", content: "/review ", start: 0, end: 8 },
|
||||
{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 8, end: 19 },
|
||||
{ type: "text", content: " ", start: 19, end: 20 },
|
||||
{ type: "agent", name: "review", content: "@review", start: 20, end: 27 },
|
||||
{ type: "text", content: " ", start: 27, end: 28 },
|
||||
{
|
||||
type: "skill",
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
content: "@effect",
|
||||
start: 28,
|
||||
end: 35,
|
||||
},
|
||||
])
|
||||
const sent = Promise.withResolvers<Parameters<ComposerSession["api"]["command"]>[0]>()
|
||||
const target = session({
|
||||
calls: [],
|
||||
prompt: async () => undefined,
|
||||
command: async (value) => sent.resolve(value),
|
||||
})
|
||||
target.data.location.command.list = () => [{ name: "review", description: "Review changes", template: "" }]
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
const request = await sent.promise
|
||||
|
||||
expect(request.files).toMatchObject([{ name: "app.ts", mention: { text: "@src/app.ts" } }])
|
||||
expect(request.agents).toMatchObject([{ name: "review", mention: { text: "@review" } }])
|
||||
expect(request.skills).toMatchObject([{ id: "effect", name: "Effect", mention: { text: "@effect" } }])
|
||||
expect(request.delivery).toBe("steer")
|
||||
})
|
||||
|
||||
test("does not run an empty shell command from hidden attachments", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
state.set([
|
||||
{ type: "text", content: "", start: 0, end: 0 },
|
||||
{
|
||||
type: "image",
|
||||
id: "attachment",
|
||||
filename: "notes.txt",
|
||||
mime: "text/plain",
|
||||
blob: { id: "attachment", url: "data:text/plain;base64,bm90ZXM=" },
|
||||
},
|
||||
])
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => {
|
||||
throw new Error("shell should not run")
|
||||
},
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
await submitInput(adapter, undefined, "shell").submit(new Event("submit"))
|
||||
|
||||
expect(state.current().some((part) => part.type === "image")).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { clonePromptParts, type PromptHistoryComment } from "./history/entry"
|
||||
import type { ImageAttachmentPart, Prompt } from "./state"
|
||||
import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter"
|
||||
import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
import { blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
|
||||
const submitting = new WeakSet<object>()
|
||||
|
||||
type ComposerSubmission = {
|
||||
id: SessionMessage.ID
|
||||
mode: "normal" | "shell"
|
||||
prompt: Prompt
|
||||
context: ReturnType<ComposerAdapter["state"]["context"]["items"]>
|
||||
text: string
|
||||
images: ImageAttachmentPart[]
|
||||
selection: ComposerSelection
|
||||
delivery: "steer"
|
||||
}
|
||||
|
||||
type ComposerSubmitInput = {
|
||||
adapter: ComposerAdapter
|
||||
mode: Accessor<"normal" | "shell">
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
resetHistory: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
closePopover: () => void
|
||||
notify: {
|
||||
missingSelection: () => void
|
||||
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
|
||||
}
|
||||
comments: {
|
||||
capture: () => PromptHistoryComment[]
|
||||
clear: () => void
|
||||
restore: (comments: PromptHistoryComment[]) => void
|
||||
}
|
||||
}
|
||||
|
||||
export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const submit = async (event: globalThis.Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
const submission = createComposerSubmission({
|
||||
target: input.adapter.state,
|
||||
prompt: clonePromptParts(input.adapter.state.current()),
|
||||
context: input.adapter.state.context.items().map((item) => ({
|
||||
...item,
|
||||
selection: item.selection ? { ...item.selection } : undefined,
|
||||
})),
|
||||
})
|
||||
const value = readSubmission(input, submission.prompt, submission.context)
|
||||
if (!value) {
|
||||
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
|
||||
return
|
||||
}
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
submitting.add(input.adapter.state)
|
||||
const comments = input.comments.capture()
|
||||
|
||||
try {
|
||||
const session =
|
||||
input.adapter.kind === "active-session"
|
||||
? input.adapter.session()
|
||||
: await input.adapter.start(value.selection, submission)
|
||||
if (!session) return
|
||||
|
||||
input.addToHistory(value.prompt, value.mode)
|
||||
input.resetHistory()
|
||||
const restore = () => restoreSubmission(input, submission, value, comments)
|
||||
input.adapter.submitted()
|
||||
|
||||
if (value.mode === "shell") {
|
||||
clearSubmission(input, submission)
|
||||
void sendShell(session, value).catch((error) => failSubmission(input, session, "shell", error, restore))
|
||||
return
|
||||
}
|
||||
|
||||
const command = findCommand(session, value.text)
|
||||
if (command) {
|
||||
clearSubmission(input, submission)
|
||||
void sendCommand(session, value, command).catch((error) =>
|
||||
failSubmission(input, session, "command", error, restore, value.id),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
submission.context
|
||||
.filter((item) => !!item.comment?.trim())
|
||||
.forEach((item) => submission.target().context.remove(item.key))
|
||||
input.comments.clear()
|
||||
clearSubmission(input, submission)
|
||||
void sendPrompt(session, value).catch((error) =>
|
||||
failSubmission(input, session, "prompt", error, restore, value.id),
|
||||
)
|
||||
} finally {
|
||||
submitting.delete(input.adapter.state)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
submit,
|
||||
stop: () => (input.adapter.kind === "active-session" ? input.adapter.interrupt() : Promise.resolve()),
|
||||
}
|
||||
}
|
||||
|
||||
function readSubmission(
|
||||
input: ComposerSubmitInput,
|
||||
prompt: Prompt,
|
||||
context: ComposerSubmission["context"],
|
||||
): ComposerSubmission | undefined {
|
||||
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
const mode = input.mode()
|
||||
if (mode === "shell" && !text.trim()) return
|
||||
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
const comments = context.filter((item) => !!item.comment?.trim()).length
|
||||
if (!text.trim() && images.length === 0 && comments === 0) return
|
||||
|
||||
const controls = input.adapter.controls()
|
||||
const model = controls.model.selection.current()
|
||||
const agent = controls.agents.current
|
||||
if (!model || !agent) {
|
||||
input.notify.missingSelection()
|
||||
return
|
||||
}
|
||||
const variant = controls.model.selection.variant.current()
|
||||
const retry = input.adapter.state.retry.current()
|
||||
const retryID =
|
||||
retry &&
|
||||
retry.agent === agent &&
|
||||
retry.providerID === model.provider.id &&
|
||||
retry.modelID === model.id &&
|
||||
(retry.variant ?? "default") === (variant ?? "default")
|
||||
? retry.id
|
||||
: undefined
|
||||
|
||||
return {
|
||||
id: retryID ?? SessionMessage.ID.create(),
|
||||
mode,
|
||||
prompt,
|
||||
context,
|
||||
text,
|
||||
images,
|
||||
selection: {
|
||||
agent,
|
||||
model: { modelID: model.id, providerID: model.provider.id },
|
||||
variant,
|
||||
},
|
||||
delivery: "steer",
|
||||
}
|
||||
}
|
||||
|
||||
function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<typeof createComposerSubmission>) {
|
||||
submission.clear()
|
||||
submission.target().mode.set("normal")
|
||||
input.setMode("normal")
|
||||
input.closePopover()
|
||||
}
|
||||
|
||||
function restoreSubmission(
|
||||
input: ComposerSubmitInput,
|
||||
submission: ReturnType<typeof createComposerSubmission>,
|
||||
value: ComposerSubmission,
|
||||
comments: PromptHistoryComment[],
|
||||
) {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, promptLength(restored.prompt))
|
||||
restored.target.mode.set(value.mode)
|
||||
restored.target.context.replaceComments(
|
||||
restored.context
|
||||
.filter((item) => !!item.comment?.trim())
|
||||
.map((item) => ({
|
||||
type: "file",
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
})),
|
||||
)
|
||||
if (value.mode === "normal") {
|
||||
restored.target.retry.set({
|
||||
id: value.id,
|
||||
agent: value.selection.agent,
|
||||
providerID: value.selection.model.providerID,
|
||||
modelID: value.selection.model.modelID,
|
||||
variant: value.selection.variant,
|
||||
})
|
||||
}
|
||||
if (!submission.current(input.adapter.state)) return true
|
||||
|
||||
input.comments.restore(comments)
|
||||
input.setMode(value.mode)
|
||||
input.closePopover()
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, promptLength(value.prompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
async function sendShell(session: ComposerSession, value: ComposerSubmission) {
|
||||
await session.api.shell({ sessionID: session.id, id: Event.ID.create(), command: value.text })
|
||||
}
|
||||
|
||||
function findCommand(session: ComposerSession, text: string) {
|
||||
if (!text.startsWith("/")) return
|
||||
const [name, ...arguments_] = text.split(" ")
|
||||
const command = name.slice(1)
|
||||
if (!session.data.location.command.list({ directory: session.directory })?.some((item) => item.name === command))
|
||||
return
|
||||
return { command, arguments: arguments_.join(" ") }
|
||||
}
|
||||
|
||||
async function sendCommand(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
command: { command: string; arguments: string },
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
await session.api.command({
|
||||
sessionID: session.id,
|
||||
id: value.id,
|
||||
command: command.command,
|
||||
arguments: command.arguments,
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
id: value.selection.model.modelID,
|
||||
providerID: value.selection.model.providerID,
|
||||
variant: value.selection.variant,
|
||||
},
|
||||
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
agents: request.agents,
|
||||
skills: request.skills,
|
||||
delivery: value.delivery,
|
||||
})
|
||||
}
|
||||
|
||||
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
const current = session.current()
|
||||
if (current?.agent !== value.selection.agent) {
|
||||
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
|
||||
}
|
||||
if (
|
||||
current?.model?.providerID !== value.selection.model.providerID ||
|
||||
current.model.id !== value.selection.model.modelID ||
|
||||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
|
||||
) {
|
||||
await session.api.switchModel({
|
||||
sessionID: session.id,
|
||||
model: {
|
||||
id: value.selection.model.modelID,
|
||||
providerID: value.selection.model.providerID,
|
||||
variant: value.selection.variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const admission = {
|
||||
id: value.id,
|
||||
sessionID: session.id,
|
||||
delivery: value.delivery,
|
||||
text: request.text,
|
||||
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
agents: request.agents,
|
||||
skills: request.skills,
|
||||
metadata: {
|
||||
displayText: request.displayText,
|
||||
comments: request.comments,
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
...value.selection.model,
|
||||
...(value.selection.variant ? { variant: value.selection.variant } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
}
|
||||
|
||||
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
|
||||
const images = await Promise.all(
|
||||
value.images.map(async (attachment) => ({
|
||||
...attachment,
|
||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
})),
|
||||
)
|
||||
const request = buildPromptRequest({
|
||||
prompt: value.prompt,
|
||||
context: value.context,
|
||||
images,
|
||||
text: value.text,
|
||||
sessionDirectory: session.directory,
|
||||
})
|
||||
return request
|
||||
}
|
||||
|
||||
function failSubmission(
|
||||
input: ComposerSubmitInput,
|
||||
session: ComposerSession,
|
||||
kind: "shell" | "command" | "prompt",
|
||||
error: unknown,
|
||||
restore: () => boolean,
|
||||
messageID?: string,
|
||||
) {
|
||||
if (messageID && session.admitted(messageID)) return
|
||||
restore()
|
||||
input.notify.failed(kind, error)
|
||||
}
|
||||
|
||||
function promptLength(prompt: Prompt) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
+31
-31
@@ -1,14 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types"
|
||||
import { createPromptInputV2InteractionState, transitionPromptInputV2 } from "./machine"
|
||||
import type { ComposerPersistedState, ComposerSuggestion } from "../types"
|
||||
import { createComposerInteractionState, transitionComposer } from "./machine"
|
||||
|
||||
const command: PromptInputV2Suggestion = {
|
||||
const command: ComposerSuggestion = {
|
||||
id: "review",
|
||||
kind: "command",
|
||||
label: "/review",
|
||||
}
|
||||
|
||||
function persisted(value = ""): PromptInputV2PersistedState {
|
||||
function persisted(value = ""): ComposerPersistedState {
|
||||
return {
|
||||
prompt: [{ type: "text", content: value, start: 0, end: value.length }],
|
||||
cursor: value.length,
|
||||
@@ -16,24 +16,24 @@ function persisted(value = ""): PromptInputV2PersistedState {
|
||||
}
|
||||
}
|
||||
|
||||
describe("prompt input v2 interaction machine", () => {
|
||||
describe("Composer interaction machine", () => {
|
||||
test("opens inline commands only when slash is the entire prompt", () => {
|
||||
const state = createPromptInputV2InteractionState()
|
||||
const open = transitionPromptInputV2(state, { type: "input.changed", value: "/re" }, persisted())
|
||||
const closed = transitionPromptInputV2(state, { type: "input.changed", value: "explain /re" }, persisted())
|
||||
const state = createComposerInteractionState()
|
||||
const open = transitionComposer(state, { type: "input.changed", value: "/re" }, persisted())
|
||||
const closed = transitionComposer(state, { type: "input.changed", value: "explain /re" }, persisted())
|
||||
|
||||
expect(open.state.popover).toEqual({ type: "command-inline", query: "re" })
|
||||
expect(closed.state.popover).toEqual({ type: "closed" })
|
||||
})
|
||||
|
||||
test("completes nested slash command names", () => {
|
||||
const open = transitionPromptInputV2(
|
||||
createPromptInputV2InteractionState(),
|
||||
const open = transitionComposer(
|
||||
createComposerInteractionState(),
|
||||
{ type: "input.changed", value: "/review/" },
|
||||
persisted(),
|
||||
)
|
||||
const item = { ...command, label: "/review/nested" }
|
||||
const selected = transitionPromptInputV2(open.state, { type: "popover.select", item }, persisted("/review/"))
|
||||
const selected = transitionComposer(open.state, { type: "popover.select", item }, persisted("/review/"))
|
||||
|
||||
expect(open.state.popover).toEqual({ type: "command-inline", query: "review/" })
|
||||
expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review/nested " })
|
||||
@@ -44,8 +44,8 @@ describe("prompt input v2 interaction machine", () => {
|
||||
const input = persisted(value)
|
||||
input.cursor = 9
|
||||
|
||||
const result = transitionPromptInputV2(
|
||||
createPromptInputV2InteractionState(),
|
||||
const result = transitionComposer(
|
||||
createComposerInteractionState(),
|
||||
{ type: "input.changed", value, persist: false },
|
||||
input,
|
||||
)
|
||||
@@ -54,8 +54,8 @@ describe("prompt input v2 interaction machine", () => {
|
||||
})
|
||||
|
||||
test("enters shell mode from an initial exclamation mark", () => {
|
||||
const result = transitionPromptInputV2(
|
||||
createPromptInputV2InteractionState(),
|
||||
const result = transitionComposer(
|
||||
createComposerInteractionState(),
|
||||
{ type: "input.changed", value: "!", persist: false },
|
||||
persisted("!"),
|
||||
)
|
||||
@@ -65,8 +65,8 @@ describe("prompt input v2 interaction machine", () => {
|
||||
})
|
||||
|
||||
test("leaves shell mode with escape", () => {
|
||||
const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const }
|
||||
const result = transitionPromptInputV2(
|
||||
const state = { ...createComposerInteractionState(), mode: "shell" as const }
|
||||
const result = transitionComposer(
|
||||
state,
|
||||
{ type: "key.down", key: "Escape", ctrl: false, composing: false, ids: [] },
|
||||
persisted(),
|
||||
@@ -77,8 +77,8 @@ describe("prompt input v2 interaction machine", () => {
|
||||
})
|
||||
|
||||
test("leaves shell mode with backspace when empty", () => {
|
||||
const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const }
|
||||
const result = transitionPromptInputV2(
|
||||
const state = { ...createComposerInteractionState(), mode: "shell" as const }
|
||||
const result = transitionComposer(
|
||||
state,
|
||||
{ type: "key.down", key: "Backspace", ctrl: false, composing: false, ids: [], empty: true },
|
||||
persisted(),
|
||||
@@ -90,10 +90,10 @@ describe("prompt input v2 interaction machine", () => {
|
||||
|
||||
test("closes a popover with ctrl-g before stopping a run", () => {
|
||||
const state = {
|
||||
...createPromptInputV2InteractionState(),
|
||||
...createComposerInteractionState(),
|
||||
popover: { type: "context" as const, query: "", activeID: "first" },
|
||||
}
|
||||
const result = transitionPromptInputV2(
|
||||
const result = transitionComposer(
|
||||
state,
|
||||
{ type: "key.down", key: "g", ctrl: true, composing: false, ids: ["first"] },
|
||||
persisted(),
|
||||
@@ -104,8 +104,8 @@ describe("prompt input v2 interaction machine", () => {
|
||||
})
|
||||
|
||||
test("opens the searchable command menu for a populated draft", () => {
|
||||
const result = transitionPromptInputV2(
|
||||
createPromptInputV2InteractionState(),
|
||||
const result = transitionComposer(
|
||||
createComposerInteractionState(),
|
||||
{ type: "commands.open" },
|
||||
persisted("existing text"),
|
||||
)
|
||||
@@ -115,12 +115,12 @@ describe("prompt input v2 interaction machine", () => {
|
||||
})
|
||||
|
||||
test("prepends a menu command and preserves existing text as arguments", () => {
|
||||
const open = transitionPromptInputV2(
|
||||
createPromptInputV2InteractionState(),
|
||||
const open = transitionComposer(
|
||||
createComposerInteractionState(),
|
||||
{ type: "commands.open" },
|
||||
persisted("existing text"),
|
||||
)
|
||||
const selected = transitionPromptInputV2(
|
||||
const selected = transitionComposer(
|
||||
open.state,
|
||||
{ type: "popover.select", item: command },
|
||||
persisted("existing text"),
|
||||
@@ -131,28 +131,28 @@ describe("prompt input v2 interaction machine", () => {
|
||||
})
|
||||
|
||||
test("stores selected context files as prompt file parts", () => {
|
||||
const item: PromptInputV2Suggestion = {
|
||||
const item: ComposerSuggestion = {
|
||||
id: "src/index.ts",
|
||||
kind: "file",
|
||||
label: "index.ts",
|
||||
path: "src/index.ts",
|
||||
}
|
||||
const state = {
|
||||
...createPromptInputV2InteractionState(),
|
||||
...createComposerInteractionState(),
|
||||
popover: { type: "context" as const, query: "index" },
|
||||
}
|
||||
|
||||
const selected = transitionPromptInputV2(state, { type: "popover.select", item }, persisted("@index"))
|
||||
const selected = transitionComposer(state, { type: "popover.select", item }, persisted("@index"))
|
||||
|
||||
expect(selected.commands).toContainEqual({ type: "mention.add", item })
|
||||
})
|
||||
|
||||
test("loops active popover items with arrow keys", () => {
|
||||
const state = {
|
||||
...createPromptInputV2InteractionState(),
|
||||
...createComposerInteractionState(),
|
||||
popover: { type: "context" as const, query: "", activeID: "second" },
|
||||
}
|
||||
const result = transitionPromptInputV2(
|
||||
const result = transitionComposer(
|
||||
state,
|
||||
{ type: "key.down", key: "ArrowDown", ctrl: false, composing: false, ids: ["first", "second"] },
|
||||
persisted(),
|
||||
+44
-49
@@ -1,6 +1,6 @@
|
||||
import type { PromptInputV2HistoryEntry, PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types"
|
||||
import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types"
|
||||
|
||||
export type PromptInputV2InteractionState = {
|
||||
export type ComposerInteractionState = {
|
||||
mode: "normal" | "shell"
|
||||
popover:
|
||||
| { type: "closed" }
|
||||
@@ -11,10 +11,10 @@ export type PromptInputV2InteractionState = {
|
||||
focus: "editor" | "command-search" | "external"
|
||||
activeContextID?: string
|
||||
historyIndex: number
|
||||
savedHistory?: PromptInputV2HistoryEntry
|
||||
savedHistory?: ComposerHistoryEntry
|
||||
}
|
||||
|
||||
export type PromptInputV2InteractionEvent =
|
||||
export type ComposerInteractionEvent =
|
||||
| { type: "input.changed"; value: string; persist?: boolean }
|
||||
| { type: "commands.open" }
|
||||
| { type: "context.open" }
|
||||
@@ -22,7 +22,7 @@ export type PromptInputV2InteractionEvent =
|
||||
| { type: "popover.results"; ids: string[] }
|
||||
| { type: "popover.active"; id: string }
|
||||
| { type: "popover.close" }
|
||||
| { type: "popover.select"; item: PromptInputV2Suggestion }
|
||||
| { type: "popover.select"; item: ComposerSuggestion }
|
||||
| { type: "key.down"; key: string; ctrl: boolean; composing: boolean; ids: string[]; empty?: boolean }
|
||||
| { type: "mode.shell" }
|
||||
| { type: "mode.normal" }
|
||||
@@ -32,21 +32,22 @@ export type PromptInputV2InteractionEvent =
|
||||
| { type: "focus.external" }
|
||||
| { type: "context.active"; id: string }
|
||||
|
||||
export type PromptInputV2InteractionCommand =
|
||||
export type ComposerInteractionCommand =
|
||||
| { type: "draft.setText"; value: string }
|
||||
| { type: "mention.add"; item: PromptInputV2Suggestion }
|
||||
| { type: "draft.addText"; value: string }
|
||||
| { type: "mention.add"; item: ComposerSuggestion }
|
||||
| { type: "popover.filter"; popover: "command" | "context"; query: string }
|
||||
| { type: "suggestion.select"; id: string }
|
||||
| { type: "focus.editor" }
|
||||
| { type: "focus.command-search" }
|
||||
|
||||
export type PromptInputV2Transition = {
|
||||
state: PromptInputV2InteractionState
|
||||
commands: PromptInputV2InteractionCommand[]
|
||||
export type ComposerEditorTransition = {
|
||||
state: ComposerInteractionState
|
||||
commands: ComposerInteractionCommand[]
|
||||
handled: boolean
|
||||
}
|
||||
|
||||
export function createPromptInputV2InteractionState(): PromptInputV2InteractionState {
|
||||
export function createComposerInteractionState(): ComposerInteractionState {
|
||||
return {
|
||||
mode: "normal",
|
||||
popover: { type: "closed" },
|
||||
@@ -56,14 +57,14 @@ export function createPromptInputV2InteractionState(): PromptInputV2InteractionS
|
||||
}
|
||||
}
|
||||
|
||||
export function transitionPromptInputV2(
|
||||
state: PromptInputV2InteractionState,
|
||||
event: PromptInputV2InteractionEvent,
|
||||
persisted: PromptInputV2PersistedState,
|
||||
): PromptInputV2Transition {
|
||||
export function transitionComposer(
|
||||
state: ComposerInteractionState,
|
||||
event: ComposerInteractionEvent,
|
||||
persisted: ComposerPersistedState,
|
||||
): ComposerEditorTransition {
|
||||
if (event.type === "input.changed") return inputChanged(state, event.value, event.persist !== false, persisted.cursor)
|
||||
if (event.type === "commands.open") return openCommands(state, persisted)
|
||||
if (event.type === "context.open") return openContext(state, persisted)
|
||||
if (event.type === "context.open") return openContext(state)
|
||||
if (event.type === "popover.query") return queryChanged(state, event.value)
|
||||
if (event.type === "popover.results") return resultsChanged(state, event.ids)
|
||||
if (event.type === "popover.active") return activeChanged(state, event.id)
|
||||
@@ -82,12 +83,12 @@ export function transitionPromptInputV2(
|
||||
}
|
||||
|
||||
function inputChanged(
|
||||
state: PromptInputV2InteractionState,
|
||||
state: ComposerInteractionState,
|
||||
value: string,
|
||||
persist: boolean,
|
||||
cursor: number | undefined,
|
||||
): PromptInputV2Transition {
|
||||
const setText: PromptInputV2InteractionCommand[] = persist ? [{ type: "draft.setText", value }] : []
|
||||
): ComposerEditorTransition {
|
||||
const setText: ComposerInteractionCommand[] = persist ? [{ type: "draft.setText", value }] : []
|
||||
if (state.mode === "normal" && value === "!") {
|
||||
return changed({ ...state, mode: "shell", popover: { type: "closed" }, focus: "editor" }, [
|
||||
{ type: "draft.setText", value: "" },
|
||||
@@ -117,13 +118,10 @@ function inputChanged(
|
||||
)
|
||||
}
|
||||
|
||||
function openCommands(
|
||||
state: PromptInputV2InteractionState,
|
||||
persisted: PromptInputV2PersistedState,
|
||||
): PromptInputV2Transition {
|
||||
function openCommands(state: ComposerInteractionState, persisted: ComposerPersistedState): ComposerEditorTransition {
|
||||
if (!populated(persisted)) {
|
||||
return changed({ ...state, popover: { type: "command-inline", query: "" }, focus: "editor" }, [
|
||||
{ type: "draft.setText", value: promptText(persisted) + "/" },
|
||||
{ type: "draft.addText", value: "/" },
|
||||
{ type: "popover.filter", popover: "command", query: "" },
|
||||
{ type: "focus.editor" },
|
||||
])
|
||||
@@ -134,18 +132,15 @@ function openCommands(
|
||||
])
|
||||
}
|
||||
|
||||
function openContext(
|
||||
state: PromptInputV2InteractionState,
|
||||
persisted: PromptInputV2PersistedState,
|
||||
): PromptInputV2Transition {
|
||||
function openContext(state: ComposerInteractionState): ComposerEditorTransition {
|
||||
return changed({ ...state, popover: { type: "context", query: "" }, focus: "editor" }, [
|
||||
{ type: "draft.setText", value: promptText(persisted) + "@" },
|
||||
{ type: "draft.addText", value: "@" },
|
||||
{ type: "popover.filter", popover: "context", query: "" },
|
||||
{ type: "focus.editor" },
|
||||
])
|
||||
}
|
||||
|
||||
function queryChanged(state: PromptInputV2InteractionState, query: string): PromptInputV2Transition {
|
||||
function queryChanged(state: ComposerInteractionState, query: string): ComposerEditorTransition {
|
||||
if (state.popover.type === "closed") return unchanged(state)
|
||||
const popover = state.popover.type === "context" ? "context" : "command"
|
||||
return changed({ ...state, popover: { ...state.popover, query, activeID: undefined } }, [
|
||||
@@ -153,25 +148,25 @@ function queryChanged(state: PromptInputV2InteractionState, query: string): Prom
|
||||
])
|
||||
}
|
||||
|
||||
function resultsChanged(state: PromptInputV2InteractionState, ids: string[]): PromptInputV2Transition {
|
||||
function resultsChanged(state: ComposerInteractionState, ids: string[]): ComposerEditorTransition {
|
||||
if (state.popover.type === "closed") return unchanged(state)
|
||||
const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0]
|
||||
if (activeID === state.popover.activeID) return unchanged(state)
|
||||
return changed({ ...state, popover: { ...state.popover, activeID } })
|
||||
}
|
||||
|
||||
function activeChanged(state: PromptInputV2InteractionState, id: string): PromptInputV2Transition {
|
||||
function activeChanged(state: ComposerInteractionState, id: string): ComposerEditorTransition {
|
||||
if (state.popover.type === "closed" || state.popover.activeID === id) return unchanged(state)
|
||||
return changed({ ...state, popover: { ...state.popover, activeID: id } })
|
||||
}
|
||||
|
||||
function suggestionSelected(
|
||||
state: PromptInputV2InteractionState,
|
||||
item: PromptInputV2Suggestion,
|
||||
persisted: PromptInputV2PersistedState,
|
||||
): PromptInputV2Transition {
|
||||
state: ComposerInteractionState,
|
||||
item: ComposerSuggestion,
|
||||
persisted: ComposerPersistedState,
|
||||
): ComposerEditorTransition {
|
||||
const current = promptText(persisted)
|
||||
const commands: PromptInputV2InteractionCommand[] = []
|
||||
const commands: ComposerInteractionCommand[] = []
|
||||
if (item.kind === "command") {
|
||||
commands.push({
|
||||
type: "draft.setText",
|
||||
@@ -190,9 +185,9 @@ function suggestionSelected(
|
||||
}
|
||||
|
||||
function keyDown(
|
||||
state: PromptInputV2InteractionState,
|
||||
event: Extract<PromptInputV2InteractionEvent, { type: "key.down" }>,
|
||||
): PromptInputV2Transition {
|
||||
state: ComposerInteractionState,
|
||||
event: Extract<ComposerInteractionEvent, { type: "key.down" }>,
|
||||
): ComposerEditorTransition {
|
||||
if (event.ctrl && event.key.toLowerCase() === "g") {
|
||||
if (state.popover.type === "closed") return unchanged(state)
|
||||
return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true)
|
||||
@@ -227,11 +222,11 @@ function keyDown(
|
||||
return changed({ ...state, popover: { ...state.popover, activeID: event.ids[index] } }, [], true)
|
||||
}
|
||||
|
||||
function promptText(persisted: PromptInputV2PersistedState) {
|
||||
function promptText(persisted: ComposerPersistedState) {
|
||||
return persisted.prompt.map((part) => (part.type === "text" ? part.content : "")).join("")
|
||||
}
|
||||
|
||||
function populated(persisted: PromptInputV2PersistedState) {
|
||||
function populated(persisted: ComposerPersistedState) {
|
||||
return (
|
||||
!!promptText(persisted).trim() ||
|
||||
persisted.context.items.length > 0 ||
|
||||
@@ -245,17 +240,17 @@ function replaceTrigger(value: string, trigger: "@" | "/", replacement: string)
|
||||
}
|
||||
|
||||
function changed(
|
||||
state: PromptInputV2InteractionState,
|
||||
commands: PromptInputV2InteractionCommand[] = [],
|
||||
state: ComposerInteractionState,
|
||||
commands: ComposerInteractionCommand[] = [],
|
||||
handled = false,
|
||||
): PromptInputV2Transition {
|
||||
): ComposerEditorTransition {
|
||||
return { state, commands, handled }
|
||||
}
|
||||
|
||||
function unchanged(
|
||||
state: PromptInputV2InteractionState,
|
||||
state: ComposerInteractionState,
|
||||
handled = false,
|
||||
commands: PromptInputV2InteractionCommand[] = [],
|
||||
): PromptInputV2Transition {
|
||||
commands: ComposerInteractionCommand[] = [],
|
||||
): ComposerEditorTransition {
|
||||
return { state, commands, handled }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { AgentPart, ComposerStore, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "./state"
|
||||
|
||||
export type ComposerFilePart = FileAttachmentPart
|
||||
export type ComposerAgentPart = AgentPart
|
||||
export type ComposerSkillPart = SkillPart
|
||||
export type ComposerAttachment = ImageAttachmentPart
|
||||
export type ComposerPrompt = Prompt
|
||||
export type ComposerComment = ComposerStore["context"]["items"][number]
|
||||
export type ComposerPersistedState = ComposerStore
|
||||
|
||||
export type ComposerHistoryEntry = {
|
||||
prompt: ComposerPrompt
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type ComposerHistory = {
|
||||
entries: (mode: "normal" | "shell") => ComposerHistoryEntry[]
|
||||
add: (prompt: ComposerPrompt, mode: "normal" | "shell") => void
|
||||
capture?: () => unknown
|
||||
restore?: (metadata: unknown) => void
|
||||
}
|
||||
|
||||
export type ComposerOption = {
|
||||
id: string
|
||||
label: string
|
||||
providerID?: string
|
||||
}
|
||||
|
||||
export type ComposerSuggestion = {
|
||||
id: string
|
||||
kind: "agent" | "command" | "file" | "reference" | "resource" | "skill"
|
||||
label: string
|
||||
title?: string
|
||||
trigger?: string
|
||||
description?: string
|
||||
path?: string
|
||||
keybind?: string[]
|
||||
recent?: boolean
|
||||
mention?: ComposerFilePart | ComposerAgentPart | ComposerSkillPart
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createPromptState, DEFAULT_PROMPT } from "./prompt-state"
|
||||
|
||||
describe("prompt state initialization", () => {
|
||||
test("initializes prompt text, cursor, and model together", () => {
|
||||
createRoot((dispose) => {
|
||||
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
|
||||
const prompt = createPromptState({ prompt: "hello", model })
|
||||
|
||||
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
|
||||
expect(prompt.cursor()).toBe(5)
|
||||
expect(prompt.model.current()).toEqual(model)
|
||||
expect(prompt.model.current()).not.toBe(model)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("uses the default prompt without initial values", () => {
|
||||
createRoot((dispose) => {
|
||||
const prompt = createPromptState()
|
||||
|
||||
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
|
||||
expect(prompt.cursor()).toBeUndefined()
|
||||
expect(prompt.model.current()).toBeUndefined()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,289 +0,0 @@
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { BlobReference } from "@/utils/draft-store"
|
||||
import type { Platform } from "@/context/platform"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
type FilePartSourceText = { value: string; start: number; end: number }
|
||||
type FilePartSource =
|
||||
| { text: FilePartSourceText; type: "file"; path: string }
|
||||
| {
|
||||
text: FilePartSourceText
|
||||
type: "symbol"
|
||||
path: string
|
||||
range: { start: { line: number; character: number }; end: { line: number; character: number } }
|
||||
name: string
|
||||
kind: number
|
||||
}
|
||||
| { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
mime?: string
|
||||
filename?: string
|
||||
url?: string
|
||||
source?: FilePartSource
|
||||
}
|
||||
|
||||
export interface AgentPart extends PartBase {
|
||||
type: "agent"
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
blob: BlobReference
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export type PromptModel = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string | null
|
||||
}
|
||||
|
||||
export type FileContextItem = {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type ContextItem = FileContextItem
|
||||
export type PromptScope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
export type PromptStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
model?: PromptModel
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}
|
||||
|
||||
type InitialPrompt = {
|
||||
prompt?: string
|
||||
model?: PromptModel
|
||||
}
|
||||
|
||||
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
||||
if (!a && !b) return true
|
||||
if (!a || !b) return false
|
||||
return (
|
||||
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
||||
)
|
||||
}
|
||||
|
||||
function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
||||
switch (partA.type) {
|
||||
case "text":
|
||||
return partB.type === "text" && partA.content === partB.content
|
||||
case "file":
|
||||
return (
|
||||
partB.type === "file" &&
|
||||
partA.path === partB.path &&
|
||||
partA.mime === partB.mime &&
|
||||
partA.filename === partB.filename &&
|
||||
isSelectionEqual(partA.selection, partB.selection)
|
||||
)
|
||||
case "agent":
|
||||
return partB.type === "agent" && partA.name === partB.name
|
||||
case "image":
|
||||
return partB.type === "image" && partA.id === partB.id
|
||||
}
|
||||
}
|
||||
|
||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
if (!isPartEqual(promptA[i], promptB[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: FileSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
function contextItemKey(item: ContextItem) {
|
||||
if (item.type !== "file") return item.type
|
||||
const start = item.selection?.startLine
|
||||
const end = item.selection?.endLine
|
||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||
|
||||
if (item.commentID) return `${key}:c=${item.commentID}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return key
|
||||
const digest = checksum(comment) ?? comment
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||
return item.type === "file" && !!item.comment?.trim()
|
||||
}
|
||||
|
||||
function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
|
||||
return {
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
const next = clonePrompt(prompt)
|
||||
batch(() => {
|
||||
setStore("prompt", next)
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
batch(() => {
|
||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||
setStore("cursor", 0)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||
if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt"))
|
||||
return Persist.prompt({
|
||||
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
|
||||
...(serverScope === ServerScope.local
|
||||
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
function promptStore(initial?: InitialPrompt): PromptStore {
|
||||
const text = initial?.prompt
|
||||
return {
|
||||
prompt:
|
||||
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
|
||||
cursor: text === undefined ? undefined : text.length,
|
||||
model: initial?.model ? { ...initial.model } : undefined,
|
||||
context: {
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||
const actions = createPromptActions(setStore)
|
||||
const value = {
|
||||
store: [() => store, setStore] as [Accessor<PromptStore>, SetStoreFunction<PromptStore>],
|
||||
current: () => store.prompt,
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||
model: {
|
||||
current: () => store.model,
|
||||
set: (model: PromptModel | undefined) => setStore("model", model),
|
||||
},
|
||||
context: {
|
||||
items: createMemo(() => store.context.items),
|
||||
add(item: ContextItem) {
|
||||
const key = contextItemKey(item)
|
||||
if (store.context.items.find((x) => x.key === key)) return
|
||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||
},
|
||||
removeComment(path: string, commentID: string) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
||||
)
|
||||
},
|
||||
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.map((item) => {
|
||||
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
||||
const value = { ...item, ...next }
|
||||
return { ...value, key: contextItemKey(value) }
|
||||
}),
|
||||
)
|
||||
},
|
||||
replaceComments(items: FileContextItem[]) {
|
||||
setStore("context", "items", (current) => [
|
||||
...current.filter((item) => !isCommentItem(item)),
|
||||
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
])
|
||||
},
|
||||
},
|
||||
set: actions.set,
|
||||
reset: actions.reset,
|
||||
capture: () => value,
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt, platform?: Platform) {
|
||||
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)), platform)
|
||||
return { ready, ...createPromptStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
export function createPromptSession(
|
||||
serverScope: ServerScope,
|
||||
scope: PromptScope,
|
||||
initial?: InitialPrompt,
|
||||
platform?: Platform,
|
||||
) {
|
||||
return createPersistedPrompt(promptTarget(serverScope, scope), initial, platform)
|
||||
}
|
||||
|
||||
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
||||
return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
|
||||
}
|
||||
|
||||
export type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
export function createPromptReady(session: Accessor<PromptSession>) {
|
||||
return Object.defineProperty(() => session().ready(), "promise", {
|
||||
get: () => session().ready.promise,
|
||||
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
||||
}
|
||||
|
||||
export function createPromptState(initial?: InitialPrompt) {
|
||||
const [store, setStore] = createStore<PromptStore>(promptStore(initial))
|
||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||
return {
|
||||
ready,
|
||||
...createPromptStateValue(store, setStore),
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createCatalogSync } from "./catalog"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
test("invalidates the catalog for the event location", async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const one = [ServerScope.local, "/one", "providers"] as const
|
||||
const integrations = [ServerScope.local, "/one", "integrations"] as const
|
||||
const two = [ServerScope.local, "/two", "providers"] as const
|
||||
queryClient.setQueryData(one, { providers: ["one"] })
|
||||
queryClient.setQueryData(integrations, { integrations: ["one"] })
|
||||
queryClient.setQueryData(two, { providers: ["two"] })
|
||||
const catalog = createCatalogSync({
|
||||
scope: ServerScope.local,
|
||||
queryClient,
|
||||
active: () => [pathKey("/one"), pathKey("/two")],
|
||||
load: async () => {},
|
||||
})
|
||||
|
||||
catalog.handleEvent({ type: "catalog.updated", directory: "/one" })
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(queryClient.getQueryState(one)?.isInvalidated).toBe(true)
|
||||
expect(queryClient.getQueryState(integrations)?.isInvalidated).toBe(true)
|
||||
expect(queryClient.getQueryState(two)?.isInvalidated).toBe(false)
|
||||
})
|
||||
|
||||
test("invalidates global and active catalogs after connection", async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const global = [ServerScope.local, null, "providers"] as const
|
||||
const active = [ServerScope.local, "/active", "providers"] as const
|
||||
const passive = [ServerScope.local, "/passive", "providers"] as const
|
||||
queryClient.setQueryData(global, {})
|
||||
queryClient.setQueryData(active, {})
|
||||
queryClient.setQueryData(passive, {})
|
||||
const catalog = createCatalogSync({
|
||||
scope: ServerScope.local,
|
||||
queryClient,
|
||||
active: () => [pathKey("/active")],
|
||||
load: async () => {},
|
||||
})
|
||||
|
||||
catalog.handleEvent({ type: "server.connected" })
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
|
||||
expect(queryClient.getQueryState(active)?.isInvalidated).toBe(true)
|
||||
expect(queryClient.getQueryState(passive)?.isInvalidated).toBe(false)
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { pathKey, type PathKey } from "@/utils/path-key"
|
||||
|
||||
type CatalogEvent = {
|
||||
type: string
|
||||
directory?: string
|
||||
}
|
||||
|
||||
export function createCatalogSync(input: {
|
||||
scope: ServerScope
|
||||
queryClient: QueryClient
|
||||
active: () => PathKey[]
|
||||
load: (directory: PathKey | null) => Promise<void>
|
||||
}) {
|
||||
function handleEvent(event: CatalogEvent) {
|
||||
if (event.type === "server.connected") {
|
||||
void refreshActive().catch(() => undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "catalog.updated" ||
|
||||
event.type === "integration.updated" ||
|
||||
event.type === "integration.connection.updated"
|
||||
) {
|
||||
void refresh(event.directory ? pathKey(event.directory) : null).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(directory: PathKey | null) {
|
||||
await Promise.all(
|
||||
["providers", "integrations"].map((resource) =>
|
||||
input.queryClient.invalidateQueries({
|
||||
queryKey: [input.scope, directory, resource],
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
}),
|
||||
),
|
||||
)
|
||||
await input.load(directory)
|
||||
}
|
||||
|
||||
function refreshActive() {
|
||||
return Promise.all([null, ...new Set(input.active())].map(refresh)).then(() => undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
handleEvent,
|
||||
refresh,
|
||||
refreshActive,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS } from "./constants/file-picker"
|
||||
export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./context/language"
|
||||
export { type Platform, PlatformProvider } from "./context/platform"
|
||||
export { ServerConnection, useServers } from "./context/servers"
|
||||
export { useTabs } from "./context/tabs"
|
||||
export { createDraftStore } from "./utils/draft-store"
|
||||
export { useWslServers } from "./wsl/context"
|
||||
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
|
||||
export { useCommand } from "./shell/commands/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
export { useTabs } from "./shell/tabs/tabs"
|
||||
export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
export { useWslServers } from "./servers/wsl/context"
|
||||
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user