Compare commits

...
12 Commits
8 changed files with 112 additions and 55 deletions
+25 -10
View File
@@ -218,22 +218,30 @@
}
}
@container (max-width: 64px) {
@container (44px < width <= 64px) {
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):is(:hover, [data-active="true"])
[data-slot="tab-link"] {
-webkit-mask-image: none;
mask-image: none;
}
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):is(:hover, [data-active="true"])
[data-titlebar-tab-title] {
visibility: hidden;
}
}
@container (max-width: 44px) {
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-link] {
justify-content: center;
gap: 0;
padding-inline: 0;
}
@media (hover: none) {
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab][data-title-overflow="true"]:not([data-orientation="vertical"]):not([data-editing="true"]):dir(
rtl
)
[data-slot="tab-link"] {
-webkit-mask-image: none;
mask-image: none;
}
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
-webkit-mask-image: none;
mask-image: none;
}
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-title] {
@@ -244,6 +252,13 @@
display: none;
}
@media (hover: hover) {
[data-titlebar-tab]:not([data-orientation="vertical"]):not(:hover):not(:has(:focus-visible))
[data-slot="tab-close"] {
display: none;
}
}
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-slot="tab-close"] {
right: auto;
left: 50%;
-6
View File
@@ -592,12 +592,6 @@ export function createData(config: CreateDataInput) {
function handleEvent(event: OpenCodeEvent) {
switch (event.type) {
case "location.shutdown": {
if (!event.location) return
result.location.invalidate(event.location)
refresh(() => result.location.sync(event.location))
return
}
case "server.connected": {
const updates = new Map<string, DataSessionStatus | undefined>()
activeUpdates = updates
+6 -2
View File
@@ -81,6 +81,8 @@ type ServerEntry = {
// persisted session row, so their forms are owned by this opaque sentinel session identifier.
const GLOBAL_ELICITATION_SESSION_ID = "global"
const URL_ELICITATION_FIELD_KEY = "elicitation"
// Connections remain Location-scoped, but shared remote endpoints should not receive concurrent startup bursts.
const endpointLoads = KeyedMutex.makeUnsafe<string>()
type Data = {
servers: Map<ServerName, Types.DeepMutable<Mcp.ServerConfig>>
@@ -387,7 +389,7 @@ export const layer = (options?: Options) =>
const { McpClient } = yield* Effect.promise(() => import("./client.js"))
// List tools as part of connect so a failure here marks the server failed rather than
// leaving it connected with a silently empty tool list and no path to recover.
const result = yield* McpClient.connect(
const load = McpClient.connect(
name,
entry.config,
location.directory,
@@ -399,8 +401,10 @@ export const layer = (options?: Options) =>
// A stdio server is spawned on this location's execution plane, not the host's.
Effect.provideService(Environment.Service, environment),
Scope.provide(scope),
Effect.exit,
)
const result = yield* (
entry.config.type === "remote" ? endpointLoads.withLock(entry.config.url)(load) : load
).pipe(Effect.exit)
if (Exit.isSuccess(result)) {
entry.client = result.value.connection
entry.tools = result.value.tools.map((tool) => toTool(name, entry, tool))
+7 -1
View File
@@ -74,7 +74,13 @@ const retryAfter = (input: Input) => {
return undefined
}
const schedule = Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
// Exponential from 2s capped at 10s per gap, for 10 retries: 2, 4, 8, then 10 × 7, about 84s of
// waiting when every attempt fails (67101s with jitter). `min` takes the faster schedule, so the
// cap applies per gap; `max` with `recurs` bounds the count.
const schedule = Schedule.max([
Schedule.min([Schedule.exponential("2 seconds"), Schedule.spaced("10 seconds")]),
Schedule.recurs(10),
]).pipe(
Schedule.jittered,
Schedule.setInputType<Input>(),
Schedule.modifyDelay(({ input, duration: delay }) => {
+43 -33
View File
@@ -580,6 +580,12 @@ const scenario = (
}),
)
// Nominal retry gaps: exponential from 2s capped at 10s, for 10 retries.
const RETRY_GAPS = [2_000, 4_000, 8_000, ...Array<number>(7).fill(10_000)]
// Longest possible gap per retry (+20% jitter); advancing the clock by these always fires the retry.
const RETRY_GAPS_MAX = RETRY_GAPS.map((gap) => gap * 1.2)
const RETRY_ATTEMPTS = RETRY_GAPS.map((_, index) => index + 2)
// Subscribe before resuming; model requests can arrive before retry backoff is scheduled.
const subscribeRetries = (s: Scenario) =>
Effect.gen(function* () {
@@ -2676,8 +2682,8 @@ describe("SessionRunnerLLM", () => {
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
expect(attempts).toEqual([2, 3, 4, 5])
expect(s.requests).toHaveLength(6)
expect(attempts).toEqual(RETRY_ATTEMPTS)
expect(s.requests).toHaveLength(RETRY_ATTEMPTS.length + 2)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
status: "failed",
error: { type: "provider.transport", message: "Provider unavailable" },
@@ -5476,28 +5482,34 @@ describe("SessionRunnerLLM", () => {
LLMEvent.textStart({ id: "mixed-partial" }),
LLMEvent.textDelta({ id: "mixed-partial", text: "Partial" }),
)
yield* s.llm.push(Stream.fail(failure), partial, Stream.fail(failure), partial, partial)
// Alternate transparent failures and partial continuations until the retry allowance is spent.
const outcomes = RETRY_GAPS.map((_, index) => (index % 2 === 0 ? Stream.fail(failure) : partial))
yield* s.llm.push(...outcomes, partial)
const run = yield* s.resume.pipe(Effect.forkChild)
const identities: SessionMessage.ID[] = []
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
for (const delay of RETRY_GAPS_MAX) {
identities.push(yield* Queue.take(scheduled))
yield* TestClock.adjust(delay)
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(5)
expect(identities[0]).toBe(identities[1])
expect(identities[2]).toBe(identities[3])
expect(identities[0]).not.toBe(identities[2])
expect(s.requests).toHaveLength(RETRY_GAPS.length + 1)
// A transparent retry keeps the assistant identity; a partial continuation starts a new one.
for (const [index, identity] of identities.entries()) {
if (index === 0) continue
if (index % 2 === 1) expect(identity).toBe(identities[index - 1])
else expect(identity).not.toBe(identities[index - 1])
}
const partials = outcomes.filter((outcome) => outcome === partial).length
const messages = yield* s.context
expect(messages.filter((message) => message.type === "assistant")).toHaveLength(3)
expect(messages.filter((message) => message.type === "synthetic")).toHaveLength(2)
expect(messages.filter((message) => message.type === "assistant")).toHaveLength(partials + 1)
expect(messages.filter((message) => message.type === "synthetic")).toHaveLength(partials)
const events = yield* recordedEventTypes(sessionID)
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(4)
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(3)
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(RETRY_GAPS.length)
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(partials + 1)
},
)
scenario("stops incomplete stream continuations after five total attempts", function* (s) {
scenario("stops incomplete stream continuations once the retry allowance is spent", function* (s) {
yield* s.admit("Exhaust partial continuations")
const failure = incompleteStream()
yield* s.llm.always(
@@ -5511,30 +5523,30 @@ describe("SessionRunnerLLM", () => {
const scheduled = yield* subscribeRetries(s)
const run = yield* s.resume.pipe(Effect.forkChild)
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
for (const delay of RETRY_GAPS_MAX) {
yield* Queue.take(scheduled)
yield* TestClock.adjust(delay)
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(5)
expect(s.requests).toHaveLength(RETRY_GAPS.length + 1)
const context = yield* s.context
expect(context.filter((message) => message.type === "assistant")).toHaveLength(5)
expect(context.filter((message) => message.type === "synthetic")).toHaveLength(4)
expect(context.filter((message) => message.type === "assistant")).toHaveLength(RETRY_GAPS.length + 1)
expect(context.filter((message) => message.type === "synthetic")).toHaveLength(RETRY_GAPS.length)
})
scenario("stops after five total retry attempts", function* (s) {
scenario("stops once the retry allowance is spent", function* (s) {
yield* s.admit("Exhaust retries")
const failure = providerUnavailable()
yield* s.llm.always(Stream.fail(failure))
const scheduled = yield* subscribeRetries(s)
const run = yield* s.resume.pipe(Effect.forkChild)
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
for (const delay of RETRY_GAPS_MAX) {
yield* Queue.take(scheduled)
yield* TestClock.adjust(delay)
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(5)
expect(s.requests).toHaveLength(RETRY_GAPS.length + 1)
const retries = yield* s.db
.select({ data: EventTable.data })
@@ -5543,22 +5555,20 @@ describe("SessionRunnerLLM", () => {
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
for (const [index, range] of [
[1_600, 2_400],
[4_800, 7_200],
[11_200, 16_800],
[24_000, 36_000],
].entries()) {
expect(retries[index]?.data.at).toBeGreaterThanOrEqual(range[0]!)
expect(retries[index]?.data.at).toBeLessThanOrEqual(range[1]!)
// Each scheduled time falls within the jittered cumulative window for that retry.
expect(retries).toHaveLength(RETRY_GAPS.length)
let elapsed = 0
for (const [index, gap] of RETRY_GAPS.entries()) {
elapsed += gap
expect(retries[index]?.data.at).toBeGreaterThanOrEqual(elapsed * 0.8)
expect(retries[index]?.data.at).toBeLessThanOrEqual(elapsed * 1.2)
}
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(
RETRY_GAPS.length + 1,
)
const assistant = requireAssistant(yield* s.context)
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
...RETRY_GAPS.map(() => ({ type: "session.step.started.1" })),
{ type: "session.step.started.1" },
{ type: "session.step.failed.1" },
])
+16 -3
View File
@@ -34,6 +34,7 @@ import type {
SessionMessageAssistantTool,
SessionMessageUser,
SessionInfo,
ModelInfo,
} from "@opencode/client"
import { useLocal } from "../../context/local"
import { Locale } from "../../util/locale"
@@ -3167,6 +3168,7 @@ function Subagent(props: ToolProps) {
const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId))
const description = createMemo(() => stringValue(props.input.description))
const continuation = createMemo(() => Boolean(stringValue(props.input.sessionID)))
const model = createMemo(() => subagentModelLabel(stringValue(props.input.model), data.location.model.list()))
const isRunning = createMemo(() => {
const id = sessionID()
return props.part.state.status === "running" || Boolean(id && data.session.status(id) === "running")
@@ -3190,13 +3192,24 @@ function Subagent(props: ToolProps) {
) : undefined
}
>
{continuation()
? `Continue subagent — ${description() ?? "Subagent"}`
: `${Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General")} Subagent — ${description() ?? "Subagent"}`}
{`${continuation() ? "Continue subagent" : `${Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General")} Subagent`}${description() ?? "Subagent"}${model() ? ` · ${model()}` : ""}`}
</InlineTool>
)
}
export function subagentModelLabel(
value: string | undefined,
models: readonly Pick<ModelInfo, "providerID" | "id" | "name">[] | undefined,
) {
if (!value) return
const [reference, variant] = value.split("#")
const separator = reference.indexOf("/")
const providerID = separator === -1 ? "" : reference.slice(0, separator)
const modelID = separator === -1 ? reference : reference.slice(separator + 1)
const name = models?.find((item) => item.providerID === providerID && item.id === modelID)?.name
return `${name ?? reference}${variant ? ` (${variant})` : ""}`
}
export function isBackgroundSubagent(
metadata: Record<string, unknown>,
status: SessionMessageAssistantTool["state"]["status"],
@@ -10,6 +10,7 @@ import {
parseDiagnostics,
parseQuestionAnswers,
parseQuestions,
subagentModelLabel,
toolDisplay,
} from "../../../src/routes/session"
@@ -237,6 +238,14 @@ describe("TUI inline tool wrapping", () => {
expect(isBackgroundSubagent({ status: "completed" }, "completed")).toBeFalse()
})
test("labels only explicit subagent model overrides", () => {
const models = [{ providerID: "anthropic", id: "claude-opus-4-1", name: "Claude Opus 4.1" }]
expect(subagentModelLabel(undefined, models)).toBeUndefined()
expect(subagentModelLabel("anthropic/claude-opus-4-1", models)).toBe("Claude Opus 4.1")
expect(subagentModelLabel("anthropic/claude-opus-4-1#max", models)).toBe("Claude Opus 4.1 (max)")
expect(subagentModelLabel("custom/reviewer", models)).toBe("custom/reviewer")
})
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
expect(await renderFrame(() => <Fixture />, { width: 72, height: 12 })).toMatchSnapshot()
})
+6
View File
@@ -10,6 +10,12 @@ const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
const tag = `v${Script.version}`
if (Script.channel === "beta" && Script.release) {
console.log("\n=== desktop beta release ===\n")
await $`bun ./packages/desktop/scripts/publish.ts`
process.exit(0)
}
const pkgjsons = await Array.fromAsync(
new Bun.Glob("**/package.json").scan({
absolute: true,