mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 20:34:30 +00:00
fix(app): improve touch controls and standalone PWA relaunch (#46391)
This commit is contained in:
parent
327dc809c5
commit
5894e46688
11 changed files with 257 additions and 19 deletions
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
43
packages/app/src/runtime/platform/pwa.ts
Normal file
43
packages/app/src/runtime/platform/pwa.ts
Normal 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
|
||||
}
|
||||
79
packages/app/test-browser/pwa-route.test.ts
Normal file
79
packages/app/test-browser/pwa-route.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue