Compare commits

...
19 changed files with 395 additions and 25 deletions
+6 -1
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>
),
+43
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
}
@@ -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()
}
})
+2 -3
View File
@@ -54,9 +54,8 @@ const layer = Layer.effect(
})
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
const matching = state
.get()
.formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
const extension = path.extname(filepath)
const matching = state.get().formatters.filter((formatter) => formatter.extensions.includes(extension))
for (const formatter of matching) {
const enabled = yield* command(formatter)
+1 -1
View File
@@ -95,7 +95,7 @@ export function convertHTMLToMarkdown(html: string) {
const remaining = limit - outputBytes
const next = bytes.byteLength <= remaining ? value : sliceBytes(value, remaining)
output.push(next)
outputBytes += encoder.encode(next).byteLength
outputBytes += bytes.byteLength <= remaining ? bytes.byteLength : encoder.encode(next).byteLength
last = next.at(-1) ?? last
}
const appendRaw = (value: string) => {
+28
View File
@@ -58,6 +58,34 @@ function withFormatter<A, E, R>(
}
describe("Formatter", () => {
;[
{ file: "test.match", extension: ".match", matches: true },
{ file: "test.other", extension: ".match", matches: false },
{ file: "test.MATCH", extension: ".match", matches: false },
{ file: "test.MATCH", extension: ".MATCH", matches: true },
{ file: ".match", extension: ".match", matches: false },
{ file: ".match", extension: "", matches: true },
{ file: "README", extension: ".match", matches: false },
{ file: "README", extension: "", matches: true },
{ file: "test.part.match", extension: ".match", matches: true },
{ file: "test.part.match", extension: ".part.match", matches: false },
].forEach((entry) =>
it.live(`matches ${entry.file} against ${JSON.stringify(entry.extension)}: ${entry.matches}`, () =>
withFormatter(
{
matching: {
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [entry.extension],
},
},
(formatter, directory) =>
Effect.gen(function* () {
expect(yield* formatter.file(path.join(directory, entry.file))).toBe(entry.matches)
}),
),
),
)
it.live("does not run formatters marked as disabled in config", () =>
withFormatter(
{
+9
View File
@@ -128,6 +128,15 @@ describe("WebFetchTool helpers", () => {
expect(output).toHaveLength(WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024)
})
test.each(["x", "\u00e9", "\u{1f600}"])("preserves UTF-8 boundaries at the content limit for %s", (character) => {
const budget = WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024
const fitting = "aa" + character.repeat(Math.floor((budget - 2) / Buffer.byteLength(character)))
expect(WebFetchTool.convertHTMLToMarkdown(fitting)).toBe(fitting)
const truncated = WebFetchTool.convertHTMLToMarkdown(fitting + character)
expect(truncated).toBe(fitting)
expect(Buffer.byteLength(truncated)).toBe(Buffer.byteLength(fitting))
})
test("bounds deeply nested list output and fragmented code fences", () => {
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
+12 -1
View File
@@ -1,9 +1,18 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { ProviderNotFoundError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ProviderNotFoundError } from "@opencode-ai/protocol/errors"
import { response } from "../location"
import { pluginReadiness } from "./plugin-readiness"
const flushPlugins = pluginReadiness(
() =>
new ServiceUnavailableError({
message: "Provider catalog initialization timed out",
service: "provider.catalog",
}),
)
export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) =>
Effect.gen(function* () {
@@ -11,6 +20,7 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han
.handle(
"provider.list",
Effect.fn(function* () {
yield* flushPlugins
const catalog = yield* Catalog.Service
return yield* response(catalog.provider.available())
}),
@@ -18,6 +28,7 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han
.handle(
"provider.get",
Effect.fn(function* (ctx) {
yield* flushPlugins
const catalog = yield* Catalog.Service
const provider = yield* catalog.provider.get(ctx.params.providerID)
if (!provider)
+64
View File
@@ -0,0 +1,64 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
it.live(
"waits for plugin initialization on the first provider list request",
() =>
Effect.gen(function* () {
const fixture = yield* configuredProvider("opencode-provider-list-endpoint-")
const url = new URL("/api/provider", fixture.server.base)
url.searchParams.set("location[directory]", fixture.path)
const response = yield* Effect.promise(() => fetch(url, { headers: fixture.server.headers }))
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a provider list response")
expect(body["data"].some((provider) => isRecord(provider) && provider["id"] === "custom")).toBeTrue()
}),
15_000,
)
it.live(
"waits for plugin initialization on the first provider get request",
() =>
Effect.gen(function* () {
const fixture = yield* configuredProvider("opencode-provider-get-endpoint-")
const url = new URL("/api/provider/custom", fixture.server.base)
url.searchParams.set("location[directory]", fixture.path)
const response = yield* Effect.promise(() => fetch(url, { headers: fixture.server.headers }))
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !isRecord(body["data"])) throw new Error("Expected a provider response")
expect(body["data"]["id"]).toBe("custom")
}),
15_000,
)
const configuredProvider = Effect.fnUntraced(function* (prefix: string) {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir(prefix)))
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
providers: {
custom: {
package: "@opencode-ai/ai/providers/openai-compatible",
settings: { apiKey: "secret" },
models: { chat: {} },
},
},
}),
),
)
return { server: yield* startServer(tmp.path), path: tmp.path }
})
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -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"],
+5
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;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from "bun:test"
import { Bom } from "./bom.js"
test.each([
{ prefix: "", bom: false, expected: undefined },
{ prefix: "", bom: true, expected: "\uFEFF" },
{ prefix: "\uFEFF", bom: false, expected: "" },
{ prefix: "\uFEFF", bom: true, expected: undefined },
{ prefix: "\uFEFF\uFEFF", bom: false, expected: "" },
{ prefix: "\uFEFF\uFEFF", bom: true, expected: "\uFEFF" },
])("syncBytes(%j)", (row) => {
const encoder = new TextEncoder()
const text = "a\uFEFF\u00e9"
const input = encoder.encode(row.prefix + text)
expect(Bom.syncBytes(input, row.bom)).toEqual({
text,
bytes: row.expected === undefined ? undefined : encoder.encode(row.expected + text),
})
expect(input).toEqual(encoder.encode(row.prefix + text))
})
+1 -1
View File
@@ -27,7 +27,7 @@ export function decodeBytes(content: Uint8Array) {
export function syncBytes(content: Uint8Array, bom: boolean) {
const decoded = decode(content)
const current = split(decoded)
const canonical = join(current.text, bom)
const canonical = bom ? value + current.text : current.text
return { text: current.text, bytes: decoded === canonical ? undefined : new TextEncoder().encode(canonical) }
}