Compare commits

...
34 changed files with 1417 additions and 272 deletions

No files matched your search

@@ -0,0 +1,72 @@
import { expect, test, type Page } from "@playwright/test"
import { fixture, installStressSessionTabs } from "../utils/session-fixture"
import { sessionHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const { width, direction } of [
{ width: 390, direction: "ltr" },
{ width: 390, direction: "rtl" },
{ width: 1280, direction: "ltr" },
] as const) {
test(`submitting a prompt ${width < 768 ? "unfocuses" : "keeps focus on"} the composer at ${width}px in ${direction}`, async ({
page,
}) => {
await openSubmitSession(page, width, direction)
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(editor).toBeEditable()
await editor.fill("Please check focus after sending.")
await expect(editor).toBeFocused()
const admitted = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/session/${fixture.targetID}/prompt`,
)
await editor.press("Enter")
expect((await admitted).ok()).toBe(true)
await expect(editor).toBeEmpty()
if (width < 768) {
await expect(editor).not.toBeFocused()
return
}
await expect(editor).toBeFocused()
})
}
test.describe("touch submission", () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test("tapping Send unfocuses the composer", async ({ page }) => {
await openSubmitSession(page, 390, "ltr")
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(editor).toBeEditable()
await editor.fill("Please dismiss the keyboard after sending.")
await expect(editor).toBeFocused()
const admitted = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/session/${fixture.targetID}/prompt`,
)
await page.getByRole("button", { name: "Send", exact: true }).tap()
expect((await admitted).ok()).toBe(true)
await expect(editor).toBeEmpty()
await expect(editor).not.toBeFocused()
})
})
async function openSubmitSession(page: Page, width: number, direction: "ltr" | "rtl") {
await page.setViewportSize({ width, height: 844 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(page)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
})
await openWithDirection(page, sessionHref(fixture.targetID), direction)
}
@@ -0,0 +1,197 @@
import { expect, test } from "@playwright/test"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { sessionHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
for (const scheme of ["light", "dark"] as const) {
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile extensions reuse the session drawer in ${scheme} ${direction}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.addInitScript((scheme) => {
localStorage.setItem("opencode-color-scheme", scheme)
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
}, scheme)
const warnings: string[] = []
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
await openWithDirection(page, sessionHref(fixture.targetID), direction)
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const more = tabs.getByRole("tab", { name: "More...", exact: true })
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Session")
await more.click()
await page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Session details", exact: true })
.click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await details.evaluate((element) => element.setAttribute("data-drawer-probe", "single"))
for (const [name, empty] of [
["MCP", "No MCP servers configured"],
["Plugins", "No plugins configured"],
["Skills", "No skills configured"],
["LSP", "No LSP servers configured"],
]) {
const trigger = details.getByRole("button", { name, exact: true })
await trigger.click()
const extension = page.getByRole("dialog", { name, exact: true })
await expect(extension.getByText(empty, { exact: true })).toBeVisible()
await expect(extension).toHaveAttribute("data-drawer-probe", "single")
await expect(page.getByRole("dialog")).toHaveCount(1)
await expect(page.locator('[data-slot="mobile-drawer-overlay"]')).toHaveCount(1)
await expect(extension.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(extension.locator(".session-service-menu")).toHaveCount(0)
await expect(extension).toBeInViewport({ ratio: 1 })
await extension.getByRole("button", { name: "Navigate back", exact: true }).click()
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await expect(trigger).toBeFocused()
}
await details.getByRole("button", { name: "Skills", exact: true }).click()
await expect(
page.getByRole("dialog", { name: "Skills", exact: true }).getByText("No skills configured", { exact: true }),
).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("extensions.png") })
await testInfo.attach("extensions", { path: testInfo.outputPath("extensions.png"), contentType: "image/png" })
await page.keyboard.press("Escape")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(more).toBeFocused()
await more.click()
await expect(
page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Files", exact: true }),
).toBeVisible()
expect(warnings).toEqual([])
})
}
}
test("mobile extension drawers keep configured lists, retry, refresh, and MCP toggles working", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
const state = { fail: true, extra: false, enabled: true }
await page.route(
(url) => url.pathname === "/api/plugin",
(route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{
id: "drawer-plugin",
source: { type: "package", target: "drawer-plugin" },
features: {},
state: { status: "active" },
},
...(state.extra
? [
{
id: "extra-plugin",
source: { type: "package", target: "extra-plugin" },
features: {},
state: { status: "active" },
},
]
: []),
],
},
})
},
)
await page.route(
(url) => url.pathname === "/api/skill",
(route) =>
route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "drawer-skill", name: "drawer-skill", path: "/skills/drawer/SKILL.md", content: "Drawer skill" },
],
},
}),
)
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({ json: [{ type: "document", info: { lsp: { "drawer-lsp": { command: ["drawer-lsp"] } } } }] }),
)
await page.route(
(url) => url.pathname.startsWith("/api/mcp") || url.pathname.startsWith("/api/experimental/mcp"),
(route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const path = new URL(route.request().url()).pathname
if (path.endsWith("/disconnect")) {
state.enabled = false
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
path === "/api/mcp/resource"
? { resources: [], templates: [] }
: Array.from({ length: 30 }, (_, index) => ({
name: `mcp-${String(index).padStart(2, "0")}`,
status: { status: index === 0 && !state.enabled ? "disabled" : "connected" },
})),
},
})
},
)
await openWithDirection(page, sessionHref(fixture.targetID), "ltr")
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Session")
await tabs.getByRole("tab", { name: "More...", exact: true }).click()
await page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Session details", exact: true })
.click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await details.getByRole("button", { name: "Plugins", exact: true }).click()
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(plugins.getByRole("alert")).toContainText("Request failed")
state.fail = false
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
await expect(plugins.getByText("drawer-plugin", { exact: true })).toBeVisible()
await plugins.getByRole("button", { name: "Navigate back", exact: true }).click()
state.extra = true
await details.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(plugins.getByText("extra-plugin", { exact: true })).toBeVisible()
await plugins.getByRole("button", { name: "Navigate back", exact: true }).click()
for (const [name, item] of [
["Skills", "drawer-skill"],
["LSP", "drawer-lsp"],
]) {
await details.getByRole("button", { name, exact: true }).click()
const extension = page.getByRole("dialog", { name, exact: true })
await expect(extension.getByText(item, { exact: true })).toBeVisible()
await extension.getByRole("button", { name: "Navigate back", exact: true }).click()
}
await details.getByRole("button", { name: "MCP", exact: true }).click()
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(mcp.getByRole("switch")).toHaveCount(30)
await expect
.poll(() =>
mcp
.locator('[data-slot="mobile-panel-content"]')
.evaluate((element) => element.scrollHeight > element.clientHeight),
)
.toBe(true)
await expect(mcp).toBeInViewport({ ratio: 1 })
const disconnect = page.waitForRequest((request) => new URL(request.url()).pathname.endsWith("/disconnect"))
await mcp.getByRole("switch", { name: "mcp-00", exact: true }).focus()
await mcp.getByRole("switch", { name: "mcp-00", exact: true }).press("Space")
await disconnect
await expect(mcp.getByRole("switch", { name: "mcp-00", exact: true })).not.toBeChecked()
const last = mcp
.locator(".session-mcp-row")
.filter({ has: page.getByRole("switch", { name: "mcp-29", exact: true }) })
await last.scrollIntoViewIfNeeded()
await expect(last).toBeInViewport()
await mcp.getByRole("switch", { name: "mcp-29", exact: true }).focus()
await expect(mcp.getByRole("switch", { name: "mcp-29", exact: true })).toBeFocused()
})
@@ -1,10 +1,21 @@
import { expect, test, type Request } from "@playwright/test"
import { expect, test, type Page, type Request } from "@playwright/test"
import { sessionHref } from "../utils/app"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { fileNode } from "../utils/workspace"
test.use({ viewport: { width: 390, height: 844 } })
async function openFiles(page: Page) {
await page
.getByRole("tablist", { name: "Session view", exact: true })
.getByRole("tab", { name: "More...", exact: true })
.click()
await page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Files", exact: true })
.click()
}
test("mobile files browse, comment, search, and close tabs", async ({ page }) => {
await mockStressTimeline(page, {
fileList: (path) => (path ? [] : ["first.ts", "second.ts"].map((name) => fileNode(fixture.directory, name))),
@@ -14,7 +25,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
})
await page.goto(sessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await openFiles(page)
const files = page.locator('[data-slot="session-mobile-files"]')
await files.getByRole("button", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
@@ -50,7 +61,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
.click()
await expect(session).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await openFiles(page)
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
const filter = files.getByRole("combobox", { name: "Filter files", exact: true })
await expect(filter).toBeHidden()
@@ -63,7 +74,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await openFiles(page)
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await files
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
@@ -239,7 +250,7 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
for (const file of ["added.ts", "modified.ts", "added.ts", "modified.ts"]) {
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await review.locator(`[data-file="${file}"]`).getByRole("button", { name: "Open file", exact: true }).click()
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(navigation.getByRole("tab", { name: "More...", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(selected).toHaveText([file])
await expect(files).toHaveAttribute("data-browsing", "false")
@@ -248,20 +259,23 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
}
})
test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({ page }) => {
test("summary drawer dismisses by backdrop, Escape, and drag", async ({ page }) => {
await mockStressTimeline(page)
await page.goto(sessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
.getByRole("tablist", { name: "Session view", exact: true })
.getByRole("tab", { name: "More...", exact: true })
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag"] as const) {
for (const dismissal of ["backdrop", "escape", "drag"] as const) {
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Session details", exact: true })
.click()
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
// Corvu starts opening after paint; the transition flag is also absent
// before that callback. Wait for the open position before dismissing.
@@ -270,8 +284,6 @@ test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
@@ -0,0 +1,220 @@
import { expect, test, type Page } from "@playwright/test"
import { fixture, installStressSessionTabs } from "../utils/session-fixture"
import { sessionHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
for (const direction of ["ltr", "rtl"] as const) {
for (const paid of [true, false]) {
test(`mobile model and variant pickers use drawers with ${paid ? "paid" : "free"} models in ${direction}`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
await openPickerSession(page, direction, paid)
const composer = page.locator('[data-component="composer"]')
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
await expect(variant).toHaveText("balanced")
await variant.click()
const variants = page.getByRole("dialog", { name: "Choose model variant", exact: true })
await expect(variants.getByRole("button", { name: "balanced", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
)
await expect(variants.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(variants).toBeInViewport({ ratio: 1 })
await expect(variants).not.toHaveAttribute("data-transitioning")
await variants.screenshot({ path: testInfo.outputPath("variants.png") })
await page.keyboard.press("Escape")
await expect(variants).toBeHidden()
await expect(variant).toBeFocused()
await variant.click()
await variants.getByRole("button", { name: "high", exact: true }).click()
await expect(variants).toBeHidden()
await expect(variant).toHaveText("high")
await expect(variant).toBeFocused()
const model = composer.locator('[data-action="composer-model"]')
await expect(model).toHaveText("Mobile Model A")
await expect(model).toHaveAttribute("data-control-type", "drawer")
await model.click()
const models = page.getByRole("dialog", { name: "Select model", exact: true })
const search = models.getByRole("searchbox", { name: "Search models", exact: true })
await expect(models.locator('[data-slot="model-selector-drawer"]')).toBeFocused()
await expect(search).not.toBeFocused()
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveAttribute("aria-pressed", "true")
await expect(models.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(models).toBeInViewport({ ratio: 1 })
await expect(models).not.toHaveAttribute("data-transitioning")
await expect
.poll(() =>
models.evaluate((element) => {
const drawer = element.getBoundingClientRect()
const search = element.querySelector('[data-component="text-input-v2"]')!.getBoundingClientRect()
const row = element.querySelector('[data-component="settings-row"]')!.getBoundingClientRect()
const searchClip = element.querySelector('[data-slot="mobile-panel-content"]')!.getBoundingClientRect()
const rowClip = element.querySelector(".settings-models")!.getBoundingClientRect()
return {
searchStart: Math.round(search.left - drawer.left),
searchEnd: Math.round(drawer.right - search.right),
rowStart: Math.round(row.left - drawer.left),
rowEnd: Math.round(drawer.right - row.right),
searchClipStart: Math.round(search.left - searchClip.left),
searchClipEnd: Math.round(searchClip.right - search.right),
rowClipStart: Math.round(row.left - rowClip.left),
rowClipEnd: Math.round(rowClip.right - row.right),
}
}),
)
.toEqual({
searchStart: 12,
searchEnd: 12,
rowStart: 12,
rowEnd: 12,
searchClipStart: 12,
searchClipEnd: 12,
rowClipStart: 12,
rowClipEnd: 12,
})
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCSS("padding-inline-start", "12px")
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCSS("padding-block-start", "16px")
await models.screenshot({ path: testInfo.outputPath("models.png") })
await search.click()
await expect(search).toBeFocused()
await search.fill("no-matching-model")
await expect(models.getByText("No model results", { exact: true })).toBeVisible()
await search.fill("Mobile Model B")
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCount(0)
await search.press("Enter")
await expect(models).toBeHidden()
await expect(model).toHaveText("Mobile Model B")
await expect(composer.getByRole("textbox", { name: "Prompt", exact: true })).toBeFocused()
await model.click()
await expect(search).toHaveValue("")
await expect(models.locator('[data-slot="model-selector-drawer"]')).toBeFocused()
await expect(models.getByRole("button", { name: /Mobile Model B/ })).toHaveAttribute("aria-pressed", "true")
await expect
.poll(() => models.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(models).not.toHaveAttribute("data-transitioning")
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
await expect(models).toBeHidden()
await expect(model).toBeFocused()
if (paid) {
await model.click()
await models.getByRole("button", { name: "Manage models", exact: true }).click()
await expect(models).toBeHidden()
await expect(page.getByRole("dialog", { name: "Manage models", exact: true })).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(1)
return
}
await model.click()
await models.getByRole("button", { name: "Connect provider", exact: true }).click()
await expect(models).toBeHidden()
const providers = page.getByRole("dialog", { name: "Connect provider", exact: true })
await expect(providers.getByRole("searchbox", { name: "Search providers", exact: true })).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(1)
})
}
test(`desktop model and variant pickers retain menus in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 })
await openPickerSession(page, direction, true)
const composer = page.locator('[data-component="composer"]')
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
await variant.click()
await page.getByRole("menuitemradio", { name: "high", exact: true }).click()
await expect(variant).toHaveText("high")
await expect(page.locator('[data-slot="mobile-drawer-content"]')).toHaveCount(0)
const model = composer.locator('[data-action="composer-model"]')
await expect(model).toHaveAttribute("data-control-type", "popover")
await model.click()
await expect(page.getByPlaceholder("Search models", { exact: true })).toBeFocused()
await page.getByRole("menuitemradio", { name: /Mobile Model B/ }).click()
await expect(model).toHaveText("Mobile Model B")
await expect(page.locator('[data-slot="mobile-drawer-content"]')).toHaveCount(0)
})
}
test.describe("touch composer pickers", () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test("model and default variant open with one tap", async ({ page }) => {
await openPickerSession(page, "ltr", true)
expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true)
const composer = page.locator('[data-component="composer"]')
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
await variant.tap()
const variants = page.getByRole("dialog", { name: "Choose model variant", exact: true })
await variants.getByRole("button", { name: "default", exact: true }).tap()
await expect(variants).toBeHidden()
await expect(variant).toHaveText("default")
await page.touchscreen.tap(5, 300)
await expect(variant).toHaveCSS("pointer-events", "auto")
await expect(variant).toHaveCSS("opacity", "1")
await variant.tap()
await expect(variants).toBeVisible()
await expect
.poll(() => variants.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await page.locator('[data-slot="mobile-drawer-overlay"]').tap({ position: { x: 10, y: 10 } })
await expect(variants).toBeHidden()
await composer.locator('[data-action="composer-model"]').tap()
const models = page.getByRole("dialog", { name: "Select model", exact: true })
await expect(models.locator('[data-slot="model-selector-drawer"]')).toBeFocused()
const search = models.getByRole("searchbox", { name: "Search models", exact: true })
await expect(search).not.toBeFocused()
await search.tap()
await expect(search).toBeFocused()
})
})
async function openPickerSession(page: Page, direction: "ltr" | "rtl", paid: boolean) {
await mockOpenCodeServer(page, {
...fixture,
sessions: fixture.sessions.map((session) => ({
...session,
model: { id: "mobile-a", providerID: "opencode", variant: "balanced" },
})),
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: Object.fromEntries(
["a", "b"].map((id) => [
`mobile-${id}`,
{
id: `mobile-${id}`,
name: `Mobile Model ${id.toUpperCase()}`,
cost: { input: paid ? 1 : 0, output: paid ? 1 : 0 },
limit: { context: 200_000 },
variants: { balanced: {}, high: {} },
},
]),
),
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "mobile-a" },
},
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(page)
await page.addInitScript((direction) => {
localStorage.setItem("opencode-color-scheme", direction === "rtl" ? "dark" : "light")
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
user: ["a", "b"].map((id) => ({ providerID: "opencode", modelID: `mobile-${id}`, visibility: "show" })),
recent: [],
variant: {},
}),
)
}, direction)
await openWithDirection(page, sessionHref(fixture.targetID), direction)
await expectAppVisible(page.getByRole("textbox", { name: "Prompt", exact: true }))
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
}
@@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { sessionHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
for (const scheme of ["light", "dark"] as const) {
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile session options are compact and readable in ${scheme} ${direction}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.addInitScript((scheme) => {
localStorage.setItem("opencode-color-scheme", scheme)
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
}, scheme)
await openWithDirection(page, sessionHref(fixture.targetID), direction)
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const more = tabs.getByRole("tab", { name: "More...", exact: true })
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Session")
await more.click()
const drawer = page.getByRole("dialog", { name: "More options", exact: true })
await expect(drawer).toBeVisible()
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.locator('[data-slot="mobile-panel-header"]')).toHaveCount(0)
await expect(drawer.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(drawer.getByRole("heading", { name: "More options", exact: true })).toHaveCSS("width", "1px")
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", scheme)
await expect
.poll(() =>
drawer.evaluate((element) => {
const options = element.querySelector('[data-slot="session-mobile-view-options"]')!
return getComputedStyle(options).backgroundColor !== getComputedStyle(element).backgroundColor
}),
)
.toBe(true)
await drawer.screenshot({ path: testInfo.outputPath("options.png") })
await testInfo.attach("options", { path: testInfo.outputPath("options.png"), contentType: "image/png" })
for (const name of ["Files", "Terminal", "Usage", "Session details"]) {
const option = drawer.getByRole("button", { name, exact: true })
await expect(option).toHaveCSS("height", "44px")
await expect
.poll(() =>
option.evaluate((element) => {
const context = document.createElement("canvas").getContext("2d")!
function luminance(color: string) {
context.clearRect(0, 0, 1, 1)
context.fillStyle = color
context.fillRect(0, 0, 1, 1)
const channels = Array.from(context.getImageData(0, 0, 1, 1).data)
.slice(0, 3)
.map((channel) => {
const value = channel / 255
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
})
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722
}
const text = luminance(getComputedStyle(element).color)
const surface = luminance(getComputedStyle(element.parentElement!).backgroundColor)
return (Math.max(text, surface) + 0.05) / (Math.min(text, surface) + 0.05)
}),
)
.toBeGreaterThanOrEqual(4.5)
}
await page.keyboard.press("Escape")
await expect(drawer).toBeHidden()
await expect(more).toBeFocused()
for (const dismissal of ["backdrop", "drag"] as const) {
await more.click()
await expect
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "backdrop")
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
if (dismissal === "drag") {
const handle = await drawer.locator('[data-slot="mobile-drawer-handle"]').boundingBox()
expect(handle).not.toBeNull()
await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + handle!.height / 2)
await page.mouse.down()
await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + handle!.height / 2 + 1)
await page.mouse.move(handle!.x + handle!.width / 2, 843)
await page.mouse.up()
}
await expect(drawer).toBeHidden()
await expect(more).toBeFocused()
}
})
}
}
@@ -28,30 +28,36 @@ for (const position of ["top", "bottom"] as const) {
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
const more = navigation.getByRole("button", { name: "More options", exact: true })
const more = tabs.getByRole("tab", { name: "More...", exact: true })
const views = page.getByRole("dialog", { name: "More options", exact: true })
const picker = tabs.getByRole("tab", { selected: true })
const message = page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
)
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(picker).toHaveText("Session")
await expect(navigation).toHaveCount(1)
await expect(message).toBeVisible()
await expect(composer).toBeVisible()
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "More..."])
await expect(tabs).toHaveCSS("padding-left", "0px")
await expect(tabs).toHaveCSS("padding-right", "0px")
await expect
.poll(async () => {
const bounds = await navigation.boundingBox()
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
return !!bounds && bounds.x >= 1 && bounds.x <= 2 && bounds.width >= 386 && bounds.width <= 388
})
.toBe(true)
await expect
.poll(async () => {
const bar = await tabs.boundingBox()
const input = await composer.boundingBox()
const dock = await page.locator('[data-component="session-composer-dock"]').boundingBox()
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
if (!bar || !input || !dock || !panel) return false
if (position === "bottom")
return bar.y >= dock.y + dock.height && Math.abs(bar.y + bar.height - panel.y - panel.height) <= 1
return Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
})
.toBe(true)
await expect(page.locator("[data-session-title]")).toHaveCount(0)
@@ -71,8 +77,20 @@ for (const position of ["top", "bottom"] as const) {
await expect(drawer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(picker).toHaveCount(0)
await expect(views).toBeVisible()
await expect(more).toHaveAttribute("aria-expanded", "true")
for (const name of ["Files", "Terminal", "Usage", "Session details"])
await expect(views.getByRole("button", { name, exact: true })).toBeVisible()
await expect(views).not.toHaveAttribute("data-transitioning")
await page.keyboard.press("Escape")
await expect(views).toBeHidden()
await expect(more).toHaveAttribute("aria-expanded", "false")
await expect(more).toBeFocused()
await expect(picker).toHaveText("Session")
await more.press("Enter")
await views.getByRole("button", { name: "Usage", exact: true }).click()
await expect(views).toBeHidden()
await expect(picker).toHaveText("More...")
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
const usage = page.locator('[data-slot="session-usage-content"]')
await expect(usage).toHaveCSS("padding-top", "16px")
@@ -81,24 +99,32 @@ for (const position of ["top", "bottom"] as const) {
await expect(composer).toBeHidden()
await more.click()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(views.getByRole("button", { name: "Usage", exact: true })).toHaveAttribute("aria-pressed", "true")
await expect(views.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await views.evaluate((element) => element.setAttribute("data-drawer-probe", "single"))
await views.getByRole("button", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details).toHaveAttribute("data-drawer-probe", "single")
await expect(page.getByRole("dialog")).toHaveCount(1)
await expect(page.locator('[data-slot="mobile-drawer-overlay"]')).toHaveCount(1)
await expect(details.locator('[data-slot="mobile-panel-header"]')).toHaveCount(0)
await expect(details.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(details.getByRole("heading", { name: "Session details", exact: true })).toHaveCSS("width", "1px")
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details).not.toHaveAttribute("data-transitioning")
await page.keyboard.press("Escape")
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(picker).toHaveText("Changes")
@@ -107,18 +133,23 @@ for (const position of ["top", "bottom"] as const) {
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
await expect(composer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(picker).toHaveText("Changes")
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(views).toBeHidden()
await expect(picker).toHaveText("More...")
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
await more.click()
await expect(views.getByRole("button", { name: "Files", exact: true })).toHaveAttribute("aria-pressed", "true")
await views.getByRole("button", { name: "Terminal", exact: true }).click()
await expect(views).toBeHidden()
const panel = page.locator("#terminal-panel")
await expect(panel).toHaveAttribute("data-opened", "true")
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
@@ -135,14 +166,14 @@ for (const position of ["top", "bottom"] as const) {
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await expect(picker).toHaveText("More...")
await expect(panel).toBeVisible()
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Session")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await expect(picker).toHaveText("More...")
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
await expect(picker).toHaveText("Session")
await expect(panel).toBeHidden()
@@ -160,18 +191,20 @@ for (const position of ["top", "bottom"] as const) {
await expect(trigger).toContainText(title)
}
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await views.getByRole("button", { name: "Usage", exact: true }).click()
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
await openTab(fixture.expected.sourceTitle)
await expect(picker).toHaveText("Session")
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("More...")
await openTab(fixture.expected.targetTitle)
await expect(picker).toHaveText("Session")
await openTab(fixture.expected.sourceTitle)
await expect(picker).toHaveText("Session")
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("More...")
await openTabs()
await drawer.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL("/")
@@ -206,15 +239,15 @@ test("the summary's changes row switches the view and keeps the side tabs", asyn
await page.setViewportSize({ width: 390, height: 844 })
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const more = tabs.getByRole("tab", { name: "More...", exact: true })
const views = page.getByRole("dialog", { name: "More options", exact: true })
await more.click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Changes")
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(
page.getByRole("tablist", { name: "Open files", exact: true }).getByRole("tab", { name: "README.md", exact: true }),
).toHaveAttribute("aria-selected", "true")
@@ -230,7 +263,8 @@ test("the summary's changes row switches the view and keeps the side tabs", asyn
await page.reload()
await expect(launcher).toHaveAttribute("aria-selected", "true")
await page.setViewportSize({ width: 390, height: 844 })
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
const files = page.locator('[data-slot="session-mobile-files"]')
await files.getByRole("button", { name: "README.md", exact: true }).click()
await expect(files.getByText("contents:README.md", { exact: true })).toBeVisible()
@@ -254,14 +288,17 @@ test("a narrow-screen palette pick keeps the view and dock, and the side region
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const terminal = page.locator("#terminal-panel")
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
const views = page.getByRole("dialog", { name: "More options", exact: true })
await tabs.getByRole("tab", { name: "More...", exact: true }).click()
await views.getByRole("button", { name: "Terminal", exact: true }).click()
await expect(views).toBeHidden()
await expect(terminal.locator("textarea")).toBeEditable()
await page.keyboard.press("ControlOrMeta+p")
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox").fill("README")
await dialog.getByRole("option", { name: "/ README.md", exact: true }).click()
await expect(dialog).toHaveCount(0)
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Terminal")
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("More...")
await expect(terminal).toHaveAttribute("data-opened", "true")
await page.setViewportSize({ width: 1280, height: 900 })
@@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test"
import { fixture, installStressSessionTabs, mockStressTimeline } from "../utils/session-fixture"
import { sessionHref, draftHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
for (const direction of ["ltr", "rtl"] as const) {
test(`bottom mobile navigation adds no extra top panel gutter in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
})
await openWithDirection(page, "/", direction)
const panel = page.locator('[data-slot="home-panel"]')
await expect(panel).toHaveCSS("--shell-top-inset", "0px")
await expect
.poll(async () => {
const titlebar = await page.locator('[data-slot="titlebar-v2"]').boundingBox()
const main = await page.getByRole("main").boundingBox()
return titlebar && main && titlebar.y >= main.y + main.height
})
.toBe(true)
await expect
.poll(async () => {
const bounds = await panel.boundingBox()
const main = await page.getByRole("main").boundingBox()
return bounds && main && bounds.y - main.y
})
.toBe(0)
})
for (const screen of ["home", "new-session", "session", "settings"] as const) {
test(`${screen} keeps only border clearance on mobile in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
if (screen === "new-session") await installStressSessionTabs(page, { draftID: "shell-spacing-draft" })
await openWithDirection(
page,
screen === "home"
? "/"
: screen === "new-session"
? draftHref("shell-spacing-draft")
: screen === "session"
? sessionHref(fixture.targetID)
: `/${screen}`,
direction,
)
await expect(page.locator("html")).toHaveAttribute("dir", direction)
const panel =
screen === "settings"
? page.getByTestId("settings-screen")
: screen === "session"
? page.locator('[data-slot="session-chat-panel"]')
: screen === "new-session"
? page.locator('[data-component="new-session"]')
: page.locator('[data-slot="home-panel"]')
await expect(panel).toBeVisible()
await expect(panel).toHaveCSS("--shell-top-inset", "0px")
await expect
.poll(async () => {
const bounds = await panel.boundingBox()
const main = await page.getByRole("main").boundingBox()
return bounds && main && bounds.y - main.y
})
.toBe(0)
await expect
.poll(async () => {
const bounds = await panel.boundingBox()
const main = await page.getByRole("main").boundingBox()
return bounds && main && { x: bounds.x - main.x, width: main.width - bounds.width }
})
.toEqual({ x: 1, width: 2 })
// The existing desktop gutter must return at the responsive breakpoint.
await page.setViewportSize({ width: 768, height: 900 })
await expect(panel).toHaveCSS("--shell-inline-inset", "8px")
await expect(panel).toHaveCSS("--shell-top-inset", "8px")
if (screen === "session") return
await expect
.poll(async () => {
const bounds = await panel.boundingBox()
const main = await page.getByRole("main").boundingBox()
return bounds && main && { x: bounds.x - main.x, width: main.width - bounds.width }
})
.toEqual({ x: 8, width: 16 })
})
}
}
+6 -2
View File
@@ -1,4 +1,5 @@
import { Show, createMemo } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
@@ -66,6 +67,7 @@ function ComposerModelControl(props: {
onUnpaidClick: () => void
}) {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
const mobile = createMediaQuery("(max-width: 767px)")
const content = () => (
<>
<Show when={props.provider}>
@@ -85,6 +87,7 @@ function ComposerModelControl(props: {
return (
<Show when={!props.loading}>
<Tooltip
inactive={mobile()}
placement="top"
gutter={4}
value={
@@ -95,7 +98,7 @@ function ComposerModelControl(props: {
}
>
<Show
when={props.paid}
when={props.paid || mobile()}
fallback={
<Button
data-action="composer-model"
@@ -113,6 +116,7 @@ function ComposerModelControl(props: {
>
<ModelSelectorPopover
model={props.model}
unpaid={!props.paid}
trigger={(triggerProps) => (
<Button
{...triggerProps}
@@ -122,7 +126,7 @@ function ComposerModelControl(props: {
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
data-action="composer-model"
data-control-type="popover"
data-control-type={mobile() ? "drawer" : "popover"}
>
{content()}
</Button>
+5 -3
View File
@@ -25,9 +25,11 @@
);
}
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
opacity: 0;
pointer-events: none;
@media (hover: hover) {
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
opacity: 0;
pointer-events: none;
}
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
+106 -25
View File
@@ -8,10 +8,12 @@ import {
onMount,
Show,
Suspense,
lazy,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMediaQuery } from "@solid-primitives/media"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
@@ -45,6 +47,11 @@ import { isAttachment } from "../prompt-parts"
import "../attachments/attachments.css"
import "./editor.css"
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
})
export type {
ComposerAttachment,
ComposerComment,
@@ -347,6 +354,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
mobileDrawer
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
@@ -762,6 +770,7 @@ export function ComposerEditorAddMenu(props: {
}
function ComposerEditorConfiguredSelect(props: {
mobileDrawer?: boolean
title: string
keybind?: string[]
control: ComposerSelectControl
@@ -773,6 +782,7 @@ function ComposerEditorConfiguredSelect(props: {
return (
<ComposerEditorSelect
mobileDrawer={props.mobileDrawer}
title={props.title}
class={props.class}
keybind={props.control.keybind?.() ?? props.keybind}
@@ -789,6 +799,7 @@ function ComposerEditorConfiguredSelect(props: {
}
export function ComposerEditorSelect(props: {
mobileDrawer?: boolean
title: string
keybind?: string[]
options: ComposerOption[]
@@ -798,8 +809,31 @@ export function ComposerEditorSelect(props: {
onOpenChange?: (open: boolean) => void
onSelect: (id: string) => void
}) {
const mobile = createMediaQuery("(max-width: 767px)")
const [store, setStore] = createStore({ open: false, loaded: false })
let trigger: HTMLButtonElement | undefined
const setOpen = (open: boolean) => {
setStore("open", open)
if (open) setStore("loaded", true)
props.onOpenChange?.(open)
}
createEffect(() => {
if (!mobile() && store.open) setOpen(false)
})
const content = () => (
<>
{props.currentIcon}
<span class="truncate capitalize leading-5">
{props.options.find((option) => option.id === props.current)?.label ?? props.current}
</span>
<span class="-ms-0.5 -me-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</>
)
return (
<Tooltip
inactive={props.mobileDrawer && mobile()}
placement="top"
value={
<>
@@ -808,36 +842,83 @@ export function ComposerEditorSelect(props: {
</>
}
>
<Menu gutter={6} modal={false} placement="top-start" onOpenChange={props.onOpenChange}>
<Menu.Trigger
as={Button}
<Show
when={props.mobileDrawer && mobile()}
fallback={
<Menu gutter={6} modal={false} placement="top-start" onOpenChange={props.onOpenChange}>
<Menu.Trigger
as={Button}
variant="ghost-muted"
size="normal"
class={`max-w-[220px] justify-start ![font-weight:440] ${props.class ?? ""}`}
aria-label={props.title}
>
{content()}
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<Menu.RadioGroup value={props.current} onChange={props.onSelect}>
<For each={props.options}>
{(option) => (
<Menu.RadioItem value={option.id} class="capitalize" closeOnSelect>
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
}
>
<Button
ref={(element: HTMLButtonElement) => {
trigger = element
}}
variant="ghost-muted"
size="normal"
class={`max-w-[220px] justify-start ![font-weight:440] ${props.class ?? ""}`}
aria-label={props.title}
aria-haspopup="dialog"
aria-expanded={store.open}
onClick={() => setOpen(true)}
>
{props.currentIcon}
<span class="truncate capitalize leading-5">
{props.options.find((option) => option.id === props.current)?.label ?? props.current}
</span>
<span class="-ms-0.5 -me-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<Menu.RadioGroup value={props.current} onChange={props.onSelect}>
<For each={props.options}>
{(option) => (
<Menu.RadioItem value={option.id} class="capitalize" closeOnSelect>
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
{content()}
</Button>
<Show when={store.loaded}>
<Suspense>
<MobilePanelDrawer
hideHeader
title={props.title}
open={store.open}
onOpenChange={setOpen}
returnFocus={() => trigger}
>
<div class="flex flex-col overflow-hidden rounded-lg border border-v2-border-border-base bg-v2-background-bg-layer-02 divide-y divide-v2-border-border-base">
<For each={props.options}>
{(option) => (
<Button
variant="ghost"
class="w-full !h-10 !justify-start !rounded-none !px-3 !font-[440] capitalize focus-visible:!outline-offset-[-2px]"
aria-pressed={props.current === option.id}
data-state={props.current === option.id ? "pressed" : undefined}
onClick={() => {
props.onSelect(option.id)
setOpen(false)
}}
>
{option.label}
<Show when={props.current === option.id}>
<Icon name="check" class="ms-auto" />
</Show>
</Button>
)}
</For>
</div>
</MobilePanelDrawer>
</Suspense>
</Show>
</Show>
</Tooltip>
)
}
+1
View File
@@ -287,6 +287,7 @@ function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<type
submission.target().mode.set("normal")
input.setMode("normal")
input.closePopover()
if (window.matchMedia("(max-width: 767px)").matches) input.editor()?.blur()
}
function restoreSubmission(
+2 -1
View File
@@ -19,8 +19,9 @@ export function Home() {
const scroll = createHomeScrollController(sessions.data.groups)
return (
<div
data-slot="home-panel"
class={`
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
mx-[var(--shell-inline-inset,8px)] mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
+1 -1
View File
@@ -87,7 +87,7 @@ export default function NewSessionPage(props: { draftId: string }) {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<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 class="flex-1 min-h-0 flex flex-col gap-2 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
</div>
@@ -0,0 +1,38 @@
[data-slot="mobile-drawer-content"]:has([data-slot="model-selector-drawer"]) {
padding-left: 0;
padding-right: 0;
}
[data-slot="model-selector-drawer"] {
/* Keep the gutter inside each scroll boundary so outlines are not clipped. */
[data-slot="model-selector-search"],
.settings-models,
[data-slot="model-selector-actions"] {
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
}
[data-component="settings-list"][data-variant="catalog"] {
padding-inline: 12px;
}
[data-component="settings-row"] {
width: calc(100% + 24px);
margin-inline: -12px;
padding-inline: 12px;
}
.settings-models-console-groups {
margin-inline-start: 4px;
padding-inline-start: 8px;
}
.provider-model-group-trigger,
.provider-model-group-models {
padding-inline: 12px;
}
.provider-model-group-models > [data-component="settings-list"] {
padding-inline: 0;
}
}
@@ -1,6 +1,7 @@
import { Popover } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show, Suspense, lazy, on } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { popularProviders } from "@/providers/catalog/providers"
@@ -24,8 +25,19 @@ import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./search"
import { SettingsList } from "@/settings/list"
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelIcon, ProviderModelSections } from "@/providers/models/provider-group"
import {
CONSOLE_GROUP_KEY,
consoleModelGroup,
ProviderModelIcon,
ProviderModelSections,
} from "@/providers/models/provider-group"
import "@/settings/settings.css"
import "./select-dialog.css"
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
})
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
@@ -49,6 +61,8 @@ const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { categ
}
const ModelList: Component<{
mobile?: boolean
open?: boolean
provider?: string
onSelect: () => void
model?: ModelState
@@ -61,9 +75,18 @@ const ModelList: Component<{
})
const [store, setStore] = createStore({
search: "",
active: "",
active: props.mobile ? (controller.current() ?? "") : "",
collapsed: {} as Record<string, boolean>,
})
createEffect(
on(
() => props.open,
(open) => {
if (!props.mobile || !open) return
setStore({ search: "", active: controller.current() ?? "" })
},
),
)
const models = createMemo(() => controller.models(store.search))
const modelGroups = createMemo(() => controller.groups(models()))
const managed = createMemo(() => consoleModelGroup(controller.all()))
@@ -96,7 +119,7 @@ const ModelList: Component<{
if (item) controller.select(item)
}
function ModelRows(props: { items: ModelItem[] }) {
function ModelRows(props: { items: ModelItem[]; mobile?: boolean }) {
return (
<SettingsList variant="catalog">
<For each={props.items}>
@@ -105,6 +128,7 @@ const ModelList: Component<{
type="button"
data-component="settings-row"
data-option-key={modelKey(item)}
aria-pressed={controller.current() === modelKey(item)}
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => setStore("active", modelKey(item))}
@@ -114,6 +138,7 @@ const ModelList: Component<{
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title" class="flex items-center gap-2">
<Tooltip
inactive={props.mobile}
placement="right-start"
gutter={12}
openDelay={0}
@@ -145,7 +170,7 @@ const ModelList: Component<{
return (
<div class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 px-4 pt-px pb-3">
<div data-slot="model-selector-search" class="shrink-0 pt-px pb-3" classList={{ "px-4": !props.mobile }}>
<div class="relative">
<TextInput
type="search"
@@ -153,7 +178,7 @@ const ModelList: Component<{
class="!w-full self-stretch"
placeholder={language.t("dialog.model.search.placeholder")}
value={store.search}
autofocus
autofocus={!props.mobile}
spellcheck={false}
autocorrect="off"
autocomplete="off"
@@ -191,8 +216,12 @@ const ModelList: Component<{
</Show>
</div>
</div>
<div class="relative min-h-0 flex-1">
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-1 pb-4">
<div class="relative min-h-0" classList={{ "flex-1": !props.mobile }}>
<div
ref={(element) => (scrollRef = element)}
class="settings-panel settings-models pt-1 pb-4"
classList={{ "h-full px-4": !props.mobile, "max-h-[min(360px,40dvh)]": props.mobile }}
>
<Show
when={models().length > 0}
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
@@ -203,7 +232,7 @@ const ModelList: Component<{
expanded={expanded}
disabled={store.search.length > 0}
onExpandedChange={(key, value) => setStore("collapsed", key, !value)}
rows={(items) => <ModelRows items={items} />}
rows={(items) => <ModelRows items={items} mobile={props.mobile} />}
/>
</Show>
</div>
@@ -217,10 +246,12 @@ type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
export function ModelSelectorPopover(props: {
provider?: string
model?: ModelState
unpaid?: boolean
trigger: ModelSelectorTrigger
onClose?: () => void
}) {
const dialog = useDialog()
const mobile = createMediaQuery("(max-width: 767px)")
const data = useData()
const location = useWorkspaceLocation()
const controller = createModelSelectorController({
@@ -232,26 +263,156 @@ export function ModelSelectorPopover(props: {
if (!controller.current()?.startsWith("openai:")) return false
const connection = data.location.integration
.list(location().ref)
?.find((integration) => integration.id === "openai")
?.connections[0]
?.find((integration) => integration.id === "openai")?.connections[0]
return connection?.type === "credential" && connection.method === "oauth"
}
const manage = async () => {
const { DialogManageModels } = await import("./manage")
void dialog.show(() => <DialogManageModels />)
}
const connect = async () => {
const { DialogConnectProvider } = await import("@/providers/connect/dialog")
void dialog.show(() => <DialogConnectProvider directory={location().directory} />)
}
return (
<ModelSelectorPopoverView
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current()}
chatgptPlan={chatgptPlan()}
select={controller.select}
onManage={() => {
void import("./manage").then((module) => {
void dialog.show(() => <module.DialogManageModels />)
})
}}
onClose={() => props.onClose?.()}
/>
<Show
when={mobile()}
fallback={
<ModelSelectorPopoverView
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current()}
chatgptPlan={chatgptPlan()}
select={controller.select}
onManage={manage}
onClose={() => props.onClose?.()}
/>
}
>
<ModelSelectorDrawer
trigger={props.trigger}
model={props.model}
provider={props.provider}
onConnect={props.unpaid ? connect : undefined}
chatgptPlan={chatgptPlan()}
onClose={() => props.onClose?.()}
onManage={manage}
/>
</Show>
)
}
function ModelSelectorDrawer(props: {
trigger: ModelSelectorTrigger
model?: ModelState
provider?: string
chatgptPlan?: boolean
onClose: () => void
onManage: () => void
onConnect?: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({
open: false,
loaded: false,
restoreTrigger: true,
action: undefined as "select" | "manage" | "connect" | undefined,
})
let trigger: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
return (
<>
<div ref={trigger} class="min-w-0">
{props.trigger({
"aria-haspopup": "dialog",
get "aria-expanded"() {
return store.open
},
onClick: () => setStore({ open: true, loaded: true, restoreTrigger: true }),
})}
</div>
<Show when={store.loaded}>
<Suspense>
<MobilePanelDrawer
hideHeader
title={language.t("dialog.model.select.title")}
open={store.open}
onOpenChange={(open) => setStore("open", open)}
initialFocus={() => content}
returnFocus={() => trigger?.querySelector("button") ?? undefined}
onFinalFocus={(event) => {
if (!store.restoreTrigger) event.preventDefault()
}}
onContentPresentChange={(present) => {
if (present) return
const action = store.action
setStore("action", undefined)
if (action === "manage") {
props.onManage()
return
}
if (action === "connect") {
props.onConnect?.()
return
}
if (action === "select") queueMicrotask(props.onClose)
}}
>
<div
ref={content}
tabIndex={-1}
data-slot="model-selector-drawer"
class="flex min-h-0 flex-col gap-2 outline-none"
>
<div class="flex min-h-0 flex-col">
<ModelList
mobile
open={store.open}
model={props.model}
provider={props.provider}
onSelect={() => setStore({ open: false, action: "select", restoreTrigger: false })}
/>
</div>
<div data-slot="model-selector-actions" class="flex flex-col gap-2">
<Button
variant="ghost"
class="w-full !h-10 !justify-start"
icon="outline-sliders"
onClick={() => setStore({ open: false, action: "manage", restoreTrigger: false })}
>
{language.t("dialog.model.manage")}
</Button>
<Show when={props.onConnect}>
<Button
variant="ghost"
class="w-full !h-10 !justify-start"
icon="plus"
onClick={() => setStore({ open: false, action: "connect", restoreTrigger: false })}
>
{language.t("command.provider.connect")}
</Button>
</Show>
<Show when={props.chatgptPlan}>
<div class="flex min-h-10 items-center gap-2 border-t border-v2-border-border-muted px-3 py-2 text-[13px] leading-5 text-v2-text-text-base">
<ProviderModelIcon provider={{ id: "openai", name: "OpenAI" }} class="shrink-0" />
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.chatgptPlan")}</span>
<ExternalLink
href="https://chatgpt.com/settings/usage"
class="flex shrink-0 items-center gap-1 rounded-sm text-v2-text-text-muted no-underline hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
>
{language.t("dialog.model.chatgptManageUsage")}
<Icon name="arrow-up-right" size="small" />
</ExternalLink>
</div>
</Show>
</div>
</div>
</MobilePanelDrawer>
</Suspense>
</Show>
</>
)
}
+89 -75
View File
@@ -2,10 +2,7 @@ import { createMemo, For, lazy, Show, Suspense, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Tabs } from "@opencode/ui/tabs"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import {
DrawerContext,
Panel,
type PanelSidebar,
type PanelTab,
@@ -57,7 +54,7 @@ export function createMobileViews() {
export type MobileViews = ReturnType<typeof createMobileViews>
/** The narrow-screen view switcher: the conversation, each panel's view, and an overflow menu. */
/** The narrow-screen view switcher: the conversation, each tab view, and a More drawer with the other views. */
export function MobileViewTabs(props: {
views: MobileViews
region: Region
@@ -65,34 +62,36 @@ export function MobileViewTabs(props: {
session: MountedSession
screen: SessionScreen
sidebar: PanelSidebar
bottom?: boolean
onSelect: (key: string) => void
}): JSX.Element {
const language = useLanguage()
const [store, setStore] = createStore<{
menu: boolean
drawer: string | undefined
last: string | undefined
open: boolean
loaded: boolean
pending: string | undefined
drawer: string | undefined
}>({
menu: false,
drawer: undefined,
// Keeps the last drawer's content mounted while it animates closed.
last: undefined,
open: false,
loaded: false,
pending: undefined,
drawer: undefined,
})
const drawer = createMemo(() => (store.last ? props.views.find(store.last) : undefined))
const drawer = createMemo(() => (store.drawer ? props.views.find(store.drawer) : undefined))
const tab = createMemo(() =>
props.current === "session" || props.views.tabs().some((entry) => entry.key === props.current)
? props.current
: "more",
)
let trigger: HTMLButtonElement | undefined
return (
<div
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']"
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:h-px before:bg-v2-border-border-base before:content-['']"
classList={{ "before:top-0": props.bottom, "before:bottom-0": !props.bottom }}
data-slot="session-mobile-view-navigation"
>
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
<Tabs value={tab()} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0 before:!hidden">
<Tabs.Trigger
value="session"
@@ -114,73 +113,88 @@ export function MobileViewTabs(props: {
</Tabs.Trigger>
)}
</For>
<Show when={props.views.menu().length}>
<Tabs.Trigger
value="more"
ref={(element: HTMLButtonElement) => {
trigger = element
}}
class="min-w-0 flex-1"
classes={{ button: "w-full justify-center" }}
aria-haspopup="dialog"
aria-expanded={store.open}
onClick={() => setStore({ open: true, loaded: true, drawer: undefined })}
>
{language.t("session.tab.more")}
</Tabs.Trigger>
</Show>
</Tabs.List>
</Tabs>
<Menu
appearance="standard"
modal={false}
placement="bottom-end"
gutter={4}
open={store.menu}
onOpenChange={(open) => setStore("menu", open)}
>
<Menu.Trigger
as={IconButton}
ref={(element: HTMLButtonElement) => {
trigger = element
}}
icon={<Icon name="menu" />}
variant="ghost-muted"
size="normal"
class="mx-1.5 shrink-0"
state={props.views.menu().some((entry) => entry.key === props.current) || store.menu ? "pressed" : undefined}
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content
onCloseAutoFocus={(event) => {
if (!store.pending) return
event.preventDefault()
setStore({ drawer: store.pending, last: store.pending, loaded: true, pending: undefined })
}}
>
<For each={props.views.menu()}>
{(entry) => (
<Menu.Item
onSelect={() => {
if (entry.mobile.kind === "menu") return props.onSelect(entry.key)
setStore({ pending: entry.key, menu: false })
}}
>
{entry.mobile.title}
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={store.loaded}>
<Suspense>
<MobilePanelDrawer
title={drawer()?.mobile.title ?? ""}
open={!!store.drawer}
onOpenChange={(open) => {
if (!open) setStore("drawer", undefined)
}}
title={drawer()?.mobile.title ?? language.t("common.moreOptions")}
hideHeader
open={store.open}
onOpenChange={(open) => setStore("open", open)}
returnFocus={() => trigger}
>
<Show when={drawer()} keyed>
<Show
when={drawer()}
keyed
fallback={
<div
data-slot="session-mobile-view-options"
class="flex flex-col gap-0.5 rounded-[6px] bg-v2-background-bg-base p-0.5 shadow-[var(--v2-elevation-raised)] [[data-color-scheme=dark]_&]:bg-v2-background-bg-layer-01"
>
<For each={props.views.menu().filter((entry) => entry.mobile.kind === "menu")}>
{(entry) => (
<button
type="button"
class="flex min-h-11 w-full items-center gap-2 rounded-[4px] py-1 ps-3 pe-2 text-start text-[13px] font-[440] leading-[var(--line-height-base)] text-v2-text-text-base hover:bg-[var(--v2-overlay-simple-overlay-hover)] focus-visible:outline-none focus-visible:bg-[var(--v2-overlay-simple-overlay-hover)] aria-pressed:bg-[var(--v2-overlay-simple-overlay-pressed)]"
aria-pressed={props.current === entry.key}
onClick={() => {
props.onSelect(entry.key)
setStore("open", false)
}}
>
<Show when={entry.mobile.icon}>
{(icon) => <Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
<span class="min-w-0 flex-1 truncate">{entry.mobile.title}</span>
<Show when={props.current === entry.key}>
<Icon name="check" class="ms-1 shrink-0 text-v2-icon-icon-muted" />
</Show>
</button>
)}
</For>
<For each={props.views.menu().filter((entry) => entry.mobile.kind === "drawer")}>
{(entry) => (
<button
type="button"
class="flex min-h-11 w-full items-center gap-2 rounded-[4px] py-1 ps-3 pe-2 text-start text-[13px] font-[440] leading-[var(--line-height-base)] text-v2-text-text-base hover:bg-[var(--v2-overlay-simple-overlay-hover)] focus-visible:outline-none focus-visible:bg-[var(--v2-overlay-simple-overlay-hover)]"
onClick={() => setStore("drawer", entry.key)}
>
<Show when={entry.mobile.icon}>
{(icon) => <Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
<span class="min-w-0 flex-1 truncate">{entry.mobile.title}</span>
<Icon name="chevron-right" class="ms-1 shrink-0 text-v2-icon-icon-muted" />
</button>
)}
</For>
</div>
}
>
{(entry) => (
<DrawerContext.Provider value={{ close: () => setStore("drawer", undefined) }}>
<MobilePanel
entry={entry}
view={props.session}
screen={props.screen}
sidebar={props.sidebar}
visible={store.drawer === entry.key}
open={() => props.region.openFor(entry.extension)}
/>
</DrawerContext.Provider>
<MobilePanel
entry={entry}
view={props.session}
screen={props.screen}
sidebar={props.sidebar}
visible={store.open}
open={() => props.region.openFor(entry.extension)}
/>
)}
</Show>
</MobilePanelDrawer>
+1
View File
@@ -482,6 +482,7 @@ export const dict = {
"home.workspaceTip": "Start next session in a new workspace to keep changes isolated",
"session.tab.session": "Session",
"session.tab.more": "More...",
"session.view.select": "Session view",
"session.tab.add": "Add tab",
"session.tab.unknown": "Unknown Session",
+1 -1
View File
@@ -160,7 +160,7 @@ function PendingSessionState(props: { sessionID: string }) {
function SessionStatePanel(props: ParentProps) {
return (
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div class="flex min-h-0 flex-1 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
</div>
)
+23 -16
View File
@@ -15,6 +15,7 @@ import { ResizeHandle } from "@opencode/ui/resize-handle"
import { Slot, type BackgroundTask, type MountedSession, type SessionScreen } from "@opencode/gui-extensions/sdk"
import { MessageTimeline } from "@/session/timeline/message-timeline"
import { ComposerDropzone } from "@/composer/dropzone"
import { useSettings } from "@/settings/model"
import type { SessionModel } from "@/session/model"
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
import { SessionPanelFrame } from "@/session/session-frame"
@@ -63,7 +64,9 @@ function SessionScreenContent(props: {
const session = props.session
const host = useExtensionHost()
const attachment = useExtensionAttachment()
const settings = useSettings()
const isDesktop = session.isDesktop
const bottomMobileTabs = () => settings.general.mobileTitlebarPosition() === "bottom"
const sidebar = createPanelSidebar()
const region = createRegion({
@@ -272,6 +275,23 @@ function SessionScreenContent(props: {
() => conversationVisible() && messagesReady(),
)
const mobileTabs = () => (
<Show when={session.identity.sessionKey()} keyed>
{(_key) => (
<MobileViewTabs
bottom={bottomMobileTabs()}
screen={props.screen}
views={mobile}
region={region}
current={mobileView()}
session={props.view()}
sidebar={sidebar}
onSelect={selectMobile}
/>
)}
</Show>
)
const sessionPanelContent = () => (
<>
<ComposerDropzone
@@ -279,21 +299,7 @@ function SessionScreenContent(props: {
input={composer.drop.input()}
identity={session.layout.tabKey}
/>
<Show when={!isDesktop() && !!session.identity.params.id}>
<Show when={session.identity.sessionKey()} keyed>
{(_key) => (
<MobileViewTabs
screen={props.screen}
views={mobile}
region={region}
current={mobileView()}
session={props.view()}
sidebar={sidebar}
onSelect={selectMobile}
/>
)}
</Show>
</Show>
<Show when={!isDesktop() && !!session.identity.params.id && !bottomMobileTabs()}>{mobileTabs()}</Show>
{/* Surface query errors without suspending session metadata while messages load. */}
<Show when={timeline.resource.error}>
{(error) => {
@@ -342,12 +348,13 @@ function SessionScreenContent(props: {
<Show when={composer.active()} keyed>
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
</Show>
<Show when={!isDesktop() && !!session.identity.params.id && bottomMobileTabs()}>{mobileTabs()}</Show>
</>
)
return (
<>
<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 class="flex-1 min-h-0 flex flex-col gap-2 px-[var(--shell-inline-inset,8px)] 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; a side dock's 52px header includes a 1px divider. */}
<Show when={isDesktop() && messagesReady() && session.identity.params.id}>
+4 -1
View File
@@ -4,7 +4,10 @@ export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
return (
<div
class="relative flex size-full flex-col"
classList={{ "px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]": props.padded }}
classList={{
"px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]":
props.padded,
}}
>
{props.children}
</div>
+2 -2
View File
@@ -8,10 +8,10 @@
.settings-screen {
display: flex;
flex: 1;
width: calc(100% - 16px);
width: calc(100% - 2 * var(--shell-inline-inset, 8px));
min-width: 0;
min-height: 0;
margin-inline: 8px;
margin-inline: var(--shell-inline-inset, 8px);
margin-block: var(--shell-top-inset, 8px) var(--shell-bottom-inset, 8px);
justify-content: center;
overflow: hidden;
+4
View File
@@ -8,7 +8,9 @@ export function MobileDrawer(
open: boolean
onOpenChange: (open: boolean) => void
onContentPresentChange?: (present: boolean) => void
initialFocus?: () => HTMLElement | undefined
returnFocus?: () => HTMLElement | undefined
onFinalFocus?: (event: Event) => void
closeOnOutsideFocus?: boolean
}>,
) {
@@ -18,7 +20,9 @@ export function MobileDrawer(
onOpenChange={props.onOpenChange}
onContentPresentChange={props.onContentPresentChange}
side="bottom"
initialFocusEl={props.initialFocus?.()}
finalFocusEl={props.returnFocus?.()}
onFinalFocus={props.onFinalFocus}
closeOnOutsideFocus={props.closeOnOutsideFocus}
>
{props.children}
+77 -23
View File
@@ -1,42 +1,96 @@
import type { ParentProps } from "solid-js"
import { createEffect, on, Show, type JSX, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { DrawerContext } from "@opencode/gui-extensions/sdk"
import { useLanguage } from "@/runtime/i18n/language"
import { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
import "./mobile-panel-drawer.css"
type DrawerView = { title: string; content: JSX.Element; trigger: HTMLElement }
export function MobilePanelDrawer(
props: ParentProps<{
title: string
hideHeader?: boolean
open: boolean
onOpenChange: (open: boolean) => void
onContentPresentChange?: (present: boolean) => void
initialFocus?: () => HTMLElement | undefined
returnFocus?: () => HTMLElement | undefined
onFinalFocus?: (event: Event) => void
}>,
) {
const language = useLanguage()
const [store, setStore] = createStore<{ view?: DrawerView }>({})
createEffect(
on(
() => props.open,
(open) => {
if (open) setStore("view", undefined)
},
),
)
const navigation = {
close: () => props.onOpenChange(false),
open: (view: DrawerView) => setStore("view", view),
back: () => {
const trigger = store.view?.trigger
setStore("view", undefined)
trigger?.focus()
},
}
return (
<MobileDrawer
open={props.open}
onOpenChange={props.onOpenChange}
returnFocus={props.returnFocus}
// Menu focus handoff must not dismiss the drawer during its opening transition.
closeOnOutsideFocus={false}
>
<MobileDrawerContent>
<div data-slot="mobile-panel" data-corvu-no-drag>
<div data-slot="mobile-panel-header">
<MobileDrawerLabel>{props.title}</MobileDrawerLabel>
<MobileDrawerClose
as={Button}
variant="ghost"
data-slot="mobile-panel-close"
aria-label={language.t("common.close")}
<DrawerContext.Provider value={navigation}>
<MobileDrawer
open={props.open}
onOpenChange={props.onOpenChange}
onContentPresentChange={props.onContentPresentChange}
initialFocus={props.initialFocus}
returnFocus={props.returnFocus}
onFinalFocus={props.onFinalFocus}
// Menu focus handoff must not dismiss the drawer during its opening transition.
closeOnOutsideFocus={false}
>
<MobileDrawerContent>
<div data-slot="mobile-panel" data-corvu-no-drag>
<Show
when={!props.hideHeader}
fallback={<MobileDrawerLabel class="sr-only">{store.view?.title ?? props.title}</MobileDrawerLabel>}
>
{language.t("common.close")}
</MobileDrawerClose>
<div data-slot="mobile-panel-header">
<MobileDrawerLabel>{store.view?.title ?? props.title}</MobileDrawerLabel>
<MobileDrawerClose
as={Button}
variant="ghost"
data-slot="mobile-panel-close"
aria-label={language.t("common.close")}
>
{language.t("common.close")}
</MobileDrawerClose>
</div>
</Show>
<div data-slot="mobile-panel-content">
<div hidden={!!store.view}>{props.children}</div>
<Show when={store.view}>
{(view) => (
<>
<Button
variant="ghost"
icon="arrow-left"
class="mb-2 !justify-start"
aria-label={language.t("common.goBack")}
onClick={navigation.back}
>
{props.title}
</Button>
{view().content}
</>
)}
</Show>
</div>
</div>
<div data-slot="mobile-panel-content">{props.children}</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
</MobileDrawerContent>
</MobileDrawer>
</DrawerContext.Provider>
)
}
+1 -1
View File
@@ -42,7 +42,7 @@ export function AppRoutes() {
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div class="flex min-h-0 flex-1 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
+6 -3
View File
@@ -31,9 +31,12 @@ export default function Layout(props: ParentProps) {
<div
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
"--shell-top-inset": bottomTitlebar()
? "max(0px, calc(8px - env(safe-area-inset-top, 0px)))"
// Mobile panels only need clearance for their outer border.
"--shell-inline-inset": mobile() ? "1px" : "8px",
// Mobile safe-area clearance is already applied by the titlebar or main.
// Native Windows chrome supplies the gap; retain outer-outline clearance.
"--shell-top-inset": mobile()
? "0px"
: platform.platform === "desktop" && platform.os === "windows"
? "1px"
: "8px",
@@ -40,7 +40,7 @@ const setup: Setup<typeof definition> = (ctx) => {
region: "side",
// Stored before extensions as "context", then under the extension's earlier id `usage`.
legacy: { context: "main", "usage:context": "main" },
mobile: { title: ctx.t("mobile.title"), order: 10, kind: "menu" },
mobile: { title: ctx.t("mobile.title"), order: 40, kind: "menu", icon: "status" },
list: (input) => (input.open.includes("main") ? [tab] : []),
render: (props) => {
const panel = usePanel()
@@ -139,18 +139,26 @@
}
}
.session-service-menu .session-summary-row,
.session-service-menu .session-service-row,
.session-service-menu .session-service-config,
.session-service-menu [data-component="switch"].session-mcp-row {
:is(.session-service-menu, .session-service-drawer) .session-summary-row,
:is(.session-service-menu, .session-service-drawer) .session-service-row,
:is(.session-service-menu, .session-service-drawer) .session-service-config,
:is(.session-service-menu, .session-service-drawer) [data-component="switch"].session-mcp-row {
font-size: inherit;
line-height: inherit;
}
.session-service-menu > :is(.session-service-row, .session-mcp-row) + :is(.session-service-row, .session-mcp-row) {
:is(.session-service-menu, .session-service-drawer)
> :is(.session-service-row, .session-mcp-row)
+ :is(.session-service-row, .session-mcp-row) {
margin-block-start: 2px;
}
.session-service-drawer {
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
.session-service-title {
display: flex;
align-items: center;
@@ -184,7 +192,7 @@
color: var(--v2-text-text-faint);
overflow-wrap: anywhere;
}
.session-service-menu > .session-service-footer {
:is(.session-service-menu, .session-service-drawer) > .session-service-footer {
padding-block-start: 6px;
}
.session-service-config-separator {
@@ -56,8 +56,9 @@ const setup: Setup<typeof Details> = (ctx) => {
get title() {
return ctx.t("title")
},
order: 20,
order: 50,
kind: "drawer",
icon: "info",
},
list: () => [],
render: (props) => {
@@ -7,6 +7,7 @@ import { Tooltip } from "@opencode/ui/tooltip"
import { getDirectory } from "@opencode/util/path"
import { useMutation } from "@tanstack/solid-query"
import {
children,
createMemo,
createResource,
createSignal,
@@ -19,7 +20,7 @@ import {
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { createKeyed, useExtension, type MountedSession } from "../sdk"
import { createKeyed, useDrawer, useExtension, type MountedSession } from "../sdk"
import { configuredLsps } from "./configured-lsp"
const services = [
@@ -437,23 +438,47 @@ function ServiceCatalog(props: ServiceMenuProps) {
)
}
function ServicePopover(
props: ServiceMenuProps & {
loading: boolean
ready: boolean
empty: boolean
error: unknown
retry: () => void
children: JSX.Element
},
) {
type ServiceBodyProps = {
loading: boolean
ready: boolean
error: unknown
retry: () => void
children: JSX.Element
}
function ServicePopover(props: ServiceMenuProps & ServiceBodyProps & { empty: boolean }) {
const ctx = useExtension()
const locale = ctx.locale
const drawer = useDrawer()
const placement = createMemo(() =>
props.mobile ? "top-end" : locale.direction() === "rtl" ? "right-start" : "left-start",
)
// In a narrow-screen drawer the service list replaces the drawer's view instead of opening a popover over it.
if (props.mobile && drawer) {
const content = children(() => (
<div class="session-summary-card session-service-drawer" data-service={props.service.type} aria-busy={props.loading}>
<ServiceBody {...props} />
</div>
))
return (
<button
type="button"
class="session-summary-row"
onClick={(event) => {
if (!props.loading) void props.retry()
drawer.open({ title: ctx.t(props.service.label), content: content(), trigger: event.currentTarget })
}}
>
<Icon name={props.service.icon} class="shrink-0 text-v2-icon-icon-muted" />
<span class="session-summary-label">{ctx.t(props.service.label)}</span>
<Icon name="chevron-right" class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted" />
</button>
)
}
return (
<Popover
open={props.open}
@@ -480,34 +505,42 @@ function ServicePopover(
aria-busy={props.loading}
aria-label={ctx.t(props.service.label)}
>
<Show
when={props.ready || !props.loading}
fallback={
<div class="session-service-message" role="status">
{ctx.t("common.loading")}
</div>
}
>
<Show
when={!props.error}
fallback={
<div class="session-service-message" role="alert">
<p>{ctx.t("common.requestFailed")}</p>
<button type="button" class="session-summary-row" onClick={() => props.retry()}>
{ctx.t("retry")}
</button>
</div>
}
>
{props.children}
</Show>
</Show>
<ServiceBody {...props} />
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
function ServiceBody(props: ServiceBodyProps) {
const ctx = useExtension()
return (
<Show
when={props.ready || !props.loading}
fallback={
<div class="session-service-message" role="status">
{ctx.t("common.loading")}
</div>
}
>
<Show
when={!props.error}
fallback={
<div class="session-service-message" role="alert">
<p>{ctx.t("common.requestFailed")}</p>
<button type="button" class="session-summary-row" onClick={() => props.retry()}>
{ctx.t("retry")}
</button>
</div>
}
>
{props.children}
</Show>
</Show>
)
}
function ServiceConfigLink(props: { session: MountedSession; directory: string; service: Service }) {
const ctx = useExtension()
const desktop = ctx.desktop
@@ -211,7 +211,8 @@ const setup: Setup<typeof File> = (ctx) => {
return ctx.t("mobile.title")
},
order: 20,
kind: "tab",
kind: "menu",
icon: "folder",
},
list(input) {
return input.open.flatMap((id) => {
+4 -2
View File
@@ -206,10 +206,12 @@ export interface MobileView {
/**
* Where the view is offered.
* - `tab`: a tab of the view switcher; it replaces the conversation.
* - `menu`: an overflow menu entry; it replaces the conversation.
* - `drawer`: an overflow menu entry; it opens in a drawer over the conversation (`useDrawer`).
* - `menu`: an entry of the switcher's More drawer; it replaces the conversation.
* - `drawer`: an entry of the switcher's More drawer; it opens in that drawer over the conversation (`useDrawer`).
*/
readonly kind: "tab" | "menu" | "drawer"
/** The view's icon in the More drawer. */
readonly icon?: IconName
}
/**
+10 -1
View File
@@ -1,4 +1,4 @@
import { createComponent, createContext, useContext, type Accessor, type Component } from "solid-js"
import { createComponent, createContext, useContext, type Accessor, type Component, type JSX } from "solid-js"
import type { Context, SetupContext } from "./context"
import type { Definition } from "./core"
@@ -126,6 +126,15 @@ export function usePanel() {
export const DrawerContext = createContext<{
/** Closes the drawer. */
readonly close: () => void
/** Shows content in place of the drawer's view, with a back control. */
readonly open: (view: {
/** The drawer's title while the content shows. */
readonly title: string
/** The content to show. */
readonly content: JSX.Element
/** The element that receives focus when the back control returns to the drawer's view. */
readonly trigger: HTMLElement
}) => void
}>()
/**
@@ -149,7 +149,8 @@ const setup: Setup<typeof Terminal> = (ctx) => {
return ctx.t("tab.title")
},
order: 30,
kind: "tab",
kind: "menu",
icon: "terminal",
},
list: () => [tab],
render: (props) => (
+6 -4
View File
@@ -240,10 +240,12 @@
color: var(--v2-icon-icon-muted);
}
[data-component="button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not(
[data-expanded]
) {
background-color: var(--v2-overlay-simple-overlay-hover);
@media (hover: hover) {
[data-component="button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not(
[data-expanded]
) {
background-color: var(--v2-overlay-simple-overlay-hover);
}
}
[data-component="button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) {