Compare commits

..
Author SHA1 Message Date
usrnk1 64a606a123 chore: merge v2
# Conflicts:
#	packages/app/e2e/regression/mobile-summary-drawer.spec.ts
#	packages/app/src/new-session/workspace/selector.tsx
#	packages/app/src/session/header/session-header.tsx
#	packages/app/src/session/timeline/message-timeline.tsx
2026-09-10 15:25:48 +02:00
Shoubhit Dash 573d76933f fix(ai): make xAI Responses websockets work and enable them (#48318) 2026-09-10 17:27:43 +05:30
usrnk1andBrendonovich bb8194395a feat(desktop): respect follow-up behavior for slash commands (#48169)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-10 19:24:23 +08:00
usrnk1 01ef11dcd8 fix(desktop): adjust summary panel spacing 2026-09-09 15:53:53 +02:00
usrnk1 b8990f0e80 chore: merge v2 2026-09-09 15:21:07 +02:00
usrnk1 c620b19bf7 test(desktop): capture summary states 2026-09-09 15:09:23 +02:00
usrnk1 457e934f36 chore: merge v2 2026-09-09 14:59:53 +02:00
usrnk1 a3c2f492b8 test(desktop): cover new session summary 2026-09-09 14:59:44 +02:00
usrnk1 8501afca38 feat(desktop): add summary to new sessions 2026-09-09 14:59:32 +02:00
usrnk1 0e711dcea6 test(desktop): cover summary menus and MCP controls 2026-09-09 09:59:26 +02:00
usrnk1 bb6bfa7219 feat(desktop): consolidate status into session summary 2026-09-09 09:59:26 +02:00
usrnk1 dc62569153 refactor(desktop): support explicit MCP connection toggles 2026-09-09 09:58:51 +02:00
78 changed files with 2935 additions and 2362 deletions
@@ -18,7 +18,6 @@ const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Sche
])
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
export interface Options {
readonly id: string
@@ -27,6 +26,7 @@ export interface Options {
readonly enabled?: (url: string) => boolean
readonly url?: (url: string) => string
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly continuation?: OpenResponsesContinuation.Shape
}
export interface Prepared {
@@ -60,7 +60,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
}),
observe: (_create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
),
@@ -163,6 +163,7 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
request: create.request,
message: create.message,
base,
continuation: options.continuation,
}),
}
})
@@ -6,7 +6,6 @@ import { OpenResponses } from "./open-responses.js"
const PROTOCOL = "open-responses.websocket.v1"
const VERSION = 1
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
interface CheckpointValue {
readonly version: typeof VERSION
@@ -15,12 +14,19 @@ interface CheckpointValue {
readonly output: ReadonlyArray<unknown>
}
/**
* Fields to send next to `previous_response_id` on an incremental step, or undefined to send the step in full.
* Whether omitted fields carry over from the continued response is provider behavior the route must know.
*/
export type Shape = (request: Readonly<Record<string, unknown>>) => Readonly<Record<string, unknown>> | undefined
export interface DriverInput {
readonly id: string
readonly name: string
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
readonly continuation?: Shape
}
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
@@ -127,22 +133,26 @@ const rejected = (
export const driver = (input: DriverInput): WebSocketChannelDriver => {
const { previous_response_id: _previousResponseID, ...request } = input.request
const shape = input.continuation ?? ((fields: Readonly<Record<string, unknown>>) => fields)
let output: OpenResponses.StreamItem[] = []
return {
create: (checkpoint) =>
Effect.sync(() => {
output = []
const previous = checkpointValue(checkpoint)
const delta = previous ? incremental(request, previous) : undefined
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
// Ask the route first: diffing the whole history is wasted when it declines the continuation.
const fields = previous ? shape(request) : undefined
const delta = previous && fields ? incremental(request, previous) : undefined
if (!previous || !fields || !delta)
return { message: ProviderShared.encodeJson(request), mode: "full" as const }
return {
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
mode: "incremental" as const,
}
}),
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
),
@@ -195,4 +205,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
}
}
export const OpenResponsesContinuation = { driver } as const
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
@@ -405,6 +405,24 @@ export const Event = Schema.StructWithRest(
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
? { ...value, type: "error" }
: value,
),
),
)
export interface ProviderAdapter {
readonly id: string
readonly name: string
+4
View File
@@ -41,6 +41,10 @@ const responsesRoute = Route.make({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
// fails with "Response with id=… not found", so those steps are sent in full over the reused connection. It also
// rejects `instructions` next to `previous_response_id` and keeps the instructions of the response it continues.
continuation: ({ instructions: _instructions, ...request }) => (request.store === false ? undefined : request),
}),
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
@@ -90,7 +90,11 @@ const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
}
}
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const continuationDriver = (
request: Readonly<Record<string, unknown>>,
base = baseChannelDriver,
continuation?: OpenResponsesContinuation.Shape,
) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
@@ -98,6 +102,7 @@ const continuationDriver = (request: Readonly<Record<string, unknown>>, base = b
request,
message,
base: base(message),
continuation,
})
}
@@ -921,6 +926,58 @@ describe("OpenAI Responses route", () => {
type: "provider-failure",
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
})
// A retryable failure stays one: the runner retries it, and the transport has already dropped the
// checkpoint, so that retry is a full send. xAI reports every rejection this way.
const internal = ProviderShared.encodeJson({
type: "error",
error: { type: "api_error", message: "gRPC error: Response with id=resp_1 not found" },
})
expect(yield* second.observe(yield* second.create(saved), internal)).toMatchObject({
type: "provider-failure",
error: { reason: { _tag: "ProviderInternal" } },
})
}),
)
it.effect("shapes the incremental send with the route continuation", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "grok-4.6",
store: true,
instructions: "You are terse.",
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const secondRequest = {
...firstRequest,
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
}
const saved = checkpoint(
yield* continuationDriver(firstRequest).observe(
yield* continuationDriver(firstRequest).create(undefined),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const trimmed = yield* continuationDriver(
secondRequest,
baseChannelDriver,
({ instructions: _, ...rest }) => rest,
).create(saved)
expect(trimmed.mode).toBe("incremental")
expect(JSON.parse(trimmed.message)).toEqual({
type: "response.create",
model: "grok-4.6",
store: true,
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
})
// Declining the continuation sends the step in full and never sends a previous_response_id.
const declined = yield* continuationDriver(secondRequest, baseChannelDriver, () => undefined).create(saved)
expect(declined.mode).toBe("full")
expect(JSON.parse(declined.message)).toEqual(secondRequest)
}),
)
+110 -2
View File
@@ -1,11 +1,18 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer, Stream } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import * as ProviderShared from "../../src/protocols/shared.js"
import { XAIResponses } from "../../src/protocols/xai-responses.js"
import { LLMClient } from "../../src/route.js"
import {
LLMClient,
RequestExecutor,
WebSocketTransport,
type ChannelCheckpoint,
type WebSocketChannelDriver,
} from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -13,6 +20,35 @@ import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
/** Runs a request through the WebSocket transport and hands back its channel driver; the HTTP fallback answers. */
const channelDriver = (request: ReturnType<typeof LLM.request>) =>
Effect.gen(function* () {
let driver: WebSocketChannelDriver | undefined
yield* LLMClient.generate(request, {
webSocket: {
execute: (exchange) =>
Effect.sync(() => {
driver = exchange.driver
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
}).pipe(Effect.provide(fixedResponse(sseEvents({ type: "response.completed", response: { id: "http" } }))))
if (!driver) throw new Error("Expected a WebSocket channel driver")
return driver
})
const completed = (driver: WebSocketChannelDriver, id: string) =>
Effect.gen(function* () {
const create = yield* driver.create(undefined)
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.created", response: { id } }))
const observation = yield* driver.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id } }),
)
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected a checkpoint")
return observation.checkpoint
})
describe("xAI Responses route", () => {
it.effect("composes the Open Responses baseline with xAI extensions", () =>
Effect.gen(function* () {
@@ -162,6 +198,78 @@ describe("xAI Responses route", () => {
}),
)
it.effect("classifies xAI's untyped WebSocket error envelope", () =>
Effect.gen(function* () {
// xAI answers a rejected response.create with an error envelope that carries no event type.
const envelope = ProviderShared.encodeJson({
error: {
message:
'Request validation error: {"code":"400","error":"Argument not supported: instructions and previous_response_id together"}',
type: "api_error",
},
})
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(envelope), close: Effect.void }),
})
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), { webSocket }).pipe(
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
),
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toContain("Argument not supported: instructions and previous_response_id together")
expect(error.reason.body).toBe(envelope)
}),
)
it.effect("continues stored responses without instructions and sends unstored steps in full", () =>
Effect.gen(function* () {
const step = (store: boolean, ...prompts: string[]) =>
LLM.request({
model,
system: "You are terse.",
messages: prompts.map((prompt) => Message.user(prompt)),
providerOptions: { store },
})
const send = (store: boolean, checkpoint: ChannelCheckpoint) =>
channelDriver(step(store, "First", "Second")).pipe(Effect.flatMap((driver) => driver.create(checkpoint)))
const stored = yield* send(true, yield* completed(yield* channelDriver(step(true, "First")), "resp_1"))
expect(stored.mode).toBe("incremental")
expect(JSON.parse(stored.message)).toEqual({
type: "response.create",
model: "grok-4.6",
store: true,
include: ["reasoning.encrypted_content"],
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
})
// The connection cache only serves stored responses, so the default store: false never chains.
const unstored = yield* send(false, yield* completed(yield* channelDriver(step(false, "First")), "resp_1"))
expect(unstored.mode).toBe("full")
expect(JSON.parse(unstored.message)).toMatchObject({
instructions: "You are terse.",
store: false,
input: [
{ role: "user", content: [{ type: "input_text", text: "First" }] },
{ role: "user", content: [{ type: "input_text", text: "Second" }] },
],
})
expect(JSON.parse(unstored.message).previous_response_id).toBeUndefined()
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
@@ -59,8 +59,9 @@ for (const shared of [true, false]) {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await page.keyboard.press("ControlOrMeta+;")
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const dialog = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
const toggle = dialog.getByRole("switch")
await expect(toggle).not.toBeChecked()
@@ -92,7 +93,7 @@ for (const surface of ["popover", "dialog"] as const) {
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
const requests: { path: string; directory: string }[] = []
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "mcp.toggle": "ctrl+;" } }))
})
await mockOpenCodeServer(page, {
directory,
@@ -113,6 +114,10 @@ for (const surface of ["popover", "dialog"] as const) {
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
state.status = "disabled"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/figma-desktop/connect") {
state.status = state.fail ? "failed" : "connected"
// Connection failures are reported by the refreshed status, not the HTTP response.
@@ -137,12 +142,19 @@ for (const surface of ["popover", "dialog"] as const) {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
const panel =
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
if (surface === "popover") {
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
}
if (surface === "dialog") await page.keyboard.press("Control+;")
const panel = page.getByRole("dialog", { name: surface === "popover" ? "MCP" : "MCPs", exact: true })
const toggle = panel.getByRole("switch")
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
await expect(toggle).toBeEnabled()
if (surface === "popover") {
await expect(toggle).toBeChecked()
await panel.getByText("figma-desktop", { exact: true }).click()
}
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
requests.length = 0
@@ -152,7 +164,7 @@ for (const surface of ["popover", "dialog"] as const) {
.getByRole("listitem", { includeHidden: true })
.filter({ has: page.getByText("Request failed", { exact: true }) })
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeChecked({ checked: surface === "popover" })
await expect(toggle).toBeEnabled()
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
@@ -167,9 +179,18 @@ for (const surface of ["popover", "dialog"] as const) {
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
await expect(toast).toBeHidden()
state.fail = false
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
if (surface === "popover") {
await expect(page.getByRole("dialog", { name: "Session details", exact: true })).toBeHidden()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
}
if (surface === "dialog") await page.keyboard.press("Control+;")
await expect(toggle).toBeEnabled()
if (surface === "popover") {
await panel.getByText("figma-desktop", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
}
await panel.locator('[data-slot="switch-control"]').click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
@@ -104,18 +104,12 @@ for (const position of ["top", "bottom"] as const) {
await expect(composer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
const status = page.getByRole("dialog", { name: "Status", exact: true })
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await status.getByRole("button", { name: "Close", exact: true }).click()
await expect(status).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await expect(details).toBeHidden()
await expect(more).toBeFocused()
@@ -2,20 +2,21 @@ import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
test("summary drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
// Corvu starts opening after paint; the transition flag is also absent
// before that callback. Wait for the open position before dismissing.
await expect
@@ -0,0 +1,452 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer, currentSession } from "../utils/mock-server"
const directory = "/workspace/summary-project"
const workspace = "/workspace/existing-worktree"
const createdWorkspace = "/workspace/created-worktree"
const draftID = "draft_summary"
const secondDraftID = "draft_summary_other"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const draftPath = `/new-session?draftId=${draftID}`
for (const rtl of [false, true]) {
test(`new session summary shows project extensions and follows workspace selection in ${rtl ? "rtl" : "ltr"}`, async ({
page,
}, testInfo) => {
const mock = await openDraft(page)
if (rtl) {
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
await page.getByRole("button", { name: "DIR: LTR", exact: true }).click()
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
}
const trigger = page.getByRole("button", { name: "Session details", exact: true })
await expect
.poll(async () => {
const button = await trigger.boundingBox()
const view = await page.locator('[data-component="new-session"]').boundingBox()
if (!button || !view) return false
const gap = rtl ? button.x - view.x : view.x + view.width - button.x - button.width
return Math.abs(gap - 12) <= 1 && Math.abs(button.y + button.height / 2 - view.y - 24) <= 1
})
.toBe(true)
await trigger.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByRole("button", { name: "summary-project", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveAttribute("aria-expanded", "true")
await expect
.poll(async () => {
const view = await page.locator('[data-component="new-session"]').boundingBox()
const project = await summary.locator('[data-section="project"]').boundingBox()
const server = await summary.locator('[data-section="server"]').boundingBox()
if (!view || !project || !server) return
return {
top: project.y - view.y - 48,
cards: server.y - project.y - project.height,
}
})
.toEqual({ top: 6, cards: 8 })
await testInfo.attach(`new-session-summary-${rtl ? "rtl" : "ltr"}`, {
body: await page.screenshot(),
contentType: "image/png",
})
for (const [name, item] of [
["MCP", "summary-mcp"],
["Plugins", "project-plugin"],
["Skills", "summary-skill"],
["LSP", "typescript"],
]) {
await summary.getByRole("button", { name, exact: true }).click()
await expect(page.getByRole("dialog", { name, exact: true }).getByText(item, { exact: true })).toBeVisible()
}
await page.keyboard.press("Escape")
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
const worktreeMenu = page.getByRole("menu", { name: "Local repository", exact: true })
await expect
.poll(async () => {
const menu = await worktreeMenu.boundingBox()
const panel = await summary.locator('[data-component="session-summary-panel"]').boundingBox()
if (!menu || !panel) return false
return rtl ? menu.x >= panel.x + panel.width : menu.x + menu.width <= panel.x
})
.toBe(true)
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeHidden()
await worktreeMenu.getByRole("menuitem", { name: "Worktree", exact: true }).press(rtl ? "ArrowLeft" : "ArrowRight")
await expect(page.getByRole("menu", { name: "Worktree", exact: true })).toBeVisible()
await page.keyboard.press("Enter")
await expect(summary.getByRole("button", { name: "existing-worktree", exact: true })).toBeVisible()
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = mcp.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
await mcp.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
expect(mock.calls).toEqual([{ type: "mcp", directory: workspace, enabled: true }])
expect(mock.status.get(directory)).toBe("connected")
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("workspace-plugin", { exact: true }),
).toBeVisible()
})
}
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
const mock = await openDraft(page, "create")
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.locator('[data-slot="mcp-preview-hint"]')).toHaveText("Applies when the worktree is created")
await expect(menu).toHaveCSS("opacity", "1")
await testInfo.attach("new-worktree-mcp-preview", { body: await page.screenshot(), contentType: "image/png" })
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).not.toBeChecked()
expect(mock.calls).toEqual([])
await expect
.poll(() =>
page.evaluate(
() =>
JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs") ?? "[]").find(
(tab: { draftID?: string }) => tab.draftID === "draft_summary",
)?.mcp?.states,
),
)
.toEqual({ "summary-mcp": false })
await page.goto(`/new-session?draftId=${secondDraftID}`)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await expect(toggle).toBeChecked()
await page.goto(draftPath)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await expect(toggle).not.toBeChecked()
expect(mock.calls).toEqual([])
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Use my selected MCPs")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls).toEqual([
{ type: "worktree", directory },
{ type: "mcp", directory: createdWorkspace, enabled: false },
{ type: "session", directory: createdWorkspace },
{ type: "prompt", directory: createdWorkspace },
])
expect(mock.prompts[0].body.text).toBe("Use my selected MCPs")
expect(mock.status.get(directory)).toBe("connected")
})
test("the first prompt waits for a live MCP toggle", async ({ page }) => {
const mock = await openDraft(page)
const release = Promise.withResolvers<void>()
mock.state.hold = release.promise
await page.locator('[data-component="composer-editor"]').fill("Wait for the MCP update")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
try {
await menu.getByText("summary-mcp", { exact: true }).click()
await expect.poll(() => mock.calls.length).toBe(1)
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect(page).toHaveURL(draftPath)
expect(mock.calls).toEqual([{ type: "mcp", directory, enabled: false }])
expect(mock.prompts).toEqual([])
} finally {
release.resolve()
}
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls).toEqual([
{ type: "mcp", directory, enabled: false },
{ type: "session", directory },
{ type: "prompt", directory },
])
})
test("changing worktrees does not wait for another directory's pending MCP update", async ({ page }) => {
const mock = await openDraft(page)
const release = Promise.withResolvers<void>()
mock.state.hold = release.promise
mock.state.holdDirectory = directory
await page.locator('[data-component="composer-editor"]').fill("Run in the selected worktree")
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
try {
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect.poll(() => mock.calls.length).toBe(1)
await page.keyboard.press("Escape")
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
await page.getByRole("menuitem", { name: "Worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeEnabled()
await expect(toggle).not.toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.find((call) => call.type === "session")?.directory).toBe(workspace)
expect(mock.status.get(directory)).toBe("connected")
} finally {
release.resolve()
}
})
test("failed MCP preparation restores the draft and reuses the created worktree", async ({ page }) => {
const mock = await openDraft(page, "create")
mock.state.fail = true
await page.locator('[data-component="composer-editor"]').fill("Keep this prompt on failure")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await page.getByRole("dialog", { name: "MCP", exact: true }).getByText("summary-mcp", { exact: true }).click()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep this prompt on failure")
await expect(page.getByRole("button", { name: "created-worktree", exact: true })).toBeVisible()
expect(mock.prompts).toEqual([])
expect(mock.calls.map((call) => call.type)).toEqual(["worktree", "mcp"])
mock.state.fail = false
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
expect(mock.status.get(createdWorkspace)).toBe("disabled")
expect(mock.prompts[0].body.text).toBe("Keep this prompt on failure")
})
test("new worktree sign-in completes before the draft can send", async ({ page, context }) => {
const mock = await openDraft(page, "create")
mock.status.set(createdWorkspace, "needs_auth")
const attempts: string[] = []
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await page.route("**/api/integration/**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (route.request().method() === "POST") {
attempts.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
return route.fulfill({
json: { location: { directory: createdWorkspace }, data: { url: "https://auth.example.test/authorize" } },
})
}
return route.fulfill({
json: {
location: { directory: createdWorkspace },
data: {
id: "summary-oauth",
methods: [{ id: "oauth", type: "oauth" }],
},
},
})
})
await page.locator('[data-component="composer-editor"]').fill("Wait for my sign-in")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
const popup = page.waitForEvent("popup")
await page.locator('[data-action="composer-submit"]').click()
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Wait for my sign-in")
expect(attempts).toEqual([createdWorkspace])
expect(mock.prompts).toEqual([])
mock.status.set(createdWorkspace, "connected")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
expect(attempts).toHaveLength(1)
})
async function openDraft(page: Page, worktree = "main") {
const project = {
id: "proj_new_summary",
worktree: directory,
name: "summary-project",
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [workspace],
}
const sessions: ReturnType<typeof currentSession>[] = []
const status = new Map<string, string>([
[directory, "connected"],
[workspace, "disabled"],
[createdWorkspace, "connected"],
])
const calls: { type: string; directory: string; enabled?: boolean }[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
await mockOpenCodeServer(page, {
directory,
project,
sessions,
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "summary-model": { id: "summary-model", name: "Summary Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "summary-model" },
},
pageMessages: () => ({ items: [] }),
onPrompt(input) {
const session = sessions.find((session) => session.id === input.sessionID)
if (!session?.location.directory) throw new Error("Prompt arrived before session creation")
calls.push({ type: "prompt", directory: session.location.directory })
prompts.push(input)
},
})
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
if (route.request().method() === "POST") {
const enabled = url.pathname.endsWith("/connect")
calls.push({ type: "mcp", directory: target, enabled })
if (!state.holdDirectory || state.holdDirectory === target) await state.hold
if (state.fail) return route.fulfill({ status: 500, json: { message: "MCP fixture failed" } })
status.set(target, enabled ? "connected" : "disabled")
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: target },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [
{
name: "summary-mcp",
integrationID: "summary-oauth",
status: { status: status.get(target) ?? "connected" },
},
],
},
})
})
await page.route(
(url) => url.pathname === "/api/location",
(route) =>
route.fulfill({
json: {
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
project: { id: project.id, directory, canonical: directory },
},
}),
)
await page.route(
(url) => url.pathname === "/api/plugin",
(route) => {
const target = new URL(route.request().url()).searchParams.get("location[directory]") ?? directory
const id = target === directory ? "project-plugin" : "workspace-plugin"
return route.fulfill({
json: {
location: { directory: target },
data: [{ id, source: { type: "package", target: id }, features: {}, state: { status: "active" } }],
},
})
},
)
await page.route(
(url) => url.pathname === "/api/skill",
(route) =>
route.fulfill({
json: {
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
data: [
{
id: "summary-skill",
name: "summary-skill",
location: "/skills/summary/SKILL.md",
content: "Summary skill",
},
],
},
}),
)
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
json: [{ type: "document", info: { lsp: { typescript: { command: ["typescript-language-server"] } } } }],
}),
)
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => {
if (route.request().method() !== "POST") return route.fallback()
calls.push({ type: "worktree", directory })
project.sandboxes.push(createdWorkspace)
return route.fulfill({ json: { directory: createdWorkspace } })
},
)
await page.route(
(url) => url.pathname === "/api/session",
(route) => {
if (route.request().method() !== "POST") return route.fallback()
const body: { id: string; location: { directory: string } } = route.request().postDataJSON()
calls.push({ type: "session", directory: body.location.directory })
const session = currentSession(
{ ...body, projectID: project.id, title: "Created summary session" },
body.location.directory,
)
sessions.push(session)
return route.fulfill({ json: { data: session } })
},
)
await page.addInitScript(
({ directory, server, draftID, secondDraftID, worktree }) => {
if (!localStorage.getItem("opencode.global.dat:server"))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs"))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "draft", draftID, server, directory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
]),
)
},
{ directory, server, draftID, secondDraftID, worktree },
)
await page.goto(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toContainText("Summary Model")
await expect(
page.getByRole("button", { name: worktree === "create" ? "New worktree" : "Local", exact: true }),
).toBeVisible()
return { calls, prompts, status, state }
}
@@ -4,7 +4,7 @@ import { installStressSessionTabs, stressSessionHref } from "../performance/time
import { mockOpenCodeServer } from "../utils/mock-server"
for (const direction of ["ltr", "rtl"] as const) {
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
test(`session header groups controls and exposes session details in ${direction}`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
@@ -31,7 +31,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(review).toBeVisible()
await expect(details).toBeVisible()
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
await expect(status).toBeVisible()
await expect(status).toHaveCount(0)
const titleBounds = await header.getByRole("heading").boundingBox()
expect(titleBounds).not.toBeNull()
for (const editing of [false, true]) {
@@ -122,12 +122,11 @@ for (const direction of ["ltr", "rtl"] as const) {
.toBe(true)
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await status.click()
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
await expect(mcp).toHaveAttribute("aria-selected", "true")
await plugins.click()
await expect(plugins).toHaveAttribute("aria-selected", "true")
await details.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
await expect(mcp).toBeVisible()
await expect(summary.getByRole("button", { name: "Plugins", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(mcp).toBeHidden()
})
@@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
for (const theme of ["light", "dark"] as const) {
test(`summary bounds long service lists in ${theme}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 800, height: 600 })
await mockStressTimeline(page)
await page.addInitScript((theme) => {
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", theme)
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: theme === "dark" ? "he" : "en" }))
}, theme)
await page.route("**/api/mcp**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
new URL(route.request().url()).pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: Array.from({ length: 30 }, (_, index) => ({
name: `server-${String(index).padStart(2, "0")}-בדיקה-${"long-name-".repeat(6)}`,
status: { status: "connected" },
})),
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", theme)
await page.getByRole("button", { name: theme === "dark" ? "פרטי ההפעלה" : "Session details", exact: true }).click()
const summary = page.locator('[data-component="session-summary-panel"]')
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.getByRole("switch")).toHaveCount(30)
await expect.poll(() => menu.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await expect
.poll(async () => {
const bounds = await menu.boundingBox()
return (
!!bounds &&
bounds.x >= 15 &&
bounds.y >= 15 &&
bounds.x + bounds.width <= 785 &&
bounds.y + bounds.height <= 585
)
})
.toBe(true)
await testInfo.attach(`summary-${theme}-long-list`, { body: await page.screenshot(), contentType: "image/png" })
await menu.getByRole("switch", { name: /^server-29-/ }).focus()
await expect(menu.getByRole("switch", { name: /^server-29-/ })).toBeFocused()
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeFocused()
})
}
@@ -0,0 +1,168 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("every MCP row hit area toggles exactly once and keeps the submenu open", async ({ page }, testInfo) => {
await mockStressTimeline(page)
const state = { enabled: true }
const writes: string[] = []
await page.route("**/api/mcp**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const directory = url.searchParams.get("location[directory]")
if (route.request().method() === "POST") {
expect(directory).toBe(fixture.directory)
writes.push(url.pathname)
state.enabled = url.pathname.endsWith("/connect")
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [
{ name: "figma", status: { status: state.enabled ? "connected" : "disabled" } },
{ name: "linear", status: { status: "needs_auth" }, integrationID: "linear-oauth" },
{ name: "playwright", status: { status: "failed", error: "Connection refused" } },
{ name: "waiting", status: { status: "pending" } },
],
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = submenu.getByRole("switch", { name: "figma", exact: true })
const row = submenu
.locator('[data-component="switch"]')
.filter({ has: page.getByRole("switch", { name: "figma", exact: true }) })
await expect(toggle).toBeChecked()
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toBeChecked()
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toHaveAccessibleDescription("Failed")
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toBeDisabled()
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toHaveAccessibleDescription("Connecting…")
await expect(submenu.getByRole("switch", { name: "linear", exact: true })).toHaveAccessibleDescription(
"Sign in required",
)
await testInfo.attach("summary-mcp-states", { body: await page.screenshot(), contentType: "image/png" })
for (const [index, target] of ["label", "dot", "padding", "control", "keyboard"].entries()) {
const enabled = index % 2 !== 0
await expect(toggle).toBeEnabled()
if (target === "label") await row.getByText("figma", { exact: true }).click()
if (target === "dot") await row.locator(".session-service-dot").click()
if (target === "padding") await row.click({ position: { x: 3, y: 3 } })
if (target === "control") await row.locator('[data-slot="switch-control"]').click()
if (target === "keyboard") await toggle.press("Space")
await expect(toggle).toBeChecked({ checked: enabled })
await expect(toggle).toBeEnabled()
await expect(submenu).toBeVisible()
if (target === "keyboard") await expect(toggle).toBeFocused()
expect(writes).toHaveLength(index + 1)
expect(writes[index]).toBe(`/api/mcp/figma/${enabled ? "connect" : "disconnect"}`)
}
})
test("MCP authentication starts before a slow resource catalog finishes", async ({ page, context }) => {
await mockStressTimeline(page)
const state = { status: "disabled" }
const attempts: string[] = []
const resources = Promise.withResolvers<void>()
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
if (url.pathname.endsWith("/connect")) {
state.status = "needs_auth"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/resource" && state.status === "needs_auth") await resources.promise
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [{ name: "linear", integrationID: "linear-oauth", status: { status: state.status } }],
},
})
})
await page.route("**/api/integration/**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (route.request().method() === "POST") {
attempts.push(route.request().url())
return route.fulfill({
json: { location: { directory: fixture.directory }, data: { url: "https://auth.example.test/authorize" } },
})
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: {
id: "linear-oauth",
methods: [{ id: "oauth", type: "oauth" }],
},
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = submenu.getByRole("switch", { name: "linear", exact: true })
await expect(toggle).toBeEnabled()
const refresh = page.waitForRequest(
(request) =>
state.status === "needs_auth" &&
new URL(request.url()).pathname === "/api/mcp/resource" &&
request.method() === "GET",
)
try {
const popup = page.waitForEvent("popup")
await submenu.getByText("linear", { exact: true }).click()
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
await refresh
await expect(toggle).toBeChecked()
await expect(toggle).toHaveAccessibleDescription("Sign in required")
} finally {
resources.resolve()
}
await expect(toggle).toBeEnabled()
expect(attempts).toHaveLength(1)
expect(new URL(attempts[0]).searchParams.get("location[directory]")).toBe(fixture.directory)
})
test("multiple desktop connections show the session's server name", async ({ page }) => {
await mockStressTimeline(page)
await page.route("http://secondary.test/**", (route) => route.fulfill({ json: { healthy: true, version: "2.0.0" } }))
await page.addInitScript(
({ directory, server }) => {
const current = { type: "http", http: { url: server }, displayName: "Design server" }
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [current, { type: "http", http: { url: "http://secondary.test" }, displayName: "Other server" }],
projects: { local: [{ worktree: directory, expanded: true }] },
hidden: {},
lastProject: {},
recentlyClosed: {},
}),
)
},
{
directory: fixture.directory,
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByRole("button", { name: "Design server", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveCount(0)
})
@@ -0,0 +1,125 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
const services = [
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured yet", item: "summary-mcp" },
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured yet", item: "summary-plugin" },
{ name: "Skills", path: "/api/skill", empty: "No skills configured yet", item: "summary-skill" },
{ name: "LSP", path: "/api/config", empty: "No LSP servers explicitly configured", item: "summary-lsp" },
] as const
for (const service of services) {
for (const empty of [false, true]) {
test(`${service.name} keeps ${empty ? "its empty state" : "cached items"} visible while reopening and refreshing`, async ({
page,
}, testInfo) => {
await mockStressTimeline(page)
const warnings: string[] = []
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
const state = { hold: false }
const response = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === service.path,
async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (state.hold) await response.promise
if (service.name === "LSP")
return route.fulfill({
json: empty ? [] : [{ type: "document", info: { lsp: { "summary-lsp": { command: ["summary-lsp"] } } } }],
})
const items =
service.name === "MCP"
? [{ name: service.item, status: { status: "connected" } }]
: service.name === "Plugins"
? [
{
id: service.item,
source: { type: "package", target: service.item },
features: {},
state: { status: "active" },
},
]
: [{ id: service.item, name: service.item, location: "/skills/summary/SKILL.md", content: "Summary" }]
return route.fulfill({ json: { location: { directory: fixture.directory }, data: empty ? [] : items } })
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const trigger = summary.getByRole("button", { name: service.name, exact: true })
await trigger.click()
const menu = page.getByRole("dialog", { name: service.name, exact: true })
const content = menu.getByText(empty ? service.empty : service.item, { exact: true })
await expect(content).toBeVisible()
await expect(menu).toHaveAttribute("aria-busy", "false")
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
if (empty) {
const message = menu.locator(".session-service-empty")
await expect(message).toHaveCSS("padding", "8px 12px")
await expect(message).toHaveCSS("gap", "8px")
await expect(message).toHaveCSS("font-size", "11px")
await expect(message).toHaveCSS("line-height", "16px")
await expect(message.locator("strong")).toHaveCSS("font-weight", "530")
await expect(message.locator("p")).toHaveCSS("font-weight", "440")
await testInfo.attach(`${service.name}-empty`, { body: await menu.screenshot(), contentType: "image/png" })
}
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
state.hold = true
const refresh = page.waitForRequest(
(request) => request.method() === "GET" && new URL(request.url()).pathname === service.path,
)
try {
await trigger.click()
await refresh
await expect(menu).toHaveAttribute("aria-busy", "true")
await expect(content).toBeVisible()
await expect(menu.getByRole("status")).toHaveCount(0)
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
await expect(summary).toBeVisible()
} finally {
response.resolve()
}
await expect(menu).toHaveAttribute("aria-busy", "false")
await expect(content).toBeVisible()
expect(warnings).toEqual([])
})
}
}
test("prefetching plugins does not suspend the summary or report an empty catalog", async ({ page }) => {
await mockStressTimeline(page)
const response = Promise.withResolvers<void>()
const state = { requested: false }
await page.route(
(url) => url.pathname === "/api/plugin",
async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
state.requested = true
await response.promise
return route.fulfill({ json: { location: { directory: fixture.directory }, data: [] } })
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
try {
await expect.poll(() => state.requested).toBe(true)
await expect(summary.getByRole("button", { name: fixture.project.name, exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
const menu = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(menu.getByRole("status")).toContainText("Loading")
await expect(menu.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
await expect(summary).toBeVisible()
} finally {
response.resolve()
}
await expect(
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("No plugins configured yet", { exact: true }),
).toBeVisible()
})
@@ -0,0 +1,249 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
for (const layout of ["horizontal", "vertical"] as const) {
test(`summary persists both disclosures across sessions with ${layout} tabs`, async ({ page }, testInfo) => {
await mockStressTimeline(page)
await page.addInitScript((layout) => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({
...settings,
appearance: { ...settings.appearance, tabLayout: layout },
general: { ...settings.general, showStatus: true },
}),
)
}, layout)
await page.goto(stressSessionHref(fixture.targetID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
await expect(trigger).toBeEnabled()
await expect(page.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await trigger.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const project = summary.getByRole("button", { name: fixture.project.name, exact: true })
const server = summary.getByRole("button", { name: "Server", exact: true })
await expect(project).toHaveAttribute("aria-expanded", "true")
await expect(server).toHaveAttribute("aria-expanded", "true")
for (const heading of [project, server]) {
await expect(heading).toHaveCSS("column-gap", "8px")
await expect(heading.locator(".session-summary-heading-label")).toHaveCSS("column-gap", "4px")
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("width", "16")
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("height", "16")
}
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await testInfo.attach(`summary-${layout}`, { body: await page.screenshot(), contentType: "image/png" })
await project.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(summary.getByRole("button", { name: "No changes", exact: true })).toHaveCount(0)
await expect(server).toHaveAttribute("aria-expanded", "true")
await server.click()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(server).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").sessionSummary))
.toEqual({ projectExpanded: false, serverExpanded: false })
await page.goto(stressSessionHref(fixture.sourceID))
await trigger.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(server).toHaveAttribute("aria-expanded", "false")
await server.click()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await expect(project).toHaveAttribute("aria-expanded", "false")
await page.keyboard.press("Escape")
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(settings.getByText("Server status", { exact: true })).toHaveCount(0)
})
}
for (const direction of ["ltr", "rtl"] as const) {
test(`service submenus open on click and stay aligned with the view in ${direction}`, async ({ page }, testInfo) => {
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
await expect(page.getByRole("button", { name: "Session details", exact: true })).toBeEnabled()
if (direction === "rtl") {
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
await page.getByRole("button", { name: "DIR: LTR", exact: true }).click()
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
}
const warnings: string[] = []
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
await mcp.hover()
await expect(submenu).toHaveCount(0)
await mcp.click()
await expect(submenu.getByText("No MCP servers configured yet", { exact: true })).toBeVisible()
await expect(summary).toBeVisible()
await expect
.poll(async () => {
const row = await mcp.boundingBox()
const menu = await submenu.boundingBox()
if (!row || !menu) return false
return direction === "ltr" ? menu.x + menu.width <= row.x : menu.x >= row.x + row.width
})
.toBe(true)
await testInfo.attach(`summary-submenu-${direction}`, { body: await page.screenshot(), contentType: "image/png" })
await page.keyboard.press("Escape")
await expect(submenu).toBeHidden()
await expect(summary).toBeVisible()
await expect(mcp).toBeFocused()
await mcp.press("Enter")
await expect(submenu.getByText("Add servers in opencode.json")).toBeVisible()
await mcp.click()
await expect(submenu).toBeHidden()
for (const [name, text] of [
["Plugins", "No plugins configured yet"],
["Skills", "No skills configured yet"],
["LSP", "No LSP servers explicitly configured"],
]) {
await summary.getByRole("button", { name, exact: true }).click()
await expect(page.getByRole("dialog", { name, exact: true }).getByText(text, { exact: true })).toBeVisible()
await expect(submenu).toBeHidden()
}
await summary.getByRole("button", { name: "Server", exact: true }).click()
await expect(page.getByRole("dialog", { name: "LSP", exact: true })).toBeHidden()
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
for (const reviewOpen of [false, true]) {
if (reviewOpen) await page.getByRole("button", { name: "Toggle review", exact: true }).click()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await expect(summary).toBeVisible()
await expect
.poll(async () => {
const header = await page.locator("[data-session-title]").boundingBox()
const panel = await summary.boundingBox()
if (!header || !panel) return Infinity
return direction === "ltr"
? Math.abs(header.x + header.width - panel.x - panel.width - 12)
: Math.abs(panel.x - header.x - 12)
})
.toBeLessThanOrEqual(1)
await page.keyboard.press("Escape")
}
expect(warnings).toEqual([])
})
}
test("catalog submenus show project plugins and skills, refresh on reopen, and distinguish errors from empty", async ({
page,
}, testInfo) => {
await mockStressTimeline(page)
const state = { fail: true, extra: false }
const requests: string[] = []
await page.route("**/api/plugin**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
requests.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "builtin", source: { type: "builtin" }, features: {}, state: { status: "active" } },
{
id: "supermemory",
source: { type: "package", target: "opencode-supermemory" },
features: { server: true },
state: { status: "active" },
},
{
id: "broken-plugin",
source: { type: "local", path: "/broken.ts" },
features: { server: true },
state: { status: "failed", error: "Plugin failed to activate" },
},
...(state.extra
? [
{
id: "daytona",
source: { type: "package", target: "opencode-daytona" },
features: {},
state: { status: "active" },
},
]
: []),
],
},
})
})
await page.route("**/api/skill**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "find-skills", name: "find-skills", location: "/skills/find/SKILL.md", content: "Find skills" },
{
id: "review-animations",
name: "review-animations",
location: "/skills/review/SKILL.md",
content: "Review animations",
},
],
},
})
})
await page.route("**/api/config**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: [
{
type: "document",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"] },
rust: { command: ["rust-analyzer"] },
},
},
},
{ type: "document", info: { lsp: { rust: { disabled: true } } } },
],
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(plugins.getByRole("alert")).toContainText("Request failed")
await expect(plugins.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
state.fail = false
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
await expect(plugins.getByText("supermemory", { exact: true })).toBeVisible()
await expect(plugins.getByText("builtin", { exact: true })).toHaveCount(0)
await expect(plugins.getByTitle("Plugin failed to activate")).toContainText("Failed")
expect(requests.every((directory) => directory === fixture.directory)).toBe(true)
await testInfo.attach("summary-plugins", { body: await page.screenshot(), contentType: "image/png" })
await page.keyboard.press("Escape")
state.extra = true
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(plugins.getByText("daytona", { exact: true })).toBeVisible()
await summary.getByRole("button", { name: "Skills", exact: true }).click()
const skills = page.getByRole("dialog", { name: "Skills", exact: true })
await expect(skills.getByText("find-skills", { exact: true })).toBeVisible()
await expect(skills.getByText("review-animations", { exact: true })).toBeVisible()
await expect(plugins).toBeHidden()
await summary.getByRole("button", { name: "LSP", exact: true }).click()
const lsp = page.getByRole("dialog", { name: "LSP", exact: true })
await expect(lsp.getByText("Configured LSPs", { exact: true })).toBeVisible()
await expect(lsp.getByText("typescript", { exact: true })).toBeVisible()
await expect(lsp.getByText("rust", { exact: true })).toHaveCount(0)
await expect(lsp.locator(".session-service-dot")).toHaveCount(0)
await testInfo.attach("summary-configured-lsp", { body: await page.screenshot(), contentType: "image/png" })
})
@@ -188,16 +188,8 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
).toBeVisible()
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
const status = sidebar.getByRole("button", { name: "Status", exact: true })
await expect(status).toBeVisible()
await expect
.poll(async () => {
const bounds = await sidebar.boundingBox()
const button = await status.boundingBox()
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
})
.toBe(true)
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
await expect(sidebar.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
await expect
.poll(async () => {
@@ -301,7 +293,7 @@ for (const count of [0, 26]) {
}
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
test(`vertical tabs scroll without the retired Status footer in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
@@ -334,38 +326,23 @@ for (const direction of ["ltr", "rtl"]) {
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(status).toHaveText("Status")
await expect(status).toHaveCount(0)
await expect(settings).toHaveCount(0)
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 360 })
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
await expect(status).toBeInViewport({ ratio: 1 })
await expect(status).toHaveCSS("height", "28px")
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(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
await scroll.evaluate((element) => element.scrollTo(0, 0))
await expect(scroll).toHaveJSProperty("scrollTop", 0)
const pinnedStatus = await status.boundingBox()
await scroll.hover()
await page.mouse.wheel(0, 200)
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await testInfo.attach(`vertical-tabs-status-${width}`, {
await expect(status).toHaveCount(0)
await testInfo.attach(`vertical-tabs-scroll-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
@@ -379,14 +356,9 @@ for (const direction of ["ltr", "rtl"]) {
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await expect(status).toHaveCount(0)
await expect(settings).toHaveCount(0)
}
await status.click()
await expect(status).toHaveAttribute("aria-expanded", "true")
await status.press("Escape")
await expect(status).toHaveAttribute("aria-expanded", "false")
})
}
+5 -10
View File
@@ -126,15 +126,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -326,7 +320,8 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -17,12 +17,14 @@ import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
import type { DraftMcpControls } from "./mcp"
export function createNewSessionComposerAdapter(props: {
draftID: string
worktree: () => string
branch: () => string | undefined
submitted: () => void
mcp: DraftMcpControls
}) {
const route = useSessionKey()
const prompt = useComposerState()
@@ -49,6 +51,7 @@ export function createNewSessionComposerAdapter(props: {
const projectDirectory = location().directory
const worktree = props.worktree()
const branch = props.branch()
const mcp = props.mcp.capture()
const id = Session.ID.create()
const pending =
worktree === "create"
@@ -68,6 +71,18 @@ export function createNewSessionComposerAdapter(props: {
return
}
const rollback = async () => {
if (!pending) return
data.project.invalidate()
await data.project.sync().catch(() => undefined)
await pending.rollback(sessionDirectory)
}
if (!(await props.mcp.prepare(sessionDirectory, mcp))) {
await rollback()
if (pending) props.mcp.remember(sessionDirectory, mcp)
return
}
const created = data.session.create({
id,
agent: selection.agent,
@@ -89,10 +104,7 @@ export function createNewSessionComposerAdapter(props: {
},
)
if (pending && !(await creation).ok) {
// Keep retries on the worktree that was already created, not another new checkout.
data.project.invalidate()
await data.project.sync().catch(() => undefined)
await pending.rollback(sessionDirectory)
await rollback()
return
}
const afterCreation = async <T>(run: () => Promise<T>) => {
+117
View File
@@ -0,0 +1,117 @@
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useServer } from "@/runtime/server/current"
import { useTabs } from "@/shell/tabs/tabs"
import { showToast } from "@/shell/notifications/toast"
import { useWorkspaceLocation } from "@/workspaces/location"
export function createDraftMcpControls(input: { draftID: string; worktree: () => string }) {
const tabs = useTabs()
const location = useWorkspaceLocation()
const server = useServer()
const sdk = useServerSDK()
const language = useLanguage()
const [store, setStore] = createStore<{
preparing: boolean
pending: Record<string, Promise<boolean> | undefined>
}>({ preparing: false, pending: {} })
const key = (worktree: string) => JSON.stringify([server.key, location().directory, worktree])
const target = createMemo(() => key(input.worktree()))
const preview = () => input.worktree() === "create"
const directory = createMemo(() => {
const selected = input.worktree()
return selected === "main" || selected === "create" ? location().directory : selected
})
const states = createMemo(() => {
const draft = tabs.store.find((tab) => tab.type === "draft" && tab.draftID === input.draftID)
return draft?.type === "draft" && draft.mcp?.target === target() ? draft.mcp.states : {}
})
const toggle = useMcpToggle(directory)
const controls: McpControls = {
get preview() {
return preview()
},
get states() {
return states()
},
get pending() {
return store.preparing || (!preview() && store.pending[directory()] !== undefined)
},
change(name, enabled) {
if (controls.pending) return
tabs.updateDraft(input.draftID, { mcp: { target: target(), states: { ...states(), [name]: enabled } } })
if (preview()) return
const current = directory()
const request = toggle.mutateAsync({ name, enabled, directory: current }).then(
() => true,
() => false,
)
setStore("pending", current, request)
void request.finally(() => setStore("pending", current, undefined))
},
}
const apply = async (directory: string, states: Readonly<Record<string, boolean>>) => {
const pending = store.pending[directory]
if (pending && !(await pending)) return false
const entries = Object.entries(states)
if (entries.length === 0) return true
const catalog = await sdk.api.mcp.list({ location: { directory } })
const missing = entries.find(([name, enabled]) => enabled && !catalog.data.some((server) => server.name === name))
if (missing) throw new Error(language.t("session.summary.mcp.unavailable", { name: missing[0] }))
const results = await Promise.all(
entries
.filter(([name, enabled]) => {
const server = catalog.data.find((server) => server.name === name)
return server && (enabled ? server.status.status !== "connected" : server.status.status !== "disabled")
})
.map(([name, enabled]) =>
toggle.mutateAsync({ name, enabled, directory }).then(
() => true,
() => false,
),
),
)
if (results.some((success) => !success)) return false
const current = await sdk.api.mcp.list({ location: { directory } })
const unresolved = entries.find(([name, enabled]) => {
const status = current.data.find((server) => server.name === name)?.status.status
return enabled ? status !== "connected" : status !== undefined && status !== "disabled"
})
if (!unresolved) return true
const status = current.data.find((server) => server.name === unresolved[0])?.status.status
throw new Error(
language.t(status === "needs_auth" ? "session.summary.mcp.signInBeforeSend" : "session.summary.mcp.notReady", {
name: unresolved[0],
}),
)
}
return {
controls,
directory,
capture: () => ({ ...states() }),
remember(directory: string, states: Readonly<Record<string, boolean>>) {
tabs.updateDraft(input.draftID, { mcp: { target: key(directory), states: { ...states } } })
},
async prepare(directory: string, states: Readonly<Record<string, boolean>>) {
if (!store.pending[directory] && !Object.keys(states).length) return true
setStore("preparing", true)
return apply(directory, states)
.catch((error) => {
showToast({
variant: "error",
title: language.t("session.summary.mcp.prepareFailed"),
description: error instanceof Error ? error.message : String(error),
})
return false
})
.finally(() => setStore("preparing", false))
},
}
}
export type DraftMcpControls = ReturnType<typeof createDraftMcpControls>
+5 -5
View File
@@ -1,19 +1,18 @@
import { createPromptProjectController } from "@/new-session/project/selector"
import { useSettingsDialog } from "@/settings/command"
import { useSettings } from "@/settings/model"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, untrack } from "solid-js"
import { createComposerModel } from "@/composer/model"
import { useComposerCommands } from "@/composer/commands"
import { createNewSessionComposerAdapter } from "./composer-adapter"
import { NewSessionStatus, NewSessionView } from "./view"
import { NewSessionView } from "./view"
import { createNewSessionWorkspaceController } from "./workspace/controller"
import { useNewSessionCommands } from "./commands"
import { createDraftMcpControls } from "./mcp"
/** The draft-only Session page. Submitting promotes the draft into a real Session. */
export default function NewSessionPage(props: { draftId: string }) {
const settings = useSettings()
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
@@ -31,11 +30,13 @@ export default function NewSessionPage(props: { draftId: string }) {
},
onViewAll: openWorkspaces,
})
const mcp = createDraftMcpControls({ draftID: props.draftId, worktree: workspace.selection.value })
const composer = createNewSessionComposerAdapter({
draftID: props.draftId,
worktree: workspace.selection.value,
branch: workspace.bar.branch,
submitted: workspace.selection.remember,
mcp,
})
const model = createComposerModel(composer.adapter)
useComposerCommands({ model: composer.model })
@@ -72,9 +73,8 @@ export default function NewSessionPage(props: { draftId: string }) {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
<NewSessionView composer={model} project={project} workspace={workspace} mcp={mcp} />
</div>
</div>
)
+54
View File
@@ -0,0 +1,54 @@
import { Icon } from "@opencode/ui/icon"
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { ProjectSummaryCard } from "@/session/summary/project-card"
import { SessionServerPanel } from "@/session/summary/server-panel"
import type { PromptProject } from "./project/selector"
import type { DraftMcpControls } from "./mcp"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { PromptWorkspaceSelector } from "./workspace/selector"
export function NewSessionSummary(props: {
project?: PromptProject
workspace: NewSessionWorkspaceController
mcp: DraftMcpControls
shown: boolean
onChooseProject: () => void
}) {
const language = useLanguage()
return (
<div data-component="session-summary-panel">
<Show
when={props.project}
fallback={
<div class="session-summary-card">
<button type="button" class="session-summary-row" onClick={props.onChooseProject}>
<Icon name="folder" class="text-v2-icon-icon-muted" />
{language.t("session.summary.chooseProject")}
</button>
</div>
}
>
{(project) => (
<>
<ProjectSummaryCard project={project()}>
<PromptWorkspaceSelector
variant="summary"
value={props.workspace.selection.value()}
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
branches={props.workspace.project.branches()}
branch={props.workspace.bar.branch()}
onChange={props.workspace.selection.set}
onCreate={props.workspace.selection.create}
onSearch={props.workspace.project.searchBranches}
onViewAll={props.workspace.project.openAll}
/>
</ProjectSummaryCard>
<SessionServerPanel directory={props.mcp.directory()} shown={props.shown} mcp={props.mcp.controls} />
</>
)}
</Show>
</div>
)
}
+30 -16
View File
@@ -1,7 +1,8 @@
import { useDialog } from "@opencode/ui/context/dialog"
import { Tooltip } from "@opencode/ui/tooltip"
import { Icon } from "@opencode/ui/icon"
import { Show, createMemo, createSignal } from "solid-js"
import { Show, Suspense, createMemo, createSignal, lazy } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
@@ -13,8 +14,6 @@ import {
PromptProjectSelector,
type PromptProjectController,
} from "@/new-session/project/selector"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
@@ -23,6 +22,13 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
import { SummaryPopover } from "@/session/summary/popover"
import type { DraftMcpControls } from "./mcp"
const NewSessionSummary = lazy(async () => {
const { NewSessionSummary } = await import("./summary")
return { default: NewSessionSummary }
})
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -40,7 +46,9 @@ export function NewSessionView(props: {
composer: ComposerModel
project: PromptProjectController
workspace: NewSessionWorkspaceController
mcp: DraftMcpControls
}) {
const [store, setStore] = createStore({ summary: false })
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
WorkspaceOnboardingSchema,
@@ -61,6 +69,25 @@ export function NewSessionView(props: {
active={props.composer.state.drag === "active"}
input={props.composer.model.selection.current()?.capabilities.input}
/>
<div
data-slot="new-session-summary"
class="absolute inset-x-0 top-0 z-20 flex h-12 items-center justify-end px-3"
>
<SummaryPopover open={store.summary} onOpenChange={(open) => setStore("summary", open)}>
<Suspense>
<NewSessionSummary
project={props.project.selected()}
workspace={props.workspace}
mcp={props.mcp}
shown={store.summary}
onChooseProject={() => {
setStore("summary", false)
props.project.add()
}}
/>
</Suspense>
</SummaryPopover>
</div>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<NewSessionWordmark />
@@ -115,19 +142,6 @@ export function NewSessionView(props: {
)
}
export function NewSessionStatus(props: { visible: boolean }) {
const language = useLanguage()
return (
<TitlebarRight>
<Show when={props.visible}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
)
}
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
const language = useLanguage()
const dialog = useDialog()
@@ -15,13 +15,18 @@ export function PromptWorkspaceSelector(props: {
branches: string[]
branch?: string
onboarding?: boolean
variant?: "inline" | "summary"
onChange: (value: string) => void
onCreate: (branch: string) => void
onSearch: (search: string) => void
onDone: () => void
onDone?: () => void
onViewAll: () => void
}) {
const language = useLanguage()
const summary = () => props.variant === "summary"
const placement = createMemo(() =>
summary() ? (language.direction() === "rtl" ? "right-start" : "left-start") : "bottom",
)
const [search, setSearch] = createStore({ workspaces: "", branches: "" })
let searchInput: HTMLInputElement | undefined
let branchSearchInput: HTMLInputElement | undefined
@@ -58,17 +63,20 @@ export function PromptWorkspaceSelector(props: {
props.onViewAll()
return
}
props.onDone()
props.onDone?.()
}
const label = () => {
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
if (selected() === "main")
return language.t(summary() ? "session.new.workspace.local" : "session.new.workspace.triggerLocal")
if (props.value === "create") return language.t("workspace.new")
return getFilename(props.value)
}
return (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<Show when={!summary()}>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
</Show>
<Tooltip
appearance={props.onboarding ? "large" : undefined}
placement="top"
@@ -87,18 +95,28 @@ export function PromptWorkspaceSelector(props: {
)
}
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
class="min-w-0"
class={summary() ? "min-w-0 w-full" : "min-w-0"}
>
<Menu placement="bottom" gutter={4} overflowPadding={24} onOpenChange={onOpenChange}>
<Menu
placement={placement()}
gutter={4}
overflowPadding={24}
modal={summary() ? false : undefined}
onOpenChange={onOpenChange}
>
<Menu.Trigger
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
class={
summary()
? "session-summary-row"
: "flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
}
>
<Icon
name={icon()}
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
class={`shrink-0 ${summary() || selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<span class="min-w-0 truncate">{label()}</span>
<span class={summary() ? "session-summary-label" : "min-w-0 truncate"}>{label()}</span>
<Show when={props.onboarding}>
<span
data-slot="workspace-onboarding-dot"
@@ -106,7 +124,11 @@ export function PromptWorkspaceSelector(props: {
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
/>
</Show>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<Icon
name={summary() ? "fill-triangle-down" : "chevron-down"}
size={summary() ? "normal" : "small"}
class="shrink-0 text-v2-icon-icon-muted"
/>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-[200px]">
@@ -233,22 +255,47 @@ export function PromptWorkspaceSelector(props: {
</Tooltip>
<Show
when={selected() === "create" && props.branch}
fallback={<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />}
fallback={
summary() ? (
<Show when={props.branch}>
<div class="session-summary-row">
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<span dir="auto" class="session-summary-label">
{props.branch}
</span>
</div>
</Show>
) : (
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
)
}
>
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
disabled={!branchTruncation.truncated()}
class="ms-1 min-w-0 max-w-[220px]"
class={summary() ? "min-w-0 w-full" : "ms-1 min-w-0 max-w-[220px]"}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
<Menu placement={placement()} gutter={4} modal={summary() ? false : undefined} onOpenChange={onOpenChange}>
<Menu.Trigger
class={
summary()
? "session-summary-row"
: "flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted"
}
>
<Icon name="branch-out" size={summary() ? "normal" : "small"} class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class={summary() ? "session-summary-label" : "min-w-0 truncate"}>
{language.t(summary() ? "session.summary.basedOn" : "session.new.workspace.fromBranch", {
branch: props.branch!,
})}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<Icon
name={summary() ? "fill-triangle-down" : "chevron-down"}
size={summary() ? "normal" : "small"}
class="shrink-0 text-v2-icon-icon-muted"
/>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content
+26 -14
View File
@@ -6,6 +6,13 @@ import { useServerSDK } from "@/runtime/server/client"
import { usePlatform } from "@/runtime/platform/platform"
import { showToast } from "@/shell/notifications/toast"
export type McpControls = {
readonly preview: boolean
readonly states: Readonly<Record<string, boolean>>
readonly pending: boolean
change: (name: string, enabled: boolean) => void
}
export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => unknown) {
const data = useData()
const serverSDK = useServerSDK()
@@ -17,31 +24,36 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
}
return useMutation(() => ({
mutationFn: async (name: string) => {
const ref = location()
mutationFn: async (input: string | { name: string; enabled: boolean; directory?: string }) => {
const name = typeof input === "string" ? input : input.name
const ref = typeof input !== "string" && input.directory ? { directory: input.directory } : location()
const server = (await serverSDK.api.mcp.list({ location: ref })).data.find((item) => item.name === name)
if (!server || server.status.status === "pending") return
if (server.status.status === "connected") {
if (!server || (server.status.status === "pending" && typeof input === "string")) return
const enabled = typeof input === "string" ? server.status.status !== "connected" : input.enabled
if (!enabled) {
await serverSDK.api.mcp.disconnect({ server: name, location: ref })
} else if (server.status.status === "needs_auth" && server.integrationID) {
const integration = await serverSDK.api.integration.get({ integrationID: server.integrationID, location: ref })
}
if (enabled && server.status.status !== "needs_auth") {
await serverSDK.api.mcp.connect({ server: name, location: ref })
}
data.location.mcp.server.invalidate(ref)
await data.location.mcp.server.sync(ref)
const current = data.location.mcp.server.list(ref)?.find((item) => item.name === name)
if (enabled && current?.status.status === "needs_auth" && current.integrationID) {
const integration = await serverSDK.api.integration.get({ integrationID: current.integrationID, location: ref })
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`)
if (!method || method.type !== "oauth") throw new Error(language.t("mcp.auth.interactiveForm", { name }))
const attempt = await serverSDK.api.integration.oauth.connect({
integrationID: server.integrationID,
integrationID: current.integrationID,
methodID: method.id,
location: ref,
})
platform.openExternal(attempt.data.url)
} else {
await serverSDK.api.mcp.connect({ server: name, location: ref })
}
data.location.mcp.server.invalidate(ref)
data.location.mcp.resource.invalidate(ref)
await Promise.all([data.location.mcp.server.sync(ref), data.location.mcp.resource.sync(ref), onSuccess?.()])
await Promise.all([data.location.mcp.resource.sync(ref), onSuccess?.()])
// A successful HTTP response can still leave the MCP connection in a failed state.
const status = data.location.mcp.server.list(ref)?.find((item) => item.name === name)?.status
const status = current?.status
if (status?.status === "failed") throw new Error(`${name}: ${status.error}`)
},
onError: (error) =>
+27
View File
@@ -387,6 +387,7 @@ export const dict = {
"mcp.status.needs_auth": "needs auth",
"mcp.status.disabled": "disabled",
"mcp.auth.clickToAuthenticate": "Click to authenticate",
"mcp.auth.interactiveForm": "MCP server {{name}} requires an interactive authentication form",
"dialog.fork.empty": "No messages to fork from",
@@ -1385,6 +1386,32 @@ export const dict = {
"session.summary.title": "Session details",
"session.summary.noBranch": "No branch",
"session.summary.basedOn": "Based on {{branch}}",
"session.summary.server": "Server",
"session.summary.chooseProject": "Choose a project",
"session.summary.mcp.onCreation": "Applies when the worktree is created",
"session.summary.mcp.prepareFailed": "Could not prepare MCP servers",
"session.summary.mcp.unavailable": "MCP server {{name}} is not available in this worktree.",
"session.summary.mcp.signInBeforeSend": "Sign in to {{name}} before sending the prompt.",
"session.summary.mcp.notReady": "MCP server {{name}} is not ready. Resolve its connection before sending the prompt.",
"session.summary.mcp": "MCP",
"session.summary.plugins": "Plugins",
"session.summary.skills": "Skills",
"session.summary.lsp": "LSP",
"session.summary.failed": "Failed",
"session.summary.retry": "Retry",
"session.summary.connecting": "Connecting…",
"session.summary.needsAuth": "Sign in required",
"session.summary.mcp.empty": "No MCP servers configured yet",
"session.summary.mcp.add": "Add servers in opencode.json",
"session.summary.plugins.manage": "Manage plugins in opencode.json",
"session.summary.plugins.empty": "No plugins configured yet",
"session.summary.plugins.add": "Add plugins in opencode.json",
"session.summary.skills.manage": "Manage skills in opencode.json",
"session.summary.skills.empty": "No skills configured yet",
"session.summary.skills.add": "Add skills in opencode.json",
"session.summary.lsp.configured": "Configured LSPs",
"session.summary.lsp.empty": "No LSP servers explicitly configured",
"session.summary.lsp.manage": "Manage LSP in opencode.json",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Failed to create worktree",
@@ -1,29 +1,12 @@
import { Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { Tooltip } from "@opencode/ui/tooltip"
export function SessionHeader(props: { reserveReviewToggle: boolean }) {
const language = useLanguage()
const settings = useSettings()
import { Show } from "solid-js"
export function SessionHeaderSpacer(props: { visible: boolean }) {
const isDesktop = createMediaQuery("(min-width: 768px)")
return (
<>
<TitlebarRight>
<Show when={isDesktop() && settings.visibility.status()}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
<Show when={isDesktop() && props.reserveReviewToggle}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</>
<Show when={isDesktop() && props.visible}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
)
}
+3 -24
View File
@@ -15,11 +15,6 @@ import { SessionReviewTab } from "./review-tab"
import type { ChangeMode, SessionReviewModel } from "./model"
import type { createSessionBrowser } from "../browser/model"
const StatusDrawer = lazy(async () => {
const { StatusDrawer } = await import("@/shell/status/status-drawer")
return { default: StatusDrawer }
})
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
@@ -34,11 +29,9 @@ export function SessionMobileViewTabs(props: {
const language = useLanguage()
const [store, setStore] = createStore({
menu: false,
status: false,
statusLoaded: false,
details: false,
detailsLoaded: false,
pending: undefined as "status" | "details" | undefined,
pending: false,
})
createEffect(() => props.onDetailsOpenChange?.(store.details))
onCleanup(() => props.onDetailsOpenChange?.(false))
@@ -95,32 +88,18 @@ export function SessionMobileViewTabs(props: {
onCloseAutoFocus={(event) => {
if (!store.pending) return
event.preventDefault()
if (store.pending === "status") setStore({ status: true, statusLoaded: true })
if (store.pending === "details") setStore({ details: true, detailsLoaded: true })
setStore("pending", undefined)
setStore({ details: true, detailsLoaded: true, pending: false })
}}
>
<Menu.Item onSelect={() => props.onSelect("usage")}>{language.t("session.tab.usage")}</Menu.Item>
<Show when={props.details}>
<Menu.Item onSelect={() => setStore({ pending: "details", menu: false })}>
<Menu.Item onSelect={() => setStore({ pending: true, menu: false })}>
{language.t("session.summary.title")}
</Menu.Item>
</Show>
<Menu.Item onSelect={() => setStore({ pending: "status", menu: false })}>
{language.t("status.popover.trigger")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={store.statusLoaded}>
<Suspense>
<StatusDrawer
open={store.status}
onOpenChange={(open) => setStore("status", open)}
returnFocus={() => trigger}
/>
</Suspense>
</Show>
<Show when={store.detailsLoaded}>
<Suspense>
<MobilePanelDrawer
+6 -1
View File
@@ -12,7 +12,7 @@ import {
} from "solid-js"
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode/ui/resize-handle"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { MessageTimeline } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
import { ComposerDropzone } from "@/composer/dropzone"
@@ -41,6 +41,11 @@ const SessionMobileFiles = lazy(async () => {
return { default: SessionMobileFiles }
})
const SessionSummaryPanel = lazy(async () => {
const { SessionSummaryPanel } = await import("./summary/panel")
return { default: SessionSummaryPanel }
})
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const server = useServer()
@@ -0,0 +1,89 @@
import { Popover } from "@kobalte/core/popover"
import { useData } from "@opencode/session-ui/context"
import { Icon } from "@opencode/ui/icon"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { createEffect, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useLanguage } from "@/runtime/i18n/language"
export type BackgroundTask = {
id: string
type: "shell" | "subagent"
label: string
agent?: string
}
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
const language = useLanguage()
const data = useData()
const [store, setStore] = createStore({ open: false })
createEffect(() => {
if (props.tasks.length > 0) return
setStore("open", 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")
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
}
return (
<Popover
open={store.open}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
gutter={4}
onOpenChange={(open) => setStore("open", open)}
>
<Show when={props.tasks.length > 0}>
<Popover.Trigger
as="button"
type="button"
data-component="session-background-summary"
class="session-summary-row"
aria-label={language.plural("session.background.tasksRunning", props.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", props.tasks.length)}
active
class="session-summary-label"
/>
</Popover.Trigger>
</Show>
<Popover.Portal>
<Popover.Content
data-component="session-background-list"
class="session-service-menu"
aria-label={language.plural("session.background.tasksRunning", props.tasks.length)}
>
<For each={props.tasks.slice(0, 10)}>
{(task) => (
<Dynamic
component={task.type === "subagent" ? "a" : "div"}
data-component="session-background-list-item"
class="session-service-row"
classList={{
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
task.type === "subagent",
}}
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
onClick={(event: MouseEvent) => {
if (task.type !== "subagent" || !data.navigateToSession) return
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
event.preventDefault()
setStore("open", false)
data.navigateToSession(task.id)
}}
>
<span class="shrink-0">{taskType(task)}</span>
<span class="session-summary-label text-v2-text-text-faint">{task.label}</span>
</Dynamic>
)}
</For>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
@@ -0,0 +1,38 @@
import { expect, test } from "bun:test"
import { configuredLsps } from "./configured-lsp"
test("lists configured LSP names with project overrides and no inferred built-ins", () => {
expect(
configuredLsps([
{
type: "document",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"] },
rust: { command: ["rust-analyzer"] },
},
},
},
{
type: "document",
info: {
lsp: {
typescript: { disabled: true },
eslint: { command: ["vscode-eslint-language-server", "--stdio"] },
},
},
},
]),
).toEqual(["eslint", "rust"])
expect(configuredLsps([{ type: "document", info: { lsp: true } }])).toEqual([])
})
test("a later whole-LSP setting clears earlier names", () => {
expect(
configuredLsps([
{ type: "document", info: { lsp: { rust: { command: ["rust-analyzer"] } } } },
{ type: "document", info: { lsp: false } },
{ type: "document", info: { lsp: { custom: { command: ["custom-lsp"] } } } },
]),
).toEqual(["custom"])
})
@@ -0,0 +1,17 @@
import type { ConfigEntry } from "@opencode/client"
export function configuredLsps(entries: readonly ConfigEntry[]) {
return entries
.reduce<string[]>((names, entry) => {
if (entry.type !== "document" || entry.info.lsp === undefined) return names
const lsp = entry.info.lsp
if (typeof lsp === "boolean") return []
return [
...names.filter((name) => !Object.hasOwn(lsp, name)),
...Object.entries(lsp)
.filter(([, server]) => !server.disabled)
.map(([name]) => name),
]
}, [])
.toSorted((a, b) => a.localeCompare(b))
}
+130
View File
@@ -0,0 +1,130 @@
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Icon } from "@opencode/ui/icon"
import { getFilename } from "@opencode/util/path"
import { createMemo, Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import type { Project } from "@/runtime/server/types"
import { useSettings } from "@/settings/model"
import { containsDirectory, workspaceDirectories } from "@/workspaces/paths"
import { SessionWorkspaceMenu } from "../timeline/session-workspace-menu"
import { BackgroundWorkSummary, type BackgroundTask } from "./background"
import { SessionServerPanel } from "./server-panel"
import { ProjectSummaryCard } from "./project-card"
import "./summary.css"
export function SessionSummaryPanel(props: {
shown?: boolean
mobile?: boolean
project: Project
avatar?: JSX.Element
directory: string
local: boolean
branch?: string
baseBranch?: string
diffs?: { additions: number; deletions: number }[]
sessionID: string
moveEligible: boolean
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
backgroundTasks: BackgroundTask[]
}) {
const language = useLanguage()
const settings = useSettings()
const expanded = settings.sessionSummary.projectExpanded
const placement = createMemo(() =>
props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start",
)
const location = () => {
if (props.local) return language.t("session.new.workspace.local")
const workspace = workspaceDirectories(props.project).find((item) => containsDirectory(item, props.directory))
return getFilename(workspace ?? props.directory)
}
return (
<div data-component="session-summary-panel" data-mobile={props.mobile || undefined}>
<div>
<ProjectSummaryCard project={props.project} avatar={props.avatar}>
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={placement()}
gutter={4}
class="session-summary-row"
>
<Icon name={props.local ? "monitor" : "outline-worktree"} class="shrink-0 text-v2-icon-icon-muted" />
<span dir="auto" class="session-summary-label">
{location()}
</span>
<Icon name="fill-triangle-down" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<div class="session-summary-row">
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<Show
when={props.branch}
fallback={
<span class="flex min-w-0 items-center gap-1.5">
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
<Show when={props.baseBranch}>
{(base) => (
<>
<span class="text-v2-text-text-muted">·</span>
<span class="truncate text-v2-text-text-faint">
{language.t("session.summary.basedOn", { branch: base() })}
</span>
</>
)}
</Show>
</span>
}
>
<span dir="auto" class="min-w-0 truncate">
{props.branch}
</span>
</Show>
</div>
<button type="button" class="session-summary-row" onClick={props.onReview}>
<Icon name="review" class="shrink-0 text-v2-icon-icon-muted" />
<Show when={props.diffs} fallback={<span>{language.t("session.review.loadingChanges")}</span>}>
{(diffs) => (
<Show when={diffs().length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
<span>{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}</span>
<span class="text-v2-text-text-muted">·</span>
<DiffChanges appearance="standard" changes={diffs()} />
</Show>
)}
</Show>
</button>
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
</ProjectSummaryCard>
<Show when={expanded() && props.local && props.diffs?.length && props.moveEligible && !props.moveDismissed}>
<div class="session-summary-move">
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={placement()}
gutter={4}
class="session-summary-row"
>
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
type="button"
class="session-summary-dismiss"
aria-label={language.t("common.dismiss")}
onClick={props.onMoveDismiss}
>
<Icon name="xmark-small" />
</button>
</div>
</Show>
</div>
<SessionServerPanel directory={props.directory} shown={props.shown !== false} mobile={props.mobile} />
</div>
)
}
@@ -0,0 +1,31 @@
import { Popover } from "@kobalte/core/popover"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import type { ParentProps } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
export function SummaryPopover(props: ParentProps<{ open: boolean; onOpenChange: (open: boolean) => void }>) {
const language = useLanguage()
return (
<Popover open={props.open} placement="bottom-end" gutter={2} overflowPadding={16} onOpenChange={props.onOpenChange}>
<Popover.Anchor class="pointer-events-none absolute end-3 top-0 h-12 w-0" aria-hidden="true" />
<Popover.Trigger
as={IconButton}
icon={<Icon name="window-analytics" />}
variant="ghost-muted"
size="large"
state={props.open ? "pressed" : undefined}
aria-label={language.t("session.summary.title")}
aria-expanded={props.open}
/>
<Popover.Portal>
<Popover.Content
class="z-50 max-h-[calc(100dvh-96px)] overflow-y-auto border-0 bg-transparent p-1 outline-none"
aria-label={language.t("session.summary.title")}
>
{props.children}
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
@@ -0,0 +1,50 @@
import { Icon } from "@opencode/ui/icon"
import { ProjectAvatar } from "@opencode/ui/project-avatar"
import { createUniqueId, Show, type ParentProps, type JSX } from "solid-js"
import type { Project } from "@/runtime/server/types"
import { useSettings } from "@/settings/model"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import "./summary.css"
export function ProjectSummaryCard(
props: ParentProps<{
project: Pick<Project, "name" | "worktree" | "icon"> & { id?: string }
avatar?: JSX.Element
}>,
) {
const settings = useSettings()
const contentID = createUniqueId()
const expanded = settings.sessionSummary.projectExpanded
return (
<section class="session-summary-card" data-section="project">
<button
type="button"
class="session-summary-row session-summary-heading"
aria-label={displayName(props.project)}
aria-expanded={expanded()}
aria-controls={contentID}
onClick={() => settings.sessionSummary.setProjectExpanded(!expanded())}
>
{props.avatar ?? (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
)}
<span class="session-summary-heading-label">
<span dir="auto" class="min-w-0 truncate">
{displayName(props.project)}
</span>
<Icon name="fill-triangle-down" class="session-summary-disclosure" />
</span>
</button>
<Show when={expanded()}>
<div id={contentID} class="session-summary-rows">
{props.children}
</div>
</Show>
</section>
)
}
@@ -0,0 +1,441 @@
import { Popover } from "@kobalte/core/popover"
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import {
createEffect,
createMemo,
createResource,
createUniqueId,
For,
Index,
on,
onCleanup,
Show,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useData, useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useGlobal } from "@/runtime/server/runtime"
import { useSettings } from "@/settings/model"
import { pluginLabel } from "@/providers/catalog/plugin"
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
import { configuredLsps } from "./configured-lsp"
const services = [
{ type: "mcp", icon: "mcp", label: "session.summary.mcp" },
{ type: "plugins", icon: "cube", label: "session.summary.plugins" },
{ type: "skills", icon: "post-skill", label: "session.summary.skills" },
{ type: "lsp", icon: "code", label: "session.summary.lsp" },
] as const
type Service = (typeof services)[number]["type"]
type ServiceMenuProps = {
service: (typeof services)[number]
directory: string
shown: boolean
open: boolean
mobile?: boolean
mcp?: McpControls
onOpenChange: (open: boolean) => void
}
export function SessionServerPanel(props: { directory: string; shown: boolean; mobile?: boolean; mcp?: McpControls }) {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const settings = useSettings()
const contentID = createUniqueId()
const expanded = settings.sessionSummary.serverExpanded
const name = createMemo(() => {
const servers = global.servers.list()
if (servers.length < 2) return language.t("session.summary.server")
return serverName(servers.find((connection) => ServerConnection.key(connection) === server.key) ?? server.conn)
})
const [store, setStore] = createStore<{ submenu?: Service }>({})
createEffect(on([() => props.directory, () => props.shown, expanded], () => setStore("submenu", undefined)))
return (
<section class="session-summary-card" data-section="server">
<button
type="button"
class="session-summary-row session-summary-heading"
aria-expanded={expanded()}
aria-controls={contentID}
onClick={() => settings.sessionSummary.setServerExpanded(!expanded())}
>
<Icon name="server" class="shrink-0 text-v2-icon-icon-muted" />
<span class="session-summary-heading-label">
<span dir="auto" class="min-w-0 truncate">
{name()}
</span>
<Icon name="fill-triangle-down" class="session-summary-disclosure" />
</span>
</button>
<Show when={expanded() ? props.directory : undefined} keyed>
{(directory) => (
<div id={contentID} class="session-summary-rows">
<For each={services}>
{(service) => (
<ServiceMenu
service={service}
directory={directory}
shown={props.shown}
open={store.submenu === service.type}
mobile={props.mobile}
mcp={props.mcp}
onOpenChange={(open) => setStore("submenu", open ? service.type : undefined)}
/>
)}
</For>
</div>
)}
</Show>
</section>
)
}
function ServiceMenu(props: ServiceMenuProps) {
if (props.service.type === "mcp") return <McpMenu {...props} />
if (props.service.type === "lsp") return <LspMenu {...props} />
return <ServiceCatalog {...props} />
}
function LspMenu(props: ServiceMenuProps) {
const data = useData()
const sdk = useServerSDK()
const language = useLanguage()
const [load, { refetch }] = createResource(
() => props.shown && props.directory,
(directory) => {
data.location.config.invalidate({ directory })
return data.location.config.sync({ directory })
},
)
const names = createMemo(() => configuredLsps(data.location.config.list({ directory: props.directory }) ?? []))
createEffect(() => {
onCleanup(sdk.event.location(props.directory).on("config.updated", () => void refetch()))
})
return (
<ServicePopover
{...props}
loading={load.loading}
ready={data.location.config.list({ directory: props.directory }) !== undefined}
empty={names().length === 0}
error={load.error}
retry={refetch}
>
<Show
when={names().length}
fallback={
<ServiceEmpty
title={language.t("session.summary.lsp.empty")}
description={language.t("session.summary.lsp.manage")}
/>
}
>
<div class="session-service-message">{language.t("session.summary.lsp.configured")}</div>
<For each={names()}>
{(name) => (
<div class="session-service-row">
<span dir="auto" class="session-summary-label">
{name}
</span>
</div>
)}
</For>
<div class="session-service-message">{language.t("session.summary.lsp.manage")}</div>
</Show>
</ServicePopover>
)
}
function McpMenu(props: ServiceMenuProps) {
const data = useData()
const language = useLanguage()
const toggle = useMcpToggle(() => props.directory)
const [load, { refetch }] = createResource(
() => props.shown && ([props.directory, props.mcp?.preview] as const),
async ([directory, preview]) => {
data.location.mcp.server.invalidate({ directory })
await Promise.all([
data.location.mcp.server.sync({ directory }),
...(preview ? [data.location.config.sync({ directory })] : []),
])
},
)
const servers = createMemo(() =>
(data.location.mcp.server.list({ directory: props.directory }) ?? []).toSorted((a, b) =>
a.name.localeCompare(b.name),
),
)
const defaults = createMemo(() =>
Object.fromEntries(
(data.location.config.list({ directory: props.directory }) ?? []).flatMap((entry) =>
entry.type === "document"
? Object.entries(entry.info.mcp?.servers ?? {}).map(([name, config]) => [name, !config.disabled] as const)
: [],
),
),
)
return (
<ServicePopover
{...props}
loading={load.loading}
ready={
data.location.mcp.server.list({ directory: props.directory }) !== undefined &&
(!props.mcp?.preview || data.location.config.list({ directory: props.directory }) !== undefined)
}
empty={servers().length === 0}
error={load.error}
retry={refetch}
>
<Show
when={servers().length}
fallback={
<ServiceEmpty
title={language.t("session.summary.mcp.empty")}
description={language.t("session.summary.mcp.add")}
/>
}
>
<Show when={props.mcp?.preview}>
<div class="session-service-message" data-slot="mcp-preview-hint">
{language.t("session.summary.mcp.onCreation")}
</div>
</Show>
<Index each={servers()}>
{(server) => {
const preview = () => props.mcp?.preview === true
const enabled = () =>
preview()
? (props.mcp?.states[server().name] ?? defaults()[server().name] ?? true)
: server().status.status !== "disabled"
const pending = () =>
(props.mcp?.pending ?? toggle.isPending) || (!preview() && server().status.status === "pending")
const error = () => {
const status = server().status
return status.status === "failed" ? status.error : undefined
}
const label = () => {
if (preview()) return undefined
const status = server().status.status
if (status === "failed") return language.t("session.summary.failed")
if (status === "pending") return language.t("session.summary.connecting")
if (status === "needs_auth") return language.t("session.summary.needsAuth")
return undefined
}
const change = (value: boolean) => {
if (pending()) return
if (props.mcp) return props.mcp.change(server().name, value)
toggle.mutate({ name: server().name, enabled: value })
}
return (
<Switch
class="session-mcp-row [&_[data-slot=switch-description]]:sr-only"
description={preview() ? language.t("session.summary.mcp.onCreation") : label()}
checked={enabled()}
readOnly={pending()}
aria-disabled={pending()}
aria-busy={props.mcp?.pending ?? toggle.isPending}
onChange={change}
onClick={(event: MouseEvent) => {
if (event.target === event.currentTarget) change(!enabled())
}}
title={preview() ? server().name : (error() ?? server().name)}
>
<span
class="session-service-dot"
data-status={preview() ? undefined : server().status.status}
aria-hidden="true"
/>
<span dir="auto" class="session-summary-label">
{server().name}
</span>
<Show when={label()}>
{(status) => (
<span class="session-service-status" aria-hidden="true">
{status()}
</span>
)}
</Show>
</Switch>
)
}}
</Index>
</Show>
</ServicePopover>
)
}
function ServiceCatalog(props: ServiceMenuProps) {
const data = useData()
const sdk = useServerSDK()
const language = useLanguage()
const [items, { refetch }] = createResource(
() => props.shown && props.directory,
async (directory) => {
if (props.service.type === "plugins") {
const result = await sdk.api.plugin.list({ location: { directory } })
return result.data
.filter((plugin) => plugin.source.type !== "builtin")
.map((plugin) => ({
name: pluginLabel(plugin),
status: plugin.state.status,
error: plugin.state.status === "failed" ? plugin.state.error : undefined,
}))
}
data.location.skill.invalidate({ directory })
await data.location.skill.sync({ directory })
return undefined
},
)
const loaded = () => items.state === "ready" || items.state === "refreshing"
const list = createMemo(() => {
const entries =
props.service.type === "plugins"
? loaded()
? (items.latest ?? [])
: []
: (data.location.skill.list({ directory: props.directory }) ?? []).map((skill) => ({
name: skill.name,
status: "active",
error: undefined,
}))
return entries.toSorted((a, b) => a.name.localeCompare(b.name))
})
createEffect(() => {
onCleanup(
sdk.event
.location(props.directory)
.on(props.service.type === "plugins" ? "plugin.updated" : "skill.updated", () => void refetch()),
)
})
return (
<ServicePopover
{...props}
loading={items.loading}
ready={
props.service.type === "plugins"
? loaded()
: data.location.skill.list({ directory: props.directory }) !== undefined
}
empty={list().length === 0}
error={items.error}
retry={refetch}
>
<Show
when={list().length}
fallback={
<ServiceEmpty
title={language.t(
props.service.type === "plugins" ? "session.summary.plugins.empty" : "session.summary.skills.empty",
)}
description={language.t(
props.service.type === "plugins" ? "session.summary.plugins.add" : "session.summary.skills.add",
)}
/>
}
>
<div class="session-service-message">
{language.t(
props.service.type === "plugins" ? "session.summary.plugins.manage" : "session.summary.skills.manage",
)}
</div>
<For each={list()}>
{(item) => (
<div class="session-service-row" title={item.error ?? item.name}>
<span class="session-service-dot" data-status={item.status} aria-hidden="true" />
<span dir="auto" class="session-summary-label">
{item.name}
</span>
<Show when={item.status === "failed"}>
<span class="session-service-status">{language.t("session.summary.failed")}</span>
</Show>
</div>
)}
</For>
</Show>
</ServicePopover>
)
}
function ServicePopover(
props: ServiceMenuProps & {
loading: boolean
ready: boolean
empty: boolean
error: unknown
retry: () => unknown
children: JSX.Element
},
) {
const language = useLanguage()
const placement = createMemo(() =>
props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start",
)
return (
<Popover
open={props.open}
onOpenChange={(open) => {
props.onOpenChange(open)
if (open && !props.loading) void props.retry()
}}
placement={placement()}
gutter={4}
overflowPadding={16}
modal={false}
>
<Popover.Trigger as="button" type="button" class="session-summary-row">
<Icon name={props.service.icon} class="shrink-0 text-v2-icon-icon-muted" />
<span class="session-summary-label">{language.t(props.service.label)}</span>
<Icon name="fill-triangle-down" class="shrink-0 text-v2-icon-icon-muted" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
class="session-service-menu"
data-service={props.service.type}
data-empty={(props.ready && !props.error && props.empty) || undefined}
aria-busy={props.loading}
aria-label={language.t(props.service.label)}
>
<Show
when={props.ready || !props.loading}
fallback={
<div class="session-service-message" role="status">
{language.t("common.loading")}
</div>
}
>
<Show
when={!props.error}
fallback={
<div class="session-service-message" role="alert">
<p>{language.t("common.requestFailed")}</p>
<button type="button" class="session-summary-row" onClick={() => props.retry()}>
{language.t("session.summary.retry")}
</button>
</div>
}
>
{props.children}
</Show>
</Show>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
function ServiceEmpty(props: { title: string; description: string }) {
return (
<div class="session-service-empty">
<strong>{props.title}</strong>
<p>{props.description}</p>
</div>
)
}
@@ -0,0 +1,221 @@
[data-component="session-summary-panel"] {
display: flex;
flex-direction: column;
gap: 8px;
width: 280px;
max-width: calc(100vw - 32px);
&[data-mobile] {
width: 100%;
max-width: none;
}
}
.session-summary-card {
position: relative;
z-index: 1;
padding: 4px 2px;
border-radius: 6px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
}
[data-color-scheme="dark"] .session-summary-card {
background: var(--v2-background-bg-layer-01);
}
.session-summary-rows {
display: flex;
flex-direction: column;
}
.session-summary-row,
.session-service-row,
[data-component="switch"].session-mcp-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 32px;
padding: 4px 12px;
border-radius: 4px;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
text-align: start;
}
.session-summary-row:focus-visible,
.session-mcp-row:focus-within {
outline: none;
background: var(--v2-overlay-simple-overlay-hover);
}
@media (hover: hover) {
button.session-summary-row:hover,
.session-mcp-row:not([data-disabled]):hover {
background: var(--v2-overlay-simple-overlay-hover);
}
}
.session-summary-row[data-expanded] {
background: var(--v2-overlay-simple-overlay-pressed);
}
.session-summary-heading {
color: var(--v2-text-text-muted);
font-weight: 530;
}
.session-summary-heading-label {
display: flex;
align-items: center;
min-width: 0;
gap: 4px;
}
.session-summary-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: start;
}
.session-summary-disclosure {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.session-summary-heading[aria-expanded="false"] .session-summary-disclosure {
transform: rotate(-90deg);
}
.session-summary-heading[aria-expanded="false"]:dir(rtl) .session-summary-disclosure {
transform: rotate(90deg);
}
.session-service-menu {
z-index: 60;
width: 280px;
max-width: calc(100vw - 32px);
max-height: min(480px, calc(100dvh - 32px));
overflow-y: auto;
overscroll-behavior: contain;
padding: 4px 2px;
border-radius: 6px;
background: var(--v2-background-bg-layer-01);
box-shadow: var(--v2-elevation-floating);
outline: none;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-base);
transform-origin: var(--kb-popover-content-transform-origin);
animation: menu-v2-in 120ms ease-out;
&[data-empty] {
width: 200px;
padding: 0;
}
}
.session-service-empty {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 12px;
color: var(--v2-text-text-faint);
font-size: 11px;
font-weight: 440;
line-height: var(--line-height-compact);
overflow-wrap: anywhere;
strong {
font-weight: 530;
}
}
.session-service-message {
padding: 6px 12px;
color: var(--v2-text-text-faint);
overflow-wrap: anywhere;
}
.session-service-dot {
width: 6px;
height: 6px;
flex-shrink: 0;
border-radius: 50%;
background: var(--v2-icon-icon-faint);
}
.session-service-dot[data-status="connected"],
.session-service-dot[data-status="active"] {
background: var(--icon-success-base);
}
.session-service-dot[data-status="failed"] {
background: var(--icon-critical-base);
}
.session-service-dot[data-status="needs_auth"] {
background: var(--icon-warning-base);
}
[data-component="switch"].session-mcp-row [data-slot="switch-label"] {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
height: auto;
align-self: stretch;
color: inherit;
font: inherit;
letter-spacing: inherit;
}
.session-service-status {
flex-shrink: 0;
font-size: 11px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-faint);
}
.session-mcp-row[aria-disabled="true"] [data-slot="switch-control"] {
opacity: 0.5;
}
.session-summary-move {
position: relative;
padding-top: 6px;
margin-top: -6px;
border-radius: 0 0 6px 6px;
background: var(--v2-background-bg-layer-02);
}
.session-summary-move > .session-summary-row {
padding-inline-end: 36px;
}
.session-summary-dismiss {
position: absolute;
inset-inline-end: 8px;
bottom: 6px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--v2-icon-icon-muted);
border-radius: 4px;
}
.session-summary-dismiss:hover,
.session-summary-dismiss:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
@media (max-width: 767px) {
[data-component="session-summary-panel"] .session-summary-row,
.session-service-menu .session-service-row,
.session-service-menu .session-mcp-row {
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.session-service-menu {
animation: none;
}
}
@@ -1,5 +1,7 @@
import { DataProvider } from "@opencode/session-ui/context"
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
import { BackgroundMoveHint } from "./message-timeline"
import { BackgroundWorkSummary } from "../summary/background"
import "../summary/summary.css"
const tasks = [
{ id: "task_explore", type: "subagent" as const, agent: "explore", label: "Reviewing component implementation" },
@@ -1,11 +1,19 @@
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js"
import {
createEffect,
createMemo,
createSignal,
lazy,
on,
onCleanup,
Show,
Suspense,
type Accessor,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import type { SessionUserActions } from "@opencode/session-ui/actions"
import { useData } from "@opencode/session-ui/context"
import { Button } from "@opencode/ui/button"
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { InlineInput } from "@opencode/ui/inline-input"
@@ -13,34 +21,30 @@ import { Keybind } from "@opencode/ui/keybind"
import { Menu } from "@opencode/ui/menu"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { ProjectAvatar } from "@opencode/ui/project-avatar"
import type { Project } from "@/runtime/server/types"
import { getFilename } from "@opencode/util/path"
import { Popover } from "@kobalte/core/popover"
import { SummaryPopover } from "../summary/popover"
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
import { useLanguage } from "@/runtime/i18n/language"
import { useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Timeline, TimelineRow } from "@opencode/session-ui/timeline/projection"
import { Timeline } from "@opencode/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode/session-ui/timeline/row"
import { getReadyMarkdown, preloadMarkdown } from "@opencode/session-ui/markdown-cache"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { createTimelineVirtualizer } from "./virtualizer"
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/workspaces/paths"
import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu"
import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { useCommand } from "@/shell/commands/command"
import { useSettings } from "@/settings/model"
import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionHeader } from "@/session/header/session-header"
import { SessionHeaderSpacer } from "@/session/header/session-header"
import type { BackgroundTask } from "../summary/background"
type BackgroundTask = {
id: string
type: "shell" | "subagent"
label: string
agent?: string
}
const SessionSummaryPanel = lazy(async () => {
const { SessionSummaryPanel } = await import("../summary/panel")
return { default: SessionSummaryPanel }
})
type SessionBackground = {
blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]>
@@ -70,283 +74,6 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
)
}
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
const language = useLanguage()
const data = useData()
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")
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
}
return (
<Popover
open={open()}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
gutter={4}
onOpenChange={(value) => setOpen(value && props.tasks.length > 0)}
>
<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 data-[closed]:animate-out data-[closed]:fade-out data-[closed]:duration-150 motion-reduce:data-[closed]:animate-none"
>
<For each={tasks().slice(0, 10)}>
{(task) => (
<Dynamic
component={task.type === "subagent" ? "a" : "div"}
data-component="session-background-list-item"
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]"
classList={{
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
task.type === "subagent",
}}
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
onClick={(event: MouseEvent) => {
if (task.type !== "subagent" || !data.navigateToSession) return
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
event.preventDefault()
setOpen(false)
data.navigateToSession(task.id)
}}
>
<span class="shrink-0 text-v2-text-text-base">{taskType(task)}</span>
<span class="min-w-0 flex-1 truncate text-v2-text-text-faint">{task.label}</span>
</Dynamic>
)}
</For>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
mobile?: boolean
eligible: boolean
sessionID: string
project: Project
directory: string
dismissed: boolean
onDismiss: () => void
}) {
const language = useLanguage()
const inline = () => props.variant === "inline"
return (
<div
classList={{
"group/workspace-move relative shrink-0": true,
"ms-auto h-5 w-[167px]": inline(),
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
!inline(),
hidden: props.dismissed,
}}
>
<SessionWorkspaceMenu
eligible={props.eligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={
props.mobile
? "top-end"
: inline()
? "bottom-end"
: language.direction() === "rtl"
? "right-start"
: "left-start"
}
gutter={props.mobile || inline() ? 4 : -22}
contentClass={props.mobile || inline() ? undefined : "relative top-3.5"}
class={
inline()
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pe-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint 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"
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
type="button"
class={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none ${
inline()
? "end-0 top-1/2"
: "hover-reveal end-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
}`}
aria-label={language.t("common.dismiss")}
onClick={(event) => {
event.stopPropagation()
props.onDismiss()
}}
>
<Icon name="xmark-small" />
</button>
</div>
)
}
export function SessionSummaryPanel(props: {
mobile?: boolean
project: Project
avatar?: JSX.Element
directory: string
local: boolean
branch?: string
baseBranch?: string
diffs?: { additions: number; deletions: number }[]
sessionID: string
moveEligible: boolean
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
backgroundTasks: BackgroundTask[]
}) {
const language = useLanguage()
const location = () => {
if (props.local) return language.t("session.new.workspace.local")
const workspace = workspaceDirectories(props.project).find((item) => containsDirectory(item, props.directory))
return getFilename(workspace ?? props.directory)
}
const branch = () => props.branch ?? props.baseBranch
const row =
"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"
return (
<div data-component="session-summary-panel" class={props.mobile ? "w-full" : "w-[280px]"}>
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<div class={row}>
{props.avatar ?? (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
)}
<span dir="auto" class="min-w-0 flex-1 truncate text-v2-text-text-muted">
{displayName(props.project)}
</span>
</div>
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={props.mobile ? 4 : -22}
class={`${row} 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`}
>
<Icon
name={props.local ? "monitor" : "outline-worktree"}
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
{location()}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<div class={row}>
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<Show
when={props.branch}
fallback={
<span class="flex min-w-0 items-center gap-1.5">
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
<Show when={props.baseBranch}>
{(base) => (
<>
<span class="text-v2-text-text-muted">·</span>
<span class="truncate text-v2-text-text-faint">
{language.t("session.summary.basedOn", { branch: base() })}
</span>
</>
)}
</Show>
</span>
}
>
<span dir="auto" class="min-w-0 truncate">
{branch()}
</span>
</Show>
</div>
<button
type="button"
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
onClick={props.onReview}
>
<Icon name="review" class="shrink-0 text-v2-icon-icon-muted" />
<Show when={props.diffs} fallback={<span>{language.t("session.review.loadingChanges")}</span>}>
{(diffs) => (
<Show when={diffs().length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
<span>{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}</span>
<span class="text-v2-text-text-muted">·</span>
<DiffChanges appearance="standard" changes={diffs()} />
</Show>
)}
</Show>
</button>
<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
variant="panel"
mobile={props.mobile}
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
dismissed={props.moveDismissed}
onDismiss={props.onMoveDismiss}
/>
</Show>
</div>
)
}
type MessageTimelineProps = {
hideHeader?: boolean
active?: boolean
@@ -819,42 +546,32 @@ function MessageTimelineView(
<SessionContextUsage placement="bottom" />
<Show when={!parentID() && project()}>
{(project) => (
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
<Popover.Trigger
as={IconButton}
icon={<Icon name="window-analytics" />}
variant="ghost-muted"
size="large"
state={summaryOpen() ? "pressed" : undefined}
aria-label={language.t("session.summary.title")}
aria-expanded={summaryOpen()}
/>
<Popover.Portal>
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
<SessionSummaryPanel
project={project()}
avatar={showProjectIcon() ? projectAvatar() : undefined}
directory={sessionDirectory()}
local={!workspaceSession()}
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
diffs={sessionDiffs()}
sessionID={id}
moveEligible={props.workspaceMoveEligible}
moveDismissed={workspaceSuggestionDismissed()}
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
onReview={() => {
setSummary(false)
props.onReview()
}}
backgroundTasks={props.background.tasks()}
/>
</Popover.Content>
</Popover.Portal>
</Popover>
<SummaryPopover open={summaryOpen()} onOpenChange={setSummary}>
<Suspense>
<SessionSummaryPanel
shown={summaryOpen()}
project={project()}
avatar={showProjectIcon() ? projectAvatar() : undefined}
directory={sessionDirectory()}
local={!workspaceSession()}
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
diffs={sessionDiffs()}
sessionID={id}
moveEligible={props.workspaceMoveEligible}
moveDismissed={workspaceSuggestionDismissed()}
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
onReview={() => {
setSummary(false)
props.onReview()
}}
backgroundTasks={props.background.tasks()}
/>
</Suspense>
</SummaryPopover>
)}
</Show>
<SessionHeader reserveReviewToggle={props.reserveReviewToggle} />
<SessionHeaderSpacer visible={props.reserveReviewToggle} />
</div>
)}
</Show>
@@ -382,18 +382,6 @@ export const SettingsGeneral: Component<{
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
+12 -1
View File
@@ -34,6 +34,17 @@ describe("settings timeline detail migration", () => {
})
describe("settings schema", () => {
test("restores summary expansion and discards the retired status preference", () => {
const settings = decode({
general: { showStatus: true, showSearch: true },
sessionSummary: { projectExpanded: false, serverExpanded: true },
})
expect(settings.general.showSearch).toBe(true)
expect(settings.general).not.toHaveProperty("showStatus")
expect(settings.sessionSummary).toEqual({ projectExpanded: false, serverExpanded: true })
expect(decode(encode(settings)).sessionSummary).toEqual(settings.sessionSummary)
})
test("uses the supplied initial values independently of the current schema", () => {
const initial = {
...defaultSettings,
@@ -58,7 +69,6 @@ describe("settings schema", () => {
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showProjectIcon: false,
showTerminal: false,
timelineDetail: timelinePresets[2].value,
@@ -69,6 +79,7 @@ describe("settings schema", () => {
followUpBehavior: "steer",
experimentalBrowser: false,
},
sessionSummary: { projectExpanded: true, serverExpanded: true },
appearance: {
fontSize: 14,
mono: "",
+18 -8
View File
@@ -79,7 +79,6 @@ const generalSchema = Persistence.struct({
showFileTree: Schema.Boolean,
showNavigation: Schema.Boolean,
showSearch: Schema.Boolean,
showStatus: Schema.Boolean,
showProjectIcon: Schema.Boolean,
showTerminal: Schema.Boolean,
timelineDetail: Persistence.struct({
@@ -135,6 +134,7 @@ const soundsSchema = Persistence.struct({
export const settingsSchema = Persistence.struct({
general: generalSchema,
sessionSummary: Persistence.struct({ projectExpanded: Schema.Boolean, serverExpanded: Schema.Boolean }),
appearance: appearanceSchema,
keybinds: Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
permissions: permissionsSchema,
@@ -243,7 +243,6 @@ export const defaultSettings: Settings = {
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showProjectIcon: false,
showTerminal: false,
timelineDetail: { ...timelinePresets[2].value },
@@ -254,6 +253,7 @@ export const defaultSettings: Settings = {
followUpBehavior: "steer",
experimentalBrowser: false,
},
sessionSummary: { projectExpanded: true, serverExpanded: true },
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false },
keybinds: {},
permissions: { autoApprove: false },
@@ -280,7 +280,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
const [store, setStore, , ready] = persisted({ key: "settings.v3" }, settingsPersistence, defaultSettings)
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
const showCustomAgents = withFallback(
() => store.general?.showCustomAgents,
defaultSettings.general.showCustomAgents,
@@ -322,10 +321,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowSearch(value: boolean) {
setStore("general", "showSearch", value)
},
showStatus,
setShowStatus(value: boolean) {
setStore("general", "showStatus", value)
},
showProjectIcon: withFallback(() => store.general?.showProjectIcon, defaultSettings.general.showProjectIcon),
setShowProjectIcon(value: boolean) {
setStore("general", "showProjectIcon", value)
@@ -372,10 +367,25 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "experimentalBrowser", value)
},
},
sessionSummary: {
projectExpanded: withFallback(
() => store.sessionSummary?.projectExpanded,
defaultSettings.sessionSummary.projectExpanded,
),
serverExpanded: withFallback(
() => store.sessionSummary?.serverExpanded,
defaultSettings.sessionSummary.serverExpanded,
),
setProjectExpanded(value: boolean) {
setStore("sessionSummary", "projectExpanded", value)
},
setServerExpanded(value: boolean) {
setStore("sessionSummary", "serverExpanded", value)
},
},
visibility: {
fileTree: showFileTree,
search: showSearch,
status: showStatus,
customAgents: showCustomAgents,
},
appearance: {
-156
View File
@@ -1,156 +0,0 @@
import { Switch } from "@opencode/ui/switch"
import { Tabs } from "@opencode/ui/tabs"
import { createMemo, createResource, For, Index, type JSXElement, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { pluginLabels } from "@/providers/catalog/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
if (parts.length === 1) return value
return (
<>
{parts[0]}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
{parts.slice(1).join(file)}
</>
)
}
export function StatusPopoverBody(props: { shown: boolean; embedded?: boolean }) {
const data = useData()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const language = useLanguage()
const toggleMcp = useMcpToggle(() => sdk().directory)
const mcpServers = createMemo(() =>
(data.location.mcp.server.list({ directory: sdk().directory }) ?? []).toSorted((a, b) =>
a.name.localeCompare(b.name),
),
)
const mcpConnected = createMemo(() => mcpServers().filter((server) => server.status.status === "connected").length)
const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => pluginLabels(pluginList.latest ?? []))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
return (
<div
class="flex items-center gap-1 rounded-xl"
classList={{
"w-[360px] shadow-[var(--shadow-lg-border-base)]": !props.embedded,
"w-full min-w-0": props.embedded,
}}
>
<Tabs
aria-label={language.t("status.popover.ariaLabel")}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-active="mcp"
defaultValue="mcp"
variant="underline"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */}
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={mcpServers().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
}
>
<Index each={mcpServers()}>
{(server) => {
const name = () => server().name
const status = () => server().status.status
const enabled = () => status() === "connected"
return (
<button
type="button"
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name())
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base": status() === "needs_auth",
}}
/>
<span class="flex flex-col min-w-0 flex-1">
<span class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-base truncate">{name()}</span>
</span>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker truncate">
{language.t("mcp.auth.clickToAuthenticate")}
</span>
</Show>
</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
appearance="standard"
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name())
}}
/>
</div>
</button>
)
}}
</Index>
</Show>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
</Tabs>
</div>
)
}
@@ -1,65 +0,0 @@
import { describe, expect, test } from "bun:test"
import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator"
describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
})
test("uses the session attention token when a service needs attention", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
"bg-v2-background-bg-accent",
)
})
test("uses the warning token for non-blocking issues while the server is online", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
})
test("uses the critical token only after the server connection drops", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
})
test("pulses the neutral dot while the event stream is reconnecting", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false, connecting: true })).toBe(
"bg-border-weak-base animate-pulse",
)
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false, connecting: true })).toBe(
"bg-border-weak-base animate-pulse",
)
// A server that is known to be down stays critical rather than looking like a routine reconnect.
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false, connecting: true })).toBe(
"bg-icon-critical-base",
)
})
test("stays neutral before status is ready", () => {
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
})
})
describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
})
test("detects LSP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
})
})
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
})
})
@@ -1,34 +0,0 @@
import type { LspStatus } from "@/runtime/server/types"
import type { McpServer } from "@opencode/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth")
}
export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpServer["status"]["status"]>
lsp: Array<LspStatus["status"]>
}) {
return (
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
input.lsp.some((status) => status === "error")
)
}
export function serverStatusDotClass(input: {
ready: boolean
serverHealth: boolean | undefined
attention?: boolean
issue: boolean
connecting?: boolean
}) {
if (input.serverHealth === false) return "bg-icon-critical-base"
// The event stream is (re)connecting: keep the neutral dot but let it breathe so a stale
// session is visibly waiting on the server rather than silently frozen.
if (input.connecting) return "bg-border-weak-base animate-pulse"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.attention) return "bg-v2-background-bg-accent"
if (input.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base"
}
@@ -1,9 +0,0 @@
[data-slot="mobile-status-loading"] {
display: flex;
min-height: 56px;
align-items: center;
justify-content: center;
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: var(--line-height-base);
}
@@ -1,36 +0,0 @@
import { lazy, Suspense } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { MobilePanelDrawer } from "../mobile-panel-drawer"
import "./status-drawer.css"
const Body = lazy(async () => {
const { StatusPopoverBody } = await import("./body")
return { default: StatusPopoverBody }
})
export function StatusDrawer(props: {
open: boolean
onOpenChange: (open: boolean) => void
returnFocus?: () => HTMLElement | undefined
}) {
const language = useLanguage()
return (
<MobilePanelDrawer
title={language.t("status.popover.trigger")}
open={props.open}
onOpenChange={props.onOpenChange}
returnFocus={props.returnFocus}
>
<Suspense
fallback={
<div data-slot="mobile-status-loading" role="status">
{language.t("common.loading")}
</div>
}
>
<Body shown={props.open} embedded />
</Suspense>
</MobilePanelDrawer>
)
}
@@ -1,138 +0,0 @@
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Popover } from "@opencode/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator"
import { useData, useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSettings } from "@/settings/model"
import { createMediaQuery } from "@solid-primitives/media"
const Body = lazy(() => import("./body").then((x) => ({ default: x.StatusPopoverBody })))
export function StatusPopover() {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const data = useData()
const sdk = useWorkspaceLocation()
const settings = useSettings()
const desktop = createMediaQuery("(min-width: 768px)")
const sidebar = () => desktop() && settings.appearance.tabLayout() === "vertical"
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: (mcp() ?? []).map((item) => item.status.status),
}),
)
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: (mcp() ?? []).map((item) => item.status.status),
lsp: [],
}),
)
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
connecting: server.ctx.sdk.connection.status() !== "connected",
sidebar: sidebar(),
placement: sidebar() ? "top-start" : "bottom-end",
shift: sidebar() ? 0 : -168,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<Body shown={shown()} />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
type StatusPopoverState = {
shown: boolean
ready: boolean
serverHealth: boolean | undefined
attention: boolean
issue: boolean
connecting: boolean
sidebar: boolean
placement: "top-start" | "bottom-end"
shift: number
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
}
function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
return (
<Show when={props.shown}>
<Suspense
fallback={<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />}
>
{props.children}
</Suspense>
</Show>
)
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const popoverProps = {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
gutter: 4,
placement: props.state.placement,
shift: props.state.shift,
}
return (
<Popover
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
triggerAs={props.state.sidebar ? "button" : IconButton}
triggerProps={
props.state.sidebar
? {
type: "button",
class:
"flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]",
"data-state": props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}
: {
variant: "ghost-muted",
size: "large",
class: "!w-9 shrink-0",
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}
}
trigger={
<>
<div class="relative size-4 shrink-0">
<Icon name={props.state.shown ? "status-active" : "status"} />
<div
data-slot="status-indicator"
class={`absolute -top-1 -end-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
/>
</div>
<Show when={props.state.sidebar}>
<span class="min-w-0 truncate">{props.state.label}</span>
</Show>
</>
}
{...popoverProps}
>
{props.state.body()}
</Popover>
)
}
+1
View File
@@ -21,6 +21,7 @@ export const Draft = Persistence.struct({
directory: Schema.String,
worktree: Persistence.optional(Schema.String),
branch: Persistence.optional(Schema.String),
mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })),
})
const SessionCodec = Session.pipe(
+13
View File
@@ -16,6 +16,19 @@ function sessionTab(sessionId: string): SessionTab {
}
describe("tab migration", () => {
test("round trips draft MCP choices without changing older drafts", () => {
const legacy: Tab = { type: "draft", draftID: "legacy-draft", server, directory: "/project" }
const draft: Tab = {
...legacy,
draftID: "mcp-draft",
worktree: "create",
mcp: { target: "new-worktree", states: { first: true, second: false } },
}
const restored = decodeTabs([legacy, draft])
expect(restored).toEqual([legacy, draft])
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
})
test("drops null and malformed persisted tabs", () => {
expect(
decodeTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"]),
+4 -5
View File
@@ -679,12 +679,11 @@ export function Titlebar(props: {
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col gap-2">
<TitlebarRightMount vertical />
<Show when={updateState().visible}>
<Show when={updateState().visible}>
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col">
<TitlebarUpdateIconButton state={updateState()} vertical />
</Show>
</div>
</div>
</Show>
</Portal>
)}
</Show>
+1 -11
View File
@@ -17,7 +17,6 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
import type { AgentAttachment } from "@opencode/schema/prompt"
import type { Skill } from "@opencode/schema/skill"
import type { Event } from "@opencode/schema/event"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode/schema/event-log"
@@ -37,6 +36,7 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
import type { Reference } from "@opencode/schema/reference"
import type { Worktree } from "@opencode/schema/worktree"
import type { Vcs } from "@opencode/schema/vcs"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { WebSearch } from "@opencode/schema/websearch"
import type { Config } from "@opencode/schema/config"
@@ -360,15 +360,6 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
export type SessionDiffInput = {
readonly sessionID: Session.ID
readonly messageID?: SessionMessage.ID | undefined
readonly to?: SessionMessage.ID | undefined
readonly context?: number | undefined
}
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
export type SessionInboxListInput = { readonly sessionID: Session.ID }
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (
@@ -1148,7 +1139,6 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly diff: SessionDiffOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
@@ -68,8 +68,6 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -594,17 +592,6 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
preserveEffect<SessionDiffOutput>()(
raw["session.diff"]({
params: { sessionID: input["sessionID"] },
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
preserveEffect<SessionInboxListOutput>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
@@ -744,7 +731,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
commit: EndpointSessionRevertCommit(raw),
},
context: EndpointSessionContext(raw),
diff: EndpointSessionDiff(raw),
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
@@ -62,8 +62,6 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -844,18 +842,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionDiffOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
).then((value) => value.data),
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
@@ -147,14 +147,6 @@ export type SessionProviderContextProvenance = {
endpoint: string
}
export type SessionMessageIdle = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
type: "idle"
outcome: "succeeded" | "failed" | "interrupted"
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -2202,7 +2194,6 @@ export type SessionMessageInfo =
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
| SessionMessageIdle
export type SessionMessageContentUpdated = {
id: string
@@ -3161,13 +3152,6 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["info"]
@@ -3473,13 +3457,6 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["messages"]
@@ -3785,13 +3762,6 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
@@ -4281,27 +4251,6 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionDiffInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly messageID?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["messageID"]
readonly to?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["to"]
readonly context?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["context"]
}
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
-12
View File
@@ -1024,18 +1024,6 @@ export function createData(config: CreateDataInput) {
if (currentAssistant) currentAssistant.retry = undefined
})
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
// Mirror the projected idle marker so turn boundaries match before the next message read.
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "idle",
outcome:
event.type === "session.execution.succeeded"
? "succeeded"
: event.type === "session.execution.failed"
? "failed"
: "interrupted",
time: { created: event.created },
})
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
+64 -75
View File
@@ -9,7 +9,6 @@ import { AppProcess } from "@opencode/util/process"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -309,7 +308,7 @@ const layer = Layer.effect(
operationName: OperationError["operation"],
repository: Repository,
args: string[],
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
options?: { stdin?: string; env?: Record<string, string> },
) {
const result = yield* proc
.run(
@@ -318,7 +317,7 @@ const layer = Layer.effect(
env: options?.env,
extendEnv: true,
}),
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
{ stdin: options?.stdin },
)
.pipe(
Effect.mapError(
@@ -332,8 +331,7 @@ const layer = Layer.effect(
),
)
const text = result.stdout.toString("utf8")
if (result.exitCode === 0)
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
return yield* new OperationError({
operation: operationName,
directory: repository.worktree,
@@ -387,7 +385,9 @@ const layer = Layer.effect(
maximumUntrackedFileBytes?: number
}) {
const list = (args: string[]) =>
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
repositoryOperation("refresh", input.repository, args).pipe(
Effect.map((result) => result.text.split("\0").filter(Boolean)),
)
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", input.scope]),
@@ -464,7 +464,13 @@ const layer = Layer.effect(
directory: input.repository.worktree,
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
})
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
return new Set(
result.stdout
.toString("utf8")
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file)),
)
})
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
@@ -493,23 +499,19 @@ const layer = Layer.effect(
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return nuls(
(yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text,
).map((file) => RelativePath.make(file))
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file))
})
/**
* Three batched invocations over the tree pair instead of three per file. An
* explicit empty selection diffs nothing; an absent one diffs every changed path.
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
*/
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
repository: Repository
from: TreeID
@@ -517,57 +519,49 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
if (input.paths?.length === 0) return []
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
const [names, numbers, patch] = yield* Effect.all(
[
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
repositoryOperation(
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
"diff",
input.repository,
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
),
],
{ concurrency: 3 },
)
const statuses = nuls(names.text)
const files = statuses.flatMap((code, index) => {
const file = statuses[index + 1]
if (index % 2 !== 0 || !file) return []
return [
{
file: RelativePath.make(file),
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
} as const,
]
})
const stats = new Map(
nuls(numbers.text).flatMap((line) => {
const [additions, deletions, ...file] = line.split("\t")
if (!additions || !deletions || file.length === 0) return []
return [
[
file.join("\t"),
additions === "-" || deletions === "-"
? { binary: true, additions: 0, deletions: 0 }
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
] as const,
]
"--name-status",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.trim()
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
const stats = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--numstat",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.split("\t")
const binary = stats[0] === "-" || stats[1] === "-"
const patch = binary
? ""
: (yield* repositoryOperation("diff", input.repository, [
"diff",
`--unified=${input.context ?? 3}`,
"--no-renames",
input.from,
input.to,
"--",
file,
])).text
return {
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
patch,
} satisfies File.Diff
}),
)
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
return files.map((entry) => {
const stat = stats.get(entry.file)
return {
...entry,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
} satisfies File.Diff
})
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
@@ -739,11 +733,6 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
)
}
/** Split NUL-terminated git output into its records. */
function nuls(text: string) {
return text.split("\0").filter(Boolean)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
+1
View File
@@ -101,6 +101,7 @@ export const XAIPlugin = define({
for (const model of provider.models.values()) {
catalog.model.update(providerID, model.id, (draft) => {
draft.capabilities.responsesWebsockets = true
draft.websocket = true
})
}
})
-24
View File
@@ -54,11 +54,8 @@ import { SessionModelTransport } from "./session/model-transport.js"
import { llmClient } from "./effect/app-node-platform.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { SessionDiff, TurnRangeError } from "./session/diff.js"
import { LocationServiceMap } from "./location-service-map.js"
import { FSUtil } from "@opencode/util/fs-util"
import type { EventLog } from "@opencode/schema/event-log"
import type { FileDiff } from "@opencode/schema/file-diff"
import { Job } from "./job.js"
import type { Command } from "./command.js"
import { SessionEnvironment } from "./session/environment.js"
@@ -110,7 +107,6 @@ export {
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
export { TurnRangeError }
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -137,13 +133,6 @@ export interface Interface {
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
readonly diff: (input: {
readonly sessionID: SessionSchema.ID
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
@@ -232,7 +221,6 @@ const layer = Layer.effect(
const moves = yield* SessionMove.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.make()
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -364,17 +352,6 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
diff: Effect.fn("Session.diff")(function* (input) {
const session = yield* result.get(input.sessionID)
const active = yield* execution.isActive(input.sessionID)
return yield* SessionDiff.turn(db, locations, {
session,
active,
messageID: input.messageID,
to: input.to,
context: input.context,
})
}),
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
@@ -463,7 +440,6 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionInbox.node,
SessionMove.node,
SessionProjector.node,
LocationServiceMap.node,
FSUtil.node,
App.node,
],
-138
View File
@@ -1,138 +0,0 @@
export * as SessionDiff from "./diff.js"
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
import { Context, Effect, Schema } from "effect"
import { Location } from "@opencode/schema/location"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Snapshot } from "../snapshot.js"
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
sessionID: SessionSchema.ID,
field: Schema.Literals(["messageID", "to"]),
message: Schema.String,
}) {}
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
/**
* Diff the files changed by the turn containing a user message. A turn runs from
* the first prompt after the Session was last idle until the next idle marker, so
* prompts steered in while it was busy belong to the same turn; `to` extends the
* range through the turn containing a later user message. Compares the range's
* first recorded start snapshot with its last recorded end snapshot; only a step
* still running in the active Session compares against the working copy. Like VCS
* diffs, an omitted `context` yields full-file patches.
*
* A Session without any idle marker predates them, so its prompts span until the
* next user message instead.
*
* Snapshot trees live in the repository of the Location that captured them, so a
* range spanning a location switch is rejected rather than diffed wrongly.
*/
export const turn = Effect.fn("SessionDiff.turn")(function* (
db: Database.Interface["db"],
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
input: {
readonly session: SessionSchema.Info
/** The process is currently executing this Session. */
readonly active: boolean
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
},
) {
const sessionID = input.session.id
const rows = yield* db
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
or(
inArray(SessionMessageTable.type, ["user", "idle"]),
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const users = rows.filter((row) => row.type === "user")
const markers = rows.filter((row) => row.type === "idle")
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
const row = rows.find((row) => row.id === id)
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
if (row.type !== "user")
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
return row
})
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
if (!anchor) return []
const last = input.to ? yield* resolve("to", input.to) : anchor
if (last.seq < anchor.seq)
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
const legacy = markers.length === 0
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
const steps = yield* db
.select({
seq: SessionMessageTable.seq,
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
})
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, start),
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const first = steps[0]
const final = steps[steps.length - 1]
const from = steps.find((step) => step.start)?.start
if (!first || !final || !from) return []
const switches = yield* db
.select({
seq: SessionMessageTable.seq,
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
})
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
const before = switches.findLast((row) => row.seq < first.seq)?.location
const after = switches.find((row) => row.seq > first.seq)?.previous
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
const recorded = steps.findLast((step) => step.end)?.end
return yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const running = input.active && final.completed === null
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
if (!to) return []
return yield* snapshot.diff({
from: Snapshot.ID.make(from),
to: Snapshot.ID.make(to),
context: input.context ?? PATCH_CONTEXT_LINES,
})
}).pipe(Effect.provide(locations.get(location)))
})
+3 -20
View File
@@ -60,21 +60,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
clearCurrentRetry.pipe(
Effect.andThen(
adapter.appendMessage(
SessionMessage.Idle.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "idle",
outcome,
metadata: event.metadata,
time: { created },
}),
),
),
)
const project = pipe(
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
@@ -138,11 +123,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => idle("succeeded"),
"session.execution.failed": () => idle("failed"),
// Shutdown keeps the execution claim and the resumed drain continues the turn.
"session.execution.interrupted": (event) =>
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.instructions.updated": (event) => {
if (event.data.text === undefined) return Effect.void
return adapter.appendMessage(
@@ -226,7 +226,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
switch (message.type) {
case "agent-switched":
case "model-switched":
case "idle":
return []
case "location-switched":
return [
+16 -33
View File
@@ -131,55 +131,38 @@ const layer = Layer.effect(
)
})
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
source: repo.source,
const comparison = {
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
})
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
const ignored = Effect.fnUntraced(function* (
operation: "files" | "diff",
source: Git.Repository,
paths: readonly RelativePath[],
) {
return yield* git.index
.ignored({ repository: source, paths })
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
.ignored({ repository: repo.source, paths: files })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const compared = yield* comparison("files", input)
const changed = yield* git.tree
.files({ repository: compared.repository, from: compared.from, to: compared.to })
.pipe(Effect.mapError((cause) => failure("files", cause)))
const skipped = yield* ignored("files", compared.source, changed)
return changed.filter((file) => !skipped.has(file))
const comparison = yield* compare("files", input)
return comparison.files.filter((file) => !comparison.ignored.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
if (input.paths?.length === 0) return []
const compared = yield* comparison("diff", input)
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
const diffs = yield* git.tree
const comparison = yield* compare("diff", input)
return yield* git.tree
.diff({
repository: compared.repository,
from: compared.from,
to: compared.to,
...comparison.input,
context: input.context,
paths: input.paths,
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
const skipped = yield* ignored(
"diff",
compared.source,
diffs.map((file) => RelativePath.make(file.file)),
)
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
})
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
-37
View File
@@ -6,7 +6,6 @@ import { Effect } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Git } from "@opencode/core/git"
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
import { VcsPatch } from "@opencode/core/vcs/patch"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -197,42 +196,6 @@ describe("Git trees", () => {
}),
)
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
yield* Effect.promise(async () => {
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
})
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
["a-caf\u00e9.txt", "added", 1, 0],
["a-small.txt", "added", 1, 0],
["b-large.txt", "added", lines, 0],
["c-binary.bin", "added", 0, 0],
])
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
expect(diffs[1]?.patch).toContain("+small\n")
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
expect(diffs[3]?.patch).toBe("")
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
@@ -69,7 +69,7 @@ describe("XAIPlugin", () => {
}),
)
it.effect("keeps xAI Responses WebSockets opt-in", () =>
it.effect("enables xAI Responses WebSockets", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("xai")
@@ -84,7 +84,7 @@ describe("XAIPlugin", () => {
const model = yield* catalog.model.get(providerID, Model.ID.make("grok-4.6"))
expect(model?.capabilities.responsesWebsockets).toBe(true)
expect(model?.websocket).toBeUndefined()
expect(model?.websocket).toBe(true)
}),
)
})
-198
View File
@@ -1,198 +0,0 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { Model } from "@opencode/core/model"
import { Plugin } from "@opencode/core/plugin"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionDiff } from "@opencode/core/session/diff"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionInbox } from "@opencode/core/session/inbox"
import { SessionMessage } from "@opencode/core/session/message"
import { SessionProjector } from "@opencode/core/session/projector"
import { Snapshot } from "@opencode/core/snapshot"
import { Money } from "@opencode/schema/money"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
),
)
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
file.file,
file.status,
file.additions,
file.deletions,
]
describe("Session.diff", () => {
it.live(
"diffs the busy period containing a user message and ranges across later turns",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await write("first.txt", "first\n")()
await write("second.txt", "second\n")()
await write("manual.txt", "manual\n")()
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
const diff = (input?: { messageID?: SessionMessage.ID; to?: SessionMessage.ID }) =>
sessions
.diff({ sessionID: created.id, context: 0, ...input })
.pipe(Effect.map((files) => files.map(summarize)))
expect(yield* diff()).toEqual([])
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const snapshot = yield* Snapshot.Service
const usage = {
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
const prompt = Effect.fn(function* (text: string) {
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
return admitted.id
})
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
const before = yield* snapshot.capture()
if (!before) throw new Error("Start snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(edit)
if (end === "running") return assistantMessageID
const after = end === "recorded" ? yield* snapshot.capture() : undefined
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
...usage,
snapshot: after,
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
})
return assistantMessageID
})
const idle = (outcome: "succeeded" | "failed") =>
outcome === "succeeded"
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
: bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
// Before any idle marker exists, a prompt's turn ends at the next prompt.
const first = yield* prompt("Edit the first file")
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
yield* Effect.promise(write("manual.txt", "manual edited\n"))
const second = yield* prompt("Edit the second file")
yield* step(write("second.txt", "second edited\n"), "recorded")
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
expect(yield* diff({ messageID: first })).toEqual([["first.txt", "modified", 1, 1]])
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
yield* idle("succeeded")
const third = yield* prompt("Add a third file")
yield* step(write("third.txt", "third\n"), "recorded")
const steer = yield* prompt("Also add a fourth file")
yield* step(write("fourth.txt", "fourth\n"), "recorded")
yield* idle("failed")
const busy = [
["fourth.txt", "added", 1, 0],
["third.txt", "added", 1, 0],
]
expect(yield* diff()).toEqual(busy)
expect(yield* diff({ messageID: steer })).toEqual(busy)
expect(yield* diff({ messageID: second })).toEqual([
["first.txt", "modified", 1, 1],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
])
expect(yield* diff({ messageID: first, to: third })).toEqual([
["first.txt", "modified", 1, 1],
["fourth.txt", "added", 1, 0],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
["third.txt", "added", 1, 0],
])
const full = yield* sessions.diff({ sessionID: created.id, messageID: first })
expect(full[0]?.patch).toContain("-first\n+first edited\n")
expect(yield* diff({ messageID: steer, to: second }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "to",
})
expect(yield* diff({ messageID: firstStep }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "messageID",
})
expect(yield* diff({ messageID: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
// A completed step without an end snapshot falls back to the last recorded end.
yield* prompt("Edit both files again")
yield* step(write("first.txt", "first edited twice\n"), "recorded")
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
yield* idle("succeeded")
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
// Only a step still running in the active session compares against the working copy.
yield* prompt("Delete the manual file")
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
expect(yield* diff()).toEqual([])
const session = yield* sessions.get(created.id)
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
// Reverting removes later history, markers included; a fork keeps the copied turns.
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
yield* sessions.revert.commit(created.id)
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
expect(yield* diff({ messageID: steer }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
["third.txt", "added", 1, 0],
])
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
}),
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
{ timeout: 30_000 },
)
})
+2 -3
View File
@@ -561,9 +561,7 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
expect(yield* sessions.messages({ sessionID })).toMatchObject([
{
id: background.notificationID,
type: "synthetic",
@@ -571,6 +569,7 @@ describe("SessionRestart background recovery", () => {
metadata: { state: "completed" },
},
])
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
}),
)
}
-181
View File
@@ -3242,152 +3242,6 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18540,38 +18394,6 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18603,9 +18425,6 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
-26
View File
@@ -30,7 +30,6 @@ import { Model } from "@opencode/schema/model"
import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
import { EventLog } from "@opencode/schema/event-log"
import { FileDiff } from "@opencode/schema/file-diff"
const ParentIDFilter = Schema.Union([
Session.ID,
@@ -522,31 +521,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
params: { sessionID: Session.ID },
query: Schema.Struct({
messageID: Schema.optional(SessionMessage.ID).annotate({
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
}),
to: Schema.optional(SessionMessage.ID).annotate({
description: "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone.",
}),
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
description: "Unchanged lines around each hunk. Omit for full-file patches.",
}),
}),
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.diff",
summary: "Diff session turns",
description:
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
}),
),
)
.add(
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
params: { sessionID: Session.ID },
-14
View File
@@ -280,18 +280,6 @@ export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted,
)
export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed
/**
* Marks the Session going idle: every step since the previous marker belongs to
* one turn, including prompts steered in while it was busy. A shutdown does not
* record one, since the resumed execution continues the same turn.
*/
export interface Idle extends Schema.Schema.Type<typeof Idle> {}
export const Idle = Schema.Struct({
...Base,
type: Schema.tag("idle"),
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]),
}).annotate({ identifier: "Session.Message.Idle" })
export const Info = Schema.Union([
AgentSelected,
ModelSelected,
@@ -303,7 +291,6 @@ export const Info = Schema.Union([
Shell,
Assistant,
Compaction,
Idle,
]).annotate({ identifier: "Session.Message.Info" })
export type Info =
| AgentSelected
@@ -316,5 +303,4 @@ export type Info =
| Shell
| Assistant
| Compaction
| Idle
export type Type = Info["type"]
+1 -23
View File
@@ -1,6 +1,5 @@
import { Session } from "@opencode/core/session"
import type { Snapshot } from "@opencode/core/snapshot"
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import { SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import { Effect } from "effect"
export function missingSession(error: Session.NotFoundError) {
@@ -10,14 +9,6 @@ export function missingSession(error: Session.NotFoundError) {
})
}
export function missingMessage(error: Session.MessageNotFoundError) {
return new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
})
}
export function failedMessageDecode(error: Session.MessageDecodeError) {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
@@ -27,16 +18,3 @@ export function failedMessageDecode(error: Session.MessageDecodeError) {
),
)
}
/** Snapshot repositories are host state clients cannot repair, so surface only a log reference. */
export function failedSnapshot(operation: string, sessionID: Session.ID) {
return (error: Snapshot.Error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError(`failed to ${operation}`, { cause: error }).pipe(
Effect.annotateLogs({ ref, sessionID }),
Effect.andThen(
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
),
)
}
}
+53 -32
View File
@@ -17,9 +17,10 @@ import {
ServiceUnavailableError,
SessionBusyError,
SkillNotFoundError,
UnknownError,
} from "@opencode/protocol/errors"
import { AbsolutePath } from "@opencode/core/schema"
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
import { failedMessageDecode, missingSession } from "./session-error"
const DefaultSessionsLimit = 50
@@ -211,7 +212,15 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return {
data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag(
"Session.ForkEmptyError",
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
@@ -439,14 +448,32 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
files: ctx.payload.files,
})
return {
data: yield* session.revert
.stage({ ...ctx.params, ...ctx.payload })
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
),
}
}),
)
@@ -454,13 +481,23 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.revert.clear",
Effect.fn(function* (ctx) {
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
yield* session.revert
.clear(ctx.params.sessionID)
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("clear session revert", ctx.params.sessionID)),
)
yield* session.revert.clear(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
return HttpApiSchema.NoContent.make()
}),
)
@@ -490,22 +527,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.diff",
Effect.fn(function* (ctx) {
return {
data: yield* session.diff({ sessionID: ctx.params.sessionID, ...ctx.query }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.TurnRangeError",
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
),
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
),
}
}),
)
.handle(
"session.inbox.list",
Effect.fn(function* (ctx) {
-98
View File
@@ -1,98 +0,0 @@
import { expect, setDefaultTimeout } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Model } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionMessage } from "@opencode/core/session/message"
import { Money } from "@opencode/schema/money"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Effect, Layer } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
setDefaultTimeout(30_000)
it.live("serves turn diffs by user message with range validation", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-session-diff-")))
const ids = { user: SessionMessage.ID.create(), assistant: SessionMessage.ID.create() }
// Deliver the prompt and one step the way the runner would, without a model.
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
isActive: () => Effect.succeed(false),
resume: () => Effect.void,
wake: (sessionID) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: ids.user })
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: ids.assistant,
agent: Agent.defaultID,
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
})
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: ids.assistant,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
}),
interrupt: () => Effect.succeed(false),
awaitIdle: () => Effect.void,
})
}),
)
const handler = yield* ServerFetch.make(
{
app: { version: "test-version" },
database: { path: ":memory:" },
fs: { filewatcher: false },
models: { fetch: false },
},
{
overrides: [
SessionExecution.node.replace(
makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }),
),
],
},
)
const request = (path: string, body?: unknown) =>
Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local${path}`, {
method: body === undefined ? "GET" : "POST",
headers: body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}),
)
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
})
const created = yield* request("/api/session", { location: { directory: tmp.path } })
const sessionID = Session.ID.make((created.body.data as { id: string }).id)
const diff = (query = "") => request(`/api/session/${sessionID}/diff${query}`)
expect(yield* diff()).toEqual({ status: 200, body: { data: [] } })
expect((yield* request(`/api/session/${sessionID}/prompt`, { id: ids.user, text: "prompt" })).status).toBe(200)
// Not a git repository, so steps record no snapshots and the turn has no diff.
expect(yield* diff(`?messageID=${ids.user}&context=3`)).toEqual({ status: 200, body: { data: [] } })
expect(yield* diff(`?messageID=${ids.assistant}`)).toMatchObject({
status: 400,
body: { _tag: "InvalidRequestError", field: "messageID" },
})
expect(yield* diff(`?messageID=${SessionMessage.ID.create()}`)).toMatchObject({
status: 404,
body: { _tag: "MessageNotFoundError" },
})
expect((yield* request(`/api/session/${Session.ID.create()}/diff`)).status).toBe(404)
}),
)
@@ -16,7 +16,7 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
export type ReasoningMode = "hidden" | "compact" | "full"
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" | "idle" }>
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
type Content = SessionMessageAssistant["content"][number]
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
@@ -765,8 +765,7 @@ function record(value: unknown): value is Record<string, unknown> {
}
function isNotice(message: SessionMessageInfo): message is Notice {
if (message.type === "user" || message.type === "assistant" || message.type === "shell" || message.type === "idle")
return false
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
if (message.type !== "synthetic") return true
return !!message.description?.trim() || timelineNoticeRequired(message)
}
-1
View File
@@ -305,7 +305,6 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
...messages.filter(isInput),
].reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "idle") return rows
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
if (!pending.has(message.id)) completePrevious(rows)
+1 -1
View File
@@ -214,7 +214,7 @@ const icons = {
},
"fill-triangle-down": {
viewBox: "0 0 16 16",
body: `<path d="M5.37624 6.75194C5.1818 6.41861 5.42223 6 5.80813 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43199 10.5096C8.23905 10.8404 7.76115 10.8404 7.56821 10.5096L5.37624 6.75194Z" fill="currentColor"/>`,
body: `<path d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z" fill="currentColor"/>`,
},
archive: {
viewBox: "0 0 16 16",
-181
View File
@@ -3242,152 +3242,6 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18540,38 +18394,6 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18603,9 +18425,6 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
-181
View File
@@ -3242,152 +3242,6 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18540,38 +18394,6 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18603,9 +18425,6 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
+5 -4
View File
@@ -110,10 +110,11 @@ role for other Foundry models. If a request fails because the token belongs to a
## WebSocket transport
OpenAI and supported Azure Responses models keep one WebSocket connection open per session and send each step over it
instead of opening a new HTTP request. While the request prefix is unchanged, consecutive steps only transmit what was
added since the previous response, which cuts upload volume on long sessions. Provider compaction runs over the same
connection.
OpenAI, xAI, and supported Azure Responses models keep one WebSocket connection open per session and send each step
over it instead of opening a new HTTP request. While the request prefix is unchanged, consecutive steps only transmit
what was added since the previous response, which cuts upload volume on long sessions. OpenAI provider compaction runs
over the same connection. xAI continues a chain only from stored responses, so with its default `store: false` each step
is sent in full over the reused connection.
The connection is transparent. When the provider closes the socket, the next step reconnects; when a connection cannot
be opened at all, the session continues over HTTP. Plugins that register `http.request` or `http.response` hooks for a