mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-04 16:06:23 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f8d58e95b | ||
|
|
c9d240704d | ||
|
|
c9df4ba80d | ||
|
|
ac71a55294 | ||
|
|
f84d927e07 | ||
|
|
d431fedce5 | ||
|
|
e536b9627e | ||
|
|
6af46cc8a9 | ||
|
|
c76e602ba4 | ||
|
|
726107729e | ||
|
|
0808ebc3c5 | ||
|
|
17e5c5fbf1 | ||
|
|
b0a13b810e | ||
|
|
6575215ddf | ||
|
|
09157609b7 | ||
|
|
7ba5f3e5b2 | ||
|
|
0ec1587948 | ||
|
|
2fda2c8141 | ||
|
|
f06bf47e72 | ||
|
|
fa6e7e179d | ||
|
|
4272bb83e0 | ||
|
|
1ae2cf3567 |
@@ -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
|
||||
|
||||
@@ -71,7 +71,7 @@ function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
|
||||
}
|
||||
|
||||
describe("Open Responses basic-item lifecycles", () => {
|
||||
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
|
||||
it.effect("closes implicit summary boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
|
||||
const events = yield* collect(
|
||||
@@ -90,12 +90,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
delta: "Third",
|
||||
},
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
|
||||
completed,
|
||||
)
|
||||
|
||||
@@ -129,7 +123,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
|
||||
it.effect("preserves done-only reasoning text and encryption", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "reasoning",
|
||||
@@ -139,11 +133,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "late" },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "late final" },
|
||||
completed,
|
||||
// Route termination must also prevent events after response completion.
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_after" } },
|
||||
|
||||
@@ -2861,7 +2861,6 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Think" },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
story(`keeps the Open in border visible without hovering (${theme})`, async ({ mount, page }, testInfo) => {
|
||||
const component = await mount("ui-split-button--open-in", { globals: { theme } })
|
||||
const control = component.locator('[data-component="split-button-v2"]')
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toBeVisible()
|
||||
await expect(control).not.toHaveCSS("box-shadow", "none")
|
||||
const border = await control.evaluate((element) => getComputedStyle(element).boxShadow)
|
||||
|
||||
await component.getByRole("button", { name: "Open options" }).hover()
|
||||
await expect(control).toHaveCSS("box-shadow", border)
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveCSS("box-shadow", border)
|
||||
await control.screenshot({ path: testInfo.outputPath(`open-in-${theme}.png`) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { benchmark, expect } from "./benchmark"
|
||||
import { openCommandPalette } from "../utils/command-palette"
|
||||
|
||||
benchmark.use({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
serviceWorkers: "block",
|
||||
traceScope: "interaction",
|
||||
trace: "off",
|
||||
video: "off",
|
||||
})
|
||||
|
||||
for (const home of [false, true]) {
|
||||
benchmark(`command lookup from ${home ? "home" : "session"}`, async ({ page, report }) => {
|
||||
const { dialog, input } = await openCommandPalette(page, home)
|
||||
const title = home ? "Open settings" : "Copy Session ID"
|
||||
const query = home ? "open settings" : "copy session"
|
||||
// Measure input-to-selected-result in the renderer, without assertion polling overhead.
|
||||
await input.evaluate((element, title) => {
|
||||
element.addEventListener(
|
||||
"input",
|
||||
() => {
|
||||
performance.mark("palette-input")
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.querySelectorAll('[role="dialog"] [role="option"]').length !== 1) return
|
||||
const selected = document.querySelector('[role="dialog"] [role="option"][aria-selected="true"]')
|
||||
if (!selected?.textContent?.includes(title)) return
|
||||
performance.measure("palette-result", "palette-input")
|
||||
observer.disconnect()
|
||||
})
|
||||
observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true })
|
||||
},
|
||||
{ once: true, capture: true },
|
||||
)
|
||||
}, title)
|
||||
await input.fill(query)
|
||||
await expect(dialog.getByRole("option")).toHaveCount(1)
|
||||
await expect(dialog.getByRole("option", { name: new RegExp(`^${title}(?:$| )`) })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
const result = await page.evaluate(() =>
|
||||
performance.getEntriesByName("palette-result").map((entry) => entry.duration),
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
report({ inputToResultMs: result[0] }, { home, query, data: "fixture; immediate server responses" })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { openCommandPalette, paletteSession } from "../utils/command-palette"
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
test("failed event-driven reads report an error and recover without an unhandled rejection", async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
const palette = await openCommandPalette(page)
|
||||
const path = `**/api/session/${paletteSession.id}`
|
||||
await page.route(path, (route) => route.abort("failed"))
|
||||
const requested = page.waitForRequest(path)
|
||||
await page.evaluate((sessionID) => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([
|
||||
{
|
||||
id: "evt_failed_refresh",
|
||||
created: 2,
|
||||
type: "session.viewed",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, idle: 2 },
|
||||
},
|
||||
])
|
||||
}, paletteSession.id)
|
||||
await requested
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await palette.input.fill("copy session")
|
||||
await expect(palette.dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
await palette.input.press("Escape")
|
||||
await page.unroute(path)
|
||||
await page.evaluate((sessionID) => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([
|
||||
{
|
||||
id: "evt_recovered_refresh",
|
||||
created: 3,
|
||||
type: "session.renamed",
|
||||
durable: { aggregateID: sessionID, seq: 2, version: 1 },
|
||||
data: { sessionID, title: "Recovered session" },
|
||||
},
|
||||
])
|
||||
}, paletteSession.id)
|
||||
await expect(page.getByRole("heading", { name: "Recovered session", exact: true })).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { captureConsoleWarnings, openCommandPalette, paletteSession } from "../utils/command-palette"
|
||||
|
||||
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
|
||||
|
||||
test("copies the session ID while file and session searches are still pending", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(/\/api\/(session\?|fs\/find\?)/, async (route) => {
|
||||
await release.promise
|
||||
await route.fallback()
|
||||
})
|
||||
await input.pressSequentially("copy session")
|
||||
const copy = dialog.getByRole("option", { name: "Copy Session ID", exact: true })
|
||||
await expect(copy).toHaveAttribute("aria-selected", "true")
|
||||
await input.press("Enter")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(paletteSession.id)
|
||||
await expect(page.locator('[data-testid^="toast-v2-"] [data-slot="icon-svg"]')).toBeVisible()
|
||||
expect(warnings).toEqual([])
|
||||
release.resolve()
|
||||
})
|
||||
|
||||
test("home commands do not wait for session search", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page, true)
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
await release.promise
|
||||
await route.fallback()
|
||||
})
|
||||
await input.fill("open settings")
|
||||
await expect(dialog.getByRole("option")).toHaveCount(1)
|
||||
await expect(dialog.getByRole("option", { name: /^Open settings/ })).toHaveAttribute("aria-selected", "true")
|
||||
await input.press("Enter")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
|
||||
release.resolve()
|
||||
})
|
||||
|
||||
test("appends search results without resetting the selected command", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const files = Promise.withResolvers<void>()
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
await page.route("**/api/fs/find?*", async (route) => {
|
||||
await files.promise
|
||||
await route.fulfill({ json: { data: [{ path: "copy.txt", type: "file" }] } })
|
||||
})
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
await sessions.promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "Copy fixture" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
await input.fill("copy")
|
||||
const project = dialog.getByRole("option", { name: "Copy Project ID", exact: true })
|
||||
await expect(project).toBeVisible()
|
||||
// Select a non-first command with the keyboard before remote results arrive.
|
||||
await input.press("ArrowDown")
|
||||
await expect(project).toHaveAttribute("aria-selected", "true")
|
||||
files.resolve()
|
||||
await expect(dialog.getByRole("option", { name: "/ copy.txt", exact: true })).toBeVisible()
|
||||
await expect(project).toHaveAttribute("aria-selected", "true")
|
||||
// File results are usable even while sessions are still pending.
|
||||
sessions.resolve()
|
||||
await expect(dialog.getByRole("option", { name: /Copy fixture/ })).toBeVisible()
|
||||
await expect(project).toHaveAttribute("aria-selected", "true")
|
||||
await input.fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("keeps the automatically selected file when session results arrive later", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
await page.route("**/api/fs/find?*", (route) =>
|
||||
route.fulfill({ json: { data: [{ path: "README.md", type: "file" }] } }),
|
||||
)
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
await sessions.promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "README work" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
await input.fill("README")
|
||||
const file = dialog.getByRole("option", { name: "/ README.md", exact: true })
|
||||
await expect(file).toHaveAttribute("aria-selected", "true")
|
||||
sessions.resolve()
|
||||
await expect(dialog.getByRole("option", { name: /README work/ })).toBeVisible()
|
||||
await expect(file).toHaveAttribute("aria-selected", "true")
|
||||
await input.press("Enter")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.getByRole("tab", { name: "README.md", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { captureConsoleWarnings, openCommandPalette } from "../utils/command-palette"
|
||||
|
||||
test.use({ serviceWorkers: "block", video: "off" })
|
||||
|
||||
test("opening and closing files does not duplicate tab commands", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const palette = await openCommandPalette(page)
|
||||
await page.route("**/api/fs/find?*", (route) =>
|
||||
route.fulfill({
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
json: { data: [{ path: "fixture.txt", type: "file" }] },
|
||||
}),
|
||||
)
|
||||
await palette.input.fill("fixture.txt")
|
||||
await palette.dialog.getByRole("option", { name: /fixture\.txt/ }).click()
|
||||
const file = page.getByRole("tab", { name: /fixture\.txt/ })
|
||||
await expect(file).toBeVisible()
|
||||
await expect(palette.dialog).toHaveCount(0)
|
||||
await page
|
||||
.getByRole("complementary", { name: "Review and files" })
|
||||
.getByRole("button", { name: "Close tab", exact: true })
|
||||
.click()
|
||||
await expect(file).toHaveCount(0)
|
||||
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test("navigation replaces commands without retaining disposed owners", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const palette = await openCommandPalette(page, true)
|
||||
await palette.input.press("Escape")
|
||||
await expect(palette.dialog).toHaveCount(0)
|
||||
await page
|
||||
.getByRole("region", { name: "Recent sessions" })
|
||||
.getByRole("button", { name: /Palette fixture session/ })
|
||||
.click()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await page.keyboard.press("ControlOrMeta+t")
|
||||
await expect(page).toHaveURL(/\/new-session\?/)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await page.locator('[data-component="composer-editor"]').blur()
|
||||
await page.keyboard.press("Control+l")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await expect(dialog.getByRole("textbox")).toHaveAttribute("placeholder", "Search files, commands, and sessions")
|
||||
await dialog.getByRole("textbox").fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveCount(0)
|
||||
await dialog.getByRole("textbox").press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
|
||||
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
|
||||
for (const count of [3, 4]) {
|
||||
await page.getByRole("button", { name: "New session", exact: true }).click()
|
||||
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(count)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
}
|
||||
await page.setViewportSize({ width: 600, height: 800 })
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-titlebar-tab-link]')).toHaveCount(4)
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toHaveCount(4)
|
||||
await page.keyboard.press("ControlOrMeta+w")
|
||||
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(3)
|
||||
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
|
||||
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await dialog.getByRole("textbox").fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
@@ -25,6 +26,53 @@ for (const viewport of [
|
||||
{ name: "desktop", width: 1280, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
]) {
|
||||
test(`keeps Session in the tab until the generated title arrives on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const mock = await openDraft(page, { untitled: true })
|
||||
const label = page.locator(
|
||||
viewport.name === "mobile"
|
||||
? '[data-slot="mobile-tab-title"]'
|
||||
: '[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]',
|
||||
)
|
||||
await expect(label).toHaveText("Session")
|
||||
const pending = await submitPending(page, mock)
|
||||
const spinner = page.locator(
|
||||
viewport.name === "mobile"
|
||||
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
|
||||
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
|
||||
)
|
||||
await expect(spinner).toBeVisible()
|
||||
await testInfo.attach("pending-tab-title", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
await expect(label).toHaveText("Session")
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(label).toHaveText("Session")
|
||||
|
||||
if (viewport.name === "mobile") {
|
||||
await label.click()
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
|
||||
await expect(tab.locator("[data-titlebar-tab-title]")).toHaveText("Session")
|
||||
await tab.click()
|
||||
await expect(drawer).toBeHidden()
|
||||
}
|
||||
|
||||
mock.events.push({
|
||||
id: "evt_generated_title",
|
||||
type: "session.renamed",
|
||||
created: Date.now(),
|
||||
location: { directory: workspace },
|
||||
durable: { aggregateID: pending.sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID: pending.sessionID, title: "Generated session title" },
|
||||
})
|
||||
await expect(label).toHaveText("Generated session title")
|
||||
})
|
||||
|
||||
test(`shows a pending workspace session immediately on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const mock = await openDraft(page)
|
||||
@@ -335,6 +383,61 @@ test("restores the draft after closing and revisiting a pending session that fai
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("executes a selected slash command after creating its worktree", async ({ page }, testInfo) => {
|
||||
const events: OpenCodeEvent[] = []
|
||||
const mock = await openDraft(page, { command: true, events: () => events.splice(0) })
|
||||
const commands: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const expanded =
|
||||
"Review the latest commit for correctness and regressions. Check the relevant tests and report actionable findings."
|
||||
await page.route("**/api/session/*/command", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const sessionID = new URL(route.request().url()).pathname.split("/")[3]
|
||||
commands.push({ sessionID, body: route.request().postDataJSON() })
|
||||
// The server owns command expansion; the client receives the expanded inbox item.
|
||||
events.push({
|
||||
id: "evt_workspace_review",
|
||||
type: "session.inbox.enqueued",
|
||||
created: Date.now(),
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID,
|
||||
inboxID: "msg_workspace_review",
|
||||
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await route.fulfill({ status: 204, headers })
|
||||
})
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await editor.fill("/review")
|
||||
const suggestion = page.getByRole("button", { name: "/review Review changes", exact: true })
|
||||
await expect(suggestion).toBeVisible()
|
||||
await suggestion.click()
|
||||
await expect(editor).toHaveText("/review")
|
||||
const pending = await submitPending(page, mock, "/review latest commit")
|
||||
await draftFollowUp(page)
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect
|
||||
.poll(() => commands)
|
||||
.toEqual([
|
||||
{
|
||||
sessionID: pending.sessionID,
|
||||
body: { command: "review", text: "latest commit", files: [], agents: [], skills: [], delivery: "steer" },
|
||||
},
|
||||
])
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText(expanded)
|
||||
await expect(editor).toHaveText(followUp)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
|
||||
expect(mock.prompts).toEqual([])
|
||||
await testInfo.attach("expanded-worktree-command", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("expanded-worktree-command.png") }),
|
||||
contentType: "image/png",
|
||||
})
|
||||
})
|
||||
|
||||
async function draftFollowUp(page: Page) {
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await editor.pressSequentially("!")
|
||||
@@ -346,12 +449,16 @@ async function draftFollowUp(page: Page) {
|
||||
await expect(editor).toHaveText(followUp)
|
||||
}
|
||||
|
||||
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
|
||||
async function openDraft(
|
||||
page: Page,
|
||||
options?: { failSessionCreate?: boolean; untitled?: boolean; command?: boolean; events?: () => OpenCodeEvent[] },
|
||||
) {
|
||||
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
|
||||
const calls: string[] = []
|
||||
const worktreeRequests: Record<string, unknown>[] = []
|
||||
const creates: Record<string, unknown>[] = []
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const events: OpenCodeEvent[] = []
|
||||
const project = {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
@@ -378,6 +485,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
sessions,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
events: options?.events ?? (() => events.splice(0)),
|
||||
})
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
@@ -404,7 +512,10 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
return route.fulfill({ status: 500, json: { message: "Session creation failed in the fixture" }, headers })
|
||||
}
|
||||
if (typeof body.id !== "string") throw new Error("Session creation must use the client-reserved ID")
|
||||
const session = currentSession({ ...body, id: body.id, projectID, title: "Created workspace session" }, workspace)
|
||||
const session = currentSession(
|
||||
{ ...body, id: body.id, projectID, title: options?.untitled ? "" : "Created workspace session" },
|
||||
workspace,
|
||||
)
|
||||
sessions.push(session)
|
||||
return route.fulfill({ json: { data: session }, headers })
|
||||
})
|
||||
@@ -436,6 +547,17 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
if (options?.command) {
|
||||
await page.route("**/api/command?**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
|
||||
data: [{ name: "review", description: "Review changes" }],
|
||||
},
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
}
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, otherID, server }) => {
|
||||
localStorage.setItem(
|
||||
@@ -461,11 +583,11 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
return { worktree, worktreeRequests, calls, creates, prompts }
|
||||
return { worktree, worktreeRequests, calls, creates, prompts, events }
|
||||
}
|
||||
|
||||
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
|
||||
await page.locator('[data-component="composer-editor"]').fill(text)
|
||||
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>, prompt = text) {
|
||||
await page.locator('[data-component="composer-editor"]').fill(prompt)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname))
|
||||
@@ -481,7 +603,7 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
|
||||
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
|
||||
await expect(message).toHaveCount(1)
|
||||
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(prompt)
|
||||
await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/)
|
||||
const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "")
|
||||
await expect(shimmer).toBeVisible()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import { createMockServerHandler } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
@@ -8,6 +9,8 @@ const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||
const childB = { ...session("ses_server_b_child", sessionB.directory, "Server B subagent"), parentID: sessionB.id }
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
test("tab busy indicator reflects activity in the tab session family", async ({ page }, info) => {
|
||||
await mockServers(page)
|
||||
await page.addInitScript(
|
||||
@@ -27,7 +30,7 @@ test("tab busy indicator reflects activity in the tab session family", async ({
|
||||
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(hrefB)
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
|
||||
|
||||
// The parent is idle, but its tab remains active while the background child runs.
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
|
||||
@@ -52,65 +55,61 @@ function session(id: string, directory: string, title: string) {
|
||||
}
|
||||
|
||||
async function mockServers(page: Page) {
|
||||
// Both servers stay connected while the client hydrates their active-session snapshots.
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
const servers = new Map(
|
||||
[sessionA, sessionB].map(
|
||||
(current) =>
|
||||
[
|
||||
current === sessionA ? serverA : serverB,
|
||||
createMockServerHandler({
|
||||
directory: current.directory,
|
||||
project: {
|
||||
id: current.projectID,
|
||||
worktree: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
sessions: current === sessionB ? [current, childB] : [current],
|
||||
sessionStatus: current === sessionB ? { [childB.id]: { type: "running" } } : {},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
pageMessages: () => ({ items: [] }),
|
||||
}),
|
||||
] as const,
|
||||
),
|
||||
)
|
||||
page.on("close", () => servers.forEach((server) => void server.dispose()))
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
const server = servers.get(url.origin)
|
||||
if (!server) return route.fallback()
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json(route, { data: url.origin === serverB ? { [childB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, {
|
||||
data: url.origin === serverB ? [currentSession(current), currentSession(childB)] : [currentSession(current)],
|
||||
cursor: {},
|
||||
if (directory && directory !== current.directory)
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
json: { name: "InvalidDirectory" },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||
return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: current.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||
return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
|
||||
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||
const project = {
|
||||
id: current.projectID,
|
||||
canonical: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
}
|
||||
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
data: { branch: "main", defaultBranch: "main" },
|
||||
if (route.request().method() === "OPTIONS")
|
||||
return route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
|
||||
})
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
|
||||
const body = route.request().postDataBuffer()
|
||||
const response = await server.handler(
|
||||
new Request(url, {
|
||||
method: route.request().method(),
|
||||
headers: route.request().headers(),
|
||||
body: body ? Uint8Array.from(body) : undefined,
|
||||
}),
|
||||
)
|
||||
return route.fulfill({
|
||||
status: response.status,
|
||||
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -92,10 +92,16 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Open file" }).locator("use")).toHaveAttribute(
|
||||
"href",
|
||||
"#opencode-v2-icon-plus",
|
||||
)
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
const openFileTab = panel.getByRole("tab", { name: "Open file" })
|
||||
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
|
||||
await expect(openFileTab).toHaveAttribute("data-selected", "")
|
||||
await expect(openFileTab.locator("..")).toHaveCSS("padding-inline-end", "4px")
|
||||
await expect(openFileTab.locator("..")).toHaveCSS("gap", "8px")
|
||||
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
|
||||
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
|
||||
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
|
||||
@@ -114,6 +120,8 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
|
||||
await panel.getByRole("button", { name: "README.md" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
|
||||
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("gap", "8px")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
@@ -129,6 +137,8 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||
await filter.press("Enter")
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("gap", "8px")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Locator } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewTogglePosition"
|
||||
const sessionID = "ses_review_toggle_position"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_review_toggle_position",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-toggle-position",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "review-toggle-position",
|
||||
projectID: "proj_review_toggle_position",
|
||||
directory,
|
||||
title: "Review toggle position",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
})
|
||||
|
||||
for (const width of [1000, 1440]) {
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`keeps the review toggle at the outer header edge (${width}px, ${direction})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Review toggle position")
|
||||
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
|
||||
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const header = page.locator("[data-session-title]")
|
||||
const panel = page.locator("#review-panel")
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
const closed = await toggle.boundingBox()
|
||||
if (!closed) throw new Error("Review toggle bounds are unavailable")
|
||||
const headerBox = await header.boundingBox()
|
||||
if (!headerBox) throw new Error("Session header bounds are unavailable")
|
||||
expect(closed.y).toBeGreaterThanOrEqual(headerBox.y)
|
||||
expect(closed.y + closed.height).toBeLessThanOrEqual(headerBox.y + headerBox.height)
|
||||
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
||||
await expect(toggle).toHaveCount(1)
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await panel.boundingBox()
|
||||
if (!box) return false
|
||||
return (
|
||||
closed.x >= box.x &&
|
||||
closed.x + closed.width <= box.x + box.width &&
|
||||
closed.y >= box.y &&
|
||||
closed.y + closed.height <= box.y + 52
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await panel.locator('[data-slot="session-side-panel-actions"]').boundingBox()
|
||||
return box ? box.y + box.height / 2 : undefined
|
||||
})
|
||||
.toBe(closed.y + closed.height / 2)
|
||||
|
||||
await toggle.press("Enter")
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect(toggle).toHaveCount(1)
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
|
||||
})
|
||||
|
||||
test(`keeps terminal controls clear of the review toggle (${width}px, ${direction})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const ptys: { id: string; title: string }[] = []
|
||||
const removed: string[] = []
|
||||
await page.route("**/api/pty**", async (route) => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
const location = { directory, project: { id: "proj_review_toggle_position", directory } }
|
||||
if (route.request().method() === "DELETE") {
|
||||
removed.push(path.split("/").at(-1)!)
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (path.endsWith("/connect-token")) {
|
||||
return route.fulfill({ json: { location, data: { ticket: "e2e-ticket", expires_in: 60 } } })
|
||||
}
|
||||
if (path === "/api/pty" && route.request().method() === "POST") {
|
||||
const pty = { id: `pty_review_${ptys.length + 1}`, title: `Terminal ${ptys.length + 1}` }
|
||||
ptys.push(pty)
|
||||
return route.fulfill({ json: { location, data: pty } })
|
||||
}
|
||||
return route.fulfill({ json: { location, data: ptys.find((pty) => path.endsWith(pty.id)) ?? ptys } })
|
||||
})
|
||||
await page.routeWebSocket(/\/api\/pty\/pty_review_\d+\/connect/, () => undefined)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Review toggle position")
|
||||
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
|
||||
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.getByRole("region", { name: "Terminal", exact: true })
|
||||
await expect(terminal.getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
for (const number of [2, 3, 4]) {
|
||||
await terminal.getByRole("button", { name: "New terminal", exact: true }).click()
|
||||
await expect(terminal.getByRole("tab", { name: `Terminal ${number}`, exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tabs = await terminal.getByRole("tablist").boundingBox()
|
||||
const button = await toggle.boundingBox()
|
||||
if (!tabs || !button) return false
|
||||
return direction === "rtl" ? tabs.x >= button.x + button.width : tabs.x + tabs.width <= button.x
|
||||
})
|
||||
.toBe(true)
|
||||
await expectTerminalControlsAligned(terminal, toggle)
|
||||
const fourth = terminal.locator('[data-slot="tabs-trigger-wrapper"][data-value="pty_review_4"]')
|
||||
await fourth.getByRole("button", { name: "Close terminal", exact: true }).click()
|
||||
await expect(terminal.getByRole("tab")).toHaveText(["Terminal 1", "Terminal 2", "Terminal 3"])
|
||||
expect(removed).toEqual(["pty_review_4"])
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await terminal.getByRole("button", { name: "New terminal", exact: true }).click()
|
||||
await expect(terminal.getByRole("tab", { name: "Terminal 5", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
const position = await toggle.boundingBox()
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "false")
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(position)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const actions = await page.locator('[data-slot="session-side-panel-actions"]').boundingBox()
|
||||
const button = await toggle.boundingBox()
|
||||
if (!actions || !button) return undefined
|
||||
return actions.y + actions.height / 2 - (button.y + button.height / 2)
|
||||
})
|
||||
.toBe(0)
|
||||
await toggle.press("Enter")
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(position)
|
||||
await expectTerminalControlsAligned(terminal, toggle)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function expectTerminalControlsAligned(terminal: Locator, toggle: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const centers = await Promise.all(
|
||||
[terminal.getByRole("button", { name: "New terminal", exact: true }), toggle].map((button) =>
|
||||
button.locator("svg").evaluate((element) => {
|
||||
const svg = element as SVGSVGElement
|
||||
const path = svg.getBBox()
|
||||
return new DOMPoint(path.x + path.width / 2, path.y + path.height / 2).matrixTransform(svg.getScreenCTM()!)
|
||||
.y
|
||||
}),
|
||||
),
|
||||
)
|
||||
return centers[0]! - centers[1]!
|
||||
})
|
||||
.toBeCloseTo(0, 1)
|
||||
}
|
||||
@@ -23,7 +23,8 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const header = page.locator("[data-session-title]")
|
||||
const more = header.getByRole("button", { name: "More options", exact: true })
|
||||
const review = header.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const project = header.getByRole("button", { name: fixture.project.name, exact: true })
|
||||
const review = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const details = header.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
@@ -31,22 +32,40 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(details).toBeVisible()
|
||||
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
|
||||
await expect(status).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const boxes = await Promise.all(
|
||||
[header.getByRole("heading"), more, review, details].map((button) => button.boundingBox()),
|
||||
)
|
||||
const [title, menu, sidebar, summary] = boxes
|
||||
if (!title || !menu || !sidebar || !summary) return false
|
||||
return direction === "ltr"
|
||||
? Math.abs(title.x + title.width - menu.x) <= 1 &&
|
||||
menu.x + menu.width <= summary.x &&
|
||||
summary.x + summary.width <= sidebar.x
|
||||
: Math.abs(menu.x + menu.width - title.x) <= 1 &&
|
||||
sidebar.x + sidebar.width <= summary.x &&
|
||||
summary.x + summary.width <= menu.x
|
||||
})
|
||||
.toBe(true)
|
||||
const titleBounds = await header.getByRole("heading").boundingBox()
|
||||
expect(titleBounds).not.toBeNull()
|
||||
for (const editing of [false, true]) {
|
||||
if (editing) {
|
||||
await header.getByRole("heading").click()
|
||||
await expect(header.getByRole("textbox")).toHaveValue(fixture.expected.targetTitle)
|
||||
await expect(header.getByRole("textbox")).toBeFocused()
|
||||
}
|
||||
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-left", "4px")
|
||||
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-right", "4px")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const boxes = await Promise.all(
|
||||
[project, header.locator('[data-slot="session-title-child"]'), more, review, details].map((control) =>
|
||||
control.boundingBox(),
|
||||
),
|
||||
)
|
||||
const [icon, title, menu, sidebar, summary] = boxes
|
||||
if (!icon || !title || !menu || !sidebar || !summary || !titleBounds) return false
|
||||
if (Math.abs(title.y - titleBounds.y) > 0.5 || Math.abs(title.height - titleBounds.height) > 0.5) return false
|
||||
return direction === "ltr"
|
||||
? Math.abs(title.x - icon.x - icon.width - 2) <= 0.5 &&
|
||||
Math.abs(menu.x - title.x - title.width - 2) <= 0.5 &&
|
||||
menu.x + menu.width <= summary.x &&
|
||||
summary.x + summary.width <= sidebar.x
|
||||
: Math.abs(icon.x - title.x - title.width - 2) <= 0.5 &&
|
||||
Math.abs(title.x - menu.x - menu.width - 2) <= 0.5 &&
|
||||
sidebar.x + sidebar.width <= summary.x &&
|
||||
summary.x + summary.width <= menu.x
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
await header.getByRole("textbox").press("Escape")
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
|
||||
await review.click()
|
||||
await expect(review).toHaveAttribute("aria-expanded", "true")
|
||||
@@ -55,6 +74,52 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(review).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await more.click()
|
||||
const options = page.getByRole("menu")
|
||||
await expect(options.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"])
|
||||
if (direction === "ltr") {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [button, menu] = await Promise.all([
|
||||
header.getByRole("button", { name: "More options", exact: true, includeHidden: true }).boundingBox(),
|
||||
options.boundingBox(),
|
||||
])
|
||||
return button && menu ? Math.abs(button.x - menu.x) : Infinity
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
await expect
|
||||
.poll(() =>
|
||||
options.evaluate((element) => {
|
||||
const menu = element.getBoundingClientRect()
|
||||
const rtl = getComputedStyle(element).direction === "rtl"
|
||||
return Math.min(
|
||||
...Array.from(element.querySelectorAll('[data-slot="menu-v2-item-content"]'), (label) => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(label)
|
||||
const text = range.getBoundingClientRect()
|
||||
return rtl ? text.left - menu.left : menu.right - text.right
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBeCloseTo(32, 0)
|
||||
await expect
|
||||
.poll(() =>
|
||||
options.evaluate((element) => {
|
||||
const menu = element.getBoundingClientRect()
|
||||
const divider = element.querySelector('[data-slot="menu-v2-separator"]')?.getBoundingClientRect()
|
||||
const rows = Array.from(element.querySelectorAll('[role="menuitem"]'), (row) => row.getBoundingClientRect())
|
||||
return (
|
||||
!!divider &&
|
||||
Math.abs(divider.left - menu.left) <= 0.5 &&
|
||||
Math.abs(divider.right - menu.right) <= 0.5 &&
|
||||
rows.every(
|
||||
(row) => Math.abs(row.left - menu.left - 2) <= 0.5 && Math.abs(menu.right - row.right - 2) <= 0.5,
|
||||
)
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await status.click()
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { dict } from "../../src/runtime/i18n/ar"
|
||||
import en from "../../src/runtime/i18n/en"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
for (const workspace of [false, true]) {
|
||||
test(`session project menu for ${workspace ? "worktree" : "local"} in ${direction}`, async ({ page }) => {
|
||||
const copy = direction === "rtl" ? dict : en
|
||||
const directory = workspace
|
||||
? "C:/OpenCode/Worktrees/مشروع-42/long-folder-name-for-checking-wrapped-worktree-paths/another-long-folder-name-to-exercise-the-full-path-tooltip"
|
||||
: fixture.directory
|
||||
const project = {
|
||||
...fixture.project,
|
||||
name: workspace
|
||||
? "مشروع Timeline 42 with a long project name that needs truncation and enough additional text to wrap inside the tooltip"
|
||||
: "Timeline project",
|
||||
sandboxes: workspace ? [directory] : [],
|
||||
icon: {
|
||||
url: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="7" fill="blue"/></svg>')}`,
|
||||
},
|
||||
}
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript((direction) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:language",
|
||||
JSON.stringify({ locale: direction === "rtl" ? "ar" : "en" }),
|
||||
)
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, general: { ...settings.general, showProjectIcon: false } }),
|
||||
)
|
||||
}, direction)
|
||||
await page.setViewportSize({ width: workspace ? 900 : 1440, height: 900 })
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const header = page.locator("[data-session-title]")
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", direction)
|
||||
|
||||
const trigger = header.getByRole("button", { name: project.name, exact: true })
|
||||
await expect(trigger).toBeEnabled()
|
||||
await expect(trigger.locator("use")).toHaveAttribute(
|
||||
"href",
|
||||
`#opencode-v2-icon-${workspace ? "workspace-isolated" : "monitor"}`,
|
||||
)
|
||||
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
await trigger.hover()
|
||||
await expect(trigger).not.toHaveCSS("background-color", background)
|
||||
await expect(page.getByRole("tooltip")).toHaveText(project.name)
|
||||
await trigger.click()
|
||||
|
||||
const menu = page.getByRole("menu", { name: project.name, exact: true })
|
||||
const settings = menu.getByRole("menuitem", { name: "Edit project", exact: true })
|
||||
const projectItem = menu.getByRole("menuitem", { name: project.name, exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.getByRole("tooltip")).toBeHidden()
|
||||
await expect(menu.getByText(project.name, { exact: true })).toBeVisible()
|
||||
await expect(menu.locator('[data-slot="project-avatar-image"]')).toHaveAttribute("src", project.icon.url)
|
||||
await expect(menu.getByText(directory, { exact: true })).toBeVisible()
|
||||
await expect(menu.getByText(directory, { exact: true })).toHaveAttribute("dir", "ltr")
|
||||
await expect(menu.locator('use[href="#opencode-v2-icon-folder"]')).toHaveCount(1)
|
||||
await expect(menu).toHaveCSS("direction", direction)
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([project.name, directory, "Edit project"])
|
||||
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
|
||||
await expect(settings).toBeEnabled()
|
||||
await expect
|
||||
.poll(() => menu.evaluate((element) => element.getBoundingClientRect().width))
|
||||
.toBeLessThanOrEqual(320)
|
||||
for (const text of [project.name, directory]) {
|
||||
const label = menu.getByText(text, { exact: true })
|
||||
await expect(label).toHaveCSS("text-overflow", "ellipsis")
|
||||
await expect(label).toHaveCSS("white-space", "nowrap")
|
||||
if (workspace) {
|
||||
await expect.poll(() => label.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
|
||||
}
|
||||
}
|
||||
await expect.poll(() => menu.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
const icons = menu.locator(
|
||||
'[data-component="project-avatar-v2"], [data-slot="icon-svg"]:not([data-slot="session-project-open-icon"] *)',
|
||||
)
|
||||
await expect(icons).toHaveCount(3)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [button, centers] = await Promise.all([
|
||||
trigger.boundingBox(),
|
||||
icons.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
return box.x + box.width / 2
|
||||
}),
|
||||
),
|
||||
])
|
||||
return !!button && centers.every((center) => Math.abs(center - button.x - button.width / 2) <= 1)
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
if (!workspace) await page.clock.install()
|
||||
for (const text of [project.name, directory]) {
|
||||
const label = menu.getByText(text, { exact: true })
|
||||
const item = menu.getByRole("menuitem", { name: text, exact: true })
|
||||
const anchor = item.locator("..")
|
||||
const openIcon = item.locator('[data-slot="session-project-open-icon"]')
|
||||
const content = item.locator(".session-project-link-content")
|
||||
const width = await label.evaluate((element) => element.getBoundingClientRect().width)
|
||||
await expect(openIcon).toHaveCount(text === directory ? 1 : 0)
|
||||
await anchor.hover()
|
||||
await expect(content).toHaveCSS("mask-image", "none")
|
||||
await expect.poll(() => label.evaluate((element) => element.getBoundingClientRect().width)).toBe(width)
|
||||
if (text === directory) {
|
||||
await expect(openIcon).toHaveCSS("opacity", "0")
|
||||
await expect(openIcon.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-up-right")
|
||||
await expect
|
||||
.poll(() => openIcon.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [row, icon] = await Promise.all([item.boundingBox(), openIcon.boundingBox()])
|
||||
if (!row || !icon) return false
|
||||
return (
|
||||
Math.abs(row.y + row.height / 2 - icon.y - icon.height / 2) <= 0.5 &&
|
||||
Math.abs((direction === "rtl" ? icon.x - row.x : row.x + row.width - icon.x - icon.width) - 12) <= 0.5
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
await expect(label).toHaveCSS("cursor", "default")
|
||||
await expect(anchor).toHaveCSS("cursor", "default")
|
||||
const tooltip = page.getByRole("tooltip")
|
||||
if (workspace) {
|
||||
await expect(tooltip).toHaveText(text)
|
||||
await expect(tooltip).toHaveCSS("white-space", "normal")
|
||||
await expect
|
||||
.poll(() => tooltip.evaluate((element) => element.getBoundingClientRect().width))
|
||||
.toBeLessThanOrEqual(480)
|
||||
await expect
|
||||
.poll(() =>
|
||||
tooltip
|
||||
.getByText(text, { exact: true })
|
||||
.evaluate(
|
||||
(element) =>
|
||||
element.getBoundingClientRect().height > Number.parseFloat(getComputedStyle(element).lineHeight),
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [row, tip] = await Promise.all([anchor.boundingBox(), tooltip.boundingBox()])
|
||||
return !!row && !!tip && Math.abs(row.y - tip.y - tip.height - 2) <= 1
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
if (!workspace) {
|
||||
await page.clock.runFor(500)
|
||||
await expect(tooltip).toBeHidden()
|
||||
}
|
||||
await settings.hover()
|
||||
await expect(tooltip).toBeHidden()
|
||||
if (text === directory) await expect(openIcon).toHaveCSS("opacity", "0")
|
||||
await expect(content).toHaveCSS("mask-image", "none")
|
||||
}
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await trigger.press("ArrowDown")
|
||||
await expect(projectItem).toBeFocused()
|
||||
await page.keyboard.press("ArrowDown")
|
||||
const pathItem = menu.getByRole("menuitem", { name: directory, exact: true })
|
||||
await expect(pathItem).toBeFocused()
|
||||
if (workspace) await expect(page.getByRole("tooltip")).toHaveText(directory)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(pathItem).toBeFocused()
|
||||
await page.keyboard.press("Space")
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(pathItem).toBeFocused()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await trigger.press("ArrowDown")
|
||||
await expect(projectItem).toBeFocused()
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await expect(pathItem).toBeFocused()
|
||||
if (workspace) await expect(page.getByRole("tooltip")).toHaveText(directory)
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await expect(settings).toBeFocused()
|
||||
await expect(page.getByRole("tooltip")).toBeHidden()
|
||||
await page.keyboard.press("Enter")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
|
||||
project.name,
|
||||
)
|
||||
await expect(menu).toBeHidden()
|
||||
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
for (const selected of [false, true]) {
|
||||
if (selected) {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
}
|
||||
await trigger.click()
|
||||
await expect(projectItem).toBeEnabled()
|
||||
const background = await projectItem.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
await projectItem.hover()
|
||||
await expect(projectItem).not.toHaveCSS("background-color", background)
|
||||
await projectItem.click()
|
||||
await expect(page).toHaveURL(new URL("/", page.url()).href)
|
||||
await expect(menu).toBeHidden()
|
||||
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: project.name })
|
||||
await expect(projectRow).toBeVisible()
|
||||
await expect(projectRow).toHaveAttribute("data-selected", "")
|
||||
await expect(
|
||||
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of ["closed", "unopened"] as const) {
|
||||
test(`session project menu restores ${state} projects before and after messages load`, async ({ page }) => {
|
||||
const directory = "C:/OpenCode/Worktrees/project-menu-recovery"
|
||||
const messages = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: { ...fixture.project, sandboxes: [directory] },
|
||||
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
beforeMessagesResponse: (input) => (input.sessionID === fixture.targetID ? messages.promise : Promise.resolve()),
|
||||
})
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
if (state === "closed") {
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto("/")
|
||||
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name })
|
||||
await expect(projectRow).toBeEnabled()
|
||||
await projectRow.locator("..").getByRole("button", { name: "More options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
|
||||
await expect(projectRow).toHaveCount(0)
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
}
|
||||
if (state === "unopened") await page.goto(stressSessionHref(fixture.targetID))
|
||||
|
||||
const header = page.locator("[data-session-title]")
|
||||
const trigger = header.getByRole("button", { name: fixture.project.name, exact: true })
|
||||
const menu = page.getByRole("menu", { name: fixture.project.name, exact: true })
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
for (const loaded of [false, true]) {
|
||||
if (loaded) {
|
||||
messages.resolve()
|
||||
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
|
||||
}
|
||||
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-workspace-isolated")
|
||||
await trigger.click()
|
||||
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
|
||||
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
|
||||
await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue(
|
||||
fixture.project.name,
|
||||
)
|
||||
await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
await trigger.click()
|
||||
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
|
||||
await expect(page).toHaveURL(new URL("/", page.url()).href)
|
||||
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name })
|
||||
await expect(projectRow).toBeVisible()
|
||||
await expect(projectRow).toHaveAttribute("data-selected", "")
|
||||
await expect(
|
||||
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("path arrow has a glyph when the page has an older icon sprite", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.route(
|
||||
(url) => url.pathname === stressSessionHref(fixture.targetID),
|
||||
async (route) => {
|
||||
const response = await route.fetch()
|
||||
await route.fulfill({
|
||||
response,
|
||||
body: (await response.text()).replace(
|
||||
'<div id="root"',
|
||||
'<svg id="opencode-v2-icon-sprite" width="0" height="0" aria-hidden="true"><symbol id="opencode-v2-icon-monitor" viewBox="0 0 16 16"><path d="M1 1h14v14H1z"/></symbol></svg><div id="root"',
|
||||
),
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const header = page.locator("[data-session-title]")
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
await header.getByRole("button", { name: fixture.project.name, exact: true }).click()
|
||||
const path = page.getByRole("menu").getByRole("menuitem", { name: fixture.directory, exact: true })
|
||||
const arrow = path.locator('[data-slot="session-project-open-icon"]')
|
||||
await expect(arrow).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() => arrow.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
|
||||
.toBeGreaterThan(0)
|
||||
await expect(page.locator("#opencode-v2-icon-sprite")).toHaveCount(1)
|
||||
})
|
||||
@@ -27,6 +27,12 @@ test.beforeEach(async ({ page }) => {
|
||||
})),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
}, directory)
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
|
||||
@@ -50,6 +56,34 @@ test("settings has its own route and returns through app history", async ({ page
|
||||
await expect(home).toHaveAttribute("aria-pressed", "true")
|
||||
})
|
||||
|
||||
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
await page.keyboard.press("Control+t")
|
||||
|
||||
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("recording a new session shortcut stays in settings until recording finishes", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
|
||||
const binding = settings.locator('[data-keybind-id="tab.new"]')
|
||||
await binding.click()
|
||||
await expect(binding).toHaveText("Press keys")
|
||||
await page.keyboard.press("Control+t")
|
||||
|
||||
await expect(binding).toHaveText("Ctrl+T")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(page.locator("[data-titlebar-tab]")).toHaveCount(0)
|
||||
await page.keyboard.press("Control+t")
|
||||
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
})
|
||||
|
||||
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
@@ -105,6 +139,79 @@ test("extensions opens without waiting for MCPs", async ({ page }) => {
|
||||
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
|
||||
})
|
||||
|
||||
test("about opens without waiting for contributors", async ({ page }) => {
|
||||
const contributors = Promise.withResolvers<void>()
|
||||
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
|
||||
await page.route(url, async (route) => {
|
||||
await contributors.promise
|
||||
await route.fulfill({
|
||||
json: [],
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-expose-headers": "Link",
|
||||
Link: `<${url}&page=1004>; rel="last"`,
|
||||
},
|
||||
})
|
||||
})
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest(url)
|
||||
await settings.getByRole("tab", { name: "About", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText(/^Version /)).toBeVisible()
|
||||
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByText(/^v\d+\./)).toHaveCount(0)
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
|
||||
|
||||
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
|
||||
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await settings.getByRole("tab", { name: "About", exact: true }).click()
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
|
||||
const website = settings.getByRole("link", { name: "www.opencode.ai", exact: true })
|
||||
await website.focus()
|
||||
contributors.resolve()
|
||||
await expect(settings.getByRole("link", { name: "988 others", exact: true })).toBeVisible()
|
||||
await expect(website).toBeFocused()
|
||||
})
|
||||
|
||||
test("about is available in the mobile settings menu", async ({ page }) => {
|
||||
await page.route("https://api.github.com/repos/anomalyco/opencode/contributors?*", (route) => route.abort("failed"))
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("button", { name: "Preferences", exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name: "About", exact: true }).click()
|
||||
await expect(settings.getByRole("button", { name: "About", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
|
||||
await settings.getByRole("button", { name: "About", exact: true }).click()
|
||||
await expect(page.getByRole("menuitemradio", { name: "About", exact: true })).toBeChecked()
|
||||
})
|
||||
|
||||
test("about keeps its fallback when the contributor request fails", async ({ page }) => {
|
||||
const contributors = Promise.withResolvers<void>()
|
||||
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
|
||||
await page.route(url, async (route) => {
|
||||
await contributors.promise
|
||||
await route.abort("failed")
|
||||
})
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest(url)
|
||||
await settings.getByRole("tab", { name: "About", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
|
||||
const failed = page.waitForEvent("requestfailed", (request) => request.url() === url)
|
||||
contributors.resolve()
|
||||
await failed
|
||||
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
test("workspace inventory uses the settings panel scroll area", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
|
||||
@@ -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,167 @@ 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 Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
|
||||
)
|
||||
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(`/server/${base64Encode(server)}/session/${sessionA.id}`)
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
|
||||
const status = sidebar.getByRole("button", { name: "Status", 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(status).toHaveText("Status")
|
||||
await expect(settings).toHaveCount(0)
|
||||
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 360 })
|
||||
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
|
||||
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
|
||||
await expect(status).toBeInViewport({ ratio: 1 })
|
||||
await expect(status).toHaveCSS("height", "28px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
|
||||
const content = Math.max(
|
||||
0,
|
||||
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
|
||||
)
|
||||
return element.getBoundingClientRect().height - content
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
|
||||
await scroll.evaluate((element) => element.scrollTo(0, 0))
|
||||
await expect(scroll).toHaveJSProperty("scrollTop", 0)
|
||||
const pinnedStatus = await status.boundingBox()
|
||||
await scroll.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await testInfo.attach(`vertical-tabs-status-${width}`, {
|
||||
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(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await expect(settings).toHaveCount(0)
|
||||
}
|
||||
|
||||
await status.click()
|
||||
await expect(status).toHaveAttribute("aria-expanded", "true")
|
||||
await status.press("Escape")
|
||||
await expect(status).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
|
||||
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 +410,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 +438,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 +482,7 @@ test("appearance experimental settings control vertical tab details", async ({ p
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
|
||||
await page.keyboard.press("Control+,")
|
||||
await settings.getByRole("tab", { name: "Appearance" }).click()
|
||||
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
|
||||
await expect(layout).toContainText("Vertical")
|
||||
})
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,57 @@
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "./mock-server"
|
||||
import { APP_READY_TIMEOUT } from "./waits"
|
||||
|
||||
export const paletteSession = {
|
||||
id: "ses_command_palette",
|
||||
projectID: "proj_command_palette",
|
||||
directory: "C:/OpenCode/CommandPalette",
|
||||
title: "Palette fixture session",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
}
|
||||
|
||||
export function captureConsoleWarnings(page: Page) {
|
||||
const warnings: string[] = []
|
||||
page.on("console", (message) => {
|
||||
if (message.type() !== "warning" && message.type() !== "error") return
|
||||
// This message comes from test isolation, not application code.
|
||||
if (message.text() === "Service Worker registration blocked by Playwright") return
|
||||
warnings.push(message.text())
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
export async function openCommandPalette(page: Page, home = false) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: paletteSession.directory,
|
||||
project: {
|
||||
id: paletteSession.projectID,
|
||||
worktree: paletteSession.directory,
|
||||
vcs: "git",
|
||||
name: "command-palette",
|
||||
time: paletteSession.time,
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [paletteSession],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
findFiles: () => [],
|
||||
})
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(home ? "/" : `/server/${base64Encode(server)}/session/${paletteSession.id}`)
|
||||
if (home) {
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }),
|
||||
).toBeEnabled({ timeout: APP_READY_TIMEOUT })
|
||||
}
|
||||
if (!home) {
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable({ timeout: APP_READY_TIMEOUT })
|
||||
}
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
const dialog = page.getByRole("dialog")
|
||||
const input = dialog.getByRole("textbox")
|
||||
await expect(input).toBeFocused()
|
||||
await expect(dialog.getByRole("option")).not.toHaveCount(0)
|
||||
return { dialog, input }
|
||||
}
|
||||
@@ -575,7 +575,7 @@ export function ComposerEditorAddMenu(props: {
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
style={{ "min-width": "180px" }}
|
||||
>
|
||||
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
|
||||
|
||||
@@ -251,6 +251,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
const submission = createComposerSubmit({
|
||||
adapter,
|
||||
mode,
|
||||
commands: () => data.location.command.list({ directory: sdk().directory }),
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
|
||||
@@ -52,10 +52,12 @@ function submitInput(
|
||||
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
mode: () => mode,
|
||||
commands,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory() {},
|
||||
@@ -420,7 +422,6 @@ describe("Composer submission", () => {
|
||||
prompt: async () => undefined,
|
||||
command: async (value) => sent.resolve(value),
|
||||
})
|
||||
target.data.location.command.list = () => [{ name: "review", description: "Review changes", template: "" }]
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
@@ -433,7 +434,7 @@ describe("Composer submission", () => {
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
|
||||
const request = await sent.promise
|
||||
|
||||
expect(request.files).toMatchObject([{ name: "app.ts", mention: { text: "@src/app.ts" } }])
|
||||
@@ -442,6 +443,51 @@ describe("Composer submission", () => {
|
||||
expect(request.delivery).toBe("steer")
|
||||
})
|
||||
|
||||
test("captures commands before creating a session in a new worktree", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/review https://github.com/example/repo/pull/1" }).capture()
|
||||
const catalog = [{ name: "review" }]
|
||||
const sent = Promise.withResolvers<"prompt" | "command">()
|
||||
const requests: Parameters<ComposerSession["api"]["command"]>[0][] = []
|
||||
const target = session({
|
||||
calls: [],
|
||||
prompt: async () => sent.resolve("prompt"),
|
||||
command: async (value) => {
|
||||
requests.push(value)
|
||||
sent.resolve("command")
|
||||
},
|
||||
})
|
||||
target.directory = "C:/new-worktree"
|
||||
target.data.location.command.list = () => undefined
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start() {
|
||||
// The destination catalog has not loaded, and the source composer is leaving.
|
||||
catalog.splice(0)
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
},
|
||||
}
|
||||
|
||||
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
|
||||
|
||||
expect(await sent.promise).toBe("command")
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
sessionID: target.id,
|
||||
command: "review",
|
||||
text: "https://github.com/example/repo/pull/1",
|
||||
files: [],
|
||||
agents: [],
|
||||
skills: [],
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("does not run an empty shell command from hidden attachments", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
state.set([
|
||||
|
||||
@@ -27,6 +27,7 @@ type ComposerSubmission = {
|
||||
type ComposerSubmitInput = {
|
||||
adapter: ComposerAdapter
|
||||
mode: Accessor<"normal" | "shell">
|
||||
commands: Accessor<readonly { name: string }[] | undefined>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
@@ -65,6 +66,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
submitting.add(input.adapter.state)
|
||||
const comments = input.comments.capture()
|
||||
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
|
||||
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
|
||||
|
||||
try {
|
||||
const started =
|
||||
@@ -78,7 +81,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
input.resetHistory()
|
||||
const restore = () => restoreSubmission(input, submission, value, comments)
|
||||
|
||||
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
@@ -277,12 +279,11 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
|
||||
await session.api.shell({ sessionID: session.id, id: Event.ID.create(), command: value.text })
|
||||
}
|
||||
|
||||
function findCommand(session: ComposerSession, text: string) {
|
||||
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
|
||||
if (!text.startsWith("/")) return
|
||||
const [name, ...arguments_] = text.split(" ")
|
||||
const command = name.slice(1)
|
||||
if (!session.data.location.command.list({ directory: session.directory })?.some((item) => item.name === command))
|
||||
return
|
||||
if (!commands?.some((item) => item.name === command)) return
|
||||
return { command, arguments: arguments_.join(" ") }
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +52,9 @@ export function HomeCommandPalette(props: {
|
||||
}
|
||||
if (item.type === "session") props.onSelectSession(item)
|
||||
}
|
||||
const loadItems = async (text: string) => {
|
||||
const query = text.trim()
|
||||
const items = (query: string) => {
|
||||
if (!query) return commandEntries().slice(0, 5)
|
||||
return [...commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)), ...(await sessions(query))]
|
||||
return commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query))
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -66,7 +65,8 @@ export function HomeCommandPalette(props: {
|
||||
return (
|
||||
<CommandPaletteView
|
||||
placeholder={language.t("palette.search.placeholder.home")}
|
||||
loadItems={loadItems}
|
||||
items={items}
|
||||
sources={[sessions]}
|
||||
highlight={highlight}
|
||||
select={select}
|
||||
close={() => dialog.close()}
|
||||
|
||||
@@ -812,6 +812,10 @@ export const dict = {
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
"titlebar.channel.local": "Local",
|
||||
"titlebar.channel.dev": "Dev",
|
||||
"titlebar.channel.beta": "Beta",
|
||||
"titlebar.toggleDebugTools": "Toggle debug tools",
|
||||
"titlebar.updateVersion": "Update {{version}}",
|
||||
|
||||
"common.closeTab": "Close tab",
|
||||
@@ -908,6 +912,25 @@ export const dict = {
|
||||
"settings.tab.notifications": "Notifications",
|
||||
"settings.tab.projects": "Projects",
|
||||
"settings.tab.extensions": "Extensions",
|
||||
"settings.tab.experimental": "Experimental",
|
||||
"settings.experimental.description": "Try experimental features",
|
||||
"settings.tab.about": "About",
|
||||
"settings.about.version": "Version {{version}}",
|
||||
"settings.about.devVersion": "development",
|
||||
"settings.about.license": "Released under the MIT License",
|
||||
"settings.about.writtenBy": "Written by",
|
||||
"settings.about.illustratedBy": "Illustrated by",
|
||||
"settings.about.and": "and",
|
||||
"settings.about.otherContributor.one": "{{count}} other",
|
||||
"settings.about.otherContributor.other": "{{count}} others",
|
||||
"settings.about.firstPublished": "First published in Missouri, USA",
|
||||
"settings.about.firstIllustrated": "First illustrated in London, England",
|
||||
"settings.about.website": "www.opencode.ai",
|
||||
"settings.about.description": "OpenCode, the open source coding agent",
|
||||
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
|
||||
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
|
||||
"settings.about.tagline": "AI can’t build great software, without you",
|
||||
"settings.about.copyright": "© Anomaly Innovations, Inc.",
|
||||
"settings.preferences.description": "Customize preferences and theme and default behavior",
|
||||
"settings.appearance.description": "Customize theme and fonts",
|
||||
"settings.appearance.section.experimental": "Experimental",
|
||||
@@ -935,6 +958,7 @@ export const dict = {
|
||||
"settings.desktop.wsl.title": "WSL integration",
|
||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||
"dialog.server.authenticate.title": "Authenticate",
|
||||
"project.settings.title": "Edit project",
|
||||
"project.settings.general.description": "Manage project name and appearance",
|
||||
"project.settings.scripts": "Scripts",
|
||||
"project.settings.scripts.description": "Configure scripts for this project",
|
||||
|
||||
@@ -13,6 +13,9 @@ import { createServerNotificationState } from "@/shell/notifications/notificatio
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { createDesktopData } from "./data"
|
||||
import { ModelState } from "./persistence"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "./errors"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -127,6 +130,7 @@ function createServerController(
|
||||
scope: ServerScope,
|
||||
projects: ReturnType<typeof createServerProjects>,
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const connKey = ServerConnection.key(conn)
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const source = createData({
|
||||
@@ -137,6 +141,13 @@ function createServerController(
|
||||
},
|
||||
connection: sdk.connection,
|
||||
directory: "",
|
||||
onError(error) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: formatServerError(error, language.t),
|
||||
})
|
||||
},
|
||||
})
|
||||
const data = createDesktopData({
|
||||
data: source,
|
||||
|
||||
@@ -321,9 +321,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}),
|
||||
tab &&
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
id: "file.close",
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
keybind: settings.keybinds.get("tab.close") ?? "mod+w",
|
||||
when: (event) => !(event.target instanceof Element && event.target.closest('[data-component="terminal"]')),
|
||||
onSelect: closeTab,
|
||||
}),
|
||||
].filter((v) => !!v)
|
||||
|
||||
@@ -170,10 +170,10 @@ export function SessionFileBrowserTab(props: {
|
||||
when={!props.placeholder}
|
||||
fallback={
|
||||
<SessionFilePanelV2Empty>
|
||||
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" />
|
||||
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
|
||||
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" class="mb-2" />
|
||||
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
|
||||
</div>
|
||||
</SessionFilePanelV2Empty>
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ export function SessionSidePanel(props: {
|
||||
return active !== "review" && active !== "context" && active !== "empty"
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
createEffect(() => {
|
||||
if (!file.ready()) return
|
||||
|
||||
@@ -417,7 +417,7 @@ export function SessionSidePanel(props: {
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" />}
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() => openFileBrowser()}
|
||||
@@ -427,11 +427,16 @@ export function SessionSidePanel(props: {
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<div
|
||||
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
|
||||
data-slot="session-side-panel-actions"
|
||||
class="session-review-v2-open-in-app-slot self-start shrink-0 flex items-center gap-2 pe-3"
|
||||
classList={{ "h-[51px]": props.stacked, "h-12": !props.stacked }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<OpenInAppButton directory={projectDirectory} />
|
||||
<Show when={reviewOpen()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export function SortableTab(props: {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.tab
|
||||
|
||||
@@ -3,6 +3,28 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
|
||||
export function SessionReviewToggle() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const { view } = useSessionLayout()
|
||||
|
||||
return (
|
||||
<SessionHeaderActions
|
||||
state={{
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: true,
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type SessionHeaderActionsState = {
|
||||
reviewLabel: string
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
|
||||
|
||||
export function SessionHeader() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const { view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const actions = createMemo<SessionHeaderActionsState>(() => ({
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitlebarRight>
|
||||
@@ -35,7 +23,9 @@ export function SessionHeader() {
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</TitlebarRight>
|
||||
<SessionHeaderActions state={actions()} />
|
||||
<Show when={isDesktop() && !view().reviewPanel.opened()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { SessionContextTab } from "./files/session-context-tab"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
@@ -274,10 +275,19 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
{/* Keep the control outside panel animations; the terminal's 52px header includes a 1px divider. */}
|
||||
<Show when={isDesktop() && messagesReady() && session.identity.params.id}>
|
||||
<div
|
||||
class="absolute end-3 top-0 z-30 flex items-center"
|
||||
classList={{ "h-[51px]": sideTerminalVisible(), "h-12": !sideTerminalVisible() }}
|
||||
data-slot="session-review-toggle"
|
||||
>
|
||||
<SessionReviewToggle />
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]":
|
||||
true,
|
||||
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!screen.size.active() && sidePresence.animate(),
|
||||
"transition-none": screen.size.active() || !sidePresence.animate(),
|
||||
@@ -408,6 +418,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
present={store.sideTerminalPresent}
|
||||
animate={sidePresence.animate() || sideMotion().animateTerminal}
|
||||
contentHeight={screen.side.terminal.contentHeight()}
|
||||
reserveReviewToggle={!screen.side.region.open()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Informational tooltips must not block menu items. Kobalte sets pointer events inline. */
|
||||
[data-popper-positioner]:has(.session-project-info-tooltip),
|
||||
.session-project-info-tooltip {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.session-options-menu [data-component="menu-v2-item"] {
|
||||
padding-inline-end: 30px;
|
||||
}
|
||||
|
||||
.session-project-link {
|
||||
--session-project-fade-direction: to right;
|
||||
position: relative;
|
||||
|
||||
&:dir(rtl) {
|
||||
--session-project-fade-direction: to left;
|
||||
}
|
||||
|
||||
&[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.session-project-link-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-project-link-open {
|
||||
position: absolute;
|
||||
inset-inline-end: 12px;
|
||||
inset-block: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.session-project-link:is(:hover, [data-highlighted]):not([aria-disabled="true"]) {
|
||||
.session-project-link-content {
|
||||
mask-image: linear-gradient(
|
||||
var(--session-project-fade-direction),
|
||||
#000 calc(100% - 40px),
|
||||
transparent calc(100% - 24px)
|
||||
);
|
||||
}
|
||||
|
||||
.session-project-link-open {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
|
||||
import { tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { sessionTitle } from "./title"
|
||||
import "./session-identity-header.css"
|
||||
|
||||
export function SessionTitleHeader(props: ParentProps) {
|
||||
return (
|
||||
@@ -25,6 +34,173 @@ export function SessionTitleHeader(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionProjectMenu(props: {
|
||||
project?: Omit<LocalProject, "expanded">
|
||||
directory?: string
|
||||
workspace: boolean
|
||||
showProjectIcon: boolean
|
||||
}) {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const platform = usePlatform()
|
||||
const layout = useLayout()
|
||||
const navigate = useNavigate()
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
projectTruncated: false,
|
||||
pathTruncated: false,
|
||||
pathFocused: false,
|
||||
})
|
||||
const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
|
||||
const canOpenPath = () =>
|
||||
platform.platform === "desktop" && !!platform.openPath && server.isLocal && !!props.directory
|
||||
const openPath = () => {
|
||||
if (!canOpenPath() || !platform.openPath || !props.directory) return
|
||||
void platform.openPath(props.directory).catch((cause: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const openProjectSettings = async () => {
|
||||
const current = props.project
|
||||
if (!current) return
|
||||
const { DialogEditProject } = await import("@/settings/workspaces/project-dialog")
|
||||
dialog.push(() => <DialogEditProject project={{ expanded: false, ...current }} server={server.conn} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
shift={-10}
|
||||
modal={false}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState({ open, pathFocused: false })}
|
||||
>
|
||||
<Tooltip placement="bottom" value={<bdi>{projectName()}</bdi>} class="flex shrink-0">
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
variant="ghost-muted"
|
||||
aria-label={projectName()}
|
||||
data-slot="session-project-trigger"
|
||||
icon={
|
||||
<Show
|
||||
when={props.showProjectIcon}
|
||||
fallback={
|
||||
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
|
||||
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={projectName()}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-max max-w-[min(320px,calc(100vw-16px))]" aria-label={projectName()}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
gutter={2}
|
||||
disabled={!state.projectTruncated}
|
||||
class="min-w-0 cursor-default"
|
||||
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
|
||||
value={<bdi>{projectName()}</bdi>}
|
||||
>
|
||||
<Menu.Item
|
||||
class="min-w-0 w-full"
|
||||
disabled={!props.project}
|
||||
onSelect={() => {
|
||||
const project = props.project
|
||||
if (!project) return
|
||||
server.ctx.projects.open(project.worktree)
|
||||
layout.home.setSelection({ server: server.key, directory: project.worktree })
|
||||
navigate("/")
|
||||
}}
|
||||
>
|
||||
<span class="session-project-link-content">
|
||||
<ProjectAvatar
|
||||
class="shrink-0"
|
||||
aria-hidden="true"
|
||||
fallback={projectName()}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
/>
|
||||
<bdi
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, () =>
|
||||
setState("projectTruncated", element.scrollWidth > element.clientWidth),
|
||||
)
|
||||
}
|
||||
class="min-w-0 truncate text-13-medium"
|
||||
>
|
||||
{projectName()}
|
||||
</bdi>
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
gutter={2}
|
||||
disabled={!state.pathTruncated}
|
||||
forceOpen={state.pathFocused && state.pathTruncated ? true : undefined}
|
||||
class="min-w-0 cursor-default"
|
||||
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
|
||||
value={<bdi dir="ltr">{props.directory}</bdi>}
|
||||
>
|
||||
{/* Read-only paths stay in keyboard navigation so their full tooltip remains accessible. */}
|
||||
<Menu.Item
|
||||
class="session-project-link min-w-0 w-full cursor-default"
|
||||
disabled={!props.directory}
|
||||
aria-disabled={!canOpenPath()}
|
||||
closeOnSelect={canOpenPath()}
|
||||
onSelect={openPath}
|
||||
onFocus={() => setState("pathFocused", true)}
|
||||
onBlur={() => setState("pathFocused", false)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setState({ open: false, pathFocused: false })
|
||||
}}
|
||||
>
|
||||
<span class="session-project-link-content">
|
||||
<Icon name="folder" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<bdi
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, () =>
|
||||
setState("pathTruncated", element.scrollWidth > element.clientWidth),
|
||||
)
|
||||
}
|
||||
dir="ltr"
|
||||
class="min-w-0 truncate text-v2-text-text-muted"
|
||||
>
|
||||
{props.directory}
|
||||
</bdi>
|
||||
</span>
|
||||
<span data-slot="session-project-open-icon" class="session-project-link-open" aria-hidden="true">
|
||||
<Icon name="arrow-up-right" />
|
||||
</span>
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
<Menu.Separator />
|
||||
<Menu.Item disabled={!props.project} onSelect={() => void openProjectSettings()}>
|
||||
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
|
||||
{language.t("project.settings.title")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
@@ -71,12 +247,17 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
)
|
||||
const project = createMemo(() => {
|
||||
const projects = server.ctx.projects.list()
|
||||
if (props.session) return projectForSession(props.session, projects)
|
||||
if (props.session)
|
||||
return (
|
||||
projectForSession(props.session, projects) ?? projectForSession(props.session, server.ctx.sync.data.project)
|
||||
)
|
||||
const value = directory()
|
||||
if (!value) return undefined
|
||||
const key = pathKey(value)
|
||||
return projects.find(
|
||||
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
return (
|
||||
projects.find(
|
||||
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
) ?? server.ctx.sync.data.project.find((item) => isProjectDirectory(item, value))
|
||||
)
|
||||
})
|
||||
const showProjectIcon = () =>
|
||||
@@ -95,25 +276,13 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
<SessionTitleHeader>
|
||||
<div class="flex h-12 w-full items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<div class="flex min-w-0 w-full flex-1 items-center">
|
||||
<span
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": workspaceSession() && !showProjectIcon(),
|
||||
"text-v2-icon-icon-muted": !workspaceSession() && !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={showProjectIcon()}
|
||||
fallback={<Icon name={workspaceSession() ? "workspace-isolated" : "monitor"} />}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(project() ?? { worktree: directory() ?? "" })}
|
||||
src={getProjectAvatarSource(project()?.id, project()?.icon)}
|
||||
variant={getProjectAvatarVariant(project()?.icon?.color)}
|
||||
/>
|
||||
</Show>
|
||||
</span>
|
||||
<div class="flex min-w-0 w-full flex-1 items-center gap-0.5">
|
||||
<SessionProjectMenu
|
||||
project={project()}
|
||||
directory={directory()}
|
||||
workspace={workspaceSession()}
|
||||
showProjectIcon={showProjectIcon()}
|
||||
/>
|
||||
<Show when={parentTitle()}>
|
||||
{(value) => (
|
||||
<button
|
||||
@@ -141,7 +310,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
<h1
|
||||
data-slot={parentID() ? "session-title-child" : undefined}
|
||||
dir="auto"
|
||||
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
|
||||
class="w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
|
||||
>
|
||||
{value()}
|
||||
</h1>
|
||||
|
||||
@@ -45,6 +45,7 @@ export function TerminalPanel(
|
||||
contentHeight?: string
|
||||
embedded?: boolean
|
||||
animate?: boolean
|
||||
reserveReviewToggle?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const terminal = useTerminal()
|
||||
@@ -249,7 +250,10 @@ export function TerminalPanel(
|
||||
when={terminal.ready() || store.surfaces.length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
|
||||
<div
|
||||
class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden"
|
||||
classList={{ "pe-12": props.reserveReviewToggle }}
|
||||
>
|
||||
<For each={handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
@@ -291,46 +295,53 @@ export function TerminalPanel(
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
variant="panel"
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-[52px] !flex-none"
|
||||
>
|
||||
<Tabs.List
|
||||
ref={tabList}
|
||||
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const active = document.activeElement
|
||||
if (event.target === active) return
|
||||
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
|
||||
}}
|
||||
<div class="h-[52px] shrink-0 flex border-b border-border-weaker-base">
|
||||
<Tabs
|
||||
variant="panel"
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-full min-w-0 !flex-1"
|
||||
>
|
||||
<For each={all()}>
|
||||
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
|
||||
</For>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.terminal.new")}
|
||||
<Show when={newTerminalKeybind().length > 0}>
|
||||
<Keybind keys={newTerminalKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" size="large" />}
|
||||
variant="ghost"
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Tabs.List
|
||||
ref={tabList}
|
||||
class="!border-b-0"
|
||||
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const active = document.activeElement
|
||||
if (event.target === active) return
|
||||
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
|
||||
}}
|
||||
>
|
||||
<For each={all()}>
|
||||
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
|
||||
</For>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.terminal.new")}
|
||||
<Show when={newTerminalKeybind().length > 0}>
|
||||
<Keybind keys={newTerminalKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" size="large" />}
|
||||
variant="ghost"
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
{/* Reserve outside the scroll viewport so overflowing tabs cannot cover the toggle. */}
|
||||
<Show when={props.reserveReviewToggle}>
|
||||
<div class="w-12 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<For each={store.surfaces}>
|
||||
{(surface) => (
|
||||
|
||||
@@ -10,7 +10,6 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
@@ -31,7 +30,7 @@ import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
|
||||
type BackgroundTask = {
|
||||
@@ -409,10 +408,9 @@ function MessageTimelineView(
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
|
||||
const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
|
||||
const avatarProject = createMemo(() => {
|
||||
if (!showProjectIcon()) return
|
||||
const session = props.session.data.info()
|
||||
if (!session) return
|
||||
return projectForSession(session, server.ctx.projects.list())
|
||||
return projectForSession(session, server.ctx.projects.list()) ?? project()
|
||||
})
|
||||
const projectAvatar = () => (
|
||||
<ProjectAvatar
|
||||
@@ -666,36 +664,13 @@ function MessageTimelineView(
|
||||
<SessionTitleHeader>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
placement="bottom-start"
|
||||
value={sessionDirectory()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<div class="flex items-center gap-0.5 min-w-0 flex-1 w-full">
|
||||
<SessionProjectMenu
|
||||
project={avatarProject()}
|
||||
directory={sessionDirectory()}
|
||||
workspace={workspaceSession()}
|
||||
showProjectIcon={showProjectIcon()}
|
||||
/>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -719,7 +694,7 @@ function MessageTimelineView(
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-1 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
@@ -734,7 +709,7 @@ function MessageTimelineView(
|
||||
dir="auto"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content self-start rounded-[6px] px-2 py-1"
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content rounded-[6px] px-1 py-1"
|
||||
style={{
|
||||
"--inline-input-shadow": "none",
|
||||
"text-align": "start",
|
||||
@@ -761,7 +736,7 @@ function MessageTimelineView(
|
||||
{(id) => (
|
||||
<Menu
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
placement="bottom-start"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
@@ -776,7 +751,8 @@ function MessageTimelineView(
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
style={{ "min-width": "160px" }}
|
||||
class="session-options-menu w-max"
|
||||
style={{ "min-width": "0" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { For, createResource, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import legal from "./legal.svg"
|
||||
import anomalyBrush from "./anomaly-brush.svg"
|
||||
import { AnimatedWordmark } from "./animated-wordmark"
|
||||
import { FALLBACK_OTHER_CONTRIBUTORS, loadOtherContributorCount } from "./contributors"
|
||||
|
||||
const writers = [
|
||||
"thdxr",
|
||||
"adamdotdev",
|
||||
"rekram1-node",
|
||||
"kitlangton",
|
||||
"iamdavidhill",
|
||||
"jayair",
|
||||
"fwang",
|
||||
"brendonovich",
|
||||
"nexxeln",
|
||||
"hona",
|
||||
"kommander",
|
||||
"jlongster",
|
||||
"vimtor",
|
||||
"r44vcorp",
|
||||
"simonklee",
|
||||
"arvsrn",
|
||||
] as const
|
||||
const illustrators = ["usrnk1", "ludvigrask_", "arvsrn", "iamdavidhill"] as const
|
||||
|
||||
export function SettingsAbout(props: { active: boolean }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [otherContributors] = createResource(
|
||||
() => props.active || undefined,
|
||||
() => loadOtherContributorCount(platform.fetch ?? fetch),
|
||||
{ initialValue: FALLBACK_OTHER_CONTRIBUTORS },
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="settings-about-content">
|
||||
<div class="settings-about-intro">
|
||||
<p>{language.t("settings.about.version", { version: platform.version ?? language.t("settings.about.devVersion") })}</p>
|
||||
<p>{language.t("settings.about.license")}</p>
|
||||
</div>
|
||||
|
||||
<AnimatedWordmark active={props.active} />
|
||||
|
||||
<div class="settings-about-credits">
|
||||
<CreditLine
|
||||
label={language.t("settings.about.writtenBy")}
|
||||
names={writers}
|
||||
and={language.t("settings.about.and")}
|
||||
tail={
|
||||
<ExternalLink href="https://github.com/anomalyco/opencode/graphs/contributors">
|
||||
{language.plural("settings.about.otherContributor", otherContributors.latest, {
|
||||
count: otherContributors.latest,
|
||||
})}
|
||||
</ExternalLink>
|
||||
}
|
||||
/>
|
||||
<CreditLine
|
||||
label={language.t("settings.about.illustratedBy")}
|
||||
names={illustrators}
|
||||
and={language.t("settings.about.and")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-about-publication">
|
||||
<p>{language.t("settings.about.firstPublished")}</p>
|
||||
<p>{language.t("settings.about.firstIllustrated")}</p>
|
||||
</div>
|
||||
|
||||
<p class="settings-about-faint">
|
||||
<ExternalLink href="https://opencode.ai">
|
||||
<bdi dir="ltr">{language.t("settings.about.website")}</bdi>
|
||||
</ExternalLink>
|
||||
</p>
|
||||
|
||||
<div class="settings-about-details">
|
||||
<p>{language.t("settings.about.description")}</p>
|
||||
<p>{language.t("settings.about.trademark")}</p>
|
||||
<p>{language.t("settings.about.typeset")}</p>
|
||||
</div>
|
||||
|
||||
<p>{language.t("settings.about.tagline")}</p>
|
||||
<img class="settings-about-legal" src={legal} alt="" />
|
||||
<div class="settings-about-copyright">
|
||||
<p>{language.t("settings.about.copyright")}</p>
|
||||
<img class="settings-about-anomaly-brush" src={anomalyBrush} alt="" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreditLine(props: {
|
||||
label: string
|
||||
names: readonly string[]
|
||||
and: string
|
||||
tail?: JSX.Element
|
||||
}) {
|
||||
return (
|
||||
<p>
|
||||
{props.label}{" "}
|
||||
<For each={props.names}>
|
||||
{(name, index) => (
|
||||
<>
|
||||
{index() === 0 ? "" : index() === props.names.length - 1 && !props.tail ? `, ${props.and} ` : ", "}
|
||||
<bdi dir="ltr">
|
||||
<ExternalLink href={profile(name)}>{name}</ExternalLink>
|
||||
</bdi>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
{props.tail ? <>, {props.and} {props.tail}</> : null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function profile(name: string) {
|
||||
if (name === "r44vcorp") return "https://github.com/R44VC0RP"
|
||||
if (name === "ludvigrask_") return "https://x.com/ludvigrask_"
|
||||
return `https://github.com/${name}`
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createEffect, For, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const target = ["o", "p", "e", "n", "c", "o", "d", "e"] as const
|
||||
const choices = ["o", "p", "e", "n", "c", "d"] as const
|
||||
|
||||
export function AnimatedWordmark(props: { active: boolean }) {
|
||||
const [state, setState] = createStore({ letters: [...target] })
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>()
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.active,
|
||||
(active) => {
|
||||
timers.forEach(clearTimeout)
|
||||
timers.clear()
|
||||
if (!active || matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setState("letters", [...target])
|
||||
return
|
||||
}
|
||||
|
||||
const starts = target.map(() => choices[Math.floor(Math.random() * choices.length)])
|
||||
const settles = target.map(() => 6 + Math.floor(Math.random() * 8))
|
||||
const last = Math.max(...settles)
|
||||
setState("letters", starts)
|
||||
|
||||
Array.from({ length: last }, (_, index) => index + 1).forEach((tick) => {
|
||||
const timer = setTimeout(() => {
|
||||
setState(
|
||||
"letters",
|
||||
target.map((letter, index) =>
|
||||
tick >= settles[index] ? letter : choices[Math.floor(Math.random() * choices.length)],
|
||||
),
|
||||
)
|
||||
timers.delete(timer)
|
||||
}, tick * 75)
|
||||
timers.add(timer)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => timers.forEach(clearTimeout))
|
||||
|
||||
return (
|
||||
<svg class="settings-about-wordmark" viewBox="0 0 234 42" aria-hidden="true">
|
||||
<defs>
|
||||
<symbol id="settings-about-letter-o" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-p" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M6 30H18V12H6V30ZM24 36H6V42H0V6H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-e" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M24 24V30H6V24H24Z" />
|
||||
<path d="M24 24H6V30H24V36H0V6H24V24ZM6 18H18V12H6V18Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-n" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 36H6V18H18V36Z" />
|
||||
<path d="M18 12H6V36H0V6H18V12ZM24 36H18V12H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-c" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M24 30H6V18H24V30Z" />
|
||||
<path d="M24 12H6V30H24V36H0V6H24V12Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-d" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H18V0H24V36Z" />
|
||||
</symbol>
|
||||
</defs>
|
||||
<For each={state.letters}>
|
||||
{(letter, index) => (
|
||||
<use href={`#settings-about-letter-${letter}`} x={index() * 30} width="24" height="42" />
|
||||
)}
|
||||
</For>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { FALLBACK_OTHER_CONTRIBUTORS, otherContributorCount } from "./contributors"
|
||||
|
||||
describe("otherContributorCount", () => {
|
||||
test("subtracts the contributors named in the colophon", () => {
|
||||
expect(
|
||||
otherContributorCount(
|
||||
'<https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=2>; rel="next", <https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=1004>; rel="last"',
|
||||
),
|
||||
).toBe(988)
|
||||
})
|
||||
|
||||
test("falls back when pagination metadata is unavailable", () => {
|
||||
expect(otherContributorCount(null)).toBe(FALLBACK_OTHER_CONTRIBUTORS)
|
||||
expect(otherContributorCount("invalid")).toBe(FALLBACK_OTHER_CONTRIBUTORS)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
const CREDITED_CONTRIBUTORS = 16
|
||||
let request: Promise<number> | undefined
|
||||
|
||||
export const FALLBACK_OTHER_CONTRIBUTORS = 935
|
||||
|
||||
export function otherContributorCount(link: string | null) {
|
||||
const total = Number.parseInt(link?.match(/[?&]page=(\d+)[^>]*>;\s*rel="last"/)?.[1] ?? "", 10)
|
||||
if (!Number.isFinite(total) || total <= CREDITED_CONTRIBUTORS) return FALLBACK_OTHER_CONTRIBUTORS
|
||||
return total - CREDITED_CONTRIBUTORS
|
||||
}
|
||||
|
||||
export function loadOtherContributorCount(fetcher: typeof fetch) {
|
||||
request ??= fetcher("https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}).then(
|
||||
(response) => (response.ok ? otherContributorCount(response.headers.get("Link")) : FALLBACK_OTHER_CONTRIBUTORS),
|
||||
() => FALLBACK_OTHER_CONTRIBUTORS,
|
||||
)
|
||||
return request
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="91" height="16" viewBox="0 0 91 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity=".5" fill="#808080">
|
||||
<path d="M22.463 16H11.231c6.203 0 11.232-3.582 11.232-8s-5.029-8-11.232-8S0 3.582 0 8s5.028 8 11.231 8H0V0h22.463v16Z"/>
|
||||
<path d="M13.672 4.799h5.354v.988h-2.102v5.54h-1.158v-5.54h-2.094v-.988ZM11.724 4.799h1.158v6.528h-1.158V4.799ZM3.474 4.799h1.63l1.825 5.11h.018l1.779-5.11h1.612v6.528H9.235V6.289h-.018l-1.834 5.038h-.954L4.595 6.289h-.019v5.038H3.474V4.799Z"/>
|
||||
<path fill-rule="evenodd" d="M50.363 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm-1.938 1.04a6.615 6.615 0 0 0-3.338 2.34c.483.014.783.079.783.37 0 .375.375.625 1.123-.625.543-.906 1.02-1.681 1.432-2.086Zm8.547 6.585h-2.241c-.999 0-1.123 1-1.623 1.75-.499.75-.748 1.5-.748 2.625s-.749 1.25-1.124 1.375c-.374.125-.623-.375-1.123-.75-.499-.375-.25-2.375-.25-2.75s0-.625-.374-.75c-.374-.125-1.997-.375-2.121-.75-.125-.375 0-.75.748-1.75.44-.625 1.123-.25 1.373-.25.25 0 .999.25 1.498.25.499 0 .624.25 1.373.25.748 0 .25-.25.25-1 0-.75-.5-.375-.999-.25-.499.125-.624.25-1.747.25s-.25-.375.25-.625c.498-.25.498-.25.873-.875.374-.625 0-.25-.624-.375-.624-.125-.25.375-.874.375s-.124-.625.375-1c.499-.375.374-.5.499-1.25.125-.75.25-.25.873-.5.624-.25.874 0 1.123-.25.088-.088.098-.191.092-.288a6.616 6.616 0 0 0-8.702 6.288A6.614 6.614 0 0 0 50.363 15c3.569 0 6.478-2.832 6.61-6.375Z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M66.836 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm2.496 3.5c-.624 0-.999 1.125-1.623 1.75-.624.625-.499 1.375-.25 2.375.25 1-.623 1.625-1.372 1.625s-.874-.375-1.248-.75c-.375-.375-1.248-.375-2.247-.25-.998.125-.624 1.625-.748 2.375-.125.75-.624-.25-.749-1-.079-.475-.459-1-.822-1.417A6.614 6.614 0 0 0 66.836 15a6.59 6.59 0 0 0 2.518-.498c-.378-.049-.972-.333-1.395-.502-.624-.25-.874 0-1.622-.5-.749-.5-.25-.875-.25-1.875.25-.625.998-.25 1.373-.25h2.246c.874 0 .874 1.25.874 1.875 0 .604-.7.741-.86 1.088a6.625 6.625 0 0 0 2.489-9.827A8.822 8.822 0 0 0 71.952 4.5h-2.62Z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M83.308 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm.656.782c-.752.31-.387 1.473.093 1.593.5.125.5-.625.874-.625s1.123.375 1.123.625-.25.375-.5.75c-.249.375.375.375 1.124 0 .748-.375.624.375.499 1.25s-.749 1-1.622 1h-1.498c-.328 0-.391 1.633-.25 1.75.141.117.345.375.5.375h1.871c.624 0 .749.25 1.248.375s.375.75.5 1.125c.124.375-.375.75-.75 1.25-.374.5-.748 1-.998 1.375-.25.375-.374.875-.374 1.375s-.375.5-.624.5c-.25 0-.374-.125-.374-.625s-.125-.625-.25-1.375c-.125-.75-.374-1.375-.374-1.75 0-.375-.25-.25-.669-.25s-.454-.125-.454-.75.809-.305.873-.75c.014-.097 0-.25 0-.25 0-.279-.25-.375-.419-.468-.17-.093-.598-.214-.704-.532-.125-.375-.5-.625-1.248-.625-.749 0-.998-.125-.998-.75V4.5c0-.875-.5-.5-1.498-.75.076-.114.282-.435.544-.868A6.614 6.614 0 1 0 83.964 1.782Z" clip-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -1,7 +1,6 @@
|
||||
import { Component, createMemo } from "solid-js"
|
||||
import { Component } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
@@ -10,7 +9,6 @@ import { createAppearanceSettingsController, type AppearanceSettingsController }
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
|
||||
const fontSettings = {
|
||||
ui: {
|
||||
action: "settings-ui-font",
|
||||
@@ -128,44 +126,6 @@ export const SettingsAppearance: Component = () => {
|
||||
<FontSetting kind="terminal" fonts={appearance.fonts} />
|
||||
</SettingsList>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.appearance.section.experimental")}</h3>
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.tabs.title")}
|
||||
description={language.t("settings.appearance.row.tabs.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-tab-layout"
|
||||
options={tabLayoutOptions}
|
||||
current={tabLayoutOptions.find((option) => option === appearance.tabs.current())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
label={(option) =>
|
||||
option === "horizontal"
|
||||
? language.t("settings.appearance.row.tabs.horizontal")
|
||||
: language.t("settings.appearance.row.tabs.vertical")
|
||||
}
|
||||
onSelect={(option) => option && appearance.tabs.select(option)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.projectName.title")}
|
||||
description={language.t("settings.appearance.row.projectName.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-name">
|
||||
<Switch
|
||||
checked={appearance.projectName.current()}
|
||||
onChange={appearance.projectName.set}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.appearance.row.projectName.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
|
||||
|
||||
export const SettingsExperimental: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.experimental")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">
|
||||
{language.t("settings.experimental.description")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-section">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.tabs.title")}
|
||||
description={language.t("settings.appearance.row.tabs.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-tab-layout"
|
||||
options={tabLayoutOptions}
|
||||
current={tabLayoutOptions.find((option) => option === settings.appearance.tabLayout())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
label={(option) =>
|
||||
option === "horizontal"
|
||||
? language.t("settings.appearance.row.tabs.horizontal")
|
||||
: language.t("settings.appearance.row.tabs.vertical")
|
||||
}
|
||||
onSelect={(option) => option && settings.appearance.setTabLayout(option)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.projectName.title")}
|
||||
description={language.t("settings.appearance.row.projectName.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-name">
|
||||
<Switch
|
||||
checked={settings.appearance.showProjectName()}
|
||||
onChange={settings.appearance.setShowProjectName}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.appearance.row.projectName.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -81,14 +81,6 @@ export function createAppearanceSettingsController() {
|
||||
setCode: (value: string) => settings.appearance.setFont(value),
|
||||
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
|
||||
},
|
||||
tabs: {
|
||||
current: settings.appearance.tabLayout,
|
||||
select: settings.appearance.setTabLayout,
|
||||
},
|
||||
projectName: {
|
||||
current: settings.appearance.showProjectName,
|
||||
set: settings.appearance.setShowProjectName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,19 +91,100 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-nav-footer {
|
||||
.settings-about {
|
||||
align-items: center;
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
.settings-about-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
min-height: 462px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 24px;
|
||||
padding-block: 80px 32px;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
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);
|
||||
}
|
||||
|
||||
.settings-about-legal {
|
||||
width: 109.2px;
|
||||
height: 19.2px;
|
||||
}
|
||||
|
||||
.settings-about-copyright {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-block: 20px 4px;
|
||||
padding-inline-start: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--v2-text-text-faint);
|
||||
user-select: none;
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,7 @@ export function StatusPopover() {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const settings = useSettings()
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const sidebar = () => desktop() && settings.appearance.tabLayout() === "vertical"
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
||||
@@ -41,8 +42,9 @@ export function StatusPopover() {
|
||||
serverHealth: serverHealth(),
|
||||
attention: attention(),
|
||||
issue: issue(),
|
||||
placement: desktop() && settings.appearance.tabLayout() === "vertical" ? "top-start" : "bottom-end",
|
||||
shift: desktop() && settings.appearance.tabLayout() === "vertical" ? 0 : -168,
|
||||
sidebar: sidebar(),
|
||||
placement: sidebar() ? "top-start" : "bottom-end",
|
||||
shift: sidebar() ? 0 : -168,
|
||||
label: language.t("status.popover.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
@@ -61,6 +63,7 @@ type StatusPopoverState = {
|
||||
serverHealth: boolean | undefined
|
||||
attention: boolean
|
||||
issue: boolean
|
||||
sidebar: boolean
|
||||
placement: "top-start" | "bottom-end"
|
||||
shift: number
|
||||
label: string
|
||||
@@ -93,21 +96,37 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||
<Popover
|
||||
open={props.state.shown}
|
||||
onOpenChange={props.state.onOpenChange}
|
||||
triggerAs={IconButton}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "large",
|
||||
class: "!w-9 shrink-0",
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}}
|
||||
triggerAs={props.state.sidebar ? "button" : IconButton}
|
||||
triggerProps={
|
||||
props.state.sidebar
|
||||
? {
|
||||
type: "button",
|
||||
class:
|
||||
"flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]",
|
||||
"data-state": props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}
|
||||
: {
|
||||
variant: "ghost-muted",
|
||||
size: "large",
|
||||
class: "!w-9 shrink-0",
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}
|
||||
}
|
||||
trigger={
|
||||
<div class="relative size-4">
|
||||
<Icon name={props.state.shown ? "status-active" : "status"} />
|
||||
<div
|
||||
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div class="relative size-4 shrink-0">
|
||||
<Icon name={props.state.shown ? "status-active" : "status"} />
|
||||
<div
|
||||
data-slot="status-indicator"
|
||||
class={`absolute -top-1 -end-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||
/>
|
||||
</div>
|
||||
<Show when={props.state.sidebar}>
|
||||
<span class="min-w-0 truncate">{props.state.label}</span>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
{...popoverProps}
|
||||
>
|
||||
|
||||
@@ -38,9 +38,15 @@ export function createTitlebarRightSlot(): TitlebarRightSlot {
|
||||
}
|
||||
}
|
||||
|
||||
export function TitlebarRightMount() {
|
||||
export function TitlebarRightMount(props: { vertical?: boolean }) {
|
||||
const slot = useTitlebarRightSlot()
|
||||
return <div ref={slot.setMount} id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
|
||||
return (
|
||||
<div
|
||||
ref={slot.setMount}
|
||||
id="opencode-titlebar-right"
|
||||
class={props.vertical ? "flex w-full shrink-0 flex-col" : "flex shrink-0 items-center justify-end gap-0"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitlebarRight(props: ParentProps) {
|
||||
|
||||
@@ -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,15 @@ 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(),
|
||||
}}
|
||||
>
|
||||
<Show when={!mobile() && (!props.verticalTabs || windows())}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
<Show when={!mobile() && !props.verticalTabs}>
|
||||
<ChannelIndicator horizontal debugTools={props.debugTools} />
|
||||
</Show>
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
@@ -497,7 +508,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 +636,28 @@ 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>
|
||||
<ChannelIndicator sidebar debugTools={props.debugTools} />
|
||||
{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 +676,8 @@ 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"
|
||||
>
|
||||
<TitlebarRightMount />
|
||||
<Show when={!macVerticalTabs() && !windows()}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</Show>
|
||||
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col">
|
||||
<TitlebarRightMount vertical />
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
@@ -753,45 +753,47 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||
function ChannelIndicator(props: {
|
||||
horizontal?: boolean
|
||||
sidebar?: boolean
|
||||
debugTools?: { visible: boolean; toggle: () => void }
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||
const classes = () => ({
|
||||
"px-2 rounded-sm": windows(),
|
||||
"inline-flex h-4 shrink-0 items-center leading-4 px-1.5 rounded-full": !windows(),
|
||||
})
|
||||
const style = () => ({
|
||||
"font-size": windows() ? undefined : platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
|
||||
})
|
||||
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
|
||||
if (channel === "dev" && props.debugTools) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono cursor-pointer [app-region:no-drag]"
|
||||
classList={classes()}
|
||||
style={style()}
|
||||
onClick={props.debugTools.toggle}
|
||||
aria-label="Toggle debug tools"
|
||||
aria-pressed={props.debugTools.visible}
|
||||
>
|
||||
DEV
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (!channel || channel === "prod") return null
|
||||
|
||||
const label = channel && ["local", "beta", "dev"].includes(channel) ? channel.toUpperCase() : undefined
|
||||
const label = () => language.t(`titlebar.channel.${channel}`)
|
||||
const debug = () => (channel === "dev" ? props.debugTools : undefined)
|
||||
return (
|
||||
<Show when={label}>
|
||||
{(value) => (
|
||||
<div
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono"
|
||||
classList={classes()}
|
||||
style={style()}
|
||||
>
|
||||
{value()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Tooltip
|
||||
placement={props.sidebar ? "right" : "bottom"}
|
||||
value={label()}
|
||||
class={`shrink-0 [app-region:no-drag] ${props.sidebar ? "mb-4 ms-0.5 self-start" : ""} ${props.horizontal ? "me-1.5" : ""} ${props.horizontal && platform.platform === "web" ? "ps-2.5" : ""}`}
|
||||
>
|
||||
<Dynamic
|
||||
component={debug() ? "button" : "div"}
|
||||
type={debug() ? "button" : undefined}
|
||||
data-slot="channel-indicator"
|
||||
class="flex h-7 shrink-0 items-center rounded-[6px] [app-region:no-drag]"
|
||||
classList={{
|
||||
"w-6": props.sidebar,
|
||||
"w-5": !props.sidebar,
|
||||
"cursor-pointer hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02":
|
||||
!!debug(),
|
||||
}}
|
||||
onClick={() => debug()?.toggle()}
|
||||
aria-label={debug() ? language.t("titlebar.toggleDebugTools") : undefined}
|
||||
aria-pressed={debug()?.visible}
|
||||
>
|
||||
<img
|
||||
src={channel === "beta" ? betaIcon : devIcon}
|
||||
alt={debug() ? "" : label()}
|
||||
class="shrink-0 rounded-[4px] shadow-[var(--v2-elevation-raised)]"
|
||||
classList={{ "size-6": props.sidebar, "size-5": !props.sidebar }}
|
||||
draggable={false}
|
||||
/>
|
||||
</Dynamic>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createComputed, createRoot, createSignal } from "solid-js"
|
||||
import type { CommandPaletteEntry } from "@/shell/commands/palette"
|
||||
import { createCommandPaletteSearch } from "@/shell/commands/search"
|
||||
|
||||
const copy: CommandPaletteEntry = {
|
||||
id: "command:session.copyID",
|
||||
type: "command",
|
||||
title: "Copy Session ID",
|
||||
category: "Commands",
|
||||
}
|
||||
const file: CommandPaletteEntry = { id: "file:copy.txt", type: "file", title: "copy.txt", category: "Files" }
|
||||
const session: CommandPaletteEntry = {
|
||||
id: "session:copy",
|
||||
type: "session",
|
||||
title: "Copy session",
|
||||
category: "Sessions",
|
||||
}
|
||||
|
||||
describe("command palette search", () => {
|
||||
test("matches commands synchronously and cancels obsolete requests", () => {
|
||||
const signals: AbortSignal[] = []
|
||||
const root = createRoot((dispose) => {
|
||||
const [query, setQuery] = createSignal("")
|
||||
const search = createCommandPaletteSearch({
|
||||
query,
|
||||
items: (text) => (text === "copy session" ? [copy] : []),
|
||||
sources: [
|
||||
(_text, signal) => {
|
||||
signals.push(signal)
|
||||
return new Promise<CommandPaletteEntry[]>(() => {})
|
||||
},
|
||||
],
|
||||
})
|
||||
return { search, setQuery, dispose }
|
||||
})
|
||||
root.setQuery(" copy session ")
|
||||
expect(root.search.items()).toEqual([copy])
|
||||
expect(root.search.loading()).toBe(true)
|
||||
expect(signals[0].aborted).toBe(true)
|
||||
root.setQuery("no match")
|
||||
expect(root.search.items()).toEqual([])
|
||||
expect(signals[1].aborted).toBe(true)
|
||||
root.dispose()
|
||||
expect(signals[2].aborted).toBe(true)
|
||||
})
|
||||
|
||||
test("publishes each source independently and drops stale results on a new query", async () => {
|
||||
const files = Promise.withResolvers<CommandPaletteEntry[]>()
|
||||
const sessions = Promise.withResolvers<CommandPaletteEntry[]>()
|
||||
const fileVisible = Promise.withResolvers<void>()
|
||||
const sessionVisible = Promise.withResolvers<void>()
|
||||
const root = createRoot((dispose) => {
|
||||
const [query, setQuery] = createSignal("copy")
|
||||
const search = createCommandPaletteSearch({
|
||||
query,
|
||||
items: () => [copy],
|
||||
sources: [() => sessions.promise, () => files.promise],
|
||||
})
|
||||
createComputed(() => {
|
||||
if (search.items().some((entry) => entry.id === file.id)) fileVisible.resolve()
|
||||
if (search.items().some((entry) => entry.id === session.id)) sessionVisible.resolve()
|
||||
})
|
||||
return { search, setQuery, dispose }
|
||||
})
|
||||
expect(root.search.items()).toEqual([copy])
|
||||
files.resolve([file])
|
||||
await fileVisible.promise
|
||||
expect(root.search.items()).toEqual([copy, file])
|
||||
expect(root.search.loading()).toBe(true)
|
||||
sessions.resolve([session])
|
||||
await sessionVisible.promise
|
||||
expect(root.search.items()).toEqual([copy, session, file])
|
||||
expect(root.search.loading()).toBe(false)
|
||||
root.setQuery("new query")
|
||||
expect(root.search.items()).toEqual([copy])
|
||||
root.dispose()
|
||||
})
|
||||
|
||||
test("failed searches do not hide commands or successful sources", async () => {
|
||||
const settled = Promise.withResolvers<void>()
|
||||
const root = createRoot((dispose) => {
|
||||
const search = createCommandPaletteSearch({
|
||||
query: () => "copy",
|
||||
items: () => [copy],
|
||||
sources: [() => Promise.reject(new Error("offline")), () => Promise.resolve([file])],
|
||||
})
|
||||
createComputed(() => {
|
||||
if (!search.loading()) settled.resolve()
|
||||
})
|
||||
return { search, dispose }
|
||||
})
|
||||
await settled.promise
|
||||
expect(root.search.items()).toEqual([copy, file])
|
||||
root.dispose()
|
||||
})
|
||||
})
|
||||
@@ -59,6 +59,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
commands: [
|
||||
Spec.make("upgrade", {
|
||||
description: "Upgrade OpenCode to the latest or a specific version",
|
||||
aliases: ["update"],
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
|
||||
|
||||
@@ -67,9 +67,13 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
|
||||
function add(node: Spec.Any, value: RuntimeHandlers) {
|
||||
if (typeof value === "function") {
|
||||
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
|
||||
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
|
||||
return
|
||||
}
|
||||
if (value.$) result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
|
||||
if (value.$) {
|
||||
result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
|
||||
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
|
||||
}
|
||||
for (const [name, child] of Object.entries(node.commands)) add(child, value[name] as RuntimeHandlers)
|
||||
}
|
||||
|
||||
@@ -99,8 +103,12 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
|
||||
)
|
||||
: node.spec
|
||||
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
|
||||
const children = Object.values(node.commands)
|
||||
return spec.pipe(
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
Command.withSubcommands([
|
||||
...children.map((child) => provide(child, handlers)),
|
||||
...children.flatMap((child) => child.aliases.map((alias) => provide(alias, handlers))),
|
||||
]),
|
||||
) as ProvidedCommand
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Command } from "effect/unstable/cli"
|
||||
|
||||
type Options<Config extends Command.Command.Config, Commands extends ReadonlyArray<Any>> = {
|
||||
readonly description?: string
|
||||
readonly aliases?: ReadonlyArray<string>
|
||||
readonly params?: Config
|
||||
readonly commands?: Commands
|
||||
}
|
||||
@@ -14,6 +15,7 @@ export interface Node<
|
||||
readonly name: Name
|
||||
readonly spec: Spec
|
||||
readonly commands: Commands
|
||||
readonly aliases: ReadonlyArray<Any>
|
||||
}
|
||||
|
||||
export type Any = Node<string, Command.Command<any, any, any, any, any>, Children>
|
||||
@@ -24,14 +26,28 @@ export function make<
|
||||
const Config extends Command.Command.Config = {},
|
||||
const Commands extends ReadonlyArray<Any> = [],
|
||||
>(name: Name, options: Options<Config, Commands> = {}) {
|
||||
const command = Command.make(name, options.params ?? ({} as Config))
|
||||
const spec = options.description ? command.pipe(Command.withDescription(options.description)) : command
|
||||
const aliases = options.aliases ?? []
|
||||
const params = options.params ?? ({} as Config)
|
||||
const command = Command.make(name, params)
|
||||
const described = options.description ? command.pipe(Command.withDescription(options.description)) : command
|
||||
// Effect supports a single native alias, shown inline as `name, alias` in help.
|
||||
// Extra aliases become sibling commands sharing params and subcommands.
|
||||
const spec = aliases.length > 0 ? described.pipe(Command.withAlias(aliases[0])) : described
|
||||
const commands = Object.fromEntries(
|
||||
(options.commands ?? []).map((command) => [command.name, command]),
|
||||
) as ChildrenOf<Commands>
|
||||
const extra = aliases.slice(1).map((alias) => {
|
||||
const aliasCommand = Command.make(alias, params)
|
||||
const aliasSpec = options.description
|
||||
? aliasCommand.pipe(Command.withDescription(options.description))
|
||||
: aliasCommand
|
||||
return { name: alias, spec: aliasSpec, commands, aliases: [] }
|
||||
})
|
||||
return {
|
||||
name,
|
||||
spec,
|
||||
commands: Object.fromEntries(
|
||||
(options.commands ?? []).map((command) => [command.name, command]),
|
||||
) as ChildrenOf<Commands>,
|
||||
commands,
|
||||
aliases: extra,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ describe("acp command", () => {
|
||||
test("is registered", async () => {
|
||||
const result = await cli(["--help"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("acp Start an Agent Client Protocol server")
|
||||
expect(result.stdout).toMatch(/^ acp[ \t]+Start an Agent Client Protocol server\r?$/m)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -117,8 +117,8 @@ describe("mini command", () => {
|
||||
const result = await cli(["--help"])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("mini Start the minimal interactive interface")
|
||||
expect(result.stdout).toContain("run Run OpenCode with a message")
|
||||
expect(result.stdout).toMatch(/^ mini[ \t]+Start the minimal interactive interface\r?$/m)
|
||||
expect(result.stdout).toMatch(/^ run[ \t]+Run OpenCode with a message\r?$/m)
|
||||
})
|
||||
|
||||
test("exposes run without legacy interactive, attach, or command modes", async () => {
|
||||
|
||||
@@ -66,6 +66,8 @@ export type CreateDataInput = {
|
||||
readonly connection?: {
|
||||
readonly status: () => "connected" | "connecting" | "reconnecting"
|
||||
}
|
||||
/** Receives failed event-driven reads. Explicit reads still reject to their caller. */
|
||||
readonly onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
@@ -187,6 +189,17 @@ function createSync() {
|
||||
|
||||
export function createData(config: CreateDataInput) {
|
||||
const api = config.api
|
||||
let disposed = false
|
||||
onCleanup(() => (disposed = true))
|
||||
|
||||
function refresh(load: () => Promise<unknown>) {
|
||||
if (disposed || (config.connection && config.connection.status() !== "connected")) return
|
||||
void load().catch((error) => {
|
||||
if (disposed || (config.connection && config.connection.status() !== "connected")) return
|
||||
if (config.onError) return config.onError(error)
|
||||
console.error("Failed to refresh client data", error)
|
||||
})
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<Store>({
|
||||
session: {
|
||||
@@ -554,31 +567,34 @@ export function createData(config: CreateDataInput) {
|
||||
case "server.connected": {
|
||||
const updates = new Map<string, DataSessionStatus | undefined>()
|
||||
activeUpdates = updates
|
||||
void api()
|
||||
.session.active()
|
||||
.then((active) => {
|
||||
if (activeUpdates !== updates) return
|
||||
// Lifecycle events received during hydration supersede the snapshot.
|
||||
const snapshot = new Map<string, DataSessionStatus>(Object.keys(active).map((id) => [id, "running"]))
|
||||
updates.forEach((status, id) => {
|
||||
if (status === undefined) return snapshot.delete(id)
|
||||
snapshot.set(id, status)
|
||||
refresh(() =>
|
||||
api()
|
||||
.session.active()
|
||||
.then((active) => {
|
||||
if (activeUpdates !== updates) return
|
||||
// Lifecycle events received during hydration supersede the snapshot.
|
||||
const snapshot = new Map<string, DataSessionStatus>(Object.keys(active).map((id) => [id, "running"]))
|
||||
updates.forEach((status, id) => {
|
||||
if (status === undefined) return snapshot.delete(id)
|
||||
snapshot.set(id, status)
|
||||
})
|
||||
activeUpdates = undefined
|
||||
setStore("session", "active", reconcile(Object.fromEntries(snapshot)))
|
||||
})
|
||||
activeUpdates = undefined
|
||||
setStore("session", "active", reconcile(Object.fromEntries(snapshot)))
|
||||
})
|
||||
.catch(() => {
|
||||
if (activeUpdates === updates) activeUpdates = undefined
|
||||
})
|
||||
void api()
|
||||
.location.get({ location: locationQuery(defaultLocation()) })
|
||||
.then((location) => {
|
||||
const key = locationKey(location)
|
||||
setStore("location", key, { info: location })
|
||||
})
|
||||
.catch((error) => console.error("Failed to preload location", error))
|
||||
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
|
||||
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
|
||||
.catch(() => {
|
||||
if (activeUpdates === updates) activeUpdates = undefined
|
||||
}),
|
||||
)
|
||||
refresh(() =>
|
||||
api()
|
||||
.location.get({ location: locationQuery(defaultLocation()) })
|
||||
.then((location) => {
|
||||
const key = locationKey(location)
|
||||
setStore("location", key, { info: location })
|
||||
}),
|
||||
)
|
||||
refresh(() => result.location.vcs.sync())
|
||||
refresh(() => result.project.sync())
|
||||
return
|
||||
}
|
||||
case "project.updated":
|
||||
@@ -587,7 +603,7 @@ export function createData(config: CreateDataInput) {
|
||||
case "session.created":
|
||||
sessionOutbox.delete(event.data.sessionID)
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
refresh(() => result.session.sync(event.data.sessionID))
|
||||
// Band-aid: a newly created session starts empty, so live events can be its source of truth.
|
||||
// Fetching pending inputs and projected messages separately lets promotion move an input between snapshots,
|
||||
// causing both requests to miss it and overwrite event-built state. Skip those racy initial reads until
|
||||
@@ -628,25 +644,28 @@ export function createData(config: CreateDataInput) {
|
||||
model: event.data.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
void api()
|
||||
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||
.then((item) => {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(item.id)
|
||||
if (position === undefined) return message.append(draft, index, item)
|
||||
draft[position] = item
|
||||
})
|
||||
})
|
||||
.catch((error) => console.error("Failed to load projected model switch message", error))
|
||||
refresh(() =>
|
||||
api()
|
||||
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||
.then((item) => {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(item.id)
|
||||
if (position === undefined) return message.append(draft, index, item)
|
||||
draft[position] = item
|
||||
})
|
||||
}),
|
||||
)
|
||||
return
|
||||
case "session.renamed": {
|
||||
// Preserve the live title when it races the session's initial read.
|
||||
const family = sync.pending(`session.family:${event.data.sessionID}`)
|
||||
? result.session.sync(event.data.sessionID, { children: true })
|
||||
: Promise.resolve()
|
||||
void Promise.all([result.session.sync(event.data.sessionID), family]).then(() => {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
refresh(() => {
|
||||
const family = sync.pending(`session.family:${event.data.sessionID}`)
|
||||
? result.session.sync(event.data.sessionID, { children: true })
|
||||
: Promise.resolve()
|
||||
return Promise.all([result.session.sync(event.data.sessionID), family]).then(() => {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -680,7 +699,7 @@ export function createData(config: CreateDataInput) {
|
||||
if (!directory) {
|
||||
if (info.location.workspaceID) continue
|
||||
result.session.invalidate(sessionID)
|
||||
void result.session.sync(sessionID)
|
||||
refresh(() => result.session.sync(sessionID))
|
||||
continue
|
||||
}
|
||||
const adopted = Worktree.adopt(
|
||||
@@ -791,7 +810,7 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
|
||||
result.session.message.invalidate(event.data.sessionID)
|
||||
void result.session.message.sync(event.data.sessionID)
|
||||
refresh(() => result.session.message.sync(event.data.sessionID))
|
||||
return
|
||||
}
|
||||
case "session.step.started":
|
||||
@@ -992,12 +1011,12 @@ export function createData(config: CreateDataInput) {
|
||||
// An event can overtake the first read; queue a revalidation when that read is still active.
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
refresh(() => result.session.sync(event.data.sessionID))
|
||||
return
|
||||
case "session.viewed":
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
refresh(() => result.session.sync(event.data.sessionID))
|
||||
return
|
||||
case "session.revert.staged":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
@@ -1105,7 +1124,7 @@ export function createData(config: CreateDataInput) {
|
||||
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
|
||||
if (event.type === "credential.updated") {
|
||||
result.location.integration.invalidate(location)
|
||||
void result.location.integration.sync(location)
|
||||
refresh(() => result.location.integration.sync(location))
|
||||
return
|
||||
}
|
||||
setStore("location", key, (data) => ({
|
||||
@@ -1123,7 +1142,7 @@ export function createData(config: CreateDataInput) {
|
||||
}))
|
||||
result.location.model.invalidate(location)
|
||||
result.location.provider.invalidate(location)
|
||||
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
|
||||
refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]))
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1134,19 +1153,19 @@ export function createData(config: CreateDataInput) {
|
||||
case "catalog.updated":
|
||||
result.location.model.invalidate(location)
|
||||
result.location.provider.invalidate(location)
|
||||
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
|
||||
refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]))
|
||||
break
|
||||
case "agent.updated":
|
||||
result.location.agent.invalidate(location)
|
||||
void result.location.agent.sync(location)
|
||||
refresh(() => result.location.agent.sync(location))
|
||||
break
|
||||
case "command.updated":
|
||||
result.location.command.invalidate(location)
|
||||
void result.location.command.sync(location)
|
||||
refresh(() => result.location.command.sync(location))
|
||||
break
|
||||
case "skill.updated":
|
||||
result.location.skill.invalidate(location)
|
||||
void result.location.skill.sync(location)
|
||||
refresh(() => result.location.skill.sync(location))
|
||||
break
|
||||
case "vcs.branch.updated":
|
||||
setStore("location", locationKey(location), (data) => ({
|
||||
@@ -1181,31 +1200,33 @@ export function createData(config: CreateDataInput) {
|
||||
break
|
||||
case "reference.updated":
|
||||
result.location.reference.invalidate(location)
|
||||
void result.location.reference.sync(location)
|
||||
refresh(() => result.location.reference.sync(location))
|
||||
break
|
||||
case "integration.updated":
|
||||
result.location.integration.invalidate(location)
|
||||
result.location.model.invalidate(location)
|
||||
result.location.provider.invalidate(location)
|
||||
void Promise.all([
|
||||
result.location.integration.sync(location),
|
||||
result.location.model.sync(location),
|
||||
result.location.provider.sync(location),
|
||||
])
|
||||
refresh(() =>
|
||||
Promise.all([
|
||||
result.location.integration.sync(location),
|
||||
result.location.model.sync(location),
|
||||
result.location.provider.sync(location),
|
||||
]),
|
||||
)
|
||||
break
|
||||
case "config.updated":
|
||||
case "websearch.updated":
|
||||
void result.location.websearch.refresh(location)
|
||||
refresh(() => result.location.websearch.refresh(location))
|
||||
break
|
||||
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
||||
// so the mcp list syncs here rather than off integration.updated.
|
||||
case "mcp.status.changed":
|
||||
result.location.mcp.server.invalidate(location)
|
||||
void result.location.mcp.server.sync(location)
|
||||
refresh(() => result.location.mcp.server.sync(location))
|
||||
break
|
||||
case "mcp.resources.changed":
|
||||
result.location.mcp.resource.invalidate(location)
|
||||
void result.location.mcp.resource.sync(location)
|
||||
refresh(() => result.location.mcp.resource.sync(location))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
||||
|
||||
test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const reported = Promise.withResolvers<unknown>()
|
||||
const errors: unknown[] = []
|
||||
const state = { offline: true, requests: 0 }
|
||||
const session: SessionInfo = {
|
||||
id: "ses_refresh_failure",
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0, idle: 2 },
|
||||
location: { directory: "/project" },
|
||||
}
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async () => {
|
||||
state.requests++
|
||||
if (state.offline) throw new TypeError("Failed to fetch")
|
||||
return Response.json({ data: { ...session, title: "Recovered" } })
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
onError(error) {
|
||||
errors.push(error)
|
||||
reported.resolve(error)
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const event: OpenCodeEvent = {
|
||||
id: "evt_refresh_failure",
|
||||
created: 2,
|
||||
type: "session.viewed",
|
||||
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
||||
data: { sessionID: session.id, idle: 2 },
|
||||
}
|
||||
try {
|
||||
setup.data.session.remember(session)
|
||||
setup.data.session.invalidate(session.id)
|
||||
await expect(setup.data.session.sync(session.id)).rejects.toThrow("Transport")
|
||||
expect(errors).toEqual([])
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
expect(String(await reported.promise)).toContain("Transport")
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(setup.data.session.get(session.id)?.title).toBeUndefined()
|
||||
state.offline = false
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
await setup.data.session.sync(session.id)
|
||||
expect(setup.data.session.get(session.id)?.title).toBe("Recovered")
|
||||
expect(state.requests).toBe(3)
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["reconnecting", "disposed"] as const)("background reads respect %s owners", async (mode) => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const pending = Promise.withResolvers<Response>()
|
||||
const errors: unknown[] = []
|
||||
const state = { requests: 0 }
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: () => {
|
||||
state.requests++
|
||||
return pending.promise
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
connection: { status: () => (mode === "reconnecting" ? "reconnecting" : "connected") },
|
||||
onError: (error) => errors.push(error),
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const event: OpenCodeEvent = {
|
||||
id: "evt_refresh_owner",
|
||||
type: "command.updated",
|
||||
location: { directory: "/project" },
|
||||
data: {},
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
if (mode === "reconnecting") {
|
||||
expect(state.requests).toBe(0)
|
||||
setup.dispose()
|
||||
return
|
||||
}
|
||||
const joined = setup.data.location.command.sync()
|
||||
setup.dispose()
|
||||
pending.reject(new TypeError("Failed to fetch"))
|
||||
await expect(joined).rejects.toThrow("Transport")
|
||||
expect(state.requests).toBe(1)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -1,8 +1,24 @@
|
||||
import { dlopen } from "bun:ffi"
|
||||
import { spawn } from "bun-pty"
|
||||
import type { Opts, Proc } from "./pty.js"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty.js"
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const library = dlopen("kernel32.dll", {
|
||||
SetConsoleCtrlHandler: { args: ["ptr", "i32"], returns: "i32" },
|
||||
GetLastError: { args: [], returns: "u32" },
|
||||
})
|
||||
try {
|
||||
// Detached servers start with Ctrl+C ignored, and ConPTY shells inherit it.
|
||||
// Clear that attribute once before spawning shells; keep registered handlers.
|
||||
if (library.symbols.SetConsoleCtrlHandler(null, 0) === 0)
|
||||
throw new Error(`Failed to enable PTY Ctrl+C handling: Windows error ${library.symbols.GetLastError()}`)
|
||||
} finally {
|
||||
library.close()
|
||||
}
|
||||
}
|
||||
|
||||
function spawnPty(file: string, args: string[], opts: Opts): Proc {
|
||||
const pty = spawn(file, args, opts)
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as SessionRunnerLLM from "./llm.js"
|
||||
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
@@ -15,6 +16,7 @@ import { SessionModelTransport } from "../model-transport.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
import { SessionMessageTable } from "../sql.js"
|
||||
import { SessionTitle } from "../title.js"
|
||||
import { DrainResult, Service, type Interface } from "./index.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
@@ -59,6 +61,7 @@ const layer = Layer.effect(
|
||||
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
|
||||
}
|
||||
yield* plugins.awaitActivation
|
||||
yield* settleStaleCompactions(sessionID)
|
||||
yield* settleStaleToolCalls(sessionID)
|
||||
|
||||
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
|
||||
@@ -276,6 +279,36 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const settleStaleCompactions = Effect.fn("SessionRunner.settleStaleCompactions")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
// A process death skips compaction finalizers. Include orphans behind a
|
||||
// completed checkpoint, and settle newest first to match event projection.
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
for (const row of rows) {
|
||||
const message = yield* SessionHistory.decodeMessageRow(row)
|
||||
if (message.type !== "compaction") continue
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: message.reason,
|
||||
inputID: message.id,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
|
||||
@@ -122,8 +122,8 @@ describe("CodeModeInstructions.render", () => {
|
||||
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
|
||||
expect(instructions).not.toContain("## Search")
|
||||
expect(instructions).toContain("The Code Mode tool catalog below is complete.")
|
||||
expect(instructions).toContain("This catalog is the complete set of tools available within Code Mode.")
|
||||
expect(instructions).not.toContain("surrounding top-level agent tools")
|
||||
expect(instructions).toContain("This catalog is the complete set of tools callable inside `execute`.")
|
||||
expect(instructions).toContain("It does not affect tools exposed directly outside Code Mode.")
|
||||
})
|
||||
|
||||
test("adds search guidance when the catalog exceeds the budget", () => {
|
||||
@@ -133,9 +133,9 @@ describe("CodeModeInstructions.render", () => {
|
||||
expect(partial).toContain("## Search")
|
||||
expect(partial).toContain("The Code Mode tool catalog below is partial.")
|
||||
expect(partial).toContain(
|
||||
"The Code Mode catalog and `search` results are the complete set of tools available within Code Mode.",
|
||||
"The Code Mode catalog and `search` results are the complete set of tools callable inside `execute`.",
|
||||
)
|
||||
expect(partial).not.toContain("surrounding top-level agent tools")
|
||||
expect(partial).toContain("It does not affect tools exposed directly outside Code Mode.")
|
||||
expect(partial).toContain("- search(input: {")
|
||||
expect(partial).toContain(" /** @integer @exclusiveMinimum 0 */\n limit?: number,")
|
||||
expect(partial).toContain(" /** @integer @minimum 0 */\n offset?: number,")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { spawn } from "../../src/pty/pty.bun"
|
||||
|
||||
const raw = process.argv[2] === "raw"
|
||||
const pty = spawn(
|
||||
Bun.which("pwsh") ?? "powershell.exe",
|
||||
[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
...(raw ? [] : ["-NoExit"]),
|
||||
"-Command",
|
||||
raw
|
||||
? "[Console]::TreatControlCAsInput = $true; Write-Output 'PTY_RAW_READY'; $key = [Console]::ReadKey($true); Write-Output ('PTY_KEY:' + [int]$key.KeyChar)"
|
||||
: "Remove-Module PSReadLine -ErrorAction SilentlyContinue; function prompt { 'PTY_PROMPT> ' }",
|
||||
],
|
||||
{ name: "xterm-256color", cols: 160, rows: 24, env: { ...process.env, TERM: "xterm-256color" } },
|
||||
)
|
||||
const output = { text: "", cursor: 0 }
|
||||
const listeners = new Set<() => void>()
|
||||
const exited = Promise.withResolvers<number>()
|
||||
pty.onExit((event) => exited.resolve(event.exitCode))
|
||||
pty.onData((text) => {
|
||||
output.text += text
|
||||
listeners.forEach((check) => check())
|
||||
})
|
||||
|
||||
try {
|
||||
if (raw) {
|
||||
await waitFor("PTY_RAW_READY")
|
||||
pty.write("\x03")
|
||||
await waitFor("PTY_KEY:3")
|
||||
}
|
||||
if (!raw) {
|
||||
await waitFor("PTY_PROMPT>")
|
||||
// Split markers so echoed command text cannot satisfy the output checks.
|
||||
pty.write("Write-Output ('PTY_' + 'BUSY'); Start-Sleep -Seconds 60; Write-Output ('PTY_' + 'COMPLETED')\r")
|
||||
await waitFor("PTY_BUSY")
|
||||
pty.write("\x03")
|
||||
await waitFor("PTY_PROMPT>")
|
||||
assert.ok(!output.text.includes("PTY_COMPLETED"))
|
||||
pty.write("Write-Output ('PTY_' + 'REUSED')\r")
|
||||
await waitFor("PTY_REUSED")
|
||||
await waitFor("PTY_PROMPT>")
|
||||
pty.write("exit 0\r")
|
||||
}
|
||||
assert.equal(await exited.promise, 0)
|
||||
} finally {
|
||||
pty.kill()
|
||||
}
|
||||
process.exit(0)
|
||||
|
||||
function waitFor(text: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
listeners.delete(check)
|
||||
reject(new Error(`Timed out waiting for ${JSON.stringify(text)}: ${output.text}`))
|
||||
}, 5_000)
|
||||
const check = () => {
|
||||
const index = output.text.indexOf(text, output.cursor)
|
||||
if (index === -1) return
|
||||
output.cursor = index + text.length
|
||||
listeners.delete(check)
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
listeners.add(check)
|
||||
check()
|
||||
})
|
||||
}
|
||||
@@ -69,12 +69,16 @@ async function createRegistryFixture(directory: string) {
|
||||
await Bun.$`tar -czf package.tgz package`.cwd(root)
|
||||
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
|
||||
}
|
||||
const state = { latest: "1.0.0" }
|
||||
const state = { latest: "1.0.0", audits: 0 }
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.startsWith("/-/npm/v1/security/")) {
|
||||
state.audits++
|
||||
return Response.json({})
|
||||
}
|
||||
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
|
||||
return Response.json({
|
||||
name: "@fixture/registry-plugin",
|
||||
@@ -97,7 +101,7 @@ async function createRegistryFixture(directory: string) {
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(root, ".npmrc"),
|
||||
`@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
|
||||
`registry=${server.url}\n@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=true\n`,
|
||||
)
|
||||
return root
|
||||
},
|
||||
@@ -359,6 +363,25 @@ describe("Npm.resolve", () => {
|
||||
})
|
||||
|
||||
describe("Npm.check and Npm.update", () => {
|
||||
test("installs and updates without requesting registry audits", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using registry = await createRegistryFixture(tmp.path)
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const spec = "@fixture/registry-plugin@latest"
|
||||
await registry.configure(cache, spec)
|
||||
|
||||
await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
expect((yield* npm.add(spec)).version).toBe("1.0.0")
|
||||
expect(registry.state.audits).toBe(0)
|
||||
|
||||
registry.state.latest = "1.1.0"
|
||||
expect((yield* npm.update(spec)).version).toBe("1.1.0")
|
||||
expect(registry.state.audits).toBe(0)
|
||||
expect((yield* npm.resolve(spec)).version).toBe("1.1.0")
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
})
|
||||
|
||||
test("checks a mutable registry target without mutation and explicitly updates it", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using registry = await createRegistryFixture(tmp.path)
|
||||
|
||||
@@ -4,6 +4,10 @@ import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
describe("Core test environment", () => {
|
||||
test("disables public npm security audits", () => {
|
||||
expect(process.env.NPM_CONFIG_AUDIT).toBe("false")
|
||||
})
|
||||
|
||||
test("isolates global home and XDG roots", () => {
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
expect(home).toBeDefined()
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.NPM_CONFIG_AUDIT = "false"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect } from "bun:test"
|
||||
import { spawn } from "node:child_process"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const windowsTest = process.platform === "win32" ? it.live : it.live.skip
|
||||
|
||||
Array.of("interrupt", "raw").forEach((scenario) => {
|
||||
windowsTest(
|
||||
scenario === "interrupt"
|
||||
? "detached PTY hosts interrupt commands without closing the shell"
|
||||
: "detached PTY hosts preserve Ctrl+C input for raw-mode programs",
|
||||
Effect.gen(function* () {
|
||||
// Isolate the inheritable Windows console state from the test runner.
|
||||
const worker = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const child = spawn(process.execPath, [path.join(import.meta.dir, "../fixture/pty-windows.ts"), scenario], {
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
timeout: 30_000,
|
||||
})
|
||||
const output: string[] = []
|
||||
child.stderr.on("data", (chunk: Buffer) => output.push(chunk.toString()))
|
||||
const exited = new Promise<number | null>((resolve, reject) => {
|
||||
child.once("error", reject)
|
||||
child.once("close", resolve)
|
||||
})
|
||||
return { child, output, exited }
|
||||
}),
|
||||
(worker) =>
|
||||
Effect.sync(() => {
|
||||
if (worker.child.exitCode === null && worker.child.signalCode === null) worker.child.kill()
|
||||
}),
|
||||
)
|
||||
const code = yield* Effect.promise(() => worker.exited)
|
||||
expect({ code, output: worker.output.join("") }).toEqual({ code: 0, output: "" })
|
||||
}),
|
||||
35_000,
|
||||
)
|
||||
})
|
||||
@@ -3472,6 +3472,83 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(userTexts(s.requests[1])).toEqual(["Start working", "Recover with this"])
|
||||
})
|
||||
|
||||
scenario("settles abandoned compactions before continuing after a process crash", function* (s) {
|
||||
yield* s.runPrompt("History before the crash")
|
||||
const first = SessionMessage.ID.create()
|
||||
const completed = SessionMessage.ID.create()
|
||||
const last = SessionMessage.ID.create()
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
inputID: first,
|
||||
recent: "",
|
||||
})
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
inputID: completed,
|
||||
recent: "",
|
||||
})
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: "## Objective\n- Earlier completed checkpoint",
|
||||
recent: "",
|
||||
})
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
inputID: last,
|
||||
recent: "",
|
||||
})
|
||||
|
||||
// These starts have no terminal events, as after SIGKILL. The older orphan
|
||||
// is outside model-visible history; recovery must settle it as well.
|
||||
expect(
|
||||
(yield* s.messages).filter((message) => message.type === "compaction" && message.status === "running"),
|
||||
).toHaveLength(2)
|
||||
yield* s.llm.push(TestLLM.text("Recovered response", "recovered"))
|
||||
const run = yield* s.resumePaused
|
||||
expect((yield* s.messages).filter((message) => message.type === "compaction").toReversed()).toMatchObject([
|
||||
{ id: first, status: "failed", reason: "manual", error: { type: "compaction.interrupted" } },
|
||||
{ id: completed, status: "completed", summary: "## Objective\n- Earlier completed checkpoint" },
|
||||
{ id: last, status: "failed", reason: "auto", error: { type: "compaction.interrupted" } },
|
||||
])
|
||||
yield* run.finish
|
||||
|
||||
yield* s.llm.push(TestLLM.text("## Objective\n- New checkpoint", "new-summary"))
|
||||
const next = yield* s.session.compact({ sessionID })
|
||||
yield* s.session.wait(sessionID)
|
||||
expect((yield* s.messages).find((message) => message.id === next.id)).toMatchObject({
|
||||
status: "completed",
|
||||
summary: "## Objective\n- New checkpoint",
|
||||
})
|
||||
expect(
|
||||
(yield* s.messages).filter((message) => message.type === "compaction" && message.status === "running"),
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
scenario("settles an abandoned compaction before delivering another manual compaction", function* (s) {
|
||||
yield* s.runPrompt("History before the crash")
|
||||
const previous = SessionMessage.ID.create()
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
inputID: previous,
|
||||
recent: "",
|
||||
})
|
||||
yield* s.llm.push(TestLLM.text("## Objective\n- New checkpoint", "new-summary"))
|
||||
const gate = yield* s.llm.gate
|
||||
const next = yield* s.session.compact({ sessionID })
|
||||
yield* gate.started
|
||||
expect((yield* s.messages).filter((message) => message.type === "compaction").toReversed()).toMatchObject([
|
||||
{ id: previous, status: "failed", error: { type: "compaction.interrupted" } },
|
||||
{ id: next.id, status: "running" },
|
||||
])
|
||||
yield* gate.release
|
||||
yield* s.session.wait(sessionID)
|
||||
})
|
||||
|
||||
scenario("durably fails local tools left running by a prior process before continuing", function* (s) {
|
||||
yield* s.admit("Recover interrupted tool")
|
||||
yield* SessionInbox.promote(s.db, s.bus, sessionID, "steer")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from "electron-vite"
|
||||
import { pickerPlugin } from "./scripts/picker"
|
||||
|
||||
const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
@@ -10,7 +11,6 @@ const channel = (() => {
|
||||
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
|
||||
|
||||
const appPlugin = (await import("@opencode-ai/app/vite")).default
|
||||
const picker = (await import("@brendonovich/vite-plugin-opencode")).default()
|
||||
const sentry =
|
||||
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
|
||||
? (await import("@sentry/vite-plugin")).sentryVitePlugin({
|
||||
@@ -91,7 +91,7 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
|
||||
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
|
||||
},
|
||||
plugins: [picker, appPlugin, sentry],
|
||||
plugins: [pickerPlugin(), appPlugin, sentry],
|
||||
publicDir: "../../../app/public",
|
||||
root: "src/renderer",
|
||||
build: {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Picker fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<button>Pick this element</button>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
document.body.dataset.ready = "true"
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { loadConfigFromFile, RendererConfigFactory } from "electron-vite"
|
||||
import { createServer } from "vite"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { pickerPlugin } from "./picker"
|
||||
|
||||
test("injects a browser-loadable URL instead of a bare virtual module", () => {
|
||||
const plugin = pickerPlugin()
|
||||
const tag = plugin.transformIndexHtml.handler()[0]
|
||||
expect(tag.attrs.src).toBe("/__vite_opencode_picker_client.js")
|
||||
const id = plugin.resolveId(tag.attrs.src)
|
||||
expect(id).toBeDefined()
|
||||
expect(plugin.load(id!)).toContain("opencodePickerUi")
|
||||
})
|
||||
|
||||
test.each([true, false])(
|
||||
"serves browser-loadable picker scripts with bundled dev = %s",
|
||||
async (bundledDev) => {
|
||||
const loaded = await loadConfigFromFile(
|
||||
{ command: "serve", mode: "development" },
|
||||
fileURLToPath(new URL("../electron.vite.config.ts", import.meta.url)),
|
||||
)
|
||||
if (!loaded.config.renderer) throw new Error("Missing renderer configuration")
|
||||
const config = await new RendererConfigFactory(
|
||||
loaded.config.renderer,
|
||||
{ configFile: false, mode: "development" },
|
||||
{ root: fileURLToPath(new URL("..", import.meta.url)) },
|
||||
).build()
|
||||
const server = await createServer({
|
||||
...config,
|
||||
configFile: false,
|
||||
root: fileURLToPath(new URL("./fixtures/picker", import.meta.url)),
|
||||
build: {
|
||||
...config.build,
|
||||
rolldownOptions: { input: { main: fileURLToPath(new URL("./fixtures/picker/index.html", import.meta.url)) } },
|
||||
},
|
||||
experimental: { bundledDev },
|
||||
server: { host: "127.0.0.1", port: 0 },
|
||||
logLevel: "silent",
|
||||
})
|
||||
await server.listen()
|
||||
const url = server.resolvedUrls?.local[0]
|
||||
if (!url) throw new Error("Missing fixture server URL")
|
||||
const socket = bundledDev
|
||||
? new WebSocket(`${url.replace("http:", "ws:")}?token=${server.config.webSocketToken}`, "vite-hmr")
|
||||
: undefined
|
||||
try {
|
||||
if (socket && !server.environments.client.bundledDev?.hasBuildOutput) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.addEventListener("error", reject, { once: true })
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message: { type: string } = JSON.parse(String(event.data))
|
||||
if (message.type === "full-reload") resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
for (const path of ["/", "/index.html", "/server/example/session/example", "/new-session?draftId=example"]) {
|
||||
const html = await fetch(new URL(path, url)).then((response) => response.text())
|
||||
const sources = [...html.matchAll(/<script[^>]*\bsrc="([^"]+)"/g)].map((match) => match[1])
|
||||
const scripts = await Promise.all(
|
||||
sources.map((source) => fetch(new URL(source, url)).then((response) => response.text())),
|
||||
)
|
||||
expect(scripts.length).toBeGreaterThan(0)
|
||||
if (bundledDev) expect(scripts.join("\n")).toContain("opencodePickerUi")
|
||||
expect([html, ...scripts].join("\n")).not.toMatch(/\bimport(?:\s*\(\s*|\s*)["']virtual:/)
|
||||
}
|
||||
const direct = await fetch(new URL("/__vite_opencode_picker_client.js", url))
|
||||
expect(direct.ok).toBe(true)
|
||||
expect(await direct.text()).toContain("opencodePickerUi")
|
||||
} finally {
|
||||
socket?.close()
|
||||
await server.close()
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
import picker from "@brendonovich/vite-plugin-opencode"
|
||||
|
||||
export function pickerPlugin() {
|
||||
const plugin = picker()
|
||||
const client = "/__vite_opencode_picker_client.js"
|
||||
return {
|
||||
...plugin,
|
||||
resolveId(id: string) {
|
||||
return plugin.resolveId(id === client ? "virtual:vite-opencode-picker/client" : id)
|
||||
},
|
||||
configureServer(server: Parameters<typeof plugin.configureServer>[0]) {
|
||||
server.middlewares.use(client, (_request, response) => {
|
||||
response.setHeader("content-type", "text/javascript")
|
||||
response.end(plugin.load(plugin.resolveId("virtual:vite-opencode-picker/client")!))
|
||||
})
|
||||
plugin.configureServer(server)
|
||||
},
|
||||
transformIndexHtml: {
|
||||
order: "pre" as const,
|
||||
handler() {
|
||||
// A real URL stays loadable if bundled dev leaves the HTML import unbundled.
|
||||
return [{ tag: "script", attrs: { type: "module", src: client }, injectTo: "body" as const }]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -60,3 +60,10 @@ describe("electron vite publicDir", () => {
|
||||
expect(existsSync(join(resolved, "oc-theme-preload.js"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test("renders before loading optional telemetry", async () => {
|
||||
const source = await Bun.file(join(dir, "index.tsx")).text()
|
||||
expect(source.indexOf("render(() =>")).toBeGreaterThan(-1)
|
||||
expect(source.indexOf("render(() =>")).toBeLessThan(source.indexOf("initializeSentry(version)"))
|
||||
expect(source).not.toContain("await initializeSentry")
|
||||
})
|
||||
|
||||
@@ -13,10 +13,10 @@ import { desktopVersion, initializeSentry } from "./startup/sentry"
|
||||
|
||||
const root = requireRendererRoot()
|
||||
const version = desktopVersion()
|
||||
await initializeSentry(version)
|
||||
|
||||
const updater = startDesktopUpdater(api)
|
||||
startDesktopMenu(api)
|
||||
startDeepLinks(api)
|
||||
|
||||
render(() => <DesktopApp api={api} updater={updater} version={version} />, root)
|
||||
void initializeSentry(version)
|
||||
|
||||
@@ -494,7 +494,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
padding-bottom: 160px;
|
||||
text-align: center;
|
||||
@@ -502,25 +502,26 @@
|
||||
|
||||
[data-slot="session-review-v2-empty-changes"] [data-slot="icon-svg"] {
|
||||
flex: none;
|
||||
margin-bottom: 8px;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
[data-slot="session-review-v2-empty-changes-title"] {
|
||||
flex: none;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 13px;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-slot="session-review-v2-empty-changes-description"] {
|
||||
flex: none;
|
||||
height: 20px;
|
||||
max-width: 282px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 20px;
|
||||
line-height: var(--line-height-base);
|
||||
text-align: center;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { isShellNotFoundError, type LocationRef, type ShellInfo } from "@opencode-ai/client"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show, untrack } from "solid-js"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
|
||||
const PAGE_BYTES = 64 * 1024
|
||||
|
||||
export function DialogShellOutput(props: { shell: ShellInfo; location: LocationRef }) {
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [info, setInfo] = createSignal(props.shell)
|
||||
const [output, setOutput] = createSignal<string>()
|
||||
const [omitted, setOmitted] = createSignal(false)
|
||||
const [error, setError] = createSignal("")
|
||||
const text = createMemo(() => stripAnsi(output() ?? "").replace(/\r\n?/g, "\n"))
|
||||
const height = () => Math.max(3, Math.floor(dimensions().height * 0.6) - 6)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
dialog.setSize("xlarge")
|
||||
dialog.setCentered(true)
|
||||
|
||||
createEffect(() => {
|
||||
// The running-shell inventory drops exited commands. Keep this view tied to
|
||||
// the opened ID and its original Location, not the list's current selection.
|
||||
const id = props.shell.id
|
||||
const location = { directory: props.location.directory, workspace: props.location.workspaceID }
|
||||
let cursor: number | undefined
|
||||
let disposed = false
|
||||
let missing = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const load = async () => {
|
||||
if (untrack(info).status === "running") {
|
||||
const current = await client.api.shell.get({ id, location })
|
||||
if (disposed) return false
|
||||
setInfo(current.data)
|
||||
}
|
||||
if (cursor === undefined) {
|
||||
const head = await client.api.shell.output({ id, location, cursor: Number.MAX_SAFE_INTEGER })
|
||||
if (disposed) return false
|
||||
cursor = Math.max(0, head.data.size - PAGE_BYTES)
|
||||
setOmitted(cursor > 0)
|
||||
}
|
||||
const page = await client.api.shell.output({ id, location, cursor, limit: PAGE_BYTES })
|
||||
if (disposed) return false
|
||||
cursor = page.data.cursor
|
||||
setOutput((previous) => {
|
||||
const next = (previous ?? "") + page.data.output
|
||||
if (next.length > PAGE_BYTES) setOmitted(true)
|
||||
return next.slice(-PAGE_BYTES)
|
||||
})
|
||||
setError("")
|
||||
return cursor < page.data.size
|
||||
}
|
||||
|
||||
const poll = () => {
|
||||
void load()
|
||||
.catch((cause: unknown) => {
|
||||
if (disposed) return
|
||||
missing = isShellNotFoundError(cause)
|
||||
setError(missing ? "Shell output is no longer available." : "Unable to read shell output. Retrying…")
|
||||
})
|
||||
.then((more) => {
|
||||
// Poll only while the viewer is open, including after exit so the final
|
||||
// file flush is observed. Never overlap reads or reload earlier pages.
|
||||
if (!disposed && !missing) timer = setTimeout(poll, more ? 0 : 1_000)
|
||||
})
|
||||
}
|
||||
poll()
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
clearTimeout(timer)
|
||||
})
|
||||
})
|
||||
|
||||
const status = () => {
|
||||
if (info().status === "running") return "Running"
|
||||
if (info().status === "timeout") return "Timed out"
|
||||
if (info().status === "killed") return "Killed"
|
||||
return info().exit === undefined ? "Exited" : `Exited · code ${info().exit}`
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{ bind: "up", title: "Scroll output up", group: "Shell", run: () => scroll?.scrollBy(-1) },
|
||||
{ bind: "down", title: "Scroll output down", group: "Shell", run: () => scroll?.scrollBy(1) },
|
||||
{ bind: "pageup", title: "Previous output page", group: "Shell", run: () => scroll?.scrollBy(-height()) },
|
||||
{ bind: "pagedown", title: "Next output page", group: "Shell", run: () => scroll?.scrollBy(height()) },
|
||||
{ bind: "home", title: "First loaded output", group: "Shell", run: () => scroll?.scrollTo(0) },
|
||||
{ bind: "end", title: "Follow shell output", group: "Shell", run: () => scroll?.scrollTo(Infinity) },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD} flexGrow={1}>
|
||||
Shell output
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>{status()}</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.subdued} maxHeight={3} wrapMode="word">
|
||||
{props.shell.command}
|
||||
</text>
|
||||
<Show when={omitted()}>
|
||||
<text fg={theme.text.subdued}>Earlier output omitted · showing recent output</text>
|
||||
</Show>
|
||||
<scrollbox
|
||||
id="shell-output-scroll"
|
||||
ref={(value: ScrollBoxRenderable) => (scroll = value)}
|
||||
height={height()}
|
||||
stickyScroll
|
||||
stickyStart="bottom"
|
||||
scrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<text fg={theme.text.default} wrapMode="word">
|
||||
{text() ||
|
||||
(output() === undefined
|
||||
? "Loading output…"
|
||||
: "No captured output. Output redirected to files is not shown here.")}
|
||||
</text>
|
||||
</scrollbox>
|
||||
<Show when={error()}>
|
||||
<text fg={theme.text.feedback.error.default}>{error()}</text>
|
||||
</Show>
|
||||
<box flexDirection="row" gap={2} flexWrap="wrap">
|
||||
<text fg={theme.text.subdued}>↑/↓ scroll</text>
|
||||
<text fg={theme.text.subdued}>end follow</text>
|
||||
<text fg={theme.text.subdued}>esc back</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -244,6 +244,7 @@ export const Definitions = {
|
||||
"composer.subagent.interrupt": keybind("ctrl+d", "Interrupt subagent"),
|
||||
"composer.shell.up": keybind("up", "Previous shell"),
|
||||
"composer.shell.down": keybind("down", "Next shell"),
|
||||
"composer.shell.select": keybind("return", "View shell output"),
|
||||
"composer.shell.kill": keybind("ctrl+d", "Kill shell command"),
|
||||
"composer.terminal.up": keybind("up,k", "Previous terminal"),
|
||||
"composer.terminal.down": keybind("down,j", "Next terminal"),
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useClient } from "../../../context/client"
|
||||
import { useTheme } from "../../../context/theme"
|
||||
import { Keymap } from "../../../context/keymap"
|
||||
import { useComposerTab } from "./index"
|
||||
import { useDialog } from "../../../ui/dialog"
|
||||
import { DialogShellOutput } from "../../../component/dialog-shell-output"
|
||||
|
||||
export function ShellTab(props: { sessionID: string }) {
|
||||
const data = useData()
|
||||
@@ -13,6 +15,7 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
const theme = useTheme()
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const dialog = useDialog()
|
||||
|
||||
const entries = createMemo(() =>
|
||||
data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"),
|
||||
@@ -23,6 +26,11 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
|
||||
const selectedEntry = createMemo(() => entries()[store.selected])
|
||||
|
||||
const open = () => {
|
||||
const entry = selectedEntry()
|
||||
if (entry) dialog.replace(() => <DialogShellOutput shell={entry} location={entry.location} />)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (store.selected >= entries().length) setStore("selected", Math.max(0, entries().length - 1))
|
||||
})
|
||||
@@ -42,7 +50,13 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
const cleanup = composer.register({
|
||||
id: "shell",
|
||||
label: "Shell",
|
||||
hints: () => (selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : []),
|
||||
hints: () =>
|
||||
selectedEntry()
|
||||
? [
|
||||
{ label: "output", shortcut: shortcuts.get("composer.shell.select") ?? "" },
|
||||
{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" },
|
||||
]
|
||||
: [],
|
||||
})
|
||||
onCleanup(cleanup)
|
||||
})
|
||||
@@ -74,6 +88,12 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
setStore("selected", (prev) => (prev + 1) % list.length)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "composer.shell.select",
|
||||
title: "View shell output",
|
||||
group: "Composer",
|
||||
run: open,
|
||||
},
|
||||
{
|
||||
id: "composer.shell.kill",
|
||||
title: "Kill shell command",
|
||||
@@ -106,6 +126,10 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
active() ? theme.background.action.primary.focused : theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
onMouseUp={() => {
|
||||
setStore("selected", index())
|
||||
open()
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={active() ? theme.text.action.primary.focused : theme.text.action.primary.default}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { LocationProvider } from "../../../src/context/location"
|
||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
@@ -31,6 +33,7 @@ async function renderComposer(
|
||||
const events = createEventStream()
|
||||
const interrupted: string[] = []
|
||||
const removed: string[] = []
|
||||
const viewed: string[] = []
|
||||
const ready = Promise.withResolvers<void>()
|
||||
let closed = 0
|
||||
let dispatch!: ReturnType<typeof Keymap.use>["dispatch"]
|
||||
@@ -53,6 +56,13 @@ async function renderComposer(
|
||||
})
|
||||
}
|
||||
const shellID = url.pathname.match(/^\/api\/shell\/([^/]+)$/)?.[1]
|
||||
if (shellID && request.method === "GET") {
|
||||
viewed.push(shellID)
|
||||
return json({ location: { directory }, data: shells.find((shell) => shell.id === shellID) })
|
||||
}
|
||||
if (url.pathname.endsWith("/output")) {
|
||||
return json({ location: { directory }, data: { output: "", cursor: 0, size: 0, truncated: false } })
|
||||
}
|
||||
if (shellID && request.method === "DELETE") {
|
||||
removed.push(shellID)
|
||||
return new Response(null, { status: 204 })
|
||||
@@ -100,7 +110,11 @@ async function renderComposer(
|
||||
<LocationProvider>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<Content />
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Content />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</RouteProvider>
|
||||
</LocationProvider>
|
||||
@@ -119,6 +133,7 @@ async function renderComposer(
|
||||
app,
|
||||
interrupted,
|
||||
removed,
|
||||
viewed,
|
||||
route: () => route.data,
|
||||
dispatch: (command: string) => dispatch(command),
|
||||
closed: () => closed,
|
||||
@@ -154,15 +169,18 @@ test("disabled shell bindings have no component fallbacks", async () => {
|
||||
const composer = await renderComposer("shell", {
|
||||
"composer.shell.up": "none",
|
||||
"composer.shell.down": "none",
|
||||
"composer.shell.select": "none",
|
||||
"composer.shell.kill": "none",
|
||||
})
|
||||
try {
|
||||
expect(composer.app.captureCharFrame()).toContain("bun test")
|
||||
composer.app.mockInput.pressArrow("up")
|
||||
composer.app.mockInput.pressEnter()
|
||||
composer.app.mockInput.pressKey("d", { ctrl: true })
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.closed()).toBe(0)
|
||||
expect(composer.removed).toEqual([])
|
||||
expect(composer.viewed).toEqual([])
|
||||
|
||||
composer.app.mockInput.pressArrow("down")
|
||||
composer.dispatch("composer.shell.kill")
|
||||
@@ -198,6 +216,22 @@ test("ctrl+c closes the active composer", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("shell output respects a configured binding with a focused textarea", async () => {
|
||||
const composer = await renderComposer("shell", { "composer.shell.select": "ctrl+o" }, true)
|
||||
try {
|
||||
composer.app.mockInput.pressEnter()
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.viewed).toEqual([])
|
||||
composer.app.mockInput.pressKey("o", { ctrl: true })
|
||||
await wait(() => composer.viewed.length > 0)
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.app.captureCharFrame()).toContain("Shell output")
|
||||
expect(composer.viewed).toEqual(["sh-a"])
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function session(id: string, title: string, parentID?: string) {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -15,6 +15,8 @@ import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { RouteProvider } from "../../../src/context/route"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
@@ -2020,7 +2022,11 @@ test("keeps shell state scoped to location", async () => {
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</RouteProvider>
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { ShellInfo } from "@opencode-ai/client"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
import { ClientProvider } from "../../src/context/client"
|
||||
import { DataProvider, useData } from "../../src/context/data"
|
||||
import { Keymap } from "../../src/context/keymap"
|
||||
import { RouteProvider } from "../../src/context/route"
|
||||
import { ThemeProvider } from "../../src/context/theme"
|
||||
import { Composer } from "../../src/routes/session/composer"
|
||||
import { DialogProvider } from "../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../src/ui/toast"
|
||||
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
|
||||
import { createApi, createEventStream, createFetch, json } from "../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
async function setup(width: number, output = "") {
|
||||
const temporary = await tmpdir()
|
||||
const location = { directory: `${temporary.path}/original`, workspaceID: "workspace_fixture" }
|
||||
const shell: ShellInfo = {
|
||||
id: "sh_fixture",
|
||||
command: "render-scene --quality high",
|
||||
cwd: location.directory,
|
||||
shell: "/bin/sh",
|
||||
file: `${temporary.path}/capture.out`,
|
||||
status: "running",
|
||||
metadata: { sessionID: "ses_fixture" },
|
||||
time: { started: 0 },
|
||||
}
|
||||
const state = { output, missing: false, failure: false }
|
||||
const requests: { url: URL; method: string }[] = []
|
||||
const events = createEventStream()
|
||||
const envelope = (data: unknown) => json({ location, data })
|
||||
const api = createApi(
|
||||
createFetch((url, request) => {
|
||||
if (!url.pathname.startsWith("/api/shell")) return undefined
|
||||
requests.push({ url, method: request.method })
|
||||
if (url.pathname === "/api/shell") return envelope([shell])
|
||||
if (state.missing)
|
||||
return json({ _tag: "ShellNotFoundError", id: shell.id, message: "Shell not found" }, { status: 404 })
|
||||
if (state.failure) return new Response("Unavailable", { status: 503 })
|
||||
if (url.pathname === `/api/shell/${shell.id}`) return envelope(shell)
|
||||
const bytes = Buffer.from(state.output)
|
||||
const cursor = Math.min(Number(url.searchParams.get("cursor") ?? 0), bytes.length)
|
||||
const end = Math.min(cursor + Number(url.searchParams.get("limit") ?? 65536), bytes.length)
|
||||
return envelope({
|
||||
output: bytes.subarray(cursor, end).toString(),
|
||||
cursor: end,
|
||||
size: bytes.length,
|
||||
truncated: false,
|
||||
})
|
||||
}, events).fetch,
|
||||
)
|
||||
|
||||
function Shells() {
|
||||
const data = useData()
|
||||
const [open, setOpen] = createSignal(true)
|
||||
onMount(() => void data.shell.sync(location))
|
||||
return <Composer sessionID="ses_fixture" open={open()} defaultTab="shell" onClose={() => setOpen(false)} />
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts directory={temporary.path} paths={{ state: temporary.path }}>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ session: { terminal: false } })}>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_fixture" }}>
|
||||
<ClientProvider api={api}>
|
||||
<DataProvider directory={temporary.path}>
|
||||
<ThemeProvider mode={width === 40 ? "light" : "dark"} source={emptyThemeSource}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Shells />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ThemeProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width, height: 30, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes(shell.command))
|
||||
return {
|
||||
...app,
|
||||
state,
|
||||
shell,
|
||||
location,
|
||||
requests,
|
||||
events,
|
||||
async [Symbol.asyncDispose]() {
|
||||
app.renderer.destroy()
|
||||
await temporary[Symbol.asyncDispose]()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test.each([40, 100])("shell output opens, follows, scrolls, and survives exit at %s columns", async (width) => {
|
||||
await using app = await setup(width, Array.from({ length: 50 }, (_, i) => `Frame ${i + 1}\n`).join(""))
|
||||
expect(app.captureCharFrame()).toContain("output")
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitForFrame((frame) => frame.includes("Shell output") && frame.includes("Frame 50"))
|
||||
const scroll = app.renderer.root.findDescendantById("shell-output-scroll")
|
||||
if (!(scroll instanceof ScrollBoxRenderable)) throw new Error("Output scrollbox missing")
|
||||
expect(scroll.scrollTop).toBeGreaterThan(0)
|
||||
|
||||
app.mockInput.pressKey("HOME")
|
||||
await app.waitForFrame((frame) => frame.includes("Frame 1\n") || /Frame 1\s/.test(frame))
|
||||
expect(scroll.scrollTop).toBe(0)
|
||||
app.state.output += "Frame 51\n"
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.requests.some(
|
||||
(request) => request.url.searchParams.get("cursor") === String(Buffer.byteLength(app.state.output)),
|
||||
),
|
||||
{ maxPasses: 150 },
|
||||
)
|
||||
expect(scroll.scrollTop).toBe(0)
|
||||
app.mockInput.pressKey("END")
|
||||
await app.waitForFrame((frame) => frame.includes("Frame 51"))
|
||||
app.shell.status = "exited"
|
||||
app.shell.exit = 0
|
||||
app.events.emit({
|
||||
id: "evt_exit",
|
||||
created: 0,
|
||||
type: "shell.exited",
|
||||
location: app.location,
|
||||
data: { id: app.shell.id, exit: 0, status: "exited" },
|
||||
})
|
||||
await app.waitForFrame((frame) => frame.includes("code 0"), { maxPasses: 100 })
|
||||
const metadataReads = app.requests.filter((request) => request.url.pathname === `/api/shell/${app.shell.id}`).length
|
||||
// Terminal metadata can arrive before the capture's final flush.
|
||||
app.state.output += "\u001b[32mRender complete\u001b[0m\r\n"
|
||||
await app.waitForFrame((frame) => frame.includes("Render complete") && frame.includes("code 0"), { maxPasses: 100 })
|
||||
expect(app.requests.filter((request) => request.url.pathname === `/api/shell/${app.shell.id}`)).toHaveLength(
|
||||
metadataReads,
|
||||
)
|
||||
expect(app.captureCharFrame()).not.toContain("[32m")
|
||||
expect(app.requests.every((request) => request.method === "GET")).toBe(true)
|
||||
const reads = app.requests.filter((request) => request.url.pathname !== "/api/shell")
|
||||
expect(reads.every((request) => request.url.searchParams.get("location[directory]") === app.location.directory)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
reads.every((request) => request.url.searchParams.get("location[workspace]") === app.location.workspaceID),
|
||||
).toBe(true)
|
||||
|
||||
app.mockInput.pressEscape()
|
||||
await app.waitForFrame((frame) => !frame.includes("Shell output") && frame.includes("No shell commands"))
|
||||
const count = app.requests.length
|
||||
await Bun.sleep(1100)
|
||||
expect(app.requests).toHaveLength(count)
|
||||
})
|
||||
|
||||
test("empty output explains redirection, retries errors, and preserves output after removal", async () => {
|
||||
await using app = await setup(100)
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitForFrame((frame) => frame.includes("No captured output") && frame.includes("redirected"))
|
||||
app.state.failure = true
|
||||
await app.waitForFrame((frame) => frame.includes("Retrying"), { maxPasses: 100 })
|
||||
app.state.failure = false
|
||||
app.state.output = "Recovered output\n"
|
||||
await app.waitForFrame((frame) => frame.includes("Recovered output") && !frame.includes("Retrying"), {
|
||||
maxPasses: 100,
|
||||
})
|
||||
app.state.missing = true
|
||||
await app.waitForFrame((frame) => frame.includes("no longer available"), { maxPasses: 100 })
|
||||
expect(app.captureCharFrame()).toContain("Recovered output")
|
||||
const count = app.requests.length
|
||||
await Bun.sleep(1100)
|
||||
expect(app.requests).toHaveLength(count)
|
||||
})
|
||||
|
||||
test.each([40, 100])("mouse-wheel scrolling pauses and resumes output following at %s columns", async (width) => {
|
||||
await using app = await setup(width, Array.from({ length: 50 }, (_, i) => `Frame ${i + 1}\n`).join(""))
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitForFrame((frame) => frame.includes("Shell output") && frame.includes("Frame 50"))
|
||||
const scroll = app.renderer.root.findDescendantById("shell-output-scroll")
|
||||
if (!(scroll instanceof ScrollBoxRenderable)) throw new Error("Output scrollbox missing")
|
||||
const bottom = scroll.scrollTop
|
||||
await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "up")
|
||||
await app.waitFor(() => scroll.scrollTop < bottom)
|
||||
const paused = scroll.scrollTop
|
||||
const height = scroll.scrollHeight
|
||||
|
||||
app.state.output += "Frame 51\n"
|
||||
await app.waitFor(() => scroll.scrollHeight > height, { maxPasses: 100 })
|
||||
expect(scroll.scrollTop).toBe(paused)
|
||||
expect(app.captureCharFrame()).toContain("Shell output")
|
||||
|
||||
await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "down")
|
||||
await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "down")
|
||||
await app.waitFor(() => scroll.scrollTop === scroll.scrollHeight - scroll.viewport.height)
|
||||
const followed = scroll.scrollTop
|
||||
app.state.output += "Frame 52\n"
|
||||
await app.waitForFrame((frame) => frame.includes("Frame 52"), { maxPasses: 100 })
|
||||
expect(scroll.scrollTop).toBeGreaterThan(followed)
|
||||
expect(scroll.scrollTop).toBe(scroll.scrollHeight - scroll.viewport.height)
|
||||
})
|
||||
|
||||
test("large captures open at a bounded tail and clicking a shell opens the viewer", async () => {
|
||||
await using app = await setup(100, "old output\n".repeat(20000) + "Latest frame\n")
|
||||
const row = app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.findIndex((line) => line.includes(app.shell.command))
|
||||
await app.mockMouse.click(6, row)
|
||||
await app.waitForFrame((frame) => frame.includes("Latest frame") && frame.includes("Earlier output omitted"))
|
||||
const reads = app.requests.filter((request) => request.url.pathname.endsWith("/output"))
|
||||
expect(reads[0]?.url.searchParams.get("cursor")).toBe(String(Number.MAX_SAFE_INTEGER))
|
||||
expect(reads[1]?.url.searchParams.get("cursor")).toBe(String(Buffer.byteLength(app.state.output) - 65536))
|
||||
expect(reads[1]?.url.searchParams.get("limit")).toBe("65536")
|
||||
})
|
||||
@@ -9,6 +9,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="split-button-v2"].session-review-v2-open-in-app,
|
||||
[data-component="split-button-v2"]:is(:hover, :has([data-component="split-button-v2-menu-trigger"][data-expanded])) {
|
||||
box-shadow: inset 0 0 0 1px var(--v2-border-border-muted);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||
import { SplitButton, SplitButtonAction, SplitButtonMenuTrigger } from "./split-button"
|
||||
|
||||
export default {
|
||||
@@ -29,3 +30,16 @@ export const Disabled = {
|
||||
</SplitButton>
|
||||
),
|
||||
}
|
||||
|
||||
export const OpenIn = {
|
||||
render: () => (
|
||||
<SplitButton class="session-review-v2-open-in-app">
|
||||
<SplitButtonAction aria-label="Open in Finder">
|
||||
<AppIcon id="finder" />
|
||||
</SplitButtonAction>
|
||||
<SplitButtonMenuTrigger aria-label="Open options">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</SplitButtonMenuTrigger>
|
||||
</SplitButton>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const names = [
|
||||
"archive",
|
||||
"arrow-left",
|
||||
"arrow-right",
|
||||
"arrow-up-right",
|
||||
"branch",
|
||||
"check",
|
||||
"chevron-down",
|
||||
|
||||
@@ -4,6 +4,10 @@ import "./icon.css"
|
||||
|
||||
// Consumers center the SVG viewport, so each icon must center its artwork within its viewBox.
|
||||
const icons = {
|
||||
flask: {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M5.5 2H10.5M6 2V6L2.5 12C2 13 2.5 14 3.5 14H12.5C13.5 14 14 13 13.5 12L10 6V2M4.25 9H11.75" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`,
|
||||
},
|
||||
edit: {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M13.5556 8.21529V13.5556H2.44446L2.44446 2.44445H7.78474M6.00002 8.16216V10H7.83786L14 3.83784L12.1622 2L6.00002 8.16216Z" stroke="currentColor"/>`,
|
||||
@@ -32,6 +36,10 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M6.33345 6.33349V5.00015H9.66679V7.00015L8.00015 8.00015V9.66679M8.27485 11.6819H7.71897M14.4446 8.00011C14.4446 11.5593 11.5593 14.4446 8.00011 14.4446C4.44094 14.4446 1.55566 11.5593 1.55566 8.00011C1.55566 4.44094 4.44094 1.55566 8.00011 1.55566C11.5593 1.55566 14.4446 4.44094 14.4446 8.00011Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
},
|
||||
info: {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<g transform="translate(2 2)"><path d="M12 12H0V0H12V12ZM1 1V11H11V1H1ZM6.5791 4.81641V9.37207H5.5791V5.81641H4.46777V4.81641H6.5791ZM6.85645 2.62891V3.62891H5.30078V2.62891H6.85645Z" fill="currentColor"/></g>`,
|
||||
},
|
||||
"circle-exclamation": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M8.75 11.75H7.25V10.25H8.75V11.75Z" fill="currentColor"/><path d="M8.75 9.25H7.25V4.25H8.75V9.25Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8 1C9.93286 1 11.684 1.7836 12.9502 3.0498C14.2164 4.31601 15 6.06714 15 8C15 11.866 11.866 15 8 15C6.06714 15 4.31601 14.2164 3.0498 12.9502C1.7836 11.684 1 9.93286 1 8C1 4.13401 4.13401 1 8 1ZM8 2C4.68629 2 2 4.68629 2 8C2 9.65699 2.67148 11.1559 3.75781 12.2422C4.84415 13.3285 6.34301 14 8 14C11.3137 14 14 11.3137 14 8C14 6.34301 13.3285 4.84415 12.2422 3.75781C11.1559 2.67148 9.65699 2 8 2Z" fill="currentColor"/>`,
|
||||
@@ -148,6 +156,10 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M4.14908 11.0081H1.76282V1.51758H9.1038V2.55588M14.2225 4.99681H6.75397V14.4873H14.2225V4.99681Z" stroke="currentColor"/>`,
|
||||
},
|
||||
"arrow-up-right": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M11 9.56V5H6.44M11 5L5 11" stroke="currentColor"/>`,
|
||||
},
|
||||
"outline-square-arrow": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M13.5555 6.66656V2.44434H9.33326M13.5555 2.44434L7.99993 7.99989M13.5555 9.33324V13.5555C13.5555 13.5555 12.7599 13.5555 11.7777 13.5555H2.44438C2.44438 13.5555 2.44438 12.7599 2.44438 11.7777V4.22213C2.44438 3.2399 2.44434 2.44435 2.44434 2.44435H6.66661" stroke="currentColor"/>`,
|
||||
@@ -204,6 +216,7 @@ function spriteContent() {
|
||||
function ensureSprite() {
|
||||
if (spriteInserted) return
|
||||
if (typeof document === "undefined") return
|
||||
// Hot reload preserves the DOM sprite, but its symbols may be from an older module.
|
||||
const existing = document.getElementById(spriteID)
|
||||
if (existing) {
|
||||
existing.innerHTML = spriteContent()
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
padding-inline-end: 6px;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
@@ -552,6 +552,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
&[data-value="open-file"],
|
||||
&[data-value^="file://"]
|
||||
{
|
||||
gap: 8px;
|
||||
padding-inline-end: 4px;
|
||||
|
||||
[data-slot="tabs-trigger-close-button"] {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:has([data-selected]) {
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
box-shadow: none;
|
||||
@@ -569,19 +580,19 @@
|
||||
font-variation-settings: "slnt" 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&[data-selected] {
|
||||
&[data-selected]:not([data-value="open-file"]) {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.tab-fileicon-color,
|
||||
.tab-fileicon-mono,
|
||||
[data-slot="icon-svg"] {
|
||||
.tab-fileicon-mono {
|
||||
margin-inline-start: -2px;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic !important;
|
||||
font-variation-settings: "slnt" -10 !important;
|
||||
padding-inline-end: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,8 +210,12 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
|
||||
const add = input.add ?? []
|
||||
const npmOptions = yield* NpmConfig.load(input.config ?? input.dir)
|
||||
const options = input.update ? { ...npmOptions, preferOnline: true, noGitRevCache: true } : npmOptions
|
||||
const options = {
|
||||
...(yield* NpmConfig.load(input.config ?? input.dir)),
|
||||
...(input.update ? { preferOnline: true, noGitRevCache: true } : {}),
|
||||
// Audit reports are unused here, but Arborist waits for them before completing an install.
|
||||
audit: false,
|
||||
}
|
||||
const arborist = new Arborist({
|
||||
...options,
|
||||
path: input.dir,
|
||||
|
||||
@@ -317,8 +317,13 @@ The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, an
|
||||
| `composer.subagent.interrupt` | `ctrl+d` | Interrupt subagent |
|
||||
| `composer.shell.up` | `up` | Previous shell |
|
||||
| `composer.shell.down` | `down` | Next shell |
|
||||
| `composer.shell.select` | `return` | View shell output |
|
||||
| `composer.shell.kill` | `ctrl+d` | Kill shell command |
|
||||
|
||||
Select a running command in the **Shell** tab and press **Enter**, or click it, to view captured stdout and stderr.
|
||||
Use **↑/↓**, **Page Up/Down**, or **Home** to scroll, **End** to follow new output, and **Esc** to return without stopping the command.
|
||||
The viewer shows recent output and stays open after exit; output redirected to a file is not included.
|
||||
|
||||
## Dialogs And Autocomplete
|
||||
|
||||
| ID | Default | Description |
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Llm, OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
const label = process.env.DEMO_LABEL ?? "AFTER"
|
||||
|
||||
// Run from the repository root with `opencode-drive run script/drive/shell-output.ts`.
|
||||
// Set OPENCODE_DEV to an immutable base worktree and DEMO_LABEL=BEFORE for comparison.
|
||||
// Only the conversation is simulated; shell execution and output reads are real.
|
||||
export default OpenCodeDriver.use(
|
||||
{
|
||||
opencode: { dev: process.env.OPENCODE_DEV ?? process.cwd() },
|
||||
keepArtifacts: true,
|
||||
tui: { recording: true, keypressOverlay: true, viewport: { cols: 90, rows: 30 } },
|
||||
config: { autoupdate: false, username: "Demo" },
|
||||
tuiConfig: { theme: { name: "opencode", mode: "dark" }, animations: false, tabs: { enabled: false } },
|
||||
project: {
|
||||
git: true,
|
||||
files: {
|
||||
"README.md": "# Shell output demo\nDeterministic real shell output.\n",
|
||||
"render-scene.sh": [
|
||||
"#!/bin/sh",
|
||||
"i=1",
|
||||
'while [ "$i" -le 40 ]; do printf "Frame %02d: rendered successfully\\n" "$i"; i=$((i+1)); done',
|
||||
"while [ ! -f continue ]; do sleep 0.1; done",
|
||||
'while [ "$i" -le 48 ]; do printf "Frame %02d: rendered successfully\\n" "$i"; i=$((i+1)); sleep 0.25; done',
|
||||
"while [ ! -f finish ]; do sleep 0.1; done",
|
||||
"printf 'Diagnostics: no errors\\n' >&2",
|
||||
"printf 'Render complete: 48 frames saved.\\n'",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
({ ui, llm, tui, opencode, artifacts }) =>
|
||||
Effect.gen(function* () {
|
||||
const recording = tui.recording
|
||||
if (!recording) return yield* Effect.fail(new Error("Recording required"))
|
||||
yield* llm.serve(() => Stream.make(Llm.text("Ready to inspect the render job.")))
|
||||
yield* ui.submit("Inspect the render job.")
|
||||
yield* ui.waitFor("Ready to inspect the render job.")
|
||||
const sessions = yield* opencode.session.list({ limit: 1, order: "desc" })
|
||||
const session = sessions.data[0]
|
||||
if (!session) return yield* Effect.fail(new Error("Session missing"))
|
||||
yield* opencode.session.rename({ sessionID: session.id, title: "Shell output demo" })
|
||||
yield* opencode.shell.create({ command: "sh render-scene.sh", timeout: 0, metadata: { sessionID: session.id } })
|
||||
yield* ui.arrow("down")
|
||||
yield* ui.arrow("right")
|
||||
yield* ui.waitFor("sh render-scene.sh")
|
||||
yield* recording.mark(`${label}: select a running shell`)
|
||||
yield* Effect.sleep(1000)
|
||||
yield* ui.enter()
|
||||
yield* ui.waitFor(label === "AFTER" ? "Frame 40: rendered successfully" : "sh render-scene.sh")
|
||||
yield* Effect.sleep(1000)
|
||||
yield* recording.mark(`${label}: Enter ${label === "AFTER" ? "opens live output" : "does nothing"}`)
|
||||
console.log("opened:", yield* ui.screenshot(`${label.toLowerCase()}-opened`))
|
||||
yield* Effect.promise(() => Bun.write(`${artifacts}/files/continue`, "go"))
|
||||
if (label === "AFTER") yield* ui.waitFor("Frame 48: rendered successfully")
|
||||
yield* Effect.sleep(2800)
|
||||
yield* ui.press("home")
|
||||
yield* ui.waitFor(label === "AFTER" ? "Frame 01: rendered successfully" : "sh render-scene.sh")
|
||||
yield* recording.mark(`${label}: ${label === "AFTER" ? "Home scrolls to earlier output" : "no output to scroll"}`)
|
||||
yield* Effect.sleep(1500)
|
||||
console.log("scrolled:", yield* ui.screenshot(`${label.toLowerCase()}-scrolled`))
|
||||
yield* ui.press("end")
|
||||
yield* ui.waitFor(label === "AFTER" ? "Frame 48: rendered successfully" : "sh render-scene.sh")
|
||||
yield* recording.mark(`${label}: ${label === "AFTER" ? "End follows the latest output" : "no output to follow"}`)
|
||||
yield* Effect.sleep(1000)
|
||||
yield* Effect.promise(() => Bun.write(`${artifacts}/files/finish`, "go"))
|
||||
yield* ui.waitFor(label === "AFTER" ? "Render complete: 48 frames saved." : "No shell commands")
|
||||
if (label === "AFTER") yield* ui.waitFor("Exited · code 0")
|
||||
yield* Effect.sleep(1600)
|
||||
yield* recording.mark(
|
||||
`${label}: ${label === "AFTER" ? "result stays open after exit" : "finished shell disappears"}`,
|
||||
)
|
||||
console.log("exited:", yield* ui.screenshot(`${label.toLowerCase()}-exited`))
|
||||
yield* Effect.sleep(2000)
|
||||
yield* ui.resize({ cols: 40, rows: 24 })
|
||||
yield* Effect.sleep(500)
|
||||
console.log("narrow:", yield* ui.screenshot(`${label.toLowerCase()}-narrow`))
|
||||
yield* ui.resize({ cols: 90, rows: 30 })
|
||||
yield* Effect.sleep(500)
|
||||
yield* ui.press("escape")
|
||||
if (label === "AFTER") yield* ui.waitFor("No shell commands")
|
||||
yield* recording.mark(`${label}: Esc back`)
|
||||
yield* Effect.sleep(1000)
|
||||
console.log("back:", yield* ui.screenshot(`${label.toLowerCase()}-back`))
|
||||
return console.log("video:", yield* recording.finish())
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user