mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-04 07:56:23 +00:00
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59207d0daa | ||
|
|
0d7eaa0742 | ||
|
|
55be3a6531 | ||
|
|
ffac1c5b11 | ||
|
|
c5dca2df37 | ||
|
|
8889447f5a | ||
|
|
a1cb005799 | ||
|
|
19833ad1fd | ||
|
|
cb852434b1 | ||
|
|
b3733e9517 | ||
|
|
331f4ecd2f | ||
|
|
ecaa914b79 | ||
|
|
4f8dea674a | ||
|
|
4bf5269c4c | ||
|
|
c9d240704d | ||
|
|
c9df4ba80d | ||
|
|
ac71a55294 | ||
|
|
f84d927e07 | ||
|
|
d431fedce5 | ||
|
|
e536b9627e | ||
|
|
6af46cc8a9 | ||
|
|
c76e602ba4 | ||
|
|
726107729e | ||
|
|
0808ebc3c5 | ||
|
|
17e5c5fbf1 | ||
|
|
b0a13b810e | ||
|
|
6575215ddf | ||
|
|
09157609b7 | ||
|
|
7ba5f3e5b2 | ||
|
|
0ec1587948 | ||
|
|
2fda2c8141 | ||
|
|
f06bf47e72 | ||
|
|
fa6e7e179d | ||
|
|
4272bb83e0 | ||
|
|
1ae2cf3567 | ||
|
|
2084c52952 | ||
|
|
5a9931280e | ||
|
|
b2fb2c5e36 | ||
|
|
43bd2a516b | ||
|
|
dad7688739 | ||
|
|
961b8ccb86 | ||
|
|
97303c39dd | ||
|
|
282c84d79f | ||
|
|
2dcfc89fab | ||
|
|
a222401f19 | ||
|
|
36da0d5c77 | ||
|
|
7819e7f503 | ||
|
|
6e63b970f3 | ||
|
|
610d7e952a | ||
|
|
ac874a6e90 | ||
|
|
f40ecefdef | ||
|
|
c370a1bdd0 | ||
|
|
309f4534fa | ||
|
|
0ae3bf743f | ||
|
|
f94eefaa50 | ||
|
|
a04d72bb39 |
@@ -415,10 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
|
||||
// System prompts share the cache-point convention: emit the text block, then
|
||||
// optionally a positional `cachePoint` marker.
|
||||
const lowerSystem = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
system: ReadonlyArray<LLMRequest["system"][number]>,
|
||||
) => {
|
||||
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
|
||||
const content = system
|
||||
.filter((part) => part.text.length > 0)
|
||||
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
@@ -508,7 +505,6 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
|
||||
interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly finishedTools: ReadonlySet<number>
|
||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
|
||||
// one finish after both chunks have had a chance to arrive.
|
||||
@@ -620,16 +616,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.contentBlockDelta?.delta?.toolUse) {
|
||||
const index = event.contentBlockDelta.contentBlockIndex
|
||||
if (state.finishedTools.has(index)) return [state, []] as const
|
||||
const result = ToolStream.appendExisting(
|
||||
ADAPTER,
|
||||
// A delta for a block that is not open, whether it already stopped or never
|
||||
// started, has nothing to attach to and is dropped.
|
||||
const result = ToolStream.append(
|
||||
state.tools,
|
||||
index,
|
||||
event.contentBlockDelta.contentBlockIndex,
|
||||
event.contentBlockDelta.delta.toolUse.input,
|
||||
"Bedrock Converse tool delta is missing its tool call",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
if (!result) return [state, []] as const
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...result.events)
|
||||
@@ -668,7 +662,6 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
state.hasToolCalls,
|
||||
lifecycle,
|
||||
tools: result.tools,
|
||||
finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
|
||||
reasoningSignatures: Object.fromEntries(
|
||||
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
|
||||
),
|
||||
@@ -765,7 +758,6 @@ export const protocol = Protocol.make({
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
finishedTools: new Set<number>(),
|
||||
finishReason: undefined,
|
||||
usage: undefined,
|
||||
hasToolCalls: false,
|
||||
|
||||
@@ -433,7 +433,6 @@ export interface ParserState {
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
|
||||
interface ReasoningStreamItem {
|
||||
readonly open: boolean
|
||||
readonly encryptedContent: string | null | undefined
|
||||
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
|
||||
// strings, but typing the map as `Record<number, ...>` documents intent
|
||||
@@ -950,7 +949,7 @@ export const normalize = (state: ParserState, input: Event): NormalizedEvent =>
|
||||
|
||||
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
|
||||
if (!item || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Object.entries(item.summaryParts)
|
||||
@@ -990,7 +989,7 @@ const startReasoningSummaryPart = (state: ParserState, itemID: string, index: nu
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!event.delta || !item?.open) return [state, NO_EVENTS]
|
||||
if (!event.delta || !item) return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
|
||||
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
|
||||
@@ -1015,7 +1014,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
|
||||
// as a single delta unless that summary index already streamed one.
|
||||
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
|
||||
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
|
||||
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
|
||||
@@ -1074,7 +1073,6 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
open: true,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "active" },
|
||||
deltaIndexes: new Set(),
|
||||
@@ -1112,7 +1110,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
|
||||
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item?.open) return [state, NO_EVENTS]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
|
||||
return [
|
||||
{
|
||||
@@ -1247,7 +1245,6 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
}
|
||||
|
||||
if (item.type === "reasoning") {
|
||||
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
|
||||
const summary: Array<string | undefined> = []
|
||||
@@ -1274,53 +1271,14 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
|
||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
|
||||
}
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
...reasoningItem,
|
||||
open: false,
|
||||
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
const reasoningItems = { ...state.reasoningItems }
|
||||
delete reasoningItems[item.id]
|
||||
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
|
||||
}
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(
|
||||
LLMEvent.reasoningEnd({
|
||||
id: item.id,
|
||||
providerMetadata: metadata,
|
||||
text: itemText,
|
||||
}),
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
open: false,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "concluded" },
|
||||
deltaIndexes: new Set(),
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata, text: itemText }))
|
||||
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||
}
|
||||
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
@@ -159,6 +159,17 @@ export const appendOrStart = <K extends StreamKey>(
|
||||
return appendTool(tools, key, tool, delta.text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append argument text to a started tool. Returns `undefined` when no tool is
|
||||
* open under `key`, for protocols that ignore deltas without a matching block.
|
||||
*/
|
||||
export const append = <K extends StreamKey>(tools: State<K>, key: K, text: string): AppendOutcome<K> | undefined => {
|
||||
const current = tools[key]
|
||||
if (!current) return undefined
|
||||
if (text.length === 0) return { tools, tool: current, events: [] }
|
||||
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append argument text to a tool that must already have been started. This keeps
|
||||
* protocols honest when their stream grammar promises a start event before any
|
||||
@@ -170,12 +181,7 @@ export const appendExisting = <K extends StreamKey>(
|
||||
key: K,
|
||||
text: string,
|
||||
missingToolMessage: string,
|
||||
): AppendOutcome<K> | AIError => {
|
||||
const current = tools[key]
|
||||
if (!current) return eventError(route, missingToolMessage)
|
||||
if (text.length === 0) return { tools, tool: current, events: [] }
|
||||
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
|
||||
}
|
||||
): AppendOutcome<K> | AIError => append(tools, key, text) ?? eventError(route, missingToolMessage)
|
||||
|
||||
/**
|
||||
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
||||
|
||||
@@ -713,9 +713,10 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores late tool deltas after contentBlockStop", () =>
|
||||
it.effect("ignores tool deltas without an open tool block", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["contentBlockDelta", { contentBlockIndex: 5, delta: { toolUse: { input: "{}" } } }],
|
||||
[
|
||||
"contentBlockStart",
|
||||
{
|
||||
@@ -745,27 +746,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects tool deltas without contentBlockStart", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Bedrock Converse tool delta is missing its tool call",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers incomplete tool input at finalization", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
|
||||
@@ -71,7 +71,7 @@ function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
|
||||
}
|
||||
|
||||
describe("Open Responses basic-item lifecycles", () => {
|
||||
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
|
||||
it.effect("closes implicit summary boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
|
||||
const events = yield* collect(
|
||||
@@ -90,12 +90,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
delta: "Third",
|
||||
},
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
|
||||
completed,
|
||||
)
|
||||
|
||||
@@ -129,7 +123,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
|
||||
it.effect("preserves done-only reasoning text and encryption", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "reasoning",
|
||||
@@ -139,11 +133,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "late" },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "late final" },
|
||||
completed,
|
||||
// Route termination must also prevent events after response completion.
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_after" } },
|
||||
|
||||
@@ -2861,7 +2861,6 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Think" },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
story(`keeps the Open in border visible without hovering (${theme})`, async ({ mount, page }, testInfo) => {
|
||||
const component = await mount("ui-split-button--open-in", { globals: { theme } })
|
||||
const control = component.locator('[data-component="split-button-v2"]')
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toBeVisible()
|
||||
await expect(control).not.toHaveCSS("box-shadow", "none")
|
||||
const border = await control.evaluate((element) => getComputedStyle(element).boxShadow)
|
||||
|
||||
await component.getByRole("button", { name: "Open options" }).hover()
|
||||
await expect(control).toHaveCSS("box-shadow", border)
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveCSS("box-shadow", border)
|
||||
await control.screenshot({ path: testInfo.outputPath(`open-in-${theme}.png`) })
|
||||
})
|
||||
}
|
||||
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 |
@@ -0,0 +1,57 @@
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "./mock-server"
|
||||
import { APP_READY_TIMEOUT } from "./waits"
|
||||
|
||||
export const paletteSession = {
|
||||
id: "ses_command_palette",
|
||||
projectID: "proj_command_palette",
|
||||
directory: "C:/OpenCode/CommandPalette",
|
||||
title: "Palette fixture session",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
}
|
||||
|
||||
export function captureConsoleWarnings(page: Page) {
|
||||
const warnings: string[] = []
|
||||
page.on("console", (message) => {
|
||||
if (message.type() !== "warning" && message.type() !== "error") return
|
||||
// This message comes from test isolation, not application code.
|
||||
if (message.text() === "Service Worker registration blocked by Playwright") return
|
||||
warnings.push(message.text())
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
export async function openCommandPalette(page: Page, home = false) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: paletteSession.directory,
|
||||
project: {
|
||||
id: paletteSession.projectID,
|
||||
worktree: paletteSession.directory,
|
||||
vcs: "git",
|
||||
name: "command-palette",
|
||||
time: paletteSession.time,
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [paletteSession],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
findFiles: () => [],
|
||||
})
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(home ? "/" : `/server/${base64Encode(server)}/session/${paletteSession.id}`)
|
||||
if (home) {
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }),
|
||||
).toBeEnabled({ timeout: APP_READY_TIMEOUT })
|
||||
}
|
||||
if (!home) {
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable({ timeout: APP_READY_TIMEOUT })
|
||||
}
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
const dialog = page.getByRole("dialog")
|
||||
const input = dialog.getByRole("textbox")
|
||||
await expect(input).toBeFocused()
|
||||
await expect(dialog.getByRole("option")).not.toHaveCount(0)
|
||||
return { dialog, input }
|
||||
}
|
||||
@@ -575,7 +575,7 @@ export function ComposerEditorAddMenu(props: {
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
style={{ "min-width": "180px" }}
|
||||
>
|
||||
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
|
||||
|
||||
@@ -251,6 +251,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
const submission = createComposerSubmit({
|
||||
adapter,
|
||||
mode,
|
||||
commands: () => data.location.command.list({ directory: sdk().directory }),
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
|
||||
@@ -52,10 +52,12 @@ function submitInput(
|
||||
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
mode: () => mode,
|
||||
commands,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory() {},
|
||||
@@ -420,7 +422,6 @@ describe("Composer submission", () => {
|
||||
prompt: async () => undefined,
|
||||
command: async (value) => sent.resolve(value),
|
||||
})
|
||||
target.data.location.command.list = () => [{ name: "review", description: "Review changes", template: "" }]
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
@@ -433,7 +434,7 @@ describe("Composer submission", () => {
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
|
||||
const request = await sent.promise
|
||||
|
||||
expect(request.files).toMatchObject([{ name: "app.ts", mention: { text: "@src/app.ts" } }])
|
||||
@@ -442,6 +443,51 @@ describe("Composer submission", () => {
|
||||
expect(request.delivery).toBe("steer")
|
||||
})
|
||||
|
||||
test("captures commands before creating a session in a new worktree", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/review https://github.com/example/repo/pull/1" }).capture()
|
||||
const catalog = [{ name: "review" }]
|
||||
const sent = Promise.withResolvers<"prompt" | "command">()
|
||||
const requests: Parameters<ComposerSession["api"]["command"]>[0][] = []
|
||||
const target = session({
|
||||
calls: [],
|
||||
prompt: async () => sent.resolve("prompt"),
|
||||
command: async (value) => {
|
||||
requests.push(value)
|
||||
sent.resolve("command")
|
||||
},
|
||||
})
|
||||
target.directory = "C:/new-worktree"
|
||||
target.data.location.command.list = () => undefined
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start() {
|
||||
// The destination catalog has not loaded, and the source composer is leaving.
|
||||
catalog.splice(0)
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
},
|
||||
}
|
||||
|
||||
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
|
||||
|
||||
expect(await sent.promise).toBe("command")
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
sessionID: target.id,
|
||||
command: "review",
|
||||
text: "https://github.com/example/repo/pull/1",
|
||||
files: [],
|
||||
agents: [],
|
||||
skills: [],
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("does not run an empty shell command from hidden attachments", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
state.set([
|
||||
|
||||
@@ -27,6 +27,7 @@ type ComposerSubmission = {
|
||||
type ComposerSubmitInput = {
|
||||
adapter: ComposerAdapter
|
||||
mode: Accessor<"normal" | "shell">
|
||||
commands: Accessor<readonly { name: string }[] | undefined>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
@@ -65,6 +66,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
submitting.add(input.adapter.state)
|
||||
const comments = input.comments.capture()
|
||||
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
|
||||
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
|
||||
|
||||
try {
|
||||
const started =
|
||||
@@ -78,7 +81,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
input.resetHistory()
|
||||
const restore = () => restoreSubmission(input, submission, value, comments)
|
||||
|
||||
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
@@ -277,12 +279,11 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
|
||||
await session.api.shell({ sessionID: session.id, id: Event.ID.create(), command: value.text })
|
||||
}
|
||||
|
||||
function findCommand(session: ComposerSession, text: string) {
|
||||
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
|
||||
if (!text.startsWith("/")) return
|
||||
const [name, ...arguments_] = text.split(" ")
|
||||
const command = name.slice(1)
|
||||
if (!session.data.location.command.list({ directory: session.directory })?.some((item) => item.name === command))
|
||||
return
|
||||
if (!commands?.some((item) => item.name === command)) return
|
||||
return { command, arguments: arguments_.join(" ") }
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +52,9 @@ export function HomeCommandPalette(props: {
|
||||
}
|
||||
if (item.type === "session") props.onSelectSession(item)
|
||||
}
|
||||
const loadItems = async (text: string) => {
|
||||
const query = text.trim()
|
||||
const items = (query: string) => {
|
||||
if (!query) return commandEntries().slice(0, 5)
|
||||
return [...commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)), ...(await sessions(query))]
|
||||
return commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query))
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -66,7 +65,8 @@ export function HomeCommandPalette(props: {
|
||||
return (
|
||||
<CommandPaletteView
|
||||
placeholder={language.t("palette.search.placeholder.home")}
|
||||
loadItems={loadItems}
|
||||
items={items}
|
||||
sources={[sessions]}
|
||||
highlight={highlight}
|
||||
select={select}
|
||||
close={() => dialog.close()}
|
||||
|
||||
@@ -812,6 +812,10 @@ export const dict = {
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
"titlebar.channel.local": "Local",
|
||||
"titlebar.channel.dev": "Dev",
|
||||
"titlebar.channel.beta": "Beta",
|
||||
"titlebar.toggleDebugTools": "Toggle debug tools",
|
||||
"titlebar.updateVersion": "Update {{version}}",
|
||||
|
||||
"common.closeTab": "Close tab",
|
||||
@@ -908,6 +912,25 @@ export const dict = {
|
||||
"settings.tab.notifications": "Notifications",
|
||||
"settings.tab.projects": "Projects",
|
||||
"settings.tab.extensions": "Extensions",
|
||||
"settings.tab.experimental": "Experimental",
|
||||
"settings.experimental.description": "Try experimental features",
|
||||
"settings.tab.about": "About",
|
||||
"settings.about.version": "Version {{version}}",
|
||||
"settings.about.devVersion": "development",
|
||||
"settings.about.license": "Released under the MIT License",
|
||||
"settings.about.writtenBy": "Written by",
|
||||
"settings.about.illustratedBy": "Illustrated by",
|
||||
"settings.about.and": "and",
|
||||
"settings.about.otherContributor.one": "{{count}} other",
|
||||
"settings.about.otherContributor.other": "{{count}} others",
|
||||
"settings.about.firstPublished": "First published in Missouri, USA",
|
||||
"settings.about.firstIllustrated": "First illustrated in London, England",
|
||||
"settings.about.website": "www.opencode.ai",
|
||||
"settings.about.description": "OpenCode, the open source coding agent",
|
||||
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
|
||||
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
|
||||
"settings.about.tagline": "AI can’t build great software, without you",
|
||||
"settings.about.copyright": "© Anomaly Innovations, Inc.",
|
||||
"settings.preferences.description": "Customize preferences and theme and default behavior",
|
||||
"settings.appearance.description": "Customize theme and fonts",
|
||||
"settings.appearance.section.experimental": "Experimental",
|
||||
@@ -935,6 +958,7 @@ export const dict = {
|
||||
"settings.desktop.wsl.title": "WSL integration",
|
||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||
"dialog.server.authenticate.title": "Authenticate",
|
||||
"project.settings.title": "Edit project",
|
||||
"project.settings.general.description": "Manage project name and appearance",
|
||||
"project.settings.scripts": "Scripts",
|
||||
"project.settings.scripts.description": "Configure scripts for this project",
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export function SortableTab(props: {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.tab
|
||||
|
||||
@@ -3,6 +3,28 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
|
||||
export function SessionReviewToggle() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const { view } = useSessionLayout()
|
||||
|
||||
return (
|
||||
<SessionHeaderActions
|
||||
state={{
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: true,
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type SessionHeaderActionsState = {
|
||||
reviewLabel: string
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
|
||||
|
||||
export function SessionHeader() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const { view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const actions = createMemo<SessionHeaderActionsState>(() => ({
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitlebarRight>
|
||||
@@ -35,7 +23,9 @@ export function SessionHeader() {
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</TitlebarRight>
|
||||
<SessionHeaderActions state={actions()} />
|
||||
<Show when={isDesktop() && !view().reviewPanel.opened()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { SessionContextTab } from "./files/session-context-tab"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
@@ -274,10 +275,19 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
{/* Keep the control outside panel animations; the terminal's 52px header includes a 1px divider. */}
|
||||
<Show when={isDesktop() && messagesReady() && session.identity.params.id}>
|
||||
<div
|
||||
class="absolute end-3 top-0 z-30 flex items-center"
|
||||
classList={{ "h-[51px]": sideTerminalVisible(), "h-12": !sideTerminalVisible() }}
|
||||
data-slot="session-review-toggle"
|
||||
>
|
||||
<SessionReviewToggle />
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]":
|
||||
true,
|
||||
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!screen.size.active() && sidePresence.animate(),
|
||||
"transition-none": screen.size.active() || !sidePresence.animate(),
|
||||
@@ -408,6 +418,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
present={store.sideTerminalPresent}
|
||||
animate={sidePresence.animate() || sideMotion().animateTerminal}
|
||||
contentHeight={screen.side.terminal.contentHeight()}
|
||||
reserveReviewToggle={!screen.side.region.open()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Informational tooltips must not block menu items. Kobalte sets pointer events inline. */
|
||||
[data-popper-positioner]:has(.session-project-info-tooltip),
|
||||
.session-project-info-tooltip {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.session-options-menu [data-component="menu-v2-item"] {
|
||||
padding-inline-end: 30px;
|
||||
}
|
||||
|
||||
.session-project-link {
|
||||
--session-project-fade-direction: to right;
|
||||
position: relative;
|
||||
|
||||
&:dir(rtl) {
|
||||
--session-project-fade-direction: to left;
|
||||
}
|
||||
|
||||
&[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.session-project-link-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-project-link-open {
|
||||
position: absolute;
|
||||
inset-inline-end: 12px;
|
||||
inset-block: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.session-project-link:is(:hover, [data-highlighted]):not([aria-disabled="true"]) {
|
||||
.session-project-link-content {
|
||||
mask-image: linear-gradient(
|
||||
var(--session-project-fade-direction),
|
||||
#000 calc(100% - 40px),
|
||||
transparent calc(100% - 24px)
|
||||
);
|
||||
}
|
||||
|
||||
.session-project-link-open {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
|
||||
import { tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { sessionTitle } from "./title"
|
||||
import "./session-identity-header.css"
|
||||
|
||||
export function SessionTitleHeader(props: ParentProps) {
|
||||
return (
|
||||
@@ -25,6 +34,173 @@ export function SessionTitleHeader(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionProjectMenu(props: {
|
||||
project?: Omit<LocalProject, "expanded">
|
||||
directory?: string
|
||||
workspace: boolean
|
||||
showProjectIcon: boolean
|
||||
}) {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const platform = usePlatform()
|
||||
const layout = useLayout()
|
||||
const navigate = useNavigate()
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
projectTruncated: false,
|
||||
pathTruncated: false,
|
||||
pathFocused: false,
|
||||
})
|
||||
const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
|
||||
const canOpenPath = () =>
|
||||
platform.platform === "desktop" && !!platform.openPath && server.isLocal && !!props.directory
|
||||
const openPath = () => {
|
||||
if (!canOpenPath() || !platform.openPath || !props.directory) return
|
||||
void platform.openPath(props.directory).catch((cause: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const openProjectSettings = async () => {
|
||||
const current = props.project
|
||||
if (!current) return
|
||||
const { DialogEditProject } = await import("@/settings/workspaces/project-dialog")
|
||||
dialog.push(() => <DialogEditProject project={{ expanded: false, ...current }} server={server.conn} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
shift={-10}
|
||||
modal={false}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState({ open, pathFocused: false })}
|
||||
>
|
||||
<Tooltip placement="bottom" value={<bdi>{projectName()}</bdi>} class="flex shrink-0">
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
variant="ghost-muted"
|
||||
aria-label={projectName()}
|
||||
data-slot="session-project-trigger"
|
||||
icon={
|
||||
<Show
|
||||
when={props.showProjectIcon}
|
||||
fallback={
|
||||
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
|
||||
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={projectName()}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-max max-w-[min(320px,calc(100vw-16px))]" aria-label={projectName()}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
gutter={2}
|
||||
disabled={!state.projectTruncated}
|
||||
class="min-w-0 cursor-default"
|
||||
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
|
||||
value={<bdi>{projectName()}</bdi>}
|
||||
>
|
||||
<Menu.Item
|
||||
class="min-w-0 w-full"
|
||||
disabled={!props.project}
|
||||
onSelect={() => {
|
||||
const project = props.project
|
||||
if (!project) return
|
||||
server.ctx.projects.open(project.worktree)
|
||||
layout.home.setSelection({ server: server.key, directory: project.worktree })
|
||||
navigate("/")
|
||||
}}
|
||||
>
|
||||
<span class="session-project-link-content">
|
||||
<ProjectAvatar
|
||||
class="shrink-0"
|
||||
aria-hidden="true"
|
||||
fallback={projectName()}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
/>
|
||||
<bdi
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, () =>
|
||||
setState("projectTruncated", element.scrollWidth > element.clientWidth),
|
||||
)
|
||||
}
|
||||
class="min-w-0 truncate text-13-medium"
|
||||
>
|
||||
{projectName()}
|
||||
</bdi>
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
gutter={2}
|
||||
disabled={!state.pathTruncated}
|
||||
forceOpen={state.pathFocused && state.pathTruncated ? true : undefined}
|
||||
class="min-w-0 cursor-default"
|
||||
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
|
||||
value={<bdi dir="ltr">{props.directory}</bdi>}
|
||||
>
|
||||
{/* Read-only paths stay in keyboard navigation so their full tooltip remains accessible. */}
|
||||
<Menu.Item
|
||||
class="session-project-link min-w-0 w-full cursor-default"
|
||||
disabled={!props.directory}
|
||||
aria-disabled={!canOpenPath()}
|
||||
closeOnSelect={canOpenPath()}
|
||||
onSelect={openPath}
|
||||
onFocus={() => setState("pathFocused", true)}
|
||||
onBlur={() => setState("pathFocused", false)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setState({ open: false, pathFocused: false })
|
||||
}}
|
||||
>
|
||||
<span class="session-project-link-content">
|
||||
<Icon name="folder" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<bdi
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, () =>
|
||||
setState("pathTruncated", element.scrollWidth > element.clientWidth),
|
||||
)
|
||||
}
|
||||
dir="ltr"
|
||||
class="min-w-0 truncate text-v2-text-text-muted"
|
||||
>
|
||||
{props.directory}
|
||||
</bdi>
|
||||
</span>
|
||||
<span data-slot="session-project-open-icon" class="session-project-link-open" aria-hidden="true">
|
||||
<Icon name="arrow-up-right" />
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
<Menu.Separator />
|
||||
<Menu.Item disabled={!props.project} onSelect={() => void openProjectSettings()}>
|
||||
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
|
||||
{language.t("project.settings.title")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
@@ -71,12 +247,17 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
)
|
||||
const project = createMemo(() => {
|
||||
const projects = server.ctx.projects.list()
|
||||
if (props.session) return projectForSession(props.session, projects)
|
||||
if (props.session)
|
||||
return (
|
||||
projectForSession(props.session, projects) ?? projectForSession(props.session, server.ctx.sync.data.project)
|
||||
)
|
||||
const value = directory()
|
||||
if (!value) return undefined
|
||||
const key = pathKey(value)
|
||||
return projects.find(
|
||||
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
return (
|
||||
projects.find(
|
||||
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
) ?? server.ctx.sync.data.project.find((item) => isProjectDirectory(item, value))
|
||||
)
|
||||
})
|
||||
const showProjectIcon = () =>
|
||||
@@ -95,25 +276,13 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
<SessionTitleHeader>
|
||||
<div class="flex h-12 w-full items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<div class="flex min-w-0 w-full flex-1 items-center">
|
||||
<span
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": workspaceSession() && !showProjectIcon(),
|
||||
"text-v2-icon-icon-muted": !workspaceSession() && !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={showProjectIcon()}
|
||||
fallback={<Icon name={workspaceSession() ? "workspace-isolated" : "monitor"} />}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(project() ?? { worktree: directory() ?? "" })}
|
||||
src={getProjectAvatarSource(project()?.id, project()?.icon)}
|
||||
variant={getProjectAvatarVariant(project()?.icon?.color)}
|
||||
/>
|
||||
</Show>
|
||||
</span>
|
||||
<div class="flex min-w-0 w-full flex-1 items-center gap-0.5">
|
||||
<SessionProjectMenu
|
||||
project={project()}
|
||||
directory={directory()}
|
||||
workspace={workspaceSession()}
|
||||
showProjectIcon={showProjectIcon()}
|
||||
/>
|
||||
<Show when={parentTitle()}>
|
||||
{(value) => (
|
||||
<button
|
||||
@@ -141,7 +310,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
<h1
|
||||
data-slot={parentID() ? "session-title-child" : undefined}
|
||||
dir="auto"
|
||||
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
|
||||
class="w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
|
||||
>
|
||||
{value()}
|
||||
</h1>
|
||||
|
||||
@@ -45,6 +45,7 @@ export function TerminalPanel(
|
||||
contentHeight?: string
|
||||
embedded?: boolean
|
||||
animate?: boolean
|
||||
reserveReviewToggle?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const terminal = useTerminal()
|
||||
@@ -249,7 +250,10 @@ export function TerminalPanel(
|
||||
when={terminal.ready() || store.surfaces.length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
|
||||
<div
|
||||
class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden"
|
||||
classList={{ "pe-12": props.reserveReviewToggle }}
|
||||
>
|
||||
<For each={handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
@@ -291,46 +295,53 @@ export function TerminalPanel(
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
variant="panel"
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-[52px] !flex-none"
|
||||
>
|
||||
<Tabs.List
|
||||
ref={tabList}
|
||||
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const active = document.activeElement
|
||||
if (event.target === active) return
|
||||
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
|
||||
}}
|
||||
<div class="h-[52px] shrink-0 flex border-b border-border-weaker-base">
|
||||
<Tabs
|
||||
variant="panel"
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-full min-w-0 !flex-1"
|
||||
>
|
||||
<For each={all()}>
|
||||
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
|
||||
</For>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.terminal.new")}
|
||||
<Show when={newTerminalKeybind().length > 0}>
|
||||
<Keybind keys={newTerminalKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" size="large" />}
|
||||
variant="ghost"
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Tabs.List
|
||||
ref={tabList}
|
||||
class="!border-b-0"
|
||||
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const active = document.activeElement
|
||||
if (event.target === active) return
|
||||
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
|
||||
}}
|
||||
>
|
||||
<For each={all()}>
|
||||
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
|
||||
</For>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.terminal.new")}
|
||||
<Show when={newTerminalKeybind().length > 0}>
|
||||
<Keybind keys={newTerminalKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" size="large" />}
|
||||
variant="ghost"
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
{/* Reserve outside the scroll viewport so overflowing tabs cannot cover the toggle. */}
|
||||
<Show when={props.reserveReviewToggle}>
|
||||
<div class="w-12 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<For each={store.surfaces}>
|
||||
{(surface) => (
|
||||
|
||||
@@ -10,7 +10,6 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
@@ -31,7 +30,7 @@ import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
|
||||
type BackgroundTask = {
|
||||
@@ -409,10 +408,9 @@ function MessageTimelineView(
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
|
||||
const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
|
||||
const avatarProject = createMemo(() => {
|
||||
if (!showProjectIcon()) return
|
||||
const session = props.session.data.info()
|
||||
if (!session) return
|
||||
return projectForSession(session, server.ctx.projects.list())
|
||||
return projectForSession(session, server.ctx.projects.list()) ?? project()
|
||||
})
|
||||
const projectAvatar = () => (
|
||||
<ProjectAvatar
|
||||
@@ -666,36 +664,13 @@ function MessageTimelineView(
|
||||
<SessionTitleHeader>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
placement="bottom-start"
|
||||
value={sessionDirectory()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<div class="flex items-center gap-0.5 min-w-0 flex-1 w-full">
|
||||
<SessionProjectMenu
|
||||
project={avatarProject()}
|
||||
directory={sessionDirectory()}
|
||||
workspace={workspaceSession()}
|
||||
showProjectIcon={showProjectIcon()}
|
||||
/>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -719,7 +694,7 @@ function MessageTimelineView(
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-1 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
@@ -734,7 +709,7 @@ function MessageTimelineView(
|
||||
dir="auto"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content self-start rounded-[6px] px-2 py-1"
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content rounded-[6px] px-1 py-1"
|
||||
style={{
|
||||
"--inline-input-shadow": "none",
|
||||
"text-align": "start",
|
||||
@@ -761,7 +736,7 @@ function MessageTimelineView(
|
||||
{(id) => (
|
||||
<Menu
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
placement="bottom-start"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
@@ -776,7 +751,8 @@ function MessageTimelineView(
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
style={{ "min-width": "160px" }}
|
||||
class="session-options-menu w-max"
|
||||
style={{ "min-width": "0" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { For, createResource, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import legal from "./legal.svg"
|
||||
import anomalyBrush from "./anomaly-brush.svg"
|
||||
import { AnimatedWordmark } from "./animated-wordmark"
|
||||
import { FALLBACK_OTHER_CONTRIBUTORS, loadOtherContributorCount } from "./contributors"
|
||||
|
||||
const writers = [
|
||||
"thdxr",
|
||||
"adamdotdev",
|
||||
"rekram1-node",
|
||||
"kitlangton",
|
||||
"iamdavidhill",
|
||||
"jayair",
|
||||
"fwang",
|
||||
"brendonovich",
|
||||
"nexxeln",
|
||||
"hona",
|
||||
"kommander",
|
||||
"jlongster",
|
||||
"vimtor",
|
||||
"r44vcorp",
|
||||
"simonklee",
|
||||
"arvsrn",
|
||||
] as const
|
||||
const illustrators = ["usrnk1", "ludvigrask_", "arvsrn", "iamdavidhill"] as const
|
||||
|
||||
export function SettingsAbout(props: { active: boolean }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [otherContributors] = createResource(
|
||||
() => props.active || undefined,
|
||||
() => loadOtherContributorCount(platform.fetch ?? fetch),
|
||||
{ initialValue: FALLBACK_OTHER_CONTRIBUTORS },
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="settings-about-content">
|
||||
<div class="settings-about-intro">
|
||||
<p>{language.t("settings.about.version", { version: platform.version ?? language.t("settings.about.devVersion") })}</p>
|
||||
<p>{language.t("settings.about.license")}</p>
|
||||
</div>
|
||||
|
||||
<AnimatedWordmark active={props.active} />
|
||||
|
||||
<div class="settings-about-credits">
|
||||
<CreditLine
|
||||
label={language.t("settings.about.writtenBy")}
|
||||
names={writers}
|
||||
and={language.t("settings.about.and")}
|
||||
tail={
|
||||
<ExternalLink href="https://github.com/anomalyco/opencode/graphs/contributors">
|
||||
{language.plural("settings.about.otherContributor", otherContributors.latest, {
|
||||
count: otherContributors.latest,
|
||||
})}
|
||||
</ExternalLink>
|
||||
}
|
||||
/>
|
||||
<CreditLine
|
||||
label={language.t("settings.about.illustratedBy")}
|
||||
names={illustrators}
|
||||
and={language.t("settings.about.and")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-about-publication">
|
||||
<p>{language.t("settings.about.firstPublished")}</p>
|
||||
<p>{language.t("settings.about.firstIllustrated")}</p>
|
||||
</div>
|
||||
|
||||
<p class="settings-about-faint">
|
||||
<ExternalLink href="https://opencode.ai">
|
||||
<bdi dir="ltr">{language.t("settings.about.website")}</bdi>
|
||||
</ExternalLink>
|
||||
</p>
|
||||
|
||||
<div class="settings-about-details">
|
||||
<p>{language.t("settings.about.description")}</p>
|
||||
<p>{language.t("settings.about.trademark")}</p>
|
||||
<p>{language.t("settings.about.typeset")}</p>
|
||||
</div>
|
||||
|
||||
<p>{language.t("settings.about.tagline")}</p>
|
||||
<img class="settings-about-legal" src={legal} alt="" />
|
||||
<div class="settings-about-copyright">
|
||||
<p>{language.t("settings.about.copyright")}</p>
|
||||
<img class="settings-about-anomaly-brush" src={anomalyBrush} alt="" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreditLine(props: {
|
||||
label: string
|
||||
names: readonly string[]
|
||||
and: string
|
||||
tail?: JSX.Element
|
||||
}) {
|
||||
return (
|
||||
<p>
|
||||
{props.label}{" "}
|
||||
<For each={props.names}>
|
||||
{(name, index) => (
|
||||
<>
|
||||
{index() === 0 ? "" : index() === props.names.length - 1 && !props.tail ? `, ${props.and} ` : ", "}
|
||||
<bdi dir="ltr">
|
||||
<ExternalLink href={profile(name)}>{name}</ExternalLink>
|
||||
</bdi>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
{props.tail ? <>, {props.and} {props.tail}</> : null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function profile(name: string) {
|
||||
if (name === "r44vcorp") return "https://github.com/R44VC0RP"
|
||||
if (name === "ludvigrask_") return "https://x.com/ludvigrask_"
|
||||
return `https://github.com/${name}`
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createEffect, For, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const target = ["o", "p", "e", "n", "c", "o", "d", "e"] as const
|
||||
const choices = ["o", "p", "e", "n", "c", "d"] as const
|
||||
|
||||
export function AnimatedWordmark(props: { active: boolean }) {
|
||||
const [state, setState] = createStore({ letters: [...target] })
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>()
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.active,
|
||||
(active) => {
|
||||
timers.forEach(clearTimeout)
|
||||
timers.clear()
|
||||
if (!active || matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setState("letters", [...target])
|
||||
return
|
||||
}
|
||||
|
||||
const starts = target.map(() => choices[Math.floor(Math.random() * choices.length)])
|
||||
const settles = target.map(() => 6 + Math.floor(Math.random() * 8))
|
||||
const last = Math.max(...settles)
|
||||
setState("letters", starts)
|
||||
|
||||
Array.from({ length: last }, (_, index) => index + 1).forEach((tick) => {
|
||||
const timer = setTimeout(() => {
|
||||
setState(
|
||||
"letters",
|
||||
target.map((letter, index) =>
|
||||
tick >= settles[index] ? letter : choices[Math.floor(Math.random() * choices.length)],
|
||||
),
|
||||
)
|
||||
timers.delete(timer)
|
||||
}, tick * 75)
|
||||
timers.add(timer)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => timers.forEach(clearTimeout))
|
||||
|
||||
return (
|
||||
<svg class="settings-about-wordmark" viewBox="0 0 234 42" aria-hidden="true">
|
||||
<defs>
|
||||
<symbol id="settings-about-letter-o" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-p" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M6 30H18V12H6V30ZM24 36H6V42H0V6H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-e" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M24 24V30H6V24H24Z" />
|
||||
<path d="M24 24H6V30H24V36H0V6H24V24ZM6 18H18V12H6V18Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-n" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 36H6V18H18V36Z" />
|
||||
<path d="M18 12H6V36H0V6H18V12ZM24 36H18V12H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-c" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M24 30H6V18H24V30Z" />
|
||||
<path d="M24 12H6V30H24V36H0V6H24V12Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-d" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H18V0H24V36Z" />
|
||||
</symbol>
|
||||
</defs>
|
||||
<For each={state.letters}>
|
||||
{(letter, index) => (
|
||||
<use href={`#settings-about-letter-${letter}`} x={index() * 30} width="24" height="42" />
|
||||
)}
|
||||
</For>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { FALLBACK_OTHER_CONTRIBUTORS, otherContributorCount } from "./contributors"
|
||||
|
||||
describe("otherContributorCount", () => {
|
||||
test("subtracts the contributors named in the colophon", () => {
|
||||
expect(
|
||||
otherContributorCount(
|
||||
'<https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=2>; rel="next", <https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=1004>; rel="last"',
|
||||
),
|
||||
).toBe(988)
|
||||
})
|
||||
|
||||
test("falls back when pagination metadata is unavailable", () => {
|
||||
expect(otherContributorCount(null)).toBe(FALLBACK_OTHER_CONTRIBUTORS)
|
||||
expect(otherContributorCount("invalid")).toBe(FALLBACK_OTHER_CONTRIBUTORS)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
const CREDITED_CONTRIBUTORS = 16
|
||||
let request: Promise<number> | undefined
|
||||
|
||||
export const FALLBACK_OTHER_CONTRIBUTORS = 935
|
||||
|
||||
export function otherContributorCount(link: string | null) {
|
||||
const total = Number.parseInt(link?.match(/[?&]page=(\d+)[^>]*>;\s*rel="last"/)?.[1] ?? "", 10)
|
||||
if (!Number.isFinite(total) || total <= CREDITED_CONTRIBUTORS) return FALLBACK_OTHER_CONTRIBUTORS
|
||||
return total - CREDITED_CONTRIBUTORS
|
||||
}
|
||||
|
||||
export function loadOtherContributorCount(fetcher: typeof fetch) {
|
||||
request ??= fetcher("https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}).then(
|
||||
(response) => (response.ok ? otherContributorCount(response.headers.get("Link")) : FALLBACK_OTHER_CONTRIBUTORS),
|
||||
() => FALLBACK_OTHER_CONTRIBUTORS,
|
||||
)
|
||||
return request
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="91" height="16" viewBox="0 0 91 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity=".5" fill="#808080">
|
||||
<path d="M22.463 16H11.231c6.203 0 11.232-3.582 11.232-8s-5.029-8-11.232-8S0 3.582 0 8s5.028 8 11.231 8H0V0h22.463v16Z"/>
|
||||
<path d="M13.672 4.799h5.354v.988h-2.102v5.54h-1.158v-5.54h-2.094v-.988ZM11.724 4.799h1.158v6.528h-1.158V4.799ZM3.474 4.799h1.63l1.825 5.11h.018l1.779-5.11h1.612v6.528H9.235V6.289h-.018l-1.834 5.038h-.954L4.595 6.289h-.019v5.038H3.474V4.799Z"/>
|
||||
<path fill-rule="evenodd" d="M50.363 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm-1.938 1.04a6.615 6.615 0 0 0-3.338 2.34c.483.014.783.079.783.37 0 .375.375.625 1.123-.625.543-.906 1.02-1.681 1.432-2.086Zm8.547 6.585h-2.241c-.999 0-1.123 1-1.623 1.75-.499.75-.748 1.5-.748 2.625s-.749 1.25-1.124 1.375c-.374.125-.623-.375-1.123-.75-.499-.375-.25-2.375-.25-2.75s0-.625-.374-.75c-.374-.125-1.997-.375-2.121-.75-.125-.375 0-.75.748-1.75.44-.625 1.123-.25 1.373-.25.25 0 .999.25 1.498.25.499 0 .624.25 1.373.25.748 0 .25-.25.25-1 0-.75-.5-.375-.999-.25-.499.125-.624.25-1.747.25s-.25-.375.25-.625c.498-.25.498-.25.873-.875.374-.625 0-.25-.624-.375-.624-.125-.25.375-.874.375s-.124-.625.375-1c.499-.375.374-.5.499-1.25.125-.75.25-.25.873-.5.624-.25.874 0 1.123-.25.088-.088.098-.191.092-.288a6.616 6.616 0 0 0-8.702 6.288A6.614 6.614 0 0 0 50.363 15c3.569 0 6.478-2.832 6.61-6.375Z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M66.836 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm2.496 3.5c-.624 0-.999 1.125-1.623 1.75-.624.625-.499 1.375-.25 2.375.25 1-.623 1.625-1.372 1.625s-.874-.375-1.248-.75c-.375-.375-1.248-.375-2.247-.25-.998.125-.624 1.625-.748 2.375-.125.75-.624-.25-.749-1-.079-.475-.459-1-.822-1.417A6.614 6.614 0 0 0 66.836 15a6.59 6.59 0 0 0 2.518-.498c-.378-.049-.972-.333-1.395-.502-.624-.25-.874 0-1.622-.5-.749-.5-.25-.875-.25-1.875.25-.625.998-.25 1.373-.25h2.246c.874 0 .874 1.25.874 1.875 0 .604-.7.741-.86 1.088a6.625 6.625 0 0 0 2.489-9.827A8.822 8.822 0 0 0 71.952 4.5h-2.62Z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M83.308 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm.656.782c-.752.31-.387 1.473.093 1.593.5.125.5-.625.874-.625s1.123.375 1.123.625-.25.375-.5.75c-.249.375.375.375 1.124 0 .748-.375.624.375.499 1.25s-.749 1-1.622 1h-1.498c-.328 0-.391 1.633-.25 1.75.141.117.345.375.5.375h1.871c.624 0 .749.25 1.248.375s.375.75.5 1.125c.124.375-.375.75-.75 1.25-.374.5-.748 1-.998 1.375-.25.375-.374.875-.374 1.375s-.375.5-.624.5c-.25 0-.374-.125-.374-.625s-.125-.625-.25-1.375c-.125-.75-.374-1.375-.374-1.75 0-.375-.25-.25-.669-.25s-.454-.125-.454-.75.809-.305.873-.75c.014-.097 0-.25 0-.25 0-.279-.25-.375-.419-.468-.17-.093-.598-.214-.704-.532-.125-.375-.5-.625-1.248-.625-.749 0-.998-.125-.998-.75V4.5c0-.875-.5-.5-1.498-.75.076-.114.282-.435.544-.868A6.614 6.614 0 1 0 83.964 1.782Z" clip-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -1,7 +1,6 @@
|
||||
import { Component, createMemo } from "solid-js"
|
||||
import { Component } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
@@ -10,7 +9,6 @@ import { createAppearanceSettingsController, type AppearanceSettingsController }
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
|
||||
const fontSettings = {
|
||||
ui: {
|
||||
action: "settings-ui-font",
|
||||
@@ -128,44 +126,6 @@ export const SettingsAppearance: Component = () => {
|
||||
<FontSetting kind="terminal" fonts={appearance.fonts} />
|
||||
</SettingsList>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.appearance.section.experimental")}</h3>
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.tabs.title")}
|
||||
description={language.t("settings.appearance.row.tabs.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-tab-layout"
|
||||
options={tabLayoutOptions}
|
||||
current={tabLayoutOptions.find((option) => option === appearance.tabs.current())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
label={(option) =>
|
||||
option === "horizontal"
|
||||
? language.t("settings.appearance.row.tabs.horizontal")
|
||||
: language.t("settings.appearance.row.tabs.vertical")
|
||||
}
|
||||
onSelect={(option) => option && appearance.tabs.select(option)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.projectName.title")}
|
||||
description={language.t("settings.appearance.row.projectName.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-name">
|
||||
<Switch
|
||||
checked={appearance.projectName.current()}
|
||||
onChange={appearance.projectName.set}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.appearance.row.projectName.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
|
||||
|
||||
export const SettingsExperimental: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.experimental")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">
|
||||
{language.t("settings.experimental.description")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-section">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.tabs.title")}
|
||||
description={language.t("settings.appearance.row.tabs.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-tab-layout"
|
||||
options={tabLayoutOptions}
|
||||
current={tabLayoutOptions.find((option) => option === settings.appearance.tabLayout())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
label={(option) =>
|
||||
option === "horizontal"
|
||||
? language.t("settings.appearance.row.tabs.horizontal")
|
||||
: language.t("settings.appearance.row.tabs.vertical")
|
||||
}
|
||||
onSelect={(option) => option && settings.appearance.setTabLayout(option)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.projectName.title")}
|
||||
description={language.t("settings.appearance.row.projectName.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-name">
|
||||
<Switch
|
||||
checked={settings.appearance.showProjectName()}
|
||||
onChange={settings.appearance.setShowProjectName}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.appearance.row.projectName.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -81,14 +81,6 @@ export function createAppearanceSettingsController() {
|
||||
setCode: (value: string) => settings.appearance.setFont(value),
|
||||
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
|
||||
},
|
||||
tabs: {
|
||||
current: settings.appearance.tabLayout,
|
||||
select: settings.appearance.setTabLayout,
|
||||
},
|
||||
projectName: {
|
||||
current: settings.appearance.showProjectName,
|
||||
set: settings.appearance.setShowProjectName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
|
||||
.settings-screen .settings-tab-body {
|
||||
padding-inline: 0;
|
||||
padding-bottom: var(--settings-bottom-inset, 0px);
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
@@ -91,19 +90,101 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-nav-footer {
|
||||
.settings-about {
|
||||
align-items: center;
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
.settings-about-content {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
min-height: 462px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-block: 20px 4px;
|
||||
padding-inline-start: 4px;
|
||||
font-size: 11px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 24px;
|
||||
padding-block: 80px calc(80px + var(--settings-bottom-inset, 0px));
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-tight);
|
||||
line-height: var(--line-height-base);
|
||||
letter-spacing: -0.04px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-about-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-about-intro,
|
||||
.settings-about-credits,
|
||||
.settings-about-publication,
|
||||
.settings-about-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-about-intro {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-about-credits,
|
||||
.settings-about-publication,
|
||||
.settings-about-details {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-about-wordmark {
|
||||
width: 178px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.2;
|
||||
color: var(--v2-text-text-muted);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.settings-about-letter-shadow {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.settings-about-faint,
|
||||
.settings-about-credits {
|
||||
color: var(--v2-text-text-faint);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-about-legal {
|
||||
width: 109.2px;
|
||||
height: 19.2px;
|
||||
}
|
||||
|
||||
.settings-about-copyright {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-about-anomaly-brush {
|
||||
width: 126.72px;
|
||||
height: 48.384px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settings-about-content a {
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.settings-about-content a:hover,
|
||||
.settings-about-content a:focus-visible {
|
||||
color: var(--v2-text-text-base);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.settings-back {
|
||||
@@ -192,7 +273,7 @@
|
||||
flex-direction: column;
|
||||
gap: 36px;
|
||||
width: 100%;
|
||||
padding: 0 40px 40px;
|
||||
padding: 0 40px calc(80px + var(--settings-bottom-inset, 0px));
|
||||
}
|
||||
|
||||
[data-slot="settings-row-description"] a.settings-link {
|
||||
@@ -1089,7 +1170,7 @@
|
||||
}
|
||||
|
||||
.settings-tab-body.settings-workspaces {
|
||||
padding: 0 20px 24px;
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
.settings-workspaces-toolbar,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Component, createEffect, createMemo, For, Show, onCleanup, onMount, startTransition } from "solid-js"
|
||||
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { SettingsGeneral } from "./general/general"
|
||||
import { SettingsAppearance } from "./appearance/appearance"
|
||||
import { SettingsExperimental } from "./experimental/experimental"
|
||||
import { SettingsKeybinds } from "./keybinds/keybinds"
|
||||
import { SettingsNotifications } from "./notifications/notifications"
|
||||
import { SettingsProviders } from "./providers/providers"
|
||||
@@ -15,13 +15,13 @@ import { SettingsServers } from "./servers/servers"
|
||||
import { SettingsWorkspaces } from "./workspaces/workspaces"
|
||||
import { SettingsProjects } from "./workspaces/projects"
|
||||
import { SettingsExtensions } from "./providers/extensions"
|
||||
import { SettingsAbout } from "./about/about"
|
||||
import { SettingsServerScope } from "./server-scope"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettingsSurface } from "./surface"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
@@ -42,13 +42,13 @@ const sections = [
|
||||
{ value: "models", icon: "models", label: "settings.models.title" },
|
||||
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
|
||||
],
|
||||
[{ value: "experimental", icon: "flask", label: "settings.tab.experimental" }],
|
||||
[{ value: "about", icon: "info", label: "settings.tab.about" }],
|
||||
] as const
|
||||
|
||||
export const SettingsScreen: Component = () => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const surface = useSettingsSurface()
|
||||
const layout = useLayout()
|
||||
const servers = useServers()
|
||||
@@ -57,10 +57,8 @@ export const SettingsScreen: Component = () => {
|
||||
let root: HTMLDivElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
command.keybinds(false)
|
||||
root?.focus({ preventScroll: true })
|
||||
})
|
||||
onCleanup(() => command.keybinds(true))
|
||||
|
||||
const server = createMemo(() => {
|
||||
const route = surface.route()
|
||||
@@ -185,12 +183,6 @@ export const SettingsScreen: Component = () => {
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-nav-footer">
|
||||
<span>{language.t("app.name.desktop")}</span>
|
||||
<span>
|
||||
<bdi dir="ltr">v{platform.version}</bdi>
|
||||
</span>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="settings-panel">
|
||||
@@ -205,6 +197,9 @@ export const SettingsScreen: Component = () => {
|
||||
<Tabs.Content value="shortcuts" class="settings-panel">
|
||||
<SettingsKeybinds />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="experimental" class="settings-panel">
|
||||
<SettingsExperimental />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="servers" class="settings-panel">
|
||||
<SettingsServers />
|
||||
</Tabs.Content>
|
||||
@@ -225,6 +220,9 @@ export const SettingsScreen: Component = () => {
|
||||
<SettingsExtensions />
|
||||
</Tabs.Content>
|
||||
</SettingsServerScope>
|
||||
<Tabs.Content value="about" class="settings-panel settings-about">
|
||||
<SettingsAbout active={surface.tab() === "about"} />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,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>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -430,7 +430,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
key: id,
|
||||
options,
|
||||
}
|
||||
setStore("registrations", (arr) => addCommandRegistration(arr, entry))
|
||||
// Register only committed owners. Updating the registry during a transition
|
||||
// can restore its pending snapshot after the outgoing owner's cleanup.
|
||||
onMount(() => setStore("registrations", (arr) => addCommandRegistration(arr, entry)))
|
||||
onCleanup(() => {
|
||||
setStore("registrations", (arr) => arr.filter((x) => x !== entry))
|
||||
})
|
||||
|
||||
@@ -5,21 +5,15 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { formatKeybindParts } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { getRelativeTime } from "@/shell/time"
|
||||
import {
|
||||
createCommandPaletteCommandEntry,
|
||||
createCommandPaletteFileEntry,
|
||||
createCommandPaletteModel,
|
||||
createServerSessionEntries,
|
||||
uniqueCommandPaletteEntries,
|
||||
type CommandPaletteEntry,
|
||||
} from "./palette"
|
||||
import { createCommandPaletteFileEntry, createCommandPaletteModel, type CommandPaletteEntry } from "./palette"
|
||||
import { createCommandPaletteSearch } from "./search"
|
||||
import "./dialog.css"
|
||||
|
||||
function groups(entries: CommandPaletteEntry[]) {
|
||||
@@ -35,23 +29,24 @@ export function matchesCommandPaletteEntry(entry: CommandPaletteEntry, query: st
|
||||
|
||||
export function DialogCommandPalette(props: { onOpenFile?: (path: string) => void }) {
|
||||
const palette = createCommandPaletteModel(props)
|
||||
const loadItems = async (text: string) => {
|
||||
const q = text.trim()
|
||||
const items = (q: string) => {
|
||||
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||
|
||||
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
|
||||
const category = palette.language.t("palette.group.files")
|
||||
return [
|
||||
...palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q)),
|
||||
...nextSessions,
|
||||
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
||||
]
|
||||
return palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q))
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandPaletteView
|
||||
placeholder={palette.language.t("palette.search.placeholder")}
|
||||
loadItems={loadItems}
|
||||
items={items}
|
||||
sources={[
|
||||
palette.sessions,
|
||||
async (query, signal) => {
|
||||
if (!query) return []
|
||||
const files = await palette.file.searchFiles(query, { signal })
|
||||
const category = palette.language.t("palette.group.files")
|
||||
return files.map((path) => createCommandPaletteFileEntry(path, category))
|
||||
},
|
||||
]}
|
||||
highlight={palette.highlight}
|
||||
select={palette.select}
|
||||
close={palette.close}
|
||||
@@ -61,29 +56,31 @@ export function DialogCommandPalette(props: { onOpenFile?: (path: string) => voi
|
||||
|
||||
export function CommandPaletteView(props: {
|
||||
placeholder: string
|
||||
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
|
||||
items: (query: string) => CommandPaletteEntry[]
|
||||
sources: ((query: string, signal: AbortSignal) => Promise<CommandPaletteEntry[]>)[]
|
||||
highlight: (item: CommandPaletteEntry | undefined) => void
|
||||
select: (item: CommandPaletteEntry | undefined) => void
|
||||
close: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const tabs = useTabs()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [active, setActive] = createSignal(0)
|
||||
const [store, setStore] = createStore({ query: "", active: undefined as string | undefined })
|
||||
|
||||
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
||||
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
||||
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
||||
const search = createCommandPaletteSearch({ query: () => store.query, items: props.items, sources: props.sources })
|
||||
const visibleEntries = search.items
|
||||
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
||||
const activeEntry = createMemo(() => visibleEntries()[active()])
|
||||
// Keep keyboard selection stable when another search source adds results.
|
||||
const activeEntry = createMemo(
|
||||
() => visibleEntries().find((entry) => entry.id === store.active) ?? visibleEntries()[0],
|
||||
)
|
||||
const openSessions = createMemo(
|
||||
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
visibleEntries()
|
||||
setActive(0)
|
||||
// Pin automatic selection too: a later source can insert rows before it.
|
||||
const id = activeEntry()?.id
|
||||
if (store.active !== id) setStore("active", id)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -95,7 +92,8 @@ export function CommandPaletteView(props: {
|
||||
const move = (delta: -1 | 1) => {
|
||||
const count = visibleEntries().length
|
||||
if (count === 0) return
|
||||
setActive((index) => (index + delta + count) % count)
|
||||
const index = visibleEntries().findIndex((entry) => entry.id === activeEntry()?.id)
|
||||
setStore("active", visibleEntries()[(index + delta + count) % count].id)
|
||||
requestAnimationFrame(() => {
|
||||
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
|
||||
})
|
||||
@@ -128,14 +126,14 @@ export function CommandPaletteView(props: {
|
||||
<DialogBody class="command-palette-body">
|
||||
<div class="command-palette-search">
|
||||
<TextInput
|
||||
value={query()}
|
||||
value={store.query}
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
appearance="large"
|
||||
placeholder={props.placeholder}
|
||||
leadingIcon={<Icon name="magnifying-glass" />}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
onInput={(event) => setStore({ query: event.currentTarget.value, active: undefined })}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -145,7 +143,7 @@ export function CommandPaletteView(props: {
|
||||
when={visibleEntries().length > 0}
|
||||
fallback={
|
||||
<div class="command-palette-state">
|
||||
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
|
||||
{search.loading() ? language.t("common.loading") : language.t("palette.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -166,7 +164,7 @@ export function CommandPaletteView(props: {
|
||||
? openSessions().has(`${item.server}\0${item.sessionID}`)
|
||||
: false
|
||||
}
|
||||
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
||||
onActive={() => setStore("active", item.id)}
|
||||
onSelect={() => props.select(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { uniqueCommandPaletteEntries, type CommandPaletteEntry } from "./palette"
|
||||
|
||||
export function createCommandPaletteSearch(props: {
|
||||
query: () => string
|
||||
items: (query: string) => CommandPaletteEntry[]
|
||||
sources: ((query: string, signal: AbortSignal) => Promise<CommandPaletteEntry[]>)[]
|
||||
}) {
|
||||
const query = createMemo(() => props.query().trim())
|
||||
const local = createMemo(() => props.items(query()))
|
||||
const sources = props.sources.map((load) => {
|
||||
let abort: AbortController | undefined
|
||||
onCleanup(() => abort?.abort())
|
||||
const [result] = createResource(
|
||||
query,
|
||||
async (query) => {
|
||||
abort?.abort()
|
||||
const current = new AbortController()
|
||||
abort = current
|
||||
return { query, items: await load(query, current.signal).catch(() => []) }
|
||||
},
|
||||
// Remote searches must not suspend the dialog's local results on first render.
|
||||
{ initialValue: { query: "", items: [] as CommandPaletteEntry[] } },
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
return {
|
||||
items: createMemo(() =>
|
||||
uniqueCommandPaletteEntries([
|
||||
...local(),
|
||||
...sources.flatMap((source) => {
|
||||
// Never keep results for an older query selectable while the next one loads.
|
||||
const result = source.latest
|
||||
return result.query === query() ? result.items : []
|
||||
}),
|
||||
]),
|
||||
),
|
||||
loading: () => sources.some((source) => source.loading),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
|
||||
import { Toast, showToast, toaster, type ToastOptions } from "@opencode-ai/ui/toast"
|
||||
import type { JSX } from "solid-js"
|
||||
|
||||
type AppToastOptions = Omit<ToastOptions, "icon"> & {
|
||||
icon?: IconProps["name"]
|
||||
@@ -30,6 +31,7 @@ export function dismissToast(toastId: number) {
|
||||
|
||||
function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastOptions["variant"]) {
|
||||
const name = icon ?? (variant === "success" ? "check" : undefined)
|
||||
if (!name) return
|
||||
return <Icon name={name} />
|
||||
if (!name) return undefined
|
||||
// Solid resolves JSX accessors under the toast's render owner, not this imperative call site.
|
||||
return (() => <Icon name={name} />) as unknown as JSX.Element
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function Layout(props: ParentProps) {
|
||||
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
|
||||
style={{
|
||||
width: `${state.tabsWidth}px`,
|
||||
"padding-bottom": "max(8px, env(safe-area-inset-bottom, 0px))",
|
||||
"padding-bottom": "max(10px, env(safe-area-inset-bottom, 0px))",
|
||||
}}
|
||||
>
|
||||
<ResizeHandle
|
||||
|
||||
@@ -13,7 +13,7 @@ import { displayName, projectForSession } from "@/shell/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { canOpenTabRename, forwardTabRef } from "./tab-gesture"
|
||||
import { TabPreviewPopover } from "./tab-popover"
|
||||
@@ -63,7 +63,7 @@ export function TabNavItem(props: {
|
||||
})
|
||||
const title = createMemo(() => {
|
||||
const session = props.session
|
||||
return session ? sessionLabel(session) : props.fallbackTitle
|
||||
return sessionTabTitle(session ? session.title : props.fallbackTitle, language.t("session.tab.session"))
|
||||
})
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
|
||||
@@ -171,7 +171,7 @@ function SessionTabEntry(props: {
|
||||
preparing={!!pending()}
|
||||
fallbackTitle={
|
||||
pending()
|
||||
? language.t("command.session.new")
|
||||
? language.t("session.tab.session")
|
||||
: (persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined))
|
||||
}
|
||||
onRename={rename}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
|
||||
describe("session tab titles", () => {
|
||||
test("uses the same localized label before a title arrives", () => {
|
||||
for (const title of [
|
||||
undefined,
|
||||
"",
|
||||
"New session - 2026-07-30T18:45:03.662Z",
|
||||
"Child session - 2026-07-30T18:45:03.662Z",
|
||||
]) {
|
||||
expect(sessionTabTitle(title, "Session")).toBe("Session")
|
||||
expect(sessionTabTitle(title, "Sitzung")).toBe("Sitzung")
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves generated and user-supplied titles", () => {
|
||||
for (const title of ["Generated title", "New session", "New session - custom"]) {
|
||||
expect(sessionTabTitle(title, "Session")).toBe(title)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
// Draft, preparing, and untitled session tabs share one localized label.
|
||||
export function sessionTabTitle(title: string | undefined, fallback: string) {
|
||||
return !title || isFallbackTitle(title) ? fallback : title
|
||||
}
|
||||
@@ -2,6 +2,21 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="vertical-tabs-sidebar"] [data-slot="vertical-tabs"] {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-slot="vertical-tabs-sidebar"] [data-slot="vertical-tabs-scroll"] {
|
||||
min-height: 0;
|
||||
/* Leave enough space to scroll the final tab clear of the fade above Settings. */
|
||||
padding-block-end: 16px;
|
||||
scroll-padding-block-end: 16px;
|
||||
-webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
|
||||
mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-trigger"][aria-expanded="true"] {
|
||||
background:
|
||||
linear-gradient(var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
|
||||
import { createStore, unwrap } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { Dynamic, Portal } from "solid-js/web"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -26,17 +26,20 @@ import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
|
||||
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import devIcon from "../../../../desktop/icons/dev/64x64.png"
|
||||
import betaIcon from "../../../../desktop/icons/beta/64x64.png"
|
||||
|
||||
const titlebarHeight = 36
|
||||
const windowsTitlebarHeight = 44 // Includes the content inset; matches the native Windows overlay.
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
const macTrafficLightsBaseWidth = 84
|
||||
// Native controls: 14px left inset, two 20px button pitches, and a 14px button.
|
||||
const macTrafficLightsBaseWidth = 68
|
||||
const macTrafficLightsTopClearance = 28
|
||||
|
||||
export type TitlebarUpdate = {
|
||||
@@ -348,13 +351,19 @@ export function Titlebar(props: {
|
||||
type="button"
|
||||
data-action="vertical-tabs-home"
|
||||
data-state={layout.route().type === "home" ? "pressed" : undefined}
|
||||
class="mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
|
||||
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
|
||||
onClick={toggleHome}
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
>
|
||||
<Icon name="grid-plus" />
|
||||
{language.t("home.title")}
|
||||
<span class="min-w-0 truncate">{language.t("home.title")}</span>
|
||||
<span
|
||||
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("home.toggle")}</bdi>
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
)
|
||||
@@ -413,9 +422,12 @@ export function Titlebar(props: {
|
||||
const currentTitle = () => {
|
||||
const tab = currentTab()
|
||||
if (!tab) return language.t("home.title")
|
||||
if (tab.type === "draft") return language.t("command.session.new")
|
||||
if (tab.type === "draft") return language.t("session.tab.session")
|
||||
const value = session()
|
||||
return value ? sessionLabel(value) : (tabs.info[tabKey(tab)]?.title ?? language.t("command.session.new"))
|
||||
return sessionTabTitle(
|
||||
value ? value.title : tabs.info[tabKey(tab)]?.title,
|
||||
language.t("session.tab.session"),
|
||||
)
|
||||
}
|
||||
createEffect(() => {
|
||||
path()
|
||||
@@ -425,16 +437,17 @@ export function Titlebar(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pe-3"
|
||||
classList={{
|
||||
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
|
||||
"pb-[max(0px,calc(8px-env(safe-area-inset-bottom,0px)))]": bottom(),
|
||||
"md:pl-2": macTrafficLights(),
|
||||
"md:pl-4": !macTrafficLights(),
|
||||
"pl-4": macTrafficLights(),
|
||||
// Center the 20px app icon over the sidebar's 16px icon column.
|
||||
"ps-3.5": windows(),
|
||||
}}
|
||||
>
|
||||
<Show when={!mobile() && (!props.verticalTabs || windows())}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
<ChannelIndicator horizontal debugTools={props.debugTools} />
|
||||
</Show>
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
@@ -497,7 +510,7 @@ export function Titlebar(props: {
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
|
||||
<span data-slot="mobile-tab-title" dir="auto" class="min-w-0 flex-1 truncate text-start">
|
||||
{currentTitle()}
|
||||
</span>
|
||||
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
|
||||
@@ -625,33 +638,30 @@ export function Titlebar(props: {
|
||||
>
|
||||
<Show when={macVerticalTabs()}>
|
||||
<div
|
||||
class="relative mb-2 w-full shrink-0"
|
||||
class="mb-4 min-h-7 w-full shrink-0"
|
||||
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div
|
||||
class="absolute -top-0.5 bottom-0.5 flex items-center"
|
||||
style={{
|
||||
// Native traffic lights stay on the physical left; subtract the sidebar padding.
|
||||
left: macTrafficLights()
|
||||
? `calc(${macTrafficLightsBaseWidth / zoom()}px - 0.625rem)`
|
||||
: "0px",
|
||||
}}
|
||||
>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!windows()}>
|
||||
<ChannelIndicator sidebar debugTools={props.debugTools} />
|
||||
</Show>
|
||||
{homeButton(true)}
|
||||
<button
|
||||
type="button"
|
||||
data-action="vertical-tabs-new-session"
|
||||
class="flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="edit" />
|
||||
{language.t("command.session.new")}
|
||||
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
|
||||
<span
|
||||
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
|
||||
</span>
|
||||
</button>
|
||||
<div class="h-4 w-full shrink-0" aria-hidden="true" />
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-1">
|
||||
@@ -670,14 +680,20 @@ export function Titlebar(props: {
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-slot="vertical-tabs-footer"
|
||||
class="mt-auto flex h-9 w-full shrink-0 items-center gap-1.5"
|
||||
<button
|
||||
type="button"
|
||||
data-action="vertical-tabs-settings"
|
||||
data-state={layout.route().type === "settings" ? "pressed" : undefined}
|
||||
class="mt-2 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]"
|
||||
onClick={openSettings}
|
||||
aria-label={language.t("sidebar.settings")}
|
||||
aria-pressed={layout.route().type === "settings"}
|
||||
>
|
||||
<Icon name="settings-gear" />
|
||||
{language.t("sidebar.settings")}
|
||||
</button>
|
||||
<div data-slot="vertical-tabs-footer" class="flex w-full shrink-0 items-center gap-1.5">
|
||||
<TitlebarRightMount />
|
||||
<Show when={!macVerticalTabs() && !windows()}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</Show>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
@@ -753,45 +769,47 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||
function ChannelIndicator(props: {
|
||||
horizontal?: boolean
|
||||
sidebar?: boolean
|
||||
debugTools?: { visible: boolean; toggle: () => void }
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||
const classes = () => ({
|
||||
"px-2 rounded-sm": windows(),
|
||||
"inline-flex h-4 shrink-0 items-center leading-4 px-1.5 rounded-full": !windows(),
|
||||
})
|
||||
const style = () => ({
|
||||
"font-size": windows() ? undefined : platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
|
||||
})
|
||||
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
|
||||
if (channel === "dev" && props.debugTools) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono cursor-pointer [app-region:no-drag]"
|
||||
classList={classes()}
|
||||
style={style()}
|
||||
onClick={props.debugTools.toggle}
|
||||
aria-label="Toggle debug tools"
|
||||
aria-pressed={props.debugTools.visible}
|
||||
>
|
||||
DEV
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (!channel || channel === "prod") return null
|
||||
|
||||
const label = channel && ["local", "beta", "dev"].includes(channel) ? channel.toUpperCase() : undefined
|
||||
const label = () => language.t(`titlebar.channel.${channel}`)
|
||||
const debug = () => (channel === "dev" ? props.debugTools : undefined)
|
||||
return (
|
||||
<Show when={label}>
|
||||
{(value) => (
|
||||
<div
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono"
|
||||
classList={classes()}
|
||||
style={style()}
|
||||
>
|
||||
{value()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Tooltip
|
||||
placement={props.sidebar ? "right" : "bottom"}
|
||||
value={label()}
|
||||
class={`shrink-0 [app-region:no-drag] ${props.sidebar ? "mb-4 ms-0.5 self-start" : ""} ${props.horizontal ? "me-1.5" : ""} ${props.horizontal && platform.platform === "web" ? "ps-2.5" : ""}`}
|
||||
>
|
||||
<Dynamic
|
||||
component={debug() ? "button" : "div"}
|
||||
type={debug() ? "button" : undefined}
|
||||
data-slot="channel-indicator"
|
||||
class="flex h-7 shrink-0 items-center rounded-[6px] [app-region:no-drag]"
|
||||
classList={{
|
||||
"w-6": props.sidebar,
|
||||
"w-5": !props.sidebar,
|
||||
"cursor-pointer hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02":
|
||||
!!debug(),
|
||||
}}
|
||||
onClick={() => debug()?.toggle()}
|
||||
aria-label={debug() ? language.t("titlebar.toggleDebugTools") : undefined}
|
||||
aria-pressed={debug()?.visible}
|
||||
>
|
||||
<img
|
||||
src={channel === "beta" ? betaIcon : devIcon}
|
||||
alt={debug() ? "" : label()}
|
||||
class="shrink-0 rounded-[4px] shadow-[var(--v2-elevation-raised)]"
|
||||
classList={{ "size-6": props.sidebar, "size-5": !props.sidebar }}
|
||||
draggable={false}
|
||||
/>
|
||||
</Dynamic>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createComputed, createRoot, createSignal } from "solid-js"
|
||||
import type { CommandPaletteEntry } from "@/shell/commands/palette"
|
||||
import { createCommandPaletteSearch } from "@/shell/commands/search"
|
||||
|
||||
const copy: CommandPaletteEntry = {
|
||||
id: "command:session.copyID",
|
||||
type: "command",
|
||||
title: "Copy Session ID",
|
||||
category: "Commands",
|
||||
}
|
||||
const file: CommandPaletteEntry = { id: "file:copy.txt", type: "file", title: "copy.txt", category: "Files" }
|
||||
const session: CommandPaletteEntry = {
|
||||
id: "session:copy",
|
||||
type: "session",
|
||||
title: "Copy session",
|
||||
category: "Sessions",
|
||||
}
|
||||
|
||||
describe("command palette search", () => {
|
||||
test("matches commands synchronously and cancels obsolete requests", () => {
|
||||
const signals: AbortSignal[] = []
|
||||
const root = createRoot((dispose) => {
|
||||
const [query, setQuery] = createSignal("")
|
||||
const search = createCommandPaletteSearch({
|
||||
query,
|
||||
items: (text) => (text === "copy session" ? [copy] : []),
|
||||
sources: [
|
||||
(_text, signal) => {
|
||||
signals.push(signal)
|
||||
return new Promise<CommandPaletteEntry[]>(() => {})
|
||||
},
|
||||
],
|
||||
})
|
||||
return { search, setQuery, dispose }
|
||||
})
|
||||
root.setQuery(" copy session ")
|
||||
expect(root.search.items()).toEqual([copy])
|
||||
expect(root.search.loading()).toBe(true)
|
||||
expect(signals[0].aborted).toBe(true)
|
||||
root.setQuery("no match")
|
||||
expect(root.search.items()).toEqual([])
|
||||
expect(signals[1].aborted).toBe(true)
|
||||
root.dispose()
|
||||
expect(signals[2].aborted).toBe(true)
|
||||
})
|
||||
|
||||
test("publishes each source independently and drops stale results on a new query", async () => {
|
||||
const files = Promise.withResolvers<CommandPaletteEntry[]>()
|
||||
const sessions = Promise.withResolvers<CommandPaletteEntry[]>()
|
||||
const fileVisible = Promise.withResolvers<void>()
|
||||
const sessionVisible = Promise.withResolvers<void>()
|
||||
const root = createRoot((dispose) => {
|
||||
const [query, setQuery] = createSignal("copy")
|
||||
const search = createCommandPaletteSearch({
|
||||
query,
|
||||
items: () => [copy],
|
||||
sources: [() => sessions.promise, () => files.promise],
|
||||
})
|
||||
createComputed(() => {
|
||||
if (search.items().some((entry) => entry.id === file.id)) fileVisible.resolve()
|
||||
if (search.items().some((entry) => entry.id === session.id)) sessionVisible.resolve()
|
||||
})
|
||||
return { search, setQuery, dispose }
|
||||
})
|
||||
expect(root.search.items()).toEqual([copy])
|
||||
files.resolve([file])
|
||||
await fileVisible.promise
|
||||
expect(root.search.items()).toEqual([copy, file])
|
||||
expect(root.search.loading()).toBe(true)
|
||||
sessions.resolve([session])
|
||||
await sessionVisible.promise
|
||||
expect(root.search.items()).toEqual([copy, session, file])
|
||||
expect(root.search.loading()).toBe(false)
|
||||
root.setQuery("new query")
|
||||
expect(root.search.items()).toEqual([copy])
|
||||
root.dispose()
|
||||
})
|
||||
|
||||
test("failed searches do not hide commands or successful sources", async () => {
|
||||
const settled = Promise.withResolvers<void>()
|
||||
const root = createRoot((dispose) => {
|
||||
const search = createCommandPaletteSearch({
|
||||
query: () => "copy",
|
||||
items: () => [copy],
|
||||
sources: [() => Promise.reject(new Error("offline")), () => Promise.resolve([file])],
|
||||
})
|
||||
createComputed(() => {
|
||||
if (!search.loading()) settled.resolve()
|
||||
})
|
||||
return { search, dispose }
|
||||
})
|
||||
await settled.promise
|
||||
expect(root.search.items()).toEqual([copy, file])
|
||||
root.dispose()
|
||||
})
|
||||
})
|
||||
@@ -59,6 +59,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
commands: [
|
||||
Spec.make("upgrade", {
|
||||
description: "Upgrade OpenCode to the latest or a specific version",
|
||||
aliases: ["update"],
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
|
||||
@@ -67,9 +67,13 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
|
||||
function add(node: Spec.Any, value: RuntimeHandlers) {
|
||||
if (typeof value === "function") {
|
||||
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
|
||||
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
|
||||
return
|
||||
}
|
||||
if (value.$) result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
|
||||
if (value.$) {
|
||||
result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
|
||||
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
|
||||
}
|
||||
for (const [name, child] of Object.entries(node.commands)) add(child, value[name] as RuntimeHandlers)
|
||||
}
|
||||
|
||||
@@ -99,8 +103,12 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
|
||||
)
|
||||
: node.spec
|
||||
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
|
||||
const children = Object.values(node.commands)
|
||||
return spec.pipe(
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
Command.withSubcommands([
|
||||
...children.map((child) => provide(child, handlers)),
|
||||
...children.flatMap((child) => child.aliases.map((alias) => provide(alias, handlers))),
|
||||
]),
|
||||
) as ProvidedCommand
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Command } from "effect/unstable/cli"
|
||||
|
||||
type Options<Config extends Command.Command.Config, Commands extends ReadonlyArray<Any>> = {
|
||||
readonly description?: string
|
||||
readonly aliases?: ReadonlyArray<string>
|
||||
readonly params?: Config
|
||||
readonly commands?: Commands
|
||||
}
|
||||
@@ -14,6 +15,7 @@ export interface Node<
|
||||
readonly name: Name
|
||||
readonly spec: Spec
|
||||
readonly commands: Commands
|
||||
readonly aliases: ReadonlyArray<Any>
|
||||
}
|
||||
|
||||
export type Any = Node<string, Command.Command<any, any, any, any, any>, Children>
|
||||
@@ -24,14 +26,28 @@ export function make<
|
||||
const Config extends Command.Command.Config = {},
|
||||
const Commands extends ReadonlyArray<Any> = [],
|
||||
>(name: Name, options: Options<Config, Commands> = {}) {
|
||||
const command = Command.make(name, options.params ?? ({} as Config))
|
||||
const spec = options.description ? command.pipe(Command.withDescription(options.description)) : command
|
||||
const aliases = options.aliases ?? []
|
||||
const params = options.params ?? ({} as Config)
|
||||
const command = Command.make(name, params)
|
||||
const described = options.description ? command.pipe(Command.withDescription(options.description)) : command
|
||||
// Effect supports a single native alias, shown inline as `name, alias` in help.
|
||||
// Extra aliases become sibling commands sharing params and subcommands.
|
||||
const spec = aliases.length > 0 ? described.pipe(Command.withAlias(aliases[0])) : described
|
||||
const commands = Object.fromEntries(
|
||||
(options.commands ?? []).map((command) => [command.name, command]),
|
||||
) as ChildrenOf<Commands>
|
||||
const extra = aliases.slice(1).map((alias) => {
|
||||
const aliasCommand = Command.make(alias, params)
|
||||
const aliasSpec = options.description
|
||||
? aliasCommand.pipe(Command.withDescription(options.description))
|
||||
: aliasCommand
|
||||
return { name: alias, spec: aliasSpec, commands, aliases: [] }
|
||||
})
|
||||
return {
|
||||
name,
|
||||
spec,
|
||||
commands: Object.fromEntries(
|
||||
(options.commands ?? []).map((command) => [command.name, command]),
|
||||
) as ChildrenOf<Commands>,
|
||||
commands,
|
||||
aliases: extra,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
])
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user