Compare commits

...
Author SHA1 Message Date
David Hill 317142cb9e feat(app): add settings to vertical tabs 2026-09-03 12:13:53 -06: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
210 changed files with 4988 additions and 1055 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",
@@ -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`) })
@@ -231,6 +231,92 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabB).toBeVisible()
})
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
...Array.from({ length: 24 }, (_, index) => ({
type: "draft",
server,
directory,
draftID: `draft_scroll_${index}`,
})),
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
)
await page.goto("/")
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
await expect(settings).toHaveText("Settings")
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 360 })
await expect(settings).toBeInViewport({ ratio: 1 })
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
await expect(settings).toHaveCSS("margin-top", "8px")
await expect
.poll(() =>
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
const content = Math.max(
0,
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
)
return element.getBoundingClientRect().height - content
}),
)
.toBe(0)
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
await scroll.evaluate((element) => element.scrollTo(0, 0))
await expect(scroll).toHaveJSProperty("scrollTop", 0)
const pinned = await settings.boundingBox()
await scroll.hover()
await page.mouse.wheel(0, 200)
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
await testInfo.attach(`vertical-tabs-settings-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
await scroll.evaluate((element) => element.scrollTo(0, element.scrollHeight))
await expect(tabB).toBeInViewport({ ratio: 1 })
await expect
.poll(async () => {
const tab = await tabB.boundingBox()
const viewport = await scroll.boundingBox()
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
}
await settings.click()
await expect(page.getByTestId("settings-screen")).toBeVisible()
await expect(settings).toHaveAttribute("aria-pressed", "true")
await sidebar.getByRole("button", { name: "Home", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeHidden()
await settings.focus()
await settings.press("Enter")
await expect(page.getByTestId("settings-screen")).toBeVisible()
})
}
test("appearance experimental settings control vertical tab details", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
@@ -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")
})
}
+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);
}
+23 -2
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()}
@@ -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(),
+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>
@@ -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": "Άνοιγμα ξανά κλειστής καρτέλας",
+4
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",
+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",
+1
View File
@@ -140,6 +140,7 @@ export const dict = {
"command.language.cycle": "Chuyển ngôn ngữ",
"command.language.set": "Sử dụng ngôn ngữ: {{language}}",
"command.session.new": "Phiên mới",
"command.session.import": "Nhập phiên",
"command.file.open": "Mở tệp",
"command.tab.close": "Đóng tab",
"command.tab.reopenClosed": "Mở lại tab đã đóng",
+1
View File
@@ -154,6 +154,7 @@ export const dict = {
"command.language.set": "使用语言:{{language}}",
"command.session.new": "新建会话",
"command.session.import": "导入会话",
"command.file.open": "打开文件",
+1
View File
@@ -149,6 +149,7 @@ export const dict = {
"command.language.set": "使用語言: {{language}}",
"command.session.new": "新增工作階段",
"command.session.import": "匯入工作階段",
"command.file.open": "開啟檔案",
"command.tab.close": "關閉分頁",
"command.tab.reopenClosed": "重新開啟已關閉的分頁",
@@ -59,8 +59,8 @@ type PlatformBase = {
/** Resolve the native source path for a desktop File. */
getPathForFile?(file: File): string
/** Open a native save file picker dialog (desktop only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
/** Open a native save file dialog and write content to the selected path (desktop only) */
saveFile?(opts: SaveFilePickerOptions, content: string): Promise<boolean>
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
@@ -1,12 +1,60 @@
import { describe, expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query"
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { OpenCode } from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store"
import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerSync } from "@/runtime/server/sync"
type ProjectApi = ServerApi["project"]
type WorktreeApi = ServerApi["worktree"]
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
if (url.pathname === "/api/location")
return Response.json({
directory: "/repo",
project: { id: "project", directory: "/repo", canonical: "/repo" },
})
if (url.pathname === "/api/project")
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
throw new Error(`Unexpected request: ${url.pathname}`)
},
{ preconnect() {} },
),
})
const [store, setStore] = createStore<ServerSync["data"]>({
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
provider_auth: {},
config: {},
reload: undefined,
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
try {
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
setStore("project", (projects) => projects.map((project) => ({ ...project, name: "Renamed" })))
expect(store.project[0]?.name).toBe("Renamed")
setStore("project", [])
expect(store.project).toEqual([])
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
expect(store.config).toEqual({})
} finally {
queryClient.clear()
}
})
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const location = {} as ServerApi["location"]
@@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test"
import type { AgentListOutput, ModelListOutput, Project, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import {
directoryKey,
normalizeAgentList,
normalizeProjectInfo,
normalizeProviderList,
updateProjectInfo,
} from "./utils"
describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => {
@@ -110,3 +116,34 @@ describe("directoryKey", () => {
expect(String(directoryKey("/"))).toBe("/")
})
})
describe("updateProjectInfo", () => {
test("applies saved metadata without losing workspace inventory", () => {
const update = {
id: "project",
canonical: "/repo",
name: "Repo",
icon: { color: "purple" },
time: { created: 1, updated: 2 },
sandboxes: ["/repo-sandbox"],
} satisfies Project
expect(
updateProjectInfo(
{
...update,
name: "Old name",
icon: { color: "gray" },
worktree: "/old-repo",
worktrees: [{ directory: "/repo", strategy: "git" }],
},
update,
),
).toMatchObject({
name: "Repo",
icon: { color: "purple" },
worktree: "/repo",
worktrees: [{ directory: "/repo", strategy: "git" }],
})
})
})
@@ -137,3 +137,12 @@ export function normalizeProjectInfo(project: Project | CurrentProject): Project
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
}
}
export function updateProjectInfo(project: Project, update: CurrentProject): Project {
return {
...project,
...update,
worktree: update.canonical,
worktrees: project.worktrees,
}
}
+11 -23
View File
@@ -11,7 +11,7 @@ import type { ProjectMeta } from "./global-sync/types"
import { formatServerError } from "@/runtime/server/errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { directoryKey, updateProjectInfo } from "./global-sync/utils"
import { PathKey } from "@/workspaces/path-key"
import type { ServerScope } from "@/runtime/server/scope"
import { persisted } from "@/runtime/persistence/storage"
@@ -79,25 +79,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
})
const queryClient = useQueryClient()
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
setGlobalStore("project", next)
}
const setBootStore = ((...input: unknown[]) => {
if (input[0] === "project" && Array.isArray(input[1])) {
setProjects(input[1] as Project[])
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const bootstrap = useQuery(() => ({
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverAPI: serverSDK.api,
scope: serverSDK.scope,
setGlobalStore: setBootStore,
setGlobalStore,
queryClient,
})
return Date.now()
@@ -105,14 +93,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
enabled: connected(),
}))
const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const paused = () => untrack(() => globalStore.reload) !== undefined
const queue = createRefreshQueue({
@@ -215,8 +195,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
return promise
}
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
setGlobalStore("project", (projects) =>
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
)
}
const unsub = serverSDK.event.listen((event) => {
connection.handleEvent({ type: event.type })
if (event.type === "project.updated") applyProjectUpdate(event.data)
if (!event.location) {
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
@@ -243,6 +230,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
})
const projectApi = {
update: applyProjectUpdate,
meta(directory: string, patch: ProjectMeta) {
children.projectMeta(directory, patch)
},
@@ -267,7 +255,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
return {
data: globalStore,
set,
set: setGlobalStore,
child: children.child,
disableMcp: children.disableMcp,
// bootstrap,
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import { fetchSessionExport, sessionExportFilename } from "./export"
import type { Platform } from "@/runtime/platform/platform"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "./export"
describe("sessionExportFilename", () => {
test("generates filename from title", () => {
@@ -55,3 +56,31 @@ describe("fetchSessionExport", () => {
expect(fetchSessionExport({ sessionID: "ses_missing", api })).rejects.toThrow("Session not found")
})
})
describe("saveSessionExport", () => {
test("returns false when the native save dialog is cancelled", async () => {
const calls: string[][] = []
const platform: Pick<Platform, "saveFile"> = {
saveFile: async (_options, content) => {
calls.push([content])
return false
},
}
expect(await saveSessionExport("session.json", { id: "ses_1" }, platform)).toBe(false)
expect(calls).toEqual([['{\n "id": "ses_1"\n}']])
})
test("passes serialized data to the native save operation", async () => {
const writes: string[][] = []
const platform: Pick<Platform, "saveFile"> = {
saveFile: async (options, content) => {
writes.push([options.defaultPath ?? "", content])
return true
},
}
expect(await saveSessionExport("session.json", { id: "ses_1" }, platform)).toBe(true)
expect(writes).toEqual([["session.json", '{\n "id": "ses_1"\n}']])
})
})
@@ -1,5 +1,6 @@
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import type { Platform } from "@/runtime/platform/platform"
export type SessionExportData = {
info: SessionInfo
@@ -53,3 +54,15 @@ export function downloadSessionExport(filename: string, data: unknown) {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
export async function saveSessionExport(
filename: string,
data: unknown,
platform: Pick<Platform, "saveFile">,
) {
if (!platform.saveFile) {
downloadSessionExport(filename, data)
return true
}
return platform.saveFile({ defaultPath: filename }, JSON.stringify(data, null, 2))
}
@@ -9,7 +9,7 @@ import { useServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
import { useTerminal } from "@/session/terminal/context"
import { showToast } from "@/shell/notifications/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
import type { SessionModel } from "@/session/model"
import type { SessionRevert } from "@/session/revert"
@@ -104,7 +104,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
api: serverSDK.api,
})
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
if (!(await saveSessionExport(filename, data, platform))) return
showToast({
variant: "success",
icon: "circle-check",
@@ -156,10 +156,10 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
data-action="session-queue-steer"
type="button"
size="small"
variant="ghost-muted"
variant="ghost-faint"
icon="arrow-up"
disabled={props.queue.busy()}
class="text-v2-text-text-muted ![font-weight:530]"
class="![font-weight:530]"
onClick={() => void props.queue.steer(props.id)}
>
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
@@ -12,8 +12,9 @@ import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { showToast } from "@/shell/notifications/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useProviders } from "@/providers/catalog/providers"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
@@ -84,6 +85,7 @@ const emptyMessages: SessionMessageInfo[] = []
export function SessionContextTab() {
const data = useData()
const language = useLanguage()
const platform = usePlatform()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory)
@@ -199,7 +201,7 @@ export function SessionContextTab() {
api: serverSDK.api,
})
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
if (!(await saveSessionExport(filename, data, platform))) return
showToast({
variant: "success",
icon: "circle-check",
@@ -15,8 +15,9 @@ import { removedSessionIDs } from "@/session/session-domain"
import { useServerSDK } from "@/runtime/server/client"
import { sessionHref } from "@/shell/routes/session"
import { sessionTitle } from "@/session/title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { showToast } from "@/shell/notifications/toast"
import { usePlatform } from "@/runtime/platform/platform"
import { applyTimelineMessageHandoff, timelineChildTitle, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/runtime/server/current"
@@ -55,6 +56,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const handedOffMessages = createMemo(() =>
applyTimelineMessageHandoff(
input.session.history.messages(),
@@ -170,7 +172,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
try {
const data = await fetchSessionExport({ sessionID: id, api: serverSDK.api })
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
if (!(await saveSessionExport(filename, data, platform))) return
showToast({
variant: "success",
icon: "circle-check",
@@ -2,7 +2,7 @@ import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type
import { createStore } from "solid-js/store"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
import { Badge } from "@opencode-ai/ui/badge"
import { Button } from "@opencode-ai/ui/button"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -47,32 +47,42 @@ type SessionBackground = {
move: () => Promise<void>
}
export function BackgroundMoveHint(props: { keybind?: string[] }) {
export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => void }) {
const language = useLanguage()
const command = useCommand()
const marker = "__OPENCODE_BACKGROUND_KEYBIND__"
const parts = createMemo(() => language.t("session.background.moveInline", { keybind: marker }).split(marker))
const keys = () => props.keybind ?? command.keybindParts("session.background")
const keybind = () => props.keybind?.join("+") ?? command.keybind("session.background")
return (
<div
<Button
data-component="session-background-hint"
class="flex h-6 max-w-full items-center justify-center gap-[3px] overflow-hidden text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
type="button"
variant="ghost-faint"
size="small"
icon="outline-arrow-to-corner-top-right"
class="max-w-full"
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
onClick={() => props.onMove?.()}
>
<span data-slot="session-background-hint-prefix" class="shrink-0">
{parts()[0].trim()}
</span>
<span class="min-w-0 truncate">{language.t("session.background.moveRunning")}</span>
<Keybind keys={keys()} variant="neutral" />
<span class="min-w-0 truncate">{parts()[1].trim()}</span>
</div>
</Button>
)
}
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
const language = useLanguage()
const [open, setOpen] = createSignal(false)
const [triggerRef, setTriggerRef] = createSignal<HTMLButtonElement>()
const tasks = createMemo<BackgroundTask[]>((previous = []) => (props.tasks.length > 0 ? props.tasks : previous))
const presence = createAnimatedPresence(
() => (props.tasks.length > 0 ? true : undefined),
() => triggerRef() ?? null,
)
createEffect(() => {
if (props.tasks.length > 0) return
setOpen(false)
})
const taskType = (task: BackgroundTask) => {
if (task.type === "shell") return language.t("ui.tool.shell")
if (!task.agent) return language.t("ui.tool.agent.default")
@@ -84,31 +94,38 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?:
open={open()}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
gutter={4}
onOpenChange={setOpen}
onOpenChange={(value) => setOpen(value && props.tasks.length > 0)}
>
<Popover.Trigger
as="button"
type="button"
data-component="session-background-summary"
class="flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
aria-label={language.plural("session.background.runningCount", props.tasks.length)}
>
<Badge class="!w-4 !px-0 !border-v2-border-border-strong !bg-v2-background-bg-layer-03">
{props.tasks.length}
</Badge>
<TextShimmer
as="span"
text={language.t("session.background.running")}
active
class="min-w-0 flex-1 truncate text-start"
/>
</Popover.Trigger>
<Show when={presence.present()}>
<Popover.Trigger
ref={setTriggerRef}
as="button"
type="button"
data-component="session-background-summary"
class="flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed duration-150 motion-reduce:animate-none"
classList={{
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
}}
aria-label={language.plural("session.background.tasksRunning", tasks().length)}
>
<Icon
name="outline-arrow-to-corner-top-right"
class="shrink-0 text-v2-icon-icon-muted"
/>
<TextShimmer
as="span"
text={language.plural("session.background.tasksRunning", tasks().length)}
active
class="min-w-0 flex-1 truncate text-start"
/>
</Popover.Trigger>
</Show>
<Popover.Portal>
<Popover.Content
data-component="session-background-list"
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none"
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none data-[closed]:animate-out data-[closed]:fade-out data-[closed]:duration-150 motion-reduce:data-[closed]:animate-none"
>
<For each={props.tasks.slice(0, 10)}>
<For each={tasks().slice(0, 10)}>
{(task) => (
<div
data-component="session-background-list-item"
@@ -253,7 +270,7 @@ export function SessionSummaryPanel(props: {
when={props.branch}
fallback={
<span class="flex min-w-0 items-center gap-1.5">
<span>{language.t("session.summary.noBranch")}</span>
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
<Show when={props.baseBranch}>
{(base) => (
<>
@@ -288,9 +305,17 @@ export function SessionSummaryPanel(props: {
)}
</Show>
</button>
<Show when={props.backgroundTasks.length > 0}>
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
</Show>
<div
class="grid transition-[grid-template-rows] duration-150 ease-out motion-reduce:transition-none"
classList={{
"grid-rows-[1fr]": props.backgroundTasks.length > 0,
"grid-rows-[0fr]": props.backgroundTasks.length === 0,
}}
>
<div class="min-h-0 overflow-hidden">
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
</div>
</div>
</div>
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
<WorkspaceMoveAction
@@ -597,44 +622,38 @@ function MessageTimelineView(
<VirtualizedTimeline
workspaceSession={workspaceSession}
bottomSpacer={
<>
<Show when={showWorking()}>
<Show when={showWorking() || backgroundHintPresence.present()}>
<div
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<div
data-component="session-working"
role="status"
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": props.centered,
}}
class={`flex h-9 items-center gap-2 pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}
>
<div class={`flex h-9 items-start pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}>
<TextShimmer text={language.t("session.timeline.working")} active />
</div>
<Show when={showWorking()}>
<div data-component="session-working" role="status">
<TextShimmer text={language.t("session.timeline.working")} active />
</div>
</Show>
<Show when={backgroundHintPresence.present()}>
<div
ref={setBackgroundHintRef}
data-component="session-background-hint-row"
class="duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
}}
>
<BackgroundMoveHint onMove={props.background.move} />
</div>
</Show>
</div>
</Show>
<Show when={backgroundHintPresence.present()}>
<div
data-component="session-background-hint-row"
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<div
ref={setBackgroundHintRef}
class="duration-150 motion-reduce:animate-none"
classList={{
[`flex items-start ${showWorking() ? "h-6" : "h-9 pt-3"} ${turnPadding()}`]: true,
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
}}
>
<BackgroundMoveHint />
</div>
</div>
</Show>
</>
</div>
</Show>
}
deferred={(row) => {
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
@@ -738,7 +757,7 @@ function MessageTimelineView(
/>
</Show>
</Show>
<Show when={sessionID()} keyed>
<Show when={!parentID() && sessionID()} keyed>
{(id) => (
<Menu
gutter={6}
@@ -70,12 +70,13 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverCtx().sdk.api.project.update({
const project = await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
commands: { start },
})
serverCtx().sync.project.update(project)
dialog.close()
return
}

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