Compare commits

...
44 Commits
Author SHA1 Message Date
David HillandLukeParkerDev 17e5c5fbf1 feat(app): show vertical sidebar shortcut hints (#47122)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 09:42:29 +10:00
David Hill b0a13b810e fix(app): allow new session shortcut from settings (#47123) 2026-09-04 09:42:14 +10:00
Luke Parker 6575215ddf fix(desktop): keep command palette responsive and scoped (#47164) 2026-09-04 09:41:12 +10:00
David HillandLukeParkerDev 09157609b7 feat(app): add session project menu and polish header (#46977)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 09:25:31 +10:00
Luke Parker 7ba5f3e5b2 fix(core): restore Ctrl+C in Windows terminals (#47163) 2026-09-04 09:16:14 +10:00
David HillandLukeParkerDev 0ec1587948 feat(app): add about settings page (#45782)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-03 23:07:41 +00:00
David Hill 2fda2c8141 feat(app): give experimental settings a dedicated page (#46973) 2026-09-04 08:33:29 +10:00
Luke Parker f06bf47e72 fix(app): preserve slash commands when creating worktrees (#47155) 2026-09-03 22:25:42 +00:00
opencode-agent[bot]andHona fa6e7e179d fix(desktop): render before telemetry (#47102)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-04 08:21:16 +10:00
Dax Raad 4272bb83e0 feat(cli): support command aliases with update alias for upgrade 2026-09-03 18:10:01 -04:00
usrnk1andiamdavidhill 1ae2cf3567 feat(desktop): polish menus, file tabs, and empty states (#47095)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-04 07:55:27 +10:00
Dax Raad 2084c52952 fix(core): clarify code mode tool scope 2026-09-03 17:21:06 -04:00
Aiden Cline 5a9931280e feat(core): add Modal live model backfill plugin (#47136) 2026-09-03 16:03:21 -05:00
Aiden Cline b2fb2c5e36 chore(core): bump GitHub Copilot API version to 2026-08-01 (#47108) 2026-09-03 15:38:32 -05:00
James Long 43bd2a516b refactor(cli): move update checks to TUI clients (#46963) 2026-09-03 14:55:26 -04:00
Kit Langton dad7688739 refactor(core): reuse job scope as generation identity (#47121) 2026-09-03 14:47:44 -04:00
Kit Langton 961b8ccb86 refactor(core): remove unobservable websocket states (#47117) 2026-09-03 14:24:45 -04:00
Kit Langton 97303c39dd fix(core): expand portable shell scanner compatibility (#47110) 2026-09-03 14:11:58 -04:00
Kit Langton 282c84d79f refactor(core): remove redundant step closeout flags (#46937) 2026-09-03 13:35:42 -04:00
Kit Langton 2dcfc89fab feat(tui): announce server plugin failures (#47103)
Show grouped server plugin failure notices with an Open plugins action, retain the home failure count, and reveal failed built-ins in the plugin dialog. Keep unchanged failures quiet across inventory refreshes and reconnects.
2026-09-03 13:17:10 -04:00
Kit Langton a222401f19 fix(core): normalize RPC handler failures (#46946) 2026-09-03 13:15:24 -04:00
Kit Langton 36da0d5c77 fix(core): retry failed location initialization (#46957) 2026-09-03 13:10:59 -04:00
Shoubhit Dash 7819e7f503 fix(core): run command subagents in the background (#47081) 2026-09-03 22:37:58 +05:30
Aiden Cline 6e63b970f3 feat(core): persist compaction model and provider state (#46962) 2026-09-03 12:06:50 -05:00
Kit Langton 610d7e952a refactor(client): remove redundant location spreads (#47086) 2026-09-03 12:51:12 -04:00
Kit Langton ac874a6e90 fix(core): recover idle moves through the selected instance (#46955) 2026-09-03 16:45:16 +00:00
Kit Langton f40ecefdef feat(core): disable plugins after transform failures (#47083)
Disable failed registration groups, rebuild healthy state, and report plugin failures with safe diagnostic references. Keep cleanup outside activation locks and preserve disabled revisions across unrelated reloads. Cover deferred hook and RPC cleanup with real-service regression tests.
2026-09-03 16:38:11 +00:00
Kit Langton c370a1bdd0 refactor(app): remove redundant project setter wrappers (#47085) 2026-09-03 16:33:41 +00:00
Kit Langton 309f4534fa test(app): align timeline assertions with activity controls (#47089) 2026-09-03 12:20:08 -04:00
Kit Langton 0ae3bf743f fix(tui): show execution failures in the viewed session (#46968) 2026-09-03 12:00:48 -04:00
Kit Langton f94eefaa50 fix(tui): preserve markdown blocks on plugin toggles (#47084) 2026-09-03 11:37:41 -04:00
Kit Langton a04d72bb39 fix(sdk): bind embedded transport at request time (#46971) 2026-09-03 11:16:22 -04:00
Aiden Cline 206f51547c fix(core): reject GitHub Copilot login without chat entitlement (#46959) 2026-09-03 09:56:36 -05:00
Aiden Cline 5716f8ba60 feat(ai): add UnsupportedOperation error for route capability mismatches (#46960) 2026-09-03 09:44:36 -05:00
Aiden Cline f98a6286da refactor(ai): drop responses replay tombstones (#46965) 2026-09-03 09:39:47 -05:00
usrnk1 de365ecbaa feat(desktop): reflect saved project colors (#46787) 2026-09-03 16:37:57 +02:00
usrnk1 b3b08c9a04 feat(desktop): update blue accent styling (#47009) 2026-09-03 16:31:15 +02:00
usrnk1 4fcb59e4c7 feat(desktop): polish session activity controls (#47033) 2026-09-03 16:23:54 +02:00
OpeOginni c7263309d4 fix(desktop): wait for session export (#46435) 2026-09-03 14:04:23 +00:00
opencode-agent[bot]andthdxr 5d8a01dedc feat(tui): add copy session ID command (#47064)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-09-03 13:50:21 +00:00
OpeOginniandBrendan Allan 0f6393dab1 feat(app): add desktop session import (#46416)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-09-03 13:14:40 +00:00
Shoubhit Dash 59b29de409 fix(core): detect new ecosystem config roots (#47026) 2026-09-03 16:09:58 +05:30
opencode-agent[bot]andHona 887f319769 fix(desktop): restore compact Windows channel badge (#47016)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 20:19:03 +10:00
24f6cb51c8 fix(core): watch new config files and directories (#46925)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-09-03 15:38:28 +05:30
298 changed files with 8813 additions and 1911 deletions
+4 -23
View File
@@ -423,14 +423,10 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -1042,16 +1038,12 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
const item = event.item
if (!item) return [state, NO_EVENTS]
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== item.id) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== item.id)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -1064,7 +1056,6 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
{
...state,
lifecycle,
completedMessages,
message: {
id: item.id,
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
@@ -1094,7 +1085,7 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
]
}
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined || state.completedTools.has(item.id)) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -1198,14 +1189,9 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const active = state.message?.id === item.id
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const phase = itemPhase === undefined && active ? state.message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
const content: string[] = []
for (const part of parts) {
@@ -1221,8 +1207,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
completedMessages,
message: undefined,
message: active ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1230,7 +1215,6 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
if (state.completedTools.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
@@ -1257,7 +1241,6 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, item.id]),
},
events,
] satisfies StepResult
@@ -1518,11 +1501,9 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
providerMetadataKey: metadataKey(request.model),
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
completedTools: new Set<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
+25
View File
@@ -6,11 +6,13 @@ import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/
import {
InvalidProviderOutputError,
InvalidRequestError,
UnsupportedOperationError,
AIError,
HttpContext,
type ContentPart,
type LLMRequest,
type MediaPart,
type ProviderID,
type TextPart,
type ToolResultPart,
} from "../schema/index.js"
@@ -254,6 +256,29 @@ export const invalidRequest = (message: string, cause?: unknown) =>
reason: new InvalidRequestError({ message, cause }),
})
/**
* Canonical constructor for operations the selected route does not implement.
* Prefer this over `invalidRequest` when the failure is a missing route
* capability rather than a malformed caller input, so consumers can branch on
* `reason._tag` plus `reason.operation` instead of matching message text.
*/
export const unsupportedOperation = (input: {
readonly operation: string
readonly message: string
readonly provider?: ProviderID
readonly route?: string
readonly cause?: unknown
}) =>
new AIError({
reason: new UnsupportedOperationError({
operation: input.operation,
message: input.message,
provider: input.provider,
route: input.route,
cause: input.cause,
}),
})
export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* (
route: string,
name: string,
+6 -3
View File
@@ -46,9 +46,12 @@ const adapter = {
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
if (request.providerOptions?.contextManagement !== undefined)
return yield* ProviderShared.invalidRequest(
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
)
return yield* ProviderShared.unsupportedOperation({
operation: "in-band-compaction",
provider: request.model.provider,
route: request.model.route.id,
message: "xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
})
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
})
+6 -3
View File
@@ -585,9 +585,12 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.suspend(() => {
const operation = request.model.route.compact
if (!operation)
return ProviderShared.invalidRequest(
`${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
)
return ProviderShared.unsupportedOperation({
operation: "compact",
provider: request.model.provider,
route: request.model.route.id,
message: `${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
})
return operation(prepareRequest(request), executor, options)
}),
})
+16
View File
@@ -35,6 +35,21 @@ export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>
},
) {}
/**
* A caller-requested operation the selected route does not implement, such as
* explicit compaction on a route without a compact endpoint. Detected locally
* before any network I/O, so unlike transport or provider-output failures it
* never carries HTTP context from a provider round-trip.
*/
export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOperationError>(
"AI.Error.UnsupportedOperation",
)("UnsupportedOperation", {
...ReasonFields,
operation: Schema.String,
provider: Schema.optional(ProviderID),
route: Schema.optional(RouteID),
}) {}
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
...ReasonFields,
route: RouteID,
@@ -107,6 +122,7 @@ export class UnknownProviderError extends Schema.TaggedError<UnknownProviderErro
export const AIErrorReason = Schema.Union([
InvalidRequestError,
UnsupportedOperationError,
NoRouteError,
AuthenticationError,
RateLimitError,
+25 -1
View File
@@ -1,8 +1,10 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import { CompactionPart, CompactionResponse, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js"
import { LLM, LLMClient, LLMRequest, LanguageModel } from "../src/index.js"
import { OpenAI, Anthropic } from "../src/providers.js"
import { testEffect } from "./lib/effect.js"
import { fixedResponse } from "./lib/http.js"
test("runtime capability checks follow model and route updates", () => {
const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture")
@@ -75,3 +77,25 @@ test("tagged content and event guards accept both checkpoint representations", (
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))).toEqual(message)
}
})
testEffect(fixedResponse("")).effect(
"explicit compaction on a route without a compact endpoint fails with UnsupportedOperation",
() =>
Effect.gen(function* () {
const request = LLM.request({
model: Anthropic.configure({ apiKey: "test" }).model("fixture"),
prompt: "hello",
})
expect(LLMClient.canCompact(request)).toBe(false)
const error = yield* LLMClient.compact(
request as unknown as Parameters<typeof LLMClient.compact>[0],
).pipe(Effect.flip)
expect(error.reason._tag).toBe("UnsupportedOperation")
expect(error.message).toContain("does not support explicit compaction")
if (error.reason._tag === "UnsupportedOperation") {
expect(error.reason.operation).toBe("compact")
expect(error.reason.provider).toBe("anthropic")
expect(error.reason.route).toBe("anthropic-messages")
}
}),
)
@@ -130,16 +130,22 @@ for (const model of [
}),
],
})
for (const candidate of [
LLMRequest.update(request, {
tools: [
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
],
}),
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
]) {
for (const [candidate, tag] of [
[
LLMRequest.update(request, {
tools: [
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
],
}),
"InvalidRequest",
],
[
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
model.provider === "xai" ? "UnsupportedOperation" : "InvalidRequest",
],
] as const) {
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.reason._tag).toBe(tag)
const response = yield* LLMClient.compact(candidate)
expect(response.replacement[0]?.content[0]?.type).toBe("compaction")
}
@@ -361,8 +367,9 @@ testEffect(fixedResponse("must not execute")).effect("xAI rejects automatic comp
{ providerOptions: { contextManagement: [{ type: "compaction" }] } },
)
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.reason._tag).toBe("UnsupportedOperation")
expect(error.message).toContain("LLMClient.compact")
if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("in-band-compaction")
}),
)
@@ -428,7 +435,9 @@ for (const model of [
Effect.gen(function* () {
// @ts-expect-error Untyped callers must still receive the runtime capability error.
const error = yield* LLMClient.compact(LLM.request({ model, prompt: "hello" })).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.reason._tag).toBe("UnsupportedOperation")
expect(error.message).toContain("does not support explicit compaction")
if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("compact")
}),
)
}
@@ -82,32 +82,6 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -217,7 +217,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
it.effect("preserves non-empty done-only message content", () =>
Effect.gen(function* () {
const text = {
type: "message",
@@ -230,17 +230,11 @@ describe("Open Responses basic-item lifecycles", () => {
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
@@ -272,63 +266,6 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
completed,
)
expect(events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "text-end",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
}),
)
// Captured from Bedrock Mantle (openai.gpt-oss-120b): the terminal function_call
// items rename `id` to `item_id` and carry a stray `output_index`.
it.effect("recovers a terminal function_call id from its output slot", () =>
@@ -419,7 +356,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("opens and closes a done-only tool once", () =>
it.effect("opens and closes a done-only tool", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
@@ -428,12 +365,7 @@ describe("Open Responses basic-item lifecycles", () => {
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
completed,
)
const events = yield* collect({ type: "response.output_item.done", item }, completed)
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
@@ -2850,7 +2850,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("ignores duplicate item boundary events", () =>
it.effect("ignores duplicate item start events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
@@ -2881,21 +2881,6 @@ describe("OpenAI Responses route", () => {
arguments: '{"query":"weather"}',
},
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
// A completed item that is re-added stays closed.
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
+8
View File
@@ -24,6 +24,7 @@ import {
ToolResultValue,
TransportError,
UnknownProviderError,
UnsupportedOperationError,
Usage,
} from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js"
@@ -276,6 +277,12 @@ test("AI errors serialize diagnostics only on their typed reason", () => {
test("AI error reasons are tagged Errors with required messages", () => {
const reasons = [
new InvalidRequestError({ message: "Invalid request" }),
new UnsupportedOperationError({
message: "Unsupported operation",
operation: "compact",
provider: model.provider,
route: "fake-route",
}),
new NoRouteError({
message: "No route",
route: RouteID.make("missing"),
@@ -293,6 +300,7 @@ test("AI error reasons are tagged Errors with required messages", () => {
]
expect(reasons.map((reason) => reason._tag)).toEqual([
"InvalidRequest",
"UnsupportedOperation",
"NoRoute",
"Authentication",
"RateLimit",
@@ -0,0 +1,47 @@
import { benchmark, expect } from "./benchmark"
import { openCommandPalette } from "../utils/command-palette"
benchmark.use({
viewport: { width: 1440, height: 900 },
serviceWorkers: "block",
traceScope: "interaction",
trace: "off",
video: "off",
})
for (const home of [false, true]) {
benchmark(`command lookup from ${home ? "home" : "session"}`, async ({ page, report }) => {
const { dialog, input } = await openCommandPalette(page, home)
const title = home ? "Open settings" : "Copy Session ID"
const query = home ? "open settings" : "copy session"
// Measure input-to-selected-result in the renderer, without assertion polling overhead.
await input.evaluate((element, title) => {
element.addEventListener(
"input",
() => {
performance.mark("palette-input")
const observer = new MutationObserver(() => {
if (document.querySelectorAll('[role="dialog"] [role="option"]').length !== 1) return
const selected = document.querySelector('[role="dialog"] [role="option"][aria-selected="true"]')
if (!selected?.textContent?.includes(title)) return
performance.measure("palette-result", "palette-input")
observer.disconnect()
})
observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true })
},
{ once: true, capture: true },
)
}, title)
await input.fill(query)
await expect(dialog.getByRole("option")).toHaveCount(1)
await expect(dialog.getByRole("option", { name: new RegExp(`^${title}(?:$| )`) })).toHaveAttribute(
"aria-selected",
"true",
)
const result = await page.evaluate(() =>
performance.getEntriesByName("palette-result").map((entry) => entry.duration),
)
expect(result).toHaveLength(1)
report({ inputToResultMs: result[0] }, { home, query, data: "fixture; immediate server responses" })
})
}
@@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test"
import { openCommandPalette, paletteSession } from "../utils/command-palette"
test.use({ serviceWorkers: "block" })
test("failed event-driven reads report an error and recover without an unhandled rejection", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
const palette = await openCommandPalette(page)
const path = `**/api/session/${paletteSession.id}`
await page.route(path, (route) => route.abort("failed"))
const requested = page.waitForRequest(path)
await page.evaluate((sessionID) => {
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([
{
id: "evt_failed_refresh",
created: 2,
type: "session.viewed",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, idle: 2 },
},
])
}, paletteSession.id)
await requested
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await palette.input.fill("copy session")
await expect(palette.dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await palette.input.press("Escape")
await page.unroute(path)
await page.evaluate((sessionID) => {
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([
{
id: "evt_recovered_refresh",
created: 3,
type: "session.renamed",
durable: { aggregateID: sessionID, seq: 2, version: 1 },
data: { sessionID, title: "Recovered session" },
},
])
}, paletteSession.id)
await expect(page.getByRole("heading", { name: "Recovered session", exact: true })).toBeVisible()
expect(errors).toEqual([])
})
@@ -0,0 +1,102 @@
import { expect, test } from "@playwright/test"
import { captureConsoleWarnings, openCommandPalette, paletteSession } from "../utils/command-palette"
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
test("copies the session ID while file and session searches are still pending", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const { dialog, input } = await openCommandPalette(page)
const release = Promise.withResolvers<void>()
await page.route(/\/api\/(session\?|fs\/find\?)/, async (route) => {
await release.promise
await route.fallback()
})
await input.pressSequentially("copy session")
const copy = dialog.getByRole("option", { name: "Copy Session ID", exact: true })
await expect(copy).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(dialog).toHaveCount(0)
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(paletteSession.id)
await expect(page.locator('[data-testid^="toast-v2-"] [data-slot="icon-svg"]')).toBeVisible()
expect(warnings).toEqual([])
release.resolve()
})
test("home commands do not wait for session search", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page, true)
const release = Promise.withResolvers<void>()
await page.route("**/api/session?*", async (route) => {
await release.promise
await route.fallback()
})
await input.fill("open settings")
await expect(dialog.getByRole("option")).toHaveCount(1)
await expect(dialog.getByRole("option", { name: /^Open settings/ })).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(page).toHaveURL("/settings")
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
release.resolve()
})
test("appends search results without resetting the selected command", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const files = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
await page.route("**/api/fs/find?*", async (route) => {
await files.promise
await route.fulfill({ json: { data: [{ path: "copy.txt", type: "file" }] } })
})
await page.route("**/api/session?*", async (route) => {
await sessions.promise
await route.fulfill({
json: {
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "Copy fixture" }],
},
})
})
await input.fill("copy")
const project = dialog.getByRole("option", { name: "Copy Project ID", exact: true })
await expect(project).toBeVisible()
// Select a non-first command with the keyboard before remote results arrive.
await input.press("ArrowDown")
await expect(project).toHaveAttribute("aria-selected", "true")
files.resolve()
await expect(dialog.getByRole("option", { name: "/ copy.txt", exact: true })).toBeVisible()
await expect(project).toHaveAttribute("aria-selected", "true")
// File results are usable even while sessions are still pending.
sessions.resolve()
await expect(dialog.getByRole("option", { name: /Copy fixture/ })).toBeVisible()
await expect(project).toHaveAttribute("aria-selected", "true")
await input.fill("copy session")
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0)
})
test("keeps the automatically selected file when session results arrive later", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const sessions = Promise.withResolvers<void>()
await page.route("**/api/fs/find?*", (route) =>
route.fulfill({ json: { data: [{ path: "README.md", type: "file" }] } }),
)
await page.route("**/api/session?*", async (route) => {
await sessions.promise
await route.fulfill({
json: {
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "README work" }],
},
})
})
await input.fill("README")
const file = dialog.getByRole("option", { name: "/ README.md", exact: true })
await expect(file).toHaveAttribute("aria-selected", "true")
sessions.resolve()
await expect(dialog.getByRole("option", { name: /README work/ })).toBeVisible()
await expect(file).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(dialog).toHaveCount(0)
await expect(page.getByRole("tab", { name: "README.md", exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
})
@@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test"
import { captureConsoleWarnings, openCommandPalette } from "../utils/command-palette"
test.use({ serviceWorkers: "block", video: "off" })
test("opening and closing files does not duplicate tab commands", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const palette = await openCommandPalette(page)
await page.route("**/api/fs/find?*", (route) =>
route.fulfill({
headers: { "access-control-allow-origin": "*" },
json: { data: [{ path: "fixture.txt", type: "file" }] },
}),
)
await palette.input.fill("fixture.txt")
await palette.dialog.getByRole("option", { name: /fixture\.txt/ }).click()
const file = page.getByRole("tab", { name: /fixture\.txt/ })
await expect(file).toBeVisible()
await expect(palette.dialog).toHaveCount(0)
await page
.getByRole("complementary", { name: "Review and files" })
.getByRole("button", { name: "Close tab", exact: true })
.click()
await expect(file).toHaveCount(0)
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
expect(warnings).toEqual([])
})
test("navigation replaces commands without retaining disposed owners", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const palette = await openCommandPalette(page, true)
await palette.input.press("Escape")
await expect(palette.dialog).toHaveCount(0)
await page
.getByRole("region", { name: "Recent sessions" })
.getByRole("button", { name: /Palette fixture session/ })
.click()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await page.keyboard.press("ControlOrMeta+t")
await expect(page).toHaveURL(/\/new-session\?/)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await page.locator('[data-component="composer-editor"]').blur()
await page.keyboard.press("Control+l")
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
await page.keyboard.press("ControlOrMeta+Shift+P")
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox")).toBeFocused()
await expect(dialog.getByRole("textbox")).toHaveAttribute("placeholder", "Search files, commands, and sessions")
await dialog.getByRole("textbox").fill("copy session")
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveCount(0)
await dialog.getByRole("textbox").press("Escape")
await expect(dialog).toHaveCount(0)
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
for (const count of [3, 4]) {
await page.getByRole("button", { name: "New session", exact: true }).click()
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(count)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
}
await page.setViewportSize({ width: 600, height: 800 })
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-titlebar-tab-link]')).toHaveCount(4)
await page.setViewportSize({ width: 1280, height: 800 })
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toHaveCount(4)
await page.keyboard.press("ControlOrMeta+w")
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(3)
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+Shift+P")
await expect(dialog.getByRole("textbox")).toBeFocused()
await dialog.getByRole("textbox").fill("copy session")
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
expect(warnings).toEqual([])
})
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -335,6 +336,61 @@ test("restores the draft after closing and revisiting a pending session that fai
expect(mock.prompts).toEqual([])
})
test("executes a selected slash command after creating its worktree", async ({ page }, testInfo) => {
const events: OpenCodeEvent[] = []
const mock = await openDraft(page, { command: true, events: () => events.splice(0) })
const commands: { sessionID: string; body: Record<string, unknown> }[] = []
const expanded =
"Review the latest commit for correctness and regressions. Check the relevant tests and report actionable findings."
await page.route("**/api/session/*/command", async (route) => {
if (route.request().method() !== "POST") return route.fallback()
const sessionID = new URL(route.request().url()).pathname.split("/")[3]
commands.push({ sessionID, body: route.request().postDataJSON() })
// The server owns command expansion; the client receives the expanded inbox item.
events.push({
id: "evt_workspace_review",
type: "session.inbox.enqueued",
created: Date.now(),
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: {
sessionID,
inboxID: "msg_workspace_review",
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
},
})
await route.fulfill({ status: 204, headers })
})
const editor = page.locator('[data-component="composer-editor"]')
await editor.fill("/review")
const suggestion = page.getByRole("button", { name: "/review Review changes", exact: true })
await expect(suggestion).toBeVisible()
await suggestion.click()
await expect(editor).toHaveText("/review")
const pending = await submitPending(page, mock, "/review latest commit")
await draftFollowUp(page)
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect
.poll(() => commands)
.toEqual([
{
sessionID: pending.sessionID,
body: { command: "review", text: "latest commit", files: [], agents: [], skills: [], delivery: "steer" },
},
])
await expect(pending.shimmer).toHaveCount(0)
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText(expanded)
await expect(editor).toHaveText(followUp)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
expect(mock.prompts).toEqual([])
await testInfo.attach("expanded-worktree-command", {
body: await page.screenshot({ path: testInfo.outputPath("expanded-worktree-command.png") }),
contentType: "image/png",
})
})
async function draftFollowUp(page: Page) {
const editor = page.locator('[data-component="composer-editor"]')
await editor.pressSequentially("!")
@@ -346,7 +402,10 @@ async function draftFollowUp(page: Page) {
await expect(editor).toHaveText(followUp)
}
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
async function openDraft(
page: Page,
options?: { failSessionCreate?: boolean; command?: boolean; events?: () => OpenCodeEvent[] },
) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
const worktreeRequests: Record<string, unknown>[] = []
@@ -378,6 +437,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
sessions,
pageMessages: () => ({ items: [] }),
onPrompt: (input) => prompts.push(input),
events: options?.events,
})
page.on("request", (request) => {
if (request.method() !== "POST") return
@@ -436,6 +496,17 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
headers,
}),
)
if (options?.command) {
await page.route("**/api/command?**", (route) =>
route.fulfill({
json: {
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
data: [{ name: "review", description: "Review changes" }],
},
headers,
}),
)
}
await page.addInitScript(
({ directory, draftID, otherID, server }) => {
localStorage.setItem(
@@ -464,8 +535,8 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
return { worktree, worktreeRequests, calls, creates, prompts }
}
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
await page.locator('[data-component="composer-editor"]').fill(text)
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>, prompt = text) {
await page.locator('[data-component="composer-editor"]').fill(prompt)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await page.locator('[data-action="composer-submit"]').click()
await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname))
@@ -481,7 +552,7 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
await expect(message).toHaveCount(1)
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(prompt)
await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/)
const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "")
await expect(shimmer).toBeVisible()
@@ -1,6 +1,7 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import { createMockServerHandler } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097"
@@ -8,6 +9,8 @@ const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
const childB = { ...session("ses_server_b_child", sessionB.directory, "Server B subagent"), parentID: sessionB.id }
test.use({ serviceWorkers: "block" })
test("tab busy indicator reflects activity in the tab session family", async ({ page }, info) => {
await mockServers(page)
await page.addInitScript(
@@ -27,7 +30,7 @@ test("tab busy indicator reflects activity in the tab session family", async ({
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(hrefB)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
// The parent is idle, but its tab remains active while the background child runs.
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
@@ -52,65 +55,61 @@ function session(id: string, directory: string, title: string) {
}
async function mockServers(page: Page) {
// Both servers stay connected while the client hydrates their active-session snapshots.
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
const servers = new Map(
[sessionA, sessionB].map(
(current) =>
[
current === sessionA ? serverA : serverB,
createMockServerHandler({
directory: current.directory,
project: {
id: current.projectID,
worktree: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
},
sessions: current === sessionB ? [current, childB] : [current],
sessionStatus: current === sessionB ? { [childB.id]: { type: "running" } } : {},
provider: { all: [], connected: [], default: {} },
pageMessages: () => ({ items: [] }),
}),
] as const,
),
)
page.on("close", () => servers.forEach((server) => void server.dispose()))
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const server = servers.get(url.origin)
if (!server) return route.fallback()
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [childB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session")
return json(route, {
data: url.origin === serverB ? [currentSession(current), currentSession(childB)] : [currentSession(current)],
cursor: {},
if (directory && directory !== current.directory)
return route.fulfill({
status: 500,
json: { name: "InvalidDirectory" },
headers: { "access-control-allow-origin": "*" },
})
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: current.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
const project = {
id: current.projectID,
canonical: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
}
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
if (route.request().method() === "OPTIONS")
return route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
})
return json(route, {})
})
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
const body = route.request().postDataBuffer()
const response = await server.handler(
new Request(url, {
method: route.request().method(),
headers: route.request().headers(),
body: body ? Uint8Array.from(body) : undefined,
}),
)
return route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
body: Buffer.from(await response.arrayBuffer()),
})
})
}
@@ -92,10 +92,16 @@ test("opens and searches project files inline", async ({ page }) => {
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Open file" }).locator("use")).toHaveAttribute(
"href",
"#opencode-v2-icon-plus",
)
await panel.getByRole("button", { name: "Open file" }).click()
const openFileTab = panel.getByRole("tab", { name: "Open file" })
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
await expect(openFileTab).toHaveAttribute("data-selected", "")
await expect(openFileTab.locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(openFileTab.locator("..")).toHaveCSS("gap", "8px")
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
@@ -114,6 +120,8 @@ test("opens and searches project files inline", async ({ page }) => {
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("gap", "8px")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
@@ -129,6 +137,8 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("gap", "8px")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
@@ -23,6 +23,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
const more = header.getByRole("button", { name: "More options", exact: true })
const project = header.getByRole("button", { name: fixture.project.name, exact: true })
const review = header.getByRole("button", { name: "Toggle review", exact: true })
const details = header.getByRole("button", { name: "Session details", exact: true })
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
@@ -31,22 +32,40 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(details).toBeVisible()
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
await expect(status).toBeVisible()
await expect
.poll(async () => {
const boxes = await Promise.all(
[header.getByRole("heading"), more, review, details].map((button) => button.boundingBox()),
)
const [title, menu, sidebar, summary] = boxes
if (!title || !menu || !sidebar || !summary) return false
return direction === "ltr"
? Math.abs(title.x + title.width - menu.x) <= 1 &&
menu.x + menu.width <= summary.x &&
summary.x + summary.width <= sidebar.x
: Math.abs(menu.x + menu.width - title.x) <= 1 &&
sidebar.x + sidebar.width <= summary.x &&
summary.x + summary.width <= menu.x
})
.toBe(true)
const titleBounds = await header.getByRole("heading").boundingBox()
expect(titleBounds).not.toBeNull()
for (const editing of [false, true]) {
if (editing) {
await header.getByRole("heading").click()
await expect(header.getByRole("textbox")).toHaveValue(fixture.expected.targetTitle)
await expect(header.getByRole("textbox")).toBeFocused()
}
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-left", "4px")
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-right", "4px")
await expect
.poll(async () => {
const boxes = await Promise.all(
[project, header.locator('[data-slot="session-title-child"]'), more, review, details].map((control) =>
control.boundingBox(),
),
)
const [icon, title, menu, sidebar, summary] = boxes
if (!icon || !title || !menu || !sidebar || !summary || !titleBounds) return false
if (Math.abs(title.y - titleBounds.y) > 0.5 || Math.abs(title.height - titleBounds.height) > 0.5) return false
return direction === "ltr"
? Math.abs(title.x - icon.x - icon.width - 2) <= 0.5 &&
Math.abs(menu.x - title.x - title.width - 2) <= 0.5 &&
menu.x + menu.width <= summary.x &&
summary.x + summary.width <= sidebar.x
: Math.abs(icon.x - title.x - title.width - 2) <= 0.5 &&
Math.abs(title.x - menu.x - menu.width - 2) <= 0.5 &&
sidebar.x + sidebar.width <= summary.x &&
summary.x + summary.width <= menu.x
})
.toBe(true)
}
await header.getByRole("textbox").press("Escape")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await review.click()
await expect(review).toHaveAttribute("aria-expanded", "true")
@@ -55,6 +74,52 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(review).toHaveAttribute("aria-expanded", "false")
await more.click()
const options = page.getByRole("menu")
await expect(options.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"])
if (direction === "ltr") {
await expect
.poll(async () => {
const [button, menu] = await Promise.all([
header.getByRole("button", { name: "More options", exact: true, includeHidden: true }).boundingBox(),
options.boundingBox(),
])
return button && menu ? Math.abs(button.x - menu.x) : Infinity
})
.toBeLessThanOrEqual(1)
}
await expect
.poll(() =>
options.evaluate((element) => {
const menu = element.getBoundingClientRect()
const rtl = getComputedStyle(element).direction === "rtl"
return Math.min(
...Array.from(element.querySelectorAll('[data-slot="menu-v2-item-content"]'), (label) => {
const range = document.createRange()
range.selectNodeContents(label)
const text = range.getBoundingClientRect()
return rtl ? text.left - menu.left : menu.right - text.right
}),
)
}),
)
.toBeCloseTo(32, 0)
await expect
.poll(() =>
options.evaluate((element) => {
const menu = element.getBoundingClientRect()
const divider = element.querySelector('[data-slot="menu-v2-separator"]')?.getBoundingClientRect()
const rows = Array.from(element.querySelectorAll('[role="menuitem"]'), (row) => row.getBoundingClientRect())
return (
!!divider &&
Math.abs(divider.left - menu.left) <= 0.5 &&
Math.abs(divider.right - menu.right) <= 0.5 &&
rows.every(
(row) => Math.abs(row.left - menu.left - 2) <= 0.5 && Math.abs(menu.right - row.right - 2) <= 0.5,
)
)
}),
)
.toBe(true)
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await status.click()
@@ -0,0 +1,327 @@
import { expect, test } from "@playwright/test"
import { dict } from "../../src/runtime/i18n/ar"
import en from "../../src/runtime/i18n/en"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ serviceWorkers: "block" })
for (const direction of ["ltr", "rtl"] as const) {
for (const workspace of [false, true]) {
test(`session project menu for ${workspace ? "worktree" : "local"} in ${direction}`, async ({ page }) => {
const copy = direction === "rtl" ? dict : en
const directory = workspace
? "C:/OpenCode/Worktrees/مشروع-42/long-folder-name-for-checking-wrapped-worktree-paths/another-long-folder-name-to-exercise-the-full-path-tooltip"
: fixture.directory
const project = {
...fixture.project,
name: workspace
? "مشروع Timeline 42 with a long project name that needs truncation and enough additional text to wrap inside the tooltip"
: "Timeline project",
sandboxes: workspace ? [directory] : [],
icon: {
url: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="7" fill="blue"/></svg>')}`,
},
}
await mockOpenCodeServer(page, {
directory,
project,
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.addInitScript((direction) => {
localStorage.setItem(
"opencode.global.dat:language",
JSON.stringify({ locale: direction === "rtl" ? "ar" : "en" }),
)
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, general: { ...settings.general, showProjectIcon: false } }),
)
}, direction)
await page.setViewportSize({ width: workspace ? 900 : 1440, height: 900 })
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await expect(page.locator("html")).toHaveAttribute("dir", direction)
const trigger = header.getByRole("button", { name: project.name, exact: true })
await expect(trigger).toBeEnabled()
await expect(trigger.locator("use")).toHaveAttribute(
"href",
`#opencode-v2-icon-${workspace ? "workspace-isolated" : "monitor"}`,
)
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
await trigger.hover()
await expect(trigger).not.toHaveCSS("background-color", background)
await expect(page.getByRole("tooltip")).toHaveText(project.name)
await trigger.click()
const menu = page.getByRole("menu", { name: project.name, exact: true })
const settings = menu.getByRole("menuitem", { name: "Edit project", exact: true })
const projectItem = menu.getByRole("menuitem", { name: project.name, exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(page.getByRole("tooltip")).toBeHidden()
await expect(menu.getByText(project.name, { exact: true })).toBeVisible()
await expect(menu.locator('[data-slot="project-avatar-image"]')).toHaveAttribute("src", project.icon.url)
await expect(menu.getByText(directory, { exact: true })).toBeVisible()
await expect(menu.getByText(directory, { exact: true })).toHaveAttribute("dir", "ltr")
await expect(menu.locator('use[href="#opencode-v2-icon-folder"]')).toHaveCount(1)
await expect(menu).toHaveCSS("direction", direction)
await expect(menu.getByRole("menuitem")).toHaveText([project.name, directory, "Edit project"])
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
await expect(settings).toBeEnabled()
await expect
.poll(() => menu.evaluate((element) => element.getBoundingClientRect().width))
.toBeLessThanOrEqual(320)
for (const text of [project.name, directory]) {
const label = menu.getByText(text, { exact: true })
await expect(label).toHaveCSS("text-overflow", "ellipsis")
await expect(label).toHaveCSS("white-space", "nowrap")
if (workspace) {
await expect.poll(() => label.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
}
}
await expect.poll(() => menu.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
const icons = menu.locator(
'[data-component="project-avatar-v2"], [data-slot="icon-svg"]:not([data-slot="session-project-open-icon"] *)',
)
await expect(icons).toHaveCount(3)
await expect
.poll(async () => {
const [button, centers] = await Promise.all([
trigger.boundingBox(),
icons.evaluateAll((elements) =>
elements.map((element) => {
const box = element.getBoundingClientRect()
return box.x + box.width / 2
}),
),
])
return !!button && centers.every((center) => Math.abs(center - button.x - button.width / 2) <= 1)
})
.toBe(true)
if (!workspace) await page.clock.install()
for (const text of [project.name, directory]) {
const label = menu.getByText(text, { exact: true })
const item = menu.getByRole("menuitem", { name: text, exact: true })
const anchor = item.locator("..")
const openIcon = item.locator('[data-slot="session-project-open-icon"]')
const content = item.locator(".session-project-link-content")
const width = await label.evaluate((element) => element.getBoundingClientRect().width)
await expect(openIcon).toHaveCount(text === directory ? 1 : 0)
await anchor.hover()
await expect(content).toHaveCSS("mask-image", "none")
await expect.poll(() => label.evaluate((element) => element.getBoundingClientRect().width)).toBe(width)
if (text === directory) {
await expect(openIcon).toHaveCSS("opacity", "0")
await expect(openIcon.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-up-right")
await expect
.poll(() => openIcon.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
.toBeGreaterThan(0)
await expect
.poll(async () => {
const [row, icon] = await Promise.all([item.boundingBox(), openIcon.boundingBox()])
if (!row || !icon) return false
return (
Math.abs(row.y + row.height / 2 - icon.y - icon.height / 2) <= 0.5 &&
Math.abs((direction === "rtl" ? icon.x - row.x : row.x + row.width - icon.x - icon.width) - 12) <= 0.5
)
})
.toBe(true)
}
await expect(label).toHaveCSS("cursor", "default")
await expect(anchor).toHaveCSS("cursor", "default")
const tooltip = page.getByRole("tooltip")
if (workspace) {
await expect(tooltip).toHaveText(text)
await expect(tooltip).toHaveCSS("white-space", "normal")
await expect
.poll(() => tooltip.evaluate((element) => element.getBoundingClientRect().width))
.toBeLessThanOrEqual(480)
await expect
.poll(() =>
tooltip
.getByText(text, { exact: true })
.evaluate(
(element) =>
element.getBoundingClientRect().height > Number.parseFloat(getComputedStyle(element).lineHeight),
),
)
.toBe(true)
await expect
.poll(async () => {
const [row, tip] = await Promise.all([anchor.boundingBox(), tooltip.boundingBox()])
return !!row && !!tip && Math.abs(row.y - tip.y - tip.height - 2) <= 1
})
.toBe(true)
}
if (!workspace) {
await page.clock.runFor(500)
await expect(tooltip).toBeHidden()
}
await settings.hover()
await expect(tooltip).toBeHidden()
if (text === directory) await expect(openIcon).toHaveCSS("opacity", "0")
await expect(content).toHaveCSS("mask-image", "none")
}
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.press("ArrowDown")
await expect(projectItem).toBeFocused()
await page.keyboard.press("ArrowDown")
const pathItem = menu.getByRole("menuitem", { name: directory, exact: true })
await expect(pathItem).toBeFocused()
if (workspace) await expect(page.getByRole("tooltip")).toHaveText(directory)
await page.keyboard.press("Enter")
await expect(menu).toBeVisible()
await expect(pathItem).toBeFocused()
await page.keyboard.press("Space")
await expect(menu).toBeVisible()
await expect(pathItem).toBeFocused()
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.press("ArrowDown")
await expect(projectItem).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(pathItem).toBeFocused()
if (workspace) await expect(page.getByRole("tooltip")).toHaveText(directory)
await page.keyboard.press("ArrowDown")
await expect(settings).toBeFocused()
await expect(page.getByRole("tooltip")).toBeHidden()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
project.name,
)
await expect(menu).toBeHidden()
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.setViewportSize({ width: 1440, height: 900 })
for (const selected of [false, true]) {
if (selected) {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
}
await trigger.click()
await expect(projectItem).toBeEnabled()
const background = await projectItem.evaluate((element) => getComputedStyle(element).backgroundColor)
await projectItem.hover()
await expect(projectItem).not.toHaveCSS("background-color", background)
await projectItem.click()
await expect(page).toHaveURL(new URL("/", page.url()).href)
await expect(menu).toBeHidden()
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: project.name })
await expect(projectRow).toBeVisible()
await expect(projectRow).toHaveAttribute("data-selected", "")
await expect(
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
).toBeVisible()
}
})
}
}
for (const state of ["closed", "unopened"] as const) {
test(`session project menu restores ${state} projects before and after messages load`, async ({ page }) => {
const directory = "C:/OpenCode/Worktrees/project-menu-recovery"
const messages = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory,
project: { ...fixture.project, sandboxes: [directory] },
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
provider: fixture.provider,
pageMessages,
beforeMessagesResponse: (input) => (input.sessionID === fixture.targetID ? messages.promise : Promise.resolve()),
})
await page.setViewportSize({ width: 1440, height: 900 })
if (state === "closed") {
await installStressSessionTabs(page)
await page.goto("/")
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name })
await expect(projectRow).toBeEnabled()
await projectRow.locator("..").getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
await expect(projectRow).toHaveCount(0)
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
}
if (state === "unopened") await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
const trigger = header.getByRole("button", { name: fixture.project.name, exact: true })
const menu = page.getByRole("menu", { name: fixture.project.name, exact: true })
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
for (const loaded of [false, true]) {
if (loaded) {
messages.resolve()
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
}
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-workspace-isolated")
await trigger.click()
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue(
fixture.project.name,
)
await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
}
await trigger.click()
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
await expect(page).toHaveURL(new URL("/", page.url()).href)
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name })
await expect(projectRow).toBeVisible()
await expect(projectRow).toHaveAttribute("data-selected", "")
await expect(
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
).toBeVisible()
})
}
test("path arrow has a glyph when the page has an older icon sprite", async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.route(
(url) => url.pathname === stressSessionHref(fixture.targetID),
async (route) => {
const response = await route.fetch()
await route.fulfill({
response,
body: (await response.text()).replace(
'<div id="root"',
'<svg id="opencode-v2-icon-sprite" width="0" height="0" aria-hidden="true"><symbol id="opencode-v2-icon-monitor" viewBox="0 0 16 16"><path d="M1 1h14v14H1z"/></symbol></svg><div id="root"',
),
})
},
)
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await header.getByRole("button", { name: fixture.project.name, exact: true }).click()
const path = page.getByRole("menu").getByRole("menuitem", { name: fixture.directory, exact: true })
const arrow = path.locator('[data-slot="session-project-open-icon"]')
await expect(arrow).toHaveCount(1)
await expect
.poll(() => arrow.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
.toBeGreaterThan(0)
await expect(page.locator("#opencode-v2-icon-sprite")).toHaveCount(1)
})
@@ -78,7 +78,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
const notices = page.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toContainText("Agent · explore")
await expect(notices.nth(0)).toHaveText(/^Agent changed\s*Explore$/)
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
@@ -182,20 +182,15 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await expect(card).not.toContainText("(background)")
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
const hint = page.locator('[data-component="session-background-hint"]')
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
const hint = page.getByRole("button", { name: /move running work to the background/i })
await expect(hint).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect
.poll(async () => {
const [cardBox, hintBox, prefixBox] = await Promise.all([
card.boundingBox(),
hint.boundingBox(),
hintPrefix.boundingBox(),
])
if (!cardBox || !hintBox || !prefixBox) return undefined
const [cardBox, hintBox] = await Promise.all([card.boundingBox(), hint.boundingBox()])
if (!cardBox || !hintBox) return undefined
return {
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
aligned: Math.abs(cardBox.x - hintBox.x) < 2,
ordered: cardBox.y < hintBox.y,
}
})
@@ -220,10 +215,10 @@ test("navigates from a running subagent card and hides background controls in th
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
await expect(page.getByRole("button", { name: /move running work to the background/i })).toHaveCount(0)
})
for (const name of ["shell", "subagent"] as const) {
@@ -267,7 +262,7 @@ for (const name of ["shell", "subagent"] as const) {
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
await expect(group).toBeVisible()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
@@ -286,9 +281,9 @@ test("shows a badge for active background work", async ({ page }) => {
})
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "1 item running in background" })
const summary = page.getByRole("button", { name: "1 background task running", exact: true })
await expect(summary).toContainText("1")
await expect(summary).toContainText("Running work in background")
await expect(summary).toContainText("1 background task running")
await summary.click()
await expect(
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
@@ -387,7 +382,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
},
})
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
const used = page
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
@@ -396,7 +391,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 items running in background" })
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
await expect(summary).toContainText("2")
await summary.click()
const list = page.locator('[data-component="session-background-list"]')
@@ -173,7 +173,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
"href",
"#opencode-v2-icon-circle-exclamation",
"#opencode-v2-icon-outline-hexagonal-warning",
)
await expect
.poll(() =>
@@ -134,7 +134,7 @@ for (const name of ["read", "shell", "subagent"] as const) {
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeInViewport()
if (name !== "read") {
const hint = page.locator('[data-component="session-background-hint"]')
const hint = page.getByRole("button", { name: /move running work to the background/i })
await expect(hint).toBeInViewport()
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
await page.screenshot({ path: testInfo.outputPath(`working-grouped-${name}.png`) })
@@ -27,6 +27,12 @@ test.beforeEach(async ({ page }) => {
})),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
@@ -50,6 +56,34 @@ test("settings has its own route and returns through app history", async ({ page
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeFocused()
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(1)
})
test("recording a new session shortcut stays in settings until recording finishes", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
const binding = settings.locator('[data-keybind-id="tab.new"]')
await binding.click()
await expect(binding).toHaveText("Press keys")
await page.keyboard.press("Control+t")
await expect(binding).toHaveText("Ctrl+T")
await expect(page).toHaveURL("/settings")
await expect(page.locator("[data-titlebar-tab]")).toHaveCount(0)
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
@@ -105,6 +139,79 @@ test("extensions opens without waiting for MCPs", async ({ page }) => {
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
})
test("about opens without waiting for contributors", async ({ page }) => {
const contributors = Promise.withResolvers<void>()
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
await page.route(url, async (route) => {
await contributors.promise
await route.fulfill({
json: [],
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "Link",
Link: `<${url}&page=1004>; rel="last"`,
},
})
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest(url)
await settings.getByRole("tab", { name: "About", exact: true }).click()
await requested
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(settings.getByText(/^Version /)).toBeVisible()
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
await expect(settings.getByText(/^v\d+\./)).toHaveCount(0)
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
await settings.getByRole("tab", { name: "About", exact: true }).click()
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
const website = settings.getByRole("link", { name: "www.opencode.ai", exact: true })
await website.focus()
contributors.resolve()
await expect(settings.getByRole("link", { name: "988 others", exact: true })).toBeVisible()
await expect(website).toBeFocused()
})
test("about is available in the mobile settings menu", async ({ page }) => {
await page.route("https://api.github.com/repos/anomalyco/opencode/contributors?*", (route) => route.abort("failed"))
await page.setViewportSize({ width: 390, height: 844 })
const settings = page.getByTestId("settings-screen")
await settings.getByRole("button", { name: "Preferences", exact: true }).click()
await page.getByRole("menuitemradio", { name: "About", exact: true }).click()
await expect(settings.getByRole("button", { name: "About", exact: true })).toBeVisible()
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
await settings.getByRole("button", { name: "About", exact: true }).click()
await expect(page.getByRole("menuitemradio", { name: "About", exact: true })).toBeChecked()
})
test("about keeps its fallback when the contributor request fails", async ({ page }) => {
const contributors = Promise.withResolvers<void>()
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
await page.route(url, async (route) => {
await contributors.promise
await route.abort("failed")
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest(url)
await settings.getByRole("tab", { name: "About", exact: true }).click()
await requested
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
const failed = page.waitForEvent("requestfailed", (request) => request.url() === url)
contributors.resolve()
await failed
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
})
test("workspace inventory uses the settings panel scroll area", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
@@ -1,7 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import pkg from "../../package.json" with { type: "json" }
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionA = session("ses_tab_a", "Tab A session")
@@ -185,7 +184,9 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabA).toContainText(sessionA.title)
await expect(tabB).toContainText(sessionB.title)
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
await expect(sidebar.getByRole("button", { name: "Home", exact: true })).toHaveText("Home")
await expect(
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
).toBeVisible()
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
const status = sidebar.getByRole("button", { name: "Status", exact: true })
@@ -231,7 +232,77 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabB).toBeVisible()
})
test("appearance experimental settings control vertical tab details", async ({ page }) => {
for (const profile of [
{ locale: "en", direction: "ltr" },
{ locale: "en", direction: "rtl" },
{ locale: "ar", direction: "rtl" },
]) {
test(`vertical shortcut hints align at the row end: ${profile.locale} ${profile.direction}`, async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionID, locale }) => {
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
localStorage.setItem(
"settings.v3",
JSON.stringify({
appearance: { tabLayout: "vertical" },
keybinds: { "home.toggle": "ctrl+alt+h", "tab.new": "ctrl+shift+n" },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: sessionA.id, locale: profile.locale },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar).toHaveCSS("width", "260px")
await page
.locator("html")
.evaluate((element, direction) => element.setAttribute("dir", direction), profile.direction)
await expect(sidebar).toHaveCSS("direction", profile.direction)
for (const row of [
{ action: "home", shortcut: "Ctrl+Alt+H" },
{ action: "new-session", shortcut: "Ctrl+Shift+N" },
]) {
const button = sidebar.locator(`[data-action="vertical-tabs-${row.action}"]`)
const hint = button.locator('span[aria-hidden="true"]')
await expect(hint).toHaveText(row.shortcut)
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
await expect(hint).toHaveCSS("opacity", "0")
await button.hover()
await expect(hint).toHaveCSS("opacity", "1")
await expect
.poll(() =>
hint.evaluate((element) => {
const button = element.closest("button")!
const row = button.getBoundingClientRect()
const hint = element.getBoundingClientRect()
return getComputedStyle(button).direction === "rtl" ? hint.left - row.left : row.right - hint.right
}),
)
.toBeCloseTo(8, 1)
await page.getByRole("main").hover()
await expect(hint).toHaveCSS("opacity", "0")
}
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
const newSession = sidebar.locator('[data-action="vertical-tabs-new-session"]')
await home.focus()
await page.keyboard.press("Tab")
await expect(newSession).toBeFocused()
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await page.keyboard.press("Shift+Tab")
await expect(home).toBeFocused()
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
})
}
test("dedicated experimental settings control vertical tab details", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA }) => {
@@ -249,11 +320,14 @@ test("appearance experimental settings control vertical tab details", async ({ p
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
const version = settings.getByRole("tablist").getByText(`v${pkg.version}`, { exact: true })
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toBeInViewport()
await expect(version).toBeInViewport()
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
await expect(settings.getByRole("tablist").getByText(/^v\d+\./)).toHaveCount(0)
await settings.getByRole("tab", { name: "Appearance" }).click()
await expect(settings.getByRole("heading", { name: "Experimental" })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
await expect(settings.locator('[data-action="settings-tab-layout"]')).toHaveCount(0)
await expect(settings.getByRole("switch", { name: "Show project names", exact: true })).toHaveCount(0)
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Experimental", level: 2, exact: true })).toBeVisible()
const layout = settings.locator('[data-action="settings-tab-layout"]')
await expect(layout).toContainText("Horizontal")
@@ -274,19 +348,27 @@ test("appearance experimental settings control vertical tab details", async ({ p
await page.setViewportSize({ width: 920, height: 720 })
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
await expect(settings.getByRole("tablist")).toBeHidden()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeVisible()
await page.setViewportSize({ width: 800, height: 720 })
await expect(settings.getByRole("tablist")).toBeHidden()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeVisible()
await page.setViewportSize({ width: 390, height: 720 })
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Experimental", exact: true }).click()
await page.getByRole("menuitemradio", { name: "Appearance", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
await expect(layout).toHaveCount(0)
await settings.getByRole("button", { name: "Appearance", exact: true }).click()
await page.getByRole("menuitemradio", { name: "Experimental", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Experimental", level: 2, exact: true })).toBeVisible()
await expect(layout).toContainText("Vertical")
await expect(projectNameSwitch).toBeChecked()
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeInViewport()
await page.setViewportSize({ width: 390, height: 360 })
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeInViewport()
// Reload the UI-selected preference without seeding settings storage.
await page.reload()
@@ -310,7 +392,7 @@ test("appearance experimental settings control vertical tab details", async ({ p
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
await page.keyboard.press("Control+,")
await settings.getByRole("tab", { name: "Appearance" }).click()
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
await expect(layout).toContainText("Vertical")
})
@@ -78,9 +78,13 @@ for (const theme of ["light", "dark"] as const) {
await expectToken(
message,
"background-color",
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
scenario.accent ? "--v2-background-bg-accent" : theme === "light" ? "--v2-blue-100" : "--v2-blue-1200",
)
await expectToken(
message,
"color",
scenario.accent ? "--v2-text-text-contrast" : theme === "light" ? "--v2-blue-700" : "--v2-blue-300",
)
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+57
View File
@@ -0,0 +1,57 @@
import { expect, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "./mock-server"
import { APP_READY_TIMEOUT } from "./waits"
export const paletteSession = {
id: "ses_command_palette",
projectID: "proj_command_palette",
directory: "C:/OpenCode/CommandPalette",
title: "Palette fixture session",
time: { created: 1700000000000, updated: 1700000000000 },
}
export function captureConsoleWarnings(page: Page) {
const warnings: string[] = []
page.on("console", (message) => {
if (message.type() !== "warning" && message.type() !== "error") return
// This message comes from test isolation, not application code.
if (message.text() === "Service Worker registration blocked by Playwright") return
warnings.push(message.text())
})
return warnings
}
export async function openCommandPalette(page: Page, home = false) {
await mockOpenCodeServer(page, {
directory: paletteSession.directory,
project: {
id: paletteSession.projectID,
worktree: paletteSession.directory,
vcs: "git",
name: "command-palette",
time: paletteSession.time,
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [paletteSession],
pageMessages: () => ({ items: [] }),
findFiles: () => [],
})
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(home ? "/" : `/server/${base64Encode(server)}/session/${paletteSession.id}`)
if (home) {
await expect(
page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }),
).toBeEnabled({ timeout: APP_READY_TIMEOUT })
}
if (!home) {
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable({ timeout: APP_READY_TIMEOUT })
}
await page.keyboard.press("ControlOrMeta+Shift+P")
const dialog = page.getByRole("dialog")
const input = dialog.getByRole("textbox")
await expect(input).toBeFocused()
await expect(dialog.getByRole("option")).not.toHaveCount(0)
return { dialog, input }
}
+2 -1
View File
@@ -26,7 +26,8 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]}
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
exitShellKeybind={[formatKeybind("esc", language.t)]}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
@@ -6,3 +6,7 @@
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
background: var(--v2-background-bg-layer-01);
}
[data-color-scheme="dark"] [data-component="composer-suggestions"] [data-active] {
background: var(--v2-alpha-light-10);
}
+24 -3
View File
@@ -47,6 +47,7 @@ export type ComposerEditorProps = {
attachKeybind?: string[]
attachShortcut?: string
alternateKeybind?: string[]
exitShellKeybind?: string[]
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -281,6 +282,24 @@ export function ComposerEditor(props: ComposerEditorProps) {
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
@@ -556,7 +575,7 @@ export function ComposerEditorAddMenu(props: {
/>
<Menu.Portal>
<Menu.Content
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
style={{ "min-width": "180px" }}
>
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
@@ -673,6 +692,7 @@ export function ComposerEditorPopover(props: {
}) {
return (
<div
data-component="composer-suggestions"
class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
onMouseDown={(event) => event.preventDefault()}
>
@@ -701,6 +721,7 @@ export function ComposerEditorPopover(props: {
<button
type="button"
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
@@ -749,9 +770,9 @@ function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorMode
ref={setButton}
data-action="composer-alternate-delivery"
type="button"
variant="ghost-muted"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530] duration-150 motion-reduce:animate-none"
class="me-3 gap-1.5 px-1.5 ![font-weight:530] duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": presence.animate() && presence.show(),
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
+1
View File
@@ -251,6 +251,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const submission = createComposerSubmit({
adapter,
mode,
commands: () => data.location.command.list({ directory: sdk().directory }),
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
+48 -2
View File
@@ -52,10 +52,12 @@ function submitInput(
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
@@ -420,7 +422,6 @@ describe("Composer submission", () => {
prompt: async () => undefined,
command: async (value) => sent.resolve(value),
})
target.data.location.command.list = () => [{ name: "review", description: "Review changes", template: "" }]
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
@@ -433,7 +434,7 @@ describe("Composer submission", () => {
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
const request = await sent.promise
expect(request.files).toMatchObject([{ name: "app.ts", mention: { text: "@src/app.ts" } }])
@@ -442,6 +443,51 @@ describe("Composer submission", () => {
expect(request.delivery).toBe("steer")
})
test("captures commands before creating a session in a new worktree", async () => {
const state = createMemoryComposerState({ prompt: "/review https://github.com/example/repo/pull/1" }).capture()
const catalog = [{ name: "review" }]
const sent = Promise.withResolvers<"prompt" | "command">()
const requests: Parameters<ComposerSession["api"]["command"]>[0][] = []
const target = session({
calls: [],
prompt: async () => sent.resolve("prompt"),
command: async (value) => {
requests.push(value)
sent.resolve("command")
},
})
target.directory = "C:/new-worktree"
target.data.location.command.list = () => undefined
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start() {
// The destination catalog has not loaded, and the source composer is leaving.
catalog.splice(0)
return { session: target, cleanupReady: Promise.resolve() }
},
}
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
expect(await sent.promise).toBe("command")
expect(requests).toEqual([
{
sessionID: target.id,
command: "review",
text: "https://github.com/example/repo/pull/1",
files: [],
agents: [],
skills: [],
delivery: "steer",
},
])
})
test("does not run an empty shell command from hidden attachments", async () => {
const state = createMemoryComposerState().capture()
state.set([
+5 -4
View File
@@ -27,6 +27,7 @@ type ComposerSubmission = {
type ComposerSubmitInput = {
adapter: ComposerAdapter
mode: Accessor<"normal" | "shell">
commands: Accessor<readonly { name: string }[] | undefined>
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
@@ -65,6 +66,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (submitting.has(input.adapter.state)) return
submitting.add(input.adapter.state)
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
try {
const started =
@@ -78,7 +81,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
input.resetHistory()
const restore = () => restoreSubmission(input, submission, value, comments)
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
if (value.mode === "normal" && !command) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
@@ -277,12 +279,11 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
await session.api.shell({ sessionID: session.id, id: Event.ID.create(), command: value.text })
}
function findCommand(session: ComposerSession, text: string) {
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
if (!text.startsWith("/")) return
const [name, ...arguments_] = text.split(" ")
const command = name.slice(1)
if (!session.data.location.command.list({ directory: session.directory })?.some((item) => item.name === command))
return
if (!commands?.some((item) => item.name === command)) return
return { command, arguments: arguments_.join(" ") }
}
+15 -1
View File
@@ -3,7 +3,8 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo } from "solid-js"
import { createEffect, createMemo, startTransition } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client/promise"
export function createHomeController() {
const layout = useLayout()
@@ -45,6 +46,18 @@ export function createHomeController() {
void tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
function openProjectSession(conn: ServerConnection.Any, directory: string, session: SessionInfo) {
const ctx = global.ensureServerCtx(conn)
void ctx.data.session.message.sync(session.id).catch(() => undefined)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
ctx.data.session.remember(session)
ctx.projects.open(directory)
ctx.projects.touch(directory)
})
}
return {
selection: {
value: selection,
@@ -105,6 +118,7 @@ export function createHomeController() {
openProjectNewSession(conn, project.worktree)
},
openProjectNewSession,
openProjectSession,
},
}
}
@@ -14,6 +14,7 @@ import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
@@ -79,6 +80,35 @@ export function createHomeProjectsController(home: HomeController) {
select: home.project.select,
add: home.project.add,
openNewSession: home.project.openProjectNewSession,
canImportSession: !!platform.openAttachmentPickerDialog,
importSession: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openAttachmentPickerDialog) return
void platform
.openAttachmentPickerDialog(
{
title: language.t("command.session.import"),
accept: ["application/json"],
extensions: ["json"],
},
async (file) => {
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
)
const api = home.server.context(conn).sdk.api.session
const imported = await api.import({
...Schema.encodeSync(SessionTransfer.Data)(data),
location: { directory: project.worktree },
} as Parameters<typeof api.import>[0])
home.project.openProjectSession(conn, project.worktree, imported)
},
)
.catch((cause: unknown) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
})
})
},
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
@@ -37,6 +37,8 @@ export function HomeProjects(props: {
onSelectProject={props.projects.project.select}
onAddProjects={props.projects.project.add}
onOpenProjectNewSession={props.projects.project.openNewSession}
canImportSession={props.projects.project.canImportSession}
onImportSession={props.projects.project.importSession}
onEditProject={props.projects.project.edit}
onRevealProject={props.projects.project.reveal}
onClearNotifications={props.projects.project.clearNotifications}
+7
View File
@@ -57,6 +57,8 @@ export type HomeProjectsViewProps = {
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
canImportSession: boolean
onImportSession: (server: ServerConnection.Any, project: LocalProject) => void
onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
@@ -670,6 +672,11 @@ function HomeProjectRow(
<Menu.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</Menu.Item>
<Show when={props.canImportSession}>
<Menu.Item onSelect={() => props.onImportSession(props.server, props.project)}>
{props.language.t("command.session.import")}
</Menu.Item>
</Show>
<Menu.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</Menu.Item>
@@ -52,10 +52,9 @@ export function HomeCommandPalette(props: {
}
if (item.type === "session") props.onSelectSession(item)
}
const loadItems = async (text: string) => {
const query = text.trim()
const items = (query: string) => {
if (!query) return commandEntries().slice(0, 5)
return [...commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)), ...(await sessions(query))]
return commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query))
}
onCleanup(() => {
@@ -66,7 +65,8 @@ export function HomeCommandPalette(props: {
return (
<CommandPaletteView
placeholder={language.t("palette.search.placeholder.home")}
loadItems={loadItems}
items={items}
sources={[sessions]}
highlight={highlight}
select={select}
close={() => dialog.close()}
@@ -21,7 +21,8 @@ import { errorMessage } from "@/shell/layout/helpers"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { removedSessionIDs } from "@/session/session-domain"
import { pathKey } from "@/workspaces/path-key"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
@@ -45,6 +46,7 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const queryClient = useQueryClient()
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
@@ -172,7 +174,7 @@ export function createHomeSessionsController(home: HomeController) {
try {
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
if (!(await saveSessionExport(filename, data, platform))) return
showToast({
variant: "success",
icon: "circle-check",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "ሳይክል ቋንቋ",
"command.language.set": "ቋንቋን ተጠቀም፡ {{language}}",
"command.session.new": "አዲስ ክፍለ ጊዜ",
"command.session.import": "ክፍለ ጊዜ ማስመጣት",
"command.file.open": "ክፍት ፋይል",
"command.tab.close": "ትርፉን ዝጋ",
"command.tab.reopenClosed": "የተዘጋውን ትር እንደገና ክፈት",
+1
View File
@@ -139,6 +139,7 @@ export const dict = {
"command.language.cycle": "تغيير اللغة",
"command.language.set": "استخدام اللغة: {{language}}",
"command.session.new": "جلسة جديدة",
"command.session.import": "استيراد جلسة",
"command.file.open": "فتح ملف",
"command.tab.close": "إغلاق علامة التبويب",
"command.tab.reopenClosed": "إعادة فتح علامة التبويب المغلقة",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Dili dəyiş",
"command.language.set": "Dildən istifadə et: {{language}}",
"command.session.new": "Yeni sessiya",
"command.session.import": "Sessiyanı idxal et",
"command.file.open": "Faylı aç",
"command.tab.close": "Tabı bağla",
"command.tab.reopenClosed": "Bağlanmış tabı yenidən aç",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Цикличен език",
"command.language.set": "Използвайте език: {{language}}",
"command.session.new": "Нова сесия",
"command.session.import": "Импортиране на сесия",
"command.file.open": "Отворете файла",
"command.tab.close": "Затваряне на раздела",
"command.tab.reopenClosed": "Повторно отваряне на затворен раздел",
+1
View File
@@ -134,6 +134,7 @@ export const dict: Record<string, string> = {
"command.language.cycle": "সাইকেল ভাষা",
"command.language.set": "ভাষা ব্যবহার করুন: {{language}}",
"command.session.new": "নতুন সেশন",
"command.session.import": "সেশন আমদানি করুন",
"command.file.open": "ফাইল খুলুন",
"command.tab.close": "ট্যাব বন্ধ করুন",
"command.tab.reopenClosed": "বন্ধ ট্যাব আবার খুলুন",
+1
View File
@@ -141,6 +141,7 @@ export const dict = {
"command.language.cycle": "Alternar idioma",
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nova sessão",
"command.session.import": "Importar sessão",
"command.file.open": "Abrir arquivo",
"command.tab.close": "Fechar aba",
"command.tab.reopenClosed": "Reabrir aba fechada",
+1
View File
@@ -147,6 +147,7 @@ export const dict = {
"command.language.set": "Koristi jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.import": "Uvezi sesiju",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovo otvori zatvorenu karticu",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Llenguatge de cicle",
"command.language.set": "Utilitza l'idioma: {{language}}",
"command.session.new": "Nova sessió",
"command.session.import": "Importa la sessió",
"command.file.open": "Obre el fitxer",
"command.tab.close": "Tanca la pestanya",
"command.tab.reopenClosed": "Torneu a obrir la pestanya tancada",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Jazyk cyklu",
"command.language.set": "Použít jazyk: {{language}}",
"command.session.new": "Nová relace",
"command.session.import": "Importovat relaci",
"command.file.open": "Otevřít soubor",
"command.tab.close": "Zavřít kartu",
"command.tab.reopenClosed": "Znovu otevřete zavřenou kartu",
+1
View File
@@ -46,6 +46,7 @@ export const dict = {
"command.language.set": "Brug sprog: {{language}}",
"command.session.new": "Ny session",
"command.session.import": "Importer session",
"command.file.open": "Åbn fil",
"command.tab.close": "Luk fane",
"command.tab.reopenClosed": "Åbn lukket fane igen",
+1
View File
@@ -44,6 +44,7 @@ export const dict = {
"command.language.cycle": "Sprache wechseln",
"command.language.set": "Sprache verwenden: {{language}}",
"command.session.new": "Neue Sitzung",
"command.session.import": "Sitzung importieren",
"command.file.open": "Datei öffnen",
"command.tab.close": "Tab schließen",
"command.tab.reopenClosed": "Geschlossenen Tab wieder öffnen",
+1
View File
@@ -136,6 +136,7 @@ export const dict = {
"command.language.cycle": "ސައިކަލް ބަސް",
"command.language.set": "ބަސް ބޭނުންކުރުން: {{language}}",
"command.session.new": "އާ ޖަލްސާއެއް",
"command.session.import": "ޖަލްސާ އިމްޕޯޓް ކުރައްވާ",
"command.file.open": "ފައިލް ހުޅުވާލާށެވެ",
"command.tab.close": "ޓެބް ބަންދުކުރުން",
"command.tab.reopenClosed": "ބަންދުކޮށްފައިވާ ޓެބް އަލުން ހުޅުވާލާށެވެ",
+1
View File
@@ -136,6 +136,7 @@ export const dict: Record<string, string> = {
"command.language.cycle": "འཁོར་བའི་སྐད་ཡིག།",
"command.language.set": "སྐད་ཡིག་ལག་ལེན་འཐབ།: {{language}}",
"command.session.new": "ལཱ་ཡུན་གསརཔ།",
"command.session.import": "ལཱ་ཡུན་ནང་འདྲེན།",
"command.file.open": "ཡིག་སྣོད་ཁ་ཕྱེ།",
"command.tab.close": "མཆོང་ལྡེ་ཁ་བསྡམས།",
"command.tab.reopenClosed": "ཁ་བསྡམས་ཡོད་པའི་མཆོང་ལྡེ་ལོག་ཁ་ཕྱེ།",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Γλώσσα κύκλου",
"command.language.set": "Γλώσσα χρήσης: {{language}}",
"command.session.new": "Νέα συνεδρία",
"command.session.import": "Εισαγωγή συνεδρίας",
"command.file.open": "Άνοιγμα αρχείου",
"command.tab.close": "Κλείσιμο καρτέλας",
"command.tab.reopenClosed": "Άνοιγμα ξανά κλειστής καρτέλας",
+24
View File
@@ -100,6 +100,7 @@ export const dict = {
"command.session.fork.description": "Create a new session from a previous message",
"command.session.export": "Export session",
"command.session.export.description": "Export the full session transcript as JSON",
"command.session.import": "Import session",
"command.session.copyID": "Copy Session ID",
"palette.search.placeholder": "Search files, commands, and sessions",
@@ -675,11 +676,14 @@ export const dict = {
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.moveRunning": "Move running work to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
"session.background.runningCount.one": "{{count}} item running in background",
"session.background.runningCount.other": "{{count}} items running in background",
"session.background.tasksRunning.one": "{{count}} background task running",
"session.background.tasksRunning.other": "{{count}} background tasks running",
"session.background.combine": "{{first}} and {{second}}",
"session.background.shell.one": "{{count}} shell",
"session.background.shell.other": "{{count}} shells",
@@ -904,6 +908,25 @@ export const dict = {
"settings.tab.notifications": "Notifications",
"settings.tab.projects": "Projects",
"settings.tab.extensions": "Extensions",
"settings.tab.experimental": "Experimental",
"settings.experimental.description": "Try experimental features",
"settings.tab.about": "About",
"settings.about.version": "Version {{version}}",
"settings.about.devVersion": "development",
"settings.about.license": "Released under the MIT License",
"settings.about.writtenBy": "Written by",
"settings.about.illustratedBy": "Illustrated by",
"settings.about.and": "and",
"settings.about.otherContributor.one": "{{count}} other",
"settings.about.otherContributor.other": "{{count}} others",
"settings.about.firstPublished": "First published in Missouri, USA",
"settings.about.firstIllustrated": "First illustrated in London, England",
"settings.about.website": "www.opencode.ai",
"settings.about.description": "OpenCode, the open source coding agent",
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
"settings.about.tagline": "AI cant build great software, without you",
"settings.about.copyright": "© Anomaly Innovations, Inc.",
"settings.preferences.description": "Customize preferences and theme and default behavior",
"settings.appearance.description": "Customize theme and fonts",
"settings.appearance.section.experimental": "Experimental",
@@ -931,6 +954,7 @@ export const dict = {
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"dialog.server.authenticate.title": "Authenticate",
"project.settings.title": "Edit project",
"project.settings.general.description": "Manage project name and appearance",
"project.settings.scripts": "Scripts",
"project.settings.scripts.description": "Configure scripts for this project",
+1
View File
@@ -147,6 +147,7 @@ export const dict = {
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nueva sesión",
"command.session.import": "Importar sesión",
"command.file.open": "Abrir archivo",
"command.tab.close": "Cerrar pestaña",
"command.tab.reopenClosed": "Reabrir pestaña cerrada",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Tsükli keel",
"command.language.set": "Kasuta keelt: {{language}}",
"command.session.new": "Uus seanss",
"command.session.import": "Impordi seanss",
"command.file.open": "Ava fail",
"command.tab.close": "Sule vahekaart",
"command.tab.reopenClosed": "Ava suletud vaheleht uuesti",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "زبان چرخه",
"command.language.set": "استفاده از زبان: {{language}}",
"command.session.new": "جلسه جدید",
"command.session.import": "وارد کردن جلسه",
"command.file.open": "باز کردن فایل",
"command.tab.close": "بستن برگه",
"command.tab.reopenClosed": "برگه بسته را دوباره باز کنید",
+1
View File
@@ -40,6 +40,7 @@ export const dict = {
"command.language.cycle": "Vaihda kieltä",
"command.language.set": "Käytä kieltä: {{language}}",
"command.session.new": "Uusi istunto",
"command.session.import": "Tuo istunto",
"command.file.open": "Avaa tiedosto",
"command.tab.close": "Sulje välilehti",
"command.tab.reopenClosed": "Avaa suljettu välilehti uudelleen",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Súkklumál",
"command.language.set": "Brúka mál: {{language}}",
"command.session.new": "Nýggj setan",
"command.session.import": "Innflyt setan",
"command.file.open": "Opna fíluna",
"command.tab.close": "Lat flipan aftur",
"command.tab.reopenClosed": "Opna aftur stongdan flipan",
+1
View File
@@ -141,6 +141,7 @@ export const dict = {
"command.language.cycle": "Changer de langue",
"command.language.set": "Utiliser la langue : {{language}}",
"command.session.new": "Nouvelle session",
"command.session.import": "Importer une session",
"command.file.open": "Ouvrir un fichier",
"command.tab.close": "Fermer l'onglet",
"command.tab.reopenClosed": "Rouvrir l'onglet fermé",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "מעבר לשפה הבאה",
"command.language.set": "השתמש בשפה: {{language}}",
"command.session.new": "הפעלה חדשה",
"command.session.import": "ייבוא הפעלה",
"command.file.open": "פתח את הקובץ",
"command.tab.close": "סגור כרטיסייה",
"command.tab.reopenClosed": "פתח מחדש את הכרטיסייה הסגורה",
+1
View File
@@ -140,6 +140,7 @@ export const dict = {
"command.language.cycle": "भाषा बदलें",
"command.language.set": "भाषा का प्रयोग करें: {{language}}",
"command.session.new": "नया सेशन",
"command.session.import": "सेशन आयात करें",
"command.file.open": "फ़ाइल खोलें",
"command.tab.close": "टैब बंद करें",
"command.tab.reopenClosed": "बंद टैब पुनः खोलें",
+1
View File
@@ -137,6 +137,7 @@ export const dict = {
"command.language.cycle": "Promijeni jezik",
"command.language.set": "Koristite jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.import": "Uvezi sesiju",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovno otvori zatvorenu karticu",
+1
View File
@@ -137,6 +137,7 @@ export const dict = {
"command.language.cycle": "Nyelv váltása",
"command.language.set": "Nyelv használata: {{language}}",
"command.session.new": "Új munkamenet",
"command.session.import": "Munkamenet importálása",
"command.file.open": "Nyissa meg a fájlt",
"command.tab.close": "Lap bezárása",
"command.tab.reopenClosed": "Nyissa meg újra a bezárt lapot",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Ցիկլի լեզու",
"command.language.set": "Օգտագործել լեզուն՝ {{language}}",
"command.session.new": "Նոր նիստ",
"command.session.import": "Ներմուծել նիստը",
"command.file.open": "Բացել ֆայլ",
"command.tab.close": "Փակել ներդիրը",
"command.tab.reopenClosed": "Վերաբացել փակ ներդիրը",
+1
View File
@@ -147,6 +147,7 @@ export const dict = {
"command.language.set": "Gunakan bahasa: {{language}}",
"command.session.new": "Sesi baru",
"command.session.import": "Impor sesi",
"command.file.open": "Buka berkas",
"command.tab.close": "Tutup tab",
"command.tab.reopenClosed": "Buka kembali tab yang ditutup",
+1
View File
@@ -137,6 +137,7 @@ export const dict = {
"command.language.cycle": "Skipta um tungumál",
"command.language.set": "Notaðu tungumál: {{language}}",
"command.session.new": "Ný seta",
"command.session.import": "Flytja inn setu",
"command.file.open": "Opna skrá",
"command.tab.close": "Loka flipa",
"command.tab.reopenClosed": "Opnaðu aftur lokaðan flipa",
+1
View File
@@ -41,6 +41,7 @@ export const dict = {
"command.language.cycle": "Cambia lingua",
"command.language.set": "Usa la lingua: {{language}}",
"command.session.new": "Nuova sessione",
"command.session.import": "Importa sessione",
"command.file.open": "Apri file",
"command.tab.close": "Chiudi scheda",
"command.tab.reopenClosed": "Riapri la scheda chiusa",
+1
View File
@@ -139,6 +139,7 @@ export const dict = {
"command.language.cycle": "言語の切り替え",
"command.language.set": "言語を使用: {{language}}",
"command.session.new": "新しいセッション",
"command.session.import": "セッションをインポート",
"command.file.open": "ファイルを開く",
"command.tab.close": "タブを閉じる",
"command.tab.reopenClosed": "閉じたタブを再度開く",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "ციკლის ენა",
"command.language.set": "გამოიყენე ენა: {{language}}",
"command.session.new": "ახალი სესია",
"command.session.import": "სესიის იმპორტი",
"command.file.open": "გახსენით ფაილი",
"command.tab.close": "ჩანართის დახურვა",
"command.tab.reopenClosed": "დახურული ჩანართის ხელახლა გახსნა",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "ភាសាវដ្ត",
"command.language.set": "ប្រើភាសា៖ {{language}}",
"command.session.new": "សម័យថ្មី។",
"command.session.import": "នាំចូលសម័យ",
"command.file.open": "បើកឯកសារ",
"command.tab.close": "បិទផ្ទាំង",
"command.tab.reopenClosed": "បើកផ្ទាំងបិទឡើងវិញ",
+1
View File
@@ -37,6 +37,7 @@ export const dict = {
"command.language.cycle": "언어 순환",
"command.language.set": "언어 사용: {{language}}",
"command.session.new": "새 세션",
"command.session.import": "세션 가져오기",
"command.file.open": "파일 열기",
"command.tab.close": "탭 닫기",
"command.context.addSelection": "선택 영역을 컨텍스트에 추가",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "ພາສາຮອບວຽນ",
"command.language.set": "ໃຊ້ພາສາ: {{language}}",
"command.session.new": "ເຊດຊັນໃໝ່",
"command.session.import": "ນຳເຂົ້າເຊດຊັນ",
"command.file.open": "ເປີດໄຟລ໌",
"command.tab.close": "ປິດແຖບ",
"command.tab.reopenClosed": "ເປີດແຖບປິດຄືນໃໝ່",
+1
View File
@@ -137,6 +137,7 @@ export const dict = {
"command.language.cycle": "Perjungti kalbą",
"command.language.set": "Naudokite kalbą: {{language}}",
"command.session.new": "Naujas seansas",
"command.session.import": "Importuoti seansą",
"command.file.open": "Atidaryti failą",
"command.tab.close": "Uždaryti skirtuką",
"command.tab.reopenClosed": "Iš naujo atidaryti uždarytą skirtuką",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Mainīt valodu",
"command.language.set": "Izmantot valodu: {{language}}",
"command.session.new": "Jauna sesija",
"command.session.import": "Importēt sesiju",
"command.file.open": "Atvērt failu",
"command.tab.close": "Aizvērt cilni",
"command.tab.reopenClosed": "Atvērt aizvērtu cilni",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Јазик на циклус",
"command.language.set": "Користете јазик: {{language}}",
"command.session.new": "Нова сесија",
"command.session.import": "Увези сесија",
"command.file.open": "Отворете ја датотеката",
"command.tab.close": "Затвори ја картичката",
"command.tab.reopenClosed": "Повторно отворете го затворениот таб",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Циклийн хэл",
"command.language.set": "Хэл ашиглах: {{language}}",
"command.session.new": "Шинэ сесс",
"command.session.import": "Сесс импортлох",
"command.file.open": "Файлыг нээх",
"command.tab.close": "Табыг хаах",
"command.tab.reopenClosed": "Хаагдсан табыг дахин нээнэ үү",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Tukar bahasa",
"command.language.set": "Guna bahasa: {{language}}",
"command.session.new": "Sesi baharu",
"command.session.import": "Import sesi",
"command.file.open": "Buka fail",
"command.tab.close": "Tutup tab",
"command.tab.reopenClosed": "Buka semula tab tertutup",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "စက်ဝိုင်းဘာသာစကား",
"command.language.set": "ဘာသာစကားကို အသုံးပြုပါ- {{language}}",
"command.session.new": "စက်ရှင်အသစ်",
"command.session.import": "စက်ရှင် တင်သွင်းရန်",
"command.file.open": "ဖိုင်ကိုဖွင့်ပါ။",
"command.tab.close": "တဘ်ကို ပိတ်ပါ။",
"command.tab.reopenClosed": "ပိတ်ထားသော တက်ဘ်ကို ပြန်ဖွင့်ပါ။",
+1
View File
@@ -134,6 +134,7 @@ export const dict: Record<string, string> = {
"command.language.cycle": "साइकल भाषा",
"command.language.set": "भाषा प्रयोग गर्नुहोस्: {{language}}",
"command.session.new": "नयाँ सत्र",
"command.session.import": "सत्र आयात गर्नुहोस्",
"command.file.open": "फाइल खोल्नुहोस्",
"command.tab.close": "ट्याब बन्द गर्नुहोस्",
"command.tab.reopenClosed": "बन्द ट्याब पुन: खोल्नुहोस्",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Volgende taal",
"command.language.set": "Gebruik taal: {{language}}",
"command.session.new": "Nieuwe sessie",
"command.session.import": "Sessie importeren",
"command.file.open": "Bestand openen",
"command.tab.close": "Tabblad sluiten",
"command.tab.reopenClosed": "Gesloten tabblad opnieuw openen",
+1
View File
@@ -146,6 +146,7 @@ export const dict = {
"command.language.set": "Bruk språk: {{language}}",
"command.session.new": "Ny sesjon",
"command.session.import": "Importer sesjon",
"command.file.open": "Åpne fil",
"command.tab.close": "Lukk fane",
"command.context.addSelection": "Legg til markering i kontekst",
+1
View File
@@ -139,6 +139,7 @@ export const dict = {
"command.language.cycle": "اگلی بولی ورتو",
"command.language.set": "بولی ورتو: {{language}}",
"command.session.new": "نواں سیشن",
"command.session.import": "سیشن درآمد کرو",
"command.file.open": "فائل کھولو",
"command.tab.close": "ٹیب بند کرو",
"command.tab.reopenClosed": "بند ٹیب دوبارہ کھولو",
+1
View File
@@ -140,6 +140,7 @@ export const dict = {
"command.language.cycle": "Przełącz język",
"command.language.set": "Użyj języka: {{language}}",
"command.session.new": "Nowa sesja",
"command.session.import": "Importuj sesję",
"command.file.open": "Otwórz plik",
"command.tab.close": "Zamknij kartę",
"command.tab.reopenClosed": "Otwórz ponownie zamkniętą kartę",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Schimbă limba",
"command.language.set": "Folosește limba: {{language}}",
"command.session.new": "Sesiune nouă",
"command.session.import": "Importă sesiunea",
"command.file.open": "Deschide fișier",
"command.tab.close": "Închide fila",
"command.tab.reopenClosed": "Redeschide fila închisă",
+1
View File
@@ -146,6 +146,7 @@ export const dict = {
"command.language.set": "Использовать язык: {{language}}",
"command.session.new": "Новая сессия",
"command.session.import": "Импортировать сессию",
"command.file.open": "Открыть файл",
"command.tab.close": "Закрыть вкладку",
"command.tab.reopenClosed": "Повторно открыть закрытую вкладку",
+1
View File
@@ -133,6 +133,7 @@ export const dict: Record<string, string> = {
"command.language.cycle": "චක්‍ර භාෂාව",
"command.language.set": "භාෂාව භාවිතා කරන්න: {{language}}",
"command.session.new": "නව සැසිය",
"command.session.import": "සැසිය ආනයනය කරන්න",
"command.file.open": "ගොනුව විවෘත කරන්න",
"command.tab.close": "ටැබ් එක වසන්න",
"command.tab.reopenClosed": "වසා දැමූ ටැබය නැවත විවෘත කරන්න",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Prepnúť jazyk",
"command.language.set": "Použiť jazyk: {{language}}",
"command.session.new": "Nová relácia",
"command.session.import": "Importovať reláciu",
"command.file.open": "Otvoriť súbor",
"command.tab.close": "Zavrieť kartu",
"command.tab.reopenClosed": "Obnoviť zatvorenú kartu",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Jezik cikla",
"command.language.set": "Uporabi jezik: {{language}}",
"command.session.new": "Nova seja",
"command.session.import": "Uvozi sejo",
"command.file.open": "Odpri datoteko",
"command.tab.close": "Zapri zavihek",
"command.tab.reopenClosed": "Ponovno odpri zaprt zavihek",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Gjuha e ciklit",
"command.language.set": "Përdorni gjuhën: {{language}}",
"command.session.new": "Sesion i ri",
"command.session.import": "Importo sesionin",
"command.file.open": "Hap skedarin",
"command.tab.close": "Mbyll skedën",
"command.tab.reopenClosed": "Rihap skedën e mbyllur",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "језик циклуса",
"command.language.set": "Користи језик: {{language}}",
"command.session.new": "Нова сесија",
"command.session.import": "Увези сесију",
"command.file.open": "Отворите датотеку",
"command.tab.close": "Затвори картицу",
"command.tab.reopenClosed": "Поново отворите затворену картицу",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Växla språk",
"command.language.set": "Använd språk: {{language}}",
"command.session.new": "Ny session",
"command.session.import": "Importera session",
"command.file.open": "Öppna filen",
"command.tab.close": "Stäng fliken",
"command.tab.reopenClosed": "Öppna stängd flik igen",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Забони даврӣ",
"command.language.set": "Истифодаи забон: {{language}}",
"command.session.new": "Сеанси нав",
"command.session.import": "Воридоти сеанс",
"command.file.open": "Файлро кушоед",
"command.tab.close": "Варақаро пӯшед",
"command.tab.reopenClosed": "Варақаи пӯшидаро аз нав кушоед",
+1
View File
@@ -145,6 +145,7 @@ export const dict = {
"command.language.set": "ใช้ภาษา: {{language}}",
"command.session.new": "เซสชันใหม่",
"command.session.import": "นำเข้าเซสชัน",
"command.file.open": "เปิดไฟล์",
"command.tab.close": "ปิดแท็บ",
"command.tab.reopenClosed": "เปิดแท็บที่ปิดไปอีกครั้ง",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Sikl dili",
"command.language.set": "Dil ulanyň: {{language}}",
"command.session.new": "Täze sessiýa",
"command.session.import": "Sessiýany import et",
"command.file.open": "Faýl açyň",
"command.tab.close": "Salgy ýapyň",
"command.tab.reopenClosed": "Closedapyk goýmany açyň",
+1
View File
@@ -151,6 +151,7 @@ export const dict = {
"command.language.set": "Dil kullan: {{language}}",
"command.session.new": "Yeni oturum",
"command.session.import": "Oturumu içe aktar",
"command.file.open": "Dosya aç",
"command.tab.close": "Sekmeyi kapat",
"command.tab.reopenClosed": "Kapatılan sekmeyi yeniden aç",
+1
View File
@@ -147,6 +147,7 @@ export const dict = {
"command.language.set": "Використати мову: {{language}}",
"command.session.new": "Нова сесія",
"command.session.import": "Імпортувати сесію",
"command.file.open": "Відкрити файл",
"command.tab.close": "Закрити вкладку",
"command.tab.reopenClosed": "Повторно відкрити закриту вкладку",
+1
View File
@@ -141,6 +141,7 @@ export const dict = {
"command.language.cycle": "اگلی زبان منتخب کریں",
"command.language.set": "زبان استعمال کریں: {{language}}",
"command.session.new": "نیا سیشن",
"command.session.import": "سیشن درآمد کریں",
"command.file.open": "فائل کھولیں۔",
"command.tab.close": "ٹیب بند کریں۔",
"command.tab.reopenClosed": "بند ٹیب کو دوبارہ کھولیں۔",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Keyingi til",
"command.language.set": "Tildan foydalaning: {{language}}",
"command.session.new": "Yangi sessiya",
"command.session.import": "Sessiyani import qilish",
"command.file.open": "Faylni ochish",
"command.tab.close": "Tabni yoping",
"command.tab.reopenClosed": "Yopiq tabni qayta oching",

Some files were not shown because too many files have changed in this diff Show More