mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-06 07:26:22 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0afdc40e18 | ||
|
|
f7593c0860 |
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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)]
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user