mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-31 22:16:18 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5894e46688 | ||
|
|
327dc809c5 | ||
|
|
e9f7331516 | ||
|
|
8be3ce8b6c | ||
|
|
30721b8b5d |
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user