mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 11:26:24 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
506c30e590 | ||
|
|
45fd44db01 |
@@ -153,15 +153,6 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
const rejection = code(event)
|
||||
if (rejection === "previous_response_not_found") return rejected(observation, "retry-full")
|
||||
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
|
||||
// Only the continuation distinguishes an incremental send from a full one, so an unclassified
|
||||
// invalid request there is retried full; Codex reports a stale previous_response_id that way, with
|
||||
// no code. Classified failures such as context overflow keep their runner-owned recovery.
|
||||
if (
|
||||
create.mode === "incremental" &&
|
||||
observation.error.reason._tag === "InvalidRequest" &&
|
||||
observation.error.reason.classification === undefined
|
||||
)
|
||||
return rejected(observation, "retry-full")
|
||||
}
|
||||
if (observation.type !== "completed") return observation
|
||||
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
|
||||
@@ -181,7 +172,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
responseID,
|
||||
request,
|
||||
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
|
||||
output: event.response?.output?.length
|
||||
output: event.response?.output
|
||||
? event.response.output.map((item) =>
|
||||
item.type === "reasoning" && item.id !== undefined
|
||||
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
|
||||
|
||||
@@ -655,8 +655,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
type: "message" as const,
|
||||
...(group.id === undefined ? {} : { id: group.id }),
|
||||
role: "assistant" as const,
|
||||
// Replayed text is a finished input item, even if generation was cut short.
|
||||
status: "completed",
|
||||
status: metadata?.status,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
})),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../protocols/openai-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
@@ -38,10 +37,11 @@ const responsesRoute = Route.make({
|
||||
id: "bedrock-mantle-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: OpenAIResponses.route.auth,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: OpenAIResponses.route.defaults,
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
|
||||
@@ -115,11 +115,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
}
|
||||
const onAbort = () => {
|
||||
cleanup()
|
||||
if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return
|
||||
// Node's ws reports an aborted handshake as an error event on the next tick; with no listener left
|
||||
// after cleanup, EventEmitter would throw it as an uncaught exception.
|
||||
ws.addEventListener("error", () => {}, { once: true })
|
||||
ws.close(1000)
|
||||
if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING)
|
||||
ws.close(1000)
|
||||
}
|
||||
const onOpen = () => {
|
||||
cleanup()
|
||||
|
||||
+6
-6
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+4
-4
File diff suppressed because one or more lines are too long
+6
-6
File diff suppressed because one or more lines are too long
+1
-1
@@ -81,7 +81,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
|
||||
+5
-5
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { AmazonBedrockMantle } from "../../src/providers.js"
|
||||
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
@@ -25,7 +25,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
expect(provider.model).toBe(provider.responses)
|
||||
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
|
||||
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
|
||||
@@ -38,7 +38,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
})
|
||||
expect(responses).toMatchObject({
|
||||
route: "bedrock-mantle-responses",
|
||||
protocol: "open-responses",
|
||||
protocol: "openai-responses",
|
||||
body: { model: "openai.gpt-oss-120b", store: false },
|
||||
})
|
||||
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
@@ -178,7 +178,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
const recorded = recordedTests({
|
||||
prefix: "bedrock-mantle",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "open-responses",
|
||||
protocol: "openai-responses",
|
||||
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
|
||||
metadata: { model: "openai.gpt-oss-120b" },
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ it.effect("conversation lowering excludes generation settings and tool definitio
|
||||
instructions: "Keep the context",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
|
||||
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "hi" }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src/index.js"
|
||||
import { OpenAI } from "../../src/providers.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test-key" }).responses("example-model"),
|
||||
configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
|
||||
]) {
|
||||
describe(`${model.route.protocol} message replay`, () => {
|
||||
const key = model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
it.effect("marks assistant text completed regardless of stored status", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
...[undefined, "in_progress", "incomplete", "completed"].map((status, index) =>
|
||||
Message.make({
|
||||
role: "assistant",
|
||||
providerMetadata: { [key]: { status } },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Saved ${index}`,
|
||||
providerMetadata: { [key]: { itemId: `msg_${index}`, phase: "commentary", status } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `Final ${index}`,
|
||||
providerMetadata: { [key]: { itemId: `msg_final_${index}`, phase: "final_answer", status } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
Message.make({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Continue" }],
|
||||
providerMetadata: { [key]: { status: "incomplete" } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
...[0, 1, 2, 3].flatMap((index) => [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: `msg_${index}`,
|
||||
phase: "commentary",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: `Saved ${index}` }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: `msg_final_${index}`,
|
||||
phase: "final_answer",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: `Final ${index}` }],
|
||||
},
|
||||
]),
|
||||
{ role: "user", status: "incomplete", content: [{ type: "input_text", text: "Continue" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays truncated text as completed while retaining the response finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Respond" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_partial", status: "in_progress" },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_partial", delta: "The next step is" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_partial",
|
||||
status: "incomplete",
|
||||
content: [{ type: "output_text", text: "The next step is" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.incomplete",
|
||||
response: { status: "incomplete", incomplete_details: { reason: "max_output_tokens" } },
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.finishReason.normalized).toBe("length")
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [response.message, Message.user("Continue")] }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "msg_partial",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "The next step is" }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -91,7 +91,7 @@ describe("Open Responses-compatible route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "developer", content: "Operator update." },
|
||||
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -299,27 +299,23 @@ describe("Open Responses-compatible route", () => {
|
||||
type: "message",
|
||||
id: "history_1",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Kept." }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: `history_${"a".repeat(64)}`,
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Long." }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "provider_value/with+symbols",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Opaque." }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "output_text", text: "No suffix." },
|
||||
{ type: "output_text", text: "No prefix." },
|
||||
@@ -860,7 +856,6 @@ describe("Open Responses-compatible route", () => {
|
||||
type: "message",
|
||||
id: "msg_refusal",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "I can't help with that." }],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
|
||||
instructions: "Follow the user's exact reply instruction.",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
|
||||
{ role: "assistant", status: "completed", content: [{ type: "output_text", text: "Alpha." }] },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
|
||||
],
|
||||
})
|
||||
@@ -208,7 +208,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
|
||||
instructions: "Follow the user's exact reply instruction.",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
|
||||
{ role: "assistant", status: "completed", content: [{ type: "output_text", text: "Ready." }] },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect"
|
||||
import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
LLM,
|
||||
@@ -30,7 +30,6 @@ import * as Azure from "../../src/providers/azure.js"
|
||||
import * as OpenAI from "../../src/providers/openai.js"
|
||||
import * as XAI from "../../src/providers/xai.js"
|
||||
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
|
||||
import * as ProviderShared from "../../src/protocols/shared.js"
|
||||
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
|
||||
@@ -70,34 +69,14 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
|
||||
},
|
||||
})
|
||||
|
||||
/** Classifies error frames the way the production channel does, so recovery can read the canonical reason. */
|
||||
const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
|
||||
const base = baseChannelDriver(message)
|
||||
const decodeEvent = Schema.decodeUnknownSync(OpenResponses.protocol.stream.event)
|
||||
return {
|
||||
...base,
|
||||
observe: (create, frame) =>
|
||||
base.observe(create, frame).pipe(
|
||||
Effect.map((observation) =>
|
||||
observation.type === "provider-failure"
|
||||
? {
|
||||
...observation,
|
||||
error: OpenResponses.providerFailure(decodeEvent(frame), "stream error", frame),
|
||||
}
|
||||
: observation,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
|
||||
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
|
||||
const message = ProviderShared.encodeJson(request)
|
||||
return OpenResponsesContinuation.driver({
|
||||
id: "openai-responses",
|
||||
name: "OpenAI Responses",
|
||||
request,
|
||||
message,
|
||||
base: base(message),
|
||||
base: baseChannelDriver(message),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -406,7 +385,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "developer", content: "Operator update." },
|
||||
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -581,54 +560,52 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
it.effect("continues a streamed tool call with only the new tool output", () =>
|
||||
Effect.forEach([undefined, []], (output) =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest)
|
||||
const firstCreate = yield* first.create(undefined)
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest)
|
||||
const firstCreate = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
arguments: '{ "city": "Paris" }',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
arguments: '{ "city": "Paris" }',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver({
|
||||
...firstRequest,
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
|
||||
],
|
||||
})
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver({
|
||||
...firstRequest,
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
|
||||
],
|
||||
})
|
||||
|
||||
const create = yield* second.create(saved)
|
||||
const create = yield* second.create(saved)
|
||||
|
||||
expect(create.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
|
||||
})
|
||||
}),
|
||||
),
|
||||
expect(create.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a tool call from authoritative completed response output", () =>
|
||||
@@ -682,47 +659,45 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
|
||||
Effect.forEach([undefined, []], (output) =>
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
|
||||
const create = yield* first.create(undefined)
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
|
||||
const create = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
|
||||
),
|
||||
)
|
||||
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
|
||||
const next = continuationDriver({
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
|
||||
})
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
|
||||
const next = continuationDriver({
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
|
||||
})
|
||||
|
||||
const continued = yield* next.create(saved)
|
||||
const continued = yield* next.create(saved)
|
||||
|
||||
expect(continued.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [steer],
|
||||
})
|
||||
}),
|
||||
),
|
||||
expect(continued.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [steer],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
|
||||
@@ -877,53 +852,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries an incremental send in full when the provider rejects it without a code", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest, classifyingChannelDriver)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
yield* first.create(undefined),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver(
|
||||
{
|
||||
...firstRequest,
|
||||
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
},
|
||||
classifyingChannelDriver,
|
||||
)
|
||||
// Codex reports a stale previous_response_id as a plain invalid_request_error.
|
||||
const stale = ProviderShared.encodeJson({
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", message: "Invalid `previous_response_id`." },
|
||||
})
|
||||
const incremental = yield* second.create(saved)
|
||||
expect(incremental.mode).toBe("incremental")
|
||||
expect(yield* second.observe(incremental, stale)).toMatchObject({ type: "rejected", recovery: "retry-full" })
|
||||
|
||||
// A full send has no continuation to blame, so the same error stays a provider failure.
|
||||
const full = yield* second.create(undefined)
|
||||
expect(yield* second.observe(full, stale)).toMatchObject({ type: "provider-failure" })
|
||||
|
||||
// A classified failure keeps its runner-owned recovery instead of resending the whole context.
|
||||
const overflow = ProviderShared.encodeJson({
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", code: "context_length_exceeded", message: "Too long" },
|
||||
})
|
||||
expect(yield* second.observe(yield* second.create(saved), overflow)).toMatchObject({
|
||||
type: "provider-failure",
|
||||
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds WebSocket and HTTP fallback from the same final request", () =>
|
||||
Effect.gen(function* () {
|
||||
const attempts = yield* Ref.make(0)
|
||||
@@ -2122,7 +2050,6 @@ describe("OpenAI Responses route", () => {
|
||||
type: "message",
|
||||
id: "msg_refusal",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "I can't help with that." }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
@@ -2205,7 +2132,6 @@ describe("OpenAI Responses route", () => {
|
||||
type: "message",
|
||||
id: "msg_commentary",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Checking." }],
|
||||
phase: "commentary",
|
||||
},
|
||||
@@ -2213,7 +2139,6 @@ describe("OpenAI Responses route", () => {
|
||||
type: "message",
|
||||
id: "msg_final",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Finished." }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
@@ -2221,7 +2146,6 @@ describe("OpenAI Responses route", () => {
|
||||
type: "message",
|
||||
id: "msg_null",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
},
|
||||
@@ -3352,19 +3276,14 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Before." }],
|
||||
},
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||
},
|
||||
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -3628,14 +3547,12 @@ describe("OpenAI Responses route", () => {
|
||||
type: "message",
|
||||
id: "history_1",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Hello" }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: `message_${"a".repeat(64)}`,
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "World" }],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { DialogProvider } from "@opencode/ui/context/dialog"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
|
||||
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
|
||||
import type { createSessionBrowser } from "../src/session/browser/model"
|
||||
import { SessionBrowserPane } from "../src/session/browser/pane"
|
||||
|
||||
export function mountBrowserPane() {
|
||||
const host = document.createElement("main")
|
||||
host.dataset.testid = "browser-pane-fixture"
|
||||
host.style.cssText = "position:fixed;inset:0;z-index:1000;background:#181818;color:#eee;padding:24px"
|
||||
document.body.appendChild(host)
|
||||
|
||||
function Fixture() {
|
||||
const [store, setStore] = createStore({
|
||||
session: "Alpha",
|
||||
mounted: true,
|
||||
visible: true,
|
||||
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
|
||||
})
|
||||
const tabs = ["Alpha", "Beta"].map((name) => ({
|
||||
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
|
||||
title: name,
|
||||
url: `https://${name.toLowerCase()}.example/`,
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
generation: 0,
|
||||
}))
|
||||
// Record the native boundary per registration: hiding Beta cannot hide Alpha's view.
|
||||
const registrations = new Map<string, BrowserPaneRegistration>(
|
||||
tabs.map((tab) => [
|
||||
tab.title,
|
||||
{
|
||||
setLayout: (layout) => setStore("layouts", tab.title, layout),
|
||||
command: async () => undefined,
|
||||
close: () => undefined,
|
||||
},
|
||||
]),
|
||||
)
|
||||
const browser: ReturnType<typeof createSessionBrowser> = {
|
||||
available: () => true,
|
||||
attached: () => !!registrations.get(store.session),
|
||||
opened: () => !!registrations.get(store.session),
|
||||
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
|
||||
tabs: () => tabs.filter((tab) => tab.title === store.session),
|
||||
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
|
||||
registration: () => registrations.get(store.session),
|
||||
error: () => undefined,
|
||||
suspended: () => false,
|
||||
close: () => undefined,
|
||||
open: () => undefined,
|
||||
command: () => undefined,
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Browser pane lifecycle</h1>
|
||||
<p>Selected session: {store.session}</p>
|
||||
<nav style={{ display: "flex", gap: "20px", margin: "16px 0" }}>
|
||||
<For each={["Alpha", "Beta", "Empty"]}>
|
||||
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
|
||||
</For>
|
||||
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
|
||||
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
|
||||
</nav>
|
||||
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
<Show when={store.mounted}>
|
||||
<SessionBrowserPane browser={browser} visible={store.visible} />
|
||||
</Show>
|
||||
</div>
|
||||
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
|
||||
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<div
|
||||
data-testid={`native-${tab.title}`}
|
||||
data-visible={!!store.layouts[tab.title]?.visible}
|
||||
style={{ padding: "12px", margin: "8px 0", border: "1px solid #555" }}
|
||||
>
|
||||
{tab.title}: {store.layouts[tab.title]?.visible ? "visible" : "hidden"}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
<UiI18nBridge>
|
||||
<DialogProvider>
|
||||
<Fixture />
|
||||
</DialogProvider>
|
||||
</UiI18nBridge>
|
||||
</LanguageProvider>
|
||||
),
|
||||
host,
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const fixture = `/@fs/${fileURLToPath(new URL("./browser-pane.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
|
||||
|
||||
story.beforeEach(async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--mixed-attachments")
|
||||
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountBrowserPane } = await import(fixture)
|
||||
mountBrowserPane()
|
||||
}, fixture)
|
||||
await expect(page.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("hides the previous registration when the mounted pane switches sessions", async ({ page }, testInfo) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await page.screenshot({ path: testInfo.outputPath("session-switch.png") })
|
||||
await root.getByRole("button", { name: "Alpha", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("hides the outgoing browser when the destination has no browser pane", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Empty", exact: true }).click()
|
||||
await expect(root.locator("#browser-panel")).toHaveCount(0)
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await root.getByRole("button", { name: "Alpha", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("hides and restores the same registration for Review tabs and unmount", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
# Session-export load benchmark
|
||||
|
||||
Replay an exported session against a production app build. The two cases compare the default Compact preset with every category ungrouped and details still collapsed.
|
||||
|
||||
From `packages/app` in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:PLAYWRIGHT_BUILD = '1'
|
||||
$env:PLAYWRIGHT_BASE_URL = 'http://127.0.0.1:4398' # Existing production preview
|
||||
$env:LAGGY_SESSION_FILE = 'C:\path\session.json'
|
||||
$env:LAGGY_SESSION_OUTPUT = 'C:\tmp\opencode\session-load'
|
||||
$env:LAGGY_SESSION_HISTORY = 'paged' # Or 'full' to supply all exported history
|
||||
bun x playwright test --config e2e/performance/playwright.config.ts timeline/laggy-session-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0
|
||||
bun e2e/performance/timeline/laggy-session-report.ts $env:LAGGY_SESSION_OUTPUT
|
||||
```
|
||||
|
||||
Each test uses a fresh browser context and measures one cold load, switches back to the source session, then measures one warm load. Repetitions therefore interleave `cold → warm` pairs rather than collecting separate cold and warm batches. There are no discarded warm-up switches. The warm member of every pair must issue zero message requests. Each pair is saved in a separate JSON file; compare paired differences as well as the cold and warm distributions when system load varies.
|
||||
|
||||
The report writes `summary.json` and prints the median, p95, maximum, and median paired cold-minus-warm difference. It rejects incomplete or cold-only records instead of mixing them into paired results.
|
||||
|
||||
`--repeat-each=20` collects 20 pairs per grouping mode. `LAGGY_SESSION_COLD_ONLY=1` remains available for focused cold profiling. Screenshots are taken after a pair finishes, not between its measurements.
|
||||
|
||||
The app shell, source session, model control, and fonts are ready before the timed action. These measurements cover session entry, not application startup. `firstCorrectObservedMs` begins at mousedown and ends when the destination is visible at its expected bottom position, including Compact's automatic history fill. `stableObservedMs` includes three-observation confirmation and must not be treated as additional rendering time.
|
||||
|
||||
Set `OPENCODE_PERFORMANCE_TRACE_DIR` for Chrome traces. `LAGGY_TRACE_ITERATION=0` traces the cold load; the default (`1`) traces the warm member of the pair. Profile separately from timing runs.
|
||||
|
||||
`LAGGY_HTTP=1` disables route interception for an external HTTP replay server containing the same export and source fixture. Keep direct HTTP and Playwright-routed cold series separate: routing adds transport overhead. Raw samples, mode settings, viewport, browser version, and screenshots are retained in the output directory. The export itself is not copied into the repository.
|
||||
@@ -1,227 +0,0 @@
|
||||
import { readFileSync, mkdirSync, writeFileSync } from "node:fs"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { timelineCategories, timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
import { stressSessionHref } from "./timeline-test-helpers"
|
||||
import { startChromeTrace } from "../chrome-trace"
|
||||
|
||||
const file = process.env.LAGGY_SESSION_FILE
|
||||
const session = file
|
||||
? (JSON.parse(readFileSync(file, "utf8")) as {
|
||||
info: {
|
||||
id: string
|
||||
projectID: string
|
||||
title: string
|
||||
model?: { id: string; providerID: string }
|
||||
location: { directory: string }
|
||||
time: { created: number; updated: number }
|
||||
}
|
||||
messages: SessionMessageInfo[]
|
||||
})
|
||||
: undefined
|
||||
const sourceID = "ses_laggy_benchmark_source"
|
||||
const sourceMessageID = "msg_laggy_benchmark_source"
|
||||
const history = process.env.LAGGY_SESSION_HISTORY ?? "full"
|
||||
const viewport = { width: 1440, height: 900 }
|
||||
|
||||
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
|
||||
|
||||
for (const mode of ["compact", "ungrouped"] as const) {
|
||||
benchmark(`laggy session: ${mode}`, async ({ page, report }, testInfo) => {
|
||||
benchmark.skip(!session, "Set LAGGY_SESSION_FILE to a session export")
|
||||
if (!session) return
|
||||
const output = process.env.LAGGY_SESSION_OUTPUT ?? testInfo.outputPath("session-load")
|
||||
const model = session.info.model ?? { id: "benchmark-model", providerID: "benchmark" }
|
||||
const lastID = session.messages.findLast((message) => message.type === "user")!.id
|
||||
const lastText = session.messages.findLast(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim()),
|
||||
)!
|
||||
benchmark.setTimeout(Number(process.env.LAGGY_SESSION_TIMEOUT ?? 180_000))
|
||||
const requests: string[] = []
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
if (process.env.LAGGY_HTTP === "1")
|
||||
page.on("request", (request) => {
|
||||
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (request.method() === "GET" && match) requests.push(decodeURIComponent(match[1]))
|
||||
})
|
||||
const detail = Object.fromEntries(
|
||||
timelineCategories.map((category) => [
|
||||
category,
|
||||
{
|
||||
...timelinePresets[2].value[category],
|
||||
placement: mode === "compact" ? "grouped" : "separate",
|
||||
},
|
||||
]),
|
||||
)
|
||||
const directory = session.info.location.directory
|
||||
if (process.env.LAGGY_HTTP !== "1")
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: session.info.projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "session-benchmark",
|
||||
time: session.info.time,
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: model.providerID,
|
||||
name: model.providerID,
|
||||
models: { [model.id]: { id: model.id, name: model.id, limit: { context: 1_000_000 } } },
|
||||
},
|
||||
],
|
||||
connected: [model.providerID],
|
||||
default: { providerID: model.providerID, modelID: model.id },
|
||||
},
|
||||
sessions: [session.info, { ...session.info, id: sourceID, title: "Benchmark source" }],
|
||||
pageMessages: (id, limit, before) => {
|
||||
if (id !== session.info.id)
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: sourceMessageID,
|
||||
type: "user",
|
||||
text: "Benchmark source",
|
||||
time: { created: session.info.time.created },
|
||||
},
|
||||
],
|
||||
}
|
||||
if (history === "full") return { items: session.messages }
|
||||
const end = before ? session.messages.findIndex((message) => message.id === before) : session.messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: session.messages.slice(start, end),
|
||||
cursor: start > 0 ? session.messages[start].id : undefined,
|
||||
}
|
||||
},
|
||||
onMessages: (request) => {
|
||||
if (request.phase === "start") requests.push(request.sessionID)
|
||||
},
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ detail, directory, server, sessionIDs, dirBase64 }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { timelineDetail: detail } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
{
|
||||
detail,
|
||||
directory,
|
||||
server: process.env.PLAYWRIGHT_BASE_URL!,
|
||||
sessionIDs: [sourceID, session.info.id],
|
||||
dirBase64: base64Encode(directory),
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(sourceID))
|
||||
await expectSessionTitle(page, "Benchmark source")
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(page.getByRole("button", { name: model.id, exact: true })).toBeVisible()
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
||||
expect(requests).toEqual([sourceID])
|
||||
const startedAt = new Date().toISOString()
|
||||
const samples = []
|
||||
const phases = process.env.LAGGY_SESSION_COLD_ONLY === "1" ? (["cold"] as const) : (["cold", "warm"] as const)
|
||||
for (const [iteration, phase] of phases.entries()) {
|
||||
const before = requests.length
|
||||
const stopTrace =
|
||||
iteration === Number(process.env.LAGGY_TRACE_ITERATION ?? 1)
|
||||
? await startChromeTrace(page, `laggy-${history}-${mode}`)
|
||||
: undefined
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs: session.messages.map((message) => message.id),
|
||||
sourceIDs: [sourceMessageID],
|
||||
lastID,
|
||||
requiredPartID: history === "paged" && mode === "compact" ? `${lastText.id}:text:0` : undefined,
|
||||
requireBottomAnchor: true,
|
||||
href: stressSessionHref(session.info.id),
|
||||
switch: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(session.info.id)}"]`).click()
|
||||
},
|
||||
})
|
||||
await stopTrace?.()
|
||||
await expectSessionTitle(page, session.info.title)
|
||||
if (history === "full" || mode === "ungrouped") await waitForStableTimeline(page, lastID)
|
||||
await expect(
|
||||
page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])'),
|
||||
).toHaveCount(0)
|
||||
expect(result.firstCorrectObservedMs).not.toBeNull()
|
||||
expect(result.stableObservedMs).not.toBeNull()
|
||||
if (phase === "warm") expect(requests.length - before).toBe(0)
|
||||
samples.push({
|
||||
iteration,
|
||||
phase,
|
||||
messageRequests: requests.length - before,
|
||||
messageResources: await page.evaluate((sessionID) => {
|
||||
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
|
||||
return (performance.getEntriesByType("resource") as PerformanceResourceTiming[])
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.startTime >= start && new URL(entry.name).pathname === `/api/session/${sessionID}/message`,
|
||||
)
|
||||
.map((entry) => ({
|
||||
limit: Number(new URL(entry.name).searchParams.get("limit")),
|
||||
startMs: entry.startTime - start,
|
||||
durationMs: entry.duration,
|
||||
transferBytes: entry.transferSize,
|
||||
}))
|
||||
}, session.info.id),
|
||||
...result,
|
||||
})
|
||||
if (iteration === phases.length - 1) {
|
||||
mkdirSync(output, { recursive: true })
|
||||
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: `${output}/${mode}.png` })
|
||||
break
|
||||
}
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sourceID)}"]`).click()
|
||||
await expectSessionTitle(page, "Benchmark source")
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
}
|
||||
expect(errors).toEqual([])
|
||||
const result = {
|
||||
pair: testInfo.repeatEachIndex,
|
||||
startedAt,
|
||||
mode,
|
||||
history,
|
||||
file,
|
||||
messages: session.messages.length,
|
||||
viewport,
|
||||
browser: page.context().browser()!.version(),
|
||||
detail,
|
||||
samples,
|
||||
}
|
||||
writeFileSync(`${output}/${mode}-${testInfo.repeatEachIndex}.json`, JSON.stringify(result, null, 2))
|
||||
report(
|
||||
{ samples },
|
||||
{
|
||||
mode,
|
||||
sampling: "cold/warm pair in one browser context",
|
||||
pair: testInfo.repeatEachIndex,
|
||||
messages: session.messages.length,
|
||||
viewport,
|
||||
data: history === "full" ? "full exported history" : "paginated exported history",
|
||||
transport: process.env.LAGGY_HTTP === "1" ? "http" : "playwright-route",
|
||||
inputEvent: "mousedown",
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
export {}
|
||||
|
||||
type Pair = {
|
||||
mode: "compact" | "ungrouped"
|
||||
samples: { phase: string; firstCorrectObservedMs: number | null; messageRequests: number }[]
|
||||
}
|
||||
|
||||
const directory = Bun.argv[2]
|
||||
if (!directory) throw new Error("Pass the directory containing session-load pairs")
|
||||
const pairs = await Promise.all(
|
||||
[...new Bun.Glob("{compact,ungrouped}-*.json").scanSync(directory)].map(async (file) => {
|
||||
const pair = (await Bun.file(`${directory}/${file}`).json()) as Pair
|
||||
const cold = pair.samples.find((sample) => sample.phase === "cold")
|
||||
const warm = pair.samples.find((sample) => sample.phase === "warm")
|
||||
if (cold?.firstCorrectObservedMs == null || warm?.firstCorrectObservedMs == null)
|
||||
throw new Error(`Expected a completed cold/warm pair in ${file}`)
|
||||
return {
|
||||
mode: pair.mode,
|
||||
cold: cold.firstCorrectObservedMs,
|
||||
warm: warm.firstCorrectObservedMs,
|
||||
requests: warm.messageRequests,
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!pairs.length) throw new Error(`No session-load pairs found in ${directory}`)
|
||||
|
||||
const result = ["compact", "ungrouped"].flatMap((mode) => {
|
||||
const selected = pairs.filter((pair) => pair.mode === mode)
|
||||
if (!selected.length) return []
|
||||
return [
|
||||
{
|
||||
mode,
|
||||
cold: { ...stats(selected.map((pair) => pair.cold)), over50ms: selected.filter((pair) => pair.cold > 50).length },
|
||||
warm: { ...stats(selected.map((pair) => pair.warm)), over50ms: selected.filter((pair) => pair.warm > 50).length },
|
||||
pairedColdMinusWarm: stats(selected.map((pair) => pair.cold - pair.warm)),
|
||||
messageRequestsDuringWarm: selected.reduce((total, pair) => total + pair.requests, 0),
|
||||
},
|
||||
]
|
||||
})
|
||||
await Bun.write(`${directory}/summary.json`, JSON.stringify(result, null, 2))
|
||||
console.table(
|
||||
result.map((row) => ({
|
||||
mode: row.mode,
|
||||
pairs: row.cold.n,
|
||||
coldMedianMs: Math.round(row.cold.median * 10) / 10,
|
||||
coldP95Ms: Math.round(row.cold.p95 * 10) / 10,
|
||||
warmMedianMs: Math.round(row.warm.median * 10) / 10,
|
||||
warmP95Ms: Math.round(row.warm.p95 * 10) / 10,
|
||||
warmMaxMs: Math.round(row.warm.max * 10) / 10,
|
||||
pairedDifferenceMs: Math.round(row.pairedColdMinusWarm.median * 10) / 10,
|
||||
})),
|
||||
)
|
||||
|
||||
function stats(values: number[]) {
|
||||
const sorted = values.toSorted((left, right) => left - right)
|
||||
return {
|
||||
n: sorted.length,
|
||||
median: (sorted[Math.floor((sorted.length - 1) / 2)] + sorted[Math.floor(sorted.length / 2)]) / 2,
|
||||
p95: sorted[Math.ceil(sorted.length * 0.95) - 1],
|
||||
min: sorted[0],
|
||||
max: sorted.at(-1)!,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const draftID = "draft_background_image"
|
||||
const directory = "/tmp/background-image"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const image = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
)
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_background_image",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "background-image",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", "dark")
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
})
|
||||
|
||||
test("selects, restores, and removes a background image", async ({ page }) => {
|
||||
const providerTip = page.locator('[data-component="new-session-tip"][data-kind="provider"]')
|
||||
await expect(providerTip).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
await settings.getByRole("tab", { name: "Appearance", exact: true }).click()
|
||||
const chooser = page.waitForEvent("filechooser")
|
||||
await settings.getByRole("button", { name: "Choose image", exact: true }).click()
|
||||
await (await chooser).setFiles({ name: "background.png", mimeType: "image/png", buffer: image })
|
||||
await expect(settings.getByRole("button", { name: "Remove", exact: true })).toBeVisible()
|
||||
const shell = page.locator('[data-component="app-shell"]')
|
||||
await expect(shell).toHaveAttribute("data-background-image", "")
|
||||
await expect(shell).toHaveCSS("background-image", /blob:/)
|
||||
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="new-session"][data-background-surface="canvas"]')).toBeVisible()
|
||||
await expect(providerTip).toBeHidden()
|
||||
await page.reload()
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
await expect(shell).toHaveAttribute("data-background-image", "")
|
||||
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(settings).toBeFocused()
|
||||
await settings.getByRole("tab", { name: "Appearance", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Remove", exact: true }).click()
|
||||
await expect(settings.getByRole("button", { name: "Remove", exact: true })).toBeHidden()
|
||||
await expect(shell).not.toHaveAttribute("data-background-image", "")
|
||||
})
|
||||
@@ -16,11 +16,6 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
// Corvu starts opening after paint; the transition flag is also absent
|
||||
// before that callback. Wait for the open position before dismissing.
|
||||
await expect
|
||||
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
|
||||
@@ -22,10 +22,6 @@ test("selects a base branch for a new workspace", async ({ page }) => {
|
||||
pageMessages: () => ({ items: [] }),
|
||||
vcsBranches: ["feature/api", "main", "origin/release"],
|
||||
})
|
||||
await page.route("**/api/vcs/branches?*", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
|
||||
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem(
|
||||
@@ -48,28 +44,10 @@ test("selects a base branch for a new workspace", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await page.getByRole("button", { name: "from main", exact: true }).click()
|
||||
const search = page.getByRole("textbox", { name: "Search branches", exact: true })
|
||||
await expect(search).toBeFocused()
|
||||
await page.keyboard.type("feature")
|
||||
await expect(search).toHaveValue("feature")
|
||||
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api"])
|
||||
await expect(search).toBeFocused()
|
||||
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
|
||||
|
||||
const selected = page.getByRole("button", { name: "from feature/api", exact: true })
|
||||
await expect(selected).toBeVisible()
|
||||
await selected.click()
|
||||
await expect(search).toBeFocused()
|
||||
await expect(search).toHaveValue("")
|
||||
await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(selected).toBeFocused()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(search).toBeFocused()
|
||||
await page.keyboard.type("feature")
|
||||
await expect(search).toHaveValue("feature")
|
||||
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api"])
|
||||
await page.getByRole("button", { name: "Clear", exact: true }).click()
|
||||
await expect(search).toHaveValue("")
|
||||
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api", "main", "origin/release"])
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ for (const width of [1400, 390]) {
|
||||
reducedMotion: true,
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Patch", exact: true }).click()
|
||||
const patch = page.locator('[data-component="apply-patch-tool"]')
|
||||
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { expect, test, type Locator } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -85,38 +85,6 @@ for (const width of [1000, 1440]) {
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
|
||||
})
|
||||
|
||||
test(`keeps moving header content out of the toggle area (${width}px, ${direction})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Review toggle position")
|
||||
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
|
||||
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
for (const opened of [true, false]) {
|
||||
// Pause in the same task as the click so even the first painted state can be inspected.
|
||||
await toggle.evaluate((element) => {
|
||||
;(element as HTMLButtonElement).click()
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
.forEach((animation) => animation.pause())
|
||||
})
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", String(opened))
|
||||
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", String(!opened))
|
||||
for (const progress of [0.08, 0.16, 0.25, 0.5, 0.8, 0.96]) {
|
||||
await expectHeaderClearOfToggle(page, toggle, progress)
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
.forEach((animation) => animation.finish())
|
||||
})
|
||||
}
|
||||
await expect(page.locator("#review-panel")).toBeHidden()
|
||||
})
|
||||
|
||||
test(`keeps terminal controls clear of the review toggle (${width}px, ${direction})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const ptys: { id: string; title: string }[] = []
|
||||
@@ -198,75 +166,10 @@ for (const width of [1000, 1440]) {
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(position)
|
||||
await expectTerminalControlsAligned(terminal, toggle)
|
||||
|
||||
// Closing the terminal clears the region's animation flag while retaining the review contents.
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('[data-slot="session-chat-panel"]')
|
||||
.evaluate((element) => element.getAnimations().every((animation) => animation.playState === "finished")),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(page.locator('[data-slot="session-review-content"]')).toHaveCSS("opacity", "0")
|
||||
await toggle.evaluate((element) => {
|
||||
;(element as HTMLButtonElement).click()
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
.forEach((animation) => animation.pause())
|
||||
})
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expectHeaderClearOfToggle(page, toggle, 0.25)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress: number) {
|
||||
const geometry = await page.locator('[data-slot="session-chat-panel"]').evaluate((chat, progress) => {
|
||||
const row = chat.parentElement!
|
||||
const animations = row
|
||||
.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
const width = animations.find(
|
||||
(animation) => animation instanceof CSSTransition && animation.transitionProperty === "width",
|
||||
)!
|
||||
animations.forEach((animation) => {
|
||||
animation.pause()
|
||||
animation.currentTime = Number(width.effect!.getTiming().duration) * progress
|
||||
})
|
||||
const chatBounds = chat.getBoundingClientRect()
|
||||
const panelBounds = document.querySelector("#review-panel")!.getBoundingClientRect()
|
||||
return {
|
||||
row: row.getBoundingClientRect().width,
|
||||
panelWidth: panelBounds.width,
|
||||
gap:
|
||||
getComputedStyle(row).direction === "rtl"
|
||||
? chatBounds.left - panelBounds.right
|
||||
: panelBounds.left - chatBounds.right,
|
||||
contentOpacity: Number(getComputedStyle(document.querySelector('[data-slot="session-review-content"]')!).opacity),
|
||||
panels: chatBounds.width + panelBounds.width + parseFloat(getComputedStyle(row).columnGap),
|
||||
}
|
||||
}, progress)
|
||||
expect(geometry.gap).toBeCloseTo(8, 1)
|
||||
if (geometry.panelWidth > 0) expect(Math.abs(geometry.row - geometry.panels)).toBeLessThanOrEqual(1)
|
||||
if (progress === 0.25) {
|
||||
expect(geometry.contentOpacity).toBeGreaterThan(0)
|
||||
expect(geometry.contentOpacity).toBeLessThan(1)
|
||||
}
|
||||
|
||||
const clip = await toggle.boundingBox()
|
||||
if (!clip) throw new Error("Review toggle bounds are unavailable")
|
||||
// Header contents must make no difference to the pixels behind the fixed toggle.
|
||||
expect(await page.screenshot({ clip })).toEqual(
|
||||
await page.screenshot({
|
||||
clip,
|
||||
style: ".session-review-v2-tabs-bar { visibility: hidden !important; }",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function expectTerminalControlsAligned(terminal: Locator, toggle: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const reducedMotion of [false, true]) {
|
||||
test(`suppresses the scrollbar from toggle press until timeline interaction (reduced motion: ${reducedMotion})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupTimeline(page, { seedHistory: true, reducedMotion })
|
||||
const chat = page.locator('[data-slot="session-chat-panel"]')
|
||||
const scroll = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
const viewport = scroll.locator(".scroll-view__viewport")
|
||||
const thumb = scroll.locator('.scroll-view__thumb[data-orientation="vertical"]')
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await expect(thumb).toHaveCount(1)
|
||||
await scroll.hover()
|
||||
await expect(thumb).toHaveAttribute("data-visible", "true")
|
||||
await expect(thumb).toHaveCSS("visibility", "visible")
|
||||
|
||||
for (const opened of [true, false]) {
|
||||
await toggle.hover()
|
||||
await page.mouse.down()
|
||||
await expect(thumb).toHaveCSS("visibility", "hidden")
|
||||
await page.mouse.up()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", String(opened))
|
||||
await chat.evaluate(async (element) => {
|
||||
await Promise.all(element.getAnimations().map((animation) => animation.finished))
|
||||
})
|
||||
await expect(chat).toHaveAttribute("data-width-animating", "false")
|
||||
await expect(thumb).toHaveCSS("visibility", "hidden")
|
||||
// Late scroll anchoring must not bring the thumb back after the panel has settled.
|
||||
await viewport.evaluate(
|
||||
(element) =>
|
||||
new Promise<void>((resolve) => {
|
||||
element.addEventListener("scroll", () => resolve(), { once: true })
|
||||
element.scrollTop += element.scrollTop > 0 ? -1 : 1
|
||||
}),
|
||||
)
|
||||
await expect(thumb).toHaveCSS("visibility", "hidden")
|
||||
await scroll.hover()
|
||||
await expect(thumb).toHaveAttribute("data-visible", "true")
|
||||
await expect(thumb).toHaveCSS("visibility", "visible")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { sessionID, setupTimeline, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("keeps a submitted prompt in place while its optimistic rows are measured", async ({ page }) => {
|
||||
await setupTimeline(page, { messages: [userMessage()], seedHistory: true })
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await release.promise
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Observe optimistic prompt spacing.")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
const observation = await page.evaluateHandle(() => {
|
||||
const frames: { prompt?: number; working: boolean }[] = []
|
||||
let frame = 0
|
||||
const sample = () => {
|
||||
const prompt = [...document.querySelectorAll<HTMLElement>('[data-timeline-row="UserMessage"]')].find((row) =>
|
||||
row.textContent?.includes("Observe optimistic prompt spacing."),
|
||||
)
|
||||
frames.push({
|
||||
...(prompt ? { prompt: prompt.getBoundingClientRect().y } : {}),
|
||||
working: !!document.querySelector('[data-component="session-working"]'),
|
||||
})
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
frame = requestAnimationFrame(sample)
|
||||
return {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(frame)
|
||||
return frames
|
||||
},
|
||||
}
|
||||
})
|
||||
const requested = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
)
|
||||
try {
|
||||
await editor.press("Enter")
|
||||
await requested
|
||||
const prompt = page
|
||||
.locator('[data-timeline-row="UserMessage"]')
|
||||
.filter({ hasText: "Observe optimistic prompt spacing." })
|
||||
await expect(prompt).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
const frames = await observation.evaluate((value) => value.stop())
|
||||
expect(frames.some((frame) => frame.working && frame.prompt === undefined)).toBe(false)
|
||||
const positions = frames.flatMap((frame) => (frame.prompt === undefined ? [] : [frame.prompt]))
|
||||
expect(positions.length).toBeGreaterThan(0)
|
||||
expect(new Set(positions).size).toBe(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
await observation.dispose()
|
||||
}
|
||||
})
|
||||
@@ -308,7 +308,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
})
|
||||
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
|
||||
await expect(tools).toBeVisible()
|
||||
await expect(tools).toHaveText(/^Used\s*2\s*Read, Grep$/)
|
||||
await expect(tools).toHaveText(/^2 used\s*Read, Grep$/)
|
||||
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Read, Grep")
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(pending).toBeVisible()
|
||||
@@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
|
||||
|
||||
// Soft assertions let delivery run too, even when the pending ordering regresses.
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2\s*Read, Grep$/, /U2: Also check the retry path\./])
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^2 used\s*Read, Grep$/, /U2: Also check the retry path\./])
|
||||
await expect
|
||||
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
|
||||
.toHaveAttribute("data-message-id", userID)
|
||||
@@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect(response).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(tools.or(pending).or(response)).toHaveText([
|
||||
/^Used\s*2\s*Read, Grep$/,
|
||||
/^2 used\s*Read, Grep$/,
|
||||
/U2: Also check the retry path\./,
|
||||
/A3: Now checking the retry path for U2\./,
|
||||
])
|
||||
|
||||
@@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
const trigger = page.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
|
||||
.toBeGreaterThan(300)
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "../performance/timeline/session-tab-switch-probe"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
test("recovers from a failed cold history load when another session is selected", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
pageMessages: (id) => ({
|
||||
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.route(`**/api/session/${fixture.targetID}/message?*`, (route) =>
|
||||
route.fulfill({ status: 500, json: { message: "History unavailable" } }),
|
||||
)
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("focuses Find in the selected cached timeline", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
pageMessages: (id) => ({
|
||||
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
await page.keyboard.press("ControlOrMeta+f")
|
||||
const search = page.locator('[data-component="timeline-search-bar"] input')
|
||||
await expect(search).toBeFocused()
|
||||
await page.keyboard.type("History")
|
||||
await expect(search).toHaveValue("History")
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
|
||||
await page.keyboard.press("ControlOrMeta+f")
|
||||
await expect(search).toBeFocused()
|
||||
await search.press("Escape")
|
||||
await expect(search).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("disposes the old workspace's shell while destination history is loading", async ({ page }) => {
|
||||
const destination = "C:/OpenCode/OtherProject"
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const reads: string[] = []
|
||||
const output = { text: "Initial shell output\n" }
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions: fixture.sessions.map((session) =>
|
||||
session.id === fixture.targetID ? { ...session, directory: destination } : session,
|
||||
),
|
||||
pageMessages: (id) => ({
|
||||
items:
|
||||
id === fixture.sourceID
|
||||
? ([
|
||||
{ id: "msg_workspace_source", type: "user", text: "Follow the shell", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_workspace_shell",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
time: { created: 2 },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_workspace_shell",
|
||||
name: "shell",
|
||||
time: { created: 2 },
|
||||
state: {
|
||||
status: "running",
|
||||
input: { command: "run checks" },
|
||||
metadata: { shellID: "sh_workspace_source" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[])
|
||||
: [],
|
||||
}),
|
||||
beforeMessagesResponse: async ({ sessionID }) => {
|
||||
if (sessionID !== fixture.targetID) return
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
},
|
||||
})
|
||||
await page.route("**/api/shell/sh_workspace_source/output?*", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const directory = url.searchParams.get("location[directory]")!
|
||||
reads.push(directory)
|
||||
if (directory !== fixture.directory)
|
||||
return route.fulfill({ status: 404, json: { _tag: "ShellNotFoundError", id: "sh_workspace_source" } })
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory },
|
||||
data: {
|
||||
output: output.text.slice(Number(url.searchParams.get("cursor") ?? 0)),
|
||||
cursor: output.text.length,
|
||||
size: output.text.length,
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(
|
||||
(detail) =>
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
timelineDetail: { ...detail, shell: { placement: "separate", details: "expanded" } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
timelinePresets[2].value,
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
const shell = page.locator('[data-timeline-part-id="call_workspace_shell"]')
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Initial shell output")
|
||||
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
|
||||
try {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await requested.promise
|
||||
await expect(page.locator("[data-session-title]")).toHaveText(fixture.expected.targetTitle)
|
||||
output.text += "Output after returning\n"
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Output after returning")
|
||||
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
|
||||
expect(reads.length).toBeGreaterThan(1)
|
||||
expect(reads.every((directory) => directory === fixture.directory)).toBe(true)
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
|
||||
test("loads the transcript code font before opening rich history", async ({ page }) => {
|
||||
const font = page.waitForResponse((response) => /IBMPlexMono-Text[^/]*\.woff2/.test(response.url()))
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_font_source", type: "user", text: "A transcript with no code", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByText("A transcript with no code", { exact: true })).toBeVisible()
|
||||
expect((await font).ok()).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => document.fonts.check('440 13px "IBM Plex Mono"'))).toBe(true)
|
||||
})
|
||||
|
||||
test("waits for the requested session's history before constructing its cold timeline", async ({ page }) => {
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: (id) => ({ items: fixture.messages[id] ?? [] }),
|
||||
beforeMessagesResponse: async ({ sessionID }) => {
|
||||
if (sessionID !== fixture.targetID) return
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
},
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
try {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await requested.promise
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(0)
|
||||
release.resolve()
|
||||
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
|
||||
for (const grouped of [true, false]) {
|
||||
test(`restores a ${grouped ? "grouped" : "separate"} timeline after inactive updates and a resize`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const events: OpenCodeEvent[] = []
|
||||
const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
|
||||
[fixture.sourceID, fixture.targetID].map((id) => [
|
||||
id,
|
||||
[
|
||||
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
|
||||
{
|
||||
id: `msg_assistant_${id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: `tool_${id}`,
|
||||
name: "shell",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: `echo ${id}` },
|
||||
metadata: {},
|
||||
content: [{ type: "text", text: `Output for ${id}` }],
|
||||
},
|
||||
},
|
||||
{ type: "text", text: `Answer for ${id}` },
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[],
|
||||
]),
|
||||
)
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: (id) => ({ items: messages[id] ?? [] }),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(
|
||||
({ grouped, detail }) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
timelineDetail: {
|
||||
...detail,
|
||||
shell: { placement: grouped ? "grouped" : "separate", details: "collapsed" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ grouped, detail: timelinePresets[2].value },
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByText(`Answer for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
if (grouped)
|
||||
await page
|
||||
.locator(
|
||||
'[data-component="collapsed-tool-group"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"]',
|
||||
)
|
||||
.click()
|
||||
const shell = page.locator(`[data-timeline-part-id="tool_${fixture.sourceID}"]`)
|
||||
const trigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
|
||||
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(page.getByText(`Answer for ${fixture.targetID}`, { exact: true })).toBeVisible()
|
||||
await expect(shell).toHaveCount(0)
|
||||
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
|
||||
events.push({
|
||||
id: "evt_cached_text",
|
||||
created: 4,
|
||||
type: "session.text.ended",
|
||||
location: { directory: fixture.directory },
|
||||
durable: { aggregateID: fixture.sourceID, seq: 0, version: 1 },
|
||||
data: {
|
||||
sessionID: fixture.sourceID,
|
||||
assistantMessageID: `msg_assistant_${fixture.sourceID}`,
|
||||
ordinal: 0,
|
||||
text: "Updated while inactive",
|
||||
},
|
||||
})
|
||||
await page.setViewportSize({ width: 900, height: 650 })
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(page.getByText("Updated while inactive", { exact: true })).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
|
||||
expect(await original!.evaluate((element) => element.isConnected)).toBe(true)
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator("[data-timeline-key]")
|
||||
.evaluateAll((rows) =>
|
||||
rows.every(
|
||||
(row) =>
|
||||
(row.firstElementChild?.getBoundingClientRect().height ?? 0) <= row.getBoundingClientRect().height + 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
@@ -93,8 +93,8 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ })
|
||||
await expect(summary).toHaveAccessibleName("Used 1 Patch")
|
||||
const summary = group.getByRole("button", { name: /^\d+ used Patch$/ })
|
||||
await expect(summary).toHaveAccessibleName("1 used Patch")
|
||||
await summary.click()
|
||||
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
|
||||
element.setAttribute("data-disclosure-probe", "existing")
|
||||
@@ -110,7 +110,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
if (count === 3) await trigger.click()
|
||||
const id = `prt_patch_${count}`
|
||||
events.push(...toolEvents({ ...part, id, callID: id }))
|
||||
await expect(summary).toHaveAccessibleName(`Used ${count} Patch`)
|
||||
await expect(summary).toHaveAccessibleName(`${count} used Patch`)
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch")
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
|
||||
|
||||
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
|
||||
await expectAppVisible(context)
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, List")
|
||||
|
||||
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, List")
|
||||
await page.waitForTimeout(700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const labels = trace.samples
|
||||
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
expect(labels).toEqual(["Used 4 Read, Glob, Grep, List"])
|
||||
expect(labels).toEqual(["4 used Read, Glob, Grep, List"])
|
||||
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const messagePageSize = 40
|
||||
const messagePageSize = 20
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
@@ -188,7 +188,7 @@ for (const scenario of scenarios) {
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
expect(pages).toEqual([
|
||||
{ before: undefined, limit: messagePageSize },
|
||||
{ before: messages.at(-messagePageSize)!.id, limit: 20 },
|
||||
{ before: messages.at(-messagePageSize)!.id, limit: messagePageSize },
|
||||
])
|
||||
expect(roots).toEqual([])
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
for (const window of ["assistant-only", "mixed"] as const) {
|
||||
test(`renders the ${window} latest page before parent hydration and preserves it afterward`, async ({ page }) => {
|
||||
const session = { ...fixture.sessions[0]!, id: `ses_hydration_${window}` }
|
||||
// Compact's initial 40 and the next 20 begin with an assistant; page three supplies its parent.
|
||||
const messages = Array.from({ length: 61 }, (_, index): SessionMessageInfo => {
|
||||
// Both 20-message pages begin with an assistant; only page three supplies its parent.
|
||||
const messages = Array.from({ length: 41 }, (_, index): SessionMessageInfo => {
|
||||
const id = `msg_hydration_${index}`
|
||||
const time = { created: 1700000000000 + index * 1_000 }
|
||||
if (index === 0 || (window === "mixed" && index === 59))
|
||||
if (index === 0 || (window === "mixed" && index === 39))
|
||||
return { id, type: "user", time, text: `Prompt ${index}` }
|
||||
return {
|
||||
id,
|
||||
@@ -21,7 +21,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
|
||||
time: { ...time, completed: time.created + 500 },
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
agent: "build",
|
||||
content: [{ type: "text", text: index === 60 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
|
||||
content: [{ type: "text", text: index === 40 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
|
||||
}
|
||||
})
|
||||
const gates = [21, 1].map((index) => ({
|
||||
@@ -43,18 +43,18 @@ for (const window of ["assistant-only", "mixed"] as const) {
|
||||
await gate.release.promise
|
||||
},
|
||||
pageMessages: (_, limit, before) => {
|
||||
expect(limit).toBe(before ? 20 : 40)
|
||||
expect(limit).toBe(20)
|
||||
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
|
||||
},
|
||||
})
|
||||
const tail = page.locator('[data-timeline-part-id="msg_hydration_60:text:0"]')
|
||||
const tail = page.locator('[data-timeline-part-id="msg_hydration_40:text:0"]')
|
||||
const markdown = tail.locator('[data-component="markdown"]')
|
||||
const content = page.locator("[data-timeline-virtual-content]", { has: tail })
|
||||
const viewport = page.locator(".scroll-view__viewport", { has: tail })
|
||||
const orphan = page.locator('[data-timeline-row="AssistantPart"]', {
|
||||
has: page.locator('[data-timeline-part-id="msg_hydration_58:text:0"]'),
|
||||
has: page.locator('[data-timeline-part-id="msg_hydration_38:text:0"]'),
|
||||
})
|
||||
const expectReadyTail = async () => {
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
@@ -75,7 +75,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
|
||||
await expect(orphan).toHaveAttribute("data-message-id", "msg_hydration_21")
|
||||
if (window === "mixed")
|
||||
await expect(
|
||||
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_59"]'),
|
||||
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_39"]'),
|
||||
).toBeInViewport()
|
||||
const original = await markdown.elementHandle()
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ for (const locale of ["de", "ar"] as const) {
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`Used 2 ${names}`)
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`2 used ${names}`)
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(names)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
compactionFailed,
|
||||
compactionStarted,
|
||||
directory,
|
||||
event,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
@@ -86,13 +87,12 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
expect(ownerWarnings).toEqual([])
|
||||
})
|
||||
|
||||
test("renders compaction progress, summary, and outcome in order", async ({ page }) => {
|
||||
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
|
||||
},
|
||||
sessionMessages: [user, assistant(true)],
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
await timeline.send(
|
||||
@@ -104,15 +104,7 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
|
||||
const compaction = page.locator('[data-component="session-compaction-message"]')
|
||||
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
|
||||
await expect(compaction.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="session-working"]')).toHaveCount(0)
|
||||
|
||||
await page.setViewportSize({ width: 480, height: 900 })
|
||||
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeInViewport()
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
|
||||
await timeline.send(
|
||||
compactionDelta({
|
||||
@@ -122,16 +114,6 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
const running = compaction.getByRole("status").getByLabel("Compacting", { exact: true })
|
||||
await expect(running).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const status = await running.boundingBox()
|
||||
return !!summary && !!status && status.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
|
||||
await timeline.send(
|
||||
compactionEnded({
|
||||
@@ -143,18 +125,6 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
await expect(compaction).toContainText("Final implementation details.")
|
||||
await expect(compaction).not.toContainText("Streamed implementation details.")
|
||||
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const completed = await compaction.getByText("Session compacted", { exact: true }).boundingBox()
|
||||
return !!summary && !!completed && completed.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(compaction.getByRole("status")).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
|
||||
@@ -176,10 +146,7 @@ test("updates running compactions to failed and cancelled boundaries", async ({
|
||||
|
||||
const compactions = page.locator('[data-component="session-compaction-message"]')
|
||||
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
|
||||
await expect(failed.getByText("Session compaction started", { exact: true })).toBeVisible()
|
||||
await expect(failed.getByText("Session compaction failed", { exact: true })).toBeVisible()
|
||||
await expect(failed.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
await expect(failed.getByRole("status")).toHaveCount(0)
|
||||
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
|
||||
await expect(failed).not.toContainText("Partial summary that should be discarded.")
|
||||
|
||||
@@ -197,48 +164,11 @@ test("updates running compactions to failed and cancelled boundaries", async ({
|
||||
|
||||
await expect(compactions).toHaveCount(2)
|
||||
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
|
||||
await expect(cancelled.getByText("Session compaction started", { exact: true })).toBeVisible()
|
||||
await expect(cancelled.getByText("Session compaction cancelled", { exact: true })).toBeVisible()
|
||||
await expect(cancelled.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
await expect(cancelled.getByRole("status")).toHaveCount(0)
|
||||
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
|
||||
await expect(cancelled).not.toContainText("Summary before cancellation.")
|
||||
})
|
||||
|
||||
test("shows an interrupted outcome when stopping automatic compaction", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
sessionMessages: [user, assistant(true)],
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
})
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
|
||||
await timeline.send(compactionDelta({ sessionID, text: "Partial automatic summary." }))
|
||||
const compaction = page.locator('[data-component="session-compaction-message"]')
|
||||
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
|
||||
await expect(compaction).toContainText("Partial automatic summary.")
|
||||
|
||||
const request = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/interrupt`,
|
||||
)
|
||||
await page.getByRole("button", { name: "Stop", exact: true }).click()
|
||||
await request
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByText("Session compaction interrupted", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByText("Session compaction failed", { exact: true })).toHaveCount(0)
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
await expect(compaction.getByRole("status")).toHaveCount(0)
|
||||
await expect(compaction).not.toContainText("Partial automatic summary.")
|
||||
await expect(compaction).not.toContainText("Compaction was interrupted")
|
||||
})
|
||||
|
||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
settings: {
|
||||
@@ -456,7 +386,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
const used = page
|
||||
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
|
||||
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(used).toHaveText(/^Used\s*3\s*Agent, Shell$/)
|
||||
await expect(used).toHaveText(/^3 used\s*Agent, Shell$/)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -46,7 +46,7 @@ test("changes timeline presets and saves custom thinking details", async ({ page
|
||||
.toEqual({ placement: "grouped", details: "collapsed" })
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Thought", exact: true }).click()
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await part.getByRole("button").click()
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
|
||||
|
||||
@@ -45,7 +45,7 @@ test("expands a mixed collapsed tool stack without expanding its individual call
|
||||
const group = page.locator(
|
||||
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
|
||||
)
|
||||
const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, Patch", exact: true })
|
||||
const summary = group.getByRole("button", { name: "4 used Shell, Agent, Patch", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCSS("height", "28px")
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Shell, Agent, Patch")
|
||||
@@ -75,7 +75,7 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
|
||||
|
||||
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
|
||||
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
|
||||
await expect(group.getByRole("button", { name: "Used 2 Patch, Read", exact: true })).toBeVisible()
|
||||
await expect(group.getByRole("button", { name: "2 used Patch, Read", exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch, Read")
|
||||
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
|
||||
})
|
||||
@@ -114,7 +114,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
],
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
|
||||
await group.getByRole("button", { name: "2 used Shell, Patch", exact: true }).click()
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
@@ -129,7 +129,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
),
|
||||
),
|
||||
)
|
||||
await expect(group.getByRole("button", { name: "Used 3 Shell, Patch", exact: true })).toHaveAttribute(
|
||||
await expect(group.getByRole("button", { name: "3 used Shell, Patch", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
@@ -162,7 +162,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: "Used 2 Glob, Grep", exact: true })
|
||||
const summary = group.getByRole("button", { name: "2 used Glob, Grep", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await summary.click()
|
||||
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
|
||||
|
||||
@@ -181,7 +181,7 @@ for (const grouped of [false, true]) {
|
||||
await expect(working).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
const trigger = group.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true })
|
||||
const trigger = group.getByRole("button", { name: "2 used Shell", exact: true, includeHidden: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
test("keeps five loaded workspace tabs visible and reactive through repeated switches", async ({ page }, info) => {
|
||||
const sessions = Array.from({ length: 5 }, (_, index) => ({
|
||||
...fixture.sessions[0]!,
|
||||
id: `ses_workspace_cycle_${index}`,
|
||||
directory: `${fixture.directory}/worktree-${index}`,
|
||||
title: `Workspace session ${index}`,
|
||||
}))
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions,
|
||||
pageMessages: (id) => ({
|
||||
items: [
|
||||
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
|
||||
{
|
||||
id: `msg_assistant_${id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [{ type: "text", text: `Answer for ${id}` }],
|
||||
},
|
||||
] satisfies SessionMessageInfo[],
|
||||
}),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await page.route("**/api/location?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
directory: new URL(route.request().url()).searchParams.get("location[directory]"),
|
||||
project: { id: fixture.project.id, directory: fixture.directory, canonical: fixture.directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
|
||||
await page.goto(stressSessionHref(sessions[0]!.id))
|
||||
await expect(page.getByText(`Answer for ${sessions[0]!.id}`, { exact: true })).toBeVisible()
|
||||
|
||||
for (const session of [...sessions.slice(1), ...sessions, ...sessions.toReversed()]) {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(session.id)}"]`).click()
|
||||
await expect(page.locator(`[data-timeline-part-id="msg_assistant_${session.id}:text:0"]`)).toBeVisible()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
}
|
||||
const active = sessions[0]!
|
||||
events.push({
|
||||
id: "evt_workspace_cycle_update",
|
||||
created: 4,
|
||||
type: "session.text.ended",
|
||||
location: { directory: active.directory },
|
||||
durable: { aggregateID: active.id, seq: 0, version: 1 },
|
||||
data: {
|
||||
sessionID: active.id,
|
||||
assistantMessageID: `msg_assistant_${active.id}`,
|
||||
ordinal: 0,
|
||||
text: "Still receiving updates",
|
||||
},
|
||||
})
|
||||
await expect(page.getByText("Still receiving updates", { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: info.outputPath("workspace-tabs.png") })
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
test.describe(colorScheme, () => {
|
||||
test.use({ colorScheme, contextOptions: { reducedMotion: "reduce" } })
|
||||
|
||||
test("project card edges stay inside the settings scrollport", async ({ page }, info) => {
|
||||
const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) => ({
|
||||
id: `project-${index}`,
|
||||
name,
|
||||
canonical: `/projects/${name}`,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}))
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: "/projects/rebase",
|
||||
project: projects[0],
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
})
|
||||
await page.route("**/api/project", (route) =>
|
||||
route.fulfill({ json: projects, headers: { "access-control-allow-origin": "*" } }),
|
||||
)
|
||||
await page.addInitScript((projects) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: projects.map((project) => ({ worktree: project.canonical, expanded: true })) },
|
||||
}),
|
||||
)
|
||||
}, projects)
|
||||
await page.goto("/")
|
||||
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeEnabled()
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
const panel = settings.getByRole("tabpanel")
|
||||
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("Playground", { exact: true })).toBeVisible()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
|
||||
for (const width of [1280, 1050, 960, 720, 600]) {
|
||||
await page.setViewportSize({ width, height: 720 })
|
||||
await page.mouse.move(0, 0)
|
||||
await page.screenshot({ path: info.outputPath(`projects-${width}.png`), animations: "disabled" })
|
||||
// Raised cards paint a half-pixel border outside their box. The scrollport
|
||||
// must leave room for that border and the soft shadow on both sides.
|
||||
await expect
|
||||
.poll(() =>
|
||||
panel.getByText("rebase", { exact: true }).evaluate((label) => {
|
||||
const row = label.parentElement!.parentElement!
|
||||
const bounds = row.getBoundingClientRect()
|
||||
const clips = []
|
||||
for (let parent = row.parentElement; parent; parent = parent.parentElement) {
|
||||
if (getComputedStyle(parent).overflowX === "visible") continue
|
||||
const clip = parent.getBoundingClientRect()
|
||||
clips.push(bounds.left - clip.left, clip.right - bounds.right)
|
||||
}
|
||||
return Math.min(...clips)
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(4)
|
||||
await expect(panel).toHaveJSProperty("scrollWidth", await panel.evaluate((el) => el.clientWidth))
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await panel.getByText("rebase", { exact: true }).hover()
|
||||
await panel.getByText("rebase", { exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox")).toHaveValue("rebase")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 260 })
|
||||
await panel.getByText("rebase", { exact: true }).hover()
|
||||
await page.mouse.wheel(0, 400)
|
||||
await expect(panel.getByText("Playground", { exact: true })).toBeInViewport({ ratio: 1 })
|
||||
await expect(panel.getByRole("heading", { name: "Projects", exact: true })).toBeInViewport({ ratio: 1 })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -51,7 +51,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
|
||||
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
|
||||
await Promise.all([
|
||||
@@ -76,7 +76,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await requested.promise
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
|
||||
@@ -194,7 +194,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
async function openChildFromParent(page: Page) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
|
||||
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
@@ -80,7 +80,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const id = state.connections
|
||||
let ended = false
|
||||
let own: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let keepalive: ReturnType<typeof setInterval> | undefined
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
own = controller
|
||||
@@ -90,15 +89,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
|
||||
// Match the real server's idle stream so long scenarios do not
|
||||
// trigger the client's 45-second stall watchdog and reload history.
|
||||
keepalive = setInterval(() => controller.enqueue(encoder.encode(": keepalive\n\n")), 15_000)
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (ended) return
|
||||
ended = true
|
||||
clearInterval(keepalive)
|
||||
if (state.controller === controller) state.controller = undefined
|
||||
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
|
||||
},
|
||||
@@ -108,7 +103,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
cancel() {
|
||||
if (ended) return
|
||||
ended = true
|
||||
clearInterval(keepalive)
|
||||
if (state.controller === own) state.controller = undefined
|
||||
},
|
||||
})
|
||||
|
||||
@@ -132,6 +132,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
</Show>
|
||||
<form
|
||||
data-component="composer"
|
||||
data-background-surface="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={{
|
||||
|
||||
@@ -88,12 +88,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy && input.adapter.kind === "new-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
|
||||
if (optimisticBusy && input.adapter.kind === "active-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
}).then(
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
@@ -363,8 +359,7 @@ async function applySelection(
|
||||
async function sendPrompt(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
track: ModelSelection["trackSessionCommit"] | undefined,
|
||||
onAdmit: () => void,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// Switching agent or model reconfigures the session immediately, and with it
|
||||
@@ -394,9 +389,7 @@ async function sendPrompt(
|
||||
},
|
||||
},
|
||||
}
|
||||
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
onAdmit()
|
||||
await sending
|
||||
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
}
|
||||
|
||||
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
|
||||
|
||||
@@ -19,6 +19,7 @@ export function Home() {
|
||||
const scroll = createHomeScrollController(sessions.data.groups)
|
||||
return (
|
||||
<div
|
||||
data-background-surface="panel"
|
||||
class={`
|
||||
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
|
||||
|
||||
+41
-63
@@ -3,6 +3,38 @@
|
||||
@import "@opencode/ui/styles/tokens";
|
||||
@import "tw-animate-css";
|
||||
|
||||
[data-component="app-shell"][data-background-image] {
|
||||
background-image: linear-gradient(rgb(255 255 255 / 28%), rgb(255 255 255 / 28%)), var(--app-background-image);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="app-shell"][data-background-image] {
|
||||
background-image: linear-gradient(rgb(0 0 0 / 28%), rgb(0 0 0 / 28%)), var(--app-background-image);
|
||||
}
|
||||
|
||||
[data-component="app-shell"][data-background-image] .bg-v2-background-bg-deep {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
[data-component="app-shell"][data-background-image] [data-background-surface="panel"] {
|
||||
background-color: color-mix(in srgb, var(--v2-background-bg-base) 72%, transparent);
|
||||
}
|
||||
|
||||
[data-component="app-shell"][data-background-image] [data-background-surface="canvas"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
[data-component="app-shell"][data-background-image] [data-background-surface="composer"] {
|
||||
background-color: color-mix(in srgb, var(--v2-background-bg-base) 55%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
[data-component="app-shell"][data-background-image] [data-component="new-session-tip"][data-kind="provider"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono Nerd Font Mono";
|
||||
src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
|
||||
@@ -38,65 +70,12 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-slot="session-chat-panel"][data-scrollbar-hidden="true"]
|
||||
[data-slot="session-timeline-scroll"]
|
||||
> .scroll-view__thumb {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
[data-slot="session-side-panel-presence"][data-opened="true"] {
|
||||
animation: side-region-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation: terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="session-side-panel-presence"][data-opened="false"] {
|
||||
animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
#review-panel {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Like the composer toolbar, fade only clipped content without reserving layout space.
|
||||
The fade contracts as overflow clears; the second mask preserves the header divider. */
|
||||
#review-panel .session-review-v2-tabs-bar {
|
||||
--session-review-header-fade: clamp(0px, calc(100% - 100cqi), 24px);
|
||||
mask-image:
|
||||
linear-gradient(
|
||||
to right,
|
||||
#000 calc(100cqi - 40px - var(--session-review-header-fade)),
|
||||
transparent calc(100cqi - 40px)
|
||||
),
|
||||
linear-gradient(to top, #000 1px, transparent 1px);
|
||||
|
||||
&:dir(rtl) {
|
||||
mask-image:
|
||||
linear-gradient(
|
||||
to left,
|
||||
#000 calc(100cqi - 40px - var(--session-review-header-fade)),
|
||||
transparent calc(100cqi - 40px)
|
||||
),
|
||||
linear-gradient(to top, #000 1px, transparent 1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* The panel's width animation supplies the slide; only fade its fixed-width contents. */
|
||||
[data-slot="session-side-region-presence"][data-opened] [data-slot="session-review-content"] {
|
||||
transition: opacity 200ms ease-out 40ms;
|
||||
}
|
||||
|
||||
/* Cached contents must stay transparent even after the presence animation finishes. */
|
||||
#review-panel[aria-hidden="true"] > [data-slot="session-review-content"] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="false"] [data-slot="session-review-content"] {
|
||||
transition: opacity 160ms ease-out;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
[data-slot="session-side-region-presence"][data-opened="true"] [data-slot="session-review-content"] {
|
||||
opacity: 0;
|
||||
}
|
||||
animation: terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="true"] {
|
||||
@@ -135,15 +114,10 @@
|
||||
[data-slot="terminal-panel-presence"],
|
||||
[data-slot="side-terminal-panel-presence"],
|
||||
[data-slot="session-side-panel-presence"],
|
||||
[data-slot="session-review-content"],
|
||||
[data-slot="session-side-region-presence"],
|
||||
[data-component="terminal-panel"] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
[data-slot="session-review-content"] {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-presence-in {
|
||||
@@ -183,9 +157,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Presence needs an animation lifetime, but the panel frame must never fade. */
|
||||
@keyframes side-region-presence-in {
|
||||
from,
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
0.01% {
|
||||
opacity: 0.999999;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -196,7 +174,7 @@
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
opacity: 0.999999;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export function NewSessionView(props: {
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div
|
||||
data-component="new-session"
|
||||
data-background-surface="canvas"
|
||||
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
|
||||
>
|
||||
<ComposerDropzone
|
||||
@@ -194,6 +195,7 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
|
||||
<div
|
||||
ref={setRef}
|
||||
data-component="new-session-tip"
|
||||
data-kind={displayed()}
|
||||
data-visible={tip() !== undefined}
|
||||
class="group/new-session-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class="min-w-0"
|
||||
>
|
||||
<Menu placement="bottom" gutter={4} overflowPadding={24} onOpenChange={onOpenChange}>
|
||||
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<Menu.Trigger
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
@@ -148,7 +148,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Menu.Sub
|
||||
gutter={0}
|
||||
overlap
|
||||
overflowPadding={24}
|
||||
overflowPadding={8}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
focusSearch = false
|
||||
@@ -177,7 +177,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
</span>
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.SubContent class="max-h-[66.667dvh] w-[200px] overflow-y-auto !pb-0 [&>[data-component=menu-v2-item]:last-child]:mb-0.5 [@media(max-height:600px)]:max-h-[calc(100dvh-48px)]">
|
||||
<Menu.SubContent class="max-h-[224px] w-[200px] overflow-y-auto">
|
||||
<Show when={props.workspaces.length >= 10}>
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
@@ -242,7 +242,14 @@ export function PromptWorkspaceSelector(props: {
|
||||
class="ms-1 min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<Menu
|
||||
placement="bottom"
|
||||
gutter={4}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange(open)
|
||||
if (open) requestAnimationFrame(() => branchSearchInput?.focus())
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
|
||||
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={branchTruncation.observe} class="min-w-0 truncate">
|
||||
@@ -251,14 +258,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
// Kobalte defers its list autofocus until after the focus scope opens.
|
||||
setTimeout(() => requestAnimationFrame(() => branchSearchInput?.focus({ preventScroll: true })))
|
||||
}}
|
||||
>
|
||||
<Menu.Content class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
|
||||
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
|
||||
@@ -107,7 +107,6 @@ export const dict = {
|
||||
"command.session.new": "New session",
|
||||
"command.file.open": "Open file",
|
||||
"command.browser.open": "Open browser",
|
||||
"command.browser.reload": "Reload browser page",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.tab.reopenClosed": "Reopen closed tab",
|
||||
"command.context.addSelection": "Add selection to context",
|
||||
@@ -769,8 +768,7 @@ export const dict = {
|
||||
"session.queue.steerTooltip": "Send without interrupting",
|
||||
"session.queue.remove": "Remove",
|
||||
"session.queue.reorder": "Reorder queued prompt",
|
||||
"session.queue.attachments.one": "Plus {{count}} attachment",
|
||||
"session.queue.attachments.other": "Plus {{count}} attachments",
|
||||
"session.queue.attachments": "+ attachments",
|
||||
"session.timeline.working": "Working",
|
||||
"session.timeline.notice.finished": "{{actor}} finished",
|
||||
"session.timeline.notice.failed": "{{actor}} failed",
|
||||
@@ -898,7 +896,7 @@ export const dict = {
|
||||
"session.browser.address": "Browser address",
|
||||
"session.browser.replaced": "Browser control moved to another desktop window.",
|
||||
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
|
||||
"session.browser.address.placeholder": "Enter URL",
|
||||
"session.browser.address.placeholder": "Enter a URL",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
@@ -1030,6 +1028,13 @@ export const dict = {
|
||||
"settings.appearance.row.tabs.vertical": "Vertical",
|
||||
"settings.appearance.row.projectName.title": "Show project names",
|
||||
"settings.appearance.row.projectName.description": "Show project names in vertical tabs and the mobile tab drawer",
|
||||
"settings.appearance.row.backgroundImage.title": "Background image",
|
||||
"settings.appearance.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.appearance.row.backgroundImage.choose": "Choose image",
|
||||
"settings.appearance.row.backgroundImage.remove": "Remove",
|
||||
"settings.appearance.row.backgroundImage.pickerTitle": "Choose a background image",
|
||||
"settings.appearance.row.backgroundImage.error.unsupported": "Choose a PNG, JPEG, GIF, WebP, AVIF, or BMP image.",
|
||||
"settings.appearance.row.backgroundImage.error.too-large": "Background images must be 20 MB or smaller.",
|
||||
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
|
||||
"settings.shortcuts.description": "Customize shortcuts for common actions",
|
||||
"settings.servers.description": "Manage server connections",
|
||||
@@ -1126,7 +1131,7 @@ export const dict = {
|
||||
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
|
||||
"settings.general.row.browserPane.title": "Browser",
|
||||
"settings.general.row.browserPane.title": "Browser pane",
|
||||
"settings.general.row.browserPane.description": "Allow agents to open and control an in-app development browser.",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||
|
||||
@@ -34,27 +34,6 @@ function setup(input?: {
|
||||
}
|
||||
|
||||
describe("createRequestQueue", () => {
|
||||
test("starts a free slot before the caller continues its synchronous work", async () => {
|
||||
const input = setup()
|
||||
const response = input.queue.fetch("http://server/api/session")
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/session"])
|
||||
expect(input.queue.inflight()).toBe(1)
|
||||
input.pending[0]!.resolve()
|
||||
await response
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("releases a free slot without sending an already-aborted request", async () => {
|
||||
const input = setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(input.queue.fetch("http://server/api/session", { signal: controller.signal })).rejects.toBeInstanceOf(
|
||||
DOMException,
|
||||
)
|
||||
expect(input.pending).toHaveLength(0)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("caps concurrent requests and starts queued ones as slots free up", async () => {
|
||||
const input = setup()
|
||||
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
|
||||
@@ -130,10 +109,12 @@ describe("createRequestQueue", () => {
|
||||
const input = setup({ limit: 1, headersTimeoutMs: 10 })
|
||||
const dead = input.queue.fetch("http://server/api/dead")
|
||||
const next = input.queue.fetch("http://server/api/next")
|
||||
await input.settle()
|
||||
expect(input.queue.queued()).toBe(1)
|
||||
const error = await dead.catch((cause: unknown) => cause)
|
||||
expect(error).toBeInstanceOf(DOMException)
|
||||
expect((error as DOMException).name).toBe("TimeoutError")
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/dead", "/api/next"])
|
||||
input.pending[1]!.resolve()
|
||||
await expect(next).resolves.toBeInstanceOf(Response)
|
||||
|
||||
@@ -84,23 +84,17 @@ export function createRequestQueue(input: {
|
||||
if (index === -1) return
|
||||
waiting.splice(index, 1)[0]?.start()
|
||||
}
|
||||
const acquire = (entry: Entry) => {
|
||||
// A free slot must start fetch before the caller's synchronous UI work.
|
||||
// Awaiting an already-resolved promise postpones that dispatch until after it.
|
||||
if (canStart(entry)) {
|
||||
inflight.add(entry)
|
||||
return
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
const acquire = (entry: Entry) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const start = () => {
|
||||
entry.at = now()
|
||||
inflight.add(entry)
|
||||
resolve()
|
||||
}
|
||||
if (canStart(entry)) return start()
|
||||
waiting.push({ entry, start })
|
||||
watcher ??= setTimeout(watch, stallMs)
|
||||
})
|
||||
}
|
||||
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (resource: RequestInfo | URL, init?: RequestInit) => {
|
||||
@@ -109,8 +103,7 @@ export function createRequestQueue(input: {
|
||||
// The event stream is long-lived; never count it against the request budget.
|
||||
if (pathname === "/api/event") return base(request)
|
||||
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
|
||||
const queued = acquire(entry)
|
||||
if (queued) await queued
|
||||
await acquire(entry)
|
||||
if (request.signal.aborted) {
|
||||
release(entry)
|
||||
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
|
||||
|
||||
@@ -16,8 +16,6 @@ import { ModelState } from "./persistence"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "./errors"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -133,12 +131,10 @@ function createServerController(
|
||||
projects: ReturnType<typeof createServerProjects>,
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const connKey = ServerConnection.key(conn)
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const source = createData({
|
||||
api: () => sdk.api,
|
||||
initialMessageLimit: () => (timelinePreset(settings.general.timelineDetail())?.id === "compact" ? 40 : 20),
|
||||
event: {
|
||||
on: sdk.event.on,
|
||||
listen: (handler) => sdk.event.listen((event) => handler({ name: event.type, details: event })),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
@@ -10,16 +8,13 @@ import { createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import type { createSessionBrowser } from "./model"
|
||||
|
||||
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const state = props.browser.active
|
||||
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
|
||||
const registration = props.browser.registration
|
||||
const button = { variant: "ghost", size: "large" } as const
|
||||
const [store, setStore] = createStore({
|
||||
@@ -28,28 +23,12 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
})
|
||||
let surface: HTMLDivElement | undefined
|
||||
let addressDisplay: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let layout: string | undefined
|
||||
let until = 0
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const paint = canvas.getContext("2d", { willReadFrequently: true })
|
||||
const scheme = () => store.address.match(/^https?:\/\//i)?.[0] ?? ""
|
||||
|
||||
command.register("browser.navigation", () => [
|
||||
{
|
||||
id: "browser.reload",
|
||||
title: language.t("command.browser.reload"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "f5",
|
||||
disabled: !props.visible || !state(),
|
||||
onSelect: () => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// The native page always paints above the DOM, so hide it while a floating
|
||||
// menu, select, or popover overlaps it. Tooltips are excluded.
|
||||
@@ -107,14 +86,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
createEffect(() => !store.editing && setStore("address", address()))
|
||||
createEffect(
|
||||
on(registration, (current) => {
|
||||
// Session routes can change before this pane unmounts. Hide the registration
|
||||
// that owned the native view, rather than reading the destination's handle.
|
||||
onCleanup(() => current?.setLayout())
|
||||
}),
|
||||
)
|
||||
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
@@ -147,62 +119,42 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
registration()?.setLayout()
|
||||
})
|
||||
|
||||
return (
|
||||
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
|
||||
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: direction, tabID: tab.id })
|
||||
}}
|
||||
icon={
|
||||
<Icon
|
||||
name={direction === "back" ? "chevron-left" : "chevron-right"}
|
||||
size="small"
|
||||
class="rtl:rotate-180"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
|
||||
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: direction, tabID: tab.id })
|
||||
}}
|
||||
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}</span>
|
||||
<Show when={!state()?.loading}>
|
||||
<Keybind keys={command.keybindParts("browser.reload")} variant="neutral" />
|
||||
</Show>
|
||||
</div>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
|
||||
}}
|
||||
icon={
|
||||
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
|
||||
<Loader />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
|
||||
}}
|
||||
icon={
|
||||
<Show when={state()?.loading} fallback={<Icon name="refresh" size="small" />}>
|
||||
<Loader />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
<form
|
||||
dir="ltr"
|
||||
class="relative min-w-0 flex-1 h-7 rounded-md hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover text-12-regular"
|
||||
class="min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const tab = state()
|
||||
@@ -211,30 +163,15 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
}}
|
||||
>
|
||||
<input
|
||||
class="w-full h-full px-2 rounded-md border border-transparent bg-transparent text-transparent caret-v2-text-text-base placeholder:text-v2-text-text-faint outline-none focus:border-v2-border-border-focus"
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
|
||||
value={store.address}
|
||||
disabled={!state()}
|
||||
placeholder={language.t("session.browser.address.placeholder")}
|
||||
aria-label={language.t("session.browser.address")}
|
||||
onFocus={() => setStore("editing", true)}
|
||||
onBlur={() => setStore({ editing: false, address: address() })}
|
||||
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
|
||||
onInput={(event) => setStore("address", event.currentTarget.value)}
|
||||
onScroll={(event) => {
|
||||
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
|
||||
}}
|
||||
/>
|
||||
{/* Keep native input editing and selection while coloring the scheme, including during editing. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 flex items-center px-2 border border-transparent pointer-events-none"
|
||||
>
|
||||
<div ref={addressDisplay} class="w-full overflow-hidden whitespace-pre text-v2-text-text-base">
|
||||
<span class="text-v2-text-text-muted">{scheme()}</span>
|
||||
{store.address.slice(scheme().length)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<Show when={props.browser.error()}>
|
||||
|
||||
@@ -23,12 +23,10 @@ export function SessionQueuePanel(props: { queue: SessionQueueView }) {
|
||||
<Show when={count() > 0}>
|
||||
<div
|
||||
data-component="session-queue-panel"
|
||||
class="relative z-0 -mb-3 rounded-xl bg-v2-background-bg-base px-1.5 pt-1.5 shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)]"
|
||||
// Match the composer overlap so the scroll crop meets the input edge.
|
||||
classList={{ "pb-3": count() > 4, "pb-[18px]": count() <= 4 }}
|
||||
class="relative z-0 -mb-3 rounded-xl bg-v2-background-bg-base px-1.5 pt-1.5 pb-[18px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)]"
|
||||
>
|
||||
<Show when={count() > 3}>
|
||||
<div class="px-1.5 pt-1 pb-px text-[11px] font-[530] uppercase leading-[var(--line-height-tight)] tracking-[0.05px] text-v2-text-text-muted [font-variant-numeric:tabular-nums]">
|
||||
<div class="px-1.5 pb-px text-[11px] font-[530] uppercase leading-[var(--line-height-tight)] tracking-[0.05px] text-v2-text-text-muted [font-variant-numeric:tabular-nums]">
|
||||
{language.plural("session.queue.count", count())}
|
||||
</div>
|
||||
</Show>
|
||||
@@ -60,17 +58,10 @@ export function SessionQueuePanel(props: { queue: SessionQueueView }) {
|
||||
>
|
||||
{/* Keyed on row IDs so store updates move row elements instead of
|
||||
remounting them, which would kill an in-flight drag. */}
|
||||
{/* Four 32px rows, four 1px gaps, and half a row hint at more queued prompts. */}
|
||||
<div
|
||||
ref={listRef}
|
||||
class="flex flex-col gap-px"
|
||||
classList={{ "max-h-[148px] overflow-y-auto": count() > 4 }}
|
||||
style={{
|
||||
"mask-image":
|
||||
count() > 4
|
||||
? "linear-gradient(to bottom, transparent, black 8px, black calc(100% - 12px), transparent)"
|
||||
: undefined,
|
||||
}}
|
||||
classList={{ "max-h-[131px] overflow-y-auto": count() > 3 }}
|
||||
>
|
||||
<For each={props.queue.rows().map((row) => row.id)}>
|
||||
{(id, index) => <SessionQueueRow queue={props.queue} id={id} index={index()} />}
|
||||
@@ -107,7 +98,7 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
|
||||
<div
|
||||
ref={sortable.ref}
|
||||
data-component="session-queue-row"
|
||||
class="group/queue-row flex h-8 shrink-0 items-center justify-between gap-2 rounded-md py-1 ps-1 pe-2"
|
||||
class="group/queue-row flex items-center justify-between gap-2 rounded-md py-1 ps-1 pe-2"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover": editing(),
|
||||
"opacity-60": sortable.isDragSource(),
|
||||
@@ -124,7 +115,7 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
|
||||
{() => <span class="size-[2px] bg-v2-background-bg-layer-04" />}
|
||||
</For>
|
||||
</button>
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
data-action="session-queue-edit"
|
||||
@@ -137,12 +128,11 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
|
||||
}}
|
||||
onClick={() => props.queue.edit(props.id)}
|
||||
>
|
||||
{entry.text ||
|
||||
(entry.attachments ? language.plural("session.queue.attachments", entry.attachments) : "")}
|
||||
{entry.text || (entry.attachments ? language.t("session.queue.attachments") : "")}
|
||||
</button>
|
||||
<Show when={entry.attachments && entry.text}>
|
||||
<span class="shrink-0 whitespace-nowrap text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
|
||||
{language.plural("session.queue.attachments", entry.attachments)}
|
||||
<span class="text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
|
||||
{language.t("session.queue.attachments")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -24,20 +24,20 @@ const queued = [
|
||||
describe("queuedPromptRows", () => {
|
||||
test("keeps the edited prompt to one row while its replacement is admitted", () => {
|
||||
expect(queuedPromptRows(queued, { original: "msg_original", replacement: "msg_replacement" })).toEqual([
|
||||
{ id: "msg_replacement", text: "edited", attachments: 0 },
|
||||
{ id: "msg_replacement", text: "edited", attachments: false },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps the original visible until its replacement appears", () => {
|
||||
expect(queuedPromptRows([queued[0]], { original: "msg_original", replacement: "msg_replacement" })).toEqual([
|
||||
{ id: "msg_original", text: "original", attachments: 0 },
|
||||
{ id: "msg_original", text: "original", attachments: false },
|
||||
])
|
||||
})
|
||||
|
||||
test("retains unrelated queue entries", () => {
|
||||
expect(queuedPromptRows(queued)).toEqual([
|
||||
{ id: "msg_original", text: "original", attachments: 0 },
|
||||
{ id: "msg_replacement", text: "edited", attachments: 0 },
|
||||
{ id: "msg_original", text: "original", attachments: false },
|
||||
{ id: "msg_replacement", text: "edited", attachments: false },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -47,8 +47,8 @@ describe("queuedPromptRows", () => {
|
||||
expect(
|
||||
queuedPromptRows([queued[0], other, queued[1]], { original: "msg_original", replacement: "msg_replacement" }),
|
||||
).toEqual([
|
||||
{ id: "msg_other", text: "other", attachments: 0 },
|
||||
{ id: "msg_replacement", text: "edited", attachments: 0 },
|
||||
{ id: "msg_other", text: "other", attachments: false },
|
||||
{ id: "msg_replacement", text: "edited", attachments: false },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -237,7 +237,7 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
text: queuedPromptText(item),
|
||||
attachments: item.payload.files?.length ?? 0,
|
||||
attachments: (item.payload.files?.length ?? 0) > 0,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -272,11 +272,7 @@ export function SessionSidePanel(props: {
|
||||
style={{ width: panelWidth() }}
|
||||
>
|
||||
<Show when={visible()}>
|
||||
<div
|
||||
data-slot="session-review-content"
|
||||
class="h-full flex shrink-0"
|
||||
style={{ width: "var(--session-side-content-width, 100%)" }}
|
||||
>
|
||||
<div class="size-full flex">
|
||||
<Show when={reviewVisible()}>
|
||||
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-v2-background-bg-base">
|
||||
<div class="size-full min-w-0 h-full bg-v2-background-bg-base">
|
||||
@@ -396,11 +392,9 @@ export function SessionSidePanel(props: {
|
||||
ariaControls={activeTab() === tab ? browserTabPanelID : undefined}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="globe" size="small" />
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span class="max-w-40 truncate">
|
||||
{!item().url || item().url === "about:blank"
|
||||
? language.t("session.tab.browser")
|
||||
: item().title || item().url}
|
||||
{item().title || language.t("session.tab.browser")}
|
||||
</span>
|
||||
</div>
|
||||
</SortableTab>
|
||||
@@ -510,7 +504,7 @@ export function SessionSidePanel(props: {
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="file-tree" size="small" />
|
||||
<Icon name="open-file" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
@@ -524,7 +518,7 @@ export function SessionSidePanel(props: {
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="globe" size="small" />
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span>{language.t("session.tab.browser")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
@@ -543,7 +537,7 @@ export function SessionSidePanel(props: {
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<OpenInAppButton directory={projectDirectory} />
|
||||
<Show when={reviewVisible()}>
|
||||
<Show when={reviewOpen()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -55,11 +55,6 @@ export function SessionHeaderActions(props: { state: SessionHeaderActionsState }
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
style={{
|
||||
// This fixed control sits above moving panel contents.
|
||||
"--v2-overlay-simple-overlay-hover": "var(--v2-background-bg-layer-01)",
|
||||
"--v2-overlay-simple-overlay-pressed": "var(--v2-background-bg-layer-02)",
|
||||
}}
|
||||
state={props.state.reviewOpened ? "pressed" : undefined}
|
||||
onClick={props.state.onReviewToggle}
|
||||
aria-label={props.state.reviewLabel}
|
||||
|
||||
@@ -2,13 +2,15 @@ import { Show } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
|
||||
export function SessionHeader(props: { reserveReviewToggle: boolean }) {
|
||||
export function SessionHeader() {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const { view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
@@ -21,7 +23,7 @@ export function SessionHeader(props: { reserveReviewToggle: boolean }) {
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</TitlebarRight>
|
||||
<Show when={isDesktop() && props.reserveReviewToggle}>
|
||||
<Show when={isDesktop() && !view().reviewPanel.opened()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</>
|
||||
|
||||
@@ -34,7 +34,6 @@ import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
import { createTimelineCache } from "./timeline/cache"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -62,8 +61,6 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
const [store, setStore] = createStore({
|
||||
deferRender: false,
|
||||
bottomTerminalCached: false,
|
||||
sideWidthMotion: false,
|
||||
timelineScrollbarHidden: false,
|
||||
sideHeightMotion: false,
|
||||
sideRegionPresent: false,
|
||||
sideReviewPresent: false,
|
||||
@@ -112,16 +109,6 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
sideMotion().animateRegion ||
|
||||
sideMotion().animateTerminal ||
|
||||
bottomTerminalPresence.animate()
|
||||
const trackSideWidthMotion = (event: TransitionEvent) => {
|
||||
if (event.currentTarget !== event.target || event.propertyName !== "width") return
|
||||
setStore("sideWidthMotion", event.type === "transitionrun")
|
||||
}
|
||||
const hideTimelineScrollbar = () => setStore("timelineScrollbarHidden", true)
|
||||
const revealTimelineScrollbar = (event: Event) => {
|
||||
if (!store.timelineScrollbarHidden || store.sideWidthMotion) return
|
||||
if (!(event.target instanceof Element) || !event.target.closest('[data-slot="session-timeline-scroll"]')) return
|
||||
setStore("timelineScrollbarHidden", false)
|
||||
}
|
||||
createEffect(() => {
|
||||
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
|
||||
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
|
||||
@@ -220,45 +207,6 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</Show>
|
||||
)
|
||||
|
||||
const timelineView = createTimelineCache(
|
||||
session,
|
||||
(source, active) => (
|
||||
<MessageTimeline
|
||||
active={active()}
|
||||
hideHeader={!isDesktop()}
|
||||
session={source}
|
||||
background={composer.requests.background}
|
||||
actions={composer.actions.timeline}
|
||||
scroll={timeline.scroll}
|
||||
onResumeScroll={timeline.actions.resume}
|
||||
setScrollRef={timeline.view.setScrollRef}
|
||||
onScheduleScrollState={timeline.view.scheduleScrollState}
|
||||
onPin={timeline.view.pin}
|
||||
onUnpin={timeline.view.unpin}
|
||||
onUserScroll={timeline.view.markUserScroll}
|
||||
onHistoryScroll={timeline.view.onHistoryScroll}
|
||||
onSelectionInteraction={timeline.view.selectionInteraction}
|
||||
pinned={timeline.view.pinned()}
|
||||
centered={screen.centered()}
|
||||
reserveReviewToggle={!sideVisible()}
|
||||
setContentRef={timeline.view.setContentRef}
|
||||
diffs={review.details.diffs}
|
||||
onReview={review.open}
|
||||
workspaceMoveEligible={composer.workspaceMoveEligible()}
|
||||
onSummaryOpenChange={review.details.setOpen}
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
setScrollToEnd={timeline.view.setScrollToEnd}
|
||||
search={
|
||||
<Show when={active()}>
|
||||
<TimelineSearchBar controller={timelineSearch} />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
),
|
||||
() => conversationVisible() && messagesReady(),
|
||||
)
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
<ComposerDropzone
|
||||
@@ -298,7 +246,36 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<Show when={isDesktop() && !messagesReady()}>
|
||||
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
|
||||
</Show>
|
||||
<Show when={messagesReady() && session.identity.params.id}>{timelineView()}</Show>
|
||||
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
hideHeader={!isDesktop()}
|
||||
session={session}
|
||||
background={composer.requests.background}
|
||||
actions={composer.actions.timeline}
|
||||
scroll={timeline.scroll}
|
||||
onResumeScroll={timeline.actions.resume}
|
||||
setScrollRef={timeline.view.setScrollRef}
|
||||
onScheduleScrollState={timeline.view.scheduleScrollState}
|
||||
onPin={timeline.view.pin}
|
||||
onUnpin={timeline.view.unpin}
|
||||
onUserScroll={timeline.view.markUserScroll}
|
||||
onHistoryScroll={timeline.view.onHistoryScroll}
|
||||
onSelectionInteraction={timeline.view.selectionInteraction}
|
||||
pinned={timeline.view.pinned()}
|
||||
centered={screen.centered()}
|
||||
setContentRef={timeline.view.setContentRef}
|
||||
diffs={review.details.diffs}
|
||||
onReview={review.open}
|
||||
workspaceMoveEligible={composer.workspaceMoveEligible()}
|
||||
onSummaryOpenChange={review.details.setOpen}
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
setScrollToEnd={timeline.view.setScrollToEnd}
|
||||
search={<TimelineSearchBar controller={timelineSearch} />}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
@@ -319,8 +296,6 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
class="absolute end-3 top-0 z-30 flex items-center"
|
||||
classList={{ "h-[51px]": sideTerminalVisible(), "h-12": !sideTerminalVisible() }}
|
||||
data-slot="session-review-toggle"
|
||||
onPointerDown={hideTimelineScrollbar}
|
||||
onClick={hideTimelineScrollbar}
|
||||
>
|
||||
<SessionReviewToggle />
|
||||
</div>
|
||||
@@ -328,20 +303,11 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<div
|
||||
classList={{
|
||||
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)] will-change-[width] motion-reduce:transition-none":
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!screen.size.active() && sidePresence.animate(),
|
||||
"transition-none": screen.size.active() || !sidePresence.animate(),
|
||||
}}
|
||||
data-slot="session-chat-panel"
|
||||
data-width-animating={store.sideWidthMotion}
|
||||
data-scrollbar-hidden={store.timelineScrollbarHidden || store.sideWidthMotion}
|
||||
onPointerMove={revealTimelineScrollbar}
|
||||
onPointerDown={revealTimelineScrollbar}
|
||||
onWheel={revealTimelineScrollbar}
|
||||
onKeyDown={revealTimelineScrollbar}
|
||||
onTransitionRun={trackSideWidthMotion}
|
||||
onTransitionEnd={trackSideWidthMotion}
|
||||
onTransitionCancel={trackSideWidthMotion}
|
||||
style={{
|
||||
width: screen.panel.width(),
|
||||
}}
|
||||
@@ -376,7 +342,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
data-opened={sidePresence.animate() ? sidePresence.show() : undefined}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
if (event.animationName !== "side-region-presence-in" || !sideVisible()) return
|
||||
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
|
||||
setStore("sideHeightMotion", true)
|
||||
}}
|
||||
classList={{
|
||||
@@ -387,8 +353,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
>
|
||||
<div
|
||||
data-slot="session-side-panel-content"
|
||||
class="absolute inset-y-0 start-0 size-full"
|
||||
style={{ "--session-side-content-width": screen.side.contentWidth() }}
|
||||
class="absolute inset-y-0 start-0 h-full"
|
||||
style={{ width: screen.side.contentWidth() }}
|
||||
>
|
||||
<div
|
||||
data-slot="session-side-region"
|
||||
@@ -397,7 +363,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
|
||||
}}
|
||||
style={{ height: sideVisible() ? screen.side.region.height() : "100%" }}
|
||||
style={{ height: screen.side.region.height() }}
|
||||
>
|
||||
<Show when={store.sideRegionPresent}>
|
||||
<div
|
||||
@@ -417,7 +383,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="absolute start-0 bottom-0 flex flex-col" style={{ width: screen.side.contentWidth() }}>
|
||||
<div class="absolute inset-x-0 bottom-0 flex flex-col">
|
||||
<div
|
||||
data-slot="session-side-panel-gap"
|
||||
classList={{
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import {
|
||||
createComputed,
|
||||
createMemo,
|
||||
createRoot,
|
||||
getOwner,
|
||||
on,
|
||||
onCleanup,
|
||||
untrack,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createScopedCache } from "@/runtime/server/scoped-cache"
|
||||
import type { TimelineSessionSource } from "./controller"
|
||||
|
||||
export function createTimelineCache(
|
||||
session: TimelineSessionSource & { identity: { workspaceKey: Accessor<string> } },
|
||||
render: (source: TimelineSessionSource, active: Accessor<boolean>) => JSX.Element,
|
||||
visible: Accessor<boolean>,
|
||||
) {
|
||||
const owner = getOwner()
|
||||
let workspace = untrack(session.identity.workspaceKey)
|
||||
const cache = createScopedCache(
|
||||
(key) =>
|
||||
createRoot((dispose) => {
|
||||
const id = untrack(session.identity.sessionID)
|
||||
const sessionKey = untrack(session.identity.sessionKey)
|
||||
const active = createMemo(() => visible() && session.identity.sessionKey() === key)
|
||||
// A detached view retains its own inputs until it is selected again.
|
||||
const select = <T>(read: Accessor<T>) =>
|
||||
createMemo<T>((previous) => (active() ? read() : previous), untrack(read))
|
||||
return {
|
||||
value: render(
|
||||
{
|
||||
identity: {
|
||||
params: session.identity.params,
|
||||
sessionID: () => id,
|
||||
sessionKey: () => sessionKey,
|
||||
},
|
||||
data: {
|
||||
info: select(session.data.info),
|
||||
parent: select(session.data.parent),
|
||||
parentID: select(session.data.parentID),
|
||||
status: select(session.data.status),
|
||||
},
|
||||
history: { messages: select(session.history.messages) },
|
||||
},
|
||||
active,
|
||||
),
|
||||
dispose,
|
||||
}
|
||||
}, owner),
|
||||
{ maxEntries: 16, dispose: (entry) => entry.dispose() },
|
||||
)
|
||||
onCleanup(cache.clear)
|
||||
const syncWorkspace = (key: string) => {
|
||||
if (workspace === key) return
|
||||
workspace = key
|
||||
cache.clear()
|
||||
}
|
||||
// Providers follow the selected Location even while its history is loading.
|
||||
// Dispose detached views before their effects can read the new Location.
|
||||
createComputed(on(session.identity.workspaceKey, syncWorkspace, { defer: true }))
|
||||
return () => {
|
||||
// A tab's render can run before the workspace watcher in the same batch.
|
||||
// Clear the old workspace here, and let that later watcher keep this view.
|
||||
syncWorkspace(session.identity.workspaceKey())
|
||||
return cache.get(session.identity.sessionKey()).value
|
||||
}
|
||||
}
|
||||
@@ -349,7 +349,6 @@ export function SessionSummaryPanel(props: {
|
||||
|
||||
type MessageTimelineProps = {
|
||||
hideHeader?: boolean
|
||||
active?: boolean
|
||||
session: TimelineSessionSource
|
||||
background: SessionBackground
|
||||
actions?: SessionUserActions
|
||||
@@ -364,7 +363,6 @@ type MessageTimelineProps = {
|
||||
onSelectionInteraction: (event: MouseEvent) => void
|
||||
pinned: boolean
|
||||
centered: boolean
|
||||
reserveReviewToggle: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
|
||||
onReview: () => void
|
||||
@@ -460,7 +458,6 @@ function MessageTimelineView(
|
||||
const pinned = createMemo(() => props.pinned)
|
||||
const messageByID = projection.messageByID
|
||||
const virtualized = createTimelineVirtualizer({
|
||||
active: () => props.active !== false,
|
||||
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
|
||||
presentationKey: () => JSON.stringify(props.data.timelineDetail()),
|
||||
projection,
|
||||
@@ -554,12 +551,6 @@ function MessageTimelineView(
|
||||
if (await props.action.rename(title.draft)) setTitle("editing", false)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.active !== false) return
|
||||
setSummary(false)
|
||||
setTitle({ draft: "", editing: false, menuOpen: false, pendingRename: false })
|
||||
})
|
||||
|
||||
const rowRenderer = createSessionTimelineRowRenderer({
|
||||
sessionID: () => sessionID()!,
|
||||
status: sessionStatus,
|
||||
@@ -854,7 +845,7 @@ function MessageTimelineView(
|
||||
</Popover>
|
||||
)}
|
||||
</Show>
|
||||
<SessionHeader reserveReviewToggle={props.reserveReviewToggle} />
|
||||
<SessionHeader />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -11,17 +11,17 @@ export {
|
||||
selectVisibleSessionUserMessages as selectVisibleUserMessages,
|
||||
} from "../session-domain"
|
||||
|
||||
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history" | "ownership"> }) {
|
||||
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
|
||||
const data = useData()
|
||||
|
||||
const [resource] = createResource(
|
||||
() => input.session.identity.sessionID(),
|
||||
async (id) => {
|
||||
if (!id) return
|
||||
const owner = input.session.ownership.capture()
|
||||
const key = input.session.identity.sessionKey()
|
||||
await Promise.all([data.session.message.sync(id), data.session.pending.sync(id)])
|
||||
await enrichLeadingTurn({
|
||||
current: owner.current,
|
||||
current: () => input.session.identity.sessionKey() === key,
|
||||
messages: () => data.session.message.list(id),
|
||||
more: () => data.session.message.more(id),
|
||||
loading: () => data.session.message.loading(id),
|
||||
@@ -29,13 +29,12 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
|
||||
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
|
||||
maxPages: leadingTurnPageLimit,
|
||||
}).catch(() => undefined)
|
||||
return id
|
||||
},
|
||||
)
|
||||
const ready = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
// Enrich the partial leading group without withholding the already loaded tail.
|
||||
return !id || data.session.message.list(id).length > 0 || (!resource.loading && resource.latest === id)
|
||||
return !id || data.session.message.list(id).length > 0 || !resource.loading
|
||||
})
|
||||
const more = () => {
|
||||
const id = input.session.identity.sessionID()
|
||||
|
||||
@@ -16,35 +16,6 @@ test("matches only the scroll element or an ancestor containing it", () => {
|
||||
expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
|
||||
})
|
||||
|
||||
test("restores a view observed before its first attachment", async () => {
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
const viewport = targetWindow.document.createElement("div")
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow,
|
||||
scrollOffset: 240,
|
||||
options: { horizontal: false, isRtl: false, isScrollingResetDelay: 0, useScrollendEvent: false },
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
const connections: boolean[] = []
|
||||
const cleanup = observeElementOffsetReconnectAware(
|
||||
instance,
|
||||
(offset) => {
|
||||
instance.scrollOffset = offset
|
||||
},
|
||||
() => connections.push(viewport.isConnected),
|
||||
)
|
||||
try {
|
||||
mutations.append(targetWindow.document.body, viewport)
|
||||
await frames(2, targetWindow)
|
||||
expect(connections).toEqual([true])
|
||||
expect(instance.scrollOffset).toBe(0)
|
||||
} finally {
|
||||
cleanup()
|
||||
await targetWindow.happyDOM.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
|
||||
@@ -20,8 +20,7 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
|
||||
cleanupOffset?.()
|
||||
}
|
||||
|
||||
// Cached views can be constructed before their first attachment to the page.
|
||||
let removed = !element.isConnected
|
||||
let removed = false
|
||||
let frame: number | undefined
|
||||
const clearCheck = () => {
|
||||
if (frame === undefined) return
|
||||
|
||||
@@ -80,7 +80,6 @@ export function SessionWorkspaceMenu(props: {
|
||||
<Menu
|
||||
placement={props.placement ?? "bottom-end"}
|
||||
gutter={props.gutter ?? 4}
|
||||
overflowPadding={24}
|
||||
modal={false}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
@@ -102,13 +101,13 @@ export function SessionWorkspaceMenu(props: {
|
||||
{language.t("workspace.new")}
|
||||
</Menu.Item>
|
||||
<Show when={workspaces().length > 0}>
|
||||
<Menu.Sub gutter={0} overlap overflowPadding={24}>
|
||||
<Menu.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<Menu.SubTrigger>
|
||||
<Icon name="outline-worktree" />
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.SubContent class="max-h-[66.667dvh] w-[200px] overflow-y-auto !pb-0 [&>[data-component=menu-v2-item]:last-child]:mb-0.5 [@media(max-height:600px)]:max-h-[calc(100dvh-48px)]">
|
||||
<Menu.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
createVirtualizer,
|
||||
defaultRangeExtractor,
|
||||
elementScroll,
|
||||
observeElementRect,
|
||||
type Range,
|
||||
type VirtualItem,
|
||||
} from "@tanstack/solid-virtual"
|
||||
@@ -50,7 +49,6 @@ type Projection = Pick<
|
||||
>
|
||||
|
||||
type Input = {
|
||||
active?: Accessor<boolean>
|
||||
sessionKey: Accessor<string>
|
||||
presentationKey?: Accessor<string>
|
||||
projection: Projection
|
||||
@@ -85,7 +83,6 @@ type ViewProps = {
|
||||
|
||||
export function createTimelineVirtualizer(input: Input) {
|
||||
const language = useLanguage()
|
||||
const active = () => input.active?.() !== false
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
|
||||
const ownerSessionKey = input.sessionKey()
|
||||
@@ -137,7 +134,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
!(
|
||||
row._tag === "AssistantPart" &&
|
||||
row.group.type === "context" &&
|
||||
row.group.refs.length <= 64 &&
|
||||
row.group.refs.length <= 16 &&
|
||||
!toolOpen[`context:${row.group.key}`]
|
||||
) && !input.canRenderImmediately?.(row, toolOpen),
|
||||
)
|
||||
@@ -159,7 +156,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
let virtualContent: HTMLDivElement | undefined
|
||||
let scrollTop = 0
|
||||
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
|
||||
let reportRect: ((rect: { width: number; height: number }) => void) | undefined
|
||||
let batchingColdSizes = false
|
||||
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
@@ -167,22 +163,13 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
return rows().length
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
observeElementRect: (instance, callback) => {
|
||||
reportRect = callback
|
||||
return observeElementRect(instance, (rect) => {
|
||||
if (active()) callback(rect)
|
||||
})
|
||||
},
|
||||
// Route navigation detaches and reattaches the scroll element, which drops its offset.
|
||||
observeElementOffset: (instance, callback) => {
|
||||
reportOffset = (offset, scrolling) => {
|
||||
if (!active()) return
|
||||
callback(offset, scrolling)
|
||||
settleColdBottom()
|
||||
}
|
||||
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
|
||||
if (!active()) return
|
||||
virtualContent?.querySelectorAll<HTMLDivElement>("[data-index]").forEach(virtualizer.measureElement)
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
settleColdBottom()
|
||||
})
|
||||
@@ -194,11 +181,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
// its snapshot on attachment, but later explicit measurements must read layout
|
||||
// so deferred/rewrapped content cannot keep stale, clipped heights (TanStack/virtual#1183).
|
||||
measureElement: (element, entry, instance) => {
|
||||
if (!active() || !element.isConnected)
|
||||
return (
|
||||
instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element))) ??
|
||||
fallbackItemSize
|
||||
)
|
||||
const initial = !measuredElements.has(element)
|
||||
measuredElements.add(element)
|
||||
const box = entry?.borderBoxSize[0]
|
||||
@@ -210,7 +192,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
return element.offsetHeight
|
||||
},
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
if (!active()) return
|
||||
if (batchingColdSizes && input.pinned()) return
|
||||
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
|
||||
elementScroll(offset, options, instance)
|
||||
@@ -240,7 +221,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
// Read the whole measurement delivery before committing reactive row sizes.
|
||||
// Otherwise each row can render and force layout before the next is measured.
|
||||
virtualizer.resizeItem = (index, size) => {
|
||||
if (!active()) return
|
||||
const row = rows()[index]
|
||||
if (!row) return
|
||||
const key = TimelineRow.key(row)
|
||||
@@ -256,7 +236,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (!pendingSizes.size) return
|
||||
const sizes = [...pendingSizes]
|
||||
pendingSizes.clear()
|
||||
if (!active()) return
|
||||
// The hidden pinned mount needs one bottom write after the whole batch,
|
||||
// not a layout-forcing scroll adjustment for every measured row.
|
||||
batchingColdSizes = coldPending && input.pinned()
|
||||
@@ -292,21 +271,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
|
||||
|
||||
createEffect(() => {
|
||||
if (!active()) return
|
||||
const root = listRoot()
|
||||
if (root) input.setScrollRef(root)
|
||||
if (virtualContent) input.setContentRef(virtualContent)
|
||||
queueMicrotask(() => {
|
||||
if (!active() || !root?.isConnected) return
|
||||
// A detached view can miss its nonzero ResizeObserver delivery. Publish
|
||||
// its real viewport before restoring the offset and admitting rows.
|
||||
reportRect?.({ width: root.offsetWidth, height: root.offsetHeight })
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
reportOffset?.(root.scrollTop, false)
|
||||
settleColdBottom()
|
||||
})
|
||||
input.setRevealMessage?.((id, partID) => {
|
||||
if (!active()) return
|
||||
const partIndex = partID
|
||||
? rows().findIndex(
|
||||
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
|
||||
@@ -317,7 +282,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
virtualizer.scrollToIndex(index, { align: "center" })
|
||||
})
|
||||
input.setScrollToEnd?.(() => {
|
||||
if (!active() || !listRoot()?.isConnected) return
|
||||
input.onPin()
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
@@ -328,7 +292,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
let contentObserver: MutationObserver | undefined
|
||||
let viewportObserver: ResizeObserver | undefined
|
||||
const pinColdBottom = () => {
|
||||
if (!active()) return
|
||||
const root = listRoot()
|
||||
if (!input.pinned() || !virtualContent || !root) return
|
||||
// scrollToEnd computes its target from the DOM, not the new size cache.
|
||||
@@ -346,7 +309,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
)
|
||||
}
|
||||
const settleColdBottom = () => {
|
||||
if (!active() || !coldPending || settleQueued) return
|
||||
if (!coldPending || settleQueued) return
|
||||
settleQueued = true
|
||||
queueMicrotask(() => {
|
||||
settleQueued = false
|
||||
@@ -409,7 +372,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
setListRoot(root)
|
||||
scrollTop = root.scrollTop
|
||||
maxScroll = root.scrollHeight - root.clientHeight
|
||||
if (active()) input.setScrollRef(root)
|
||||
input.setScrollRef(root)
|
||||
viewportObserver?.observe(root)
|
||||
settleColdBottom()
|
||||
}
|
||||
@@ -466,7 +429,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
// under a viewport that was already there. Merely resting near the end is not enough, otherwise
|
||||
// a later scroll would overwrite an upward intent expressed a pixel short of the bottom.
|
||||
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (!active()) return
|
||||
const root = event.currentTarget
|
||||
const previousTop = scrollTop
|
||||
const previousMaxScroll = maxScroll
|
||||
@@ -492,8 +454,15 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
let contentMeasureFrame: number | undefined
|
||||
|
||||
onMount(() => virtualizer.measureElement(element))
|
||||
// Prepending history changes data-index, not the keyed element's identity.
|
||||
// Its observer reads the current index and delivers any actual size change.
|
||||
createEffect(
|
||||
on(
|
||||
() => item().index,
|
||||
() => {
|
||||
virtualizer.measureElement(element)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
onCleanup(() => {
|
||||
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
|
||||
queueMicrotask(() => virtualizer.measureElement(null))
|
||||
@@ -515,19 +484,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
<div
|
||||
ref={(value) => {
|
||||
element = value
|
||||
if (row()._tag !== "UserMessage" || !addedKeys.has(rowProps.rowKey) || !input.pinned() || coldPending)
|
||||
return
|
||||
// The optimistic row can paint before ResizeObserver corrects the tail estimates.
|
||||
// Measure the mounted tail and pin it in this render's microtask instead.
|
||||
queueMicrotask(() => {
|
||||
if (!input.pinned() || !virtualContent?.isConnected) return
|
||||
virtualizer.elementsCache.forEach((item) => {
|
||||
if (item.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(item), item.offsetHeight)
|
||||
})
|
||||
virtualizer.resizeItem(item().index, element.offsetHeight)
|
||||
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}}
|
||||
data-index={item().index}
|
||||
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
|
||||
@@ -537,7 +493,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
|
||||
contentMeasureFrame = requestAnimationFrame(() => {
|
||||
contentMeasureFrame = undefined
|
||||
if (active() && element.isConnected) virtualizer.measureElement(element)
|
||||
if (element.isConnected) virtualizer.measureElement(element)
|
||||
})
|
||||
})}
|
||||
</div>
|
||||
@@ -580,7 +536,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
</button>
|
||||
</div>
|
||||
<ScrollView
|
||||
data-slot="session-timeline-scroll"
|
||||
viewportRef={bindListRoot}
|
||||
onWheel={handleListWheel}
|
||||
onTouchStart={handleListTouchStart}
|
||||
@@ -599,7 +554,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
data-timeline-virtual-content
|
||||
ref={(element) => {
|
||||
virtualContent = element
|
||||
if (active()) input.setContentRef(element)
|
||||
input.setContentRef(element)
|
||||
}}
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
@@ -634,11 +589,9 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
coldPending = false
|
||||
contentObserver?.disconnect()
|
||||
viewportObserver?.disconnect()
|
||||
if (active()) {
|
||||
input.setScrollRef(undefined)
|
||||
input.setRevealMessage?.(() => {})
|
||||
input.setScrollToEnd?.(() => {})
|
||||
}
|
||||
input.setScrollRef(undefined)
|
||||
input.setRevealMessage?.(() => {})
|
||||
input.setScrollToEnd?.(() => {})
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Component, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Select } from "@opencode/ui/select"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -6,6 +8,9 @@ import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import { createAppearanceSettingsController, type AppearanceSettingsController } from "@/settings/general/controllers"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { BackgroundImageSelectionError } from "@/settings/appearance/background-image"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||
@@ -64,6 +69,26 @@ const FontSetting: Component<{
|
||||
export const SettingsAppearance: Component = () => {
|
||||
const language = useLanguage()
|
||||
const appearance = createAppearanceSettingsController()
|
||||
const settings = useSettings()
|
||||
const [state, setState] = createStore({ backgroundBusy: false })
|
||||
|
||||
const backgroundAction = async (action: () => Promise<void>) => {
|
||||
if (state.backgroundBusy) return
|
||||
setState("backgroundBusy", true)
|
||||
await action().catch((error: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description:
|
||||
error instanceof BackgroundImageSelectionError
|
||||
? language.t(`settings.appearance.row.backgroundImage.error.${error.reason}`)
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
})
|
||||
})
|
||||
setState("backgroundBusy", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -121,6 +146,42 @@ export const SettingsAppearance: Component = () => {
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<Show when={settings.appearance.backgroundImage.available}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.backgroundImage.title")}
|
||||
description={language.t("settings.appearance.row.backgroundImage.description")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
data-action="settings-background-image"
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
disabled={state.backgroundBusy}
|
||||
onClick={() =>
|
||||
void backgroundAction(() =>
|
||||
settings.appearance.backgroundImage.select(
|
||||
language.t("settings.appearance.row.backgroundImage.pickerTitle"),
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{language.t("settings.appearance.row.backgroundImage.choose")}
|
||||
</Button>
|
||||
<Show when={settings.appearance.backgroundImage.active()}>
|
||||
<Button
|
||||
data-action="settings-background-image-remove"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
disabled={state.backgroundBusy}
|
||||
onClick={() => void backgroundAction(() => settings.appearance.backgroundImage.clear())}
|
||||
>
|
||||
{language.t("settings.appearance.row.backgroundImage.remove")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
|
||||
<FontSetting kind="ui" fonts={appearance.fonts} />
|
||||
<FontSetting kind="code" fonts={appearance.fonts} />
|
||||
<FontSetting kind="terminal" fonts={appearance.fonts} />
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createDraftStore, type DraftStore } from "@/runtime/persistence/drafts"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { createBackgroundImageSettings } from "./background-image"
|
||||
|
||||
function setup(file?: File, initial?: string) {
|
||||
let value = initial ?? null
|
||||
const written: unknown[] = []
|
||||
const draftStore: DraftStore = {
|
||||
getItem: async () => value,
|
||||
setItem: async (_key, next) => {
|
||||
value = next
|
||||
},
|
||||
removeItem: async () => {
|
||||
value = null
|
||||
},
|
||||
putBlob: async () => ({ id: "blob-id", url: "blob:background" }),
|
||||
setDocument: async (_key, document) => {
|
||||
written.push(document)
|
||||
value = JSON.stringify(document)
|
||||
},
|
||||
}
|
||||
const platform: Platform = {
|
||||
platform: "web",
|
||||
draftStore,
|
||||
openExternal() {},
|
||||
restart: async () => {},
|
||||
notify: async () => {},
|
||||
openAttachmentPickerDialog: async (_options, onFile) => {
|
||||
if (file) await onFile(file)
|
||||
},
|
||||
}
|
||||
const background = createBackgroundImageSettings(platform, false)
|
||||
return { background, written, value: () => value }
|
||||
}
|
||||
|
||||
describe("background image settings", () => {
|
||||
test("reloads an image through the document and blob store", async () => {
|
||||
const documents = new Map<string, string>()
|
||||
const blobs = new Map<string, Blob>()
|
||||
const draftStore = createDraftStore({
|
||||
get: async (key) => documents.get(key) ?? null,
|
||||
set: async (key, value) => {
|
||||
documents.set(key, value)
|
||||
return []
|
||||
},
|
||||
remove: async (key) => {
|
||||
documents.delete(key)
|
||||
},
|
||||
putBlob: async (blob) => {
|
||||
const id = crypto.randomUUID()
|
||||
blobs.set(id, blob)
|
||||
return id
|
||||
},
|
||||
getBlob: async (id) => blobs.get(id) ?? null,
|
||||
})
|
||||
const file = new File([new Uint8Array([1, 2, 3])], "background.png", { type: "image/png" })
|
||||
const value: Platform = {
|
||||
platform: "web",
|
||||
draftStore,
|
||||
openExternal() {},
|
||||
restart: async () => {},
|
||||
notify: async () => {},
|
||||
openAttachmentPickerDialog: async (_options, onFile) => {
|
||||
await onFile(file)
|
||||
},
|
||||
}
|
||||
|
||||
const first = createBackgroundImageSettings(value, false)
|
||||
await first.ready
|
||||
await first.select("Choose a background image")
|
||||
const second = createBackgroundImageSettings(value, false)
|
||||
await second.ready
|
||||
expect(second.active()).toBe(true)
|
||||
expect(second.url()).toStartWith("blob:")
|
||||
})
|
||||
|
||||
test("loads, replaces, and clears a persisted image", async () => {
|
||||
const current = JSON.stringify({ image: { mime: "image/png", blob: { id: "old", url: "blob:old" } } })
|
||||
const { background, written, value } = setup(new File([new Uint8Array([1, 2, 3])], "new.webp"), current)
|
||||
await background.ready
|
||||
expect(background.active()).toBe(true)
|
||||
expect(background.url()).toBe("blob:old")
|
||||
|
||||
await background.select("Choose a background image")
|
||||
expect(background.url()).toBe("blob:background")
|
||||
expect(written).toEqual([{ image: { mime: "image/webp", blob: { id: "blob-id", url: "blob:background" } } }])
|
||||
|
||||
await background.clear()
|
||||
expect(background.active()).toBe(false)
|
||||
expect(value()).toBeNull()
|
||||
})
|
||||
|
||||
test("rejects unsupported files", async () => {
|
||||
const { background } = setup(new File(["<svg />"], "background.svg", { type: "image/svg+xml" }))
|
||||
await background.ready
|
||||
expect(background.select("Choose a background image")).rejects.toMatchObject({
|
||||
reason: "unsupported",
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects files larger than 20 MB", async () => {
|
||||
const { background } = setup(new File([new Uint8Array(20 * 1024 * 1024 + 1)], "background.png"))
|
||||
await background.ready
|
||||
expect(background.select("Choose a background image")).rejects.toMatchObject({
|
||||
reason: "too-large",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { getOwner, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
|
||||
const key = "opencode.global.dat:appearance.background-image"
|
||||
const maxBytes = 20 * 1024 * 1024
|
||||
const mime = new Map([
|
||||
["avif", "image/avif"],
|
||||
["bmp", "image/bmp"],
|
||||
["gif", "image/gif"],
|
||||
["jpeg", "image/jpeg"],
|
||||
["jpg", "image/jpeg"],
|
||||
["png", "image/png"],
|
||||
["webp", "image/webp"],
|
||||
])
|
||||
const accepted = new Set(mime.values())
|
||||
const documentSchema = Schema.Struct({
|
||||
image: Schema.optional(
|
||||
Schema.Struct({
|
||||
mime: Schema.String,
|
||||
blob: Schema.Struct({ id: Schema.String, url: Schema.optional(Schema.String) }),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const decode = Schema.decodeUnknownOption(Schema.fromJsonString(documentSchema))
|
||||
|
||||
export class BackgroundImageSelectionError extends Error {
|
||||
constructor(readonly reason: "unsupported" | "too-large") {
|
||||
super(reason)
|
||||
this.name = "BackgroundImageSelectionError"
|
||||
}
|
||||
}
|
||||
|
||||
export function createBackgroundImageSettings(platform: Platform, sync = true) {
|
||||
const [state, setState] = createStore<{
|
||||
image: { mime: string; blob: { id: string; url: string } } | undefined
|
||||
}>({ image: undefined })
|
||||
const channel = sync && typeof BroadcastChannel !== "undefined" ? new BroadcastChannel(key) : undefined
|
||||
let revision = 0
|
||||
|
||||
const load = async () => {
|
||||
const current = ++revision
|
||||
const raw = await platform.draftStore?.getItem(key)
|
||||
if (current !== revision || !raw) {
|
||||
if (current === revision) setState("image", undefined)
|
||||
return
|
||||
}
|
||||
const parsed = decode(raw)
|
||||
const image = Option.isSome(parsed) ? parsed.value.image : undefined
|
||||
if (current !== revision) return
|
||||
setState(
|
||||
"image",
|
||||
image?.blob.url?.startsWith("blob:") && accepted.has(image.mime)
|
||||
? { mime: image.mime, blob: { id: image.blob.id, url: image.blob.url } }
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
channel?.addEventListener("message", () => void load().catch(() => undefined))
|
||||
if (getOwner()) onCleanup(() => channel?.close())
|
||||
const ready = load().catch(() => undefined)
|
||||
|
||||
return {
|
||||
ready,
|
||||
available: !!platform.draftStore,
|
||||
active: () => !!state.image,
|
||||
url: () => state.image?.blob.url,
|
||||
async select(title: string) {
|
||||
const file = await pick(platform, title)
|
||||
if (!file) return
|
||||
const type = file.type.toLowerCase()
|
||||
const extension = file.name.split(".").at(-1)?.toLowerCase()
|
||||
const contentType = accepted.has(type) ? type : extension ? mime.get(extension) : undefined
|
||||
if (!contentType) throw new BackgroundImageSelectionError("unsupported")
|
||||
if (file.size > maxBytes) throw new BackgroundImageSelectionError("too-large")
|
||||
const store = platform.draftStore
|
||||
if (!store) return
|
||||
revision++
|
||||
const blob = await store.putBlob(file)
|
||||
await store.setDocument(key, { image: { mime: contentType, blob } })
|
||||
revision++
|
||||
setState("image", { mime: contentType, blob })
|
||||
channel?.postMessage(null)
|
||||
},
|
||||
async clear() {
|
||||
revision++
|
||||
await platform.draftStore?.removeItem(key)
|
||||
revision++
|
||||
setState("image", undefined)
|
||||
channel?.postMessage(null)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function pick(platform: Platform, title: string) {
|
||||
if (platform.openAttachmentPickerDialog) {
|
||||
let selected: File | undefined
|
||||
await platform.openAttachmentPickerDialog(
|
||||
{ title, extensions: [...mime.keys()], accept: [...accepted] },
|
||||
async (file) => {
|
||||
selected ??= file
|
||||
},
|
||||
)
|
||||
return selected
|
||||
}
|
||||
return new Promise<File | undefined>((resolve) => {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = [...accepted].join(",")
|
||||
input.addEventListener("change", () => resolve(input.files?.[0]), { once: true })
|
||||
input.addEventListener("cancel", () => resolve(undefined), { once: true })
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
@@ -31,6 +31,22 @@ export const SettingsExperimental: Component = () => {
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-section">
|
||||
<SettingsList>
|
||||
<Show when={platform.browserPane}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.browserPane.title")}
|
||||
description={language.t("settings.general.row.browserPane.description")}
|
||||
>
|
||||
<div data-action="settings-experimental-browser">
|
||||
<Switch
|
||||
checked={settings.general.experimentalBrowser()}
|
||||
onChange={settings.general.setExperimentalBrowser}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.general.row.browserPane.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.tabs.title")}
|
||||
description={language.t("settings.appearance.row.tabs.description")}
|
||||
@@ -49,22 +65,6 @@ export const SettingsExperimental: Component = () => {
|
||||
onSelect={(option) => option && settings.appearance.setTabLayout(option)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<Show when={platform.browserPane}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.browserPane.title")}
|
||||
description={language.t("settings.general.row.browserPane.description")}
|
||||
>
|
||||
<div data-action="settings-experimental-browser">
|
||||
<Switch
|
||||
checked={settings.general.experimentalBrowser()}
|
||||
onChange={settings.general.setExperimentalBrowser}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.general.row.browserPane.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.projectName.title")}
|
||||
description={language.t("settings.appearance.row.projectName.description")}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { timelinePresets, type TimelineCategory, type TimelineDetail } from "@op
|
||||
import { persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { createBackgroundImageSettings } from "@/settings/appearance/background-image"
|
||||
|
||||
export type Settings = typeof settingsSchema.Type
|
||||
export type WorkspaceDefaultDestination = Settings["workspaces"]["defaultDestination"]
|
||||
@@ -277,7 +279,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
name: "Settings",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const [store, setStore, , ready] = persisted({ key: "settings.v3" }, settingsPersistence, defaultSettings)
|
||||
const backgroundImage = createBackgroundImageSettings(platform)
|
||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||
@@ -288,12 +292,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const root = document.documentElement
|
||||
const mono = monoFontFamily(store.appearance?.mono)
|
||||
root.style.setProperty("--font-family-mono", mono)
|
||||
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
|
||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
||||
// Inline code can first appear during history backfill. Load its selected
|
||||
// face with the shell so that font discovery does not resize that mount.
|
||||
void document.fonts?.load(`440 13px ${mono}`).catch(() => undefined)
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -379,6 +379,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
customAgents: showCustomAgents,
|
||||
},
|
||||
appearance: {
|
||||
backgroundImage,
|
||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||
setFontSize(value: number) {
|
||||
setStore("appearance", "fontSize", value)
|
||||
|
||||
@@ -51,8 +51,6 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 720px;
|
||||
/* Leave room for raised card shadows inside the scrollport. */
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.settings-screen .settings-tab-header {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -15,7 +15,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
|
||||
const PALETTE_ID = "command.palette"
|
||||
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
|
||||
const SUGGESTED_PREFIX = "suggested."
|
||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach", "browser.reload"])
|
||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
||||
|
||||
type KeyLabel =
|
||||
| "common.key.ctrl"
|
||||
@@ -306,20 +306,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
createEffect(() => {
|
||||
if (!catalogReady()) return
|
||||
|
||||
batch(() =>
|
||||
registered().forEach((opt) => {
|
||||
if (!opt.title) return
|
||||
setCatalog(
|
||||
actionId(opt.id),
|
||||
reconcile({
|
||||
setCatalog(
|
||||
registered().reduce((acc, opt) => {
|
||||
const id = actionId(opt.id)
|
||||
if (opt.title)
|
||||
acc[id] = {
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
return acc
|
||||
}, {} as CommandCatalog),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -40,8 +40,13 @@ export default function Layout(props: ParentProps) {
|
||||
return (
|
||||
<TitlebarRightProvider>
|
||||
<div
|
||||
data-component="app-shell"
|
||||
data-background-image={preferences.appearance.backgroundImage.active() ? "" : undefined}
|
||||
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
|
||||
style={{
|
||||
"--app-background-image": preferences.appearance.backgroundImage.url()
|
||||
? `url("${preferences.appearance.backgroundImage.url()}")`
|
||||
: undefined,
|
||||
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
|
||||
"--shell-top-inset": bottomTitlebar()
|
||||
? "max(0px, calc(8px - env(safe-area-inset-top, 0px)))"
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createComputed, createRoot } from "solid-js"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { createData } from "@opencode/client/solid"
|
||||
|
||||
test("publishes an initial message page, its index, and its cursor together", async () => {
|
||||
const observed: { ids: string[]; more: boolean; text: string | undefined }[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async () =>
|
||||
Response.json({
|
||||
data: [{ id: "msg_page", type: "user", text: "History", time: { created: 1 } }],
|
||||
cursor: { next: "older" },
|
||||
}),
|
||||
})
|
||||
const setup = createRoot((dispose) => {
|
||||
const data = createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
})
|
||||
createComputed(() => {
|
||||
const message = data.session.message.get("ses_page", "msg_page")
|
||||
observed.push({
|
||||
ids: data.session.message.list("ses_page").map((message) => message.id),
|
||||
more: data.session.message.more("ses_page"),
|
||||
text: message?.type === "user" ? message.text : undefined,
|
||||
})
|
||||
})
|
||||
return { data, dispose }
|
||||
})
|
||||
try {
|
||||
await setup.data.session.message.sync("ses_page")
|
||||
expect(observed).toEqual([
|
||||
{ ids: [], more: false, text: undefined },
|
||||
{ ids: ["msg_page"], more: true, text: "History" },
|
||||
])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
@@ -1,196 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerScope, SessionRouteKey, SessionStateKey } from "../src/runtime/server/scope"
|
||||
import { createTimelineCache } from "../src/session/timeline/cache"
|
||||
|
||||
function setup() {
|
||||
return createRoot((dispose) => {
|
||||
const [state, setState] = createStore({
|
||||
id: "ses_a",
|
||||
directory: "/repo",
|
||||
visible: true,
|
||||
messages: {
|
||||
ses_a: [{ id: "msg_a", type: "user", text: "First session", time: { created: 1 } }],
|
||||
ses_b: [{ id: "msg_b", type: "user", text: "Second session", time: { created: 2 } }],
|
||||
} as Record<string, SessionMessageInfo[]>,
|
||||
})
|
||||
const views = new Map<
|
||||
string,
|
||||
{ active: () => boolean; messages: () => SessionMessageInfo[]; element: HTMLDivElement }
|
||||
>()
|
||||
const disposed: string[] = []
|
||||
const cache = createTimelineCache(
|
||||
{
|
||||
identity: {
|
||||
params: {
|
||||
get id() {
|
||||
return state.id
|
||||
},
|
||||
serverKey: "local",
|
||||
},
|
||||
sessionID: () => state.id,
|
||||
workspaceKey: () => SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory)),
|
||||
sessionKey: () =>
|
||||
SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory, state.id)),
|
||||
},
|
||||
data: {
|
||||
info: createMemo(() => ({
|
||||
id: state.id,
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
location: { directory: state.directory },
|
||||
})),
|
||||
parent: () => undefined,
|
||||
parentID: () => undefined,
|
||||
status: () => ({ type: "idle" }),
|
||||
},
|
||||
history: { messages: () => state.messages[state.id] ?? [] },
|
||||
},
|
||||
(source, active) => {
|
||||
const id = source.identity.sessionID()!
|
||||
const element = document.createElement("div")
|
||||
createMemo(() =>
|
||||
element.setAttribute(
|
||||
"data-messages",
|
||||
source.history
|
||||
.messages()
|
||||
.map((message) => message.id)
|
||||
.join(","),
|
||||
),
|
||||
)
|
||||
views.set(id, { active, messages: source.history.messages, element })
|
||||
onCleanup(() => disposed.push(id))
|
||||
return element
|
||||
},
|
||||
() => state.visible,
|
||||
)
|
||||
return { state, setState, cache, views, disposed, dispose }
|
||||
})
|
||||
}
|
||||
|
||||
test("reuses a session view and refreshes its own history when selected again", () => {
|
||||
const input = setup()
|
||||
try {
|
||||
const first = input.cache()
|
||||
input.setState("id", "ses_b")
|
||||
const second = input.cache()
|
||||
expect(second).not.toBe(first)
|
||||
expect(input.views.get("ses_a")!.active()).toBe(false)
|
||||
expect(input.views.get("ses_a")!.element.dataset.messages).toBe("msg_a")
|
||||
expect(input.views.get("ses_b")!.element.dataset.messages).toBe("msg_b")
|
||||
|
||||
input.setState("messages", "ses_a", [
|
||||
{ id: "msg_c", type: "user", text: "Updated while inactive", time: { created: 3 } },
|
||||
])
|
||||
input.setState("id", "ses_a")
|
||||
expect(input.cache()).toBe(first)
|
||||
expect(input.views.get("ses_a")!.active()).toBe(true)
|
||||
expect(input.views.get("ses_a")!.element.dataset.messages).toBe("msg_c")
|
||||
expect(input.views.get("ses_b")!.element.dataset.messages).toBe("msg_b")
|
||||
expect(input.disposed).toEqual([])
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
expect(input.disposed.sort()).toEqual(["ses_a", "ses_b"])
|
||||
})
|
||||
|
||||
test("suspends a detached mobile view and reuses it when the conversation returns", () => {
|
||||
const input = setup()
|
||||
try {
|
||||
const first = input.cache()
|
||||
input.setState("visible", false)
|
||||
expect(input.views.get("ses_a")!.active()).toBe(false)
|
||||
input.setState("visible", true)
|
||||
expect(input.cache()).toBe(first)
|
||||
expect(input.views.get("ses_a")!.active()).toBe(true)
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("disposes views whose Location-scoped providers no longer match", () => {
|
||||
const input = setup()
|
||||
try {
|
||||
const first = input.cache()
|
||||
input.setState("directory", "/other")
|
||||
expect(input.cache()).not.toBe(first)
|
||||
expect(input.disposed).toEqual(["ses_a"])
|
||||
input.setState("directory", "/repo")
|
||||
expect(input.cache()).not.toBe(first)
|
||||
expect(input.disposed).toEqual(["ses_a", "ses_a"])
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
expect(input.disposed).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("disposes views on workspace changes while the destination is not rendered", () => {
|
||||
const input = setup()
|
||||
try {
|
||||
const first = input.cache()
|
||||
input.setState("visible", false)
|
||||
input.setState("directory", "/other")
|
||||
expect(input.disposed).toEqual(["ses_a"])
|
||||
input.setState("directory", "/repo")
|
||||
input.setState("visible", true)
|
||||
expect(input.cache()).not.toBe(first)
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
expect(input.disposed).toEqual(["ses_a", "ses_a"])
|
||||
})
|
||||
|
||||
for (const order of ["session-first", "workspace-first"] as const) {
|
||||
test(`keeps views live across five workspaces when updates are ${order}`, () => {
|
||||
const input = setup()
|
||||
const render = createRoot((dispose) => ({ selected: createMemo(input.cache), dispose }))
|
||||
const visited = ["ses_a"]
|
||||
try {
|
||||
;["ses_b", "ses_c", "ses_d", "ses_e", "ses_a", "ses_c", "ses_b", "ses_e", "ses_d", "ses_a"].forEach(
|
||||
(id, index) => {
|
||||
batch(() => {
|
||||
if (order === "workspace-first") input.setState("directory", `/repo/${id}`)
|
||||
input.setState("id", id)
|
||||
if (order === "session-first") input.setState("directory", `/repo/${id}`)
|
||||
})
|
||||
expect(input.disposed).toEqual(visited)
|
||||
input.setState("messages", id, [
|
||||
{ id: `msg_live_${index}`, type: "user", text: "Live update", time: { created: index + 3 } },
|
||||
])
|
||||
expect((render.selected() as HTMLDivElement).dataset.messages).toBe(`msg_live_${index}`)
|
||||
expect(input.views.get(id)!.active()).toBe(true)
|
||||
visited.push(id)
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
render.dispose()
|
||||
input.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("evicts the least recently selected view and disposes all retained owners", () => {
|
||||
const input = setup()
|
||||
try {
|
||||
const first = input.cache()
|
||||
Array.from({ length: 15 }, (_, index) => `ses_${index}`).forEach((id) => {
|
||||
input.setState("id", id)
|
||||
input.cache()
|
||||
})
|
||||
input.setState("id", "ses_a")
|
||||
expect(input.cache()).toBe(first)
|
||||
input.setState("id", "ses_last")
|
||||
input.cache()
|
||||
expect(input.disposed).toEqual(["ses_0"])
|
||||
input.setState("id", "ses_0")
|
||||
input.cache()
|
||||
expect(input.disposed).toEqual(["ses_0", "ses_1"])
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
expect(input.disposed).toHaveLength(18)
|
||||
})
|
||||
@@ -100,11 +100,7 @@ function packageNames() {
|
||||
function copyBinary(source) {
|
||||
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
|
||||
fs.mkdirSync(path.dirname(targetBinary), { recursive: true })
|
||||
if (fs.existsSync(targetBinary)) {
|
||||
try {
|
||||
fs.unlinkSync(targetBinary)
|
||||
} catch {}
|
||||
}
|
||||
if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary)
|
||||
try {
|
||||
fs.linkSync(source, targetBinary)
|
||||
} catch {
|
||||
|
||||
@@ -119,26 +119,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
commands: [
|
||||
Spec.make("agents", { description: "List all agents" }),
|
||||
Spec.make("config", { description: "List configuration sources" }),
|
||||
Spec.make("paths", {
|
||||
description: "Show global paths (data, config, cache, state)",
|
||||
params: {
|
||||
name: Argument.choice("name", [
|
||||
"db",
|
||||
"home",
|
||||
"data",
|
||||
"config",
|
||||
"cache",
|
||||
"state",
|
||||
"tmp",
|
||||
"bin",
|
||||
"log",
|
||||
"repos",
|
||||
]).pipe(
|
||||
Argument.withDescription("Print only one path: db, home, data, config, cache, state, tmp, bin, log, repos"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("paths", { description: "Show global paths (data, config, cache, state)" }),
|
||||
],
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { databasePath } from "../../../database-path"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.paths,
|
||||
Effect.fn("cli.debug.paths")(function* (input) {
|
||||
Effect.fn("cli.debug.paths")(function* () {
|
||||
const global = yield* Global.Service
|
||||
const paths = { ...global, db: databasePath(global.data) }
|
||||
if (Option.isSome(input.name)) {
|
||||
process.stdout.write(paths[input.name.value] + EOL)
|
||||
return
|
||||
}
|
||||
process.stdout.write(
|
||||
Object.entries(paths)
|
||||
Object.entries(global)
|
||||
.map(([key, value]) => `${key.padEnd(10)} ${value}${EOL}`)
|
||||
.join(""),
|
||||
)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import path from "node:path"
|
||||
import { OPENCODE_CHANNEL } from "./version"
|
||||
|
||||
export function databasePath(data: string) {
|
||||
const filename =
|
||||
process.env.OPENCODE_DB ??
|
||||
(["latest", "dev", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
||||
? "opencode.db"
|
||||
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
|
||||
return filename === ":memory:" ? filename : path.resolve(data, filename)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
import { databasePath } from "./database-path"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
|
||||
@@ -95,7 +94,13 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
pty: { handoff },
|
||||
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||
database: {
|
||||
path: databasePath(global.data),
|
||||
path:
|
||||
process.env.OPENCODE_DB ??
|
||||
(["latest", "dev", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
||||
? "opencode.db"
|
||||
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
|
||||
},
|
||||
models: {
|
||||
url: process.env.OPENCODE_MODELS_URL,
|
||||
|
||||
@@ -167,11 +167,6 @@ const make = Effect.gen(function* () {
|
||||
|
||||
const latest = () => release().pipe(Effect.map((data) => data.version))
|
||||
|
||||
const temporaryDirectory = (prefix: string) =>
|
||||
Effect.acquireRelease(fs.makeTempDirectory({ directory: global.cache, prefix }), (directory) =>
|
||||
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
@@ -197,12 +192,12 @@ const make = Effect.gen(function* () {
|
||||
if (method === "bun") {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* temporaryDirectory("update-")
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const directory = yield* temporaryDirectory("update-")
|
||||
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
const installer = path.join(directory, "install")
|
||||
const download = yield* exec(
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NodeServices } from "@effect/platform-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { expect, spyOn, test } from "bun:test"
|
||||
import { Effect, FileSystem, PlatformError, Stream } from "effect"
|
||||
import { Effect, FileSystem, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
@@ -18,7 +18,6 @@ function fixture(
|
||||
error?: AppProcess.AppProcessError
|
||||
} = () => ({}),
|
||||
name = "@opencode/cli",
|
||||
failCleanup = false,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
@@ -58,17 +57,6 @@ function fixture(
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(FileSystem.FileSystem, {
|
||||
...fs,
|
||||
remove: (target, options) =>
|
||||
failCleanup && target.startsWith(global.cache)
|
||||
? Effect.fail(
|
||||
PlatformError.systemError({
|
||||
_tag: "PermissionDenied",
|
||||
module: "FileSystem",
|
||||
method: "remove",
|
||||
pathOrDescriptor: target,
|
||||
}),
|
||||
)
|
||||
: fs.remove(target, options),
|
||||
realPath: (input) => (input === process.execPath ? Effect.succeed(executable) : fs.realPath(input)),
|
||||
}),
|
||||
Effect.provideService(
|
||||
@@ -137,14 +125,6 @@ installs.forEach(({ method, command }) => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("bun ignores install cache cleanup failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture(() => ({}), "@opencode/cli", true)
|
||||
yield* test.updater.upgrade("bun", "v2.3.4-beta.1")
|
||||
expect(test.commands).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
;["success", "download", "install"].forEach((failure) => {
|
||||
it.live(`curl uses the V2 installer and cleans its directory: ${failure}`, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { AgentAttachment } from "@opencode/schema/prompt"
|
||||
import type { Skill } from "@opencode/schema/skill"
|
||||
import type { Event } from "@opencode/schema/event"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
|
||||
import type { Schema } from "effect"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
@@ -37,6 +36,7 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
|
||||
import type { Reference } from "@opencode/schema/reference"
|
||||
import type { Worktree } from "@opencode/schema/worktree"
|
||||
import type { Vcs } from "@opencode/schema/vcs"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode/schema/websearch"
|
||||
import type { Config } from "@opencode/schema/config"
|
||||
|
||||
@@ -360,15 +360,6 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
|
||||
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID?: SessionMessage.ID | undefined
|
||||
readonly to?: SessionMessage.ID | undefined
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
|
||||
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: Session.ID }
|
||||
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (
|
||||
@@ -995,15 +986,6 @@ export type SessionLogOutput =
|
||||
| undefined
|
||||
readonly text: string
|
||||
readonly recent: string
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1018,15 +1000,6 @@ export type SessionLogOutput =
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly inputID?: SessionMessage.ID | undefined
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1148,7 +1121,6 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly diff: SessionDiffOperation<E>
|
||||
readonly inbox: {
|
||||
readonly list: SessionInboxListOperation<E>
|
||||
readonly cancel: SessionInboxCancelOperation<E>
|
||||
@@ -1176,18 +1148,6 @@ export type MessageListInput = {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}
|
||||
export type MessageListOutput = {
|
||||
readonly data: ReadonlyArray<SessionMessage.Info>
|
||||
|
||||
@@ -68,8 +68,6 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -594,17 +592,6 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
|
||||
preserveEffect<SessionDiffOutput>()(
|
||||
raw["session.diff"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
|
||||
preserveEffect<SessionInboxListOutput>()(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
@@ -744,7 +731,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
commit: EndpointSessionRevertCommit(raw),
|
||||
},
|
||||
context: EndpointSessionContext(raw),
|
||||
diff: EndpointSessionDiff(raw),
|
||||
inbox: {
|
||||
list: EndpointSessionInboxList(raw),
|
||||
cancel: EndpointSessionInboxCancel(raw),
|
||||
@@ -771,7 +757,7 @@ const EndpointMessageList = (raw: RawClient["server.message"]) => (input: Messag
|
||||
preserveEffect<MessageListOutput>()(
|
||||
raw["session.messages"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"], type: input["type"] },
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
|
||||
@@ -62,8 +62,6 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -844,18 +842,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionDiffOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
inbox: {
|
||||
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionInboxListOutput }>(
|
||||
@@ -1029,7 +1015,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"], type: input["type"] },
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
|
||||
@@ -147,14 +147,6 @@ export type SessionProviderContextProvenance = {
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type SessionMessageIdle = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
type: "idle"
|
||||
outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -531,8 +523,6 @@ export type SessionMessageCompactionFailed = {
|
||||
status: "failed"
|
||||
reason: "auto" | "manual"
|
||||
error: SessionStructuredError
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
}
|
||||
|
||||
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
|
||||
@@ -818,14 +808,7 @@ export type SessionCompactionFailed = {
|
||||
type: "session.compaction.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
error: SessionStructuredError
|
||||
inputID?: string
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
}
|
||||
data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string }
|
||||
}
|
||||
|
||||
export type SessionRevertCleared = {
|
||||
@@ -1377,7 +1360,6 @@ export type ProviderInfo = {
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
package: string
|
||||
compaction?: ProviderCompaction
|
||||
websocket?: boolean
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
@@ -1756,8 +1738,6 @@ export type SessionMessageCompactionCompleted = {
|
||||
summary: string
|
||||
recent: string
|
||||
providerContext?: SessionProviderContext
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
@@ -1775,8 +1755,6 @@ export type SessionCompactionEnded = {
|
||||
providerContext?: SessionProviderContext
|
||||
text: string
|
||||
recent: string
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1858,7 +1836,6 @@ export type ModelInfo = {
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
compaction?: ProviderCompaction
|
||||
websocket?: boolean
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
@@ -2035,7 +2012,6 @@ export type ConfigEntry =
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
websocket?: boolean
|
||||
canonical?: string
|
||||
name?: string
|
||||
env?: Array<string>
|
||||
@@ -2046,7 +2022,6 @@ export type ConfigEntry =
|
||||
models?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
websocket?: boolean
|
||||
modelID?: string
|
||||
family?: string
|
||||
name?: string
|
||||
@@ -2202,7 +2177,6 @@ export type SessionMessageInfo =
|
||||
| SessionMessageShell
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
| SessionMessageIdle
|
||||
|
||||
export type SessionMessageContentUpdated = {
|
||||
id: string
|
||||
@@ -3136,13 +3110,6 @@ export type SessionImportInput = {
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -3152,22 +3119,8 @@ export type SessionImportInput = {
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["info"]
|
||||
@@ -3448,13 +3401,6 @@ export type SessionImportInput = {
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -3464,22 +3410,8 @@ export type SessionImportInput = {
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["messages"]
|
||||
@@ -3760,13 +3692,6 @@ export type SessionImportInput = {
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -3776,22 +3701,8 @@ export type SessionImportInput = {
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["location"]
|
||||
@@ -4281,27 +4192,6 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
|
||||
|
||||
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly messageID?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["messageID"]
|
||||
readonly to?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["to"]
|
||||
readonly context?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["context"]
|
||||
}
|
||||
|
||||
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
|
||||
@@ -4399,70 +4289,17 @@ export type MessageListInput = {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}["limit"]
|
||||
readonly order?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}["order"]
|
||||
readonly cursor?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}["cursor"]
|
||||
readonly type?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}["type"]
|
||||
}
|
||||
|
||||
export type MessageListOutput = SessionMessagesResponse
|
||||
|
||||
@@ -57,8 +57,6 @@ type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent
|
||||
export type CreateDataInput = {
|
||||
readonly api: () => OpenCodeClient
|
||||
readonly directory: string
|
||||
/** Raw-message window used for an initial transcript read. Older pages retain their normal size. */
|
||||
readonly initialMessageLimit?: () => number
|
||||
readonly event: {
|
||||
readonly on: <Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
@@ -1024,18 +1022,6 @@ export function createData(config: CreateDataInput) {
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
|
||||
// Mirror the projected idle marker so turn boundaries match before the next message read.
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome:
|
||||
event.type === "session.execution.succeeded"
|
||||
? "succeeded"
|
||||
: event.type === "session.execution.failed"
|
||||
? "failed"
|
||||
: "interrupted",
|
||||
time: { created: event.created },
|
||||
})
|
||||
// An event can overtake the first read; queue a revalidation when that read is still active.
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
@@ -1087,11 +1073,8 @@ export function createData(config: CreateDataInput) {
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
providerContext: event.data.providerContext,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1102,11 +1085,8 @@ export function createData(config: CreateDataInput) {
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
providerContext: event.data.providerContext,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
@@ -1126,8 +1106,6 @@ export function createData(config: CreateDataInput) {
|
||||
message: "Compaction failed before recording an error",
|
||||
},
|
||||
metadata: current?.type === "compaction" ? current.metadata : event.metadata,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
time: current?.type === "compaction" ? current.time : { created: event.created },
|
||||
}
|
||||
if (current?.type === "compaction") {
|
||||
@@ -1583,11 +1561,7 @@ export function createData(config: CreateDataInput) {
|
||||
},
|
||||
sync(sessionID: string) {
|
||||
return sync.run(`session.message:${sessionID}`, async () => {
|
||||
const response = await api().message.list({
|
||||
sessionID,
|
||||
limit: config.initialMessageLimit?.() ?? messagePageLimit,
|
||||
order: "desc",
|
||||
})
|
||||
const response = await api().message.list({ sessionID, limit: messagePageLimit, order: "desc" })
|
||||
const fetched = response.data.toReversed()
|
||||
// Same protection as the pending sync: a re-fetch racing an
|
||||
// admission must not wipe its local transcript row.
|
||||
@@ -1601,11 +1575,9 @@ export function createData(config: CreateDataInput) {
|
||||
(item) => !ids.has(item.id) && (outbox.has(item.id) || admitted.has(item.id)),
|
||||
)
|
||||
const messages = local.length === 0 ? fetched : [...fetched, ...local]
|
||||
batch(() => {
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
|
||||
})
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
|
||||
})
|
||||
},
|
||||
more(sessionID: string) {
|
||||
|
||||
@@ -100,46 +100,13 @@ test.each(["started", "cancelled", "failed"])(
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
|
||||
const model = { providerID: "demo", id: "model" }
|
||||
const providerState = { responseId: "summary-response" }
|
||||
const tokens = { input: 10, output: 4, reasoning: 0, cache: { read: 3, write: 0 } }
|
||||
const providerContext = {
|
||||
version: 1 as const,
|
||||
provenance: {
|
||||
providerID: "demo",
|
||||
provider: "demo",
|
||||
modelID: "model",
|
||||
route: "demo-responses",
|
||||
protocol: "demo",
|
||||
endpoint: "digest",
|
||||
},
|
||||
messages: [],
|
||||
}
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.ended",
|
||||
data: {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
model,
|
||||
providerState,
|
||||
providerContext,
|
||||
text: "Summary",
|
||||
recent: "Recent",
|
||||
cost: 0.01,
|
||||
tokens,
|
||||
},
|
||||
data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" },
|
||||
})
|
||||
// The live fold carries the provider window and request usage so the label matches a reloaded session.
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
|
||||
{
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
summary: "Summary",
|
||||
model,
|
||||
providerState,
|
||||
providerContext,
|
||||
cost: 0.01,
|
||||
tokens,
|
||||
},
|
||||
{ type: "compaction", status: "completed", summary: "Summary", model, providerState },
|
||||
])
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,44 +14,6 @@ const session = (viewed: number): SessionInfo => ({
|
||||
location: { directory: "/project" },
|
||||
})
|
||||
|
||||
test("uses the configured initial window and retains normal cursor page sizes", async () => {
|
||||
const requests: { limit: string | null; cursor: string | null }[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const url = new URL((input instanceof Request ? input : new Request(input, init)).url)
|
||||
const cursor = url.searchParams.get("cursor")
|
||||
requests.push({ limit: url.searchParams.get("limit"), cursor })
|
||||
return Response.json({
|
||||
data: [{ id: cursor ? "msg_1" : "msg_2", type: "user", text: "History", time: { created: cursor ? 1 : 2 } }],
|
||||
cursor: cursor ? {} : { next: "older" },
|
||||
})
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
initialMessageLimit: () => 40,
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
try {
|
||||
await setup.data.session.message.sync("ses_refresh")
|
||||
await setup.data.session.message.sync("ses_refresh")
|
||||
expect(requests).toEqual([{ limit: "40", cursor: null }])
|
||||
await setup.data.session.message.loadMore("ses_refresh")
|
||||
expect(requests).toEqual([
|
||||
{ limit: "40", cursor: null },
|
||||
{ limit: "20", cursor: "older" },
|
||||
])
|
||||
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_1", "msg_2"])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("revalidates after an event overtakes an active session read", async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => (release = resolve))
|
||||
|
||||
@@ -31,8 +31,8 @@ ultimate source of truth.
|
||||
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
iterators, and synchronous generators.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
|
||||
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and
|
||||
`undefined` are no-ops, while arrays are rejected.
|
||||
- [x] Template literals with interpolation.
|
||||
- [x] Regular-expression literals.
|
||||
- [x] `NaN` and `Infinity` globals.
|
||||
@@ -44,21 +44,18 @@ ultimate source of truth.
|
||||
|
||||
## Bindings and destructuring
|
||||
|
||||
- [x] `const`, `let`, and `var` declarations.
|
||||
- [x] `const`, `let`, and accepted `var` declarations.
|
||||
- [x] Object and array destructuring in declarations, parameters, assignment expressions, and `for...of` bindings.
|
||||
- [x] Nested patterns, defaults, elisions, and rest elements.
|
||||
- [x] Assignment to identifiers, plain-object fields, non-negative integer array indexes, and writable URL
|
||||
- [x] Assignment to identifiers, unblocked plain-object fields, non-negative integer array indexes, and writable URL
|
||||
fields.
|
||||
- [x] Direct function declarations are hoisted in program and block statement lists.
|
||||
- [x] Parameter defaults observe a temporal dead zone for later parameters.
|
||||
- [x] `var` is function-scoped and hoisted: names declared anywhere in a function or program body, including loop
|
||||
heads, blocks, `switch` cases, and `try`/`catch`, read as `undefined` before their statement runs; redeclaration
|
||||
assigns the one binding; a same-named parameter keeps its argument; closures in parameter defaults see outer
|
||||
names rather than body `var`s.
|
||||
- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations.
|
||||
- [x] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and
|
||||
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
|
||||
temporal dead zone.
|
||||
- [x] Function declarations are hoisted across all cases of a `switch`, like any other statement list.
|
||||
- [ ] Hoist function declarations accepted directly in switch cases.
|
||||
- [x] Computed object destructuring keys such as `const { [field]: value } = record`.
|
||||
- [x] Object destructuring from arrays, such as `const { length } = values`.
|
||||
- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
@@ -73,7 +70,7 @@ ultimate source of truth.
|
||||
- [x] `for`, `while`, and `do...while`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
|
||||
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, and tool references.
|
||||
- [x] Unlabeled `break` and `continue`.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
- [x] `throw` with arbitrary values.
|
||||
@@ -141,10 +138,7 @@ ultimate source of truth.
|
||||
- [x] Sequence expressions (the comma operator).
|
||||
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
|
||||
`await` still defers its continuation one reaction turn.
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
|
||||
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
|
||||
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
|
||||
non-callable values are not constructors.
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
|
||||
@@ -213,16 +207,12 @@ ultimate source of truth.
|
||||
primitive wrapper objects (`Object(1)`) are rejected explicitly.
|
||||
- [x] Computed property names and object spread.
|
||||
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with
|
||||
synchronous iterator support for `fromEntries`. Sources follow ToObject: strings enumerate by index, other
|
||||
primitives and wrappers contribute nothing, and `null`/`undefined` throw. `Object.assign` accepts array
|
||||
targets for index keys only; a primitive target is a `TypeError` rather than a boxed object.
|
||||
synchronous iterator support for `fromEntries`.
|
||||
- [x] `Object.keys` over arrays and tool references.
|
||||
- [x] Object identity is preserved by in-CodeMode Object helpers.
|
||||
- [x] `__proto__`, `constructor`, and `prototype` are ordinary own data keys. `x.constructor` without an own key resolves
|
||||
to the owning built-in (`[].constructor === Array`, `new TypeError().constructor === TypeError`); prototype objects
|
||||
are not observable, so `[].__proto__` and `Object.prototype` read as `undefined` and `o.__proto__ = x` sets an own
|
||||
field.
|
||||
- [x] Circular references are rejected when created (`o.self = o`, `array.push(array)`), not at serialization as in JS.
|
||||
- [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked.
|
||||
- [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and
|
||||
cannot be created, read, or written in CodeMode; tool path segments with those names remain supported.
|
||||
- [x] `Object.is` for supported data values.
|
||||
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
|
||||
and null-prototype results.
|
||||
@@ -253,13 +243,12 @@ ultimate source of truth.
|
||||
## Strings
|
||||
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
|
||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`.
|
||||
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
||||
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`.
|
||||
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
|
||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||
- [x] `isWellFormed` and `toWellFormed`.
|
||||
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
|
||||
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
||||
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
|
||||
@@ -287,20 +276,23 @@ ultimate source of truth.
|
||||
use their epoch time) and reject opaque runtime references as data errors.
|
||||
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
|
||||
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
|
||||
example `Math.sum is not a function.` Unknown `Promise` statics keep their descriptive error.
|
||||
example `Math.sum is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
|
||||
and unknown `Promise` statics keep their descriptive error.
|
||||
- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number
|
||||
elements without coercion.
|
||||
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
|
||||
|
||||
## JSON and console
|
||||
|
||||
- [x] `JSON.parse` and `JSON.stringify` for supported data objects.
|
||||
- [x] `JSON.parse` and `JSON.stringify` for supported data objects; the blocked data-key gap listed above still applies.
|
||||
- [x] Numeric/string indentation for `JSON.stringify`.
|
||||
- [x] `JSON.parse` reviver callbacks, including postorder traversal, deletion through `undefined`, and root replacement.
|
||||
Revivers receive `(key, value)` but no `this` holder because CodeMode functions intentionally have no `this`.
|
||||
- [x] `JSON.stringify` function and array replacers. Function replacers receive `(key, value)` in preorder, including
|
||||
the root, but no `this` holder. Array replacers preserve requested property order, deduplicate names, coerce
|
||||
number primitives, and ignore non-string/non-number entries. Primitive wrapper entries remain unsupported.
|
||||
- [x] JSON callbacks retain the blocked-key boundary: parsed or stringified data containing `__proto__`, `constructor`,
|
||||
or `prototype` is rejected before callback traversal.
|
||||
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`.
|
||||
- [x] Captured `console.dir` and `console.table`.
|
||||
|
||||
@@ -320,7 +312,6 @@ ultimate source of truth.
|
||||
- [x] Local and UTC Date setters, including native argument coercion, mutation, rollover, invalid-Date recovery, and
|
||||
`TimeClip` behavior.
|
||||
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
|
||||
- [x] `toDateString` and `toTimeString` in the host's local timezone.
|
||||
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
|
||||
- [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string
|
||||
representation for the string primitive.
|
||||
@@ -332,7 +323,7 @@ ultimate source of truth.
|
||||
- [x] `test`, `exec`, and `toString`.
|
||||
- [x] Readable `source`, `flags`, `lastIndex`, `hasIndices`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`,
|
||||
`unicodeSets`, and `dotAll`.
|
||||
- [x] Captures, named groups, match `.index`, and stateful global matching.
|
||||
- [x] Captures, safe named groups (blocked member names are omitted), match `.index`, and stateful global matching.
|
||||
- [x] Integration with supported String methods, including function replacers.
|
||||
- [x] Writable `lastIndex`.
|
||||
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
|
||||
@@ -364,30 +355,20 @@ ultimate source of truth.
|
||||
`entries`, `toString`, and `size`.
|
||||
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
|
||||
|
||||
## Web platform helpers
|
||||
|
||||
- [x] `atob` and `btoa` with forgiving-base64 decoding and WebIDL string conversion; invalid input throws an Error
|
||||
named `InvalidCharacterError`, since there is no `DOMException`.
|
||||
- [x] `crypto.randomUUID()`.
|
||||
- [ ] `crypto.getRandomValues` and `crypto.subtle`, `TextEncoder`/`TextDecoder`, and `Blob`: these need a binary
|
||||
value type, which the JSON-like data model does not have yet.
|
||||
|
||||
## Errors and diagnostics
|
||||
|
||||
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
|
||||
or without `new`.
|
||||
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
|
||||
an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators.
|
||||
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. Errors have no
|
||||
`stack`; the diagnostic carries the source location instead.
|
||||
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization.
|
||||
- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types.
|
||||
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
|
||||
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
|
||||
`catch`.
|
||||
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
|
||||
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
|
||||
subset; this matrix is the full reference.
|
||||
shift them.
|
||||
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
|
||||
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
|
||||
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
|
||||
This is deliberate: the program should handle a failure the same way regardless of where it originated.
|
||||
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from internal tool
|
||||
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
|
||||
reasons.
|
||||
|
||||
@@ -105,7 +105,7 @@ export type Result = typeof Result.Type
|
||||
|
||||
/** Reusable confined runtime over explicit tools. */
|
||||
export type Runtime<R = never> = {
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
readonly catalog: () => ReadonlyArray<ToolDescription>
|
||||
readonly execute: (code: string) => Effect.Effect<Result, never, R>
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
|
||||
const prepared = ToolRuntime.prepare((options.tools ?? {}) as Tools<Services<Provided>>)
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
return {
|
||||
catalog: prepared.catalog,
|
||||
catalog: () => prepared.catalog,
|
||||
execute: (code) => executeProgram(code, prepared, limits, options),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export class ToolRuntimeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
|
||||
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
|
||||
|
||||
/**
|
||||
* Brings a host-produced runtime value into the program: runtime values pass through, their host
|
||||
* counterparts (Date, RegExp, Map, Set, URL, URLSearchParams) are wrapped, and objects become
|
||||
@@ -114,7 +118,10 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
|
||||
if (mode === "program") {
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (Object.hasOwn(copied, key)) continue
|
||||
define(copied, key, copy(item, label, mode, depth + 1, seen))
|
||||
if (isBlockedMember(key)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
|
||||
}
|
||||
Reflect.set(copied, key, copy(item, label, mode, depth + 1, seen))
|
||||
}
|
||||
}
|
||||
seen.delete(value)
|
||||
@@ -128,16 +135,13 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
|
||||
|
||||
const copied: SafeObject = plain ? (Object.create(null) as SafeObject) : {}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (isBlockedMember(key)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
|
||||
}
|
||||
const next = copy(item, label, mode, depth + 1, seen)
|
||||
if (next === undefined && mode === "json") continue
|
||||
define(copied, key, next)
|
||||
copied[key] = next
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
|
||||
// Own data property regardless of the target's prototype, so a "__proto__" key on a host object or
|
||||
// array never reaches the Object.prototype setter.
|
||||
const define = (target: object, key: string, value: unknown): void => {
|
||||
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { regexpGlobal } from "../stdlib/regexp.js"
|
||||
import { stringGlobal } from "../stdlib/string.js"
|
||||
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
|
||||
import { coercion, errorConstructors } from "../stdlib/value.js"
|
||||
import { atobGlobal, btoaGlobal, cryptoGlobal } from "../stdlib/web.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { errorGlobal } from "./errors.js"
|
||||
import { HostFunction } from "./host.js"
|
||||
@@ -72,8 +71,5 @@ export const globals = <R>(host: Host<R>): ReadonlyArray<readonly [string, unkno
|
||||
["encodeURIComponent", uriGlobal("encodeURIComponent")],
|
||||
["decodeURI", uriGlobal("decodeURI")],
|
||||
["decodeURIComponent", uriGlobal("decodeURIComponent")],
|
||||
["atob", atobGlobal],
|
||||
["btoa", btoaGlobal],
|
||||
["crypto", cryptoGlobal],
|
||||
...[...errorConstructors].map((name) => [name, errorGlobal(name, host.runner)] as const),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { type SafeObject, toProgram } from "../data.js"
|
||||
import { isBlockedMember, type SafeObject, toProgram } from "../data.js"
|
||||
import { dateSetterArgumentCount, invokeDateMethod } from "../stdlib/date.js"
|
||||
import { invokeNumberMethod } from "../stdlib/number.js"
|
||||
import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js"
|
||||
@@ -118,11 +118,9 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
result = value.trim()
|
||||
break
|
||||
case "trimStart":
|
||||
case "trimLeft":
|
||||
result = value.trimStart()
|
||||
break
|
||||
case "trimEnd":
|
||||
case "trimRight":
|
||||
result = value.trimEnd()
|
||||
break
|
||||
// Locale/options are deliberately unsupported; comparison uses the host default locale.
|
||||
@@ -243,15 +241,6 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
case "substring":
|
||||
result = value.substring(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "substr":
|
||||
result = value.substr(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "isWellFormed":
|
||||
result = value.isWellFormed()
|
||||
break
|
||||
case "toWellFormed":
|
||||
result = value.toWellFormed()
|
||||
break
|
||||
case "charCodeAt":
|
||||
result = value.charCodeAt(optNum(0) ?? 0)
|
||||
break
|
||||
@@ -291,7 +280,7 @@ const invokeStringReplacer = <R>(
|
||||
if (hasGroups) {
|
||||
const safeGroups: SafeObject = Object.create(null) as SafeObject
|
||||
for (const [key, group] of Object.entries(groups)) {
|
||||
safeGroups[key] = group
|
||||
if (!isBlockedMember(key)) safeGroups[key] = group
|
||||
}
|
||||
callbackArgs[callbackArgs.length - 1] = safeGroups
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ export class GeneratorReturn {
|
||||
|
||||
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
|
||||
|
||||
export const supportedSyntaxMessage =
|
||||
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, Promise.all/allSettled/race/any/resolve/reject over arrays mixing promises and plain values for parallel tool calls, promise chaining with .then/.catch/.finally, and new Promise((resolve, reject) => ...) construction."
|
||||
|
||||
export class InterpreterRuntimeError extends Error {
|
||||
readonly node?: AstNode
|
||||
errorName = "Error"
|
||||
@@ -109,10 +112,6 @@ export class InterpreterRuntimeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Orient the agent rather than enumerate JavaScript; interpreter-support.md is the full matrix.
|
||||
export const supportedSyntaxMessage =
|
||||
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols. Use plain functions and data objects instead."
|
||||
|
||||
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
|
||||
new InterpreterRuntimeError(
|
||||
`Syntax '${kind}' is not supported. ${supportedSyntaxMessage}`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user