Compare commits

...
Author SHA1 Message Date
David Hill 59207d0daa fix(app): open advanced for custom timeline settings 2026-09-04 00:02:12 -06:00
David Hill 0d7eaa0742 fix(app): simplify custom timeline description 2026-09-04 00:00:00 -06:00
David Hill 55be3a6531 feat(app): refine timeline detail settings 2026-09-03 23:49:37 -06:00
Aiden Cline ffac1c5b11 fix(core): classify GitHub Copilot requests on every route (#47160) 2026-09-03 22:13:04 -05:00
opencode-agent[bot]andrekram1-node c5dca2df37 feat(core): suggest moving sessions into task worktrees (#47202)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 22:06:15 -05:00
Aiden Cline 8889447f5a fix(core): scope compaction Completed section to finished work (#47203) 2026-09-03 22:05:25 -05:00
opencode-agent[bot]andHona a1cb005799 fix(app): remove timeline settings advanced divider (#47196)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-04 12:25:33 +10:00
Kit Langton 19833ad1fd feat(simulation): expose and record real mouse input (#47194) 2026-09-04 02:17:33 +00:00
Kit Langton cb852434b1 fix(tui): preserve selection under a stationary pointer (#47138) 2026-09-04 02:04:29 +00:00
opencode-agent[bot]andiamdavidhill b3733e9517 fix(app): add bottom padding to settings pages (#47191)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-04 01:58:55 +00:00
opencode-agent[bot]andHona 331f4ecd2f fix(app): contain preferences scroll overflow (#47190)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-04 01:54:52 +00:00
Luke Parker ecaa914b79 fix(app): align Windows app icon in title bar (#47189) 2026-09-04 01:54:22 +00:00
Aiden Cline 4f8dea674a refactor(ai): drop converse tool deltas without an open block (#47182) 2026-09-03 20:50:35 -05:00
Aiden Cline 4bf5269c4c fix(core): retry transient compaction failures (#47159) 2026-09-03 20:45:47 -05:00
Kit Langton c9d240704d fix(core): settle abandoned compactions before resuming sessions (#47178) 2026-09-04 01:02:29 +00:00
Kit Langton c9df4ba80d fix(util): skip unused audits during package installs (#47176) 2026-09-04 00:58:29 +00:00
David HillandLukeParkerDev ac71a55294 fix(app): keep right panel controls aligned (#46996)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 00:54:39 +00:00
Aiden Cline f84d927e07 refactor(ai): drop reasoning items when they finish (#47100) 2026-09-03 19:53:22 -05:00
d431fedce5 test(core): disable npm audits in the test preload (#47170)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 19:52:18 -05:00
Kit Langton e536b9627e feat(tui): inspect live shell output (#47134) 2026-09-04 00:47:58 +00:00
David HillandLukeParkerDev 6af46cc8a9 feat(app): add settings to vertical tabs (#47119)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 10:45:09 +10:00
David HillandLukeParkerDev c76e602ba4 fix(app): keep session tab labels stable during creation (#47099)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 10:33:51 +10:00
opencode-agent[bot]andrekram1-node 726107729e test(core): align Code Mode catalog scope assertions (#47169)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 19:30:25 -05:00
David Hill 0808ebc3c5 fix(app): refine build indicators in tab navigation (#47112) 2026-09-04 09:57:32 +10:00
David HillandLukeParkerDev 17e5c5fbf1 feat(app): show vertical sidebar shortcut hints (#47122)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 09:42:29 +10:00
David Hill b0a13b810e fix(app): allow new session shortcut from settings (#47123) 2026-09-04 09:42:14 +10:00
Luke Parker 6575215ddf fix(desktop): keep command palette responsive and scoped (#47164) 2026-09-04 09:41:12 +10:00
David HillandLukeParkerDev 09157609b7 feat(app): add session project menu and polish header (#46977)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 09:25:31 +10:00
Luke Parker 7ba5f3e5b2 fix(core): restore Ctrl+C in Windows terminals (#47163) 2026-09-04 09:16:14 +10:00
David HillandLukeParkerDev 0ec1587948 feat(app): add about settings page (#45782)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-03 23:07:41 +00:00
David Hill 2fda2c8141 feat(app): give experimental settings a dedicated page (#46973) 2026-09-04 08:33:29 +10:00
Luke Parker f06bf47e72 fix(app): preserve slash commands when creating worktrees (#47155) 2026-09-03 22:25:42 +00:00
opencode-agent[bot]andHona fa6e7e179d fix(desktop): render before telemetry (#47102)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-04 08:21:16 +10:00
Dax Raad 4272bb83e0 feat(cli): support command aliases with update alias for upgrade 2026-09-03 18:10:01 -04:00
usrnk1andiamdavidhill 1ae2cf3567 feat(desktop): polish menus, file tabs, and empty states (#47095)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-04 07:55:27 +10:00
Dax Raad 2084c52952 fix(core): clarify code mode tool scope 2026-09-03 17:21:06 -04:00
Aiden Cline 5a9931280e feat(core): add Modal live model backfill plugin (#47136) 2026-09-03 16:03:21 -05:00
Aiden Cline b2fb2c5e36 chore(core): bump GitHub Copilot API version to 2026-08-01 (#47108) 2026-09-03 15:38:32 -05:00
James Long 43bd2a516b refactor(cli): move update checks to TUI clients (#46963) 2026-09-03 14:55:26 -04:00
Kit Langton dad7688739 refactor(core): reuse job scope as generation identity (#47121) 2026-09-03 14:47:44 -04:00
Kit Langton 961b8ccb86 refactor(core): remove unobservable websocket states (#47117) 2026-09-03 14:24:45 -04:00
Kit Langton 97303c39dd fix(core): expand portable shell scanner compatibility (#47110) 2026-09-03 14:11:58 -04:00
Kit Langton 282c84d79f refactor(core): remove redundant step closeout flags (#46937) 2026-09-03 13:35:42 -04:00
Kit Langton 2dcfc89fab feat(tui): announce server plugin failures (#47103)
Show grouped server plugin failure notices with an Open plugins action, retain the home failure count, and reveal failed built-ins in the plugin dialog. Keep unchanged failures quiet across inventory refreshes and reconnects.
2026-09-03 13:17:10 -04:00
Kit Langton a222401f19 fix(core): normalize RPC handler failures (#46946) 2026-09-03 13:15:24 -04:00
Kit Langton 36da0d5c77 fix(core): retry failed location initialization (#46957) 2026-09-03 13:10:59 -04:00
Shoubhit Dash 7819e7f503 fix(core): run command subagents in the background (#47081) 2026-09-03 22:37:58 +05:30
Aiden Cline 6e63b970f3 feat(core): persist compaction model and provider state (#46962) 2026-09-03 12:06:50 -05:00
Kit Langton 610d7e952a refactor(client): remove redundant location spreads (#47086) 2026-09-03 12:51:12 -04:00
Kit Langton ac874a6e90 fix(core): recover idle moves through the selected instance (#46955) 2026-09-03 16:45:16 +00:00
Kit Langton f40ecefdef feat(core): disable plugins after transform failures (#47083)
Disable failed registration groups, rebuild healthy state, and report plugin failures with safe diagnostic references. Keep cleanup outside activation locks and preserve disabled revisions across unrelated reloads. Cover deferred hook and RPC cleanup with real-service regression tests.
2026-09-03 16:38:11 +00:00
Kit Langton c370a1bdd0 refactor(app): remove redundant project setter wrappers (#47085) 2026-09-03 16:33:41 +00:00
Kit Langton 309f4534fa test(app): align timeline assertions with activity controls (#47089) 2026-09-03 12:20:08 -04:00
Kit Langton 0ae3bf743f fix(tui): show execution failures in the viewed session (#46968) 2026-09-03 12:00:48 -04:00
Kit Langton f94eefaa50 fix(tui): preserve markdown blocks on plugin toggles (#47084) 2026-09-03 11:37:41 -04:00
Kit Langton a04d72bb39 fix(sdk): bind embedded transport at request time (#46971) 2026-09-03 11:16:22 -04:00
218 changed files with 9553 additions and 1810 deletions
+6 -14
View File
@@ -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,
+11 -53
View File
@@ -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
+12 -6
View File
@@ -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`) })
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,264 @@
import { expect, story } from "../../storybook/playwright/story"
story("maps grouped and collapsed switches to the timeline settings", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shell = component.getByRole("group", { name: "Shell", exact: true })
const grouped = shell.getByRole("switch", { name: "Shell grouped", exact: true })
const collapsed = shell.getByRole("switch", { name: "Shell collapsed", exact: true })
const value = component.locator('[data-slot="timeline-detail-fixture-value"]')
await expect(component.getByRole("switch")).toHaveCount(9)
await expect(component.getByText("Activity", { exact: true })).toHaveCount(0)
await expect(grouped).toBeChecked()
await expect(collapsed).toBeChecked()
await shell.locator('[data-field="placement"] [data-slot="switch-control"]').click()
await shell.locator('[data-field="details"] [data-slot="switch-control"]').click()
await expect(value).toContainText('"shell":{"placement":"separate","details":"expanded"}')
await grouped.focus()
await grouped.press("Space")
await collapsed.focus()
await collapsed.press("Space")
await expect(value).toContainText('"shell":{"placement":"grouped","details":"collapsed"}')
await expect(component.getByRole("group", { name: "Subagents", exact: true }).getByRole("switch")).toHaveCount(1)
})
story("replaces hidden switches with solid lines and restores options", async ({ mount, page }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shell = component.getByRole("group", { name: "Shell", exact: true })
const visibility = shell.getByRole("button", { name: "Shell visibility" })
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
const color = await label.evaluate((element) => getComputedStyle(element).color)
const iconColor = await visibility.evaluate((element) => getComputedStyle(element).color)
await shell.locator('[data-field="placement"] [data-slot="switch-control"]').click()
await shell.locator('[data-field="details"] [data-slot="switch-control"]').click()
await visibility.hover()
await expect(page.getByRole("tooltip")).toHaveText("Hide")
expect(await page.getByRole("tooltip").evaluate((element) => element.getBoundingClientRect().bottom)).toBeLessThan(
await visibility.evaluate((element) => element.getBoundingClientRect().top),
)
await visibility.click()
await expect(visibility).toHaveAttribute("aria-pressed", "false")
await expect(shell.getByRole("switch")).toHaveCount(0)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(2)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]').first()).toHaveCSS(
"border-top-style",
"solid",
)
await expect(component.locator('[data-slot="timeline-detail-fixture-value"]')).toContainText(
'"shell":{"placement":"hidden","details":"expanded"}',
)
await expect(label).not.toHaveCSS("color", color)
await expect(shell.locator('[data-slot="timeline-detail-eye-slash"]')).toHaveCount(1)
await component.getByRole("button", { name: "Advanced", exact: true }).hover()
await expect(visibility).not.toHaveCSS("color", iconColor)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]').first()).toHaveCSS(
"border-top-color",
await visibility.evaluate((element) => getComputedStyle(element).color),
)
await visibility.hover()
await expect(page.getByRole("tooltip")).toHaveText("Show")
expect(await page.getByRole("tooltip").evaluate((element) => element.getBoundingClientRect().bottom)).toBeLessThan(
await visibility.evaluate((element) => element.getBoundingClientRect().top),
)
await visibility.click()
await expect(visibility).toHaveAttribute("aria-pressed", "true")
await expect(label).toHaveCSS("color", color)
await expect(shell.locator('[data-slot="timeline-detail-eye-slash"]')).toHaveCount(0)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(0)
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).toBeEnabled()
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).toBeEnabled()
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).not.toBeChecked()
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).not.toBeChecked()
})
story("toggles visibility by clicking the activity label", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shell = component.getByRole("group", { name: "Shell", exact: true })
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
const visibility = shell.getByRole("button", { name: "Shell visibility" })
await expect(label).toHaveCSS("cursor", "default")
await label.click()
await expect(visibility).toHaveAttribute("aria-pressed", "false")
await expect(shell.getByRole("switch")).toHaveCount(0)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(2)
await label.click()
await expect(visibility).toHaveAttribute("aria-pressed", "true")
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).toBeChecked()
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).toBeChecked()
})
story("only highlights the eye when hovering the icon, not its activity label", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shell = component.getByRole("group", { name: "Shell", exact: true })
const visibility = shell.getByRole("button", { name: "Shell visibility" })
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
for (const hidden of [false, true]) {
if (hidden) await label.click()
await visibility.hover()
await expect(visibility).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
await label.hover()
await expect(visibility).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
}
})
story("opens advanced on returning to custom settings but not presets", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
const advanced = component.getByRole("button", { name: "Advanced", exact: true })
await expect(advanced).toHaveAttribute("aria-expanded", "false")
await advanced.click()
await component.locator('[data-category="shell"][data-field="placement"] [data-slot="switch-control"]').click()
await expect(component.getByRole("slider")).toHaveAttribute("aria-valuetext", "Custom")
await advanced.click()
await expect(advanced).toHaveAttribute("aria-expanded", "false")
await component.getByRole("button", { name: "Leave settings" }).click()
await expect(advanced).toHaveCount(0)
await component.getByRole("button", { name: "Return to settings" }).click()
await expect(advanced).toHaveAttribute("aria-expanded", "true")
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).not.toBeChecked()
await component.getByRole("slider").press("End")
await expect(component.getByRole("slider")).toHaveAttribute("aria-valuetext", "Everything")
await component.getByRole("button", { name: "Leave settings" }).click()
await component.getByRole("button", { name: "Return to settings" }).click()
await expect(advanced).toHaveAttribute("aria-expanded", "false")
})
story("keeps visibility and switches in sync with the preset slider", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const slider = component.getByRole("slider", { name: "Timeline detail" })
await slider.focus()
await slider.press("Home")
await expect(slider).toHaveAttribute("aria-valuetext", "Messages only")
await expect(component.getByRole("switch")).toHaveCount(0)
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(9)
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="false"]')).toHaveCount(6)
await component.getByRole("button", { name: "Shell visibility" }).click()
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeChecked()
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
await slider.focus()
await slider.press("End")
await expect(slider).toHaveAttribute("aria-valuetext", "Everything")
await expect(component.getByRole("switch")).toHaveCount(9)
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(0)
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="true"]')).toHaveCount(6)
await expect(component.getByRole("switch", { checked: true })).toHaveCount(0)
})
for (const direction of ["ltr", "rtl"]) {
for (const theme of ["light", "dark"]) {
story(`fits narrow and wide layouts in ${direction}, ${theme}`, async ({ mount, page }, testInfo) => {
await page.setViewportSize({ width: 900, height: 900 })
const component = await mount("settings-timeline-detail--interactive", { globals: { direction, theme } })
await expect(component.locator('[data-slot="timeline-detail-summary"]')).toHaveCSS(
"color",
await component
.locator('[data-slot="settings-row-title"]')
.evaluate((element) => getComputedStyle(element).color),
)
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const track = component.locator('[data-slot="timeline-detail-track"]')
await expect(track).toHaveCSS(
"--timeline-detail-track-background",
await track.evaluate(
(element, theme) =>
getComputedStyle(element)
.getPropertyValue(theme === "light" ? "--v2-background-bg-layer-04" : "--v2-background-bg-layer-03")
.trim(),
theme,
),
)
if (theme === "light") {
await expect(track.locator("span").first()).toHaveCSS("background-image", "none")
await expect(track).toHaveCSS(
"--timeline-detail-marker-background",
await track.evaluate((element) => getComputedStyle(element).getPropertyValue("--v2-grey-500").trim()),
)
}
if (theme === "dark") {
await expect(track.locator("span").first()).not.toHaveCSS("background-image", "none")
}
await page.screenshot({ path: testInfo.outputPath(`timeline-${theme}-${direction}.png`) })
const list = component.locator('[data-slot="timeline-detail-list"]')
await expect(component.locator('[data-slot="timeline-detail-categories"]')).toHaveCSS("margin-top", "0px")
for (const [column, field] of [
[2, "placement"],
[3, "details"],
] as const) {
const heading = await component
.locator(`[data-slot="timeline-detail-columns"] > :nth-child(${column})`)
.evaluate((element) => {
const rect = element.getBoundingClientRect()
return rect.x + rect.width / 2
})
const toggle = await component
.locator(`[data-category="shell"][data-field="${field}"] [data-slot="switch-control"]`)
.evaluate((element) => {
const rect = element.getBoundingClientRect()
return rect.x + rect.width / 2
})
expect(Math.abs(heading - toggle)).toBeLessThan(1)
}
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "12px")
for (const width of [900, 320]) {
await page.setViewportSize({ width, height: 900 })
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeVisible()
expect(await list.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
const visibility = component.getByRole("button", { name: "Shell visibility" })
await visibility.focus()
await visibility.press("Space")
await expect(visibility).toHaveAttribute("aria-pressed", "false")
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toHaveCount(0)
await expect(
component
.getByRole("group", { name: "Shell", exact: true })
.locator('[data-slot="timeline-detail-unavailable"]'),
).toHaveCount(2)
await visibility.press("Space")
await expect(visibility).toHaveAttribute("aria-pressed", "true")
const slider = component.getByRole("slider", { name: "Timeline detail" })
const track = component.locator('[data-slot="timeline-detail-track"]')
expect(await track.evaluate((element) => element.getBoundingClientRect().width)).toBe(
await component
.locator('[data-slot="timeline-detail-scale"]')
.evaluate((element) => element.getBoundingClientRect().width),
)
await slider.focus()
await slider.press("Home")
for (const position of [0, 1, 2, 3, 4]) {
if (position > 0) await slider.press("ArrowUp")
await expect(track).toHaveCSS("--timeline-detail-progress", `${position * 25}%`)
const fill = await track.evaluate((element) => {
const style = getComputedStyle(element, "::before")
return {
fraction: parseFloat(style.width) / element.getBoundingClientRect().width,
start: style.getPropertyValue("inset-inline-start"),
color: style.backgroundColor,
remainder: getComputedStyle(element).backgroundColor,
}
})
expect(fill.fraction).toBeCloseTo(position / 4, 2)
expect(fill.start).toBe("0px")
expect(fill.color).not.toBe(fill.remainder)
const marker = await track.locator("span").nth(position).boundingBox()
const bounds = await track.boundingBox()
expect(marker).not.toBeNull()
expect(bounds).not.toBeNull()
expect((marker!.x + marker!.width / 2 - bounds!.x) / bounds!.width).toBeCloseTo(
direction === "rtl" ? 1 - position / 4 : position / 4,
2,
)
}
}
})
}
}
@@ -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)
})
@@ -78,7 +78,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
const notices = page.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toContainText("Agent · explore")
await expect(notices.nth(0)).toHaveText(/^Agent changed\s*Explore$/)
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
@@ -182,20 +182,15 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await expect(card).not.toContainText("(background)")
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
const hint = page.locator('[data-component="session-background-hint"]')
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
const hint = page.getByRole("button", { name: /move running work to the background/i })
await expect(hint).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect
.poll(async () => {
const [cardBox, hintBox, prefixBox] = await Promise.all([
card.boundingBox(),
hint.boundingBox(),
hintPrefix.boundingBox(),
])
if (!cardBox || !hintBox || !prefixBox) return undefined
const [cardBox, hintBox] = await Promise.all([card.boundingBox(), hint.boundingBox()])
if (!cardBox || !hintBox) return undefined
return {
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
aligned: Math.abs(cardBox.x - hintBox.x) < 2,
ordered: cardBox.y < hintBox.y,
}
})
@@ -220,10 +215,10 @@ test("navigates from a running subagent card and hides background controls in th
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
await expect(page.getByRole("button", { name: /move running work to the background/i })).toHaveCount(0)
})
for (const name of ["shell", "subagent"] as const) {
@@ -267,7 +262,7 @@ for (const name of ["shell", "subagent"] as const) {
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
await expect(group).toBeVisible()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
@@ -286,9 +281,9 @@ test("shows a badge for active background work", async ({ page }) => {
})
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "1 item running in background" })
const summary = page.getByRole("button", { name: "1 background task running", exact: true })
await expect(summary).toContainText("1")
await expect(summary).toContainText("Running work in background")
await expect(summary).toContainText("1 background task running")
await summary.click()
await expect(
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
@@ -387,7 +382,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
},
})
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
const used = page
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
@@ -396,7 +391,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 items running in background" })
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
await expect(summary).toContainText("2")
await summary.click()
const list = page.locator('[data-component="session-background-list"]')
@@ -173,7 +173,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
"href",
"#opencode-v2-icon-circle-exclamation",
"#opencode-v2-icon-outline-hexagonal-warning",
)
await expect
.poll(() =>
@@ -134,7 +134,7 @@ for (const name of ["read", "shell", "subagent"] as const) {
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeInViewport()
if (name !== "read") {
const hint = page.locator('[data-component="session-background-hint"]')
const hint = page.getByRole("button", { name: /move running work to the background/i })
await expect(hint).toBeInViewport()
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
await page.screenshot({ path: testInfo.outputPath(`working-grouped-${name}.png`) })
@@ -27,6 +27,12 @@ test.beforeEach(async ({ page }) => {
})),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
@@ -50,6 +56,34 @@ test("settings has its own route and returns through app history", async ({ page
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeFocused()
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(1)
})
test("recording a new session shortcut stays in settings until recording finishes", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
const binding = settings.locator('[data-keybind-id="tab.new"]')
await binding.click()
await expect(binding).toHaveText("Press keys")
await page.keyboard.press("Control+t")
await expect(binding).toHaveText("Ctrl+T")
await expect(page).toHaveURL("/settings")
await expect(page.locator("[data-titlebar-tab]")).toHaveCount(0)
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
@@ -105,6 +139,79 @@ test("extensions opens without waiting for MCPs", async ({ page }) => {
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
})
test("about opens without waiting for contributors", async ({ page }) => {
const contributors = Promise.withResolvers<void>()
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
await page.route(url, async (route) => {
await contributors.promise
await route.fulfill({
json: [],
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "Link",
Link: `<${url}&page=1004>; rel="last"`,
},
})
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest(url)
await settings.getByRole("tab", { name: "About", exact: true }).click()
await requested
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(settings.getByText(/^Version /)).toBeVisible()
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
await expect(settings.getByText(/^v\d+\./)).toHaveCount(0)
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
await settings.getByRole("tab", { name: "About", exact: true }).click()
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
const website = settings.getByRole("link", { name: "www.opencode.ai", exact: true })
await website.focus()
contributors.resolve()
await expect(settings.getByRole("link", { name: "988 others", exact: true })).toBeVisible()
await expect(website).toBeFocused()
})
test("about is available in the mobile settings menu", async ({ page }) => {
await page.route("https://api.github.com/repos/anomalyco/opencode/contributors?*", (route) => route.abort("failed"))
await page.setViewportSize({ width: 390, height: 844 })
const settings = page.getByTestId("settings-screen")
await settings.getByRole("button", { name: "Preferences", exact: true }).click()
await page.getByRole("menuitemradio", { name: "About", exact: true }).click()
await expect(settings.getByRole("button", { name: "About", exact: true })).toBeVisible()
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
await settings.getByRole("button", { name: "About", exact: true }).click()
await expect(page.getByRole("menuitemradio", { name: "About", exact: true })).toBeChecked()
})
test("about keeps its fallback when the contributor request fails", async ({ page }) => {
const contributors = Promise.withResolvers<void>()
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
await page.route(url, async (route) => {
await contributors.promise
await route.abort("failed")
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest(url)
await settings.getByRole("tab", { name: "About", exact: true }).click()
await requested
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
const failed = page.waitForEvent("requestfailed", (request) => request.url() === url)
contributors.resolve()
await failed
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
})
test("workspace inventory uses the settings panel scroll area", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
@@ -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

+57
View File
@@ -0,0 +1,57 @@
import { expect, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "./mock-server"
import { APP_READY_TIMEOUT } from "./waits"
export const paletteSession = {
id: "ses_command_palette",
projectID: "proj_command_palette",
directory: "C:/OpenCode/CommandPalette",
title: "Palette fixture session",
time: { created: 1700000000000, updated: 1700000000000 },
}
export function captureConsoleWarnings(page: Page) {
const warnings: string[] = []
page.on("console", (message) => {
if (message.type() !== "warning" && message.type() !== "error") return
// This message comes from test isolation, not application code.
if (message.text() === "Service Worker registration blocked by Playwright") return
warnings.push(message.text())
})
return warnings
}
export async function openCommandPalette(page: Page, home = false) {
await mockOpenCodeServer(page, {
directory: paletteSession.directory,
project: {
id: paletteSession.projectID,
worktree: paletteSession.directory,
vcs: "git",
name: "command-palette",
time: paletteSession.time,
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [paletteSession],
pageMessages: () => ({ items: [] }),
findFiles: () => [],
})
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(home ? "/" : `/server/${base64Encode(server)}/session/${paletteSession.id}`)
if (home) {
await expect(
page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }),
).toBeEnabled({ timeout: APP_READY_TIMEOUT })
}
if (!home) {
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable({ timeout: APP_READY_TIMEOUT })
}
await page.keyboard.press("ControlOrMeta+Shift+P")
const dialog = page.getByRole("dialog")
const input = dialog.getByRole("textbox")
await expect(input).toBeFocused()
await expect(dialog.getByRole("option")).not.toHaveCount(0)
return { dialog, input }
}
+1 -1
View File
@@ -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}>
+1
View File
@@ -251,6 +251,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const submission = createComposerSubmit({
adapter,
mode,
commands: () => data.location.command.list({ directory: sdk().directory }),
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
+48 -2
View File
@@ -52,10 +52,12 @@ function submitInput(
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
@@ -420,7 +422,6 @@ describe("Composer submission", () => {
prompt: async () => undefined,
command: async (value) => sent.resolve(value),
})
target.data.location.command.list = () => [{ name: "review", description: "Review changes", template: "" }]
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
@@ -433,7 +434,7 @@ describe("Composer submission", () => {
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
const request = await sent.promise
expect(request.files).toMatchObject([{ name: "app.ts", mention: { text: "@src/app.ts" } }])
@@ -442,6 +443,51 @@ describe("Composer submission", () => {
expect(request.delivery).toBe("steer")
})
test("captures commands before creating a session in a new worktree", async () => {
const state = createMemoryComposerState({ prompt: "/review https://github.com/example/repo/pull/1" }).capture()
const catalog = [{ name: "review" }]
const sent = Promise.withResolvers<"prompt" | "command">()
const requests: Parameters<ComposerSession["api"]["command"]>[0][] = []
const target = session({
calls: [],
prompt: async () => sent.resolve("prompt"),
command: async (value) => {
requests.push(value)
sent.resolve("command")
},
})
target.directory = "C:/new-worktree"
target.data.location.command.list = () => undefined
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start() {
// The destination catalog has not loaded, and the source composer is leaving.
catalog.splice(0)
return { session: target, cleanupReady: Promise.resolve() }
},
}
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
expect(await sent.promise).toBe("command")
expect(requests).toEqual([
{
sessionID: target.id,
command: "review",
text: "https://github.com/example/repo/pull/1",
files: [],
agents: [],
skills: [],
delivery: "steer",
},
])
})
test("does not run an empty shell command from hidden attachments", async () => {
const state = createMemoryComposerState().capture()
state.set([
+5 -4
View File
@@ -27,6 +27,7 @@ type ComposerSubmission = {
type ComposerSubmitInput = {
adapter: ComposerAdapter
mode: Accessor<"normal" | "shell">
commands: Accessor<readonly { name: string }[] | undefined>
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
@@ -65,6 +66,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (submitting.has(input.adapter.state)) return
submitting.add(input.adapter.state)
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
try {
const started =
@@ -78,7 +81,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
input.resetHistory()
const restore = () => restoreSubmission(input, submission, value, comments)
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
if (value.mode === "normal" && !command) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
@@ -277,12 +279,11 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
await session.api.shell({ sessionID: session.id, id: Event.ID.create(), command: value.text })
}
function findCommand(session: ComposerSession, text: string) {
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
if (!text.startsWith("/")) return
const [name, ...arguments_] = text.split(" ")
const command = name.slice(1)
if (!session.data.location.command.list({ directory: session.directory })?.some((item) => item.name === command))
return
if (!commands?.some((item) => item.name === command)) return
return { command, arguments: arguments_.join(" ") }
}
@@ -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()}
+39 -7
View File
@@ -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 cant build great software, without you",
"settings.about.copyright": "© Anomaly Innovations, Inc.",
"settings.preferences.description": "Customize preferences and theme and default behavior",
"settings.appearance.description": "Customize theme and fonts",
"settings.appearance.section.experimental": "Experimental",
@@ -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",
@@ -957,24 +981,32 @@ export const dict = {
"settings.timeline.title": "Timeline",
"settings.timeline.detail": "Timeline detail",
"settings.timeline.description": "Choose how much activity appears in the timeline. Messages stay visible.",
"settings.timeline.description": "Choose how much detail appears in the session timeline.",
"settings.timeline.summary": "{{preset}}:",
"settings.timeline.preset.everything": "Everything",
"settings.timeline.preset.detailed": "Detailed",
"settings.timeline.preset.compact": "Compact",
"settings.timeline.preset.quiet": "Quiet",
"settings.timeline.preset.text-only": "Text only",
"settings.timeline.preset.text-only": "Messages only",
"settings.timeline.description.everything": "Show all activity separately. Expand shell output, edits, and thinking.",
"settings.timeline.description.detailed":
"Expand shell output and edits. Show subagents separately and group other activity in Used.",
"settings.timeline.description.compact": "Group all activity in Used with details collapsed.",
"settings.timeline.description.quiet": "Group edits and subagents in Used. Hide other activity.",
"settings.timeline.description.text-only": "Hide all activity. Show only messages.",
"settings.timeline.description.custom": "Use your selected placement and details for each activity category.",
"Expand shell output and edits. Show subagents separately and group other activity.",
"settings.timeline.description.compact": "Group all activity with details collapsed.",
"settings.timeline.description.quiet": "Group edits and subagents. Hide other activity.",
"settings.timeline.description.text-only": "Hide all activity.",
"settings.timeline.description.custom": "Uses advanced settings.",
"settings.timeline.custom": "Custom",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.advanced.explainer": "Grouped activity goes into Used. Details applies after opening the group.",
"settings.timeline.activity": "Activity",
"settings.timeline.group": "Group",
"settings.timeline.collapse": "Collapse",
"settings.timeline.visibility.show": "Show",
"settings.timeline.visibility.hide": "Hide",
"settings.timeline.visibility.label": "{{activity}} visibility",
"settings.timeline.grouped.label": "{{activity}} grouped",
"settings.timeline.collapsed.label": "{{activity}} collapsed",
"settings.timeline.category.shell": "Shell",
"settings.timeline.category.edit": "Edits",
"settings.timeline.category.thinking": "Thinking",
@@ -1,12 +1,60 @@
import { describe, expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query"
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { OpenCode } from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store"
import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerSync } from "@/runtime/server/sync"
type ProjectApi = ServerApi["project"]
type WorktreeApi = ServerApi["worktree"]
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
if (url.pathname === "/api/location")
return Response.json({
directory: "/repo",
project: { id: "project", directory: "/repo", canonical: "/repo" },
})
if (url.pathname === "/api/project")
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
throw new Error(`Unexpected request: ${url.pathname}`)
},
{ preconnect() {} },
),
})
const [store, setStore] = createStore<ServerSync["data"]>({
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
provider_auth: {},
config: {},
reload: undefined,
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
try {
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
setStore("project", (projects) => projects.map((project) => ({ ...project, name: "Renamed" })))
expect(store.project[0]?.name).toBe("Renamed")
setStore("project", [])
expect(store.project).toEqual([])
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
expect(store.config).toEqual({})
} finally {
queryClient.clear()
}
})
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const location = {} as ServerApi["location"]
@@ -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,
+3 -23
View File
@@ -79,25 +79,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
})
const queryClient = useQueryClient()
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
setGlobalStore("project", next)
}
const setBootStore = ((...input: unknown[]) => {
if (input[0] === "project" && Array.isArray(input[1])) {
setProjects(input[1] as Project[])
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const bootstrap = useQuery(() => ({
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverAPI: serverSDK.api,
scope: serverSDK.scope,
setGlobalStore: setBootStore,
setGlobalStore,
queryClient,
})
return Date.now()
@@ -105,14 +93,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
enabled: connected(),
}))
const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const paused = () => untrack(() => globalStore.reload) !== undefined
const queue = createRefreshQueue({
@@ -216,7 +196,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
}
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
setProjects((projects) =>
setGlobalStore("project", (projects) =>
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
)
}
@@ -275,7 +255,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
return {
data: globalStore,
set,
set: setGlobalStore,
child: children.child,
disableMcp: children.disableMcp,
// bootstrap,
@@ -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>
+1 -1
View File
@@ -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>
</>
)
}
+13 -2
View File
@@ -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>
+51 -40
View File
@@ -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()
+123
View File
@@ -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,
},
}
}
+92 -11
View File
@@ -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,
+11 -13
View File
@@ -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>
)
+122 -58
View File
@@ -1,4 +1,7 @@
[data-component="timeline-detail-control"] {
--timeline-detail-track-background: var(--v2-background-bg-layer-04);
--timeline-detail-marker-background: var(--v2-grey-500);
position: relative;
display: flex;
min-width: 0;
flex-direction: column;
@@ -9,48 +12,55 @@
letter-spacing: -0.04px;
container: timeline-detail / inline-size;
[data-slot="timeline-detail-heading"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 24px;
font-weight: 530;
line-height: var(--line-height-compact);
}
[data-slot="timeline-detail-current"] {
color: var(--v2-text-text-muted);
text-align: end;
}
p {
margin: 0;
color: var(--v2-text-text-muted);
}
[data-slot="timeline-detail-summary"] {
color: var(--v2-text-text-base);
}
[data-slot="timeline-detail-scale"] {
position: relative;
height: 28px;
margin-top: 24px;
margin-inline: 8px;
}
[data-slot="timeline-detail-track"] {
position: absolute;
inset-inline: 8px;
inset-inline: 0;
top: 12px;
height: 4px;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 2px;
background: var(--v2-background-bg-layer-03);
background: var(--timeline-detail-track-background);
pointer-events: none;
&::before {
content: "";
position: absolute;
inset-inline-start: 0;
width: var(--timeline-detail-progress);
height: 100%;
border-radius: inherit;
background: var(--v2-background-bg-accent);
}
span {
width: 4px;
height: 4px;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
box-sizing: content-box;
width: 6px;
height: 6px;
border: 2px solid var(--v2-background-bg-layer-01);
border-radius: 50%;
background: var(--v2-border-border-strong);
background: var(--timeline-detail-marker-background);
&:dir(rtl) {
transform: translate(50%, -50%);
}
}
}
@@ -58,9 +68,11 @@
appearance: none;
position: relative;
display: block;
width: 100%;
/* Native ranges reserve half the thumb width at each end of their track. */
width: calc(100% + 16px);
height: 28px;
margin: 0;
margin-inline: -8px;
background: transparent;
cursor: pointer;
border-radius: 4px;
@@ -80,9 +92,15 @@
width: 16px;
height: 16px;
margin-top: -6px;
border: 1px solid var(--v2-border-border-base);
border: 0;
border-radius: 50%;
background: var(--v2-background-bg-base);
background:
linear-gradient(
180deg,
var(--v2-overlay-gradient-depth-overlay-depth-top) 0%,
var(--v2-overlay-gradient-depth-overlay-depth-bot) 100%
),
var(--v2-grey-300);
box-shadow: var(--v2-elevation-button-neutral);
}
@@ -95,29 +113,34 @@
box-sizing: border-box;
width: 16px;
height: 16px;
border: 1px solid var(--v2-border-border-base);
border: 0;
border-radius: 50%;
background: var(--v2-background-bg-base);
background:
linear-gradient(
180deg,
var(--v2-overlay-gradient-depth-overlay-depth-top) 0%,
var(--v2-overlay-gradient-depth-overlay-depth-bot) 100%
),
var(--v2-grey-300);
box-shadow: var(--v2-elevation-button-neutral);
}
}
[data-slot="timeline-detail-advanced"] {
margin-top: 4px;
margin-top: 20px;
padding-top: 8px;
border-top: 0.5px solid var(--v2-border-border-base);
}
[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"] {
width: fit-content;
height: 28px;
height: 24px;
align-self: flex-start;
gap: 6px;
font-size: inherit;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: inherit;
color: var(--v2-text-text-muted);
color: var(--v2-text-text-base);
[data-slot="collapsible-arrow"] {
width: 16px;
@@ -133,20 +156,28 @@
}
[data-slot="timeline-detail-categories"] {
--timeline-detail-columns: minmax(0, 1fr) 100px 108px;
--timeline-detail-columns: minmax(0, 1fr) 72px 88px;
display: flex;
flex-direction: column;
margin-top: 8px;
}
[data-slot="timeline-detail-explainer"] {
margin-bottom: 12px;
margin-top: 0;
}
[data-slot="timeline-detail-field-label"] {
display: none;
}
[data-slot="timeline-detail-unavailable"] {
width: 24px;
border-block-start: 1px solid var(--v2-icon-icon-muted);
opacity: 0.5;
}
[data-slot="timeline-detail-list"] {
border: 0.5px solid var(--v2-border-border-base);
border-radius: 8px;
background-color: var(--v2-background-bg-layer-02);
}
[data-slot="timeline-detail-columns"],
[data-slot="timeline-detail-category"] {
display: grid;
@@ -155,6 +186,10 @@
column-gap: 8px;
line-height: var(--line-height-compact);
> :nth-child(3) {
margin-inline-start: 8px;
}
> span {
min-width: 0;
overflow-wrap: normal;
@@ -162,49 +197,78 @@
}
[data-slot="timeline-detail-columns"] {
min-height: 28px;
min-height: var(--line-height-compact);
margin-bottom: 8px;
padding-inline: 12.5px;
color: var(--v2-text-text-muted);
span:not(:first-child) {
padding-inline-start: 8px;
text-align: center;
}
}
[data-slot="timeline-detail-category"] {
min-height: 40px;
padding-block: 8px;
padding-inline: 12px;
border-bottom: 0.5px solid var(--v2-border-border-base);
&:last-child {
border-bottom: 0;
}
[data-component="select-v2-root"],
[data-slot="timeline-detail-placement"],
[data-slot="timeline-detail-expansion"] {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
[data-component="select-v2-root"][data-field] {
width: 100%;
&[data-hidden] [data-slot="timeline-detail-activity"] {
color: var(--v2-text-text-faint);
}
}
[data-slot="timeline-detail-activity"] {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
> label {
min-width: 0;
overflow-wrap: anywhere;
cursor: default;
}
[data-component="select-v2"][data-appearance="inline"] {
width: 100%;
[data-component="tooltip-v2-trigger"] {
flex-shrink: 0;
}
[data-action="timeline-detail-visibility"][aria-pressed="true"] {
color: var(--v2-icon-icon-base);
}
/* Labels also activate :hover on their associated button. Keep that feedback local to the icon. */
&:has(> label:hover) [data-action="timeline-detail-visibility"][data-variant="ghost-muted"] {
background-color: transparent;
}
}
}
[data-color-scheme="dark"] [data-component="timeline-detail-control"] {
--timeline-detail-track-background: var(--v2-background-bg-layer-03);
--timeline-detail-marker-background:
linear-gradient(
180deg,
var(--v2-overlay-gradient-depth-overlay-depth-top) 0%,
var(--v2-overlay-gradient-depth-overlay-depth-bot) 100%
),
var(--v2-grey-300);
}
@container timeline-detail (max-width: 250px) {
[data-component="timeline-detail-control"] [data-slot="timeline-detail-heading"] {
flex-wrap: wrap;
gap: 4px 8px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-current"] {
margin-inline-start: auto;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"] {
display: none;
}
@@ -214,20 +278,20 @@
row-gap: 8px;
padding-block: 12px;
> span {
font-weight: 530;
> :nth-child(3) {
margin-inline-start: 0;
}
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-field-label"] {
display: block;
margin-bottom: 2px;
padding-inline-start: 8px;
font-size: 12px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-placement"]:empty,
[data-component="timeline-detail-control"] [data-slot="timeline-detail-expansion"]:empty {
display: none;
}
@@ -235,7 +299,7 @@
@container timeline-detail (max-width: 310px) {
[data-component="timeline-detail-control"] [data-slot="timeline-detail-categories"] {
--timeline-detail-columns: minmax(0, 1fr) 90px 96px;
--timeline-detail-columns: minmax(0, 1fr) 64px 80px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"],
@@ -0,0 +1,40 @@
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { timelinePresets, type TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import { SettingsList } from "./list"
import { TimelineDetailControl } from "./timeline-detail"
import "./settings.css"
export default {
title: "OpenCode/Settings/Timeline detail",
id: "settings-timeline-detail",
}
export const Interactive = {
render: () => {
const [state, setState] = createStore<{ value: TimelineDetail; visible: boolean }>({
value: structuredClone(timelinePresets[2].value),
visible: true,
})
return (
<div class="flex w-[560px] max-w-full flex-col gap-4">
<Show when={state.visible}>
<SettingsList>
<div class="py-5">
<TimelineDetailControl value={state.value} onChange={(value) => setState("value", value)} />
</div>
</SettingsList>
</Show>
<Button onClick={() => setState("visible", !state.visible)}>
{state.visible ? "Leave settings" : "Return to settings"}
</Button>
<Button onClick={() => setState("value", structuredClone(timelinePresets[2].value))}>Reset</Button>
<output data-slot="timeline-detail-fixture-value" hidden>
{JSON.stringify(state.value)}
</output>
</div>
)
},
}
+155 -64
View File
@@ -1,27 +1,32 @@
import { For, Show, createMemo, createUniqueId } from "solid-js"
import { createStore } from "solid-js/store"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { Select } from "@opencode-ai/ui/select"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import {
timelineCategories,
timelinePreset,
timelinePresets,
type TimelineCategory,
type TimelineDetail,
type TimelineExpansion,
type TimelinePlacement,
} from "@opencode-ai/session-ui/timeline/detail"
import { useLanguage } from "@/runtime/i18n/language"
import "./timeline-detail.css"
const placements: TimelinePlacement[] = ["separate", "grouped", "hidden"]
const expansions: TimelineExpansion[] = ["collapsed", "expanded"]
const presets = timelinePresets.toReversed()
export function TimelineDetailControl(props: { value: TimelineDetail; onChange: (value: TimelineDetail) => void }) {
const language = useLanguage()
const id = createUniqueId()
const [visiblePlacements, setVisiblePlacements] = createStore<
Partial<Record<TimelineCategory, Exclude<TimelinePlacement, "hidden">>>
>({})
const preset = createMemo(() => timelinePreset(props.value))
const position = () => {
const current = preset()
return current ? timelinePresets.indexOf(current) : 2
return current ? presets.indexOf(current) : 2
}
const label = () => {
const current = preset()
@@ -30,34 +35,42 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange:
return (
<div data-component="timeline-detail-control">
<div data-slot="timeline-detail-heading">
<label for={`${id}-slider`}>{language.t("settings.timeline.detail")}</label>
<span data-slot="timeline-detail-current" aria-live="polite">
{label()}
</span>
<div data-slot="settings-row-copy">
<label data-slot="settings-row-title" for={`${id}-slider`}>
{language.t("settings.timeline.detail")}
</label>
<div id={`${id}-description`} data-slot="settings-row-description">
{language.t("settings.timeline.description")}
</div>
</div>
<p id={`${id}-description`} class="sr-only">
{language.t("settings.timeline.description")}
</p>
<div data-slot="timeline-detail-scale">
<div data-slot="timeline-detail-track" aria-hidden="true">
<For each={timelinePresets}>{() => <span />}</For>
<div
data-slot="timeline-detail-track"
aria-hidden="true"
style={{ "--timeline-detail-progress": `${(position() / (presets.length - 1)) * 100}%` }}
>
<For each={presets}>
{(_, index) => <span style={{ "inset-inline-start": `${(index() / (presets.length - 1)) * 100}%` }} />}
</For>
</div>
<input
id={`${id}-slider`}
data-action="settings-timeline-detail"
type="range"
min="0"
max={timelinePresets.length - 1}
max={presets.length - 1}
step="1"
value={position()}
aria-valuetext={label()}
aria-describedby={`${id}-description ${id}-preset-description`}
onInput={(event) => props.onChange({ ...timelinePresets[event.currentTarget.valueAsNumber].value })}
onInput={(event) => props.onChange({ ...presets[event.currentTarget.valueAsNumber].value })}
/>
</div>
<p id={`${id}-preset-description`}>{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}</p>
<Collapsible variant="ghost" data-slot="timeline-detail-advanced">
<p id={`${id}-preset-description`} aria-live="polite">
<span data-slot="timeline-detail-summary">{language.t("settings.timeline.summary", { preset: label() })}</span>{" "}
{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}
</p>
<Collapsible variant="ghost" data-slot="timeline-detail-advanced" defaultOpen={!preset()}>
<Collapsible.Trigger>
<span>{language.t("settings.timeline.advanced")}</span>
<Collapsible.Arrow />
@@ -68,57 +81,135 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange:
role="group"
aria-label={language.t("settings.timeline.advanced.description")}
>
<p data-slot="timeline-detail-explainer">{language.t("settings.timeline.advanced.explainer")}</p>
<div data-slot="timeline-detail-columns">
<span>{language.t("settings.timeline.activity")}</span>
<span id={`${id}-placement`}>{language.t("settings.timeline.placement.title")}</span>
<span id={`${id}-expansion`}>{language.t("settings.timeline.expansion.title")}</span>
<span aria-hidden="true" />
<span>{language.t("settings.timeline.group")}</span>
<span>{language.t("settings.timeline.collapse")}</span>
</div>
<For each={timelineCategories}>
{(category) => (
<div data-slot="timeline-detail-category" role="group" aria-labelledby={`${id}-${category}`}>
<span id={`${id}-${category}`}>{language.t(`settings.timeline.category.${category}`)}</span>
<div data-slot="timeline-detail-placement">
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.placement.title")}
</span>
<Select
data-category={category}
data-field="placement"
aria-labelledby={`${id}-${category} ${id}-placement`}
options={placements}
current={props.value[category].placement}
label={(value) => language.t(`settings.timeline.placement.${value}`)}
onSelect={(placement) =>
placement &&
props.onChange({ ...props.value, [category]: { ...props.value[category], placement } })
}
/>
</div>
<div data-slot="timeline-detail-expansion">
{category === "shell" || category === "edit" || category === "thinking" ? (
<Show when={props.value[category].placement !== "hidden"}>
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.expansion.title")}
</span>
<Select
data-category={category}
data-field="details"
aria-labelledby={`${id}-${category} ${id}-expansion`}
options={expansions}
current={props.value[category].details}
label={(value) => language.t(`settings.timeline.expansion.${value}`)}
onSelect={(details) =>
details &&
props.onChange({ ...props.value, [category]: { ...props.value[category], details } })
<div data-slot="timeline-detail-list">
<For each={timelineCategories}>
{(category) => (
<div
data-slot="timeline-detail-category"
data-hidden={props.value[category].placement === "hidden" ? "" : undefined}
role="group"
aria-labelledby={`${id}-${category}`}
>
<div data-slot="timeline-detail-activity">
<Tooltip
placement="top"
value={language.t(
props.value[category].placement === "hidden"
? "settings.timeline.visibility.show"
: "settings.timeline.visibility.hide",
)}
>
<IconButton
id={`${id}-${category}-visibility`}
type="button"
variant="ghost-muted"
size="small"
data-action="timeline-detail-visibility"
aria-label={language.t("settings.timeline.visibility.label", {
activity: language.t(`settings.timeline.category.${category}`),
})}
aria-pressed={props.value[category].placement !== "hidden"}
onClick={() => {
const placement = props.value[category].placement
if (placement !== "hidden") setVisiblePlacements(category, placement)
props.onChange({
...props.value,
[category]: {
...props.value[category],
placement:
placement === "hidden" ? (visiblePlacements[category] ?? "grouped") : "hidden",
},
})
}}
icon={
<svg
width="16"
height="16"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M2.5 10s3.33-5.42 7.5-5.42S17.5 10 17.5 10s-3.33 5.42-7.5 5.42S2.5 10 2.5 10Z" />
<circle cx="10" cy="10" r="2.5" />
<Show when={props.value[category].placement === "hidden"}>
<path data-slot="timeline-detail-eye-slash" d="M3 3 17 17" />
</Show>
</svg>
}
/>
</Tooltip>
<label id={`${id}-${category}`} for={`${id}-${category}-visibility`}>
{language.t(`settings.timeline.category.${category}`)}
</label>
</div>
<div data-slot="timeline-detail-placement">
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.group")}
</span>
<Show
when={props.value[category].placement !== "hidden"}
fallback={<span data-slot="timeline-detail-unavailable" aria-hidden="true" />}
>
<Switch
data-category={category}
data-field="placement"
hideLabel
checked={props.value[category].placement === "grouped"}
onChange={(checked) =>
props.onChange({
...props.value,
[category]: { ...props.value[category], placement: checked ? "grouped" : "separate" },
})
}
>
{language.t("settings.timeline.grouped.label", {
activity: language.t(`settings.timeline.category.${category}`),
})}
</Switch>
</Show>
) : null}
</div>
<div data-slot="timeline-detail-expansion">
{category === "shell" || category === "edit" || category === "thinking" ? (
<>
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.collapse")}
</span>
<Show
when={props.value[category].placement !== "hidden"}
fallback={<span data-slot="timeline-detail-unavailable" aria-hidden="true" />}
>
<Switch
data-category={category}
data-field="details"
hideLabel
checked={props.value[category].details === "collapsed"}
onChange={(checked) =>
props.onChange({
...props.value,
[category]: { ...props.value[category], details: checked ? "collapsed" : "expanded" },
})
}
>
{language.t("settings.timeline.collapsed.label", {
activity: language.t(`settings.timeline.category.${category}`),
})}
</Switch>
</Show>
</>
) : null}
</div>
</div>
</div>
)}
</For>
)}
</For>
</div>
</div>
</Collapsible.Content>
</Collapsible>
+3 -1
View File
@@ -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))
})
+34 -36
View File
@@ -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)}
/>
)}
+41
View File
@@ -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
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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)),
+88 -70
View File
@@ -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()
})
})
+1
View File
@@ -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)"),
@@ -47,7 +47,6 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
if (!server.service) yield* updater.check().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -83,11 +82,14 @@ export default Runtime.handler(Commands, (input) =>
get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)),
},
updater: service
? {
apply: (version) => runPromise(updater.apply(version)),
}
: undefined,
updater: {
monitor: (notify, signal) =>
runPromise(
updater.monitor((version) => Effect.sync(() => notify(version))),
{ signal },
),
apply: (version) => runPromise(updater.apply(version)),
},
packages: {
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
},
+10 -2
View File
@@ -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
}
+21 -5
View File
@@ -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,
}
}
+6 -55
View File
@@ -7,14 +7,12 @@ import { Global } from "@opencode-ai/util/global"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { spawn } from "node:child_process"
import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect"
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
export type Mode = "default" | "service" | "stdio"
@@ -29,7 +27,6 @@ export type Options = {
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
export const run = Effect.fnUntraced(function* (options: Options) {
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
replacements: [
@@ -54,8 +51,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
const replacement = yield* Deferred.make<PersistentPty.Handoff | null>()
const next = yield* Effect.scoped(
return yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default"
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
@@ -66,7 +62,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
serviceOptions !== undefined && port !== undefined
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (incumbent !== undefined) return Option.none<PersistentPty.Handoff | null>()
if (incumbent !== undefined) return
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
@@ -163,62 +159,17 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
}),
)
if (server === undefined) return Option.none<PersistentPty.Handoff | null>()
if (server === undefined) return
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater
.monitor({
url,
password,
managed: options.mode === "service",
notify: server.updateAvailable,
restart: (handoff) => Deferred.succeed(replacement, handoff).pipe(Effect.asVoid),
})
.pipe(Effect.forkScoped)
return yield* options.mode === "service"
? Effect.raceFirst(
server.shutdown.pipe(Effect.as(Option.none<PersistentPty.Handoff | null>())),
Deferred.await(replacement).pipe(Effect.map(Option.some)),
)
? server.shutdown
: options.mode === "stdio"
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
? waitForStdinClose()
: Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
)
if (Option.isNone(next)) return
yield* spawnReplacement(next.value)
})
const spawnReplacement = Effect.fnUntraced(function* (handoff: PersistentPty.Handoff | null) {
const options = yield* ServiceConfig.options()
const [command, ...args] = options.command
if (!command) return yield* Effect.fail(new Error("Failed to resolve CLI command for restart"))
// We do not monitor the replacement after spawn. A managed TUI
// recovers with Service.ensure if startup fails; a future client
// restart signal could coordinate that recovery instead.
yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
env: {
...process.env,
...options.env,
OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined,
},
})
child.once("spawn", () => {
child.unref()
resolve()
})
child.once("error", reject)
}),
catch: (cause) => new Error("Failed to start replacement server", { cause }),
})
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
+3 -6
View File
@@ -1,5 +1,5 @@
export type Policy = "disable" | "notify" | "auto"
export type Action = "none" | "notify" | "upgrade"
export type Policy = "disable" | "notify"
export type Action = "none" | "notify"
const maximumComponent = "9007199254740991"
const versionPattern =
@@ -10,10 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
if (policy === "notify") return "notify"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "notify"
return "upgrade"
return "notify"
}
export function parseReleaseVersion(input: string) {
+19 -28
View File
@@ -6,22 +6,17 @@ describe("updater", () => {
test("reads update policy from JSONC", () => {
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
})
test("maps the v1 update policy", () => {
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
})
test("automatically updates patches and minors", () => {
expect(action("1.2.3", "1.2.4", "auto")).toBe("upgrade")
expect(action("1.2.3", "1.3.0", "auto")).toBe("upgrade")
})
test("reports patches and minors without automatically installing them", () => {
test("reports every available release", () => {
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
expect(action("1.2.3", "2.0.0", "notify")).toBe("notify")
@@ -32,25 +27,21 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
})
test("reports majors instead of automatically installing them", () => {
expect(action("1.2.3", "2.0.0", "auto")).toBe("notify")
})
test("reports up-to-date only when versions match", () => {
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
})
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade")
test("reports when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", "notify")).toBe("notify")
})
test("accepts strict release version variants", () => {
expect(action("v1.2.3", " 1.2.4\n", "auto")).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "auto")).toBe("upgrade")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "auto")).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "auto")).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", "auto")).toBe("none")
expect(action("v1.2.3+old", "1.2.3", "auto")).toBe("none")
expect(action("v1.2.3", " 1.2.4\n", "notify")).toBe("notify")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "notify")).toBe("notify")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "notify")).toBe("notify")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "notify")).toBe("notify")
expect(action("1.2.3+old", "1.2.3+new", "notify")).toBe("none")
expect(action("v1.2.3+old", "1.2.3", "notify")).toBe("none")
})
test("preserves strict validity", () => {
@@ -71,21 +62,21 @@ describe("updater", () => {
"0.9007199254740992.0",
"0.0.9007199254740992",
]
invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none"))
invalid.forEach((version) => expect(action("1.2.3", version, "notify"), version).toBe("none"))
})
test("handles numeric limits without losing precision", () => {
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify")
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "notify")).toBe("notify")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "notify")).toBe("notify")
})
test("preserves equality for oversized numeric prerelease identifiers", () => {
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "auto")).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "auto")).toBe("upgrade")
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "notify")).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "notify")).toBe("notify")
})
test("rejects versions longer than semver's limit before trimming", () => {
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "auto")).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "auto")).toBe("upgrade")
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "notify")).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "notify")).toBe("notify")
})
})
+28 -165
View File
@@ -1,154 +1,36 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OpenCode } from "@opencode-ai/client"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export interface Interface {
readonly check: () => Effect.Effect<void>
readonly monitor: (input: {
readonly url: string
readonly password: string
readonly managed: boolean
readonly notify: (version: string) => Effect.Effect<void>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
}) => Effect.Effect<void>
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
}
export type Inspection =
| { readonly action: "none" }
| { readonly action: Exclude<Action, "none">; readonly version: string }
type State =
| { readonly type: "current" }
| { readonly type: "available"; readonly version: string; readonly availableSince: number }
| { readonly type: "ready-to-restart"; readonly version: string }
export interface MonitorInput {
readonly url: string
readonly password: string
readonly managed: boolean
readonly inspect: () => Effect.Effect<Inspection, Error>
readonly install: (version: string) => Effect.Effect<boolean, Error>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
readonly interval?: Duration.Input
readonly notificationThreshold?: Duration.Input
export const monitorUpdates = Effect.fnUntraced(function* (input: {
readonly inspect: () => Effect.Effect<string | undefined, Error>
readonly notify: (version: string) => Effect.Effect<void>
}
export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) {
const state = yield* Ref.make<State>({ type: "current" })
const applyLock = yield* Semaphore.make(1)
const client = OpenCode.make({
baseUrl: input.url,
headers: { authorization: `Basic ${btoa(`opencode:${input.password}`)}` },
})
const applyIfIdle = () =>
applyLock.withPermit(
Effect.gen(function* () {
const pending = yield* Ref.get(state)
if (pending.type !== "available") return
const active = yield* Effect.tryPromise({
try: () => client.session.active(),
catch: (cause) => new Error("Failed to read active sessions", { cause }),
})
if (Object.keys(active).length > 0) return
const latest = yield* input.inspect()
if (latest.action !== "upgrade") {
yield* Ref.set(state, { type: "current" })
return
}
const installed = yield* input
.install(latest.version)
.pipe(
Effect.catch((error) =>
Effect.logWarning("automatic update failed", { cause: error }).pipe(Effect.as(false)),
),
)
if (!installed) return
const handoff = input.managed
? yield* Effect.tryPromise({
try: () => client.experimental.persistentPty.handoff(),
catch: (cause) => new Error("Failed to prepare persistent terminals for restart", { cause }),
})
: undefined
yield* Ref.set(state, { type: "ready-to-restart", version: latest.version })
if (handoff) yield* input.restart(handoff.handoff)
}),
)
const checkServer = Effect.gen(function* () {
const result = yield* input.inspect()
if (result.action === "notify") {
yield* input.notify(result.version)
return
}
if (result.action !== "upgrade") {
yield* Ref.update(
state,
(current): State => (current.type === "ready-to-restart" ? current : { type: "current" }),
)
return
}
yield* Ref.update(state, (current): State => {
if (current.type === "ready-to-restart" && current.version === result.version) return current
return {
type: "available",
version: result.version,
availableSince: current.type === "available" ? current.availableSince : Date.now(),
}
})
yield* applyIfIdle()
const pending = yield* Ref.get(state)
if (
pending.type === "available" &&
Date.now() - pending.availableSince >= Duration.toMillis(input.notificationThreshold ?? "3 days")
)
yield* input.notify(pending.version)
}).pipe(Effect.catch((cause) => Effect.logWarning("automatic update check failed", { cause })))
const subscribe = Effect.suspend(() =>
Stream.fromAsyncIterable(
client.event.subscribe(),
(cause) => new Error("Update event stream failed", { cause }),
).pipe(
Stream.runForEach((event) => {
if (event.type === "server.connected") return applyIfIdle()
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.failed" &&
event.type !== "session.execution.interrupted"
)
return Effect.void
return Effect.tryPromise({
try: () => client.session.wait({ sessionID: event.data.sessionID }),
catch: (cause) => new Error(`Failed to wait for Session ${event.data.sessionID}`, { cause }),
}).pipe(Effect.andThen(applyIfIdle()))
}),
Effect.catch((cause) => Effect.logWarning("update event stream disconnected", { cause })),
),
).pipe(Effect.repeat(Schedule.spaced("1 second")))
return yield* Effect.all(
[checkServer.pipe(Effect.repeat(Schedule.spaced(input.interval ?? "10 minutes"))), subscribe],
{
concurrency: "unbounded",
discard: true,
},
)
readonly initialDelay?: Duration.Input
readonly interval?: Duration.Input
}) {
const interval = input.interval ?? "10 minutes"
const initialDelay = input.initialDelay ?? "90 seconds"
const check = Effect.gen(function* () {
const version = yield* input.inspect()
if (version !== undefined) yield* input.notify(version)
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
})
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -161,13 +43,14 @@ export function decodePolicy(text: string): Policy | undefined {
if (errors.length || typeof input !== "object" || input === null) return
if ("update" in input) {
const value = input.update
if (value === "disable" || value === "notify" || value === "auto") return value
if (value === "disable" || value === "notify") return value
if (value === "auto") return "notify"
return
}
if (!("autoupdate" in input)) return
if (input.autoupdate === false) return "disable"
if (input.autoupdate === "notify") return "notify"
if (input.autoupdate === true) return "auto"
if (input.autoupdate === true) return "notify"
}
const make = Effect.gen(function* () {
@@ -192,7 +75,7 @@ const make = Effect.gen(function* () {
Effect.orElseSucceed(() => undefined),
),
)
return values.findLast((value) => value !== undefined) ?? "auto"
return values.findLast((value) => value !== undefined) ?? "notify"
})
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
@@ -302,19 +185,19 @@ const make = Effect.gen(function* () {
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
const inspect = Effect.fnUntraced(function* (): Effect.fn.Return<Inspection, Error> {
const inspect = Effect.fnUntraced(function* () {
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) {
yield* Effect.logInfo("update check skipped", {
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
})
return { action: "none" }
return undefined
}
const policy = yield* readPolicy()
if (policy === "disable") {
yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
return { action: "none" }
return undefined
}
const version = yield* latest()
@@ -325,19 +208,16 @@ const make = Effect.gen(function* () {
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") {
yield* Effect.logInfo("update check done", { action: "up-to-date" })
return { action: "none" }
return undefined
}
if (next === "notify") {
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return { action: next, version }
}
return { action: next, version }
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return version
})
const install = Effect.fnUntraced(function* (version: string) {
const detected = yield* method()
if (!detected) {
yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* Effect.logWarning("update skipped: installation method not found")
return false
}
yield* upgrade(detected, version)
@@ -349,26 +229,9 @@ const make = Effect.gen(function* () {
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
})
const check = Effect.fn("cli.updater.check")(
function* () {
const result = yield* inspect()
if (result.action !== "upgrade") return
yield* install(result.version)
},
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
)
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
const monitor = Effect.fn("cli.updater.monitor")(function* (input: {
readonly url: string
readonly password: string
readonly managed: boolean
readonly notify: (version: string) => Effect.Effect<void>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
}) {
return yield* monitorServer({ ...input, inspect, install })
})
return Service.of({ check, monitor, apply, method, latest, upgrade })
return Service.of({ monitor, apply, method, latest, upgrade })
})
export const layer = Layer.effect(Service, make)
+1 -1
View File
@@ -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)
})
})
+1 -2
View File
@@ -12,9 +12,8 @@ await Effect.runPromise(
process.argv.slice(2),
).pipe(
Effect.provideService(Updater.Service, {
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
apply: () => Effect.die("Manual upgrades must not apply automatic updates"),
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
method: () =>
Effect.sync(() => {
record("method")
+2 -2
View File
@@ -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 () => {
+27 -94
View File
@@ -1,107 +1,40 @@
import { expect } from "bun:test"
import { Deferred, Effect, Layer, Option } from "effect"
import { Effect, Layer, Queue } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.live("installs and restarts after the final Session settles", () =>
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(Effect.sync(makeServer), (server) => Effect.sync(() => server.stop()))
const installed = yield* Deferred.make<string>()
const restarted = yield* Deferred.make<void>()
yield* Updater.monitorServer({
url: fixture.url,
password: "test",
managed: true,
inspect: () => Effect.succeed({ action: "upgrade", version: "1.1.0" }),
install: (version) => Deferred.succeed(installed, version).pipe(Effect.as(true)),
restart: () => Deferred.succeed(restarted, undefined).pipe(Effect.asVoid),
notify: () => Effect.void,
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed("2.0.0"),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
}).pipe(Effect.forkScoped)
yield* wait(fixture.activeRead, () => "Updater did not check active Sessions")
yield* wait(fixture.eventOpened, () => "Updater did not open the server event stream")
expect(Option.isNone(yield* Deferred.poll(installed))).toBe(true)
fixture.settle()
yield* wait(fixture.waited, () => "Updater did not receive the settlement event")
expect(
yield* Effect.raceFirst(
Deferred.await(installed),
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not install the update")))),
),
).toBe("1.1.0")
yield* Effect.raceFirst(
Deferred.await(restarted),
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not restart the server")))),
)
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
yield* TestClock.adjust("89 seconds")
expect(yield* Queue.size(updates)).toBe(0)
yield* TestClock.adjust("1 second")
expect(yield* Queue.take(updates)).toBe("2.0.0")
yield* Effect.yieldNow
yield* TestClock.adjust("10 minutes")
expect(yield* Queue.take(updates)).toBe("2.0.0")
}),
)
const wait = (promise: Promise<unknown>, message: () => string) =>
Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))]))
it.effect("does not notify when no update is available", () =>
Effect.gen(function* () {
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed(undefined),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
}).pipe(Effect.forkScoped)
function makeServer() {
const encoder = new TextEncoder()
const activeRead = Promise.withResolvers<void>()
const eventOpened = Promise.withResolvers<void>()
const waited = Promise.withResolvers<void>()
let active = true
let events: ReadableStreamDefaultController<Uint8Array> | undefined
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/session/active") {
activeRead.resolve()
return Response.json({ data: active ? { ses_test: { type: "running" } } : {} })
}
if (url.pathname === "/api/session/ses_test/wait" && request.method === "POST") {
waited.resolve()
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/experimental/persistent-pty/handoff" && request.method === "POST") {
return Response.json({ handoff: null })
}
if (url.pathname === "/api/event") {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
events = controller
eventOpened.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
return new Response("Not found", { status: 404 })
},
})
return {
url: server.url.origin,
activeRead: activeRead.promise,
eventOpened: eventOpened.promise,
waited: waited.promise,
settle() {
active = false
events?.enqueue(
encoder.encode(
`data: ${JSON.stringify({
id: "evt_settled",
created: Date.now(),
type: "session.execution.succeeded",
durable: { aggregateID: "ses_test", seq: 0, version: 1 },
data: { sessionID: "ses_test" },
})}\n\n`,
),
)
events?.close()
events = undefined
},
stop() {
server.stop(true)
},
}
}
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
}),
)
+2
View File
@@ -965,6 +965,8 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly model?: Model.Ref | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly text: string
readonly recent: string
}
+38 -22
View File
@@ -138,17 +138,6 @@ export type SessionMessageCompactionRunning = {
recent: string
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
summary: string
recent: string
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -521,6 +510,19 @@ export type SessionMessageAssistantReasoning = {
time?: { created: number; completed?: number }
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
summary: string
recent: string
}
export type ToolContent = ToolTextContent | ToolFileContent
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
@@ -809,16 +811,6 @@ export type SessionCompactionStarted = {
data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string }
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
}
export type SessionCompactionFailed = {
id: string
created: number
@@ -1351,6 +1343,23 @@ export type SessionToolCalled = {
}
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
text: string
recent: string
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
@@ -1883,7 +1892,7 @@ export type ConfigEntry =
shell?: string
model?: string | { providerID: string; model: string; variant?: string }
default_agent?: string
update?: "disable" | "notify" | "auto"
update?: "disable" | "notify"
share?: "manual" | "auto" | "disabled"
enterprise?: { url?: string }
username?: string
@@ -1971,6 +1980,7 @@ export type ConfigEntry =
description?: string
agent?: string
model?: string | { providerID: string; model: string; variant?: string }
subagent?: boolean
subtask?: boolean
}
}
@@ -3063,6 +3073,8 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
}
@@ -3340,6 +3352,8 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
}
@@ -3617,6 +3631,8 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
}
+86 -68
View File
@@ -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, { ...store.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])
@@ -1038,6 +1057,8 @@ export function createData(config: CreateDataInput) {
Object.assign(current, {
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
recent: event.data.recent,
})
@@ -1048,6 +1069,8 @@ export function createData(config: CreateDataInput) {
type: "compaction",
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.created },
@@ -1101,11 +1124,10 @@ 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) => ({
...data,
integration: data?.integration?.map((integration) => {
if (integration.id !== event.data.integrationID) return integration
const active = integration.connections.find(
@@ -1120,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
}
@@ -1131,23 +1153,22 @@ 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) => ({
...data,
vcs: {
branch: {
...data?.vcs?.branch,
@@ -1165,7 +1186,6 @@ export function createData(config: CreateDataInput) {
break
case "shell.created":
setStore("location", locationKey(location), (data) => ({
...data,
shell: {
...data?.shell,
[event.data.info.id]: { ...event.data.info, location },
@@ -1175,37 +1195,38 @@ export function createData(config: CreateDataInput) {
case "shell.exited":
case "shell.deleted":
setStore("location", locationKey(location), (data) => ({
...data,
shell: Object.fromEntries(Object.entries(data?.shell ?? {}).filter(([id]) => id !== event.data.id)),
}))
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
}
}
@@ -1801,10 +1822,7 @@ export function createData(config: CreateDataInput) {
const input = { location: locationQuery(ref ?? defaultLocation()) }
const providers = await api().websearch.providers(input)
const key = locationKey(providers.location)
setStore("location", key, {
...store.location[key],
websearch: providers.data,
})
setStore("location", key, { websearch: providers.data })
},
},
skill: locationResource("skill", (location) => api().skill.list({ location })),
@@ -98,13 +98,15 @@ test.each(["started", "cancelled", "failed"])(
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
if (kind === "started") {
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
const model = { providerID: "demo", id: "model" }
const providerState = { responseId: "summary-response" }
fixture.emit({
...event,
type: "session.compaction.ended",
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" },
})
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ type: "compaction", status: "completed", summary: "Summary" },
{ type: "compaction", status: "completed", summary: "Summary", model, providerState },
])
}
},
+98
View File
@@ -355,8 +355,10 @@ test("refreshes global credential events across every loaded location and worksp
setup.data.location.integration.sync(location),
setup.data.location.model.sync(location),
setup.data.location.provider.sync(location),
setup.data.location.reference.sync(location),
]),
)
const references = locations.map((location) => setup.data.location.reference.list(location))
requests.length = 0
const updated: OpenCodeEvent = {
@@ -402,6 +404,9 @@ test("refreshes global credential events across every loaded location and worksp
["/api/provider", "/other", "workspace-other"],
]),
)
locations.forEach((location, index) =>
expect(setup.data.location.reference.list(location)).toBe(references[index]),
)
requests.length = 0
}
} finally {
@@ -469,6 +474,99 @@ test("refreshes references for the location an update names", async () => {
}
})
test("preserves sibling catalogs through location preload, branch, shell, and websearch updates", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const location = { directory: "/project", project: { id: "project", directory: "/project", canonical: "/project" } }
const requests: string[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const pathname = new URL((input instanceof Request ? input : new Request(input, init)).url).pathname
requests.push(pathname)
if (pathname === "/api/session/active") return Response.json({})
if (pathname === "/api/project") return Response.json([])
if (pathname === "/api/location") return Response.json(location)
if (pathname === "/api/vcs")
return Response.json({ location, data: { branch: { current: "main", default: "main" } } })
if (pathname === "/api/reference")
return Response.json({
location,
data: [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
})
if (pathname === "/api/websearch/provider")
return Response.json({ location, data: [{ id: "search", name: "Search" }] })
throw new Error(`Unexpected request: ${pathname}`)
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: location.directory,
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
const emit = (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details }))
const shell = {
id: "sh_first",
status: "running" as const,
command: "echo hello",
cwd: location.directory,
shell: "/bin/sh",
file: "/shell-output",
metadata: {},
time: { started: 1 },
}
try {
// A live event may arrive before any location reads have populated this key.
emit({ type: "shell.created", location, data: { info: shell } })
expect(setup.data.shell.get(shell.id)).toMatchObject(shell)
const first = setup.data.shell.get(shell.id)
await Promise.all([setup.data.location.reference.sync(), setup.data.location.vcs.sync()])
const references = setup.data.location.reference.list()
expect(references?.map((reference) => [reference.name, reference.path])).toEqual([["docs", "/docs"]])
expect(setup.data.shell.get(shell.id)).toBe(first)
emit({ type: "vcs.branch.updated", location, data: { branch: "feature" } })
expect(setup.data.location.vcs.info()?.branch).toEqual({ current: "feature", default: "main" })
expect(setup.data.location.reference.list()).toBe(references)
emit({ type: "shell.created", location, data: { info: { ...shell, id: "sh_second" } } })
expect(setup.data.shell.list().map((shell) => shell.id)).toEqual(["sh_first", "sh_second"])
expect(setup.data.location.reference.list()).toBe(references)
emit({ type: "shell.deleted", location, data: { id: "sh_second" } })
expect(setup.data.shell.list().map((shell) => shell.id)).toEqual(["sh_first"])
expect(setup.data.shell.get(shell.id)).toBe(first)
expect(setup.data.location.reference.list()).toBe(references)
await setup.data.location.websearch.refresh()
expect(setup.data.location.websearch.list()).toEqual([{ id: "search", name: "Search" }])
expect(setup.data.location.reference.list()).toBe(references)
emit({ type: "server.connected", data: {} })
await wait(() => setup.data.location.info() !== undefined)
expect(setup.data.location.info()).toMatchObject(location)
expect(setup.data.location.reference.list()).toBe(references)
expect(setup.data.shell.get(shell.id)).toBe(first)
expect(setup.data.location.vcs.info()?.branch).toEqual({ current: "feature", default: "main" })
expect(requests.toSorted()).toEqual([
"/api/location",
"/api/project",
"/api/reference",
"/api/session/active",
"/api/vcs",
"/api/websearch/provider",
])
} finally {
setup.dispose()
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
+117
View File
@@ -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 -1
View File
@@ -8,7 +8,7 @@ import { CodeModeCatalog } from "./catalog.js"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools callable inside \`execute\`. It does not affect tools exposed directly outside Code Mode.${hasMoreTools ? `
## Search
+9 -2
View File
@@ -73,6 +73,11 @@ export function normalize(input: unknown): Result {
const legacyUpdate = own(input, "autoupdate")
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
: undefined
const nativeUpdate = own(input, "update")
? input.update === "auto"
? "notify"
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
: undefined
const legacyShare = own(input, "autoshare")
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
? "auto"
@@ -86,7 +91,10 @@ export function normalize(input: unknown): Result {
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
}
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
if (legacyUpdate !== undefined) encoded.update = ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
const migratedUpdate =
legacyUpdate === undefined ? undefined : ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
const update = prefer(migratedUpdate, nativeUpdate, ["update"], diagnostics)
if (update !== undefined) encoded.update = update
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
@@ -196,7 +204,6 @@ export function normalize(input: unknown): Result {
shell: Info.fields.shell,
model: Info.fields.model,
default_agent: Info.fields.default_agent,
update: Info.fields.update,
share: Info.fields.share,
enterprise: Info.fields.enterprise,
username: Info.fields.username,
+46 -14
View File
@@ -1,7 +1,6 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
@@ -10,8 +9,11 @@ import { AppProcess } from "@opencode-ai/util/process"
import path from "path"
import { Effect, Option, PubSub, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { Location } from "../../location.js"
import { Session } from "../../session.js"
import { SubagentJob } from "../../session/subagent-job.js"
import { ShellSelect } from "../../shell/select.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigMarkdown } from "../markdown.js"
@@ -32,6 +34,9 @@ export const Plugin = define({
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
const sessions = yield* Session.Service
const agents = yield* Agent.Service
const subagents = yield* SubagentJob.make
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
@@ -67,18 +72,14 @@ export const Plugin = define({
yield* ctx.command.transform((editor) => {
for (const document of loaded.documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
const subagent = command.subagent ?? command.subtask
editor.add({
name,
description: command.description,
execute: (input) =>
Effect.gen(function* () {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const commandAgent = agent === undefined ? undefined : (yield* ctx.agent.get({ agentID: agent })).data
const model =
command.model === undefined
? commandAgent?.model
@@ -89,15 +90,46 @@ export const Plugin = define({
? {}
: { variant: Model.VariantID.make(command.model.variant) }),
}
const text = yield* evaluateTemplate(command.template, input.prompt.text, {
location,
processes,
shell,
})
if (subagent ?? commandAgent?.mode === "subagent") {
const parent = yield* sessions.get(input.sessionID)
const selected = yield* agents.select(agent ?? parent.agent)
const child = yield* sessions.create({
parentID: parent.id,
title: command.description ?? name,
agent: selected.id,
model: model ?? selected.info?.model ?? parent.model,
})
yield* sessions.prompt({
...input.prompt,
sessionID: child.id,
text: ["You are a subagent spawned by another session.", text].join("\n"),
resume: false,
})
const recovery = {
kind: "subagent" as const,
parentSessionID: parent.id,
childSessionID: child.id,
agent: selected.id,
description: command.description ?? name,
}
yield* subagents.start(recovery)
yield* subagents.background(recovery)
return
}
if (agent !== undefined) {
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, {
location,
processes,
shell,
}),
text,
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
@@ -196,8 +228,8 @@ function evaluateTemplate(
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError((error) =>
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
Effect.mapError(
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
+6 -2
View File
@@ -18,7 +18,9 @@ const RemoteModel = Schema.Struct({
Schema.Struct({
batch_size: Schema.Number,
default: Schema.Struct({
cache_price: Schema.Number,
// API version 2026-08-01 renamed cache_price to cache_read_price.
cache_price: Schema.optional(Schema.Number),
cache_read_price: Schema.optional(Schema.Number),
input_price: Schema.Number,
output_price: Schema.Number,
}),
@@ -166,7 +168,9 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
input: Money.USDPerMillionTokens.make((prices?.default.input_price ?? 0) * usdPerMillion),
output: Money.USDPerMillionTokens.make((prices?.default.output_price ?? 0) * usdPerMillion),
cache: {
read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion),
read: Money.USDPerMillionTokens.make(
(prices?.default.cache_read_price ?? prices?.default.cache_price ?? 0) * usdPerMillion,
),
write: Money.USDPerMillionTokens.zero,
},
},
+5 -8
View File
@@ -55,7 +55,6 @@ type Active = {
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
scope: Scope.Closeable
token: object
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
recovery?: Recovery
@@ -77,7 +76,7 @@ type BackgroundResult = {
backgrounded?: Deferred.Deferred<Info>
}
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
type BlockWait = {
done: Deferred.Deferred<Info>
@@ -184,14 +183,14 @@ export const make = Effect.gen(function* () {
})
})
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const settle = Effect.fnUntraced(function* (id: string, scope: Scope.Closeable, exit: Exit.Exit<string, unknown>) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.token !== token) return [{}, jobs]
if (job.scope !== scope) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed"
@@ -241,7 +240,6 @@ export const make = Effect.gen(function* () {
return [{ info: snapshot(existing) }, jobs]
}
const scope = yield* Scope.fork(state.scope, "parallel")
const token = {}
const job = {
info: {
id,
@@ -255,18 +253,17 @@ export const make = Effect.gen(function* () {
done,
backgrounded,
scope,
token,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
}),
)
if ("scope" in result)
yield* restore(input.run).pipe(
Effect.exit,
Effect.flatMap((exit) => settle(id, result.token, exit)),
Effect.flatMap((exit) => settle(id, result.scope, exit)),
Effect.asVoid,
Effect.forkIn(result.scope, { startImmediately: true }),
)
+38 -11
View File
@@ -1,5 +1,4 @@
import { Duration, Effect, Layer, LayerMap } from "effect"
import { existsSync } from "fs"
import { Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Option } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Instance } from "./instance.js"
import { Location } from "./location.js"
@@ -16,20 +15,48 @@ export function buildLocationServiceMap(
return Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const inner = yield* LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements: bindings }), {
// Workspace-placed directories exist only inside the workspace, so a
// local stat consults the wrong filesystem. Workspace liveness is
// owned by placement; do not probe the sandbox here, which would
// provision lazily-idle workspaces.
idleTimeToLive: (ref) =>
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
})
const owner = yield* Effect.scope
const booting = MutableHashMap.empty<Location.Ref, object>()
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = yield* LayerMap.make(
(ref: Location.Ref) => {
const build = {}
MutableHashMap.set(booting, ref, build)
return Layer.fromBuild((memoMap, scope) =>
Effect.suspend(() =>
Layer.buildWithMemoMap(Instance.layer(ref, { replacements: bindings }), memoMap, scope),
).pipe(
Effect.onExit((exit) => {
const finish = Effect.suspend(() => {
// An explicitly invalidated build must not evict its replacement.
if (Option.getOrUndefined(MutableHashMap.get(booting, ref)) !== build) return Effect.void
MutableHashMap.remove(booting, ref)
// Evict once per failed build, before its result reaches borrowers.
return Exit.isFailure(exit) ? inner.invalidate(ref) : Effect.void
})
// With no borrowers, invalidation closes the entry's scope and
// joins this lookup fiber. Let the owner finish that cleanup.
return Exit.isFailure(exit)
? finish.pipe(Effect.forkIn(owner, { startImmediately: true }), Effect.asVoid)
: finish
}),
),
)
},
// Retain healthy graphs. Boot failures, not local filesystem probes,
// decide whether a location (including workspace placement) can retry.
{ idleTimeToLive: Duration.infinity },
)
const map = {
...inner,
get: (ref: Location.Ref) => inner.get(LocationServiceMap.canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(LocationServiceMap.canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(LocationServiceMap.canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(LocationServiceMap.canonical(ref)),
invalidate: (ref: Location.Ref) =>
Effect.suspend(() => {
const key = LocationServiceMap.canonical(ref)
MutableHashMap.remove(booting, key)
return inner.invalidate(key)
}),
}
// Cached instances borrow their owner instead of retaining its Layer scope.
const bindings: LayerNode.Replacements = [
+141
View File
@@ -0,0 +1,141 @@
export * as ModalModels from "./models.js"
import { Money } from "@opencode-ai/schema/money"
import { Option, Schema } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
const providerID = Provider.ID.make("modal")
const ReasoningOption = Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
})
const RemoteModel = Schema.Struct({
id: Schema.String,
base_model_id: Schema.optional(Schema.String),
hugging_face_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
input_modalities: Schema.optional(Schema.Array(Schema.String)),
output_modalities: Schema.optional(Schema.Array(Schema.String)),
context_length: Schema.optional(Schema.Number),
max_output_length: Schema.optional(Schema.Number),
pricing: Schema.optional(
Schema.Struct({
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
}),
),
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
supported_features: Schema.optional(Schema.Array(Schema.String)),
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Boolean,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
})
const Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeResponse = Schema.decodeUnknownSync(Response)
const decodeModel = Schema.decodeUnknownOption(RemoteModel)
type RemoteModel = typeof RemoteModel.Type
export async function get(baseURL: string, apiKey: string, existing: readonly Model.Info[]) {
const response = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(3_000),
})
if (!response.ok) throw new Error(`Failed to fetch Modal models: ${response.status}`)
// Decode each item tolerantly so one malformed entry cannot discard the
// whole inventory. A malformed envelope still fails the fetch.
const remote = decodeResponse(await response.json()).data.flatMap((raw) => {
const model = Option.getOrUndefined(decodeModel(raw))
return model ? [model] : []
})
const templates = new Map(existing.map((model) => [model.id, model]))
const result = new Map<Model.ID, Model.Info>()
for (const item of remote) {
const template = templates.get(Model.ID.make(item.base_model_id ?? item.hugging_face_id ?? item.id))
const id = Model.ID.make(item.id)
result.set(id, build(id, item, baseURL, template))
}
return result
}
function price(value: string | number | undefined, fallback: Money.USDPerMillionTokens) {
if (value === undefined) return fallback
const parsed = Number(value) * 1_000_000
return Number.isFinite(parsed) ? Money.USDPerMillionTokens.make(parsed) : fallback
}
function limit(value: number | undefined, fallback: number) {
const parsed = value === undefined ? fallback : Math.trunc(value)
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
}
function build(id: Model.ID, remote: RemoteModel, baseURL: string, previous?: Model.Info) {
const cost = previous?.cost[0]
const input = previous?.limit.input
return Model.Info.make({
...Model.Info.default(providerID, id),
id,
modelID: Model.ID.make(remote.id),
providerID,
name: remote.name ?? previous?.name ?? remote.id,
family: previous?.family,
compatibility:
remote.interleaved === undefined
? previous?.compatibility
: (Model.compatibility(remote.interleaved) ?? previous?.compatibility),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: Provider.mergeOverlay(previous?.settings, { baseURL }),
headers: previous?.headers,
body: previous?.body,
capabilities: {
tools: remote.supported_features?.includes("tools") ?? previous?.capabilities.tools ?? true,
input: remote.input_modalities ?? previous?.capabilities.input ?? ["text"],
output: remote.output_modalities ?? previous?.capabilities.output ?? ["text"],
},
variants: remote.reasoning_options === undefined ? (previous?.variants ?? []) : variants(remote),
time: previous?.time ?? { released: 0 },
cost: [
{
input: price(remote.pricing?.prompt, cost?.input ?? Money.USDPerMillionTokens.zero),
output: price(remote.pricing?.completion, cost?.output ?? Money.USDPerMillionTokens.zero),
cache: {
read: price(remote.pricing?.input_cache_read, cost?.cache.read ?? Money.USDPerMillionTokens.zero),
write: cost?.cache.write ?? Money.USDPerMillionTokens.zero,
},
},
],
status: previous?.status ?? "active",
enabled: previous?.enabled ?? true,
limit: {
context: limit(remote.context_length, previous?.limit.context ?? 0),
...(input === undefined ? {} : { input }),
output: limit(remote.max_output_length, previous?.limit.output ?? 0),
},
})
}
function variants(remote: RemoteModel): Model.Info["variants"] {
const seen = new Map<string, Model.Info["variants"][number]>()
for (const option of remote.reasoning_options ?? []) {
for (const value of option.values) {
const effort = value ?? "none"
if (!seen.has(effort))
seen.set(effort, { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } })
}
}
return [...seen.values()]
}

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