mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-04 16:06:23 +00:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b1dcbb45c | ||
|
|
542e79efd5 | ||
|
|
7e228666e9 | ||
|
|
a52c7ab089 | ||
|
|
986ee8c060 | ||
|
|
89478b36f1 | ||
|
|
46458f0753 | ||
|
|
c907d2ba27 | ||
|
|
ffac1c5b11 | ||
|
|
c5dca2df37 | ||
|
|
8889447f5a | ||
|
|
a1cb005799 | ||
|
|
19833ad1fd | ||
|
|
cb852434b1 | ||
|
|
b3733e9517 | ||
|
|
331f4ecd2f | ||
|
|
ecaa914b79 | ||
|
|
4f8dea674a | ||
|
|
4bf5269c4c | ||
|
|
c9d240704d | ||
|
|
c9df4ba80d | ||
|
|
ac71a55294 | ||
|
|
f84d927e07 | ||
|
|
d431fedce5 | ||
|
|
e536b9627e | ||
|
|
6af46cc8a9 | ||
|
|
c76e602ba4 | ||
|
|
726107729e | ||
|
|
0808ebc3c5 | ||
|
|
17e5c5fbf1 | ||
|
|
b0a13b810e | ||
|
|
6575215ddf | ||
|
|
09157609b7 | ||
|
|
7ba5f3e5b2 | ||
|
|
0ec1587948 | ||
|
|
2fda2c8141 | ||
|
|
f06bf47e72 | ||
|
|
fa6e7e179d | ||
|
|
4272bb83e0 | ||
|
|
1ae2cf3567 |
@@ -415,10 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
|
||||
// System prompts share the cache-point convention: emit the text block, then
|
||||
// optionally a positional `cachePoint` marker.
|
||||
const lowerSystem = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
system: ReadonlyArray<LLMRequest["system"][number]>,
|
||||
) => {
|
||||
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
|
||||
const content = system
|
||||
.filter((part) => part.text.length > 0)
|
||||
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
@@ -508,7 +505,6 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
|
||||
interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly finishedTools: ReadonlySet<number>
|
||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
|
||||
// one finish after both chunks have had a chance to arrive.
|
||||
@@ -620,16 +616,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.contentBlockDelta?.delta?.toolUse) {
|
||||
const index = event.contentBlockDelta.contentBlockIndex
|
||||
if (state.finishedTools.has(index)) return [state, []] as const
|
||||
const result = ToolStream.appendExisting(
|
||||
ADAPTER,
|
||||
// A delta for a block that is not open, whether it already stopped or never
|
||||
// started, has nothing to attach to and is dropped.
|
||||
const result = ToolStream.append(
|
||||
state.tools,
|
||||
index,
|
||||
event.contentBlockDelta.contentBlockIndex,
|
||||
event.contentBlockDelta.delta.toolUse.input,
|
||||
"Bedrock Converse tool delta is missing its tool call",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
if (!result) return [state, []] as const
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...result.events)
|
||||
@@ -668,7 +662,6 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
state.hasToolCalls,
|
||||
lifecycle,
|
||||
tools: result.tools,
|
||||
finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
|
||||
reasoningSignatures: Object.fromEntries(
|
||||
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
|
||||
),
|
||||
@@ -765,7 +758,6 @@ export const protocol = Protocol.make({
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
finishedTools: new Set<number>(),
|
||||
finishReason: undefined,
|
||||
usage: undefined,
|
||||
hasToolCalls: false,
|
||||
|
||||
@@ -433,7 +433,6 @@ export interface ParserState {
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
|
||||
interface ReasoningStreamItem {
|
||||
readonly open: boolean
|
||||
readonly encryptedContent: string | null | undefined
|
||||
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
|
||||
// strings, but typing the map as `Record<number, ...>` documents intent
|
||||
@@ -950,7 +949,7 @@ export const normalize = (state: ParserState, input: Event): NormalizedEvent =>
|
||||
|
||||
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
|
||||
if (!item || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Object.entries(item.summaryParts)
|
||||
@@ -990,7 +989,7 @@ const startReasoningSummaryPart = (state: ParserState, itemID: string, index: nu
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!event.delta || !item?.open) return [state, NO_EVENTS]
|
||||
if (!event.delta || !item) return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
|
||||
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
|
||||
@@ -1015,7 +1014,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
|
||||
// as a single delta unless that summary index already streamed one.
|
||||
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
|
||||
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
|
||||
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
|
||||
@@ -1074,7 +1073,6 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
open: true,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "active" },
|
||||
deltaIndexes: new Set(),
|
||||
@@ -1112,7 +1110,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
|
||||
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item?.open) return [state, NO_EVENTS]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
|
||||
return [
|
||||
{
|
||||
@@ -1247,7 +1245,6 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
}
|
||||
|
||||
if (item.type === "reasoning") {
|
||||
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
|
||||
const summary: Array<string | undefined> = []
|
||||
@@ -1274,53 +1271,14 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
|
||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
|
||||
}
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
...reasoningItem,
|
||||
open: false,
|
||||
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
const reasoningItems = { ...state.reasoningItems }
|
||||
delete reasoningItems[item.id]
|
||||
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
|
||||
}
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(
|
||||
LLMEvent.reasoningEnd({
|
||||
id: item.id,
|
||||
providerMetadata: metadata,
|
||||
text: itemText,
|
||||
}),
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
open: false,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "concluded" },
|
||||
deltaIndexes: new Set(),
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata, text: itemText }))
|
||||
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||
}
|
||||
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
@@ -159,6 +159,17 @@ export const appendOrStart = <K extends StreamKey>(
|
||||
return appendTool(tools, key, tool, delta.text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append argument text to a started tool. Returns `undefined` when no tool is
|
||||
* open under `key`, for protocols that ignore deltas without a matching block.
|
||||
*/
|
||||
export const append = <K extends StreamKey>(tools: State<K>, key: K, text: string): AppendOutcome<K> | undefined => {
|
||||
const current = tools[key]
|
||||
if (!current) return undefined
|
||||
if (text.length === 0) return { tools, tool: current, events: [] }
|
||||
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append argument text to a tool that must already have been started. This keeps
|
||||
* protocols honest when their stream grammar promises a start event before any
|
||||
@@ -170,12 +181,7 @@ export const appendExisting = <K extends StreamKey>(
|
||||
key: K,
|
||||
text: string,
|
||||
missingToolMessage: string,
|
||||
): AppendOutcome<K> | AIError => {
|
||||
const current = tools[key]
|
||||
if (!current) return eventError(route, missingToolMessage)
|
||||
if (text.length === 0) return { tools, tool: current, events: [] }
|
||||
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
|
||||
}
|
||||
): AppendOutcome<K> | AIError => append(tools, key, text) ?? eventError(route, missingToolMessage)
|
||||
|
||||
/**
|
||||
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
||||
|
||||
@@ -713,9 +713,10 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores late tool deltas after contentBlockStop", () =>
|
||||
it.effect("ignores tool deltas without an open tool block", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["contentBlockDelta", { contentBlockIndex: 5, delta: { toolUse: { input: "{}" } } }],
|
||||
[
|
||||
"contentBlockStart",
|
||||
{
|
||||
@@ -745,27 +746,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects tool deltas without contentBlockStart", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Bedrock Converse tool delta is missing its tool call",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers incomplete tool input at finalization", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
|
||||
@@ -71,7 +71,7 @@ function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
|
||||
}
|
||||
|
||||
describe("Open Responses basic-item lifecycles", () => {
|
||||
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
|
||||
it.effect("closes implicit summary boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
|
||||
const events = yield* collect(
|
||||
@@ -90,12 +90,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
delta: "Third",
|
||||
},
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
|
||||
completed,
|
||||
)
|
||||
|
||||
@@ -129,7 +123,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
|
||||
it.effect("preserves done-only reasoning text and encryption", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "reasoning",
|
||||
@@ -139,11 +133,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "late" },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "late final" },
|
||||
completed,
|
||||
// Route termination must also prevent events after response completion.
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_after" } },
|
||||
|
||||
@@ -2861,7 +2861,6 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Think" },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
story(`keeps the Open in border visible without hovering (${theme})`, async ({ mount, page }, testInfo) => {
|
||||
const component = await mount("ui-split-button--open-in", { globals: { theme } })
|
||||
const control = component.locator('[data-component="split-button-v2"]')
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toBeVisible()
|
||||
await expect(control).not.toHaveCSS("box-shadow", "none")
|
||||
const border = await control.evaluate((element) => getComputedStyle(element).boxShadow)
|
||||
|
||||
await component.getByRole("button", { name: "Open options" }).hover()
|
||||
await expect(control).toHaveCSS("box-shadow", border)
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveCSS("box-shadow", border)
|
||||
await control.screenshot({ path: testInfo.outputPath(`open-in-${theme}.png`) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { benchmark, expect } from "./benchmark"
|
||||
import { openCommandPalette } from "../utils/command-palette"
|
||||
|
||||
benchmark.use({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
serviceWorkers: "block",
|
||||
traceScope: "interaction",
|
||||
trace: "off",
|
||||
video: "off",
|
||||
})
|
||||
|
||||
for (const home of [false, true]) {
|
||||
benchmark(`command lookup from ${home ? "home" : "session"}`, async ({ page, report }) => {
|
||||
const { dialog, input } = await openCommandPalette(page, home)
|
||||
const title = home ? "Open settings" : "Copy Session ID"
|
||||
const query = home ? "open settings" : "copy session"
|
||||
// Measure input-to-selected-result in the renderer, without assertion polling overhead.
|
||||
await input.evaluate((element, title) => {
|
||||
element.addEventListener(
|
||||
"input",
|
||||
() => {
|
||||
performance.mark("palette-input")
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.querySelectorAll('[role="dialog"] [role="option"]').length !== 1) return
|
||||
const selected = document.querySelector('[role="dialog"] [role="option"][aria-selected="true"]')
|
||||
if (!selected?.textContent?.includes(title)) return
|
||||
performance.measure("palette-result", "palette-input")
|
||||
observer.disconnect()
|
||||
})
|
||||
observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true })
|
||||
},
|
||||
{ once: true, capture: true },
|
||||
)
|
||||
}, title)
|
||||
await input.fill(query)
|
||||
await expect(dialog.getByRole("option")).toHaveCount(1)
|
||||
await expect(dialog.getByRole("option", { name: new RegExp(`^${title}(?:$| )`) })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
const result = await page.evaluate(() =>
|
||||
performance.getEntriesByName("palette-result").map((entry) => entry.duration),
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
report({ inputToResultMs: result[0] }, { home, query, data: "fixture; immediate server responses" })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { openCommandPalette, paletteSession } from "../utils/command-palette"
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
test("failed event-driven reads report an error and recover without an unhandled rejection", async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
const palette = await openCommandPalette(page)
|
||||
const path = `**/api/session/${paletteSession.id}`
|
||||
await page.route(path, (route) => route.abort("failed"))
|
||||
const requested = page.waitForRequest(path)
|
||||
await page.evaluate((sessionID) => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([
|
||||
{
|
||||
id: "evt_failed_refresh",
|
||||
created: 2,
|
||||
type: "session.viewed",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, idle: 2 },
|
||||
},
|
||||
])
|
||||
}, paletteSession.id)
|
||||
await requested
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await palette.input.fill("copy session")
|
||||
await expect(palette.dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
await palette.input.press("Escape")
|
||||
await page.unroute(path)
|
||||
await page.evaluate((sessionID) => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([
|
||||
{
|
||||
id: "evt_recovered_refresh",
|
||||
created: 3,
|
||||
type: "session.renamed",
|
||||
durable: { aggregateID: sessionID, seq: 2, version: 1 },
|
||||
data: { sessionID, title: "Recovered session" },
|
||||
},
|
||||
])
|
||||
}, paletteSession.id)
|
||||
await expect(page.getByRole("heading", { name: "Recovered session", exact: true })).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { captureConsoleWarnings, openCommandPalette, paletteSession } from "../utils/command-palette"
|
||||
|
||||
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
|
||||
|
||||
test("copies the session ID while file and session searches are still pending", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(/\/api\/(session\?|fs\/find\?)/, async (route) => {
|
||||
await release.promise
|
||||
await route.fallback()
|
||||
})
|
||||
await input.pressSequentially("copy session")
|
||||
const copy = dialog.getByRole("option", { name: "Copy Session ID", exact: true })
|
||||
await expect(copy).toHaveAttribute("aria-selected", "true")
|
||||
await input.press("Enter")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(paletteSession.id)
|
||||
await expect(page.locator('[data-testid^="toast-v2-"] [data-slot="icon-svg"]')).toBeVisible()
|
||||
expect(warnings).toEqual([])
|
||||
release.resolve()
|
||||
})
|
||||
|
||||
test("home commands do not wait for session search", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page, true)
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
await release.promise
|
||||
await route.fallback()
|
||||
})
|
||||
await input.fill("open settings")
|
||||
await expect(dialog.getByRole("option")).toHaveCount(1)
|
||||
await expect(dialog.getByRole("option", { name: /^Open settings/ })).toHaveAttribute("aria-selected", "true")
|
||||
await input.press("Enter")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
|
||||
release.resolve()
|
||||
})
|
||||
|
||||
test("appends search results without resetting the selected command", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const files = Promise.withResolvers<void>()
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
await page.route("**/api/fs/find?*", async (route) => {
|
||||
await files.promise
|
||||
await route.fulfill({ json: { data: [{ path: "copy.txt", type: "file" }] } })
|
||||
})
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
await sessions.promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "Copy fixture" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
await input.fill("copy")
|
||||
const project = dialog.getByRole("option", { name: "Copy Project ID", exact: true })
|
||||
await expect(project).toBeVisible()
|
||||
// Select a non-first command with the keyboard before remote results arrive.
|
||||
await input.press("ArrowDown")
|
||||
await expect(project).toHaveAttribute("aria-selected", "true")
|
||||
files.resolve()
|
||||
await expect(dialog.getByRole("option", { name: "/ copy.txt", exact: true })).toBeVisible()
|
||||
await expect(project).toHaveAttribute("aria-selected", "true")
|
||||
// File results are usable even while sessions are still pending.
|
||||
sessions.resolve()
|
||||
await expect(dialog.getByRole("option", { name: /Copy fixture/ })).toBeVisible()
|
||||
await expect(project).toHaveAttribute("aria-selected", "true")
|
||||
await input.fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("keeps the automatically selected file when session results arrive later", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
await page.route("**/api/fs/find?*", (route) =>
|
||||
route.fulfill({ json: { data: [{ path: "README.md", type: "file" }] } }),
|
||||
)
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
await sessions.promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "README work" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
await input.fill("README")
|
||||
const file = dialog.getByRole("option", { name: "/ README.md", exact: true })
|
||||
await expect(file).toHaveAttribute("aria-selected", "true")
|
||||
sessions.resolve()
|
||||
await expect(dialog.getByRole("option", { name: /README work/ })).toBeVisible()
|
||||
await expect(file).toHaveAttribute("aria-selected", "true")
|
||||
await input.press("Enter")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.getByRole("tab", { name: "README.md", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { captureConsoleWarnings, openCommandPalette } from "../utils/command-palette"
|
||||
|
||||
test.use({ serviceWorkers: "block", video: "off" })
|
||||
|
||||
test("opening and closing files does not duplicate tab commands", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const palette = await openCommandPalette(page)
|
||||
await page.route("**/api/fs/find?*", (route) =>
|
||||
route.fulfill({
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
json: { data: [{ path: "fixture.txt", type: "file" }] },
|
||||
}),
|
||||
)
|
||||
await palette.input.fill("fixture.txt")
|
||||
await palette.dialog.getByRole("option", { name: /fixture\.txt/ }).click()
|
||||
const file = page.getByRole("tab", { name: /fixture\.txt/ })
|
||||
await expect(file).toBeVisible()
|
||||
await expect(palette.dialog).toHaveCount(0)
|
||||
await page
|
||||
.getByRole("complementary", { name: "Review and files" })
|
||||
.getByRole("button", { name: "Close tab", exact: true })
|
||||
.click()
|
||||
await expect(file).toHaveCount(0)
|
||||
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test("navigation replaces commands without retaining disposed owners", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const palette = await openCommandPalette(page, true)
|
||||
await palette.input.press("Escape")
|
||||
await expect(palette.dialog).toHaveCount(0)
|
||||
await page
|
||||
.getByRole("region", { name: "Recent sessions" })
|
||||
.getByRole("button", { name: /Palette fixture session/ })
|
||||
.click()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await page.keyboard.press("ControlOrMeta+t")
|
||||
await expect(page).toHaveURL(/\/new-session\?/)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await page.locator('[data-component="composer-editor"]').blur()
|
||||
await page.keyboard.press("Control+l")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await expect(dialog.getByRole("textbox")).toHaveAttribute("placeholder", "Search files, commands, and sessions")
|
||||
await dialog.getByRole("textbox").fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveCount(0)
|
||||
await dialog.getByRole("textbox").press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
|
||||
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
|
||||
for (const count of [3, 4]) {
|
||||
await page.getByRole("button", { name: "New session", exact: true }).click()
|
||||
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(count)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
}
|
||||
await page.setViewportSize({ width: 600, height: 800 })
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-titlebar-tab-link]')).toHaveCount(4)
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toHaveCount(4)
|
||||
await page.keyboard.press("ControlOrMeta+w")
|
||||
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(3)
|
||||
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
|
||||
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await dialog.getByRole("textbox").fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
@@ -25,6 +26,53 @@ for (const viewport of [
|
||||
{ name: "desktop", width: 1280, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
]) {
|
||||
test(`keeps Session in the tab until the generated title arrives on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const mock = await openDraft(page, { untitled: true })
|
||||
const label = page.locator(
|
||||
viewport.name === "mobile"
|
||||
? '[data-slot="mobile-tab-title"]'
|
||||
: '[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]',
|
||||
)
|
||||
await expect(label).toHaveText("Session")
|
||||
const pending = await submitPending(page, mock)
|
||||
const spinner = page.locator(
|
||||
viewport.name === "mobile"
|
||||
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
|
||||
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
|
||||
)
|
||||
await expect(spinner).toBeVisible()
|
||||
await testInfo.attach("pending-tab-title", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
await expect(label).toHaveText("Session")
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(label).toHaveText("Session")
|
||||
|
||||
if (viewport.name === "mobile") {
|
||||
await label.click()
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
|
||||
await expect(tab.locator("[data-titlebar-tab-title]")).toHaveText("Session")
|
||||
await tab.click()
|
||||
await expect(drawer).toBeHidden()
|
||||
}
|
||||
|
||||
mock.events.push({
|
||||
id: "evt_generated_title",
|
||||
type: "session.renamed",
|
||||
created: Date.now(),
|
||||
location: { directory: workspace },
|
||||
durable: { aggregateID: pending.sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID: pending.sessionID, title: "Generated session title" },
|
||||
})
|
||||
await expect(label).toHaveText("Generated session title")
|
||||
})
|
||||
|
||||
test(`shows a pending workspace session immediately on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const mock = await openDraft(page)
|
||||
@@ -335,6 +383,61 @@ test("restores the draft after closing and revisiting a pending session that fai
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("executes a selected slash command after creating its worktree", async ({ page }, testInfo) => {
|
||||
const events: OpenCodeEvent[] = []
|
||||
const mock = await openDraft(page, { command: true, events: () => events.splice(0) })
|
||||
const commands: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const expanded =
|
||||
"Review the latest commit for correctness and regressions. Check the relevant tests and report actionable findings."
|
||||
await page.route("**/api/session/*/command", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const sessionID = new URL(route.request().url()).pathname.split("/")[3]
|
||||
commands.push({ sessionID, body: route.request().postDataJSON() })
|
||||
// The server owns command expansion; the client receives the expanded inbox item.
|
||||
events.push({
|
||||
id: "evt_workspace_review",
|
||||
type: "session.inbox.enqueued",
|
||||
created: Date.now(),
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID,
|
||||
inboxID: "msg_workspace_review",
|
||||
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await route.fulfill({ status: 204, headers })
|
||||
})
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await editor.fill("/review")
|
||||
const suggestion = page.getByRole("button", { name: "/review Review changes", exact: true })
|
||||
await expect(suggestion).toBeVisible()
|
||||
await suggestion.click()
|
||||
await expect(editor).toHaveText("/review")
|
||||
const pending = await submitPending(page, mock, "/review latest commit")
|
||||
await draftFollowUp(page)
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect
|
||||
.poll(() => commands)
|
||||
.toEqual([
|
||||
{
|
||||
sessionID: pending.sessionID,
|
||||
body: { command: "review", text: "latest commit", files: [], agents: [], skills: [], delivery: "steer" },
|
||||
},
|
||||
])
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText(expanded)
|
||||
await expect(editor).toHaveText(followUp)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
|
||||
expect(mock.prompts).toEqual([])
|
||||
await testInfo.attach("expanded-worktree-command", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("expanded-worktree-command.png") }),
|
||||
contentType: "image/png",
|
||||
})
|
||||
})
|
||||
|
||||
async function draftFollowUp(page: Page) {
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await editor.pressSequentially("!")
|
||||
@@ -346,12 +449,16 @@ async function draftFollowUp(page: Page) {
|
||||
await expect(editor).toHaveText(followUp)
|
||||
}
|
||||
|
||||
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
|
||||
async function openDraft(
|
||||
page: Page,
|
||||
options?: { failSessionCreate?: boolean; untitled?: boolean; command?: boolean; events?: () => OpenCodeEvent[] },
|
||||
) {
|
||||
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
|
||||
const calls: string[] = []
|
||||
const worktreeRequests: Record<string, unknown>[] = []
|
||||
const creates: Record<string, unknown>[] = []
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const events: OpenCodeEvent[] = []
|
||||
const project = {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
@@ -378,6 +485,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
sessions,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
events: options?.events ?? (() => events.splice(0)),
|
||||
})
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
@@ -404,7 +512,10 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
return route.fulfill({ status: 500, json: { message: "Session creation failed in the fixture" }, headers })
|
||||
}
|
||||
if (typeof body.id !== "string") throw new Error("Session creation must use the client-reserved ID")
|
||||
const session = currentSession({ ...body, id: body.id, projectID, title: "Created workspace session" }, workspace)
|
||||
const session = currentSession(
|
||||
{ ...body, id: body.id, projectID, title: options?.untitled ? "" : "Created workspace session" },
|
||||
workspace,
|
||||
)
|
||||
sessions.push(session)
|
||||
return route.fulfill({ json: { data: session }, headers })
|
||||
})
|
||||
@@ -436,6 +547,17 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
if (options?.command) {
|
||||
await page.route("**/api/command?**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
|
||||
data: [{ name: "review", description: "Review changes" }],
|
||||
},
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
}
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, otherID, server }) => {
|
||||
localStorage.setItem(
|
||||
@@ -461,11 +583,11 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
return { worktree, worktreeRequests, calls, creates, prompts }
|
||||
return { worktree, worktreeRequests, calls, creates, prompts, events }
|
||||
}
|
||||
|
||||
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
|
||||
await page.locator('[data-component="composer-editor"]').fill(text)
|
||||
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>, prompt = text) {
|
||||
await page.locator('[data-component="composer-editor"]').fill(prompt)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname))
|
||||
@@ -481,7 +603,7 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
|
||||
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
|
||||
await expect(message).toHaveCount(1)
|
||||
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(prompt)
|
||||
await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/)
|
||||
const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "")
|
||||
await expect(shimmer).toBeVisible()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import { createMockServerHandler } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
@@ -8,6 +9,8 @@ const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||
const childB = { ...session("ses_server_b_child", sessionB.directory, "Server B subagent"), parentID: sessionB.id }
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
test("tab busy indicator reflects activity in the tab session family", async ({ page }, info) => {
|
||||
await mockServers(page)
|
||||
await page.addInitScript(
|
||||
@@ -27,7 +30,7 @@ test("tab busy indicator reflects activity in the tab session family", async ({
|
||||
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(hrefB)
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
|
||||
|
||||
// The parent is idle, but its tab remains active while the background child runs.
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
|
||||
@@ -52,65 +55,61 @@ function session(id: string, directory: string, title: string) {
|
||||
}
|
||||
|
||||
async function mockServers(page: Page) {
|
||||
// Both servers stay connected while the client hydrates their active-session snapshots.
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
const servers = new Map(
|
||||
[sessionA, sessionB].map(
|
||||
(current) =>
|
||||
[
|
||||
current === sessionA ? serverA : serverB,
|
||||
createMockServerHandler({
|
||||
directory: current.directory,
|
||||
project: {
|
||||
id: current.projectID,
|
||||
worktree: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
sessions: current === sessionB ? [current, childB] : [current],
|
||||
sessionStatus: current === sessionB ? { [childB.id]: { type: "running" } } : {},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
pageMessages: () => ({ items: [] }),
|
||||
}),
|
||||
] as const,
|
||||
),
|
||||
)
|
||||
page.on("close", () => servers.forEach((server) => void server.dispose()))
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
const server = servers.get(url.origin)
|
||||
if (!server) return route.fallback()
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json(route, { data: url.origin === serverB ? { [childB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, {
|
||||
data: url.origin === serverB ? [currentSession(current), currentSession(childB)] : [currentSession(current)],
|
||||
cursor: {},
|
||||
if (directory && directory !== current.directory)
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
json: { name: "InvalidDirectory" },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||
return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: current.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||
return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
|
||||
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||
const project = {
|
||||
id: current.projectID,
|
||||
canonical: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
}
|
||||
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
data: { branch: "main", defaultBranch: "main" },
|
||||
if (route.request().method() === "OPTIONS")
|
||||
return route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
|
||||
})
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
|
||||
const body = route.request().postDataBuffer()
|
||||
const response = await server.handler(
|
||||
new Request(url, {
|
||||
method: route.request().method(),
|
||||
headers: route.request().headers(),
|
||||
body: body ? Uint8Array.from(body) : undefined,
|
||||
}),
|
||||
)
|
||||
return route.fulfill({
|
||||
status: response.status,
|
||||
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -92,10 +92,16 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Open file" }).locator("use")).toHaveAttribute(
|
||||
"href",
|
||||
"#opencode-v2-icon-plus",
|
||||
)
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
const openFileTab = panel.getByRole("tab", { name: "Open file" })
|
||||
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
|
||||
await expect(openFileTab).toHaveAttribute("data-selected", "")
|
||||
await expect(openFileTab.locator("..")).toHaveCSS("padding-inline-end", "4px")
|
||||
await expect(openFileTab.locator("..")).toHaveCSS("gap", "8px")
|
||||
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
|
||||
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
|
||||
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
|
||||
@@ -114,6 +120,8 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
|
||||
await panel.getByRole("button", { name: "README.md" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
|
||||
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("gap", "8px")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
@@ -129,6 +137,8 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||
await filter.press("Enter")
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("gap", "8px")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Locator } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewTogglePosition"
|
||||
const sessionID = "ses_review_toggle_position"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_review_toggle_position",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-toggle-position",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "review-toggle-position",
|
||||
projectID: "proj_review_toggle_position",
|
||||
directory,
|
||||
title: "Review toggle position",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
})
|
||||
|
||||
for (const width of [1000, 1440]) {
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`keeps the review toggle at the outer header edge (${width}px, ${direction})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Review toggle position")
|
||||
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
|
||||
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const header = page.locator("[data-session-title]")
|
||||
const panel = page.locator("#review-panel")
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
const closed = await toggle.boundingBox()
|
||||
if (!closed) throw new Error("Review toggle bounds are unavailable")
|
||||
const headerBox = await header.boundingBox()
|
||||
if (!headerBox) throw new Error("Session header bounds are unavailable")
|
||||
expect(closed.y).toBeGreaterThanOrEqual(headerBox.y)
|
||||
expect(closed.y + closed.height).toBeLessThanOrEqual(headerBox.y + headerBox.height)
|
||||
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
||||
await expect(toggle).toHaveCount(1)
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await panel.boundingBox()
|
||||
if (!box) return false
|
||||
return (
|
||||
closed.x >= box.x &&
|
||||
closed.x + closed.width <= box.x + box.width &&
|
||||
closed.y >= box.y &&
|
||||
closed.y + closed.height <= box.y + 52
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await panel.locator('[data-slot="session-side-panel-actions"]').boundingBox()
|
||||
return box ? box.y + box.height / 2 : undefined
|
||||
})
|
||||
.toBe(closed.y + closed.height / 2)
|
||||
|
||||
await toggle.press("Enter")
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect(toggle).toHaveCount(1)
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
|
||||
})
|
||||
|
||||
test(`keeps terminal controls clear of the review toggle (${width}px, ${direction})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const ptys: { id: string; title: string }[] = []
|
||||
const removed: string[] = []
|
||||
await page.route("**/api/pty**", async (route) => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
const location = { directory, project: { id: "proj_review_toggle_position", directory } }
|
||||
if (route.request().method() === "DELETE") {
|
||||
removed.push(path.split("/").at(-1)!)
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (path.endsWith("/connect-token")) {
|
||||
return route.fulfill({ json: { location, data: { ticket: "e2e-ticket", expires_in: 60 } } })
|
||||
}
|
||||
if (path === "/api/pty" && route.request().method() === "POST") {
|
||||
const pty = { id: `pty_review_${ptys.length + 1}`, title: `Terminal ${ptys.length + 1}` }
|
||||
ptys.push(pty)
|
||||
return route.fulfill({ json: { location, data: pty } })
|
||||
}
|
||||
return route.fulfill({ json: { location, data: ptys.find((pty) => path.endsWith(pty.id)) ?? ptys } })
|
||||
})
|
||||
await page.routeWebSocket(/\/api\/pty\/pty_review_\d+\/connect/, () => undefined)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Review toggle position")
|
||||
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
|
||||
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.getByRole("region", { name: "Terminal", exact: true })
|
||||
await expect(terminal.getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
for (const number of [2, 3, 4]) {
|
||||
await terminal.getByRole("button", { name: "New terminal", exact: true }).click()
|
||||
await expect(terminal.getByRole("tab", { name: `Terminal ${number}`, exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tabs = await terminal.getByRole("tablist").boundingBox()
|
||||
const button = await toggle.boundingBox()
|
||||
if (!tabs || !button) return false
|
||||
return direction === "rtl" ? tabs.x >= button.x + button.width : tabs.x + tabs.width <= button.x
|
||||
})
|
||||
.toBe(true)
|
||||
await expectTerminalControlsAligned(terminal, toggle)
|
||||
const fourth = terminal.locator('[data-slot="tabs-trigger-wrapper"][data-value="pty_review_4"]')
|
||||
await fourth.getByRole("button", { name: "Close terminal", exact: true }).click()
|
||||
await expect(terminal.getByRole("tab")).toHaveText(["Terminal 1", "Terminal 2", "Terminal 3"])
|
||||
expect(removed).toEqual(["pty_review_4"])
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await terminal.getByRole("button", { name: "New terminal", exact: true }).click()
|
||||
await expect(terminal.getByRole("tab", { name: "Terminal 5", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
const position = await toggle.boundingBox()
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "false")
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(position)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const actions = await page.locator('[data-slot="session-side-panel-actions"]').boundingBox()
|
||||
const button = await toggle.boundingBox()
|
||||
if (!actions || !button) return undefined
|
||||
return actions.y + actions.height / 2 - (button.y + button.height / 2)
|
||||
})
|
||||
.toBe(0)
|
||||
await toggle.press("Enter")
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(position)
|
||||
await expectTerminalControlsAligned(terminal, toggle)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function expectTerminalControlsAligned(terminal: Locator, toggle: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const centers = await Promise.all(
|
||||
[terminal.getByRole("button", { name: "New terminal", exact: true }), toggle].map((button) =>
|
||||
button.locator("svg").evaluate((element) => {
|
||||
const svg = element as SVGSVGElement
|
||||
const path = svg.getBBox()
|
||||
return new DOMPoint(path.x + path.width / 2, path.y + path.height / 2).matrixTransform(svg.getScreenCTM()!)
|
||||
.y
|
||||
}),
|
||||
),
|
||||
)
|
||||
return centers[0]! - centers[1]!
|
||||
})
|
||||
.toBeCloseTo(0, 1)
|
||||
}
|
||||
@@ -23,7 +23,8 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const header = page.locator("[data-session-title]")
|
||||
const more = header.getByRole("button", { name: "More options", exact: true })
|
||||
const review = header.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const project = header.getByRole("button", { name: fixture.project.name, exact: true })
|
||||
const review = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const details = header.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
@@ -31,22 +32,40 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(details).toBeVisible()
|
||||
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
|
||||
await expect(status).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const boxes = await Promise.all(
|
||||
[header.getByRole("heading"), more, review, details].map((button) => button.boundingBox()),
|
||||
)
|
||||
const [title, menu, sidebar, summary] = boxes
|
||||
if (!title || !menu || !sidebar || !summary) return false
|
||||
return direction === "ltr"
|
||||
? Math.abs(title.x + title.width - menu.x) <= 1 &&
|
||||
menu.x + menu.width <= summary.x &&
|
||||
summary.x + summary.width <= sidebar.x
|
||||
: Math.abs(menu.x + menu.width - title.x) <= 1 &&
|
||||
sidebar.x + sidebar.width <= summary.x &&
|
||||
summary.x + summary.width <= menu.x
|
||||
})
|
||||
.toBe(true)
|
||||
const titleBounds = await header.getByRole("heading").boundingBox()
|
||||
expect(titleBounds).not.toBeNull()
|
||||
for (const editing of [false, true]) {
|
||||
if (editing) {
|
||||
await header.getByRole("heading").click()
|
||||
await expect(header.getByRole("textbox")).toHaveValue(fixture.expected.targetTitle)
|
||||
await expect(header.getByRole("textbox")).toBeFocused()
|
||||
}
|
||||
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-left", "4px")
|
||||
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-right", "4px")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const boxes = await Promise.all(
|
||||
[project, header.locator('[data-slot="session-title-child"]'), more, review, details].map((control) =>
|
||||
control.boundingBox(),
|
||||
),
|
||||
)
|
||||
const [icon, title, menu, sidebar, summary] = boxes
|
||||
if (!icon || !title || !menu || !sidebar || !summary || !titleBounds) return false
|
||||
if (Math.abs(title.y - titleBounds.y) > 0.5 || Math.abs(title.height - titleBounds.height) > 0.5) return false
|
||||
return direction === "ltr"
|
||||
? Math.abs(title.x - icon.x - icon.width - 2) <= 0.5 &&
|
||||
Math.abs(menu.x - title.x - title.width - 2) <= 0.5 &&
|
||||
menu.x + menu.width <= summary.x &&
|
||||
summary.x + summary.width <= sidebar.x
|
||||
: Math.abs(icon.x - title.x - title.width - 2) <= 0.5 &&
|
||||
Math.abs(title.x - menu.x - menu.width - 2) <= 0.5 &&
|
||||
sidebar.x + sidebar.width <= summary.x &&
|
||||
summary.x + summary.width <= menu.x
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
await header.getByRole("textbox").press("Escape")
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
|
||||
await review.click()
|
||||
await expect(review).toHaveAttribute("aria-expanded", "true")
|
||||
@@ -55,6 +74,52 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(review).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await more.click()
|
||||
const options = page.getByRole("menu")
|
||||
await expect(options.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"])
|
||||
if (direction === "ltr") {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [button, menu] = await Promise.all([
|
||||
header.getByRole("button", { name: "More options", exact: true, includeHidden: true }).boundingBox(),
|
||||
options.boundingBox(),
|
||||
])
|
||||
return button && menu ? Math.abs(button.x - menu.x) : Infinity
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
await expect
|
||||
.poll(() =>
|
||||
options.evaluate((element) => {
|
||||
const menu = element.getBoundingClientRect()
|
||||
const rtl = getComputedStyle(element).direction === "rtl"
|
||||
return Math.min(
|
||||
...Array.from(element.querySelectorAll('[data-slot="menu-v2-item-content"]'), (label) => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(label)
|
||||
const text = range.getBoundingClientRect()
|
||||
return rtl ? text.left - menu.left : menu.right - text.right
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBeCloseTo(32, 0)
|
||||
await expect
|
||||
.poll(() =>
|
||||
options.evaluate((element) => {
|
||||
const menu = element.getBoundingClientRect()
|
||||
const divider = element.querySelector('[data-slot="menu-v2-separator"]')?.getBoundingClientRect()
|
||||
const rows = Array.from(element.querySelectorAll('[role="menuitem"]'), (row) => row.getBoundingClientRect())
|
||||
return (
|
||||
!!divider &&
|
||||
Math.abs(divider.left - menu.left) <= 0.5 &&
|
||||
Math.abs(divider.right - menu.right) <= 0.5 &&
|
||||
rows.every(
|
||||
(row) => Math.abs(row.left - menu.left - 2) <= 0.5 && Math.abs(menu.right - row.right - 2) <= 0.5,
|
||||
)
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await status.click()
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { dict } from "../../src/runtime/i18n/ar"
|
||||
import en from "../../src/runtime/i18n/en"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
for (const workspace of [false, true]) {
|
||||
test(`session project menu for ${workspace ? "worktree" : "local"} in ${direction}`, async ({ page }) => {
|
||||
const copy = direction === "rtl" ? dict : en
|
||||
const directory = workspace
|
||||
? "C:/OpenCode/Worktrees/مشروع-42/long-folder-name-for-checking-wrapped-worktree-paths/another-long-folder-name-to-exercise-the-full-path-tooltip"
|
||||
: fixture.directory
|
||||
const project = {
|
||||
...fixture.project,
|
||||
name: workspace
|
||||
? "مشروع Timeline 42 with a long project name that needs truncation and enough additional text to wrap inside the tooltip"
|
||||
: "Timeline project",
|
||||
sandboxes: workspace ? [directory] : [],
|
||||
icon: {
|
||||
url: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="7" fill="blue"/></svg>')}`,
|
||||
},
|
||||
}
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript((direction) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:language",
|
||||
JSON.stringify({ locale: direction === "rtl" ? "ar" : "en" }),
|
||||
)
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, general: { ...settings.general, showProjectIcon: false } }),
|
||||
)
|
||||
}, direction)
|
||||
await page.setViewportSize({ width: workspace ? 900 : 1440, height: 900 })
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const header = page.locator("[data-session-title]")
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", direction)
|
||||
|
||||
const trigger = header.getByRole("button", { name: project.name, exact: true })
|
||||
await expect(trigger).toBeEnabled()
|
||||
await expect(trigger.locator("use")).toHaveAttribute(
|
||||
"href",
|
||||
`#opencode-v2-icon-${workspace ? "workspace-isolated" : "monitor"}`,
|
||||
)
|
||||
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
await trigger.hover()
|
||||
await expect(trigger).not.toHaveCSS("background-color", background)
|
||||
await expect(page.getByRole("tooltip")).toHaveText(project.name)
|
||||
await trigger.click()
|
||||
|
||||
const menu = page.getByRole("menu", { name: project.name, exact: true })
|
||||
const settings = menu.getByRole("menuitem", { name: "Edit project", exact: true })
|
||||
const projectItem = menu.getByRole("menuitem", { name: project.name, exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.getByRole("tooltip")).toBeHidden()
|
||||
await expect(menu.getByText(project.name, { exact: true })).toBeVisible()
|
||||
await expect(menu.locator('[data-slot="project-avatar-image"]')).toHaveAttribute("src", project.icon.url)
|
||||
await expect(menu.getByText(directory, { exact: true })).toBeVisible()
|
||||
await expect(menu.getByText(directory, { exact: true })).toHaveAttribute("dir", "ltr")
|
||||
await expect(menu.locator('use[href="#opencode-v2-icon-folder"]')).toHaveCount(1)
|
||||
await expect(menu).toHaveCSS("direction", direction)
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([project.name, directory, "Edit project"])
|
||||
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
|
||||
await expect(settings).toBeEnabled()
|
||||
await expect
|
||||
.poll(() => menu.evaluate((element) => element.getBoundingClientRect().width))
|
||||
.toBeLessThanOrEqual(320)
|
||||
for (const text of [project.name, directory]) {
|
||||
const label = menu.getByText(text, { exact: true })
|
||||
await expect(label).toHaveCSS("text-overflow", "ellipsis")
|
||||
await expect(label).toHaveCSS("white-space", "nowrap")
|
||||
if (workspace) {
|
||||
await expect.poll(() => label.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
|
||||
}
|
||||
}
|
||||
await expect.poll(() => menu.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
const icons = menu.locator(
|
||||
'[data-component="project-avatar-v2"], [data-slot="icon-svg"]:not([data-slot="session-project-open-icon"] *)',
|
||||
)
|
||||
await expect(icons).toHaveCount(3)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [button, centers] = await Promise.all([
|
||||
trigger.boundingBox(),
|
||||
icons.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
return box.x + box.width / 2
|
||||
}),
|
||||
),
|
||||
])
|
||||
return !!button && centers.every((center) => Math.abs(center - button.x - button.width / 2) <= 1)
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
if (!workspace) await page.clock.install()
|
||||
for (const text of [project.name, directory]) {
|
||||
const label = menu.getByText(text, { exact: true })
|
||||
const item = menu.getByRole("menuitem", { name: text, exact: true })
|
||||
const anchor = item.locator("..")
|
||||
const openIcon = item.locator('[data-slot="session-project-open-icon"]')
|
||||
const content = item.locator(".session-project-link-content")
|
||||
const width = await label.evaluate((element) => element.getBoundingClientRect().width)
|
||||
await expect(openIcon).toHaveCount(text === directory ? 1 : 0)
|
||||
await anchor.hover()
|
||||
await expect(content).toHaveCSS("mask-image", "none")
|
||||
await expect.poll(() => label.evaluate((element) => element.getBoundingClientRect().width)).toBe(width)
|
||||
if (text === directory) {
|
||||
await expect(openIcon).toHaveCSS("opacity", "0")
|
||||
await expect(openIcon.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-up-right")
|
||||
await expect
|
||||
.poll(() => openIcon.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [row, icon] = await Promise.all([item.boundingBox(), openIcon.boundingBox()])
|
||||
if (!row || !icon) return false
|
||||
return (
|
||||
Math.abs(row.y + row.height / 2 - icon.y - icon.height / 2) <= 0.5 &&
|
||||
Math.abs((direction === "rtl" ? icon.x - row.x : row.x + row.width - icon.x - icon.width) - 12) <= 0.5
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
await expect(label).toHaveCSS("cursor", "default")
|
||||
await expect(anchor).toHaveCSS("cursor", "default")
|
||||
const tooltip = page.getByRole("tooltip")
|
||||
if (workspace) {
|
||||
await expect(tooltip).toHaveText(text)
|
||||
await expect(tooltip).toHaveCSS("white-space", "normal")
|
||||
await expect
|
||||
.poll(() => tooltip.evaluate((element) => element.getBoundingClientRect().width))
|
||||
.toBeLessThanOrEqual(480)
|
||||
await expect
|
||||
.poll(() =>
|
||||
tooltip
|
||||
.getByText(text, { exact: true })
|
||||
.evaluate(
|
||||
(element) =>
|
||||
element.getBoundingClientRect().height > Number.parseFloat(getComputedStyle(element).lineHeight),
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [row, tip] = await Promise.all([anchor.boundingBox(), tooltip.boundingBox()])
|
||||
return !!row && !!tip && Math.abs(row.y - tip.y - tip.height - 2) <= 1
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
if (!workspace) {
|
||||
await page.clock.runFor(500)
|
||||
await expect(tooltip).toBeHidden()
|
||||
}
|
||||
await settings.hover()
|
||||
await expect(tooltip).toBeHidden()
|
||||
if (text === directory) await expect(openIcon).toHaveCSS("opacity", "0")
|
||||
await expect(content).toHaveCSS("mask-image", "none")
|
||||
}
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await trigger.press("ArrowDown")
|
||||
await expect(projectItem).toBeFocused()
|
||||
await page.keyboard.press("ArrowDown")
|
||||
const pathItem = menu.getByRole("menuitem", { name: directory, exact: true })
|
||||
await expect(pathItem).toBeFocused()
|
||||
if (workspace) await expect(page.getByRole("tooltip")).toHaveText(directory)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(pathItem).toBeFocused()
|
||||
await page.keyboard.press("Space")
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(pathItem).toBeFocused()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await trigger.press("ArrowDown")
|
||||
await expect(projectItem).toBeFocused()
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await expect(pathItem).toBeFocused()
|
||||
if (workspace) await expect(page.getByRole("tooltip")).toHaveText(directory)
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await expect(settings).toBeFocused()
|
||||
await expect(page.getByRole("tooltip")).toBeHidden()
|
||||
await page.keyboard.press("Enter")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
|
||||
project.name,
|
||||
)
|
||||
await expect(menu).toBeHidden()
|
||||
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
for (const selected of [false, true]) {
|
||||
if (selected) {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
}
|
||||
await trigger.click()
|
||||
await expect(projectItem).toBeEnabled()
|
||||
const background = await projectItem.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
await projectItem.hover()
|
||||
await expect(projectItem).not.toHaveCSS("background-color", background)
|
||||
await projectItem.click()
|
||||
await expect(page).toHaveURL(new URL("/", page.url()).href)
|
||||
await expect(menu).toBeHidden()
|
||||
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: project.name })
|
||||
await expect(projectRow).toBeVisible()
|
||||
await expect(projectRow).toHaveAttribute("data-selected", "")
|
||||
await expect(
|
||||
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of ["closed", "unopened"] as const) {
|
||||
test(`session project menu restores ${state} projects before and after messages load`, async ({ page }) => {
|
||||
const directory = "C:/OpenCode/Worktrees/project-menu-recovery"
|
||||
const messages = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: { ...fixture.project, sandboxes: [directory] },
|
||||
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
beforeMessagesResponse: (input) => (input.sessionID === fixture.targetID ? messages.promise : Promise.resolve()),
|
||||
})
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
if (state === "closed") {
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto("/")
|
||||
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name })
|
||||
await expect(projectRow).toBeEnabled()
|
||||
await projectRow.locator("..").getByRole("button", { name: "More options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
|
||||
await expect(projectRow).toHaveCount(0)
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
}
|
||||
if (state === "unopened") await page.goto(stressSessionHref(fixture.targetID))
|
||||
|
||||
const header = page.locator("[data-session-title]")
|
||||
const trigger = header.getByRole("button", { name: fixture.project.name, exact: true })
|
||||
const menu = page.getByRole("menu", { name: fixture.project.name, exact: true })
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
for (const loaded of [false, true]) {
|
||||
if (loaded) {
|
||||
messages.resolve()
|
||||
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
|
||||
}
|
||||
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-workspace-isolated")
|
||||
await trigger.click()
|
||||
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
|
||||
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
|
||||
await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue(
|
||||
fixture.project.name,
|
||||
)
|
||||
await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
await trigger.click()
|
||||
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
|
||||
await expect(page).toHaveURL(new URL("/", page.url()).href)
|
||||
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name })
|
||||
await expect(projectRow).toBeVisible()
|
||||
await expect(projectRow).toHaveAttribute("data-selected", "")
|
||||
await expect(
|
||||
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("path arrow has a glyph when the page has an older icon sprite", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.route(
|
||||
(url) => url.pathname === stressSessionHref(fixture.targetID),
|
||||
async (route) => {
|
||||
const response = await route.fetch()
|
||||
await route.fulfill({
|
||||
response,
|
||||
body: (await response.text()).replace(
|
||||
'<div id="root"',
|
||||
'<svg id="opencode-v2-icon-sprite" width="0" height="0" aria-hidden="true"><symbol id="opencode-v2-icon-monitor" viewBox="0 0 16 16"><path d="M1 1h14v14H1z"/></symbol></svg><div id="root"',
|
||||
),
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const header = page.locator("[data-session-title]")
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
await header.getByRole("button", { name: fixture.project.name, exact: true }).click()
|
||||
const path = page.getByRole("menu").getByRole("menuitem", { name: fixture.directory, exact: true })
|
||||
const arrow = path.locator('[data-slot="session-project-open-icon"]')
|
||||
await expect(arrow).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() => arrow.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
|
||||
.toBeGreaterThan(0)
|
||||
await expect(page.locator("#opencode-v2-icon-sprite")).toHaveCount(1)
|
||||
})
|
||||
@@ -27,6 +27,12 @@ test.beforeEach(async ({ page }) => {
|
||||
})),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
}, directory)
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
|
||||
@@ -50,6 +56,34 @@ test("settings has its own route and returns through app history", async ({ page
|
||||
await expect(home).toHaveAttribute("aria-pressed", "true")
|
||||
})
|
||||
|
||||
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
await page.keyboard.press("Control+t")
|
||||
|
||||
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("recording a new session shortcut stays in settings until recording finishes", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
|
||||
const binding = settings.locator('[data-keybind-id="tab.new"]')
|
||||
await binding.click()
|
||||
await expect(binding).toHaveText("Press keys")
|
||||
await page.keyboard.press("Control+t")
|
||||
|
||||
await expect(binding).toHaveText("Ctrl+T")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(page.locator("[data-titlebar-tab]")).toHaveCount(0)
|
||||
await page.keyboard.press("Control+t")
|
||||
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
})
|
||||
|
||||
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
@@ -105,6 +139,79 @@ test("extensions opens without waiting for MCPs", async ({ page }) => {
|
||||
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
|
||||
})
|
||||
|
||||
test("about opens without waiting for contributors", async ({ page }) => {
|
||||
const contributors = Promise.withResolvers<void>()
|
||||
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
|
||||
await page.route(url, async (route) => {
|
||||
await contributors.promise
|
||||
await route.fulfill({
|
||||
json: [],
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-expose-headers": "Link",
|
||||
Link: `<${url}&page=1004>; rel="last"`,
|
||||
},
|
||||
})
|
||||
})
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest(url)
|
||||
await settings.getByRole("tab", { name: "About", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText(/^Version /)).toBeVisible()
|
||||
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByText(/^v\d+\./)).toHaveCount(0)
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
|
||||
|
||||
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
|
||||
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await settings.getByRole("tab", { name: "About", exact: true }).click()
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
|
||||
const website = settings.getByRole("link", { name: "www.opencode.ai", exact: true })
|
||||
await website.focus()
|
||||
contributors.resolve()
|
||||
await expect(settings.getByRole("link", { name: "988 others", exact: true })).toBeVisible()
|
||||
await expect(website).toBeFocused()
|
||||
})
|
||||
|
||||
test("about is available in the mobile settings menu", async ({ page }) => {
|
||||
await page.route("https://api.github.com/repos/anomalyco/opencode/contributors?*", (route) => route.abort("failed"))
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("button", { name: "Preferences", exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name: "About", exact: true }).click()
|
||||
await expect(settings.getByRole("button", { name: "About", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
|
||||
await settings.getByRole("button", { name: "About", exact: true }).click()
|
||||
await expect(page.getByRole("menuitemradio", { name: "About", exact: true })).toBeChecked()
|
||||
})
|
||||
|
||||
test("about keeps its fallback when the contributor request fails", async ({ page }) => {
|
||||
const contributors = Promise.withResolvers<void>()
|
||||
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
|
||||
await page.route(url, async (route) => {
|
||||
await contributors.promise
|
||||
await route.abort("failed")
|
||||
})
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest(url)
|
||||
await settings.getByRole("tab", { name: "About", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
|
||||
const failed = page.waitForEvent("requestfailed", (request) => request.url() === url)
|
||||
contributors.resolve()
|
||||
await failed
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
test("workspace inventory uses the settings panel scroll area", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/tmp/settings-padding"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_settings_padding",
|
||||
canonical: directory,
|
||||
name: "Settings padding",
|
||||
vcs: "git",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("https://api.github.com/repos/anomalyco/opencode/contributors?*", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
)
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeFocused()
|
||||
})
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1280, height: 720, bottom: false },
|
||||
{ width: 900, height: 600, bottom: false },
|
||||
{ width: 780, height: 600, bottom: false },
|
||||
{ width: 390, height: 844, bottom: false },
|
||||
{ width: 390, height: 844, bottom: true },
|
||||
]) {
|
||||
test.describe(`${viewport.width}px, ${viewport.bottom ? "bottom" : "top"} navigation`, () => {
|
||||
test.use({ viewport: { width: 1280, height: 720 }, contextOptions: { reducedMotion: "reduce" } })
|
||||
|
||||
test("every settings page leaves room below its final content", async ({ page }) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const panel = settings.locator(":scope > .settings > .settings-panel:visible")
|
||||
if (viewport.bottom) {
|
||||
const toggle = settings.locator('[data-action="settings-mobile-titlebar-bottom"]')
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
await expect(toggle.getByRole("switch")).toBeChecked()
|
||||
}
|
||||
let current = "Preferences"
|
||||
for (const name of [
|
||||
"Preferences",
|
||||
"Appearance",
|
||||
"Notifications",
|
||||
"Shortcuts",
|
||||
"Servers",
|
||||
"Projects",
|
||||
"Worktrees",
|
||||
"Providers",
|
||||
"Models",
|
||||
"Extensions",
|
||||
"Experimental",
|
||||
"About",
|
||||
]) {
|
||||
if (viewport.width >= 816) await settings.getByRole("tab", { name, exact: true }).click()
|
||||
if (viewport.width < 816) {
|
||||
await settings.getByRole("button", { name: current, exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name, exact: true }).click()
|
||||
}
|
||||
current = name
|
||||
if (name === "About") await expect(panel.getByText("Released under the MIT License")).toBeVisible()
|
||||
if (name !== "About") {
|
||||
await expect(
|
||||
panel.getByRole("heading", { name: name === "Shortcuts" ? "Keyboard shortcuts" : name, exact: true }),
|
||||
).toBeVisible()
|
||||
}
|
||||
await panel.hover()
|
||||
await page.mouse.wheel(0, 10000)
|
||||
await expect
|
||||
.poll(() => panel.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
panel.evaluate((el) => {
|
||||
const body = el.querySelector(".settings-tab-body, .settings-about-content")!
|
||||
return el.getBoundingClientRect().bottom - body.lastElementChild!.getBoundingClientRect().bottom
|
||||
}),
|
||||
{ message: `${name} bottom clearance` },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(viewport.bottom ? 119.5 : 79.5)
|
||||
await expect
|
||||
.poll(() => page.getByRole("main").evaluate((el) => el.scrollWidth - el.clientWidth))
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ contextOptions: { reducedMotion: "reduce" }, colorScheme: "dark" })
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1280, height: 720 },
|
||||
{ width: 900, height: 600 },
|
||||
{ width: 780, height: 600 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
test(`preferences scroll only inside the panel at ${viewport.width}x${viewport.height}`, async ({ page }) => {
|
||||
const directory = "/tmp/settings-scroll"
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_settings_scroll",
|
||||
canonical: directory,
|
||||
name: "Settings scroll",
|
||||
vcs: "git",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const panel = settings.getByRole("tabpanel")
|
||||
const main = page.getByRole("main")
|
||||
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
|
||||
await expect(settings).toBeFocused()
|
||||
await page.setViewportSize(viewport)
|
||||
await expect(slider).toHaveAccessibleDescription(/Choose how much activity appears in the timeline/)
|
||||
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
|
||||
|
||||
// Wheel over the outer gutter must not move the entire settings screen.
|
||||
await main.hover({ position: { x: 1, y: 200 } })
|
||||
await page.mouse.wheel(0, 10000)
|
||||
await panel.hover()
|
||||
await page.mouse.wheel(0, 10000)
|
||||
await expect.poll(() => panel.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
|
||||
await expect(main).toHaveJSProperty("scrollTop", 0)
|
||||
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeInViewport()
|
||||
|
||||
await slider.scrollIntoViewIfNeeded()
|
||||
await slider.click()
|
||||
await page.keyboard.press("Home")
|
||||
await expect(slider).toHaveValue("0")
|
||||
await page.keyboard.press("ArrowRight")
|
||||
await expect(slider).toHaveValue("1")
|
||||
await expect(slider).toBeFocused()
|
||||
|
||||
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
await expect(
|
||||
settings.getByRole("group", { name: "Set placement and details for each activity category.", exact: true }),
|
||||
).toBeVisible()
|
||||
await panel.hover()
|
||||
await page.mouse.wheel(0, 10000)
|
||||
await expect
|
||||
.poll(() => panel.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect(main).toHaveJSProperty("scrollTop", 0)
|
||||
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
|
||||
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeInViewport()
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import pkg from "../../package.json" with { type: "json" }
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sessionA = session("ses_tab_a", "Tab A session")
|
||||
@@ -185,7 +184,9 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await expect(tabA).toContainText(sessionA.title)
|
||||
await expect(tabB).toContainText(sessionB.title)
|
||||
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
|
||||
await expect(sidebar.getByRole("button", { name: "Home", exact: true })).toHaveText("Home")
|
||||
await expect(
|
||||
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
|
||||
const status = sidebar.getByRole("button", { name: "Status", exact: true })
|
||||
@@ -231,7 +232,163 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await expect(tabB).toBeVisible()
|
||||
})
|
||||
|
||||
test("appearance experimental settings control vertical tab details", async ({ page }) => {
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionA },
|
||||
...Array.from({ length: 24 }, (_, index) => ({
|
||||
type: "draft",
|
||||
server,
|
||||
directory,
|
||||
draftID: `draft_scroll_${index}`,
|
||||
})),
|
||||
{ type: "session", server, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
|
||||
)
|
||||
await page.goto("/")
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
|
||||
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
|
||||
await expect(settings).toHaveText("Settings")
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 360 })
|
||||
await expect(settings).toBeInViewport({ ratio: 1 })
|
||||
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
|
||||
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
|
||||
await expect(settings).toHaveCSS("margin-top", "8px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
|
||||
const content = Math.max(
|
||||
0,
|
||||
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
|
||||
)
|
||||
return element.getBoundingClientRect().height - content
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
|
||||
await scroll.evaluate((element) => element.scrollTo(0, 0))
|
||||
await expect(scroll).toHaveJSProperty("scrollTop", 0)
|
||||
const pinned = await settings.boundingBox()
|
||||
await scroll.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
|
||||
await testInfo.attach(`vertical-tabs-settings-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
|
||||
await scroll.evaluate((element) => element.scrollTo(0, element.scrollHeight))
|
||||
await expect(tabB).toBeInViewport({ ratio: 1 })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tab = await tabB.boundingBox()
|
||||
const viewport = await scroll.boundingBox()
|
||||
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
|
||||
}
|
||||
|
||||
await settings.click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await expect(settings).toHaveAttribute("aria-pressed", "true")
|
||||
await sidebar.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeHidden()
|
||||
await settings.focus()
|
||||
await settings.press("Enter")
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
for (const profile of [
|
||||
{ locale: "en", direction: "ltr" },
|
||||
{ locale: "en", direction: "rtl" },
|
||||
{ locale: "ar", direction: "rtl" },
|
||||
]) {
|
||||
test(`vertical shortcut hints align at the row end: ${profile.locale} ${profile.direction}`, async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionID, locale }) => {
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
appearance: { tabLayout: "vertical" },
|
||||
keybinds: { "home.toggle": "ctrl+alt+h", "tab.new": "ctrl+shift+n" },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ server, sessionID: sessionA.id, locale: profile.locale },
|
||||
)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
await expect(sidebar).toHaveCSS("width", "260px")
|
||||
await page
|
||||
.locator("html")
|
||||
.evaluate((element, direction) => element.setAttribute("dir", direction), profile.direction)
|
||||
await expect(sidebar).toHaveCSS("direction", profile.direction)
|
||||
|
||||
for (const row of [
|
||||
{ action: "home", shortcut: "Ctrl+Alt+H" },
|
||||
{ action: "new-session", shortcut: "Ctrl+Shift+N" },
|
||||
]) {
|
||||
const button = sidebar.locator(`[data-action="vertical-tabs-${row.action}"]`)
|
||||
const hint = button.locator('span[aria-hidden="true"]')
|
||||
await expect(hint).toHaveText(row.shortcut)
|
||||
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
await button.hover()
|
||||
await expect(hint).toHaveCSS("opacity", "1")
|
||||
await expect
|
||||
.poll(() =>
|
||||
hint.evaluate((element) => {
|
||||
const button = element.closest("button")!
|
||||
const row = button.getBoundingClientRect()
|
||||
const hint = element.getBoundingClientRect()
|
||||
return getComputedStyle(button).direction === "rtl" ? hint.left - row.left : row.right - hint.right
|
||||
}),
|
||||
)
|
||||
.toBeCloseTo(8, 1)
|
||||
await page.getByRole("main").hover()
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
}
|
||||
|
||||
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
|
||||
const newSession = sidebar.locator('[data-action="vertical-tabs-new-session"]')
|
||||
await home.focus()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(newSession).toBeFocused()
|
||||
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Shift+Tab")
|
||||
await expect(home).toBeFocused()
|
||||
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
})
|
||||
}
|
||||
|
||||
test("dedicated experimental settings control vertical tab details", async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA }) => {
|
||||
@@ -249,11 +406,14 @@ test("appearance experimental settings control vertical tab details", async ({ p
|
||||
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeVisible()
|
||||
const version = settings.getByRole("tablist").getByText(`v${pkg.version}`, { exact: true })
|
||||
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toBeInViewport()
|
||||
await expect(version).toBeInViewport()
|
||||
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByRole("tablist").getByText(/^v\d+\./)).toHaveCount(0)
|
||||
await settings.getByRole("tab", { name: "Appearance" }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Experimental" })).toBeVisible()
|
||||
await expect(settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(settings.locator('[data-action="settings-tab-layout"]')).toHaveCount(0)
|
||||
await expect(settings.getByRole("switch", { name: "Show project names", exact: true })).toHaveCount(0)
|
||||
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Experimental", level: 2, exact: true })).toBeVisible()
|
||||
|
||||
const layout = settings.locator('[data-action="settings-tab-layout"]')
|
||||
await expect(layout).toContainText("Horizontal")
|
||||
@@ -274,19 +434,27 @@ test("appearance experimental settings control vertical tab details", async ({ p
|
||||
await page.setViewportSize({ width: 920, height: 720 })
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
|
||||
await expect(settings.getByRole("tablist")).toBeHidden()
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 800, height: 720 })
|
||||
await expect(settings.getByRole("tablist")).toBeHidden()
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 720 })
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await settings.getByRole("button", { name: "Experimental", exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name: "Appearance", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(layout).toHaveCount(0)
|
||||
await settings.getByRole("button", { name: "Appearance", exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name: "Experimental", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Experimental", level: 2, exact: true })).toBeVisible()
|
||||
await expect(layout).toContainText("Vertical")
|
||||
await expect(projectNameSwitch).toBeChecked()
|
||||
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
|
||||
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeInViewport()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 360 })
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
|
||||
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeInViewport()
|
||||
|
||||
// Reload the UI-selected preference without seeding settings storage.
|
||||
await page.reload()
|
||||
@@ -310,7 +478,7 @@ test("appearance experimental settings control vertical tab details", async ({ p
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
|
||||
await page.keyboard.press("Control+,")
|
||||
await settings.getByRole("tab", { name: "Appearance" }).click()
|
||||
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
|
||||
await expect(layout).toContainText("Vertical")
|
||||
})
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,57 @@
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "./mock-server"
|
||||
import { APP_READY_TIMEOUT } from "./waits"
|
||||
|
||||
export const paletteSession = {
|
||||
id: "ses_command_palette",
|
||||
projectID: "proj_command_palette",
|
||||
directory: "C:/OpenCode/CommandPalette",
|
||||
title: "Palette fixture session",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
}
|
||||
|
||||
export function captureConsoleWarnings(page: Page) {
|
||||
const warnings: string[] = []
|
||||
page.on("console", (message) => {
|
||||
if (message.type() !== "warning" && message.type() !== "error") return
|
||||
// This message comes from test isolation, not application code.
|
||||
if (message.text() === "Service Worker registration blocked by Playwright") return
|
||||
warnings.push(message.text())
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
export async function openCommandPalette(page: Page, home = false) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: paletteSession.directory,
|
||||
project: {
|
||||
id: paletteSession.projectID,
|
||||
worktree: paletteSession.directory,
|
||||
vcs: "git",
|
||||
name: "command-palette",
|
||||
time: paletteSession.time,
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [paletteSession],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
findFiles: () => [],
|
||||
})
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(home ? "/" : `/server/${base64Encode(server)}/session/${paletteSession.id}`)
|
||||
if (home) {
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }),
|
||||
).toBeEnabled({ timeout: APP_READY_TIMEOUT })
|
||||
}
|
||||
if (!home) {
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable({ timeout: APP_READY_TIMEOUT })
|
||||
}
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
const dialog = page.getByRole("dialog")
|
||||
const input = dialog.getByRole("textbox")
|
||||
await expect(input).toBeFocused()
|
||||
await expect(dialog.getByRole("option")).not.toHaveCount(0)
|
||||
return { dialog, input }
|
||||
}
|
||||
@@ -575,7 +575,7 @@ export function ComposerEditorAddMenu(props: {
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
style={{ "min-width": "180px" }}
|
||||
>
|
||||
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
|
||||
|
||||
@@ -251,6 +251,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
const submission = createComposerSubmit({
|
||||
adapter,
|
||||
mode,
|
||||
commands: () => data.location.command.list({ directory: sdk().directory }),
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
|
||||
@@ -52,10 +52,12 @@ function submitInput(
|
||||
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
mode: () => mode,
|
||||
commands,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory() {},
|
||||
@@ -420,7 +422,6 @@ describe("Composer submission", () => {
|
||||
prompt: async () => undefined,
|
||||
command: async (value) => sent.resolve(value),
|
||||
})
|
||||
target.data.location.command.list = () => [{ name: "review", description: "Review changes", template: "" }]
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
@@ -433,7 +434,7 @@ describe("Composer submission", () => {
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
|
||||
const request = await sent.promise
|
||||
|
||||
expect(request.files).toMatchObject([{ name: "app.ts", mention: { text: "@src/app.ts" } }])
|
||||
@@ -442,6 +443,51 @@ describe("Composer submission", () => {
|
||||
expect(request.delivery).toBe("steer")
|
||||
})
|
||||
|
||||
test("captures commands before creating a session in a new worktree", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/review https://github.com/example/repo/pull/1" }).capture()
|
||||
const catalog = [{ name: "review" }]
|
||||
const sent = Promise.withResolvers<"prompt" | "command">()
|
||||
const requests: Parameters<ComposerSession["api"]["command"]>[0][] = []
|
||||
const target = session({
|
||||
calls: [],
|
||||
prompt: async () => sent.resolve("prompt"),
|
||||
command: async (value) => {
|
||||
requests.push(value)
|
||||
sent.resolve("command")
|
||||
},
|
||||
})
|
||||
target.directory = "C:/new-worktree"
|
||||
target.data.location.command.list = () => undefined
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start() {
|
||||
// The destination catalog has not loaded, and the source composer is leaving.
|
||||
catalog.splice(0)
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
},
|
||||
}
|
||||
|
||||
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
|
||||
|
||||
expect(await sent.promise).toBe("command")
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
sessionID: target.id,
|
||||
command: "review",
|
||||
text: "https://github.com/example/repo/pull/1",
|
||||
files: [],
|
||||
agents: [],
|
||||
skills: [],
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("does not run an empty shell command from hidden attachments", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
state.set([
|
||||
|
||||
@@ -27,6 +27,7 @@ type ComposerSubmission = {
|
||||
type ComposerSubmitInput = {
|
||||
adapter: ComposerAdapter
|
||||
mode: Accessor<"normal" | "shell">
|
||||
commands: Accessor<readonly { name: string }[] | undefined>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
@@ -65,6 +66,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
submitting.add(input.adapter.state)
|
||||
const comments = input.comments.capture()
|
||||
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
|
||||
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
|
||||
|
||||
try {
|
||||
const started =
|
||||
@@ -78,7 +81,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
input.resetHistory()
|
||||
const restore = () => restoreSubmission(input, submission, value, comments)
|
||||
|
||||
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
@@ -277,12 +279,11 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
|
||||
await session.api.shell({ sessionID: session.id, id: Event.ID.create(), command: value.text })
|
||||
}
|
||||
|
||||
function findCommand(session: ComposerSession, text: string) {
|
||||
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
|
||||
if (!text.startsWith("/")) return
|
||||
const [name, ...arguments_] = text.split(" ")
|
||||
const command = name.slice(1)
|
||||
if (!session.data.location.command.list({ directory: session.directory })?.some((item) => item.name === command))
|
||||
return
|
||||
if (!commands?.some((item) => item.name === command)) return
|
||||
return { command, arguments: arguments_.join(" ") }
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +52,9 @@ export function HomeCommandPalette(props: {
|
||||
}
|
||||
if (item.type === "session") props.onSelectSession(item)
|
||||
}
|
||||
const loadItems = async (text: string) => {
|
||||
const query = text.trim()
|
||||
const items = (query: string) => {
|
||||
if (!query) return commandEntries().slice(0, 5)
|
||||
return [...commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)), ...(await sessions(query))]
|
||||
return commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query))
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -66,7 +65,8 @@ export function HomeCommandPalette(props: {
|
||||
return (
|
||||
<CommandPaletteView
|
||||
placeholder={language.t("palette.search.placeholder.home")}
|
||||
loadItems={loadItems}
|
||||
items={items}
|
||||
sources={[sessions]}
|
||||
highlight={highlight}
|
||||
select={select}
|
||||
close={() => dialog.close()}
|
||||
|
||||
@@ -812,6 +812,10 @@ export const dict = {
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
"titlebar.channel.local": "Local",
|
||||
"titlebar.channel.dev": "Dev",
|
||||
"titlebar.channel.beta": "Beta",
|
||||
"titlebar.toggleDebugTools": "Toggle debug tools",
|
||||
"titlebar.updateVersion": "Update {{version}}",
|
||||
|
||||
"common.closeTab": "Close tab",
|
||||
@@ -908,6 +912,25 @@ export const dict = {
|
||||
"settings.tab.notifications": "Notifications",
|
||||
"settings.tab.projects": "Projects",
|
||||
"settings.tab.extensions": "Extensions",
|
||||
"settings.tab.experimental": "Experimental",
|
||||
"settings.experimental.description": "Try experimental features",
|
||||
"settings.tab.about": "About",
|
||||
"settings.about.version": "Version {{version}}",
|
||||
"settings.about.devVersion": "development",
|
||||
"settings.about.license": "Released under the MIT License",
|
||||
"settings.about.writtenBy": "Written by",
|
||||
"settings.about.illustratedBy": "Illustrated by",
|
||||
"settings.about.and": "and",
|
||||
"settings.about.otherContributor.one": "{{count}} other",
|
||||
"settings.about.otherContributor.other": "{{count}} others",
|
||||
"settings.about.firstPublished": "First published in Missouri, USA",
|
||||
"settings.about.firstIllustrated": "First illustrated in London, England",
|
||||
"settings.about.website": "www.opencode.ai",
|
||||
"settings.about.description": "OpenCode, the open source coding agent",
|
||||
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
|
||||
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
|
||||
"settings.about.tagline": "AI can’t build great software, without you",
|
||||
"settings.about.copyright": "© Anomaly Innovations, Inc.",
|
||||
"settings.preferences.description": "Customize preferences and theme and default behavior",
|
||||
"settings.appearance.description": "Customize theme and fonts",
|
||||
"settings.appearance.section.experimental": "Experimental",
|
||||
@@ -935,6 +958,7 @@ export const dict = {
|
||||
"settings.desktop.wsl.title": "WSL integration",
|
||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||
"dialog.server.authenticate.title": "Authenticate",
|
||||
"project.settings.title": "Edit project",
|
||||
"project.settings.general.description": "Manage project name and appearance",
|
||||
"project.settings.scripts": "Scripts",
|
||||
"project.settings.scripts.description": "Configure scripts for this project",
|
||||
|
||||
@@ -13,6 +13,9 @@ import { createServerNotificationState } from "@/shell/notifications/notificatio
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { createDesktopData } from "./data"
|
||||
import { ModelState } from "./persistence"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "./errors"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -127,6 +130,7 @@ function createServerController(
|
||||
scope: ServerScope,
|
||||
projects: ReturnType<typeof createServerProjects>,
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const connKey = ServerConnection.key(conn)
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const source = createData({
|
||||
@@ -137,6 +141,13 @@ function createServerController(
|
||||
},
|
||||
connection: sdk.connection,
|
||||
directory: "",
|
||||
onError(error) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: formatServerError(error, language.t),
|
||||
})
|
||||
},
|
||||
})
|
||||
const data = createDesktopData({
|
||||
data: source,
|
||||
|
||||
@@ -321,9 +321,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}),
|
||||
tab &&
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
id: "file.close",
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
keybind: settings.keybinds.get("tab.close") ?? "mod+w",
|
||||
when: (event) => !(event.target instanceof Element && event.target.closest('[data-component="terminal"]')),
|
||||
onSelect: closeTab,
|
||||
}),
|
||||
].filter((v) => !!v)
|
||||
|
||||
@@ -170,10 +170,10 @@ export function SessionFileBrowserTab(props: {
|
||||
when={!props.placeholder}
|
||||
fallback={
|
||||
<SessionFilePanelV2Empty>
|
||||
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" />
|
||||
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
|
||||
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" class="mb-2" />
|
||||
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
|
||||
</div>
|
||||
</SessionFilePanelV2Empty>
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ export function SessionSidePanel(props: {
|
||||
return active !== "review" && active !== "context" && active !== "empty"
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
createEffect(() => {
|
||||
if (!file.ready()) return
|
||||
|
||||
@@ -417,7 +417,7 @@ export function SessionSidePanel(props: {
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" />}
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() => openFileBrowser()}
|
||||
@@ -427,11 +427,16 @@ export function SessionSidePanel(props: {
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<div
|
||||
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
|
||||
data-slot="session-side-panel-actions"
|
||||
class="session-review-v2-open-in-app-slot self-start shrink-0 flex items-center gap-2 pe-3"
|
||||
classList={{ "h-[51px]": props.stacked, "h-12": !props.stacked }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<OpenInAppButton directory={projectDirectory} />
|
||||
<Show when={reviewOpen()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export function SortableTab(props: {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.tab
|
||||
|
||||
@@ -3,6 +3,28 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
|
||||
export function SessionReviewToggle() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const { view } = useSessionLayout()
|
||||
|
||||
return (
|
||||
<SessionHeaderActions
|
||||
state={{
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: true,
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type SessionHeaderActionsState = {
|
||||
reviewLabel: string
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
|
||||
|
||||
export function SessionHeader() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const { view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const actions = createMemo<SessionHeaderActionsState>(() => ({
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitlebarRight>
|
||||
@@ -35,7 +23,9 @@ export function SessionHeader() {
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</TitlebarRight>
|
||||
<SessionHeaderActions state={actions()} />
|
||||
<Show when={isDesktop() && !view().reviewPanel.opened()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { SessionContextTab } from "./files/session-context-tab"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
@@ -274,10 +275,19 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<>
|
||||
<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)]">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
{/* Keep the control outside panel animations; the terminal's 52px header includes a 1px divider. */}
|
||||
<Show when={isDesktop() && messagesReady() && session.identity.params.id}>
|
||||
<div
|
||||
class="absolute end-3 top-0 z-30 flex items-center"
|
||||
classList={{ "h-[51px]": sideTerminalVisible(), "h-12": !sideTerminalVisible() }}
|
||||
data-slot="session-review-toggle"
|
||||
>
|
||||
<SessionReviewToggle />
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]":
|
||||
true,
|
||||
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!screen.size.active() && sidePresence.animate(),
|
||||
"transition-none": screen.size.active() || !sidePresence.animate(),
|
||||
@@ -408,6 +418,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
present={store.sideTerminalPresent}
|
||||
animate={sidePresence.animate() || sideMotion().animateTerminal}
|
||||
contentHeight={screen.side.terminal.contentHeight()}
|
||||
reserveReviewToggle={!screen.side.region.open()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Informational tooltips must not block menu items. Kobalte sets pointer events inline. */
|
||||
[data-popper-positioner]:has(.session-project-info-tooltip),
|
||||
.session-project-info-tooltip {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.session-options-menu [data-component="menu-v2-item"] {
|
||||
padding-inline-end: 30px;
|
||||
}
|
||||
|
||||
.session-project-link {
|
||||
--session-project-fade-direction: to right;
|
||||
position: relative;
|
||||
|
||||
&:dir(rtl) {
|
||||
--session-project-fade-direction: to left;
|
||||
}
|
||||
|
||||
&[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.session-project-link-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-project-link-open {
|
||||
position: absolute;
|
||||
inset-inline-end: 12px;
|
||||
inset-block: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.session-project-link:is(:hover, [data-highlighted]):not([aria-disabled="true"]) {
|
||||
.session-project-link-content {
|
||||
mask-image: linear-gradient(
|
||||
var(--session-project-fade-direction),
|
||||
#000 calc(100% - 40px),
|
||||
transparent calc(100% - 24px)
|
||||
);
|
||||
}
|
||||
|
||||
.session-project-link-open {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
|
||||
import { tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { sessionTitle } from "./title"
|
||||
import "./session-identity-header.css"
|
||||
|
||||
export function SessionTitleHeader(props: ParentProps) {
|
||||
return (
|
||||
@@ -25,6 +34,173 @@ export function SessionTitleHeader(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionProjectMenu(props: {
|
||||
project?: Omit<LocalProject, "expanded">
|
||||
directory?: string
|
||||
workspace: boolean
|
||||
showProjectIcon: boolean
|
||||
}) {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const platform = usePlatform()
|
||||
const layout = useLayout()
|
||||
const navigate = useNavigate()
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
projectTruncated: false,
|
||||
pathTruncated: false,
|
||||
pathFocused: false,
|
||||
})
|
||||
const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
|
||||
const canOpenPath = () =>
|
||||
platform.platform === "desktop" && !!platform.openPath && server.isLocal && !!props.directory
|
||||
const openPath = () => {
|
||||
if (!canOpenPath() || !platform.openPath || !props.directory) return
|
||||
void platform.openPath(props.directory).catch((cause: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const openProjectSettings = async () => {
|
||||
const current = props.project
|
||||
if (!current) return
|
||||
const { DialogEditProject } = await import("@/settings/workspaces/project-dialog")
|
||||
dialog.push(() => <DialogEditProject project={{ expanded: false, ...current }} server={server.conn} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
shift={-10}
|
||||
modal={false}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState({ open, pathFocused: false })}
|
||||
>
|
||||
<Tooltip placement="bottom" value={<bdi>{projectName()}</bdi>} class="flex shrink-0">
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
variant="ghost-muted"
|
||||
aria-label={projectName()}
|
||||
data-slot="session-project-trigger"
|
||||
icon={
|
||||
<Show
|
||||
when={props.showProjectIcon}
|
||||
fallback={
|
||||
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
|
||||
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={projectName()}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-max max-w-[min(320px,calc(100vw-16px))]" aria-label={projectName()}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
gutter={2}
|
||||
disabled={!state.projectTruncated}
|
||||
class="min-w-0 cursor-default"
|
||||
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
|
||||
value={<bdi>{projectName()}</bdi>}
|
||||
>
|
||||
<Menu.Item
|
||||
class="min-w-0 w-full"
|
||||
disabled={!props.project}
|
||||
onSelect={() => {
|
||||
const project = props.project
|
||||
if (!project) return
|
||||
server.ctx.projects.open(project.worktree)
|
||||
layout.home.setSelection({ server: server.key, directory: project.worktree })
|
||||
navigate("/")
|
||||
}}
|
||||
>
|
||||
<span class="session-project-link-content">
|
||||
<ProjectAvatar
|
||||
class="shrink-0"
|
||||
aria-hidden="true"
|
||||
fallback={projectName()}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
/>
|
||||
<bdi
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, () =>
|
||||
setState("projectTruncated", element.scrollWidth > element.clientWidth),
|
||||
)
|
||||
}
|
||||
class="min-w-0 truncate text-13-medium"
|
||||
>
|
||||
{projectName()}
|
||||
</bdi>
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
gutter={2}
|
||||
disabled={!state.pathTruncated}
|
||||
forceOpen={state.pathFocused && state.pathTruncated ? true : undefined}
|
||||
class="min-w-0 cursor-default"
|
||||
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
|
||||
value={<bdi dir="ltr">{props.directory}</bdi>}
|
||||
>
|
||||
{/* Read-only paths stay in keyboard navigation so their full tooltip remains accessible. */}
|
||||
<Menu.Item
|
||||
class="session-project-link min-w-0 w-full cursor-default"
|
||||
disabled={!props.directory}
|
||||
aria-disabled={!canOpenPath()}
|
||||
closeOnSelect={canOpenPath()}
|
||||
onSelect={openPath}
|
||||
onFocus={() => setState("pathFocused", true)}
|
||||
onBlur={() => setState("pathFocused", false)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setState({ open: false, pathFocused: false })
|
||||
}}
|
||||
>
|
||||
<span class="session-project-link-content">
|
||||
<Icon name="folder" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<bdi
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, () =>
|
||||
setState("pathTruncated", element.scrollWidth > element.clientWidth),
|
||||
)
|
||||
}
|
||||
dir="ltr"
|
||||
class="min-w-0 truncate text-v2-text-text-muted"
|
||||
>
|
||||
{props.directory}
|
||||
</bdi>
|
||||
</span>
|
||||
<span data-slot="session-project-open-icon" class="session-project-link-open" aria-hidden="true">
|
||||
<Icon name="arrow-up-right" />
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
<Menu.Separator />
|
||||
<Menu.Item disabled={!props.project} onSelect={() => void openProjectSettings()}>
|
||||
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
|
||||
{language.t("project.settings.title")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
@@ -71,12 +247,17 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
)
|
||||
const project = createMemo(() => {
|
||||
const projects = server.ctx.projects.list()
|
||||
if (props.session) return projectForSession(props.session, projects)
|
||||
if (props.session)
|
||||
return (
|
||||
projectForSession(props.session, projects) ?? projectForSession(props.session, server.ctx.sync.data.project)
|
||||
)
|
||||
const value = directory()
|
||||
if (!value) return undefined
|
||||
const key = pathKey(value)
|
||||
return projects.find(
|
||||
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
return (
|
||||
projects.find(
|
||||
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
) ?? server.ctx.sync.data.project.find((item) => isProjectDirectory(item, value))
|
||||
)
|
||||
})
|
||||
const showProjectIcon = () =>
|
||||
@@ -95,25 +276,13 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
<SessionTitleHeader>
|
||||
<div class="flex h-12 w-full items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<div class="flex min-w-0 w-full flex-1 items-center">
|
||||
<span
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": workspaceSession() && !showProjectIcon(),
|
||||
"text-v2-icon-icon-muted": !workspaceSession() && !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={showProjectIcon()}
|
||||
fallback={<Icon name={workspaceSession() ? "workspace-isolated" : "monitor"} />}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(project() ?? { worktree: directory() ?? "" })}
|
||||
src={getProjectAvatarSource(project()?.id, project()?.icon)}
|
||||
variant={getProjectAvatarVariant(project()?.icon?.color)}
|
||||
/>
|
||||
</Show>
|
||||
</span>
|
||||
<div class="flex min-w-0 w-full flex-1 items-center gap-0.5">
|
||||
<SessionProjectMenu
|
||||
project={project()}
|
||||
directory={directory()}
|
||||
workspace={workspaceSession()}
|
||||
showProjectIcon={showProjectIcon()}
|
||||
/>
|
||||
<Show when={parentTitle()}>
|
||||
{(value) => (
|
||||
<button
|
||||
@@ -141,7 +310,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
<h1
|
||||
data-slot={parentID() ? "session-title-child" : undefined}
|
||||
dir="auto"
|
||||
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
|
||||
class="w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
|
||||
>
|
||||
{value()}
|
||||
</h1>
|
||||
|
||||
@@ -45,6 +45,7 @@ export function TerminalPanel(
|
||||
contentHeight?: string
|
||||
embedded?: boolean
|
||||
animate?: boolean
|
||||
reserveReviewToggle?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const terminal = useTerminal()
|
||||
@@ -249,7 +250,10 @@ export function TerminalPanel(
|
||||
when={terminal.ready() || store.surfaces.length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
|
||||
<div
|
||||
class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden"
|
||||
classList={{ "pe-12": props.reserveReviewToggle }}
|
||||
>
|
||||
<For each={handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
@@ -291,46 +295,53 @@ export function TerminalPanel(
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
variant="panel"
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-[52px] !flex-none"
|
||||
>
|
||||
<Tabs.List
|
||||
ref={tabList}
|
||||
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const active = document.activeElement
|
||||
if (event.target === active) return
|
||||
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
|
||||
}}
|
||||
<div class="h-[52px] shrink-0 flex border-b border-border-weaker-base">
|
||||
<Tabs
|
||||
variant="panel"
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-full min-w-0 !flex-1"
|
||||
>
|
||||
<For each={all()}>
|
||||
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
|
||||
</For>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.terminal.new")}
|
||||
<Show when={newTerminalKeybind().length > 0}>
|
||||
<Keybind keys={newTerminalKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" size="large" />}
|
||||
variant="ghost"
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Tabs.List
|
||||
ref={tabList}
|
||||
class="!border-b-0"
|
||||
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const active = document.activeElement
|
||||
if (event.target === active) return
|
||||
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
|
||||
}}
|
||||
>
|
||||
<For each={all()}>
|
||||
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
|
||||
</For>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.terminal.new")}
|
||||
<Show when={newTerminalKeybind().length > 0}>
|
||||
<Keybind keys={newTerminalKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" size="large" />}
|
||||
variant="ghost"
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
{/* Reserve outside the scroll viewport so overflowing tabs cannot cover the toggle. */}
|
||||
<Show when={props.reserveReviewToggle}>
|
||||
<div class="w-12 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<For each={store.surfaces}>
|
||||
{(surface) => (
|
||||
|
||||
@@ -10,7 +10,6 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
@@ -31,7 +30,7 @@ import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
|
||||
type BackgroundTask = {
|
||||
@@ -409,10 +408,9 @@ function MessageTimelineView(
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
|
||||
const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
|
||||
const avatarProject = createMemo(() => {
|
||||
if (!showProjectIcon()) return
|
||||
const session = props.session.data.info()
|
||||
if (!session) return
|
||||
return projectForSession(session, server.ctx.projects.list())
|
||||
return projectForSession(session, server.ctx.projects.list()) ?? project()
|
||||
})
|
||||
const projectAvatar = () => (
|
||||
<ProjectAvatar
|
||||
@@ -666,36 +664,13 @@ function MessageTimelineView(
|
||||
<SessionTitleHeader>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
placement="bottom-start"
|
||||
value={sessionDirectory()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<div class="flex items-center gap-0.5 min-w-0 flex-1 w-full">
|
||||
<SessionProjectMenu
|
||||
project={avatarProject()}
|
||||
directory={sessionDirectory()}
|
||||
workspace={workspaceSession()}
|
||||
showProjectIcon={showProjectIcon()}
|
||||
/>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -719,7 +694,7 @@ function MessageTimelineView(
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-1 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
@@ -734,7 +709,7 @@ function MessageTimelineView(
|
||||
dir="auto"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content self-start rounded-[6px] px-2 py-1"
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content rounded-[6px] px-1 py-1"
|
||||
style={{
|
||||
"--inline-input-shadow": "none",
|
||||
"text-align": "start",
|
||||
@@ -761,7 +736,7 @@ function MessageTimelineView(
|
||||
{(id) => (
|
||||
<Menu
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
placement="bottom-start"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
@@ -776,7 +751,8 @@ function MessageTimelineView(
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
style={{ "min-width": "160px" }}
|
||||
class="session-options-menu w-max"
|
||||
style={{ "min-width": "0" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { For, createResource, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import legal from "./legal.svg"
|
||||
import anomalyBrush from "./anomaly-brush.svg"
|
||||
import { AnimatedWordmark } from "./animated-wordmark"
|
||||
import { FALLBACK_OTHER_CONTRIBUTORS, loadOtherContributorCount } from "./contributors"
|
||||
|
||||
const writers = [
|
||||
"thdxr",
|
||||
"adamdotdev",
|
||||
"rekram1-node",
|
||||
"kitlangton",
|
||||
"iamdavidhill",
|
||||
"jayair",
|
||||
"fwang",
|
||||
"brendonovich",
|
||||
"nexxeln",
|
||||
"hona",
|
||||
"kommander",
|
||||
"jlongster",
|
||||
"vimtor",
|
||||
"r44vcorp",
|
||||
"simonklee",
|
||||
"arvsrn",
|
||||
] as const
|
||||
const illustrators = ["usrnk1", "ludvigrask_", "arvsrn", "iamdavidhill"] as const
|
||||
|
||||
export function SettingsAbout(props: { active: boolean }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [otherContributors] = createResource(
|
||||
() => props.active || undefined,
|
||||
() => loadOtherContributorCount(platform.fetch ?? fetch),
|
||||
{ initialValue: FALLBACK_OTHER_CONTRIBUTORS },
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="settings-about-content">
|
||||
<div class="settings-about-intro">
|
||||
<p>{language.t("settings.about.version", { version: platform.version ?? language.t("settings.about.devVersion") })}</p>
|
||||
<p>{language.t("settings.about.license")}</p>
|
||||
</div>
|
||||
|
||||
<AnimatedWordmark active={props.active} />
|
||||
|
||||
<div class="settings-about-credits">
|
||||
<CreditLine
|
||||
label={language.t("settings.about.writtenBy")}
|
||||
names={writers}
|
||||
and={language.t("settings.about.and")}
|
||||
tail={
|
||||
<ExternalLink href="https://github.com/anomalyco/opencode/graphs/contributors">
|
||||
{language.plural("settings.about.otherContributor", otherContributors.latest, {
|
||||
count: otherContributors.latest,
|
||||
})}
|
||||
</ExternalLink>
|
||||
}
|
||||
/>
|
||||
<CreditLine
|
||||
label={language.t("settings.about.illustratedBy")}
|
||||
names={illustrators}
|
||||
and={language.t("settings.about.and")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-about-publication">
|
||||
<p>{language.t("settings.about.firstPublished")}</p>
|
||||
<p>{language.t("settings.about.firstIllustrated")}</p>
|
||||
</div>
|
||||
|
||||
<p class="settings-about-faint">
|
||||
<ExternalLink href="https://opencode.ai">
|
||||
<bdi dir="ltr">{language.t("settings.about.website")}</bdi>
|
||||
</ExternalLink>
|
||||
</p>
|
||||
|
||||
<div class="settings-about-details">
|
||||
<p>{language.t("settings.about.description")}</p>
|
||||
<p>{language.t("settings.about.trademark")}</p>
|
||||
<p>{language.t("settings.about.typeset")}</p>
|
||||
</div>
|
||||
|
||||
<p>{language.t("settings.about.tagline")}</p>
|
||||
<img class="settings-about-legal" src={legal} alt="" />
|
||||
<div class="settings-about-copyright">
|
||||
<p>{language.t("settings.about.copyright")}</p>
|
||||
<img class="settings-about-anomaly-brush" src={anomalyBrush} alt="" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreditLine(props: {
|
||||
label: string
|
||||
names: readonly string[]
|
||||
and: string
|
||||
tail?: JSX.Element
|
||||
}) {
|
||||
return (
|
||||
<p>
|
||||
{props.label}{" "}
|
||||
<For each={props.names}>
|
||||
{(name, index) => (
|
||||
<>
|
||||
{index() === 0 ? "" : index() === props.names.length - 1 && !props.tail ? `, ${props.and} ` : ", "}
|
||||
<bdi dir="ltr">
|
||||
<ExternalLink href={profile(name)}>{name}</ExternalLink>
|
||||
</bdi>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
{props.tail ? <>, {props.and} {props.tail}</> : null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function profile(name: string) {
|
||||
if (name === "r44vcorp") return "https://github.com/R44VC0RP"
|
||||
if (name === "ludvigrask_") return "https://x.com/ludvigrask_"
|
||||
return `https://github.com/${name}`
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createEffect, For, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const target = ["o", "p", "e", "n", "c", "o", "d", "e"] as const
|
||||
const choices = ["o", "p", "e", "n", "c", "d"] as const
|
||||
|
||||
export function AnimatedWordmark(props: { active: boolean }) {
|
||||
const [state, setState] = createStore({ letters: [...target] })
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>()
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.active,
|
||||
(active) => {
|
||||
timers.forEach(clearTimeout)
|
||||
timers.clear()
|
||||
if (!active || matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setState("letters", [...target])
|
||||
return
|
||||
}
|
||||
|
||||
const starts = target.map(() => choices[Math.floor(Math.random() * choices.length)])
|
||||
const settles = target.map(() => 6 + Math.floor(Math.random() * 8))
|
||||
const last = Math.max(...settles)
|
||||
setState("letters", starts)
|
||||
|
||||
Array.from({ length: last }, (_, index) => index + 1).forEach((tick) => {
|
||||
const timer = setTimeout(() => {
|
||||
setState(
|
||||
"letters",
|
||||
target.map((letter, index) =>
|
||||
tick >= settles[index] ? letter : choices[Math.floor(Math.random() * choices.length)],
|
||||
),
|
||||
)
|
||||
timers.delete(timer)
|
||||
}, tick * 75)
|
||||
timers.add(timer)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => timers.forEach(clearTimeout))
|
||||
|
||||
return (
|
||||
<svg class="settings-about-wordmark" viewBox="0 0 234 42" aria-hidden="true">
|
||||
<defs>
|
||||
<symbol id="settings-about-letter-o" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-p" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M6 30H18V12H6V30ZM24 36H6V42H0V6H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-e" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M24 24V30H6V24H24Z" />
|
||||
<path d="M24 24H6V30H24V36H0V6H24V24ZM6 18H18V12H6V18Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-n" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 36H6V18H18V36Z" />
|
||||
<path d="M18 12H6V36H0V6H18V12ZM24 36H18V12H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-c" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M24 30H6V18H24V30Z" />
|
||||
<path d="M24 12H6V30H24V36H0V6H24V12Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-d" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H18V0H24V36Z" />
|
||||
</symbol>
|
||||
</defs>
|
||||
<For each={state.letters}>
|
||||
{(letter, index) => (
|
||||
<use href={`#settings-about-letter-${letter}`} x={index() * 30} width="24" height="42" />
|
||||
)}
|
||||
</For>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { FALLBACK_OTHER_CONTRIBUTORS, otherContributorCount } from "./contributors"
|
||||
|
||||
describe("otherContributorCount", () => {
|
||||
test("subtracts the contributors named in the colophon", () => {
|
||||
expect(
|
||||
otherContributorCount(
|
||||
'<https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=2>; rel="next", <https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=1004>; rel="last"',
|
||||
),
|
||||
).toBe(988)
|
||||
})
|
||||
|
||||
test("falls back when pagination metadata is unavailable", () => {
|
||||
expect(otherContributorCount(null)).toBe(FALLBACK_OTHER_CONTRIBUTORS)
|
||||
expect(otherContributorCount("invalid")).toBe(FALLBACK_OTHER_CONTRIBUTORS)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
const CREDITED_CONTRIBUTORS = 16
|
||||
let request: Promise<number> | undefined
|
||||
|
||||
export const FALLBACK_OTHER_CONTRIBUTORS = 935
|
||||
|
||||
export function otherContributorCount(link: string | null) {
|
||||
const total = Number.parseInt(link?.match(/[?&]page=(\d+)[^>]*>;\s*rel="last"/)?.[1] ?? "", 10)
|
||||
if (!Number.isFinite(total) || total <= CREDITED_CONTRIBUTORS) return FALLBACK_OTHER_CONTRIBUTORS
|
||||
return total - CREDITED_CONTRIBUTORS
|
||||
}
|
||||
|
||||
export function loadOtherContributorCount(fetcher: typeof fetch) {
|
||||
request ??= fetcher("https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}).then(
|
||||
(response) => (response.ok ? otherContributorCount(response.headers.get("Link")) : FALLBACK_OTHER_CONTRIBUTORS),
|
||||
() => FALLBACK_OTHER_CONTRIBUTORS,
|
||||
)
|
||||
return request
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="91" height="16" viewBox="0 0 91 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity=".5" fill="#808080">
|
||||
<path d="M22.463 16H11.231c6.203 0 11.232-3.582 11.232-8s-5.029-8-11.232-8S0 3.582 0 8s5.028 8 11.231 8H0V0h22.463v16Z"/>
|
||||
<path d="M13.672 4.799h5.354v.988h-2.102v5.54h-1.158v-5.54h-2.094v-.988ZM11.724 4.799h1.158v6.528h-1.158V4.799ZM3.474 4.799h1.63l1.825 5.11h.018l1.779-5.11h1.612v6.528H9.235V6.289h-.018l-1.834 5.038h-.954L4.595 6.289h-.019v5.038H3.474V4.799Z"/>
|
||||
<path fill-rule="evenodd" d="M50.363 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm-1.938 1.04a6.615 6.615 0 0 0-3.338 2.34c.483.014.783.079.783.37 0 .375.375.625 1.123-.625.543-.906 1.02-1.681 1.432-2.086Zm8.547 6.585h-2.241c-.999 0-1.123 1-1.623 1.75-.499.75-.748 1.5-.748 2.625s-.749 1.25-1.124 1.375c-.374.125-.623-.375-1.123-.75-.499-.375-.25-2.375-.25-2.75s0-.625-.374-.75c-.374-.125-1.997-.375-2.121-.75-.125-.375 0-.75.748-1.75.44-.625 1.123-.25 1.373-.25.25 0 .999.25 1.498.25.499 0 .624.25 1.373.25.748 0 .25-.25.25-1 0-.75-.5-.375-.999-.25-.499.125-.624.25-1.747.25s-.25-.375.25-.625c.498-.25.498-.25.873-.875.374-.625 0-.25-.624-.375-.624-.125-.25.375-.874.375s-.124-.625.375-1c.499-.375.374-.5.499-1.25.125-.75.25-.25.873-.5.624-.25.874 0 1.123-.25.088-.088.098-.191.092-.288a6.616 6.616 0 0 0-8.702 6.288A6.614 6.614 0 0 0 50.363 15c3.569 0 6.478-2.832 6.61-6.375Z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M66.836 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm2.496 3.5c-.624 0-.999 1.125-1.623 1.75-.624.625-.499 1.375-.25 2.375.25 1-.623 1.625-1.372 1.625s-.874-.375-1.248-.75c-.375-.375-1.248-.375-2.247-.25-.998.125-.624 1.625-.748 2.375-.125.75-.624-.25-.749-1-.079-.475-.459-1-.822-1.417A6.614 6.614 0 0 0 66.836 15a6.59 6.59 0 0 0 2.518-.498c-.378-.049-.972-.333-1.395-.502-.624-.25-.874 0-1.622-.5-.749-.5-.25-.875-.25-1.875.25-.625.998-.25 1.373-.25h2.246c.874 0 .874 1.25.874 1.875 0 .604-.7.741-.86 1.088a6.625 6.625 0 0 0 2.489-9.827A8.822 8.822 0 0 0 71.952 4.5h-2.62Z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M83.308 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm.656.782c-.752.31-.387 1.473.093 1.593.5.125.5-.625.874-.625s1.123.375 1.123.625-.25.375-.5.75c-.249.375.375.375 1.124 0 .748-.375.624.375.499 1.25s-.749 1-1.622 1h-1.498c-.328 0-.391 1.633-.25 1.75.141.117.345.375.5.375h1.871c.624 0 .749.25 1.248.375s.375.75.5 1.125c.124.375-.375.75-.75 1.25-.374.5-.748 1-.998 1.375-.25.375-.374.875-.374 1.375s-.375.5-.624.5c-.25 0-.374-.125-.374-.625s-.125-.625-.25-1.375c-.125-.75-.374-1.375-.374-1.75 0-.375-.25-.25-.669-.25s-.454-.125-.454-.75.809-.305.873-.75c.014-.097 0-.25 0-.25 0-.279-.25-.375-.419-.468-.17-.093-.598-.214-.704-.532-.125-.375-.5-.625-1.248-.625-.749 0-.998-.125-.998-.75V4.5c0-.875-.5-.5-1.498-.75.076-.114.282-.435.544-.868A6.614 6.614 0 1 0 83.964 1.782Z" clip-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -1,7 +1,6 @@
|
||||
import { Component, createMemo } from "solid-js"
|
||||
import { Component } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
@@ -10,7 +9,6 @@ import { createAppearanceSettingsController, type AppearanceSettingsController }
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
|
||||
const fontSettings = {
|
||||
ui: {
|
||||
action: "settings-ui-font",
|
||||
@@ -128,44 +126,6 @@ export const SettingsAppearance: Component = () => {
|
||||
<FontSetting kind="terminal" fonts={appearance.fonts} />
|
||||
</SettingsList>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.appearance.section.experimental")}</h3>
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.tabs.title")}
|
||||
description={language.t("settings.appearance.row.tabs.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-tab-layout"
|
||||
options={tabLayoutOptions}
|
||||
current={tabLayoutOptions.find((option) => option === appearance.tabs.current())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
label={(option) =>
|
||||
option === "horizontal"
|
||||
? language.t("settings.appearance.row.tabs.horizontal")
|
||||
: language.t("settings.appearance.row.tabs.vertical")
|
||||
}
|
||||
onSelect={(option) => option && appearance.tabs.select(option)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.projectName.title")}
|
||||
description={language.t("settings.appearance.row.projectName.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-name">
|
||||
<Switch
|
||||
checked={appearance.projectName.current()}
|
||||
onChange={appearance.projectName.set}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.appearance.row.projectName.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
|
||||
|
||||
export const SettingsExperimental: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.experimental")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">
|
||||
{language.t("settings.experimental.description")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-section">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.tabs.title")}
|
||||
description={language.t("settings.appearance.row.tabs.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-tab-layout"
|
||||
options={tabLayoutOptions}
|
||||
current={tabLayoutOptions.find((option) => option === settings.appearance.tabLayout())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
label={(option) =>
|
||||
option === "horizontal"
|
||||
? language.t("settings.appearance.row.tabs.horizontal")
|
||||
: language.t("settings.appearance.row.tabs.vertical")
|
||||
}
|
||||
onSelect={(option) => option && settings.appearance.setTabLayout(option)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.projectName.title")}
|
||||
description={language.t("settings.appearance.row.projectName.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-name">
|
||||
<Switch
|
||||
checked={settings.appearance.showProjectName()}
|
||||
onChange={settings.appearance.setShowProjectName}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.appearance.row.projectName.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -81,14 +81,6 @@ export function createAppearanceSettingsController() {
|
||||
setCode: (value: string) => settings.appearance.setFont(value),
|
||||
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
|
||||
},
|
||||
tabs: {
|
||||
current: settings.appearance.tabLayout,
|
||||
select: settings.appearance.setTabLayout,
|
||||
},
|
||||
projectName: {
|
||||
current: settings.appearance.showProjectName,
|
||||
set: settings.appearance.setShowProjectName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
|
||||
.settings-screen .settings-tab-body {
|
||||
padding-inline: 0;
|
||||
padding-bottom: var(--settings-bottom-inset, 0px);
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
@@ -91,19 +90,101 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-nav-footer {
|
||||
.settings-about {
|
||||
align-items: center;
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
.settings-about-content {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
min-height: 462px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-block: 20px 4px;
|
||||
padding-inline-start: 4px;
|
||||
font-size: 11px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 24px;
|
||||
padding-block: 80px calc(80px + var(--settings-bottom-inset, 0px));
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-tight);
|
||||
line-height: var(--line-height-base);
|
||||
letter-spacing: -0.04px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-about-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-about-intro,
|
||||
.settings-about-credits,
|
||||
.settings-about-publication,
|
||||
.settings-about-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-about-intro {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-about-credits,
|
||||
.settings-about-publication,
|
||||
.settings-about-details {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-about-wordmark {
|
||||
width: 178px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.2;
|
||||
color: var(--v2-text-text-muted);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.settings-about-letter-shadow {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.settings-about-faint,
|
||||
.settings-about-credits {
|
||||
color: var(--v2-text-text-faint);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-about-legal {
|
||||
width: 109.2px;
|
||||
height: 19.2px;
|
||||
}
|
||||
|
||||
.settings-about-copyright {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-about-anomaly-brush {
|
||||
width: 126.72px;
|
||||
height: 48.384px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settings-about-content a {
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.settings-about-content a:hover,
|
||||
.settings-about-content a:focus-visible {
|
||||
color: var(--v2-text-text-base);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.settings-back {
|
||||
@@ -192,7 +273,7 @@
|
||||
flex-direction: column;
|
||||
gap: 36px;
|
||||
width: 100%;
|
||||
padding: 0 40px 40px;
|
||||
padding: 0 40px calc(80px + var(--settings-bottom-inset, 0px));
|
||||
}
|
||||
|
||||
[data-slot="settings-row-description"] a.settings-link {
|
||||
@@ -1089,7 +1170,7 @@
|
||||
}
|
||||
|
||||
.settings-tab-body.settings-workspaces {
|
||||
padding: 0 20px 24px;
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
.settings-workspaces-toolbar,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Component, createEffect, createMemo, For, Show, onCleanup, onMount, startTransition } from "solid-js"
|
||||
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { SettingsGeneral } from "./general/general"
|
||||
import { SettingsAppearance } from "./appearance/appearance"
|
||||
import { SettingsExperimental } from "./experimental/experimental"
|
||||
import { SettingsKeybinds } from "./keybinds/keybinds"
|
||||
import { SettingsNotifications } from "./notifications/notifications"
|
||||
import { SettingsProviders } from "./providers/providers"
|
||||
@@ -15,13 +15,13 @@ import { SettingsServers } from "./servers/servers"
|
||||
import { SettingsWorkspaces } from "./workspaces/workspaces"
|
||||
import { SettingsProjects } from "./workspaces/projects"
|
||||
import { SettingsExtensions } from "./providers/extensions"
|
||||
import { SettingsAbout } from "./about/about"
|
||||
import { SettingsServerScope } from "./server-scope"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettingsSurface } from "./surface"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
@@ -42,13 +42,13 @@ const sections = [
|
||||
{ value: "models", icon: "models", label: "settings.models.title" },
|
||||
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
|
||||
],
|
||||
[{ value: "experimental", icon: "flask", label: "settings.tab.experimental" }],
|
||||
[{ value: "about", icon: "info", label: "settings.tab.about" }],
|
||||
] as const
|
||||
|
||||
export const SettingsScreen: Component = () => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const surface = useSettingsSurface()
|
||||
const layout = useLayout()
|
||||
const servers = useServers()
|
||||
@@ -57,10 +57,8 @@ export const SettingsScreen: Component = () => {
|
||||
let root: HTMLDivElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
command.keybinds(false)
|
||||
root?.focus({ preventScroll: true })
|
||||
})
|
||||
onCleanup(() => command.keybinds(true))
|
||||
|
||||
const server = createMemo(() => {
|
||||
const route = surface.route()
|
||||
@@ -185,12 +183,6 @@ export const SettingsScreen: Component = () => {
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-nav-footer">
|
||||
<span>{language.t("app.name.desktop")}</span>
|
||||
<span>
|
||||
<bdi dir="ltr">v{platform.version}</bdi>
|
||||
</span>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="settings-panel">
|
||||
@@ -205,6 +197,9 @@ export const SettingsScreen: Component = () => {
|
||||
<Tabs.Content value="shortcuts" class="settings-panel">
|
||||
<SettingsKeybinds />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="experimental" class="settings-panel">
|
||||
<SettingsExperimental />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="servers" class="settings-panel">
|
||||
<SettingsServers />
|
||||
</Tabs.Content>
|
||||
@@ -225,6 +220,9 @@ export const SettingsScreen: Component = () => {
|
||||
<SettingsExtensions />
|
||||
</Tabs.Content>
|
||||
</SettingsServerScope>
|
||||
<Tabs.Content value="about" class="settings-panel settings-about">
|
||||
<SettingsAbout active={surface.tab() === "about"} />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[data-component="timeline-detail-control"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
@@ -105,7 +106,6 @@
|
||||
[data-slot="timeline-detail-advanced"] {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"] {
|
||||
|
||||
@@ -430,7 +430,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
key: id,
|
||||
options,
|
||||
}
|
||||
setStore("registrations", (arr) => addCommandRegistration(arr, entry))
|
||||
// Register only committed owners. Updating the registry during a transition
|
||||
// can restore its pending snapshot after the outgoing owner's cleanup.
|
||||
onMount(() => setStore("registrations", (arr) => addCommandRegistration(arr, entry)))
|
||||
onCleanup(() => {
|
||||
setStore("registrations", (arr) => arr.filter((x) => x !== entry))
|
||||
})
|
||||
|
||||
@@ -5,21 +5,15 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { formatKeybindParts } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { getRelativeTime } from "@/shell/time"
|
||||
import {
|
||||
createCommandPaletteCommandEntry,
|
||||
createCommandPaletteFileEntry,
|
||||
createCommandPaletteModel,
|
||||
createServerSessionEntries,
|
||||
uniqueCommandPaletteEntries,
|
||||
type CommandPaletteEntry,
|
||||
} from "./palette"
|
||||
import { createCommandPaletteFileEntry, createCommandPaletteModel, type CommandPaletteEntry } from "./palette"
|
||||
import { createCommandPaletteSearch } from "./search"
|
||||
import "./dialog.css"
|
||||
|
||||
function groups(entries: CommandPaletteEntry[]) {
|
||||
@@ -35,23 +29,24 @@ export function matchesCommandPaletteEntry(entry: CommandPaletteEntry, query: st
|
||||
|
||||
export function DialogCommandPalette(props: { onOpenFile?: (path: string) => void }) {
|
||||
const palette = createCommandPaletteModel(props)
|
||||
const loadItems = async (text: string) => {
|
||||
const q = text.trim()
|
||||
const items = (q: string) => {
|
||||
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||
|
||||
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
|
||||
const category = palette.language.t("palette.group.files")
|
||||
return [
|
||||
...palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q)),
|
||||
...nextSessions,
|
||||
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
||||
]
|
||||
return palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q))
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandPaletteView
|
||||
placeholder={palette.language.t("palette.search.placeholder")}
|
||||
loadItems={loadItems}
|
||||
items={items}
|
||||
sources={[
|
||||
palette.sessions,
|
||||
async (query, signal) => {
|
||||
if (!query) return []
|
||||
const files = await palette.file.searchFiles(query, { signal })
|
||||
const category = palette.language.t("palette.group.files")
|
||||
return files.map((path) => createCommandPaletteFileEntry(path, category))
|
||||
},
|
||||
]}
|
||||
highlight={palette.highlight}
|
||||
select={palette.select}
|
||||
close={palette.close}
|
||||
@@ -61,29 +56,31 @@ export function DialogCommandPalette(props: { onOpenFile?: (path: string) => voi
|
||||
|
||||
export function CommandPaletteView(props: {
|
||||
placeholder: string
|
||||
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
|
||||
items: (query: string) => CommandPaletteEntry[]
|
||||
sources: ((query: string, signal: AbortSignal) => Promise<CommandPaletteEntry[]>)[]
|
||||
highlight: (item: CommandPaletteEntry | undefined) => void
|
||||
select: (item: CommandPaletteEntry | undefined) => void
|
||||
close: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const tabs = useTabs()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [active, setActive] = createSignal(0)
|
||||
const [store, setStore] = createStore({ query: "", active: undefined as string | undefined })
|
||||
|
||||
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
||||
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
||||
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
||||
const search = createCommandPaletteSearch({ query: () => store.query, items: props.items, sources: props.sources })
|
||||
const visibleEntries = search.items
|
||||
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
||||
const activeEntry = createMemo(() => visibleEntries()[active()])
|
||||
// Keep keyboard selection stable when another search source adds results.
|
||||
const activeEntry = createMemo(
|
||||
() => visibleEntries().find((entry) => entry.id === store.active) ?? visibleEntries()[0],
|
||||
)
|
||||
const openSessions = createMemo(
|
||||
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
visibleEntries()
|
||||
setActive(0)
|
||||
// Pin automatic selection too: a later source can insert rows before it.
|
||||
const id = activeEntry()?.id
|
||||
if (store.active !== id) setStore("active", id)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -95,7 +92,8 @@ export function CommandPaletteView(props: {
|
||||
const move = (delta: -1 | 1) => {
|
||||
const count = visibleEntries().length
|
||||
if (count === 0) return
|
||||
setActive((index) => (index + delta + count) % count)
|
||||
const index = visibleEntries().findIndex((entry) => entry.id === activeEntry()?.id)
|
||||
setStore("active", visibleEntries()[(index + delta + count) % count].id)
|
||||
requestAnimationFrame(() => {
|
||||
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
|
||||
})
|
||||
@@ -128,14 +126,14 @@ export function CommandPaletteView(props: {
|
||||
<DialogBody class="command-palette-body">
|
||||
<div class="command-palette-search">
|
||||
<TextInput
|
||||
value={query()}
|
||||
value={store.query}
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
appearance="large"
|
||||
placeholder={props.placeholder}
|
||||
leadingIcon={<Icon name="magnifying-glass" />}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
onInput={(event) => setStore({ query: event.currentTarget.value, active: undefined })}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -145,7 +143,7 @@ export function CommandPaletteView(props: {
|
||||
when={visibleEntries().length > 0}
|
||||
fallback={
|
||||
<div class="command-palette-state">
|
||||
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
|
||||
{search.loading() ? language.t("common.loading") : language.t("palette.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -166,7 +164,7 @@ export function CommandPaletteView(props: {
|
||||
? openSessions().has(`${item.server}\0${item.sessionID}`)
|
||||
: false
|
||||
}
|
||||
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
||||
onActive={() => setStore("active", item.id)}
|
||||
onSelect={() => props.select(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { uniqueCommandPaletteEntries, type CommandPaletteEntry } from "./palette"
|
||||
|
||||
export function createCommandPaletteSearch(props: {
|
||||
query: () => string
|
||||
items: (query: string) => CommandPaletteEntry[]
|
||||
sources: ((query: string, signal: AbortSignal) => Promise<CommandPaletteEntry[]>)[]
|
||||
}) {
|
||||
const query = createMemo(() => props.query().trim())
|
||||
const local = createMemo(() => props.items(query()))
|
||||
const sources = props.sources.map((load) => {
|
||||
let abort: AbortController | undefined
|
||||
onCleanup(() => abort?.abort())
|
||||
const [result] = createResource(
|
||||
query,
|
||||
async (query) => {
|
||||
abort?.abort()
|
||||
const current = new AbortController()
|
||||
abort = current
|
||||
return { query, items: await load(query, current.signal).catch(() => []) }
|
||||
},
|
||||
// Remote searches must not suspend the dialog's local results on first render.
|
||||
{ initialValue: { query: "", items: [] as CommandPaletteEntry[] } },
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
return {
|
||||
items: createMemo(() =>
|
||||
uniqueCommandPaletteEntries([
|
||||
...local(),
|
||||
...sources.flatMap((source) => {
|
||||
// Never keep results for an older query selectable while the next one loads.
|
||||
const result = source.latest
|
||||
return result.query === query() ? result.items : []
|
||||
}),
|
||||
]),
|
||||
),
|
||||
loading: () => sources.some((source) => source.loading),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
|
||||
import { Toast, showToast, toaster, type ToastOptions } from "@opencode-ai/ui/toast"
|
||||
import type { JSX } from "solid-js"
|
||||
|
||||
type AppToastOptions = Omit<ToastOptions, "icon"> & {
|
||||
icon?: IconProps["name"]
|
||||
@@ -30,6 +31,7 @@ export function dismissToast(toastId: number) {
|
||||
|
||||
function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastOptions["variant"]) {
|
||||
const name = icon ?? (variant === "success" ? "check" : undefined)
|
||||
if (!name) return
|
||||
return <Icon name={name} />
|
||||
if (!name) return undefined
|
||||
// Solid resolves JSX accessors under the toast's render owner, not this imperative call site.
|
||||
return (() => <Icon name={name} />) as unknown as JSX.Element
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function Layout(props: ParentProps) {
|
||||
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
|
||||
style={{
|
||||
width: `${state.tabsWidth}px`,
|
||||
"padding-bottom": "max(8px, env(safe-area-inset-bottom, 0px))",
|
||||
"padding-bottom": "max(10px, env(safe-area-inset-bottom, 0px))",
|
||||
}}
|
||||
>
|
||||
<ResizeHandle
|
||||
|
||||
@@ -13,7 +13,7 @@ import { displayName, projectForSession } from "@/shell/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { canOpenTabRename, forwardTabRef } from "./tab-gesture"
|
||||
import { TabPreviewPopover } from "./tab-popover"
|
||||
@@ -63,7 +63,7 @@ export function TabNavItem(props: {
|
||||
})
|
||||
const title = createMemo(() => {
|
||||
const session = props.session
|
||||
return session ? sessionLabel(session) : props.fallbackTitle
|
||||
return sessionTabTitle(session ? session.title : props.fallbackTitle, language.t("session.tab.session"))
|
||||
})
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
|
||||
@@ -171,7 +171,7 @@ function SessionTabEntry(props: {
|
||||
preparing={!!pending()}
|
||||
fallbackTitle={
|
||||
pending()
|
||||
? language.t("command.session.new")
|
||||
? language.t("session.tab.session")
|
||||
: (persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined))
|
||||
}
|
||||
onRename={rename}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
|
||||
describe("session tab titles", () => {
|
||||
test("uses the same localized label before a title arrives", () => {
|
||||
for (const title of [
|
||||
undefined,
|
||||
"",
|
||||
"New session - 2026-07-30T18:45:03.662Z",
|
||||
"Child session - 2026-07-30T18:45:03.662Z",
|
||||
]) {
|
||||
expect(sessionTabTitle(title, "Session")).toBe("Session")
|
||||
expect(sessionTabTitle(title, "Sitzung")).toBe("Sitzung")
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves generated and user-supplied titles", () => {
|
||||
for (const title of ["Generated title", "New session", "New session - custom"]) {
|
||||
expect(sessionTabTitle(title, "Session")).toBe(title)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
// Draft, preparing, and untitled session tabs share one localized label.
|
||||
export function sessionTabTitle(title: string | undefined, fallback: string) {
|
||||
return !title || isFallbackTitle(title) ? fallback : title
|
||||
}
|
||||
@@ -2,6 +2,21 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="vertical-tabs-sidebar"] [data-slot="vertical-tabs"] {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-slot="vertical-tabs-sidebar"] [data-slot="vertical-tabs-scroll"] {
|
||||
min-height: 0;
|
||||
/* Leave enough space to scroll the final tab clear of the fade above Settings. */
|
||||
padding-block-end: 16px;
|
||||
scroll-padding-block-end: 16px;
|
||||
-webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
|
||||
mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-trigger"][aria-expanded="true"] {
|
||||
background:
|
||||
linear-gradient(var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
|
||||
import { createStore, unwrap } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { Dynamic, Portal } from "solid-js/web"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -26,17 +26,20 @@ import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
|
||||
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import devIcon from "../../../../desktop/icons/dev/64x64.png"
|
||||
import betaIcon from "../../../../desktop/icons/beta/64x64.png"
|
||||
|
||||
const titlebarHeight = 36
|
||||
const windowsTitlebarHeight = 44 // Includes the content inset; matches the native Windows overlay.
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
const macTrafficLightsBaseWidth = 84
|
||||
// Native controls: 14px left inset, two 20px button pitches, and a 14px button.
|
||||
const macTrafficLightsBaseWidth = 68
|
||||
const macTrafficLightsTopClearance = 28
|
||||
|
||||
export type TitlebarUpdate = {
|
||||
@@ -348,13 +351,19 @@ export function Titlebar(props: {
|
||||
type="button"
|
||||
data-action="vertical-tabs-home"
|
||||
data-state={layout.route().type === "home" ? "pressed" : undefined}
|
||||
class="mb-1 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"
|
||||
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 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"
|
||||
onClick={toggleHome}
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
>
|
||||
<Icon name="grid-plus" />
|
||||
{language.t("home.title")}
|
||||
<span class="min-w-0 truncate">{language.t("home.title")}</span>
|
||||
<span
|
||||
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("home.toggle")}</bdi>
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
)
|
||||
@@ -413,9 +422,12 @@ export function Titlebar(props: {
|
||||
const currentTitle = () => {
|
||||
const tab = currentTab()
|
||||
if (!tab) return language.t("home.title")
|
||||
if (tab.type === "draft") return language.t("command.session.new")
|
||||
if (tab.type === "draft") return language.t("session.tab.session")
|
||||
const value = session()
|
||||
return value ? sessionLabel(value) : (tabs.info[tabKey(tab)]?.title ?? language.t("command.session.new"))
|
||||
return sessionTabTitle(
|
||||
value ? value.title : tabs.info[tabKey(tab)]?.title,
|
||||
language.t("session.tab.session"),
|
||||
)
|
||||
}
|
||||
createEffect(() => {
|
||||
path()
|
||||
@@ -425,16 +437,17 @@ export function Titlebar(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pe-3"
|
||||
classList={{
|
||||
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
|
||||
"pb-[max(0px,calc(8px-env(safe-area-inset-bottom,0px)))]": bottom(),
|
||||
"md:pl-2": macTrafficLights(),
|
||||
"md:pl-4": !macTrafficLights(),
|
||||
"pl-4": macTrafficLights(),
|
||||
// Center the 20px app icon over the sidebar's 16px icon column.
|
||||
"ps-3.5": windows(),
|
||||
}}
|
||||
>
|
||||
<Show when={!mobile() && (!props.verticalTabs || windows())}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
<ChannelIndicator horizontal debugTools={props.debugTools} />
|
||||
</Show>
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
@@ -497,7 +510,7 @@ export function Titlebar(props: {
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
|
||||
<span data-slot="mobile-tab-title" dir="auto" class="min-w-0 flex-1 truncate text-start">
|
||||
{currentTitle()}
|
||||
</span>
|
||||
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
|
||||
@@ -625,33 +638,30 @@ export function Titlebar(props: {
|
||||
>
|
||||
<Show when={macVerticalTabs()}>
|
||||
<div
|
||||
class="relative mb-2 w-full shrink-0"
|
||||
class="mb-4 min-h-7 w-full shrink-0"
|
||||
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div
|
||||
class="absolute -top-0.5 bottom-0.5 flex items-center"
|
||||
style={{
|
||||
// Native traffic lights stay on the physical left; subtract the sidebar padding.
|
||||
left: macTrafficLights()
|
||||
? `calc(${macTrafficLightsBaseWidth / zoom()}px - 0.625rem)`
|
||||
: "0px",
|
||||
}}
|
||||
>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!windows()}>
|
||||
<ChannelIndicator sidebar debugTools={props.debugTools} />
|
||||
</Show>
|
||||
{homeButton(true)}
|
||||
<button
|
||||
type="button"
|
||||
data-action="vertical-tabs-new-session"
|
||||
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"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="edit" />
|
||||
{language.t("command.session.new")}
|
||||
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
|
||||
<span
|
||||
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
|
||||
</span>
|
||||
</button>
|
||||
<div class="h-4 w-full shrink-0" aria-hidden="true" />
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-1">
|
||||
@@ -670,14 +680,20 @@ export function Titlebar(props: {
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-slot="vertical-tabs-footer"
|
||||
class="mt-auto flex h-9 w-full shrink-0 items-center gap-1.5"
|
||||
<button
|
||||
type="button"
|
||||
data-action="vertical-tabs-settings"
|
||||
data-state={layout.route().type === "settings" ? "pressed" : undefined}
|
||||
class="mt-2 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]"
|
||||
onClick={openSettings}
|
||||
aria-label={language.t("sidebar.settings")}
|
||||
aria-pressed={layout.route().type === "settings"}
|
||||
>
|
||||
<Icon name="settings-gear" />
|
||||
{language.t("sidebar.settings")}
|
||||
</button>
|
||||
<div data-slot="vertical-tabs-footer" class="flex w-full shrink-0 items-center gap-1.5">
|
||||
<TitlebarRightMount />
|
||||
<Show when={!macVerticalTabs() && !windows()}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</Show>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
@@ -753,45 +769,47 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||
function ChannelIndicator(props: {
|
||||
horizontal?: boolean
|
||||
sidebar?: boolean
|
||||
debugTools?: { visible: boolean; toggle: () => void }
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||
const classes = () => ({
|
||||
"px-2 rounded-sm": windows(),
|
||||
"inline-flex h-4 shrink-0 items-center leading-4 px-1.5 rounded-full": !windows(),
|
||||
})
|
||||
const style = () => ({
|
||||
"font-size": windows() ? undefined : platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
|
||||
})
|
||||
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
|
||||
if (channel === "dev" && props.debugTools) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono cursor-pointer [app-region:no-drag]"
|
||||
classList={classes()}
|
||||
style={style()}
|
||||
onClick={props.debugTools.toggle}
|
||||
aria-label="Toggle debug tools"
|
||||
aria-pressed={props.debugTools.visible}
|
||||
>
|
||||
DEV
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (!channel || channel === "prod") return null
|
||||
|
||||
const label = channel && ["local", "beta", "dev"].includes(channel) ? channel.toUpperCase() : undefined
|
||||
const label = () => language.t(`titlebar.channel.${channel}`)
|
||||
const debug = () => (channel === "dev" ? props.debugTools : undefined)
|
||||
return (
|
||||
<Show when={label}>
|
||||
{(value) => (
|
||||
<div
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono"
|
||||
classList={classes()}
|
||||
style={style()}
|
||||
>
|
||||
{value()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Tooltip
|
||||
placement={props.sidebar ? "right" : "bottom"}
|
||||
value={label()}
|
||||
class={`shrink-0 [app-region:no-drag] ${props.sidebar ? "mb-4 ms-0.5 self-start" : ""} ${props.horizontal ? "me-1.5" : ""} ${props.horizontal && platform.platform === "web" ? "ps-2.5" : ""}`}
|
||||
>
|
||||
<Dynamic
|
||||
component={debug() ? "button" : "div"}
|
||||
type={debug() ? "button" : undefined}
|
||||
data-slot="channel-indicator"
|
||||
class="flex h-7 shrink-0 items-center rounded-[6px] [app-region:no-drag]"
|
||||
classList={{
|
||||
"w-6": props.sidebar,
|
||||
"w-5": !props.sidebar,
|
||||
"cursor-pointer hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02":
|
||||
!!debug(),
|
||||
}}
|
||||
onClick={() => debug()?.toggle()}
|
||||
aria-label={debug() ? language.t("titlebar.toggleDebugTools") : undefined}
|
||||
aria-pressed={debug()?.visible}
|
||||
>
|
||||
<img
|
||||
src={channel === "beta" ? betaIcon : devIcon}
|
||||
alt={debug() ? "" : label()}
|
||||
class="shrink-0 rounded-[4px] shadow-[var(--v2-elevation-raised)]"
|
||||
classList={{ "size-6": props.sidebar, "size-5": !props.sidebar }}
|
||||
draggable={false}
|
||||
/>
|
||||
</Dynamic>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createComputed, createRoot, createSignal } from "solid-js"
|
||||
import type { CommandPaletteEntry } from "@/shell/commands/palette"
|
||||
import { createCommandPaletteSearch } from "@/shell/commands/search"
|
||||
|
||||
const copy: CommandPaletteEntry = {
|
||||
id: "command:session.copyID",
|
||||
type: "command",
|
||||
title: "Copy Session ID",
|
||||
category: "Commands",
|
||||
}
|
||||
const file: CommandPaletteEntry = { id: "file:copy.txt", type: "file", title: "copy.txt", category: "Files" }
|
||||
const session: CommandPaletteEntry = {
|
||||
id: "session:copy",
|
||||
type: "session",
|
||||
title: "Copy session",
|
||||
category: "Sessions",
|
||||
}
|
||||
|
||||
describe("command palette search", () => {
|
||||
test("matches commands synchronously and cancels obsolete requests", () => {
|
||||
const signals: AbortSignal[] = []
|
||||
const root = createRoot((dispose) => {
|
||||
const [query, setQuery] = createSignal("")
|
||||
const search = createCommandPaletteSearch({
|
||||
query,
|
||||
items: (text) => (text === "copy session" ? [copy] : []),
|
||||
sources: [
|
||||
(_text, signal) => {
|
||||
signals.push(signal)
|
||||
return new Promise<CommandPaletteEntry[]>(() => {})
|
||||
},
|
||||
],
|
||||
})
|
||||
return { search, setQuery, dispose }
|
||||
})
|
||||
root.setQuery(" copy session ")
|
||||
expect(root.search.items()).toEqual([copy])
|
||||
expect(root.search.loading()).toBe(true)
|
||||
expect(signals[0].aborted).toBe(true)
|
||||
root.setQuery("no match")
|
||||
expect(root.search.items()).toEqual([])
|
||||
expect(signals[1].aborted).toBe(true)
|
||||
root.dispose()
|
||||
expect(signals[2].aborted).toBe(true)
|
||||
})
|
||||
|
||||
test("publishes each source independently and drops stale results on a new query", async () => {
|
||||
const files = Promise.withResolvers<CommandPaletteEntry[]>()
|
||||
const sessions = Promise.withResolvers<CommandPaletteEntry[]>()
|
||||
const fileVisible = Promise.withResolvers<void>()
|
||||
const sessionVisible = Promise.withResolvers<void>()
|
||||
const root = createRoot((dispose) => {
|
||||
const [query, setQuery] = createSignal("copy")
|
||||
const search = createCommandPaletteSearch({
|
||||
query,
|
||||
items: () => [copy],
|
||||
sources: [() => sessions.promise, () => files.promise],
|
||||
})
|
||||
createComputed(() => {
|
||||
if (search.items().some((entry) => entry.id === file.id)) fileVisible.resolve()
|
||||
if (search.items().some((entry) => entry.id === session.id)) sessionVisible.resolve()
|
||||
})
|
||||
return { search, setQuery, dispose }
|
||||
})
|
||||
expect(root.search.items()).toEqual([copy])
|
||||
files.resolve([file])
|
||||
await fileVisible.promise
|
||||
expect(root.search.items()).toEqual([copy, file])
|
||||
expect(root.search.loading()).toBe(true)
|
||||
sessions.resolve([session])
|
||||
await sessionVisible.promise
|
||||
expect(root.search.items()).toEqual([copy, session, file])
|
||||
expect(root.search.loading()).toBe(false)
|
||||
root.setQuery("new query")
|
||||
expect(root.search.items()).toEqual([copy])
|
||||
root.dispose()
|
||||
})
|
||||
|
||||
test("failed searches do not hide commands or successful sources", async () => {
|
||||
const settled = Promise.withResolvers<void>()
|
||||
const root = createRoot((dispose) => {
|
||||
const search = createCommandPaletteSearch({
|
||||
query: () => "copy",
|
||||
items: () => [copy],
|
||||
sources: [() => Promise.reject(new Error("offline")), () => Promise.resolve([file])],
|
||||
})
|
||||
createComputed(() => {
|
||||
if (!search.loading()) settled.resolve()
|
||||
})
|
||||
return { search, dispose }
|
||||
})
|
||||
await settled.promise
|
||||
expect(root.search.items()).toEqual([copy, file])
|
||||
root.dispose()
|
||||
})
|
||||
})
|
||||
@@ -59,6 +59,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
commands: [
|
||||
Spec.make("upgrade", {
|
||||
description: "Upgrade OpenCode to the latest or a specific version",
|
||||
aliases: ["update"],
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
|
||||
|
||||
@@ -67,9 +67,13 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
|
||||
function add(node: Spec.Any, value: RuntimeHandlers) {
|
||||
if (typeof value === "function") {
|
||||
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
|
||||
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
|
||||
return
|
||||
}
|
||||
if (value.$) result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
|
||||
if (value.$) {
|
||||
result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
|
||||
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
|
||||
}
|
||||
for (const [name, child] of Object.entries(node.commands)) add(child, value[name] as RuntimeHandlers)
|
||||
}
|
||||
|
||||
@@ -99,8 +103,12 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
|
||||
)
|
||||
: node.spec
|
||||
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
|
||||
const children = Object.values(node.commands)
|
||||
return spec.pipe(
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
Command.withSubcommands([
|
||||
...children.map((child) => provide(child, handlers)),
|
||||
...children.flatMap((child) => child.aliases.map((alias) => provide(alias, handlers))),
|
||||
]),
|
||||
) as ProvidedCommand
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Command } from "effect/unstable/cli"
|
||||
|
||||
type Options<Config extends Command.Command.Config, Commands extends ReadonlyArray<Any>> = {
|
||||
readonly description?: string
|
||||
readonly aliases?: ReadonlyArray<string>
|
||||
readonly params?: Config
|
||||
readonly commands?: Commands
|
||||
}
|
||||
@@ -14,6 +15,7 @@ export interface Node<
|
||||
readonly name: Name
|
||||
readonly spec: Spec
|
||||
readonly commands: Commands
|
||||
readonly aliases: ReadonlyArray<Any>
|
||||
}
|
||||
|
||||
export type Any = Node<string, Command.Command<any, any, any, any, any>, Children>
|
||||
@@ -24,14 +26,28 @@ export function make<
|
||||
const Config extends Command.Command.Config = {},
|
||||
const Commands extends ReadonlyArray<Any> = [],
|
||||
>(name: Name, options: Options<Config, Commands> = {}) {
|
||||
const command = Command.make(name, options.params ?? ({} as Config))
|
||||
const spec = options.description ? command.pipe(Command.withDescription(options.description)) : command
|
||||
const aliases = options.aliases ?? []
|
||||
const params = options.params ?? ({} as Config)
|
||||
const command = Command.make(name, params)
|
||||
const described = options.description ? command.pipe(Command.withDescription(options.description)) : command
|
||||
// Effect supports a single native alias, shown inline as `name, alias` in help.
|
||||
// Extra aliases become sibling commands sharing params and subcommands.
|
||||
const spec = aliases.length > 0 ? described.pipe(Command.withAlias(aliases[0])) : described
|
||||
const commands = Object.fromEntries(
|
||||
(options.commands ?? []).map((command) => [command.name, command]),
|
||||
) as ChildrenOf<Commands>
|
||||
const extra = aliases.slice(1).map((alias) => {
|
||||
const aliasCommand = Command.make(alias, params)
|
||||
const aliasSpec = options.description
|
||||
? aliasCommand.pipe(Command.withDescription(options.description))
|
||||
: aliasCommand
|
||||
return { name: alias, spec: aliasSpec, commands, aliases: [] }
|
||||
})
|
||||
return {
|
||||
name,
|
||||
spec,
|
||||
commands: Object.fromEntries(
|
||||
(options.commands ?? []).map((command) => [command.name, command]),
|
||||
) as ChildrenOf<Commands>,
|
||||
commands,
|
||||
aliases: extra,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ describe("acp command", () => {
|
||||
test("is registered", async () => {
|
||||
const result = await cli(["--help"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("acp Start an Agent Client Protocol server")
|
||||
expect(result.stdout).toMatch(/^ acp[ \t]+Start an Agent Client Protocol server\r?$/m)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -117,8 +117,8 @@ describe("mini command", () => {
|
||||
const result = await cli(["--help"])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("mini Start the minimal interactive interface")
|
||||
expect(result.stdout).toContain("run Run OpenCode with a message")
|
||||
expect(result.stdout).toMatch(/^ mini[ \t]+Start the minimal interactive interface\r?$/m)
|
||||
expect(result.stdout).toMatch(/^ run[ \t]+Run OpenCode with a message\r?$/m)
|
||||
})
|
||||
|
||||
test("exposes run without legacy interactive, attach, or command modes", async () => {
|
||||
|
||||
@@ -66,6 +66,8 @@ export type CreateDataInput = {
|
||||
readonly connection?: {
|
||||
readonly status: () => "connected" | "connecting" | "reconnecting"
|
||||
}
|
||||
/** Receives failed event-driven reads. Explicit reads still reject to their caller. */
|
||||
readonly onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
@@ -187,6 +189,17 @@ function createSync() {
|
||||
|
||||
export function createData(config: CreateDataInput) {
|
||||
const api = config.api
|
||||
let disposed = false
|
||||
onCleanup(() => (disposed = true))
|
||||
|
||||
function refresh(load: () => Promise<unknown>) {
|
||||
if (disposed || (config.connection && config.connection.status() !== "connected")) return
|
||||
void load().catch((error) => {
|
||||
if (disposed || (config.connection && config.connection.status() !== "connected")) return
|
||||
if (config.onError) return config.onError(error)
|
||||
console.error("Failed to refresh client data", error)
|
||||
})
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<Store>({
|
||||
session: {
|
||||
@@ -554,31 +567,34 @@ export function createData(config: CreateDataInput) {
|
||||
case "server.connected": {
|
||||
const updates = new Map<string, DataSessionStatus | undefined>()
|
||||
activeUpdates = updates
|
||||
void api()
|
||||
.session.active()
|
||||
.then((active) => {
|
||||
if (activeUpdates !== updates) return
|
||||
// Lifecycle events received during hydration supersede the snapshot.
|
||||
const snapshot = new Map<string, DataSessionStatus>(Object.keys(active).map((id) => [id, "running"]))
|
||||
updates.forEach((status, id) => {
|
||||
if (status === undefined) return snapshot.delete(id)
|
||||
snapshot.set(id, status)
|
||||
refresh(() =>
|
||||
api()
|
||||
.session.active()
|
||||
.then((active) => {
|
||||
if (activeUpdates !== updates) return
|
||||
// Lifecycle events received during hydration supersede the snapshot.
|
||||
const snapshot = new Map<string, DataSessionStatus>(Object.keys(active).map((id) => [id, "running"]))
|
||||
updates.forEach((status, id) => {
|
||||
if (status === undefined) return snapshot.delete(id)
|
||||
snapshot.set(id, status)
|
||||
})
|
||||
activeUpdates = undefined
|
||||
setStore("session", "active", reconcile(Object.fromEntries(snapshot)))
|
||||
})
|
||||
activeUpdates = undefined
|
||||
setStore("session", "active", reconcile(Object.fromEntries(snapshot)))
|
||||
})
|
||||
.catch(() => {
|
||||
if (activeUpdates === updates) activeUpdates = undefined
|
||||
})
|
||||
void api()
|
||||
.location.get({ location: locationQuery(defaultLocation()) })
|
||||
.then((location) => {
|
||||
const key = locationKey(location)
|
||||
setStore("location", key, { info: location })
|
||||
})
|
||||
.catch((error) => console.error("Failed to preload location", error))
|
||||
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
|
||||
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
|
||||
.catch(() => {
|
||||
if (activeUpdates === updates) activeUpdates = undefined
|
||||
}),
|
||||
)
|
||||
refresh(() =>
|
||||
api()
|
||||
.location.get({ location: locationQuery(defaultLocation()) })
|
||||
.then((location) => {
|
||||
const key = locationKey(location)
|
||||
setStore("location", key, { info: location })
|
||||
}),
|
||||
)
|
||||
refresh(() => result.location.vcs.sync())
|
||||
refresh(() => result.project.sync())
|
||||
return
|
||||
}
|
||||
case "project.updated":
|
||||
@@ -587,7 +603,7 @@ export function createData(config: CreateDataInput) {
|
||||
case "session.created":
|
||||
sessionOutbox.delete(event.data.sessionID)
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
refresh(() => result.session.sync(event.data.sessionID))
|
||||
// Band-aid: a newly created session starts empty, so live events can be its source of truth.
|
||||
// Fetching pending inputs and projected messages separately lets promotion move an input between snapshots,
|
||||
// causing both requests to miss it and overwrite event-built state. Skip those racy initial reads until
|
||||
@@ -628,25 +644,28 @@ export function createData(config: CreateDataInput) {
|
||||
model: event.data.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
void api()
|
||||
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||
.then((item) => {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(item.id)
|
||||
if (position === undefined) return message.append(draft, index, item)
|
||||
draft[position] = item
|
||||
})
|
||||
})
|
||||
.catch((error) => console.error("Failed to load projected model switch message", error))
|
||||
refresh(() =>
|
||||
api()
|
||||
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||
.then((item) => {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(item.id)
|
||||
if (position === undefined) return message.append(draft, index, item)
|
||||
draft[position] = item
|
||||
})
|
||||
}),
|
||||
)
|
||||
return
|
||||
case "session.renamed": {
|
||||
// Preserve the live title when it races the session's initial read.
|
||||
const family = sync.pending(`session.family:${event.data.sessionID}`)
|
||||
? result.session.sync(event.data.sessionID, { children: true })
|
||||
: Promise.resolve()
|
||||
void Promise.all([result.session.sync(event.data.sessionID), family]).then(() => {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
refresh(() => {
|
||||
const family = sync.pending(`session.family:${event.data.sessionID}`)
|
||||
? result.session.sync(event.data.sessionID, { children: true })
|
||||
: Promise.resolve()
|
||||
return Promise.all([result.session.sync(event.data.sessionID), family]).then(() => {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -680,7 +699,7 @@ export function createData(config: CreateDataInput) {
|
||||
if (!directory) {
|
||||
if (info.location.workspaceID) continue
|
||||
result.session.invalidate(sessionID)
|
||||
void result.session.sync(sessionID)
|
||||
refresh(() => result.session.sync(sessionID))
|
||||
continue
|
||||
}
|
||||
const adopted = Worktree.adopt(
|
||||
@@ -791,7 +810,7 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
|
||||
result.session.message.invalidate(event.data.sessionID)
|
||||
void result.session.message.sync(event.data.sessionID)
|
||||
refresh(() => result.session.message.sync(event.data.sessionID))
|
||||
return
|
||||
}
|
||||
case "session.step.started":
|
||||
@@ -992,12 +1011,12 @@ export function createData(config: CreateDataInput) {
|
||||
// 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)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
refresh(() => result.session.sync(event.data.sessionID))
|
||||
return
|
||||
case "session.viewed":
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
refresh(() => result.session.sync(event.data.sessionID))
|
||||
return
|
||||
case "session.revert.staged":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
@@ -1105,7 +1124,7 @@ export function createData(config: CreateDataInput) {
|
||||
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
|
||||
if (event.type === "credential.updated") {
|
||||
result.location.integration.invalidate(location)
|
||||
void result.location.integration.sync(location)
|
||||
refresh(() => result.location.integration.sync(location))
|
||||
return
|
||||
}
|
||||
setStore("location", key, (data) => ({
|
||||
@@ -1123,7 +1142,7 @@ export function createData(config: CreateDataInput) {
|
||||
}))
|
||||
result.location.model.invalidate(location)
|
||||
result.location.provider.invalidate(location)
|
||||
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
|
||||
refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]))
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1134,19 +1153,19 @@ export function createData(config: CreateDataInput) {
|
||||
case "catalog.updated":
|
||||
result.location.model.invalidate(location)
|
||||
result.location.provider.invalidate(location)
|
||||
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
|
||||
refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]))
|
||||
break
|
||||
case "agent.updated":
|
||||
result.location.agent.invalidate(location)
|
||||
void result.location.agent.sync(location)
|
||||
refresh(() => result.location.agent.sync(location))
|
||||
break
|
||||
case "command.updated":
|
||||
result.location.command.invalidate(location)
|
||||
void result.location.command.sync(location)
|
||||
refresh(() => result.location.command.sync(location))
|
||||
break
|
||||
case "skill.updated":
|
||||
result.location.skill.invalidate(location)
|
||||
void result.location.skill.sync(location)
|
||||
refresh(() => result.location.skill.sync(location))
|
||||
break
|
||||
case "vcs.branch.updated":
|
||||
setStore("location", locationKey(location), (data) => ({
|
||||
@@ -1181,31 +1200,33 @@ export function createData(config: CreateDataInput) {
|
||||
break
|
||||
case "reference.updated":
|
||||
result.location.reference.invalidate(location)
|
||||
void result.location.reference.sync(location)
|
||||
refresh(() => result.location.reference.sync(location))
|
||||
break
|
||||
case "integration.updated":
|
||||
result.location.integration.invalidate(location)
|
||||
result.location.model.invalidate(location)
|
||||
result.location.provider.invalidate(location)
|
||||
void Promise.all([
|
||||
result.location.integration.sync(location),
|
||||
result.location.model.sync(location),
|
||||
result.location.provider.sync(location),
|
||||
])
|
||||
refresh(() =>
|
||||
Promise.all([
|
||||
result.location.integration.sync(location),
|
||||
result.location.model.sync(location),
|
||||
result.location.provider.sync(location),
|
||||
]),
|
||||
)
|
||||
break
|
||||
case "config.updated":
|
||||
case "websearch.updated":
|
||||
void result.location.websearch.refresh(location)
|
||||
refresh(() => result.location.websearch.refresh(location))
|
||||
break
|
||||
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
||||
// so the mcp list syncs here rather than off integration.updated.
|
||||
case "mcp.status.changed":
|
||||
result.location.mcp.server.invalidate(location)
|
||||
void result.location.mcp.server.sync(location)
|
||||
refresh(() => result.location.mcp.server.sync(location))
|
||||
break
|
||||
case "mcp.resources.changed":
|
||||
result.location.mcp.resource.invalidate(location)
|
||||
void result.location.mcp.resource.sync(location)
|
||||
refresh(() => result.location.mcp.resource.sync(location))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
||||
|
||||
test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const reported = Promise.withResolvers<unknown>()
|
||||
const errors: unknown[] = []
|
||||
const state = { offline: true, requests: 0 }
|
||||
const session: SessionInfo = {
|
||||
id: "ses_refresh_failure",
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0, idle: 2 },
|
||||
location: { directory: "/project" },
|
||||
}
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async () => {
|
||||
state.requests++
|
||||
if (state.offline) throw new TypeError("Failed to fetch")
|
||||
return Response.json({ data: { ...session, title: "Recovered" } })
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
onError(error) {
|
||||
errors.push(error)
|
||||
reported.resolve(error)
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const event: OpenCodeEvent = {
|
||||
id: "evt_refresh_failure",
|
||||
created: 2,
|
||||
type: "session.viewed",
|
||||
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
||||
data: { sessionID: session.id, idle: 2 },
|
||||
}
|
||||
try {
|
||||
setup.data.session.remember(session)
|
||||
setup.data.session.invalidate(session.id)
|
||||
await expect(setup.data.session.sync(session.id)).rejects.toThrow("Transport")
|
||||
expect(errors).toEqual([])
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
expect(String(await reported.promise)).toContain("Transport")
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(setup.data.session.get(session.id)?.title).toBeUndefined()
|
||||
state.offline = false
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
await setup.data.session.sync(session.id)
|
||||
expect(setup.data.session.get(session.id)?.title).toBe("Recovered")
|
||||
expect(state.requests).toBe(3)
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["reconnecting", "disposed"] as const)("background reads respect %s owners", async (mode) => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const pending = Promise.withResolvers<Response>()
|
||||
const errors: unknown[] = []
|
||||
const state = { requests: 0 }
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: () => {
|
||||
state.requests++
|
||||
return pending.promise
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
connection: { status: () => (mode === "reconnecting" ? "reconnecting" : "connected") },
|
||||
onError: (error) => errors.push(error),
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const event: OpenCodeEvent = {
|
||||
id: "evt_refresh_owner",
|
||||
type: "command.updated",
|
||||
location: { directory: "/project" },
|
||||
data: {},
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
if (mode === "reconnecting") {
|
||||
expect(state.requests).toBe(0)
|
||||
setup.dispose()
|
||||
return
|
||||
}
|
||||
const joined = setup.data.location.command.sync()
|
||||
setup.dispose()
|
||||
pending.reject(new TypeError("Failed to fetch"))
|
||||
await expect(joined).rejects.toThrow("Transport")
|
||||
expect(state.requests).toBe(1)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session"
|
||||
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Catalog } from "../../catalog.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { CopilotModels } from "../../github-copilot/models.js"
|
||||
import { App } from "../../app.js"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
@@ -250,15 +250,26 @@ export const GithubCopilotPlugin = define({
|
||||
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
||||
}),
|
||||
)
|
||||
// Runs for every route, unlike http.request, which the AI SDK route bypasses.
|
||||
yield* ctx.session.hook(
|
||||
"model.request",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.githubCopilot) return
|
||||
const session = yield* ctx.session
|
||||
.get({ sessionID: evt.sessionID })
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
const interaction = interactionType(evt.kind, session?.parentID !== undefined)
|
||||
evt.headers["X-Interaction-Type"] = interaction
|
||||
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
)
|
||||
yield* ctx.session.hook(
|
||||
"http.request",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.githubCopilot) return
|
||||
if (evt.agent === Agent.ID.make("title"))
|
||||
evt.request.headers.set("X-Interaction-Type", "conversation-background")
|
||||
if (evt.agent === Agent.ID.make("compaction"))
|
||||
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
|
||||
const token = evt.request.headers.get("x-api-key")
|
||||
if (!token) return
|
||||
const text = yield* Effect.promise(() => evt.request.clone().text())
|
||||
@@ -370,11 +381,23 @@ function applyHeaders(
|
||||
headers.set("User-Agent", App.useragent(app))
|
||||
headers.set("Openai-Intent", "conversation-edits")
|
||||
headers.set("X-GitHub-Api-Version", apiVersion)
|
||||
headers.set("x-initiator", metadata.agent ? "agent" : "user")
|
||||
// The step may already have declared itself agent-initiated (subagent, title, compaction);
|
||||
// the body can only ever escalate to "agent", never back to "user".
|
||||
if (metadata.agent) headers.set("x-initiator", "agent")
|
||||
else if (!headers.has("x-initiator")) headers.set("x-initiator", "user")
|
||||
if (metadata.vision) headers.set("Copilot-Vision-Request", "true")
|
||||
if (anthropic) headers.set("anthropic-beta", "interleaved-thinking-2025-05-14")
|
||||
}
|
||||
|
||||
// Mirrors the Copilot client's X-Interaction-Type vocabulary: the agent loop is the default,
|
||||
// nested sessions are subagents, and title/compaction are the two utility overrides.
|
||||
export function interactionType(kind: SessionRequestKind, child: boolean) {
|
||||
if (kind === "title") return "conversation-background"
|
||||
if (kind === "compaction") return "conversation-compaction"
|
||||
if (child) return "conversation-subagent"
|
||||
return "conversation-agent"
|
||||
}
|
||||
|
||||
type RequestMetadata = ReturnType<typeof requestMetadata>
|
||||
|
||||
function requestMetadata(url: string, body: unknown) {
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { dlopen } from "bun:ffi"
|
||||
import { spawn } from "bun-pty"
|
||||
import type { Opts, Proc } from "./pty.js"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty.js"
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const library = dlopen("kernel32.dll", {
|
||||
SetConsoleCtrlHandler: { args: ["ptr", "i32"], returns: "i32" },
|
||||
GetLastError: { args: [], returns: "u32" },
|
||||
})
|
||||
try {
|
||||
// Detached servers start with Ctrl+C ignored, and ConPTY shells inherit it.
|
||||
// Clear that attribute once before spawning shells; keep registered handlers.
|
||||
if (library.symbols.SetConsoleCtrlHandler(null, 0) === 0)
|
||||
throw new Error(`Failed to enable PTY Ctrl+C handling: Windows error ${library.symbols.GetLastError()}`)
|
||||
} finally {
|
||||
library.close()
|
||||
}
|
||||
}
|
||||
|
||||
function spawnPty(file: string, args: string[], opts: Opts): Proc {
|
||||
const pty = spawn(file, args, opts)
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLMClient, LLMEvent, LLMRequest, Message, type ContentPart } from "@opencode-ai/ai"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import {
|
||||
AIError,
|
||||
InvalidProviderOutputError,
|
||||
UnknownProviderError,
|
||||
isContextOverflowFailure,
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
type ContentPart,
|
||||
} from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -12,6 +21,7 @@ import type { SessionContext } from "./context.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionRunnerRetry } from "./runner/retry.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { Token } from "../util/token.js"
|
||||
@@ -40,7 +50,7 @@ const SUMMARY_TEMPLATE = `You MUST use this format for your response (you may om
|
||||
|
||||
## Work State
|
||||
### Completed
|
||||
- [finished work, verified facts, or changes made; otherwise "(none)"]
|
||||
- [finished work or changes made; otherwise "(none)"]
|
||||
|
||||
### Active
|
||||
- [current work, partial changes, or investigation state; otherwise "(none)"]
|
||||
@@ -385,10 +395,10 @@ export const layer = Layer.effect(
|
||||
messages: history.messages,
|
||||
})
|
||||
const prepared = yield* input.prepare({
|
||||
kind: "compaction",
|
||||
scope: {
|
||||
session: context.session,
|
||||
agentID: Agent.ID.make("compaction"),
|
||||
contextAgentID: context.agent.id,
|
||||
agentID: context.agent.id,
|
||||
model: context.model,
|
||||
tools: context.tools,
|
||||
},
|
||||
@@ -405,68 +415,105 @@ export const layer = Layer.effect(
|
||||
],
|
||||
},
|
||||
})
|
||||
// Ignored tool calls never enter the follow-up history or need fabricated results.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
chunks.length = 0
|
||||
providerState = undefined
|
||||
yield* llm
|
||||
.stream(
|
||||
attempt === 0
|
||||
? prepared.request
|
||||
: LLMRequest.update(prepared.request, {
|
||||
messages: [
|
||||
...prepared.request.messages,
|
||||
Message.user(
|
||||
"The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.",
|
||||
),
|
||||
],
|
||||
}),
|
||||
prepared.options,
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: context.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
providerState =
|
||||
event.providerMetadata?.[
|
||||
context.model.model.route.providerMetadataKey ?? context.model.model.provider
|
||||
]
|
||||
const step = SessionUsage.record(event.usage, context.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
const retry = yield* SessionRunnerRetry.policy(context.session.id)
|
||||
// Both requests share the retry allowance; rejected output never enters the reminder request.
|
||||
for (const request of [
|
||||
prepared.request,
|
||||
LLMRequest.update(prepared.request, {
|
||||
messages: [
|
||||
...prepared.request.messages,
|
||||
Message.user(
|
||||
"The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.",
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
input.reason === "auto"
|
||||
? failed({
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: input.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
],
|
||||
}),
|
||||
]) {
|
||||
yield* Stream.suspend(() => {
|
||||
chunks.length = 0
|
||||
providerState = undefined
|
||||
failure = undefined
|
||||
return llm.stream(request, prepared.options)
|
||||
}).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: context.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
providerState =
|
||||
event.providerMetadata?.[context.model.model.route.providerMetadataKey ?? context.model.model.provider]
|
||||
const step = SessionUsage.record(event.usage, context.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
if (LLMEvent.is.finish(event)) {
|
||||
if (event.reason.normalized === "length")
|
||||
failure = { type: "compaction.failed", message: "Compaction summary reached the output token limit" }
|
||||
if (event.reason.normalized === "content-filter")
|
||||
failure = {
|
||||
type: "provider.content-filter",
|
||||
message: "Compaction summary was blocked by the provider",
|
||||
}
|
||||
if (event.reason.normalized === "unknown")
|
||||
return Effect.fail(
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "The provider response ended with an unknown finish reason.",
|
||||
classification: "incomplete-stream",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
if (event.reason.normalized === "error")
|
||||
return Effect.fail(
|
||||
new AIError({ reason: new UnknownProviderError({ message: "Compaction generation failed" }) }),
|
||||
)
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.retry({
|
||||
while: (cause) =>
|
||||
Effect.gen(function* () {
|
||||
if (isContextOverflowFailure(cause)) return false
|
||||
const decision = yield* retry({
|
||||
cause,
|
||||
error: toSessionError(cause),
|
||||
agent: context.agent.id,
|
||||
model: context.model.ref,
|
||||
hook: prepared.retry,
|
||||
retry: SessionRunnerRetry.isRetryable(cause),
|
||||
})
|
||||
if (!decision.retry) return false
|
||||
yield* Effect.sleep(decision.delay)
|
||||
return true
|
||||
}),
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
input.reason === "auto"
|
||||
? failed({
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: input.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
if (failure || hasSummarySection(chunks.join(""))) break
|
||||
}
|
||||
yield* recordUsage
|
||||
|
||||
@@ -38,6 +38,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
|
||||
messages: history.messages,
|
||||
})
|
||||
const prepared = yield* context.prepare({
|
||||
kind: "generate",
|
||||
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
export * as SessionModelRequest from "./model-request.js"
|
||||
|
||||
import { HttpOptions, LanguageModel, LLM, LLMRequest, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import {
|
||||
GenerationOptions,
|
||||
type GenerationOptionsFields,
|
||||
HttpOptions,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMRequest,
|
||||
Message,
|
||||
SystemPart,
|
||||
} from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { SessionRequestKind, SessionRequestOptions } from "@opencode-ai/plugin/effect/session"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
@@ -25,6 +35,8 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
|
||||
const IMAGE_REMOVED =
|
||||
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
|
||||
|
||||
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
|
||||
|
||||
const responsesWebSocketFlag = (providerID: string) =>
|
||||
`OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`
|
||||
|
||||
@@ -48,7 +60,10 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
||||
export interface Prepared {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
|
||||
/** Runs retry hooks with this request's kind; the returned event carries the hooked decision. */
|
||||
readonly retry: (
|
||||
event: Omit<PluginHooks.Domains["session"]["retry"], "kind">,
|
||||
) => Effect.Effect<PluginHooks.Domains["session"]["retry"]>
|
||||
/**
|
||||
* One request-scoped execution operation. Unknown and hook-removed calls
|
||||
* fail individually through the same seam.
|
||||
@@ -59,11 +74,11 @@ export interface Prepared {
|
||||
}
|
||||
|
||||
interface PrepareInput {
|
||||
/** Which Session flow issues this request; request hooks receive it alongside the Session identity. */
|
||||
readonly kind: SessionRequestKind
|
||||
readonly scope: {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agentID: Agent.ID
|
||||
/** Agent whose context an auxiliary request reuses, without changing its request-hook identity. */
|
||||
readonly contextAgentID?: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
/** Omitted for requests that carry no tool definitions, such as titles. */
|
||||
readonly tools?: Tool.Snapshot
|
||||
@@ -73,11 +88,6 @@ interface PrepareInput {
|
||||
readonly messages: Array<Message>
|
||||
}
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/**
|
||||
* Session context hooks shape the agent conversation. Standalone requests
|
||||
* such as titles opt out; compaction uses the selected Session context.
|
||||
*/
|
||||
readonly contextHooks?: false
|
||||
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
|
||||
readonly webSocket?: "session"
|
||||
}
|
||||
@@ -197,6 +207,7 @@ interface HookScope {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
}
|
||||
|
||||
const sessionHeaders = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, app: App.Info) => ({
|
||||
@@ -301,17 +312,26 @@ export const layer = Layer.effect(
|
||||
)
|
||||
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
||||
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
|
||||
const context: PluginHooks.Domains["session"]["context"] = {
|
||||
const draft = {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.contextAgentID ?? input.scope.agentID,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
tools: definitions,
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
options: {} as SessionRequestOptions,
|
||||
}
|
||||
if (input.contextHooks !== false) yield* hooks.trigger("session", "context", context)
|
||||
// Titles are not part of the agent conversation and skip context hooks.
|
||||
const context =
|
||||
input.kind === "title" ? draft : yield* hooks.trigger("session", "context", { ...draft, kind: input.kind })
|
||||
// Typed generation keys and provider-semantic keys share one bag in the hook;
|
||||
// the request keeps them apart.
|
||||
const generation = Object.fromEntries(
|
||||
Object.entries(context.options).filter(([key]) => GENERATION_KEYS.has(key)),
|
||||
) as GenerationOptionsFields
|
||||
const providerOptions = Object.fromEntries(
|
||||
Object.entries(context.options).filter(([key]) => !GENERATION_KEYS.has(key)),
|
||||
)
|
||||
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
||||
// by key. Identity wins so a definition moved onto another tool's name still executes
|
||||
// the tool it describes. Entries matching neither were invented by a hook and dropped.
|
||||
@@ -325,7 +345,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
const request = yield* applyModelHooks(
|
||||
hooks,
|
||||
{ sessionID: session.id, agent: input.scope.agentID, model: resolved.ref },
|
||||
{ sessionID: session.id, agent: input.scope.agentID, model: resolved.ref, kind: input.kind },
|
||||
LLM.request({
|
||||
model,
|
||||
http: {
|
||||
@@ -337,8 +357,8 @@ export const layer = Layer.effect(
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
toolChoice: input.toolChoice,
|
||||
generation: Object.keys(context.generation).length === 0 ? undefined : context.generation,
|
||||
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
|
||||
generation: Object.keys(generation).length === 0 ? undefined : generation,
|
||||
providerOptions: Object.keys(providerOptions).length === 0 ? undefined : providerOptions,
|
||||
}),
|
||||
)
|
||||
const hasHttpHooks =
|
||||
@@ -356,6 +376,7 @@ export const layer = Layer.effect(
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
kind: input.kind,
|
||||
})
|
||||
: undefined
|
||||
const options: StreamOptions = {
|
||||
@@ -368,7 +389,7 @@ export const layer = Layer.effect(
|
||||
tools
|
||||
.execute({ ...input, definitions: hooked })
|
||||
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
|
||||
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid)
|
||||
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", { ...event, kind: input.kind })
|
||||
return {
|
||||
request,
|
||||
options,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as SessionRunnerLLM from "./llm.js"
|
||||
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
@@ -15,6 +16,7 @@ import { SessionModelTransport } from "../model-transport.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
import { SessionMessageTable } from "../sql.js"
|
||||
import { SessionTitle } from "../title.js"
|
||||
import { DrainResult, Service, type Interface } from "./index.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
@@ -59,6 +61,7 @@ const layer = Layer.effect(
|
||||
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
|
||||
}
|
||||
yield* plugins.awaitActivation
|
||||
yield* settleStaleCompactions(sessionID)
|
||||
yield* settleStaleToolCalls(sessionID)
|
||||
|
||||
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
|
||||
@@ -214,6 +217,7 @@ const layer = Layer.effect(
|
||||
messages: loaded.messages,
|
||||
})
|
||||
const prepared = yield* context.prepare({
|
||||
kind: "primary",
|
||||
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
@@ -276,6 +280,36 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const settleStaleCompactions = Effect.fn("SessionRunner.settleStaleCompactions")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
// A process death skips compaction finalizers. Include orphans behind a
|
||||
// completed checkpoint, and settle newest first to match event projection.
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
for (const row of rows) {
|
||||
const message = yield* SessionHistory.decodeMessageRow(row)
|
||||
if (message.type !== "compaction") continue
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: message.reason,
|
||||
inputID: message.id,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Model } from "@opencode-ai/schema/model"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Clock, Duration, Effect, Pull, Schedule } from "effect"
|
||||
import { Bus } from "../../bus.js"
|
||||
import type { PluginHooks } from "../../plugin/hooks.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import type { SessionModelRequest } from "../model-request.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
|
||||
@@ -16,7 +16,7 @@ interface Input {
|
||||
readonly error: SessionError.Error
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly hook: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
|
||||
readonly hook: SessionModelRequest.Prepared["retry"]
|
||||
readonly retry: boolean
|
||||
}
|
||||
|
||||
@@ -78,11 +78,11 @@ const schedule = Schedule.max([Schedule.exponential("2 seconds"), Schedule.recur
|
||||
}),
|
||||
)
|
||||
|
||||
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
|
||||
export const policy = (sessionID: SessionSchema.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const step = yield* Schedule.toStep(schedule)
|
||||
let attempt = 1
|
||||
const decide = (input: Input) =>
|
||||
return (input: Input) =>
|
||||
Effect.gen(function* () {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const next = yield* step(now, input).pipe(Pull.catchDone(() => Effect.succeed(undefined)))
|
||||
@@ -90,20 +90,24 @@ export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
|
||||
const [, duration] = next
|
||||
attempt++
|
||||
const delay = Math.ceil(Duration.toMillis(duration))
|
||||
const event: PluginHooks.Domains["session"]["retry"] = {
|
||||
const event = yield* input.hook({
|
||||
sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
error: input.error,
|
||||
attempt,
|
||||
decision: input.retry ? { retry: true, delay } : { retry: false },
|
||||
}
|
||||
yield* input.hook(event)
|
||||
})
|
||||
if (!event.decision.retry) return event.decision
|
||||
const normalized =
|
||||
Number.isFinite(event.decision.delay) && event.decision.delay >= 0 ? Math.ceil(event.decision.delay) : delay
|
||||
return { retry: true as const, attempt, delay: normalized }
|
||||
})
|
||||
})
|
||||
|
||||
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const decide = yield* policy(sessionID)
|
||||
const wait = (input: {
|
||||
readonly decision: Decision
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
|
||||
@@ -64,12 +64,12 @@ export const layer = Layer.effect(
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* context.prepare({
|
||||
kind: "title",
|
||||
scope: { session: input.session, agentID: input.agent.id, model: input.model },
|
||||
transcript: {
|
||||
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
|
||||
messages: [Message.user(input.text)],
|
||||
},
|
||||
contextHooks: false,
|
||||
})
|
||||
yield* llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as OpenCodeTools from "./opencode.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { SystemPart, ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
@@ -18,6 +18,15 @@ const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePat
|
||||
export const Plugin = {
|
||||
id: "opencode.tools",
|
||||
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.system.push(
|
||||
SystemPart.make(
|
||||
"When you create a worktree outside the current working directory and intend to use it as your primary working directory, consider using `execute` to call `tools.opencode.session_move` and make the worktree the session's working directory.",
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* ctx.tool
|
||||
.transform((draft) => {
|
||||
draft.namespace({ name: "opencode", description: "OpenCode session and runtime tools." })
|
||||
|
||||
@@ -122,8 +122,8 @@ describe("CodeModeInstructions.render", () => {
|
||||
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
|
||||
expect(instructions).not.toContain("## Search")
|
||||
expect(instructions).toContain("The Code Mode tool catalog below is complete.")
|
||||
expect(instructions).toContain("This catalog is the complete set of tools available within Code Mode.")
|
||||
expect(instructions).not.toContain("surrounding top-level agent tools")
|
||||
expect(instructions).toContain("This catalog is the complete set of tools callable inside `execute`.")
|
||||
expect(instructions).toContain("It does not affect tools exposed directly outside Code Mode.")
|
||||
})
|
||||
|
||||
test("adds search guidance when the catalog exceeds the budget", () => {
|
||||
@@ -133,9 +133,9 @@ describe("CodeModeInstructions.render", () => {
|
||||
expect(partial).toContain("## Search")
|
||||
expect(partial).toContain("The Code Mode tool catalog below is partial.")
|
||||
expect(partial).toContain(
|
||||
"The Code Mode catalog and `search` results are the complete set of tools available within Code Mode.",
|
||||
"The Code Mode catalog and `search` results are the complete set of tools callable inside `execute`.",
|
||||
)
|
||||
expect(partial).not.toContain("surrounding top-level agent tools")
|
||||
expect(partial).toContain("It does not affect tools exposed directly outside Code Mode.")
|
||||
expect(partial).toContain("- search(input: {")
|
||||
expect(partial).toContain(" /** @integer @exclusiveMinimum 0 */\n limit?: number,")
|
||||
expect(partial).toContain(" /** @integer @minimum 0 */\n offset?: number,")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { spawn } from "../../src/pty/pty.bun"
|
||||
|
||||
const raw = process.argv[2] === "raw"
|
||||
const pty = spawn(
|
||||
Bun.which("pwsh") ?? "powershell.exe",
|
||||
[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
...(raw ? [] : ["-NoExit"]),
|
||||
"-Command",
|
||||
raw
|
||||
? "[Console]::TreatControlCAsInput = $true; Write-Output 'PTY_RAW_READY'; $key = [Console]::ReadKey($true); Write-Output ('PTY_KEY:' + [int]$key.KeyChar)"
|
||||
: "Remove-Module PSReadLine -ErrorAction SilentlyContinue; function prompt { 'PTY_PROMPT> ' }",
|
||||
],
|
||||
{ name: "xterm-256color", cols: 160, rows: 24, env: { ...process.env, TERM: "xterm-256color" } },
|
||||
)
|
||||
const output = { text: "", cursor: 0 }
|
||||
const listeners = new Set<() => void>()
|
||||
const exited = Promise.withResolvers<number>()
|
||||
pty.onExit((event) => exited.resolve(event.exitCode))
|
||||
pty.onData((text) => {
|
||||
output.text += text
|
||||
listeners.forEach((check) => check())
|
||||
})
|
||||
|
||||
try {
|
||||
if (raw) {
|
||||
await waitFor("PTY_RAW_READY")
|
||||
pty.write("\x03")
|
||||
await waitFor("PTY_KEY:3")
|
||||
}
|
||||
if (!raw) {
|
||||
await waitFor("PTY_PROMPT>")
|
||||
// Split markers so echoed command text cannot satisfy the output checks.
|
||||
pty.write("Write-Output ('PTY_' + 'BUSY'); Start-Sleep -Seconds 60; Write-Output ('PTY_' + 'COMPLETED')\r")
|
||||
await waitFor("PTY_BUSY")
|
||||
pty.write("\x03")
|
||||
await waitFor("PTY_PROMPT>")
|
||||
assert.ok(!output.text.includes("PTY_COMPLETED"))
|
||||
pty.write("Write-Output ('PTY_' + 'REUSED')\r")
|
||||
await waitFor("PTY_REUSED")
|
||||
await waitFor("PTY_PROMPT>")
|
||||
pty.write("exit 0\r")
|
||||
}
|
||||
assert.equal(await exited.promise, 0)
|
||||
} finally {
|
||||
pty.kill()
|
||||
}
|
||||
process.exit(0)
|
||||
|
||||
function waitFor(text: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
listeners.delete(check)
|
||||
reject(new Error(`Timed out waiting for ${JSON.stringify(text)}: ${output.text}`))
|
||||
}, 5_000)
|
||||
const check = () => {
|
||||
const index = output.text.indexOf(text, output.cursor)
|
||||
if (index === -1) return
|
||||
output.cursor = index + text.length
|
||||
listeners.delete(check)
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
listeners.add(check)
|
||||
check()
|
||||
})
|
||||
}
|
||||
@@ -69,12 +69,16 @@ async function createRegistryFixture(directory: string) {
|
||||
await Bun.$`tar -czf package.tgz package`.cwd(root)
|
||||
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
|
||||
}
|
||||
const state = { latest: "1.0.0" }
|
||||
const state = { latest: "1.0.0", audits: 0 }
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.startsWith("/-/npm/v1/security/")) {
|
||||
state.audits++
|
||||
return Response.json({})
|
||||
}
|
||||
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
|
||||
return Response.json({
|
||||
name: "@fixture/registry-plugin",
|
||||
@@ -97,7 +101,7 @@ async function createRegistryFixture(directory: string) {
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(root, ".npmrc"),
|
||||
`@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
|
||||
`registry=${server.url}\n@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=true\n`,
|
||||
)
|
||||
return root
|
||||
},
|
||||
@@ -359,6 +363,25 @@ describe("Npm.resolve", () => {
|
||||
})
|
||||
|
||||
describe("Npm.check and Npm.update", () => {
|
||||
test("installs and updates without requesting registry audits", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using registry = await createRegistryFixture(tmp.path)
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const spec = "@fixture/registry-plugin@latest"
|
||||
await registry.configure(cache, spec)
|
||||
|
||||
await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
expect((yield* npm.add(spec)).version).toBe("1.0.0")
|
||||
expect(registry.state.audits).toBe(0)
|
||||
|
||||
registry.state.latest = "1.1.0"
|
||||
expect((yield* npm.update(spec)).version).toBe("1.1.0")
|
||||
expect(registry.state.audits).toBe(0)
|
||||
expect((yield* npm.resolve(spec)).version).toBe("1.1.0")
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
})
|
||||
|
||||
test("checks a mutable registry target without mutation and explicitly updates it", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using registry = await createRegistryFixture(tmp.path)
|
||||
|
||||
@@ -121,11 +121,11 @@ const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => (
|
||||
sessionID,
|
||||
agent,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test") },
|
||||
kind: "primary",
|
||||
system: [],
|
||||
messages,
|
||||
tools: {},
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
options: {},
|
||||
})
|
||||
|
||||
type ToolErrorEvent = Extract<ToolHooks["execute.after"], { readonly status: "error" }>
|
||||
|
||||
@@ -281,6 +281,7 @@ describe("AzurePlugin", () => {
|
||||
sessionID: Session.ID.make("ses_azure"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model,
|
||||
kind: "primary",
|
||||
request: new Request("https://test-resource.openai.azure.com/openai/v1/responses", {
|
||||
headers: { "api-key": "stored-token", "x-keep": "yes" },
|
||||
}),
|
||||
@@ -295,6 +296,7 @@ describe("AzurePlugin", () => {
|
||||
sessionID: Session.ID.make("ses_foundry"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model,
|
||||
kind: "primary",
|
||||
request: new Request("https://test-resource.services.ai.azure.com/anthropic/v1/messages", {
|
||||
headers: { "x-api-key": "stored-token" },
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from "@opencode-ai/core/plugin/provider/github-copilot"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session"
|
||||
import { fakeSelectorSdk } from "../fixture/selector"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -35,6 +37,25 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
const sessions = Effect.fn(function* () {
|
||||
const service = yield* Session.Service
|
||||
const location = yield* Location.Service
|
||||
const parent = yield* service.create({ location: { directory: location.directory } })
|
||||
const child = yield* service.create({ parentID: parent.id })
|
||||
return { parent: parent.id, child: child.id }
|
||||
})
|
||||
|
||||
const modelRequest = Effect.fn(function* (sessionID: Session.ID, kind: SessionRequestKind, agent = "build") {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
return yield* hooks.trigger("session", "model.request", {
|
||||
sessionID,
|
||||
agent: Agent.ID.make(agent),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
|
||||
kind,
|
||||
headers: {},
|
||||
})
|
||||
})
|
||||
|
||||
describe("GithubCopilotPlugin", () => {
|
||||
test("prefers the account-specific Copilot API endpoint", () => {
|
||||
expect(
|
||||
@@ -135,6 +156,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("claude-sonnet-4.5") }),
|
||||
kind: "primary",
|
||||
request: new Request("https://api.githubcopilot.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-api-key": "token" },
|
||||
@@ -149,31 +171,88 @@ describe("GithubCopilotPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies title generation as a background interaction", () =>
|
||||
it.effect("classifies main-loop steps as agent interactions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_title"),
|
||||
agent: Agent.ID.make("title"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }),
|
||||
request: new Request("https://api.githubcopilot.com/chat/completions"),
|
||||
})
|
||||
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-background")
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "primary")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies compaction requests", () =>
|
||||
it.effect("classifies child-session steps as subagent interactions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).child, "primary")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-subagent", "x-initiator": "agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies title generation as a background interaction", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "title")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-background", "x-initiator": "agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies compaction requests by kind rather than agent", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).child, "compaction", "build")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-compaction", "x-initiator": "agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not classify by agent name", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "primary", "compaction")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores other providers' model requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_compaction"),
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
|
||||
request: new Request("https://api.githubcopilot.com/responses"),
|
||||
const event = yield* hooks.trigger("session", "model.request", {
|
||||
sessionID: (yield* sessions()).parent,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5.4") }),
|
||||
kind: "primary",
|
||||
headers: {},
|
||||
})
|
||||
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-compaction")
|
||||
expect(event.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps a declared agent initiator when the body looks user-initiated", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests: Headers[] = []
|
||||
const send = copilotFetch(
|
||||
"token",
|
||||
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
requests.push(new Headers(init?.headers))
|
||||
return Response.json({ ok: true })
|
||||
},
|
||||
App.make({ name: "test", version: "1.2.3", channel: "beta" }),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
send("https://api.githubcopilot.com/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "x-initiator": "agent" },
|
||||
body: JSON.stringify({ messages: [{ role: "user", content: "summarize" }] }),
|
||||
}),
|
||||
)
|
||||
expect(requests[0]?.get("x-initiator")).toBe("agent")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies session generation requests as agent interactions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "generate")
|
||||
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||
kind: "primary",
|
||||
baseURL,
|
||||
headers: {},
|
||||
})
|
||||
@@ -226,6 +227,7 @@ describe("OpenAIPlugin", () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
return yield* requests.prepare({
|
||||
kind: "primary",
|
||||
scope: {
|
||||
session: Session.Info.make({
|
||||
id: sessionID,
|
||||
|
||||
@@ -29,11 +29,11 @@ const context = (id: string, system = fallback): SessionHooks["context"] => ({
|
||||
sessionID: Session.ID.make("ses_system_prompt"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }),
|
||||
kind: "primary",
|
||||
system: [SystemPart.make(system)],
|
||||
messages: [],
|
||||
tools: {},
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
options: {},
|
||||
})
|
||||
|
||||
describe("SystemPromptPlugin", () => {
|
||||
|
||||
@@ -4,6 +4,10 @@ import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
describe("Core test environment", () => {
|
||||
test("disables public npm security audits", () => {
|
||||
expect(process.env.NPM_CONFIG_AUDIT).toBe("false")
|
||||
})
|
||||
|
||||
test("isolates global home and XDG roots", () => {
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
expect(home).toBeDefined()
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.NPM_CONFIG_AUDIT = "false"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect } from "bun:test"
|
||||
import { spawn } from "node:child_process"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const windowsTest = process.platform === "win32" ? it.live : it.live.skip
|
||||
|
||||
Array.of("interrupt", "raw").forEach((scenario) => {
|
||||
windowsTest(
|
||||
scenario === "interrupt"
|
||||
? "detached PTY hosts interrupt commands without closing the shell"
|
||||
: "detached PTY hosts preserve Ctrl+C input for raw-mode programs",
|
||||
Effect.gen(function* () {
|
||||
// Isolate the inheritable Windows console state from the test runner.
|
||||
const worker = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const child = spawn(process.execPath, [path.join(import.meta.dir, "../fixture/pty-windows.ts"), scenario], {
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
timeout: 30_000,
|
||||
})
|
||||
const output: string[] = []
|
||||
child.stderr.on("data", (chunk: Buffer) => output.push(chunk.toString()))
|
||||
const exited = new Promise<number | null>((resolve, reject) => {
|
||||
child.once("error", reject)
|
||||
child.once("close", resolve)
|
||||
})
|
||||
return { child, output, exited }
|
||||
}),
|
||||
(worker) =>
|
||||
Effect.sync(() => {
|
||||
if (worker.child.exitCode === null && worker.child.signalCode === null) worker.child.kill()
|
||||
}),
|
||||
)
|
||||
const code = yield* Effect.promise(() => worker.exited)
|
||||
expect({ code, output: worker.output.join("") }).toEqual({ code: 0, output: "" })
|
||||
}),
|
||||
35_000,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const KINDS: ReadonlyArray<SessionRequestKind> = ["primary", "compaction", "title", "generate"]
|
||||
|
||||
const session = Session.Info.make({
|
||||
id: Session.ID.make("ses_hook_kind"),
|
||||
projectID: Project.ID.global,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
})
|
||||
const model = SessionRunnerModel.resolved(OpenAIChat.route.model({ id: "gpt-5.5", provider: "test" }), {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
})
|
||||
const transport = SessionModelTransport.Service.of({
|
||||
bind: () => ({ execute: () => Effect.die("unused WebSocket execution") }),
|
||||
close: () => Effect.void,
|
||||
closeAll: Effect.void,
|
||||
})
|
||||
|
||||
describe("SessionModelRequest HTTP hooks", () => {
|
||||
it.effect("tags every Session request kind on http.request and http.response", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const seen: Array<{ hook: string; kind: SessionRequestKind; agent: Agent.ID }> = []
|
||||
yield* hooks.register("session", "http.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push({ hook: "request", kind: event.kind, agent: event.agent })
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "http.response", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push({ hook: "response", kind: event.kind, agent: event.agent })
|
||||
}),
|
||||
)
|
||||
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
|
||||
|
||||
for (const kind of KINDS) {
|
||||
const prepared = yield* requests.prepare({
|
||||
kind,
|
||||
scope: { session, agentID: Agent.ID.make("build"), model },
|
||||
transcript: { system: [], messages: [] },
|
||||
})
|
||||
const http = prepared.options.http
|
||||
if (!http) throw new Error(`Expected HTTP middleware for ${kind}`)
|
||||
yield* http(HttpClientRequest.post("https://example.test/v1/chat/completions"), (request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 200 }))),
|
||||
)
|
||||
}
|
||||
|
||||
expect(seen).toEqual(
|
||||
KINDS.flatMap((kind) => [
|
||||
{ hook: "request", kind, agent: Agent.ID.make("build") },
|
||||
{ hook: "response", kind, agent: Agent.ID.make("build") },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AIError,
|
||||
HttpContext,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
@@ -2123,19 +2124,19 @@ describe("SessionRunnerLLM", () => {
|
||||
sessionID,
|
||||
model: { id: ID.make(s.currentModel.id), providerID: Provider.ID.make(s.currentModel.provider), variant },
|
||||
})
|
||||
const requestAgents: Agent.ID[] = []
|
||||
const hookRequests: Array<{ agent: Agent.ID; kind: string }> = []
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.agent).toBe(agentID)
|
||||
expect(event.model.variant).toBe(variant)
|
||||
event.system.push(SystemPart.make("Hook-provided instructions"))
|
||||
event.tools.echo.description = "Hook-provided tool description"
|
||||
event.generation.maxTokens = 4_000
|
||||
event.options.maxTokens = 4_000
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
requestAgents.push(event.agent)
|
||||
hookRequests.push({ agent: event.agent, kind: event.kind })
|
||||
}),
|
||||
)
|
||||
yield* s.llm.push(
|
||||
@@ -2181,7 +2182,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(compact[field]).toEqual(normal[field])
|
||||
expect(compact.toolChoice).toBeUndefined()
|
||||
expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.")
|
||||
expect(requestAgents[2]).toBe(Agent.ID.make("compaction"))
|
||||
expect(hookRequests[2]).toEqual({ agent: agentID, kind: "compaction" })
|
||||
expect(s.executions).toEqual(["x".repeat(4_000)])
|
||||
expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({
|
||||
model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant },
|
||||
@@ -2260,21 +2261,151 @@ describe("SessionRunnerLLM", () => {
|
||||
}
|
||||
}
|
||||
|
||||
scenario("preserves typed provider failures from manual compaction", function* (s) {
|
||||
scenario("restarts compaction drafts after transient failures and unsuccessful finishes", function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-failure-history"))
|
||||
yield* s.runPrompt("Earlier question")
|
||||
|
||||
yield* s.llm.push(Stream.fail(providerUnavailable()))
|
||||
s.requests.length = 0
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const retries: PluginHooks.Domains["session"]["retry"][] = []
|
||||
yield* hooks.register("session", "retry", (event) =>
|
||||
Effect.sync(() => {
|
||||
retries.push({ ...event })
|
||||
event.decision = { retry: true, delay: 0 }
|
||||
}),
|
||||
)
|
||||
const draft = TestLLM.complete(
|
||||
{
|
||||
reason: { normalized: "unknown" },
|
||||
usage: { nonCachedInputTokens: 10 },
|
||||
providerMetadata: { openai: { responseId: "discarded-draft" } },
|
||||
},
|
||||
LLMEvent.textDelta({ id: "draft", text: "## Objective\n- Partial draft" }),
|
||||
)
|
||||
yield* s.llm.push(
|
||||
TestLLM.failAfter(streamDisconnected(), ...draft.slice(0, -1)),
|
||||
draft,
|
||||
TestLLM.complete(
|
||||
{ reason: { normalized: "error" } },
|
||||
LLMEvent.textDelta({ id: "failed", text: "## Objective\n- Failed draft" }),
|
||||
),
|
||||
Stream.fail(rateLimited(60_000)),
|
||||
TestLLM.textWithUsage("## Objective\n- Accepted summary", "accepted", 30),
|
||||
)
|
||||
const compaction = yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests).toHaveLength(5)
|
||||
for (const request of s.requests) expect(request).toEqual(s.requests[0])
|
||||
expect(retries.map((event) => event.attempt)).toEqual([2, 3, 4, 5])
|
||||
expect(retries.every((event) => event.sessionID === sessionID && event.kind === "compaction")).toBe(true)
|
||||
expect(retries[3].decision).toEqual({ retry: true, delay: 60_000 })
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
status: "completed",
|
||||
summary: "## Objective\n- Accepted summary",
|
||||
})
|
||||
expect(JSON.stringify(yield* s.messages)).not.toContain("discarded-draft")
|
||||
expect((yield* s.session.get(sessionID))?.tokens.input).toBe(50)
|
||||
})
|
||||
|
||||
scenario("bounds compaction network retries across a template correction", function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Earlier answer", "history"))
|
||||
yield* s.runPrompt("Earlier question")
|
||||
s.requests.length = 0
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const attempts: number[] = []
|
||||
yield* hooks.register("session", "retry", (event) =>
|
||||
Effect.sync(() => {
|
||||
attempts.push(event.attempt)
|
||||
expect(event.decision).toMatchObject({ retry: true })
|
||||
event.decision = { retry: true, delay: 0 }
|
||||
}),
|
||||
)
|
||||
yield* s.llm.push(Stream.fail(providerUnavailable()), TestLLM.text("Not a summary", "invalid"))
|
||||
yield* s.llm.always(Stream.fail(providerUnavailable()))
|
||||
const compaction = yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
|
||||
expect(attempts).toEqual([2, 3, 4, 5])
|
||||
expect(s.requests).toHaveLength(6)
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||
})
|
||||
expect((yield* s.context).some((message) => message.type === "user" && message.text === "Earlier question")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
for (const header of [false, true]) {
|
||||
scenario(`stops compaction retries through the ${header ? "provider header" : "retry hook"}`, function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Earlier answer", "history"))
|
||||
yield* s.runPrompt("Earlier question")
|
||||
s.requests.length = 0
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "retry", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.decision.retry).toBe(!header)
|
||||
event.decision = { retry: false }
|
||||
}),
|
||||
)
|
||||
yield* s.llm.push(
|
||||
Stream.fail(
|
||||
header
|
||||
? new AIError({
|
||||
reason: new TransportError({
|
||||
message: "Connection closed",
|
||||
transport: "http",
|
||||
operation: "read",
|
||||
http: new HttpContext({
|
||||
url: "https://example.com",
|
||||
status: 200,
|
||||
headers: { "x-should-retry": "false" },
|
||||
}),
|
||||
}),
|
||||
})
|
||||
: incompleteStream(),
|
||||
),
|
||||
)
|
||||
const compaction = yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
status: "failed",
|
||||
error: { type: header ? "provider.transport" : "provider.invalid-output" },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const response of ["length", "content-filter", "context overflow"] as const) {
|
||||
scenario(`rejects compaction ${response} without retrying or committing its draft`, function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Earlier answer", "history"))
|
||||
yield* s.runPrompt("Earlier question")
|
||||
s.requests.length = 0
|
||||
yield* s.llm.push(
|
||||
response === "context overflow"
|
||||
? Stream.fail(
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({ message: "Too long", classification: "context-overflow" }),
|
||||
}),
|
||||
)
|
||||
: TestLLM.complete(
|
||||
{ reason: { normalized: response } },
|
||||
LLMEvent.textDelta({ id: "truncated", text: "## Objective\n- Incomplete summary" }),
|
||||
),
|
||||
)
|
||||
const compaction = yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "failed" })
|
||||
yield* s.llm.push(TestLLM.text("Continued", "continued"))
|
||||
yield* s.runPrompt("Continue")
|
||||
expect(userTexts(s.requests[1])).toContain("Earlier question")
|
||||
expect(JSON.stringify(s.requests[1])).not.toContain("Incomplete summary")
|
||||
})
|
||||
}
|
||||
|
||||
scenario("records cancelled manual compaction without surfacing an internal failure", function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-interrupt-history"))
|
||||
yield* s.runPrompt("Earlier question")
|
||||
@@ -3472,6 +3603,83 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(userTexts(s.requests[1])).toEqual(["Start working", "Recover with this"])
|
||||
})
|
||||
|
||||
scenario("settles abandoned compactions before continuing after a process crash", function* (s) {
|
||||
yield* s.runPrompt("History before the crash")
|
||||
const first = SessionMessage.ID.create()
|
||||
const completed = SessionMessage.ID.create()
|
||||
const last = SessionMessage.ID.create()
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
inputID: first,
|
||||
recent: "",
|
||||
})
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
inputID: completed,
|
||||
recent: "",
|
||||
})
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: "## Objective\n- Earlier completed checkpoint",
|
||||
recent: "",
|
||||
})
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
inputID: last,
|
||||
recent: "",
|
||||
})
|
||||
|
||||
// These starts have no terminal events, as after SIGKILL. The older orphan
|
||||
// is outside model-visible history; recovery must settle it as well.
|
||||
expect(
|
||||
(yield* s.messages).filter((message) => message.type === "compaction" && message.status === "running"),
|
||||
).toHaveLength(2)
|
||||
yield* s.llm.push(TestLLM.text("Recovered response", "recovered"))
|
||||
const run = yield* s.resumePaused
|
||||
expect((yield* s.messages).filter((message) => message.type === "compaction").toReversed()).toMatchObject([
|
||||
{ id: first, status: "failed", reason: "manual", error: { type: "compaction.interrupted" } },
|
||||
{ id: completed, status: "completed", summary: "## Objective\n- Earlier completed checkpoint" },
|
||||
{ id: last, status: "failed", reason: "auto", error: { type: "compaction.interrupted" } },
|
||||
])
|
||||
yield* run.finish
|
||||
|
||||
yield* s.llm.push(TestLLM.text("## Objective\n- New checkpoint", "new-summary"))
|
||||
const next = yield* s.session.compact({ sessionID })
|
||||
yield* s.session.wait(sessionID)
|
||||
expect((yield* s.messages).find((message) => message.id === next.id)).toMatchObject({
|
||||
status: "completed",
|
||||
summary: "## Objective\n- New checkpoint",
|
||||
})
|
||||
expect(
|
||||
(yield* s.messages).filter((message) => message.type === "compaction" && message.status === "running"),
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
scenario("settles an abandoned compaction before delivering another manual compaction", function* (s) {
|
||||
yield* s.runPrompt("History before the crash")
|
||||
const previous = SessionMessage.ID.create()
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
inputID: previous,
|
||||
recent: "",
|
||||
})
|
||||
yield* s.llm.push(TestLLM.text("## Objective\n- New checkpoint", "new-summary"))
|
||||
const gate = yield* s.llm.gate
|
||||
const next = yield* s.session.compact({ sessionID })
|
||||
yield* gate.started
|
||||
expect((yield* s.messages).filter((message) => message.type === "compaction").toReversed()).toMatchObject([
|
||||
{ id: previous, status: "failed", error: { type: "compaction.interrupted" } },
|
||||
{ id: next.id, status: "running" },
|
||||
])
|
||||
yield* gate.release
|
||||
yield* s.session.wait(sessionID)
|
||||
})
|
||||
|
||||
scenario("durably fails local tools left running by a prior process before continuing", function* (s) {
|
||||
yield* s.admit("Recover interrupted tool")
|
||||
yield* SessionInbox.promote(s.db, s.bus, sessionID, "steer")
|
||||
|
||||
@@ -105,7 +105,7 @@ for (const fixture of [
|
||||
agent: Agent.defaultID,
|
||||
model,
|
||||
prepared: {
|
||||
retry: () => Effect.void,
|
||||
retry: (event) => Effect.succeed({ ...event, kind: "primary" as const }),
|
||||
request: LLM.request({ model: model.model, prompt: "Run one tool", toolChoice: fixture.toolChoice }),
|
||||
options: {},
|
||||
executeTool: () =>
|
||||
|
||||
@@ -232,6 +232,26 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not run context hooks for title requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_context_hook")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Help me debug the failing build")
|
||||
|
||||
const hooks = yield* PluginHooks.Service
|
||||
let calls = 0
|
||||
yield* hooks.register("session", "context", () => Effect.sync(() => calls++))
|
||||
yield* hooks.register("session", "model.request", (event) => Effect.sync(() => expect(event.kind).toBe("title")))
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(calls).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses a small model from the primary provider", () =>
|
||||
Effect.gen(function* () {
|
||||
selectedSmall = small
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from "electron-vite"
|
||||
import { pickerPlugin } from "./scripts/picker"
|
||||
|
||||
const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
@@ -10,7 +11,6 @@ const channel = (() => {
|
||||
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
|
||||
|
||||
const appPlugin = (await import("@opencode-ai/app/vite")).default
|
||||
const picker = (await import("@brendonovich/vite-plugin-opencode")).default()
|
||||
const sentry =
|
||||
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
|
||||
? (await import("@sentry/vite-plugin")).sentryVitePlugin({
|
||||
@@ -91,7 +91,7 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
|
||||
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
|
||||
},
|
||||
plugins: [picker, appPlugin, sentry],
|
||||
plugins: [pickerPlugin(), appPlugin, sentry],
|
||||
publicDir: "../../../app/public",
|
||||
root: "src/renderer",
|
||||
build: {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Picker fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<button>Pick this element</button>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
document.body.dataset.ready = "true"
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { loadConfigFromFile, RendererConfigFactory } from "electron-vite"
|
||||
import { createServer } from "vite"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { pickerPlugin } from "./picker"
|
||||
|
||||
test("injects a browser-loadable URL instead of a bare virtual module", () => {
|
||||
const plugin = pickerPlugin()
|
||||
const tag = plugin.transformIndexHtml.handler()[0]
|
||||
expect(tag.attrs.src).toBe("/__vite_opencode_picker_client.js")
|
||||
const id = plugin.resolveId(tag.attrs.src)
|
||||
expect(id).toBeDefined()
|
||||
expect(plugin.load(id!)).toContain("opencodePickerUi")
|
||||
})
|
||||
|
||||
test.each([true, false])(
|
||||
"serves browser-loadable picker scripts with bundled dev = %s",
|
||||
async (bundledDev) => {
|
||||
const loaded = await loadConfigFromFile(
|
||||
{ command: "serve", mode: "development" },
|
||||
fileURLToPath(new URL("../electron.vite.config.ts", import.meta.url)),
|
||||
)
|
||||
if (!loaded.config.renderer) throw new Error("Missing renderer configuration")
|
||||
const config = await new RendererConfigFactory(
|
||||
loaded.config.renderer,
|
||||
{ configFile: false, mode: "development" },
|
||||
{ root: fileURLToPath(new URL("..", import.meta.url)) },
|
||||
).build()
|
||||
const server = await createServer({
|
||||
...config,
|
||||
configFile: false,
|
||||
root: fileURLToPath(new URL("./fixtures/picker", import.meta.url)),
|
||||
build: {
|
||||
...config.build,
|
||||
rolldownOptions: { input: { main: fileURLToPath(new URL("./fixtures/picker/index.html", import.meta.url)) } },
|
||||
},
|
||||
experimental: { bundledDev },
|
||||
server: { host: "127.0.0.1", port: 0 },
|
||||
logLevel: "silent",
|
||||
})
|
||||
await server.listen()
|
||||
const url = server.resolvedUrls?.local[0]
|
||||
if (!url) throw new Error("Missing fixture server URL")
|
||||
const socket = bundledDev
|
||||
? new WebSocket(`${url.replace("http:", "ws:")}?token=${server.config.webSocketToken}`, "vite-hmr")
|
||||
: undefined
|
||||
try {
|
||||
if (socket && !server.environments.client.bundledDev?.hasBuildOutput) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.addEventListener("error", reject, { once: true })
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message: { type: string } = JSON.parse(String(event.data))
|
||||
if (message.type === "full-reload") resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
for (const path of ["/", "/index.html", "/server/example/session/example", "/new-session?draftId=example"]) {
|
||||
const html = await fetch(new URL(path, url)).then((response) => response.text())
|
||||
const sources = [...html.matchAll(/<script[^>]*\bsrc="([^"]+)"/g)].map((match) => match[1])
|
||||
const scripts = await Promise.all(
|
||||
sources.map((source) => fetch(new URL(source, url)).then((response) => response.text())),
|
||||
)
|
||||
expect(scripts.length).toBeGreaterThan(0)
|
||||
if (bundledDev) expect(scripts.join("\n")).toContain("opencodePickerUi")
|
||||
expect([html, ...scripts].join("\n")).not.toMatch(/\bimport(?:\s*\(\s*|\s*)["']virtual:/)
|
||||
}
|
||||
const direct = await fetch(new URL("/__vite_opencode_picker_client.js", url))
|
||||
expect(direct.ok).toBe(true)
|
||||
expect(await direct.text()).toContain("opencodePickerUi")
|
||||
} finally {
|
||||
socket?.close()
|
||||
await server.close()
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user