From 5894e4668872ecb071bd10ac01b32dfb7e93fb0c Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:35:40 +0800 Subject: [PATCH] fix(app): improve touch controls and standalone PWA relaunch (#46391) --- packages/app/src/entry.tsx | 7 +- packages/app/src/runtime/platform/pwa.ts | 43 ++++++++++ packages/app/test-browser/pwa-route.test.ts | 79 +++++++++++++++++++ .../session-timeline-mobile.spec.ts | 75 ++++++++++++++++++ .../session-ui/src/components/basic-tool.css | 11 ++- .../session-ui/src/components/markdown.css | 5 +- .../src/components/message-part.css | 21 +++-- .../src/components/session-review.css | 18 +++-- .../src/components/session-turn.css | 5 +- .../src/components/tool-error-card.css | 7 +- packages/ui/src/styles/base.css | 5 ++ 11 files changed, 257 insertions(+), 19 deletions(-) create mode 100644 packages/app/src/runtime/platform/pwa.ts create mode 100644 packages/app/test-browser/pwa-route.test.ts create mode 100644 packages/session-ui/component-tests/session-timeline-mobile.spec.ts diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 4c58374b7b4..3232695ee1a 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -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 && } + ), diff --git a/packages/app/src/runtime/platform/pwa.ts b/packages/app/src/runtime/platform/pwa.ts new file mode 100644 index 00000000000..ffa89347683 --- /dev/null +++ b/packages/app/src/runtime/platform/pwa.ts @@ -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 +} diff --git a/packages/app/test-browser/pwa-route.test.ts b/packages/app/test-browser/pwa-route.test.ts new file mode 100644 index 00000000000..8c4e0bf63eb --- /dev/null +++ b/packages/app/test-browser/pwa-route.test.ts @@ -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() + } +}) diff --git a/packages/session-ui/component-tests/session-timeline-mobile.spec.ts b/packages/session-ui/component-tests/session-timeline-mobile.spec.ts new file mode 100644 index 00000000000..90dc100c984 --- /dev/null +++ b/packages/session-ui/component-tests/session-timeline-mobile.spec.ts @@ -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() + } +}) diff --git a/packages/session-ui/src/components/basic-tool.css b/packages/session-ui/src/components/basic-tool.css index 0fce2044a36..3f21d380ebb 100644 --- a/packages/session-ui/src/components/basic-tool.css +++ b/packages/session-ui/src/components/basic-tool.css @@ -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 { diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css index d4eb5805fd3..6a53aabb803 100644 --- a/packages/session-ui/src/components/markdown.css +++ b/packages/session-ui/src/components/markdown.css @@ -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"], diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 03472c2e2e5..e24521c1ec7 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -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"], diff --git a/packages/session-ui/src/components/session-review.css b/packages/session-ui/src/components/session-review.css index 23cfe4cc0a3..702edf27ab4 100644 --- a/packages/session-ui/src/components/session-review.css +++ b/packages/session-ui/src/components/session-review.css @@ -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"] { diff --git a/packages/session-ui/src/components/session-turn.css b/packages/session-ui/src/components/session-turn.css index 58b274babe8..25516f70d33 100644 --- a/packages/session-ui/src/components/session-turn.css +++ b/packages/session-ui/src/components/session-turn.css @@ -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"] { diff --git a/packages/session-ui/src/components/tool-error-card.css b/packages/session-ui/src/components/tool-error-card.css index a0d4519c860..495721cfc05 100644 --- a/packages/session-ui/src/components/tool-error-card.css +++ b/packages/session-ui/src/components/tool-error-card.css @@ -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"], diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index a032f9ea2db..ee222399021 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -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; + } }