fix(app): more startup efficiency (#19454)
This commit is contained in:
@@ -159,7 +159,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
|
|||||||
const dialog = await openSettings(page)
|
const dialog = await openSettings(page)
|
||||||
const input = dialog.locator(settingsCodeFontSelector)
|
const input = dialog.locator(settingsCodeFontSelector)
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
|
await expect(input).toHaveAttribute("placeholder", "System Mono")
|
||||||
|
|
||||||
const initialFontFamily = await page.evaluate(() =>
|
const initialFontFamily = await page.evaluate(() =>
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||||
@@ -167,7 +167,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
|
|||||||
const initialUIFamily = await page.evaluate(() =>
|
const initialUIFamily = await page.evaluate(() =>
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||||
)
|
)
|
||||||
expect(initialFontFamily).toContain("IBM Plex Mono")
|
expect(initialFontFamily).toContain("ui-monospace")
|
||||||
|
|
||||||
const next = "Test Mono"
|
const next = "Test Mono"
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
|
|||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
appearance: {
|
appearance: {
|
||||||
font: next,
|
mono: next,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
|
|||||||
const dialog = await openSettings(page)
|
const dialog = await openSettings(page)
|
||||||
const input = dialog.locator(settingsUIFontSelector)
|
const input = dialog.locator(settingsUIFontSelector)
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await expect(input).toHaveAttribute("placeholder", "Inter")
|
await expect(input).toHaveAttribute("placeholder", "System Sans")
|
||||||
|
|
||||||
const initialFontFamily = await page.evaluate(() =>
|
const initialFontFamily = await page.evaluate(() =>
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||||
@@ -214,7 +214,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
|
|||||||
const initialCodeFamily = await page.evaluate(() =>
|
const initialCodeFamily = await page.evaluate(() =>
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||||
)
|
)
|
||||||
expect(initialFontFamily).toContain("Inter")
|
expect(initialFontFamily).toContain("ui-sans-serif")
|
||||||
|
|
||||||
const next = "Test Sans"
|
const next = "Test Sans"
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
|
|||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
appearance: {
|
appearance: {
|
||||||
uiFont: next,
|
sans: next,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -267,14 +267,14 @@ test("clearing the code font field restores the default placeholder and stack",
|
|||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
appearance: {
|
appearance: {
|
||||||
font: "Reset Mono",
|
mono: "Reset Mono",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await input.clear()
|
await input.clear()
|
||||||
await input.press("Space")
|
await input.press("Space")
|
||||||
await expect(input).toHaveValue("")
|
await expect(input).toHaveValue("")
|
||||||
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
|
await expect(input).toHaveAttribute("placeholder", "System Mono")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -285,14 +285,14 @@ test("clearing the code font field restores the default placeholder and stack",
|
|||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
appearance: {
|
appearance: {
|
||||||
font: "",
|
mono: "",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const fontFamily = await page.evaluate(() =>
|
const fontFamily = await page.evaluate(() =>
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||||
)
|
)
|
||||||
expect(fontFamily).toContain("IBM Plex Mono")
|
expect(fontFamily).toContain("ui-monospace")
|
||||||
expect(fontFamily).not.toContain("Reset Mono")
|
expect(fontFamily).not.toContain("Reset Mono")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -316,14 +316,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
|
|||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
appearance: {
|
appearance: {
|
||||||
uiFont: "Reset Sans",
|
sans: "Reset Sans",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await input.clear()
|
await input.clear()
|
||||||
await input.press("Space")
|
await input.press("Space")
|
||||||
await expect(input).toHaveValue("")
|
await expect(input).toHaveValue("")
|
||||||
await expect(input).toHaveAttribute("placeholder", "Inter")
|
await expect(input).toHaveAttribute("placeholder", "System Sans")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -334,14 +334,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
|
|||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
appearance: {
|
appearance: {
|
||||||
uiFont: "",
|
sans: "",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const fontFamily = await page.evaluate(() =>
|
const fontFamily = await page.evaluate(() =>
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||||
)
|
)
|
||||||
expect(fontFamily).toContain("Inter")
|
expect(fontFamily).toContain("ui-sans-serif")
|
||||||
expect(fontFamily).not.toContain("Reset Sans")
|
expect(fontFamily).not.toContain("Reset Sans")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -373,8 +373,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
|||||||
return raw ? JSON.parse(raw) : null
|
return raw ? JSON.parse(raw) : null
|
||||||
}, settingsKey)
|
}, settingsKey)
|
||||||
|
|
||||||
const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
|
const mono = initialSettings?.appearance?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
|
||||||
const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
|
const sans = initialSettings?.appearance?.sans === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
|
||||||
|
|
||||||
await code.click()
|
await code.click()
|
||||||
await code.clear()
|
await code.clear()
|
||||||
@@ -395,8 +395,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
|||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
appearance: {
|
appearance: {
|
||||||
font: mono,
|
mono,
|
||||||
uiFont: sans,
|
sans,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -415,8 +415,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
|||||||
expect(updatedMono).not.toBe(initialMono)
|
expect(updatedMono).not.toBe(initialMono)
|
||||||
expect(updatedSans).toContain(sans)
|
expect(updatedSans).toContain(sans)
|
||||||
expect(updatedSans).not.toBe(initialSans)
|
expect(updatedSans).not.toBe(initialSans)
|
||||||
expect(updatedSettings?.appearance?.font).toBe(mono)
|
expect(updatedSettings?.appearance?.mono).toBe(mono)
|
||||||
expect(updatedSettings?.appearance?.uiFont).toBe(sans)
|
expect(updatedSettings?.appearance?.sans).toBe(sans)
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
await closeDialog(page, dialog)
|
||||||
await page.reload()
|
await page.reload()
|
||||||
@@ -432,8 +432,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
|||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
appearance: {
|
appearance: {
|
||||||
font: mono,
|
mono,
|
||||||
uiFont: sans,
|
sans,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -468,8 +468,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
|||||||
expect(rehydratedMono).not.toBe(initialMono)
|
expect(rehydratedMono).not.toBe(initialMono)
|
||||||
expect(rehydratedSans).toContain(sans)
|
expect(rehydratedSans).toContain(sans)
|
||||||
expect(rehydratedSans).not.toBe(initialSans)
|
expect(rehydratedSans).not.toBe(initialSans)
|
||||||
expect(rehydratedSettings?.appearance?.font).toBe(mono)
|
expect(rehydratedSettings?.appearance?.mono).toBe(mono)
|
||||||
expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
|
expect(rehydratedSettings?.appearance?.sans).toBe(sans)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
||||||
|
|||||||
@@ -47,9 +47,14 @@ import { ErrorPage } from "./pages/error"
|
|||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
|
|
||||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const loadSession = () => import("@/pages/session")
|
||||||
|
const Session = lazy(loadSession)
|
||||||
const Loading = () => <div class="size-full" />
|
const Loading = () => <div class="size-full" />
|
||||||
|
|
||||||
|
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
|
||||||
|
void loadSession()
|
||||||
|
}
|
||||||
|
|
||||||
const SessionRoute = () => (
|
const SessionRoute = () => (
|
||||||
<SessionProviders>
|
<SessionProviders>
|
||||||
<Session />
|
<Session />
|
||||||
@@ -278,7 +283,11 @@ export function AppInterface(props: {
|
|||||||
disableHealthCheck?: boolean
|
disableHealthCheck?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
<ServerProvider
|
||||||
|
defaultServer={props.defaultServer}
|
||||||
|
disableHealthCheck={props.disableHealthCheck}
|
||||||
|
servers={props.servers}
|
||||||
|
>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
const aborted = (error: unknown) => abortError.safeParse(error).success
|
const aborted = (error: unknown) => abortError.safeParse(error).success
|
||||||
|
|
||||||
let attempt: AbortController | undefined
|
let attempt: AbortController | undefined
|
||||||
|
let run: Promise<void> | undefined
|
||||||
|
let started = false
|
||||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||||
let lastEventAt = Date.now()
|
let lastEventAt = Date.now()
|
||||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||||
@@ -121,78 +123,93 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
heartbeat = undefined
|
heartbeat = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
const start = () => {
|
||||||
while (!abort.signal.aborted) {
|
if (started) return run
|
||||||
attempt = new AbortController()
|
started = true
|
||||||
lastEventAt = Date.now()
|
run = (async () => {
|
||||||
const onAbort = () => {
|
while (!abort.signal.aborted && started) {
|
||||||
attempt?.abort()
|
attempt = new AbortController()
|
||||||
}
|
lastEventAt = Date.now()
|
||||||
abort.signal.addEventListener("abort", onAbort)
|
const onAbort = () => {
|
||||||
try {
|
attempt?.abort()
|
||||||
const events = await eventSdk.global.event({
|
}
|
||||||
signal: attempt.signal,
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
onSseError: (error) => {
|
try {
|
||||||
if (aborted(error)) return
|
const events = await eventSdk.global.event({
|
||||||
if (streamErrorLogged) return
|
signal: attempt.signal,
|
||||||
|
onSseError: (error) => {
|
||||||
|
if (aborted(error)) return
|
||||||
|
if (streamErrorLogged) return
|
||||||
|
streamErrorLogged = true
|
||||||
|
console.error("[global-sdk] event stream error", {
|
||||||
|
url: currentServer.http.url,
|
||||||
|
fetch: eventFetch ? "platform" : "webview",
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let yielded = Date.now()
|
||||||
|
resetHeartbeat()
|
||||||
|
for await (const event of events.stream) {
|
||||||
|
resetHeartbeat()
|
||||||
|
streamErrorLogged = false
|
||||||
|
const directory = event.directory ?? "global"
|
||||||
|
const payload = event.payload
|
||||||
|
const k = key(directory, payload)
|
||||||
|
if (k) {
|
||||||
|
const i = coalesced.get(k)
|
||||||
|
if (i !== undefined) {
|
||||||
|
queue[i] = { directory, payload }
|
||||||
|
if (payload.type === "message.part.updated") {
|
||||||
|
const part = payload.properties.part
|
||||||
|
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
coalesced.set(k, queue.length)
|
||||||
|
}
|
||||||
|
queue.push({ directory, payload })
|
||||||
|
schedule()
|
||||||
|
|
||||||
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
|
yielded = Date.now()
|
||||||
|
await wait(0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!aborted(error) && !streamErrorLogged) {
|
||||||
streamErrorLogged = true
|
streamErrorLogged = true
|
||||||
console.error("[global-sdk] event stream error", {
|
console.error("[global-sdk] event stream failed", {
|
||||||
url: currentServer.http.url,
|
url: currentServer.http.url,
|
||||||
fetch: eventFetch ? "platform" : "webview",
|
fetch: eventFetch ? "platform" : "webview",
|
||||||
error,
|
error,
|
||||||
})
|
})
|
||||||
},
|
|
||||||
})
|
|
||||||
let yielded = Date.now()
|
|
||||||
resetHeartbeat()
|
|
||||||
for await (const event of events.stream) {
|
|
||||||
resetHeartbeat()
|
|
||||||
streamErrorLogged = false
|
|
||||||
const directory = event.directory ?? "global"
|
|
||||||
const payload = event.payload
|
|
||||||
const k = key(directory, payload)
|
|
||||||
if (k) {
|
|
||||||
const i = coalesced.get(k)
|
|
||||||
if (i !== undefined) {
|
|
||||||
queue[i] = { directory, payload }
|
|
||||||
if (payload.type === "message.part.updated") {
|
|
||||||
const part = payload.properties.part
|
|
||||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
coalesced.set(k, queue.length)
|
|
||||||
}
|
}
|
||||||
queue.push({ directory, payload })
|
} finally {
|
||||||
schedule()
|
abort.signal.removeEventListener("abort", onAbort)
|
||||||
|
attempt = undefined
|
||||||
|
clearHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
if (abort.signal.aborted || !started) return
|
||||||
yielded = Date.now()
|
await wait(RECONNECT_DELAY_MS)
|
||||||
await wait(0)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!aborted(error) && !streamErrorLogged) {
|
|
||||||
streamErrorLogged = true
|
|
||||||
console.error("[global-sdk] event stream failed", {
|
|
||||||
url: currentServer.http.url,
|
|
||||||
fetch: eventFetch ? "platform" : "webview",
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
abort.signal.removeEventListener("abort", onAbort)
|
|
||||||
attempt = undefined
|
|
||||||
clearHeartbeat()
|
|
||||||
}
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
run = undefined
|
||||||
|
flush()
|
||||||
|
})
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
if (abort.signal.aborted) return
|
const stop = () => {
|
||||||
await wait(RECONNECT_DELAY_MS)
|
started = false
|
||||||
}
|
attempt?.abort()
|
||||||
})().finally(flush)
|
clearHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
const onVisibility = () => {
|
const onVisibility = () => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
if (document.visibilityState !== "visible") return
|
if (document.visibilityState !== "visible") return
|
||||||
|
if (!started) return
|
||||||
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||||
attempt?.abort()
|
attempt?.abort()
|
||||||
}
|
}
|
||||||
@@ -204,6 +221,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
document.removeEventListener("visibilitychange", onVisibility)
|
document.removeEventListener("visibilitychange", onVisibility)
|
||||||
}
|
}
|
||||||
|
stop()
|
||||||
abort.abort()
|
abort.abort()
|
||||||
flush()
|
flush()
|
||||||
})
|
})
|
||||||
@@ -217,7 +235,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
return {
|
return {
|
||||||
url: currentServer.http.url,
|
url: currentServer.http.url,
|
||||||
client: sdk,
|
client: sdk,
|
||||||
event: emitter,
|
event: {
|
||||||
|
on: emitter.on.bind(emitter),
|
||||||
|
listen: emitter.listen.bind(emitter),
|
||||||
|
start,
|
||||||
|
},
|
||||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||||
const s = server.current
|
const s = server.current
|
||||||
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
|
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
|
||||||
|
|||||||
@@ -72,10 +72,16 @@ function createGlobalSync() {
|
|||||||
let projectWritten = false
|
let projectWritten = false
|
||||||
let bootedAt = 0
|
let bootedAt = 0
|
||||||
let bootingRoot = false
|
let bootingRoot = false
|
||||||
|
let eventFrame: number | undefined
|
||||||
|
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
active = false
|
active = false
|
||||||
})
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
||||||
|
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
||||||
|
})
|
||||||
|
|
||||||
const cacheProjects = () => {
|
const cacheProjects = () => {
|
||||||
setProjectCache(
|
setProjectCache(
|
||||||
@@ -348,6 +354,20 @@ function createGlobalSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
if (typeof requestAnimationFrame === "function") {
|
||||||
|
eventFrame = requestAnimationFrame(() => {
|
||||||
|
eventFrame = undefined
|
||||||
|
eventTimer = setTimeout(() => {
|
||||||
|
eventTimer = undefined
|
||||||
|
globalSDK.event.start()
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
eventTimer = setTimeout(() => {
|
||||||
|
eventTimer = undefined
|
||||||
|
globalSDK.event.start()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
void bootstrap()
|
void bootstrap()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ function waitForPaint() {
|
|||||||
const timer = setTimeout(finish, 50)
|
const timer = setTimeout(finish, 50)
|
||||||
if (typeof requestAnimationFrame !== "function") return
|
if (typeof requestAnimationFrame !== "function") return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
clearTimeout(timer)
|
setTimeout(() => {
|
||||||
finish()
|
clearTimeout(timer)
|
||||||
|
finish()
|
||||||
|
}, 0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -87,12 +89,6 @@ export async function bootstrapGlobal(input: {
|
|||||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||||
}) {
|
}) {
|
||||||
const fast = [
|
const fast = [
|
||||||
() =>
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.path.get().then((x) => {
|
|
||||||
input.setGlobalStore("path", x.data!)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.globalSDK.global.config.get().then((x) => {
|
input.globalSDK.global.config.get().then((x) => {
|
||||||
@@ -108,6 +104,12 @@ export async function bootstrapGlobal(input: {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const slow = [
|
const slow = [
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.globalSDK.path.get().then((x) => {
|
||||||
|
input.setGlobalStore("path", x.data!)
|
||||||
|
}),
|
||||||
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.globalSDK.project.list().then((x) => {
|
input.globalSDK.project.list().then((x) => {
|
||||||
@@ -221,12 +223,16 @@ export async function bootstrapDirectory(input: {
|
|||||||
if (loading) input.setStore("status", "partial")
|
if (loading) input.setStore("status", "partial")
|
||||||
|
|
||||||
const fast = [
|
const fast = [
|
||||||
|
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||||
|
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||||
|
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||||
|
]
|
||||||
|
|
||||||
|
const slow = [
|
||||||
() =>
|
() =>
|
||||||
seededProject
|
seededProject
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
|
||||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
|
||||||
() =>
|
() =>
|
||||||
seededPath
|
seededPath
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
@@ -237,7 +243,6 @@ export async function bootstrapDirectory(input: {
|
|||||||
if (next) input.setStore("project", next)
|
if (next) input.setStore("project", next)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.vcs.get().then((x) => {
|
input.sdk.vcs.get().then((x) => {
|
||||||
@@ -299,9 +304,6 @@ export async function bootstrapDirectory(input: {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
]
|
|
||||||
|
|
||||||
const slow = [
|
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
|
|||||||
@@ -544,12 +544,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let sessionFrame: number | undefined
|
||||||
|
let sessionTimer: number | undefined
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
Promise.all(
|
sessionFrame = requestAnimationFrame(() => {
|
||||||
server.projects.list().map((project) => {
|
sessionFrame = undefined
|
||||||
return globalSync.project.loadSessions(project.worktree)
|
sessionTimer = window.setTimeout(() => {
|
||||||
}),
|
sessionTimer = undefined
|
||||||
)
|
void Promise.all(
|
||||||
|
server.projects.list().map((project) => {
|
||||||
|
return globalSync.project.loadSessions(project.worktree)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
|
||||||
|
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -94,7 +94,11 @@ export namespace ServerConnection {
|
|||||||
|
|
||||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||||
name: "Server",
|
name: "Server",
|
||||||
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
init: (props: {
|
||||||
|
defaultServer: ServerConnection.Key
|
||||||
|
disableHealthCheck?: boolean
|
||||||
|
servers?: Array<ServerConnection.Any>
|
||||||
|
}) => {
|
||||||
const checkServerHealth = useCheckServerHealth()
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
@@ -202,6 +206,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
const current_ = current()
|
const current_ = current()
|
||||||
if (!current_) return
|
if (!current_) return
|
||||||
|
|
||||||
|
if (props.disableHealthCheck) {
|
||||||
|
setState("healthy", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
setState("healthy", undefined)
|
setState("healthy", undefined)
|
||||||
onCleanup(startHealthPolling(current_))
|
onCleanup(startHealthPolling(current_))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export interface Settings {
|
|||||||
}
|
}
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: number
|
fontSize: number
|
||||||
font: string
|
mono: string
|
||||||
uiFont: string
|
sans: string
|
||||||
}
|
}
|
||||||
keybinds: Record<string, string>
|
keybinds: Record<string, string>
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -43,20 +43,18 @@ export interface Settings {
|
|||||||
sounds: SoundSettings
|
sounds: SoundSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const monoDefault = "IBM Plex Mono"
|
export const monoDefault = "System Mono"
|
||||||
export const sansDefault = "Inter"
|
export const sansDefault = "System Sans"
|
||||||
|
|
||||||
const monoFallback =
|
const monoFallback =
|
||||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||||
|
|
||||||
const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
|
const monoBase = monoFallback
|
||||||
const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
|
const sansBase = sansFallback
|
||||||
const monoKey = "ibm-plex-mono"
|
|
||||||
|
|
||||||
function input(font: string | undefined, key?: string) {
|
function input(font: string | undefined) {
|
||||||
if (!font || font === key || !font.trim()) return ""
|
return font ?? ""
|
||||||
return font
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function family(font: string) {
|
function family(font: string) {
|
||||||
@@ -64,14 +62,14 @@ function family(font: string) {
|
|||||||
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
|
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
|
||||||
}
|
}
|
||||||
|
|
||||||
function stack(font: string | undefined, base: string, key?: string) {
|
function stack(font: string | undefined, base: string) {
|
||||||
const value = input(font, key).trim()
|
const value = font?.trim() ?? ""
|
||||||
if (!value) return base
|
if (!value) return base
|
||||||
return `${family(value)}, ${base}`
|
return `${family(value)}, ${base}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function monoInput(font: string | undefined) {
|
export function monoInput(font: string | undefined) {
|
||||||
return input(font, monoKey)
|
return input(font)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sansInput(font: string | undefined) {
|
export function sansInput(font: string | undefined) {
|
||||||
@@ -79,7 +77,7 @@ export function sansInput(font: string | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function monoFontFamily(font: string | undefined) {
|
export function monoFontFamily(font: string | undefined) {
|
||||||
return stack(font, monoBase, monoKey)
|
return stack(font, monoBase)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sansFontFamily(font: string | undefined) {
|
export function sansFontFamily(font: string | undefined) {
|
||||||
@@ -100,8 +98,8 @@ const defaultSettings: Settings = {
|
|||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
font: "",
|
mono: "",
|
||||||
uiFont: "",
|
sans: "",
|
||||||
},
|
},
|
||||||
keybinds: {},
|
keybinds: {},
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -134,8 +132,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
|
||||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
|
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -189,13 +187,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setFontSize(value: number) {
|
setFontSize(value: number) {
|
||||||
setStore("appearance", "fontSize", value)
|
setStore("appearance", "fontSize", value)
|
||||||
},
|
},
|
||||||
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
|
font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
|
||||||
setFont(value: string) {
|
setFont(value: string) {
|
||||||
setStore("appearance", "font", value.trim() ? value : "")
|
setStore("appearance", "mono", value.trim() ? value : "")
|
||||||
},
|
},
|
||||||
uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
|
uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
|
||||||
setUIFont(value: string) {
|
setUIFont(value: string) {
|
||||||
setStore("appearance", "uiFont", value.trim() ? value : "")
|
setStore("appearance", "sans", value.trim() ? value : "")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
keybinds: {
|
keybinds: {
|
||||||
|
|||||||
@@ -544,6 +544,8 @@ export default function Page() {
|
|||||||
let reviewFrame: number | undefined
|
let reviewFrame: number | undefined
|
||||||
let refreshFrame: number | undefined
|
let refreshFrame: number | undefined
|
||||||
let refreshTimer: number | undefined
|
let refreshTimer: number | undefined
|
||||||
|
let todoFrame: number | undefined
|
||||||
|
let todoTimer: number | undefined
|
||||||
let diffFrame: number | undefined
|
let diffFrame: number | undefined
|
||||||
let diffTimer: number | undefined
|
let diffTimer: number | undefined
|
||||||
|
|
||||||
@@ -718,7 +720,6 @@ export default function Page() {
|
|||||||
if (!info) return true
|
if (!info) return true
|
||||||
return Date.now() - info.at > SESSION_PREFETCH_TTL
|
return Date.now() - info.at > SESSION_PREFETCH_TTL
|
||||||
})()
|
})()
|
||||||
const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
void sync.session.sync(id)
|
void sync.session.sync(id)
|
||||||
})
|
})
|
||||||
@@ -730,13 +731,47 @@ export default function Page() {
|
|||||||
if (params.id !== id) return
|
if (params.id !== id) return
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (stale) void sync.session.sync(id, { force: true })
|
if (stale) void sync.session.sync(id, { force: true })
|
||||||
void sync.session.todo(id, todos ? { force: true } : undefined)
|
|
||||||
})
|
})
|
||||||
}, 0)
|
}, 0)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => {
|
||||||
|
const id = params.id
|
||||||
|
return [
|
||||||
|
sdk.directory,
|
||||||
|
id,
|
||||||
|
id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
|
||||||
|
id ? composer.blocked() : false,
|
||||||
|
] as const
|
||||||
|
},
|
||||||
|
([dir, id, status, blocked]) => {
|
||||||
|
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||||
|
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||||
|
todoFrame = undefined
|
||||||
|
todoTimer = undefined
|
||||||
|
if (!id) return
|
||||||
|
if (status === "idle" && !blocked) return
|
||||||
|
const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
|
||||||
|
|
||||||
|
todoFrame = requestAnimationFrame(() => {
|
||||||
|
todoFrame = undefined
|
||||||
|
todoTimer = window.setTimeout(() => {
|
||||||
|
todoTimer = undefined
|
||||||
|
if (sdk.directory !== dir || params.id !== id) return
|
||||||
|
untrack(() => {
|
||||||
|
void sync.session.todo(id, cached ? { force: true } : undefined)
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => visibleUserMessages().at(-1)?.id,
|
() => visibleUserMessages().at(-1)?.id,
|
||||||
@@ -1658,6 +1693,8 @@ export default function Page() {
|
|||||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||||
|
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||||
|
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
BlexMonoNerdFontMono-Bold.woff2
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
BlexMonoNerdFontMono-Medium.woff2
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
BlexMonoNerdFontMono-Regular.woff2
|
|
||||||
Binary file not shown.
@@ -2,24 +2,24 @@
|
|||||||
import * as mod from "./font"
|
import * as mod from "./font"
|
||||||
|
|
||||||
const docs = `### Overview
|
const docs = `### Overview
|
||||||
Loads OpenCode typography assets and mono nerd fonts.
|
Uses native system font stacks for sans and mono typography.
|
||||||
|
|
||||||
Render once at the app root or Storybook preview.
|
Optional compatibility component. Existing roots can keep rendering it, but it does nothing.
|
||||||
|
|
||||||
### API
|
### API
|
||||||
- No props.
|
- No props.
|
||||||
|
|
||||||
### Variants and states
|
### Variants and states
|
||||||
- Fonts include sans and multiple mono families.
|
- No variants.
|
||||||
|
|
||||||
### Behavior
|
### Behavior
|
||||||
- Injects @font-face rules and preload links into the document head.
|
- Compatibility wrapper only. No font assets are injected or preloaded.
|
||||||
|
|
||||||
### Accessibility
|
### Accessibility
|
||||||
- Not applicable.
|
- Not applicable.
|
||||||
|
|
||||||
### Theming/tokens
|
### Theming/tokens
|
||||||
- Provides font families used by theme tokens.
|
- Theme tokens come from CSS variables, not this component.
|
||||||
|
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -1,63 +1 @@
|
|||||||
import { Link, Style } from "@solidjs/meta"
|
export const Font = () => null
|
||||||
import { Show } from "solid-js"
|
|
||||||
import inter from "../assets/fonts/inter.woff2"
|
|
||||||
import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2"
|
|
||||||
import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2"
|
|
||||||
import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2"
|
|
||||||
|
|
||||||
export const Font = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Style>{`
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
src: url("${inter}") format("woff2-variations");
|
|
||||||
font-display: swap;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100 900;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter Fallback";
|
|
||||||
src: local("Arial");
|
|
||||||
size-adjust: 100%;
|
|
||||||
ascent-override: 97%;
|
|
||||||
descent-override: 25%;
|
|
||||||
line-gap-override: 1%;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "IBM Plex Mono";
|
|
||||||
src: url("${ibmPlexMonoRegular}") format("woff2");
|
|
||||||
font-display: swap;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "IBM Plex Mono";
|
|
||||||
src: url("${ibmPlexMonoMedium}") format("woff2");
|
|
||||||
font-display: swap;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "IBM Plex Mono";
|
|
||||||
src: url("${ibmPlexMonoBold}") format("woff2");
|
|
||||||
font-display: swap;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "IBM Plex Mono Fallback";
|
|
||||||
src: local("Courier New");
|
|
||||||
size-adjust: 100%;
|
|
||||||
ascent-override: 97%;
|
|
||||||
descent-override: 25%;
|
|
||||||
line-gap-override: 1%;
|
|
||||||
}
|
|
||||||
`}</Style>
|
|
||||||
<Show when={typeof location === "undefined" || location.protocol !== "file:"}>
|
|
||||||
<Link rel="preload" href={inter} as="font" type="font/woff2" crossorigin="anonymous" />
|
|
||||||
<Link rel="preload" href={ibmPlexMonoRegular} as="font" type="font/woff2" crossorigin="anonymous" />
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
:root {
|
:root {
|
||||||
--font-family-sans: "Inter", "Inter Fallback";
|
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
--font-family-sans--font-feature-settings: "ss03" 1;
|
--font-family-sans--font-feature-settings: normal;
|
||||||
--font-family-mono: "IBM Plex Mono", "IBM Plex Mono Fallback";
|
--font-family-mono:
|
||||||
--font-family-mono--font-feature-settings: "ss01" 1;
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
--font-family-mono--font-feature-settings: normal;
|
||||||
|
|
||||||
--font-size-small: 13px;
|
--font-size-small: 13px;
|
||||||
--font-size-base: 14px;
|
--font-size-base: 14px;
|
||||||
|
|||||||
Reference in New Issue
Block a user