fix(app): improve touch controls and standalone PWA relaunch (#46391)

This commit is contained in:
Brendan Allan 2026-08-31 23:35:40 +08:00 committed by GitHub
parent 327dc809c5
commit 5894e46688
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 257 additions and 19 deletions

View file

@ -6,6 +6,7 @@ import { AppBaseProviders, AppInterface } from "@/app"
import { loadInitialLocale } from "@/runtime/i18n/language"
import { PlatformProvider } from "@/runtime/platform/platform"
import { createWebPlatform } from "@/runtime/platform/web"
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "@/runtime/platform/pwa"
import en from "@/runtime/i18n/en"
import zh from "@/runtime/i18n/zh"
import { authFromToken } from "@/runtime/server/api"
@ -71,6 +72,8 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
void loadInitialLocale().then((locale) => {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
const standalone = isStandalone()
if (standalone) restorePwaRoute()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
@ -87,7 +90,9 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
/>
>
{standalone && <PwaRoutePersistence />}
</AppInterface>
</AppBaseProviders>
</PlatformProvider>
),

View file

@ -0,0 +1,43 @@
import { useLocation } from "@solidjs/router"
import { createEffect } from "solid-js"
const LAST_ROUTE_KEY = "opencode.pwa.last-route"
export function isStandalone() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
("standalone" in navigator && navigator.standalone === true)
)
}
export function restorePwaRoute() {
if (location.pathname !== "/" || location.search || location.hash) return
try {
const value = localStorage.getItem(LAST_ROUTE_KEY)
if (!value) return
const url = new URL(value, location.origin)
if (url.origin !== location.origin || url.searchParams.has("auth_token")) return
if (
url.pathname !== "/" &&
url.pathname !== "/new-session" &&
!/^\/server\/[^/]+\/session\/[^/]+$/.test(url.pathname)
)
return
history.replaceState(history.state, "", url.pathname + url.search + url.hash)
} catch {
// Storage may be unavailable; keep the launch URL in that case.
}
}
export function PwaRoutePersistence() {
const location = useLocation()
createEffect(() => {
const value = location.pathname + location.search + location.hash
try {
localStorage.setItem(LAST_ROUTE_KEY, value)
} catch {
// Navigation must still work when storage is unavailable or full.
}
})
return null
}

View file

@ -0,0 +1,79 @@
import { afterEach, beforeEach, expect, test } from "bun:test"
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
import { createComponent, render } from "solid-js/web"
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "../src/runtime/platform/pwa"
const key = "opencode.pwa.last-route"
const originalUrl = window.location.href
beforeEach(() => {
window.location.href = "http://localhost/"
})
afterEach(() => {
localStorage.removeItem(key)
window.location.href = originalUrl
})
test("normal browser windows are not standalone", () => {
expect(isStandalone()).toBe(false)
})
test("restores the last PWA route including query and hash without adding history", () => {
window.history.replaceState({ retained: true }, "", "http://localhost/")
const length = window.history.length
localStorage.setItem(key, "/server/local/session/session-1?view=files#file")
restorePwaRoute()
expect(window.location.pathname + window.location.search + window.location.hash).toBe(
"/server/local/session/session-1?view=files#file",
)
expect(window.history.length).toBe(length)
expect(window.history.state).toEqual({ retained: true })
})
test("preserves explicit launch routes, queries, and hashes", () => {
localStorage.setItem(key, "/server/local/session/saved")
for (const route of ["/server/local/session/linked", "/new-session?draftId=123", "/?launch=1", "/#launch"]) {
window.history.replaceState(null, "", `http://localhost${route}`)
restorePwaRoute()
expect(window.location.pathname + window.location.search + window.location.hash).toBe(route)
}
})
test("ignores missing, invalid, external, and auth-bearing saved routes", () => {
window.history.replaceState(null, "", "http://localhost/")
restorePwaRoute()
expect(window.location.pathname).toBe("/")
for (const value of [
"/removed-route",
"https://example.com/new-session",
"//example.com/new-session",
"http://[",
"/new-session?auth_token=secret",
]) {
localStorage.setItem(key, value)
restorePwaRoute()
expect(window.location.href).toBe("http://localhost/")
}
})
test("persists router navigation including returning home", async () => {
const host = document.createElement("div")
const history = createMemoryHistory()
history.set({ value: "/new-session?draftId=123", replace: true, scroll: false })
const dispose = render(() => createComponent(MemoryRouter, { history, root: PwaRoutePersistence }), host)
try {
expect(localStorage.getItem(key)).toBe("/new-session?draftId=123")
history.set({ value: "/server/local/session/next#file", scroll: false })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(localStorage.getItem(key)).toBe("/server/local/session/next#file")
history.set({ value: "/", scroll: false })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(localStorage.getItem(key)).toBe("/")
} finally {
dispose()
}
})

View file

@ -0,0 +1,75 @@
import { expect, story } from "../../storybook/playwright/story"
story.describe("touch timeline", () => {
story.use({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } })
story("keeps message actions and metadata visible without hover", async ({ mount, page }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "interruption" } })
expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true)
for (const action of [
{ slot: "user-message-copy-wrapper", name: "Copy message" },
{ slot: "text-part-copy-wrapper", name: "Copy response" },
]) {
const actions = timeline.locator(`[data-slot="${action.slot}"]`)
await expect(actions).toHaveCount(1)
await expect(actions).toHaveCSS("opacity", "1")
await expect(actions).toHaveCSS("pointer-events", "auto")
await expect(actions.getByRole("button", { name: action.name, exact: true })).toBeVisible()
}
await expect(timeline.locator('[data-slot="user-message-meta"]')).toContainText("Build")
await expect(timeline.locator('[data-slot="user-message-meta-tail"]')).not.toBeEmpty()
await expect(timeline.locator('[data-slot="text-part-meta"]')).toContainText("Build")
await expect(timeline.locator('[data-slot="text-part-meta"]')).toContainText("Sonnet")
})
story("keeps shell copy visible without hover", async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--expanded-shell")
const copy = timeline.locator('[data-slot="bash-copy"]')
await expect(copy).toHaveCount(1)
await expect(copy).toHaveCSS("opacity", "1")
await expect(copy).toHaveCSS("pointer-events", "auto")
})
story("keeps error copy visible without hover", async ({ mount, page }) => {
const errors = await mount("components-tool-error-card--all")
const patch = errors.locator('[data-kind="tool-error-card"]').filter({ hasText: "Patch" })
await patch.getByRole("button", { name: /Patch.*Verification failed/ }).tap()
await page.touchscreen.tap(385, 800)
const copy = patch.locator('[data-slot="tool-error-card-copy"]')
await expect(copy).toHaveCSS("opacity", "1")
await expect(copy).toHaveCSS("pointer-events", "auto")
})
story("keeps fenced code copy visible without hover", async ({ mount }) => {
const markdown = await mount("components-markdown--complete-response")
const code = markdown.locator('[data-component="markdown-code"]').filter({ hasText: "export const value = 42" })
await expect(code).toHaveCount(1)
await expect(code.locator('[data-slot="markdown-copy-button"]')).toHaveCSS("opacity", "1")
})
})
story("desktop message actions still appear on hover and keyboard focus", async ({ mount, page }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "interruption" } })
expect(await page.evaluate(() => matchMedia("(hover: hover)").matches)).toBe(true)
for (const action of [
{ slot: "user-message-copy-wrapper", name: "Copy message" },
{ slot: "text-part-copy-wrapper", name: "Copy response" },
]) {
const actions = timeline.locator(`[data-slot="${action.slot}"]`)
await expect(actions).toHaveCount(1)
await expect(actions).toHaveCSS("opacity", "0")
await expect(actions).toHaveCSS("pointer-events", "none")
await actions.locator("..").hover()
await expect(actions).toHaveCSS("opacity", "1")
await expect(actions).toHaveCSS("pointer-events", "auto")
await page.mouse.move(0, 0)
await expect(actions).toHaveCSS("opacity", "0")
await actions.getByRole("button", { name: action.name, exact: true }).focus()
await expect(actions).toHaveCSS("opacity", "1")
await expect(actions).toHaveCSS("pointer-events", "auto")
await page.getByRole("button", { name: "Reset", exact: true }).focus()
}
})

View file

@ -249,10 +249,13 @@
flex-shrink: 0;
color: var(--v2-text-text-faint);
margin-left: auto;
opacity: 0;
transition:
opacity 0.15s ease,
color 0.15s ease;
@media (hover: hover) {
opacity: 0;
}
}
[data-component="task-tool-title"] {
@ -391,11 +394,15 @@
}
.webfetch-link-icon {
display: none;
display: inline-flex;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--v2-icon-icon-accent, var(--v2-text-text-accent));
@media (hover: hover) {
display: none;
}
}
&:hover {

View file

@ -240,9 +240,12 @@
position: absolute;
top: 4px;
inset-inline-end: 4px;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1;
@media (hover: hover) {
opacity: 0;
}
}
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"],

View file

@ -167,11 +167,14 @@
justify-content: flex-end;
gap: 10px;
width: 100%;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
@media (hover: hover) {
opacity: 0;
pointer-events: none;
}
[data-component="tooltip-v2-trigger"] {
display: inline-flex;
width: fit-content;
@ -235,11 +238,14 @@
align-items: center;
justify-content: flex-start;
gap: 10px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
@media (hover: hover) {
opacity: 0;
pointer-events: none;
}
[data-component="tooltip-v2-trigger"] {
display: inline-flex;
width: fit-content;
@ -371,9 +377,12 @@
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
@media (hover: hover) {
opacity: 0;
pointer-events: none;
}
}
&:hover [data-slot="bash-copy"],

View file

@ -133,19 +133,25 @@
color: var(--text-base);
cursor: pointer;
border-radius: 4px;
opacity: 0;
opacity: 1;
will-change: opacity;
transform: translateZ(0);
transition: opacity 0.15s ease;
&:hover {
color: var(--text-strong);
background: var(--surface-base);
@media (hover: hover) {
opacity: 0;
&:hover {
color: var(--text-strong);
background: var(--surface-base);
}
}
}
[data-slot="accordion-trigger"]:hover [data-slot="session-review-view-button"] {
opacity: 1;
@media (hover: hover) {
[data-slot="accordion-trigger"]:hover [data-slot="session-review-view-button"] {
opacity: 1;
}
}
[data-slot="session-review-trigger-actions"] {

View file

@ -126,9 +126,12 @@
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
margin-left: 4px;
@media (hover: hover) {
opacity: 0;
}
}
[data-component="session-turn-diffs-group"]:hover [data-slot="session-turn-diffs-toggle"] {

View file

@ -123,10 +123,13 @@
position: absolute;
top: 0;
right: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
@media (hover: hover) {
opacity: 0;
pointer-events: none;
}
}
&:hover [data-slot="tool-error-card-copy"],

View file

@ -401,4 +401,9 @@ input:where([type="button"], [type="reset"], [type="submit"]),
[contenteditable="true"] {
font-size: 16px !important;
}
input::placeholder,
textarea::placeholder {
font-size: 13px;
}
}