mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(app): reduce cold and warm session load work (#48223)
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Session-export load benchmark
|
||||
|
||||
Replay an exported session against a production app build. The two cases compare the default Compact preset with every category ungrouped and details still collapsed.
|
||||
|
||||
From `packages/app` in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:PLAYWRIGHT_BUILD = '1'
|
||||
$env:PLAYWRIGHT_BASE_URL = 'http://127.0.0.1:4398' # Existing production preview
|
||||
$env:LAGGY_SESSION_FILE = 'C:\path\session.json'
|
||||
$env:LAGGY_SESSION_OUTPUT = 'C:\tmp\opencode\session-load'
|
||||
$env:LAGGY_SESSION_HISTORY = 'paged' # Or 'full' to supply all exported history
|
||||
bun x playwright test --config e2e/performance/playwright.config.ts timeline/laggy-session-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0
|
||||
bun e2e/performance/timeline/laggy-session-report.ts $env:LAGGY_SESSION_OUTPUT
|
||||
```
|
||||
|
||||
Each test uses a fresh browser context and measures one cold load, switches back to the source session, then measures one warm load. Repetitions therefore interleave `cold → warm` pairs rather than collecting separate cold and warm batches. There are no discarded warm-up switches. The warm member of every pair must issue zero message requests. Each pair is saved in a separate JSON file; compare paired differences as well as the cold and warm distributions when system load varies.
|
||||
|
||||
The report writes `summary.json` and prints the median, p95, maximum, and median paired cold-minus-warm difference. It rejects incomplete or cold-only records instead of mixing them into paired results.
|
||||
|
||||
`--repeat-each=20` collects 20 pairs per grouping mode. `LAGGY_SESSION_COLD_ONLY=1` remains available for focused cold profiling. Screenshots are taken after a pair finishes, not between its measurements.
|
||||
|
||||
The app shell, source session, model control, and fonts are ready before the timed action. These measurements cover session entry, not application startup. `firstCorrectObservedMs` begins at mousedown and ends when the destination is visible at its expected bottom position, including Compact's automatic history fill. `stableObservedMs` includes three-observation confirmation and must not be treated as additional rendering time.
|
||||
|
||||
Set `OPENCODE_PERFORMANCE_TRACE_DIR` for Chrome traces. `LAGGY_TRACE_ITERATION=0` traces the cold load; the default (`1`) traces the warm member of the pair. Profile separately from timing runs.
|
||||
|
||||
`LAGGY_HTTP=1` disables route interception for an external HTTP replay server containing the same export and source fixture. Keep direct HTTP and Playwright-routed cold series separate: routing adds transport overhead. Raw samples, mode settings, viewport, browser version, and screenshots are retained in the output directory. The export itself is not copied into the repository.
|
||||
@@ -0,0 +1,227 @@
|
||||
import { readFileSync, mkdirSync, writeFileSync } from "node:fs"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { timelineCategories, timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
import { stressSessionHref } from "./timeline-test-helpers"
|
||||
import { startChromeTrace } from "../chrome-trace"
|
||||
|
||||
const file = process.env.LAGGY_SESSION_FILE
|
||||
const session = file
|
||||
? (JSON.parse(readFileSync(file, "utf8")) as {
|
||||
info: {
|
||||
id: string
|
||||
projectID: string
|
||||
title: string
|
||||
model?: { id: string; providerID: string }
|
||||
location: { directory: string }
|
||||
time: { created: number; updated: number }
|
||||
}
|
||||
messages: SessionMessageInfo[]
|
||||
})
|
||||
: undefined
|
||||
const sourceID = "ses_laggy_benchmark_source"
|
||||
const sourceMessageID = "msg_laggy_benchmark_source"
|
||||
const history = process.env.LAGGY_SESSION_HISTORY ?? "full"
|
||||
const viewport = { width: 1440, height: 900 }
|
||||
|
||||
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
|
||||
|
||||
for (const mode of ["compact", "ungrouped"] as const) {
|
||||
benchmark(`laggy session: ${mode}`, async ({ page, report }, testInfo) => {
|
||||
benchmark.skip(!session, "Set LAGGY_SESSION_FILE to a session export")
|
||||
if (!session) return
|
||||
const output = process.env.LAGGY_SESSION_OUTPUT ?? testInfo.outputPath("session-load")
|
||||
const model = session.info.model ?? { id: "benchmark-model", providerID: "benchmark" }
|
||||
const lastID = session.messages.findLast((message) => message.type === "user")!.id
|
||||
const lastText = session.messages.findLast(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim()),
|
||||
)!
|
||||
benchmark.setTimeout(Number(process.env.LAGGY_SESSION_TIMEOUT ?? 180_000))
|
||||
const requests: string[] = []
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
if (process.env.LAGGY_HTTP === "1")
|
||||
page.on("request", (request) => {
|
||||
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (request.method() === "GET" && match) requests.push(decodeURIComponent(match[1]))
|
||||
})
|
||||
const detail = Object.fromEntries(
|
||||
timelineCategories.map((category) => [
|
||||
category,
|
||||
{
|
||||
...timelinePresets[2].value[category],
|
||||
placement: mode === "compact" ? "grouped" : "separate",
|
||||
},
|
||||
]),
|
||||
)
|
||||
const directory = session.info.location.directory
|
||||
if (process.env.LAGGY_HTTP !== "1")
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: session.info.projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "session-benchmark",
|
||||
time: session.info.time,
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: model.providerID,
|
||||
name: model.providerID,
|
||||
models: { [model.id]: { id: model.id, name: model.id, limit: { context: 1_000_000 } } },
|
||||
},
|
||||
],
|
||||
connected: [model.providerID],
|
||||
default: { providerID: model.providerID, modelID: model.id },
|
||||
},
|
||||
sessions: [session.info, { ...session.info, id: sourceID, title: "Benchmark source" }],
|
||||
pageMessages: (id, limit, before) => {
|
||||
if (id !== session.info.id)
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: sourceMessageID,
|
||||
type: "user",
|
||||
text: "Benchmark source",
|
||||
time: { created: session.info.time.created },
|
||||
},
|
||||
],
|
||||
}
|
||||
if (history === "full") return { items: session.messages }
|
||||
const end = before ? session.messages.findIndex((message) => message.id === before) : session.messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: session.messages.slice(start, end),
|
||||
cursor: start > 0 ? session.messages[start].id : undefined,
|
||||
}
|
||||
},
|
||||
onMessages: (request) => {
|
||||
if (request.phase === "start") requests.push(request.sessionID)
|
||||
},
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ detail, directory, server, sessionIDs, dirBase64 }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { timelineDetail: detail } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
{
|
||||
detail,
|
||||
directory,
|
||||
server: process.env.PLAYWRIGHT_BASE_URL!,
|
||||
sessionIDs: [sourceID, session.info.id],
|
||||
dirBase64: base64Encode(directory),
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(sourceID))
|
||||
await expectSessionTitle(page, "Benchmark source")
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(page.getByRole("button", { name: model.id, exact: true })).toBeVisible()
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
||||
expect(requests).toEqual([sourceID])
|
||||
const startedAt = new Date().toISOString()
|
||||
const samples = []
|
||||
const phases = process.env.LAGGY_SESSION_COLD_ONLY === "1" ? (["cold"] as const) : (["cold", "warm"] as const)
|
||||
for (const [iteration, phase] of phases.entries()) {
|
||||
const before = requests.length
|
||||
const stopTrace =
|
||||
iteration === Number(process.env.LAGGY_TRACE_ITERATION ?? 1)
|
||||
? await startChromeTrace(page, `laggy-${history}-${mode}`)
|
||||
: undefined
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs: session.messages.map((message) => message.id),
|
||||
sourceIDs: [sourceMessageID],
|
||||
lastID,
|
||||
requiredPartID: history === "paged" && mode === "compact" ? `${lastText.id}:text:0` : undefined,
|
||||
requireBottomAnchor: true,
|
||||
href: stressSessionHref(session.info.id),
|
||||
switch: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(session.info.id)}"]`).click()
|
||||
},
|
||||
})
|
||||
await stopTrace?.()
|
||||
await expectSessionTitle(page, session.info.title)
|
||||
if (history === "full" || mode === "ungrouped") await waitForStableTimeline(page, lastID)
|
||||
await expect(
|
||||
page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])'),
|
||||
).toHaveCount(0)
|
||||
expect(result.firstCorrectObservedMs).not.toBeNull()
|
||||
expect(result.stableObservedMs).not.toBeNull()
|
||||
if (phase === "warm") expect(requests.length - before).toBe(0)
|
||||
samples.push({
|
||||
iteration,
|
||||
phase,
|
||||
messageRequests: requests.length - before,
|
||||
messageResources: await page.evaluate((sessionID) => {
|
||||
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
|
||||
return (performance.getEntriesByType("resource") as PerformanceResourceTiming[])
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.startTime >= start && new URL(entry.name).pathname === `/api/session/${sessionID}/message`,
|
||||
)
|
||||
.map((entry) => ({
|
||||
limit: Number(new URL(entry.name).searchParams.get("limit")),
|
||||
startMs: entry.startTime - start,
|
||||
durationMs: entry.duration,
|
||||
transferBytes: entry.transferSize,
|
||||
}))
|
||||
}, session.info.id),
|
||||
...result,
|
||||
})
|
||||
if (iteration === phases.length - 1) {
|
||||
mkdirSync(output, { recursive: true })
|
||||
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: `${output}/${mode}.png` })
|
||||
break
|
||||
}
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sourceID)}"]`).click()
|
||||
await expectSessionTitle(page, "Benchmark source")
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
}
|
||||
expect(errors).toEqual([])
|
||||
const result = {
|
||||
pair: testInfo.repeatEachIndex,
|
||||
startedAt,
|
||||
mode,
|
||||
history,
|
||||
file,
|
||||
messages: session.messages.length,
|
||||
viewport,
|
||||
browser: page.context().browser()!.version(),
|
||||
detail,
|
||||
samples,
|
||||
}
|
||||
writeFileSync(`${output}/${mode}-${testInfo.repeatEachIndex}.json`, JSON.stringify(result, null, 2))
|
||||
report(
|
||||
{ samples },
|
||||
{
|
||||
mode,
|
||||
sampling: "cold/warm pair in one browser context",
|
||||
pair: testInfo.repeatEachIndex,
|
||||
messages: session.messages.length,
|
||||
viewport,
|
||||
data: history === "full" ? "full exported history" : "paginated exported history",
|
||||
transport: process.env.LAGGY_HTTP === "1" ? "http" : "playwright-route",
|
||||
inputEvent: "mousedown",
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export {}
|
||||
|
||||
type Pair = {
|
||||
mode: "compact" | "ungrouped"
|
||||
samples: { phase: string; firstCorrectObservedMs: number | null; messageRequests: number }[]
|
||||
}
|
||||
|
||||
const directory = Bun.argv[2]
|
||||
if (!directory) throw new Error("Pass the directory containing session-load pairs")
|
||||
const pairs = await Promise.all(
|
||||
[...new Bun.Glob("{compact,ungrouped}-*.json").scanSync(directory)].map(async (file) => {
|
||||
const pair = (await Bun.file(`${directory}/${file}`).json()) as Pair
|
||||
const cold = pair.samples.find((sample) => sample.phase === "cold")
|
||||
const warm = pair.samples.find((sample) => sample.phase === "warm")
|
||||
if (cold?.firstCorrectObservedMs == null || warm?.firstCorrectObservedMs == null)
|
||||
throw new Error(`Expected a completed cold/warm pair in ${file}`)
|
||||
return {
|
||||
mode: pair.mode,
|
||||
cold: cold.firstCorrectObservedMs,
|
||||
warm: warm.firstCorrectObservedMs,
|
||||
requests: warm.messageRequests,
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!pairs.length) throw new Error(`No session-load pairs found in ${directory}`)
|
||||
|
||||
const result = ["compact", "ungrouped"].flatMap((mode) => {
|
||||
const selected = pairs.filter((pair) => pair.mode === mode)
|
||||
if (!selected.length) return []
|
||||
return [
|
||||
{
|
||||
mode,
|
||||
cold: { ...stats(selected.map((pair) => pair.cold)), over50ms: selected.filter((pair) => pair.cold > 50).length },
|
||||
warm: { ...stats(selected.map((pair) => pair.warm)), over50ms: selected.filter((pair) => pair.warm > 50).length },
|
||||
pairedColdMinusWarm: stats(selected.map((pair) => pair.cold - pair.warm)),
|
||||
messageRequestsDuringWarm: selected.reduce((total, pair) => total + pair.requests, 0),
|
||||
},
|
||||
]
|
||||
})
|
||||
await Bun.write(`${directory}/summary.json`, JSON.stringify(result, null, 2))
|
||||
console.table(
|
||||
result.map((row) => ({
|
||||
mode: row.mode,
|
||||
pairs: row.cold.n,
|
||||
coldMedianMs: Math.round(row.cold.median * 10) / 10,
|
||||
coldP95Ms: Math.round(row.cold.p95 * 10) / 10,
|
||||
warmMedianMs: Math.round(row.warm.median * 10) / 10,
|
||||
warmP95Ms: Math.round(row.warm.p95 * 10) / 10,
|
||||
warmMaxMs: Math.round(row.warm.max * 10) / 10,
|
||||
pairedDifferenceMs: Math.round(row.pairedColdMinusWarm.median * 10) / 10,
|
||||
})),
|
||||
)
|
||||
|
||||
function stats(values: number[]) {
|
||||
const sorted = values.toSorted((left, right) => left - right)
|
||||
return {
|
||||
n: sorted.length,
|
||||
median: (sorted[Math.floor((sorted.length - 1) / 2)] + sorted[Math.floor(sorted.length / 2)]) / 2,
|
||||
p95: sorted[Math.ceil(sorted.length * 0.95) - 1],
|
||||
min: sorted[0],
|
||||
max: sorted.at(-1)!,
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,11 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
// Corvu starts opening after paint; the transition flag is also absent
|
||||
// before that callback. Wait for the open position before dismissing.
|
||||
await expect
|
||||
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "../performance/timeline/session-tab-switch-probe"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
test("recovers from a failed cold history load when another session is selected", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
pageMessages: (id) => ({
|
||||
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.route(`**/api/session/${fixture.targetID}/message?*`, (route) =>
|
||||
route.fulfill({ status: 500, json: { message: "History unavailable" } }),
|
||||
)
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("focuses Find in the selected cached timeline", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
pageMessages: (id) => ({
|
||||
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
await page.keyboard.press("ControlOrMeta+f")
|
||||
const search = page.locator('[data-component="timeline-search-bar"] input')
|
||||
await expect(search).toBeFocused()
|
||||
await page.keyboard.type("History")
|
||||
await expect(search).toHaveValue("History")
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
|
||||
await page.keyboard.press("ControlOrMeta+f")
|
||||
await expect(search).toBeFocused()
|
||||
await search.press("Escape")
|
||||
await expect(search).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("disposes the old workspace's shell while destination history is loading", async ({ page }) => {
|
||||
const destination = "C:/OpenCode/OtherProject"
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const reads: string[] = []
|
||||
const output = { text: "Initial shell output\n" }
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions: fixture.sessions.map((session) =>
|
||||
session.id === fixture.targetID ? { ...session, directory: destination } : session,
|
||||
),
|
||||
pageMessages: (id) => ({
|
||||
items:
|
||||
id === fixture.sourceID
|
||||
? ([
|
||||
{ id: "msg_workspace_source", type: "user", text: "Follow the shell", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_workspace_shell",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
time: { created: 2 },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_workspace_shell",
|
||||
name: "shell",
|
||||
time: { created: 2 },
|
||||
state: {
|
||||
status: "running",
|
||||
input: { command: "run checks" },
|
||||
metadata: { shellID: "sh_workspace_source" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[])
|
||||
: [],
|
||||
}),
|
||||
beforeMessagesResponse: async ({ sessionID }) => {
|
||||
if (sessionID !== fixture.targetID) return
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
},
|
||||
})
|
||||
await page.route("**/api/shell/sh_workspace_source/output?*", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const directory = url.searchParams.get("location[directory]")!
|
||||
reads.push(directory)
|
||||
if (directory !== fixture.directory)
|
||||
return route.fulfill({ status: 404, json: { _tag: "ShellNotFoundError", id: "sh_workspace_source" } })
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory },
|
||||
data: {
|
||||
output: output.text.slice(Number(url.searchParams.get("cursor") ?? 0)),
|
||||
cursor: output.text.length,
|
||||
size: output.text.length,
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(
|
||||
(detail) =>
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
timelineDetail: { ...detail, shell: { placement: "separate", details: "expanded" } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
timelinePresets[2].value,
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
const shell = page.locator('[data-timeline-part-id="call_workspace_shell"]')
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Initial shell output")
|
||||
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
|
||||
try {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await requested.promise
|
||||
await expect(page.locator("[data-session-title]")).toHaveText(fixture.expected.targetTitle)
|
||||
output.text += "Output after returning\n"
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Output after returning")
|
||||
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
|
||||
expect(reads.length).toBeGreaterThan(1)
|
||||
expect(reads.every((directory) => directory === fixture.directory)).toBe(true)
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
|
||||
test("loads the transcript code font before opening rich history", async ({ page }) => {
|
||||
const font = page.waitForResponse((response) => /IBMPlexMono-Text[^/]*\.woff2/.test(response.url()))
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_font_source", type: "user", text: "A transcript with no code", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByText("A transcript with no code", { exact: true })).toBeVisible()
|
||||
expect((await font).ok()).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => document.fonts.check('440 13px "IBM Plex Mono"'))).toBe(true)
|
||||
})
|
||||
|
||||
test("waits for the requested session's history before constructing its cold timeline", async ({ page }) => {
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: (id) => ({ items: fixture.messages[id] ?? [] }),
|
||||
beforeMessagesResponse: async ({ sessionID }) => {
|
||||
if (sessionID !== fixture.targetID) return
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
},
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
try {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await requested.promise
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(0)
|
||||
release.resolve()
|
||||
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
|
||||
for (const grouped of [true, false]) {
|
||||
test(`restores a ${grouped ? "grouped" : "separate"} timeline after inactive updates and a resize`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const events: OpenCodeEvent[] = []
|
||||
const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
|
||||
[fixture.sourceID, fixture.targetID].map((id) => [
|
||||
id,
|
||||
[
|
||||
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
|
||||
{
|
||||
id: `msg_assistant_${id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: `tool_${id}`,
|
||||
name: "shell",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: `echo ${id}` },
|
||||
metadata: {},
|
||||
content: [{ type: "text", text: `Output for ${id}` }],
|
||||
},
|
||||
},
|
||||
{ type: "text", text: `Answer for ${id}` },
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[],
|
||||
]),
|
||||
)
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: (id) => ({ items: messages[id] ?? [] }),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(
|
||||
({ grouped, detail }) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
timelineDetail: {
|
||||
...detail,
|
||||
shell: { placement: grouped ? "grouped" : "separate", details: "collapsed" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ grouped, detail: timelinePresets[2].value },
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByText(`Answer for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
if (grouped)
|
||||
await page
|
||||
.locator(
|
||||
'[data-component="collapsed-tool-group"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"]',
|
||||
)
|
||||
.click()
|
||||
const shell = page.locator(`[data-timeline-part-id="tool_${fixture.sourceID}"]`)
|
||||
const trigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
|
||||
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(page.getByText(`Answer for ${fixture.targetID}`, { exact: true })).toBeVisible()
|
||||
await expect(shell).toHaveCount(0)
|
||||
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
|
||||
events.push({
|
||||
id: "evt_cached_text",
|
||||
created: 4,
|
||||
type: "session.text.ended",
|
||||
location: { directory: fixture.directory },
|
||||
durable: { aggregateID: fixture.sourceID, seq: 0, version: 1 },
|
||||
data: {
|
||||
sessionID: fixture.sourceID,
|
||||
assistantMessageID: `msg_assistant_${fixture.sourceID}`,
|
||||
ordinal: 0,
|
||||
text: "Updated while inactive",
|
||||
},
|
||||
})
|
||||
await page.setViewportSize({ width: 900, height: 650 })
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(page.getByText("Updated while inactive", { exact: true })).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
|
||||
expect(await original!.evaluate((element) => element.isConnected)).toBe(true)
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator("[data-timeline-key]")
|
||||
.evaluateAll((rows) =>
|
||||
rows.every(
|
||||
(row) =>
|
||||
(row.firstElementChild?.getBoundingClientRect().height ?? 0) <= row.getBoundingClientRect().height + 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const messagePageSize = 20
|
||||
const messagePageSize = 40
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
@@ -188,7 +188,7 @@ for (const scenario of scenarios) {
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
expect(pages).toEqual([
|
||||
{ before: undefined, limit: messagePageSize },
|
||||
{ before: messages.at(-messagePageSize)!.id, limit: messagePageSize },
|
||||
{ before: messages.at(-messagePageSize)!.id, limit: 20 },
|
||||
])
|
||||
expect(roots).toEqual([])
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
for (const window of ["assistant-only", "mixed"] as const) {
|
||||
test(`renders the ${window} latest page before parent hydration and preserves it afterward`, async ({ page }) => {
|
||||
const session = { ...fixture.sessions[0]!, id: `ses_hydration_${window}` }
|
||||
// Both 20-message pages begin with an assistant; only page three supplies its parent.
|
||||
const messages = Array.from({ length: 41 }, (_, index): SessionMessageInfo => {
|
||||
// Compact's initial 40 and the next 20 begin with an assistant; page three supplies its parent.
|
||||
const messages = Array.from({ length: 61 }, (_, index): SessionMessageInfo => {
|
||||
const id = `msg_hydration_${index}`
|
||||
const time = { created: 1700000000000 + index * 1_000 }
|
||||
if (index === 0 || (window === "mixed" && index === 39))
|
||||
if (index === 0 || (window === "mixed" && index === 59))
|
||||
return { id, type: "user", time, text: `Prompt ${index}` }
|
||||
return {
|
||||
id,
|
||||
@@ -21,7 +21,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
|
||||
time: { ...time, completed: time.created + 500 },
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
agent: "build",
|
||||
content: [{ type: "text", text: index === 40 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
|
||||
content: [{ type: "text", text: index === 60 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
|
||||
}
|
||||
})
|
||||
const gates = [21, 1].map((index) => ({
|
||||
@@ -43,18 +43,18 @@ for (const window of ["assistant-only", "mixed"] as const) {
|
||||
await gate.release.promise
|
||||
},
|
||||
pageMessages: (_, limit, before) => {
|
||||
expect(limit).toBe(20)
|
||||
expect(limit).toBe(before ? 20 : 40)
|
||||
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
|
||||
},
|
||||
})
|
||||
const tail = page.locator('[data-timeline-part-id="msg_hydration_40:text:0"]')
|
||||
const tail = page.locator('[data-timeline-part-id="msg_hydration_60:text:0"]')
|
||||
const markdown = tail.locator('[data-component="markdown"]')
|
||||
const content = page.locator("[data-timeline-virtual-content]", { has: tail })
|
||||
const viewport = page.locator(".scroll-view__viewport", { has: tail })
|
||||
const orphan = page.locator('[data-timeline-row="AssistantPart"]', {
|
||||
has: page.locator('[data-timeline-part-id="msg_hydration_38:text:0"]'),
|
||||
has: page.locator('[data-timeline-part-id="msg_hydration_58:text:0"]'),
|
||||
})
|
||||
const expectReadyTail = async () => {
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
@@ -75,7 +75,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
|
||||
await expect(orphan).toHaveAttribute("data-message-id", "msg_hydration_21")
|
||||
if (window === "mixed")
|
||||
await expect(
|
||||
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_39"]'),
|
||||
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_59"]'),
|
||||
).toBeInViewport()
|
||||
const original = await markdown.elementHandle()
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const id = state.connections
|
||||
let ended = false
|
||||
let own: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let keepalive: ReturnType<typeof setInterval> | undefined
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
own = controller
|
||||
@@ -89,11 +90,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
|
||||
// Match the real server's idle stream so long scenarios do not
|
||||
// trigger the client's 45-second stall watchdog and reload history.
|
||||
keepalive = setInterval(() => controller.enqueue(encoder.encode(": keepalive\n\n")), 15_000)
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (ended) return
|
||||
ended = true
|
||||
clearInterval(keepalive)
|
||||
if (state.controller === controller) state.controller = undefined
|
||||
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
|
||||
},
|
||||
@@ -103,6 +108,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
cancel() {
|
||||
if (ended) return
|
||||
ended = true
|
||||
clearInterval(keepalive)
|
||||
if (state.controller === own) state.controller = undefined
|
||||
},
|
||||
})
|
||||
|
||||
@@ -34,6 +34,27 @@ function setup(input?: {
|
||||
}
|
||||
|
||||
describe("createRequestQueue", () => {
|
||||
test("starts a free slot before the caller continues its synchronous work", async () => {
|
||||
const input = setup()
|
||||
const response = input.queue.fetch("http://server/api/session")
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/session"])
|
||||
expect(input.queue.inflight()).toBe(1)
|
||||
input.pending[0]!.resolve()
|
||||
await response
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("releases a free slot without sending an already-aborted request", async () => {
|
||||
const input = setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(input.queue.fetch("http://server/api/session", { signal: controller.signal })).rejects.toBeInstanceOf(
|
||||
DOMException,
|
||||
)
|
||||
expect(input.pending).toHaveLength(0)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("caps concurrent requests and starts queued ones as slots free up", async () => {
|
||||
const input = setup()
|
||||
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
|
||||
@@ -109,12 +130,10 @@ describe("createRequestQueue", () => {
|
||||
const input = setup({ limit: 1, headersTimeoutMs: 10 })
|
||||
const dead = input.queue.fetch("http://server/api/dead")
|
||||
const next = input.queue.fetch("http://server/api/next")
|
||||
await input.settle()
|
||||
expect(input.queue.queued()).toBe(1)
|
||||
const error = await dead.catch((cause: unknown) => cause)
|
||||
expect(error).toBeInstanceOf(DOMException)
|
||||
expect((error as DOMException).name).toBe("TimeoutError")
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/dead", "/api/next"])
|
||||
input.pending[1]!.resolve()
|
||||
await expect(next).resolves.toBeInstanceOf(Response)
|
||||
|
||||
@@ -84,17 +84,23 @@ export function createRequestQueue(input: {
|
||||
if (index === -1) return
|
||||
waiting.splice(index, 1)[0]?.start()
|
||||
}
|
||||
const acquire = (entry: Entry) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const acquire = (entry: Entry) => {
|
||||
// A free slot must start fetch before the caller's synchronous UI work.
|
||||
// Awaiting an already-resolved promise postpones that dispatch until after it.
|
||||
if (canStart(entry)) {
|
||||
inflight.add(entry)
|
||||
return
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
const start = () => {
|
||||
entry.at = now()
|
||||
inflight.add(entry)
|
||||
resolve()
|
||||
}
|
||||
if (canStart(entry)) return start()
|
||||
waiting.push({ entry, start })
|
||||
watcher ??= setTimeout(watch, stallMs)
|
||||
})
|
||||
}
|
||||
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (resource: RequestInfo | URL, init?: RequestInit) => {
|
||||
@@ -103,7 +109,8 @@ export function createRequestQueue(input: {
|
||||
// The event stream is long-lived; never count it against the request budget.
|
||||
if (pathname === "/api/event") return base(request)
|
||||
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
|
||||
await acquire(entry)
|
||||
const queued = acquire(entry)
|
||||
if (queued) await queued
|
||||
if (request.signal.aborted) {
|
||||
release(entry)
|
||||
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
|
||||
|
||||
@@ -16,6 +16,8 @@ import { ModelState } from "./persistence"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "./errors"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -131,10 +133,12 @@ function createServerController(
|
||||
projects: ReturnType<typeof createServerProjects>,
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const connKey = ServerConnection.key(conn)
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const source = createData({
|
||||
api: () => sdk.api,
|
||||
initialMessageLimit: () => (timelinePreset(settings.general.timelineDetail())?.id === "compact" ? 40 : 20),
|
||||
event: {
|
||||
on: sdk.event.on,
|
||||
listen: (handler) => sdk.event.listen((event) => handler({ name: event.type, details: event })),
|
||||
|
||||
@@ -34,6 +34,7 @@ import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
import { createTimelineCache } from "./timeline/cache"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -219,6 +220,45 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</Show>
|
||||
)
|
||||
|
||||
const timelineView = createTimelineCache(
|
||||
session,
|
||||
(source, active) => (
|
||||
<MessageTimeline
|
||||
active={active()}
|
||||
hideHeader={!isDesktop()}
|
||||
session={source}
|
||||
background={composer.requests.background}
|
||||
actions={composer.actions.timeline}
|
||||
scroll={timeline.scroll}
|
||||
onResumeScroll={timeline.actions.resume}
|
||||
setScrollRef={timeline.view.setScrollRef}
|
||||
onScheduleScrollState={timeline.view.scheduleScrollState}
|
||||
onPin={timeline.view.pin}
|
||||
onUnpin={timeline.view.unpin}
|
||||
onUserScroll={timeline.view.markUserScroll}
|
||||
onHistoryScroll={timeline.view.onHistoryScroll}
|
||||
onSelectionInteraction={timeline.view.selectionInteraction}
|
||||
pinned={timeline.view.pinned()}
|
||||
centered={screen.centered()}
|
||||
reserveReviewToggle={!sideVisible()}
|
||||
setContentRef={timeline.view.setContentRef}
|
||||
diffs={review.details.diffs}
|
||||
onReview={review.open}
|
||||
workspaceMoveEligible={composer.workspaceMoveEligible()}
|
||||
onSummaryOpenChange={review.details.setOpen}
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
setScrollToEnd={timeline.view.setScrollToEnd}
|
||||
search={
|
||||
<Show when={active()}>
|
||||
<TimelineSearchBar controller={timelineSearch} />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
),
|
||||
() => conversationVisible() && messagesReady(),
|
||||
)
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
<ComposerDropzone
|
||||
@@ -258,37 +298,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<Show when={isDesktop() && !messagesReady()}>
|
||||
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
|
||||
</Show>
|
||||
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
hideHeader={!isDesktop()}
|
||||
session={session}
|
||||
background={composer.requests.background}
|
||||
actions={composer.actions.timeline}
|
||||
scroll={timeline.scroll}
|
||||
onResumeScroll={timeline.actions.resume}
|
||||
setScrollRef={timeline.view.setScrollRef}
|
||||
onScheduleScrollState={timeline.view.scheduleScrollState}
|
||||
onPin={timeline.view.pin}
|
||||
onUnpin={timeline.view.unpin}
|
||||
onUserScroll={timeline.view.markUserScroll}
|
||||
onHistoryScroll={timeline.view.onHistoryScroll}
|
||||
onSelectionInteraction={timeline.view.selectionInteraction}
|
||||
pinned={timeline.view.pinned()}
|
||||
centered={screen.centered()}
|
||||
reserveReviewToggle={!sideVisible()}
|
||||
setContentRef={timeline.view.setContentRef}
|
||||
diffs={review.details.diffs}
|
||||
onReview={review.open}
|
||||
workspaceMoveEligible={composer.workspaceMoveEligible()}
|
||||
onSummaryOpenChange={review.details.setOpen}
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
setScrollToEnd={timeline.view.setScrollToEnd}
|
||||
search={<TimelineSearchBar controller={timelineSearch} />}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={messagesReady() && session.identity.params.id}>{timelineView()}</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
createComputed,
|
||||
createMemo,
|
||||
createRoot,
|
||||
getOwner,
|
||||
on,
|
||||
onCleanup,
|
||||
untrack,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createScopedCache } from "@/runtime/server/scoped-cache"
|
||||
import type { TimelineSessionSource } from "./controller"
|
||||
|
||||
export function createTimelineCache(
|
||||
session: TimelineSessionSource & { identity: { workspaceKey: Accessor<string> } },
|
||||
render: (source: TimelineSessionSource, active: Accessor<boolean>) => JSX.Element,
|
||||
visible: Accessor<boolean>,
|
||||
) {
|
||||
const owner = getOwner()
|
||||
const cache = createScopedCache(
|
||||
(key) =>
|
||||
createRoot((dispose) => {
|
||||
const id = untrack(session.identity.sessionID)
|
||||
const sessionKey = untrack(session.identity.sessionKey)
|
||||
const active = createMemo(() => visible() && session.identity.sessionKey() === key)
|
||||
// A detached view retains its own inputs until it is selected again.
|
||||
const select = <T>(read: Accessor<T>) =>
|
||||
createMemo<T>((previous) => (active() ? read() : previous), untrack(read))
|
||||
return {
|
||||
value: render(
|
||||
{
|
||||
identity: {
|
||||
params: session.identity.params,
|
||||
sessionID: () => id,
|
||||
sessionKey: () => sessionKey,
|
||||
},
|
||||
data: {
|
||||
info: select(session.data.info),
|
||||
parent: select(session.data.parent),
|
||||
parentID: select(session.data.parentID),
|
||||
status: select(session.data.status),
|
||||
},
|
||||
history: { messages: select(session.history.messages) },
|
||||
},
|
||||
active,
|
||||
),
|
||||
dispose,
|
||||
}
|
||||
}, owner),
|
||||
{ maxEntries: 16, dispose: (entry) => entry.dispose() },
|
||||
)
|
||||
onCleanup(cache.clear)
|
||||
// Providers follow the selected Location even while its history is loading.
|
||||
// Dispose detached views before their effects can read the new Location.
|
||||
createComputed(on(session.identity.workspaceKey, cache.clear, { defer: true }))
|
||||
return () => cache.get(session.identity.sessionKey()).value
|
||||
}
|
||||
@@ -349,6 +349,7 @@ export function SessionSummaryPanel(props: {
|
||||
|
||||
type MessageTimelineProps = {
|
||||
hideHeader?: boolean
|
||||
active?: boolean
|
||||
session: TimelineSessionSource
|
||||
background: SessionBackground
|
||||
actions?: SessionUserActions
|
||||
@@ -459,6 +460,7 @@ function MessageTimelineView(
|
||||
const pinned = createMemo(() => props.pinned)
|
||||
const messageByID = projection.messageByID
|
||||
const virtualized = createTimelineVirtualizer({
|
||||
active: () => props.active !== false,
|
||||
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
|
||||
presentationKey: () => JSON.stringify(props.data.timelineDetail()),
|
||||
projection,
|
||||
@@ -552,6 +554,12 @@ function MessageTimelineView(
|
||||
if (await props.action.rename(title.draft)) setTitle("editing", false)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.active !== false) return
|
||||
setSummary(false)
|
||||
setTitle({ draft: "", editing: false, menuOpen: false, pendingRename: false })
|
||||
})
|
||||
|
||||
const rowRenderer = createSessionTimelineRowRenderer({
|
||||
sessionID: () => sessionID()!,
|
||||
status: sessionStatus,
|
||||
|
||||
@@ -11,17 +11,17 @@ export {
|
||||
selectVisibleSessionUserMessages as selectVisibleUserMessages,
|
||||
} from "../session-domain"
|
||||
|
||||
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
|
||||
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history" | "ownership"> }) {
|
||||
const data = useData()
|
||||
|
||||
const [resource] = createResource(
|
||||
() => input.session.identity.sessionID(),
|
||||
async (id) => {
|
||||
if (!id) return
|
||||
const key = input.session.identity.sessionKey()
|
||||
const owner = input.session.ownership.capture()
|
||||
await Promise.all([data.session.message.sync(id), data.session.pending.sync(id)])
|
||||
await enrichLeadingTurn({
|
||||
current: () => input.session.identity.sessionKey() === key,
|
||||
current: owner.current,
|
||||
messages: () => data.session.message.list(id),
|
||||
more: () => data.session.message.more(id),
|
||||
loading: () => data.session.message.loading(id),
|
||||
@@ -29,12 +29,13 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
|
||||
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
|
||||
maxPages: leadingTurnPageLimit,
|
||||
}).catch(() => undefined)
|
||||
return id
|
||||
},
|
||||
)
|
||||
const ready = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
// Enrich the partial leading group without withholding the already loaded tail.
|
||||
return !id || data.session.message.list(id).length > 0 || !resource.loading
|
||||
return !id || data.session.message.list(id).length > 0 || (!resource.loading && resource.latest === id)
|
||||
})
|
||||
const more = () => {
|
||||
const id = input.session.identity.sessionID()
|
||||
|
||||
@@ -16,6 +16,35 @@ test("matches only the scroll element or an ancestor containing it", () => {
|
||||
expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
|
||||
})
|
||||
|
||||
test("restores a view observed before its first attachment", async () => {
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
const viewport = targetWindow.document.createElement("div")
|
||||
const instance = {
|
||||
scrollElement: viewport,
|
||||
targetWindow,
|
||||
scrollOffset: 240,
|
||||
options: { horizontal: false, isRtl: false, isScrollingResetDelay: 0, useScrollendEvent: false },
|
||||
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
|
||||
const connections: boolean[] = []
|
||||
const cleanup = observeElementOffsetReconnectAware(
|
||||
instance,
|
||||
(offset) => {
|
||||
instance.scrollOffset = offset
|
||||
},
|
||||
() => connections.push(viewport.isConnected),
|
||||
)
|
||||
try {
|
||||
mutations.append(targetWindow.document.body, viewport)
|
||||
await frames(2, targetWindow)
|
||||
expect(connections).toEqual([true])
|
||||
expect(instance.scrollOffset).toBe(0)
|
||||
} finally {
|
||||
cleanup()
|
||||
await targetWindow.happyDOM.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
|
||||
@@ -20,7 +20,8 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
|
||||
cleanupOffset?.()
|
||||
}
|
||||
|
||||
let removed = false
|
||||
// Cached views can be constructed before their first attachment to the page.
|
||||
let removed = !element.isConnected
|
||||
let frame: number | undefined
|
||||
const clearCheck = () => {
|
||||
if (frame === undefined) return
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createVirtualizer,
|
||||
defaultRangeExtractor,
|
||||
elementScroll,
|
||||
observeElementRect,
|
||||
type Range,
|
||||
type VirtualItem,
|
||||
} from "@tanstack/solid-virtual"
|
||||
@@ -49,6 +50,7 @@ type Projection = Pick<
|
||||
>
|
||||
|
||||
type Input = {
|
||||
active?: Accessor<boolean>
|
||||
sessionKey: Accessor<string>
|
||||
presentationKey?: Accessor<string>
|
||||
projection: Projection
|
||||
@@ -83,6 +85,7 @@ type ViewProps = {
|
||||
|
||||
export function createTimelineVirtualizer(input: Input) {
|
||||
const language = useLanguage()
|
||||
const active = () => input.active?.() !== false
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
|
||||
const ownerSessionKey = input.sessionKey()
|
||||
@@ -134,7 +137,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
!(
|
||||
row._tag === "AssistantPart" &&
|
||||
row.group.type === "context" &&
|
||||
row.group.refs.length <= 16 &&
|
||||
row.group.refs.length <= 64 &&
|
||||
!toolOpen[`context:${row.group.key}`]
|
||||
) && !input.canRenderImmediately?.(row, toolOpen),
|
||||
)
|
||||
@@ -156,6 +159,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
let virtualContent: HTMLDivElement | undefined
|
||||
let scrollTop = 0
|
||||
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
|
||||
let reportRect: ((rect: { width: number; height: number }) => void) | undefined
|
||||
let batchingColdSizes = false
|
||||
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
@@ -163,13 +167,22 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
return rows().length
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
observeElementRect: (instance, callback) => {
|
||||
reportRect = callback
|
||||
return observeElementRect(instance, (rect) => {
|
||||
if (active()) callback(rect)
|
||||
})
|
||||
},
|
||||
// Route navigation detaches and reattaches the scroll element, which drops its offset.
|
||||
observeElementOffset: (instance, callback) => {
|
||||
reportOffset = (offset, scrolling) => {
|
||||
if (!active()) return
|
||||
callback(offset, scrolling)
|
||||
settleColdBottom()
|
||||
}
|
||||
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
|
||||
if (!active()) return
|
||||
virtualContent?.querySelectorAll<HTMLDivElement>("[data-index]").forEach(virtualizer.measureElement)
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
settleColdBottom()
|
||||
})
|
||||
@@ -181,6 +194,11 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
// its snapshot on attachment, but later explicit measurements must read layout
|
||||
// so deferred/rewrapped content cannot keep stale, clipped heights (TanStack/virtual#1183).
|
||||
measureElement: (element, entry, instance) => {
|
||||
if (!active() || !element.isConnected)
|
||||
return (
|
||||
instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element))) ??
|
||||
fallbackItemSize
|
||||
)
|
||||
const initial = !measuredElements.has(element)
|
||||
measuredElements.add(element)
|
||||
const box = entry?.borderBoxSize[0]
|
||||
@@ -192,6 +210,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
return element.offsetHeight
|
||||
},
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
if (!active()) return
|
||||
if (batchingColdSizes && input.pinned()) return
|
||||
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
|
||||
elementScroll(offset, options, instance)
|
||||
@@ -221,6 +240,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
// Read the whole measurement delivery before committing reactive row sizes.
|
||||
// Otherwise each row can render and force layout before the next is measured.
|
||||
virtualizer.resizeItem = (index, size) => {
|
||||
if (!active()) return
|
||||
const row = rows()[index]
|
||||
if (!row) return
|
||||
const key = TimelineRow.key(row)
|
||||
@@ -236,6 +256,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (!pendingSizes.size) return
|
||||
const sizes = [...pendingSizes]
|
||||
pendingSizes.clear()
|
||||
if (!active()) return
|
||||
// The hidden pinned mount needs one bottom write after the whole batch,
|
||||
// not a layout-forcing scroll adjustment for every measured row.
|
||||
batchingColdSizes = coldPending && input.pinned()
|
||||
@@ -271,7 +292,21 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
|
||||
|
||||
createEffect(() => {
|
||||
if (!active()) return
|
||||
const root = listRoot()
|
||||
if (root) input.setScrollRef(root)
|
||||
if (virtualContent) input.setContentRef(virtualContent)
|
||||
queueMicrotask(() => {
|
||||
if (!active() || !root?.isConnected) return
|
||||
// A detached view can miss its nonzero ResizeObserver delivery. Publish
|
||||
// its real viewport before restoring the offset and admitting rows.
|
||||
reportRect?.({ width: root.offsetWidth, height: root.offsetHeight })
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
reportOffset?.(root.scrollTop, false)
|
||||
settleColdBottom()
|
||||
})
|
||||
input.setRevealMessage?.((id, partID) => {
|
||||
if (!active()) return
|
||||
const partIndex = partID
|
||||
? rows().findIndex(
|
||||
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
|
||||
@@ -282,6 +317,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
virtualizer.scrollToIndex(index, { align: "center" })
|
||||
})
|
||||
input.setScrollToEnd?.(() => {
|
||||
if (!active() || !listRoot()?.isConnected) return
|
||||
input.onPin()
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
@@ -292,6 +328,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
let contentObserver: MutationObserver | undefined
|
||||
let viewportObserver: ResizeObserver | undefined
|
||||
const pinColdBottom = () => {
|
||||
if (!active()) return
|
||||
const root = listRoot()
|
||||
if (!input.pinned() || !virtualContent || !root) return
|
||||
// scrollToEnd computes its target from the DOM, not the new size cache.
|
||||
@@ -309,7 +346,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
)
|
||||
}
|
||||
const settleColdBottom = () => {
|
||||
if (!coldPending || settleQueued) return
|
||||
if (!active() || !coldPending || settleQueued) return
|
||||
settleQueued = true
|
||||
queueMicrotask(() => {
|
||||
settleQueued = false
|
||||
@@ -372,7 +409,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
setListRoot(root)
|
||||
scrollTop = root.scrollTop
|
||||
maxScroll = root.scrollHeight - root.clientHeight
|
||||
input.setScrollRef(root)
|
||||
if (active()) input.setScrollRef(root)
|
||||
viewportObserver?.observe(root)
|
||||
settleColdBottom()
|
||||
}
|
||||
@@ -429,6 +466,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
// under a viewport that was already there. Merely resting near the end is not enough, otherwise
|
||||
// a later scroll would overwrite an upward intent expressed a pixel short of the bottom.
|
||||
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (!active()) return
|
||||
const root = event.currentTarget
|
||||
const previousTop = scrollTop
|
||||
const previousMaxScroll = maxScroll
|
||||
@@ -454,15 +492,8 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
let contentMeasureFrame: number | undefined
|
||||
|
||||
onMount(() => virtualizer.measureElement(element))
|
||||
createEffect(
|
||||
on(
|
||||
() => item().index,
|
||||
() => {
|
||||
virtualizer.measureElement(element)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
// Prepending history changes data-index, not the keyed element's identity.
|
||||
// Its observer reads the current index and delivers any actual size change.
|
||||
onCleanup(() => {
|
||||
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
|
||||
queueMicrotask(() => virtualizer.measureElement(null))
|
||||
@@ -493,7 +524,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
|
||||
contentMeasureFrame = requestAnimationFrame(() => {
|
||||
contentMeasureFrame = undefined
|
||||
if (element.isConnected) virtualizer.measureElement(element)
|
||||
if (active() && element.isConnected) virtualizer.measureElement(element)
|
||||
})
|
||||
})}
|
||||
</div>
|
||||
@@ -555,7 +586,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
data-timeline-virtual-content
|
||||
ref={(element) => {
|
||||
virtualContent = element
|
||||
input.setContentRef(element)
|
||||
if (active()) input.setContentRef(element)
|
||||
}}
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
@@ -590,9 +621,11 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
coldPending = false
|
||||
contentObserver?.disconnect()
|
||||
viewportObserver?.disconnect()
|
||||
input.setScrollRef(undefined)
|
||||
input.setRevealMessage?.(() => {})
|
||||
input.setScrollToEnd?.(() => {})
|
||||
if (active()) {
|
||||
input.setScrollRef(undefined)
|
||||
input.setRevealMessage?.(() => {})
|
||||
input.setScrollToEnd?.(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -288,8 +288,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const root = document.documentElement
|
||||
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
|
||||
const mono = monoFontFamily(store.appearance?.mono)
|
||||
root.style.setProperty("--font-family-mono", mono)
|
||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
||||
// Inline code can first appear during history backfill. Load its selected
|
||||
// face with the shell so that font discovery does not resize that mount.
|
||||
void document.fonts?.load(`440 13px ${mono}`).catch(() => undefined)
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -306,19 +306,20 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
createEffect(() => {
|
||||
if (!catalogReady()) return
|
||||
|
||||
setCatalog(
|
||||
registered().reduce((acc, opt) => {
|
||||
const id = actionId(opt.id)
|
||||
if (opt.title)
|
||||
acc[id] = {
|
||||
batch(() =>
|
||||
registered().forEach((opt) => {
|
||||
if (!opt.title) return
|
||||
setCatalog(
|
||||
actionId(opt.id),
|
||||
reconcile({
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
}
|
||||
return acc
|
||||
}, {} as CommandCatalog),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createComputed, createRoot } from "solid-js"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { createData } from "@opencode/client/solid"
|
||||
|
||||
test("publishes an initial message page, its index, and its cursor together", async () => {
|
||||
const observed: { ids: string[]; more: boolean; text: string | undefined }[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async () =>
|
||||
Response.json({
|
||||
data: [{ id: "msg_page", type: "user", text: "History", time: { created: 1 } }],
|
||||
cursor: { next: "older" },
|
||||
}),
|
||||
})
|
||||
const setup = createRoot((dispose) => {
|
||||
const data = createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
})
|
||||
createComputed(() => {
|
||||
const message = data.session.message.get("ses_page", "msg_page")
|
||||
observed.push({
|
||||
ids: data.session.message.list("ses_page").map((message) => message.id),
|
||||
more: data.session.message.more("ses_page"),
|
||||
text: message?.type === "user" ? message.text : undefined,
|
||||
})
|
||||
})
|
||||
return { data, dispose }
|
||||
})
|
||||
try {
|
||||
await setup.data.session.message.sync("ses_page")
|
||||
expect(observed).toEqual([
|
||||
{ ids: [], more: false, text: undefined },
|
||||
{ ids: ["msg_page"], more: true, text: "History" },
|
||||
])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerScope, SessionRouteKey, SessionStateKey } from "../src/runtime/server/scope"
|
||||
import { createTimelineCache } from "../src/session/timeline/cache"
|
||||
|
||||
function setup() {
|
||||
return createRoot((dispose) => {
|
||||
const [state, setState] = createStore({
|
||||
id: "ses_a",
|
||||
directory: "/repo",
|
||||
visible: true,
|
||||
messages: {
|
||||
ses_a: [{ id: "msg_a", type: "user", text: "First session", time: { created: 1 } }],
|
||||
ses_b: [{ id: "msg_b", type: "user", text: "Second session", time: { created: 2 } }],
|
||||
} as Record<string, SessionMessageInfo[]>,
|
||||
})
|
||||
const views = new Map<
|
||||
string,
|
||||
{ active: () => boolean; messages: () => SessionMessageInfo[]; element: HTMLDivElement }
|
||||
>()
|
||||
const disposed: string[] = []
|
||||
const cache = createTimelineCache(
|
||||
{
|
||||
identity: {
|
||||
params: {
|
||||
get id() {
|
||||
return state.id
|
||||
},
|
||||
serverKey: "local",
|
||||
},
|
||||
sessionID: () => state.id,
|
||||
workspaceKey: () => SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory)),
|
||||
sessionKey: () =>
|
||||
SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory, state.id)),
|
||||
},
|
||||
data: {
|
||||
info: createMemo(() => ({
|
||||
id: state.id,
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
location: { directory: state.directory },
|
||||
})),
|
||||
parent: () => undefined,
|
||||
parentID: () => undefined,
|
||||
status: () => ({ type: "idle" }),
|
||||
},
|
||||
history: { messages: () => state.messages[state.id] ?? [] },
|
||||
},
|
||||
(source, active) => {
|
||||
const id = source.identity.sessionID()!
|
||||
const element = document.createElement("div")
|
||||
createMemo(() =>
|
||||
element.setAttribute(
|
||||
"data-messages",
|
||||
source.history
|
||||
.messages()
|
||||
.map((message) => message.id)
|
||||
.join(","),
|
||||
),
|
||||
)
|
||||
views.set(id, { active, messages: source.history.messages, element })
|
||||
onCleanup(() => disposed.push(id))
|
||||
return element
|
||||
},
|
||||
() => state.visible,
|
||||
)
|
||||
return { state, setState, cache, views, disposed, dispose }
|
||||
})
|
||||
}
|
||||
|
||||
test("reuses a session view and refreshes its own history when selected again", () => {
|
||||
const input = setup()
|
||||
try {
|
||||
const first = input.cache()
|
||||
input.setState("id", "ses_b")
|
||||
const second = input.cache()
|
||||
expect(second).not.toBe(first)
|
||||
expect(input.views.get("ses_a")!.active()).toBe(false)
|
||||
expect(input.views.get("ses_a")!.element.dataset.messages).toBe("msg_a")
|
||||
expect(input.views.get("ses_b")!.element.dataset.messages).toBe("msg_b")
|
||||
|
||||
input.setState("messages", "ses_a", [
|
||||
{ id: "msg_c", type: "user", text: "Updated while inactive", time: { created: 3 } },
|
||||
])
|
||||
input.setState("id", "ses_a")
|
||||
expect(input.cache()).toBe(first)
|
||||
expect(input.views.get("ses_a")!.active()).toBe(true)
|
||||
expect(input.views.get("ses_a")!.element.dataset.messages).toBe("msg_c")
|
||||
expect(input.views.get("ses_b")!.element.dataset.messages).toBe("msg_b")
|
||||
expect(input.disposed).toEqual([])
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
expect(input.disposed.sort()).toEqual(["ses_a", "ses_b"])
|
||||
})
|
||||
|
||||
test("suspends a detached mobile view and reuses it when the conversation returns", () => {
|
||||
const input = setup()
|
||||
try {
|
||||
const first = input.cache()
|
||||
input.setState("visible", false)
|
||||
expect(input.views.get("ses_a")!.active()).toBe(false)
|
||||
input.setState("visible", true)
|
||||
expect(input.cache()).toBe(first)
|
||||
expect(input.views.get("ses_a")!.active()).toBe(true)
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("disposes views whose Location-scoped providers no longer match", () => {
|
||||
const input = setup()
|
||||
try {
|
||||
const first = input.cache()
|
||||
input.setState("directory", "/other")
|
||||
expect(input.cache()).not.toBe(first)
|
||||
expect(input.disposed).toEqual(["ses_a"])
|
||||
input.setState("directory", "/repo")
|
||||
expect(input.cache()).not.toBe(first)
|
||||
expect(input.disposed).toEqual(["ses_a", "ses_a"])
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
expect(input.disposed).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("disposes views on workspace changes while the destination is not rendered", () => {
|
||||
const input = setup()
|
||||
try {
|
||||
const first = input.cache()
|
||||
input.setState("visible", false)
|
||||
input.setState("directory", "/other")
|
||||
expect(input.disposed).toEqual(["ses_a"])
|
||||
input.setState("directory", "/repo")
|
||||
input.setState("visible", true)
|
||||
expect(input.cache()).not.toBe(first)
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
expect(input.disposed).toEqual(["ses_a", "ses_a"])
|
||||
})
|
||||
|
||||
test("evicts the least recently selected view and disposes all retained owners", () => {
|
||||
const input = setup()
|
||||
try {
|
||||
const first = input.cache()
|
||||
Array.from({ length: 15 }, (_, index) => `ses_${index}`).forEach((id) => {
|
||||
input.setState("id", id)
|
||||
input.cache()
|
||||
})
|
||||
input.setState("id", "ses_a")
|
||||
expect(input.cache()).toBe(first)
|
||||
input.setState("id", "ses_last")
|
||||
input.cache()
|
||||
expect(input.disposed).toEqual(["ses_0"])
|
||||
input.setState("id", "ses_0")
|
||||
input.cache()
|
||||
expect(input.disposed).toEqual(["ses_0", "ses_1"])
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
expect(input.disposed).toHaveLength(18)
|
||||
})
|
||||
@@ -57,6 +57,8 @@ type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent
|
||||
export type CreateDataInput = {
|
||||
readonly api: () => OpenCodeClient
|
||||
readonly directory: string
|
||||
/** Raw-message window used for an initial transcript read. Older pages retain their normal size. */
|
||||
readonly initialMessageLimit?: () => number
|
||||
readonly event: {
|
||||
readonly on: <Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
@@ -1569,7 +1571,11 @@ export function createData(config: CreateDataInput) {
|
||||
},
|
||||
sync(sessionID: string) {
|
||||
return sync.run(`session.message:${sessionID}`, async () => {
|
||||
const response = await api().message.list({ sessionID, limit: messagePageLimit, order: "desc" })
|
||||
const response = await api().message.list({
|
||||
sessionID,
|
||||
limit: config.initialMessageLimit?.() ?? messagePageLimit,
|
||||
order: "desc",
|
||||
})
|
||||
const fetched = response.data.toReversed()
|
||||
// Same protection as the pending sync: a re-fetch racing an
|
||||
// admission must not wipe its local transcript row.
|
||||
@@ -1583,9 +1589,11 @@ export function createData(config: CreateDataInput) {
|
||||
(item) => !ids.has(item.id) && (outbox.has(item.id) || admitted.has(item.id)),
|
||||
)
|
||||
const messages = local.length === 0 ? fetched : [...fetched, ...local]
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
|
||||
batch(() => {
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
|
||||
})
|
||||
})
|
||||
},
|
||||
more(sessionID: string) {
|
||||
|
||||
@@ -14,6 +14,44 @@ const session = (viewed: number): SessionInfo => ({
|
||||
location: { directory: "/project" },
|
||||
})
|
||||
|
||||
test("uses the configured initial window and retains normal cursor page sizes", async () => {
|
||||
const requests: { limit: string | null; cursor: string | null }[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const url = new URL((input instanceof Request ? input : new Request(input, init)).url)
|
||||
const cursor = url.searchParams.get("cursor")
|
||||
requests.push({ limit: url.searchParams.get("limit"), cursor })
|
||||
return Response.json({
|
||||
data: [{ id: cursor ? "msg_1" : "msg_2", type: "user", text: "History", time: { created: cursor ? 1 : 2 } }],
|
||||
cursor: cursor ? {} : { next: "older" },
|
||||
})
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
initialMessageLimit: () => 40,
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
try {
|
||||
await setup.data.session.message.sync("ses_refresh")
|
||||
await setup.data.session.message.sync("ses_refresh")
|
||||
expect(requests).toEqual([{ limit: "40", cursor: null }])
|
||||
await setup.data.session.message.loadMore("ses_refresh")
|
||||
expect(requests).toEqual([
|
||||
{ limit: "40", cursor: null },
|
||||
{ limit: "20", cursor: "older" },
|
||||
])
|
||||
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_1", "msg_2"])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("revalidates after an event overtakes an active session read", async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => (release = resolve))
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("keeps file disclosures keyboard-accessible as the file list changes", async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups")
|
||||
const patch = root.locator('[data-component="apply-patch-tool"]')
|
||||
const first = patch.getByRole("button", { name: /a\.ts/ })
|
||||
const second = patch.getByRole("button", { name: /b\.ts/ })
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false")
|
||||
await first.focus()
|
||||
await first.press("ArrowDown")
|
||||
await expect(second).toBeFocused()
|
||||
await second.press("ArrowDown")
|
||||
await expect(first).toBeFocused()
|
||||
await first.press("End")
|
||||
await expect(second).toBeFocused()
|
||||
await second.press("Home")
|
||||
await expect(first).toBeFocused()
|
||||
await first.press("Enter")
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(patch.getByRole("region", { name: /a\.ts/ })).toBeVisible()
|
||||
await first.press("Space")
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(patch.getByRole("region", { name: /a\.ts/ })).toHaveCount(0)
|
||||
await root.getByRole("button", { name: "Finish follow-up patch", exact: true }).click()
|
||||
const third = patch.getByRole("button", { name: /c\.ts/ })
|
||||
await expect(third).toHaveAttribute("aria-expanded", "false")
|
||||
await second.focus()
|
||||
await second.press("ArrowDown")
|
||||
await expect(third).toBeFocused()
|
||||
await third.press("ArrowUp")
|
||||
await expect(second).toBeFocused()
|
||||
})
|
||||
|
||||
story("merges follow-up patches into one stack with a distinct file count", async ({ mount }, info) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("requests live output only when the shell is expanded", async ({ mount }) => {
|
||||
const root = await mount("current-session-terminal-work--live-user-command", { args: { expanded: false } })
|
||||
const shell = root.locator('[data-component="session-shell-message"]')
|
||||
const trigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(root.getByLabel("Output requests")).toHaveText("0")
|
||||
await root.getByRole("button", { name: "Update output", exact: true }).click()
|
||||
await expect(root.getByLabel("Output requests")).toHaveText("0")
|
||||
await trigger.click()
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText("ready\nnext line\n")
|
||||
await expect(root.getByLabel("Output requests")).toHaveText(/^[1-9]\d*$/)
|
||||
await trigger.click()
|
||||
await expect(shell.locator('[data-component="bash-output"]')).toHaveCount(0)
|
||||
await root.getByRole("button", { name: "Update output", exact: true }).click()
|
||||
await trigger.click()
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText("ready\nnext line\nnext line\n")
|
||||
})
|
||||
|
||||
story("streams user shell output and retains the saved completion", async ({ mount }) => {
|
||||
const root = await mount("current-session-terminal-work--live-user-command")
|
||||
const shell = root.locator('[data-component="session-shell-message"]')
|
||||
|
||||
@@ -334,9 +334,18 @@ export function BasicTool(props: BasicToolProps) {
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.animated && hasChildren() && !props.hideDetails}>
|
||||
<Collapsible.Content>
|
||||
<Show when={!props.defer || ready()}>{props.children}</Show>
|
||||
</Collapsible.Content>
|
||||
<Show
|
||||
when={props.locked && open() && !props.defer}
|
||||
fallback={
|
||||
<Collapsible.Content>
|
||||
<Show when={!props.defer || ready()}>{props.children}</Show>
|
||||
</Collapsible.Content>
|
||||
}
|
||||
>
|
||||
<div data-slot="collapsible-content" data-expanded="">
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
)
|
||||
|
||||
@@ -69,7 +69,10 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
const patchPartKeys = new WeakMap<SessionMessageAssistant["content"][number], string>()
|
||||
const patchOwners = createMemo(() => {
|
||||
const owners = new Map<string, string>()
|
||||
input.projection.rows().forEach((row) => {
|
||||
const rows = input.projection.rows()
|
||||
// Track status changes before a group is first opened: a failed patch can
|
||||
// split an existing group without changing the projection's row identities.
|
||||
rows.forEach((row) => {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
|
||||
row.group.refs.forEach((ref) => {
|
||||
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
|
||||
@@ -97,10 +100,15 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
}
|
||||
const copyContentID = (messageID: string) => {
|
||||
if (workingTurn(messageID)) return null
|
||||
return (input.projection.assistantMessagesByParent().get(messageID) ?? emptyAssistantMessages)
|
||||
.toReversed()
|
||||
.flatMap((message) => Timeline.contentEntries(message).toReversed())
|
||||
.find((entry) => entry.content.type === "text" && !!entry.content.text.trim())?.id
|
||||
const message = input.projection
|
||||
.assistantMessagesByParent()
|
||||
.get(messageID)
|
||||
?.findLast((message) => message.content.some((content) => content.type === "text" && !!content.text.trim()))
|
||||
return message
|
||||
? Timeline.contentEntries(message).findLast(
|
||||
(entry) => entry.content.type === "text" && !!entry.content.text.trim(),
|
||||
)?.id
|
||||
: undefined
|
||||
}
|
||||
const padding = () => input.padding?.() ?? "px-4 md:px-5"
|
||||
const indexGroupContents = (refs: PartRef[]) => {
|
||||
|
||||
@@ -69,9 +69,10 @@ export const UserCommandCompleted = {
|
||||
}
|
||||
|
||||
export const LiveUserCommand = {
|
||||
args: { outcome: "exited", output: true },
|
||||
args: { outcome: "exited", output: true, expanded: true },
|
||||
argTypes: { outcome: { control: "select", options: ["exited", "nonzero", "timeout", "killed"] } },
|
||||
render: (args: { outcome: "exited" | "nonzero" | "timeout" | "killed"; output: boolean }) => {
|
||||
render: (args: { outcome: "exited" | "nonzero" | "timeout" | "killed"; output: boolean; expanded: boolean }) => {
|
||||
const [stats, setStats] = createStore({ reads: 0 })
|
||||
const [message, setMessage] = createSignal<SessionMessageShell>({
|
||||
id: "msg_shell_live",
|
||||
type: "shell",
|
||||
@@ -83,6 +84,7 @@ export const LiveUserCommand = {
|
||||
let output = args.output ? "ready\n" : ""
|
||||
return (
|
||||
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
|
||||
<output aria-label="Output requests">{stats.reads}</output>
|
||||
<button type="button" onClick={() => (output += "next line\n")}>
|
||||
Update output
|
||||
</button>
|
||||
@@ -105,6 +107,7 @@ export const LiveUserCommand = {
|
||||
directory="/workspace"
|
||||
data={{ session: [], session_status: {}, session_diff: {} }}
|
||||
shellOutput={async (input) => {
|
||||
setStats("reads", (value) => value + 1)
|
||||
if (message().status !== "running") throw new Error("Shell output unavailable")
|
||||
if (input.id !== "shell_live" || input.location?.directory !== "/workspace") {
|
||||
throw new Error("Unexpected shell output request")
|
||||
@@ -123,7 +126,7 @@ export const LiveUserCommand = {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SessionShellMessage message={message()} defaultOpen />
|
||||
<SessionShellMessage message={message()} defaultOpen={args.expanded} />
|
||||
</DataProvider>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Current Session tool presentation grouped by visual family.
|
||||
import {
|
||||
Component,
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
@@ -19,8 +21,6 @@ import { type SessionSummary, useData } from "../context"
|
||||
import { useFileComponent } from "@opencode/ui/context/file"
|
||||
import { type UiI18n, useI18n } from "@opencode/ui/context/i18n"
|
||||
import { BasicTool, GenericTool } from "../components/basic-tool"
|
||||
import { Accordion } from "@opencode/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode/ui/sticky-accordion-header"
|
||||
import { Collapsible } from "@opencode/ui/collapsible"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon, type IconProps } from "@opencode/ui/icon"
|
||||
@@ -526,7 +526,8 @@ export function CurrentContextToolGroup(props: {
|
||||
? i18n.t("ui.tool.skill")
|
||||
: part.name === "subagent"
|
||||
? i18n.t("ui.tool.agent.default")
|
||||
: getToolInfo(part.name, currentToolInput(part), currentToolMetadata(part)).title,
|
||||
: getToolInfo(part.name, undefined, part.name === "websearch" ? currentToolMetadata(part) : undefined)
|
||||
.title,
|
||||
]
|
||||
}),
|
||||
),
|
||||
@@ -557,40 +558,39 @@ export function CurrentContextToolGroup(props: {
|
||||
}
|
||||
})
|
||||
const items = createMemo(() =>
|
||||
props.parts.reduce<(SessionMessageAssistantTool[] | Exclude<ContextGroupPart, SessionMessageAssistantTool>)[]>(
|
||||
(groups, tool) => {
|
||||
if (tool.type !== "tool") {
|
||||
groups.push(tool)
|
||||
return groups
|
||||
}
|
||||
const previous = groups.at(-1)
|
||||
if (
|
||||
tool.name === "patch" &&
|
||||
tool.state.status !== "error" &&
|
||||
Array.isArray(previous) &&
|
||||
previous?.[0]?.name === "patch" &&
|
||||
previous[0].state.status !== "error"
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
}
|
||||
if (
|
||||
tool.name === "skill" &&
|
||||
tool.state.status !== "error" &&
|
||||
skillToolName(currentToolInput(tool), currentToolMetadata(tool)) &&
|
||||
Array.isArray(previous) &&
|
||||
previous?.[0]?.name === "skill" &&
|
||||
previous[0].state.status !== "error" &&
|
||||
skillToolName(currentToolInput(previous[0]), currentToolMetadata(previous[0]))
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
}
|
||||
groups.push([tool])
|
||||
(props.open ? props.parts : []).reduce<
|
||||
(SessionMessageAssistantTool[] | Exclude<ContextGroupPart, SessionMessageAssistantTool>)[]
|
||||
>((groups, tool) => {
|
||||
if (tool.type !== "tool") {
|
||||
groups.push(tool)
|
||||
return groups
|
||||
},
|
||||
[],
|
||||
),
|
||||
}
|
||||
const previous = groups.at(-1)
|
||||
if (
|
||||
tool.name === "patch" &&
|
||||
tool.state.status !== "error" &&
|
||||
Array.isArray(previous) &&
|
||||
previous?.[0]?.name === "patch" &&
|
||||
previous[0].state.status !== "error"
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
}
|
||||
if (
|
||||
tool.name === "skill" &&
|
||||
tool.state.status !== "error" &&
|
||||
skillToolName(currentToolInput(tool), currentToolMetadata(tool)) &&
|
||||
Array.isArray(previous) &&
|
||||
previous?.[0]?.name === "skill" &&
|
||||
previous[0].state.status !== "error" &&
|
||||
skillToolName(currentToolInput(previous[0]), currentToolMetadata(previous[0]))
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
}
|
||||
groups.push([tool])
|
||||
return groups
|
||||
}, []),
|
||||
)
|
||||
const patchKeys = createMemo(() => {
|
||||
const keys = new Map<SessionMessageAssistantTool, string>()
|
||||
@@ -988,6 +988,7 @@ function FileTool(props: ToolProps & { title: string; count: number; children: J
|
||||
onOpenChange={undefined}
|
||||
locked
|
||||
icon="code-lines"
|
||||
hasContent
|
||||
defer={false}
|
||||
rail={false}
|
||||
trigger={{
|
||||
@@ -1000,6 +1001,88 @@ function FileTool(props: ToolProps & { title: string; count: number; children: J
|
||||
)
|
||||
}
|
||||
|
||||
function FileAccordionGroup(props: { children: JSX.Element }) {
|
||||
return (
|
||||
<div
|
||||
data-component="accordion"
|
||||
data-scope="apply-patch"
|
||||
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return
|
||||
if (!(event.target instanceof HTMLButtonElement) || event.target.dataset.slot !== "accordion-trigger") return
|
||||
if (event.target.closest('[data-component="accordion"]') !== event.currentTarget) return
|
||||
const triggers = [
|
||||
...event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||
':scope > [data-slot="accordion-item"] > [data-slot="accordion-header"] > [data-slot="accordion-trigger"]',
|
||||
),
|
||||
]
|
||||
const index = triggers.indexOf(event.target)
|
||||
if (index < 0) return
|
||||
event.preventDefault()
|
||||
const next =
|
||||
event.key === "Home"
|
||||
? 0
|
||||
: event.key === "End"
|
||||
? triggers.length - 1
|
||||
: (index + (event.key === "ArrowDown" ? 1 : -1) + triggers.length) % triggers.length
|
||||
triggers[next]?.focus()
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileAccordionItem(props: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
type?: string
|
||||
header: JSX.Element
|
||||
children: JSX.Element
|
||||
}) {
|
||||
const id = createUniqueId()
|
||||
return (
|
||||
<div
|
||||
data-slot="accordion-item"
|
||||
data-type={props.type}
|
||||
data-expanded={props.open ? "" : undefined}
|
||||
data-closed={!props.open ? "" : undefined}
|
||||
>
|
||||
<h3
|
||||
data-slot="accordion-header"
|
||||
data-component="sticky-accordion-header"
|
||||
data-expanded={props.open ? "" : undefined}
|
||||
data-closed={!props.open ? "" : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id={`${id}-trigger`}
|
||||
data-slot="accordion-trigger"
|
||||
data-expanded={props.open ? "" : undefined}
|
||||
data-closed={!props.open ? "" : undefined}
|
||||
aria-expanded={props.open}
|
||||
aria-controls={props.open ? `${id}-content` : undefined}
|
||||
onClick={() => batch(() => props.onOpenChange(!props.open))}
|
||||
>
|
||||
{props.header}
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
id={`${id}-content`}
|
||||
role="region"
|
||||
aria-labelledby={`${id}-trigger`}
|
||||
data-slot="accordion-content"
|
||||
data-expanded=""
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolFileAccordion(props: {
|
||||
path: string
|
||||
actions?: JSX.Element
|
||||
@@ -1010,7 +1093,6 @@ function ToolFileAccordion(props: {
|
||||
forceOpen?: boolean
|
||||
defer?: boolean
|
||||
}) {
|
||||
const value = createMemo(() => props.path || "tool-file")
|
||||
const [expanded, setExpanded] = createSignal(props.defaultOpen ?? false)
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
const open = () => props.open ?? expanded()
|
||||
@@ -1033,38 +1115,31 @@ function ToolFileAccordion(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
multiple
|
||||
data-scope="apply-patch"
|
||||
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
|
||||
value={open() ? [value()] : []}
|
||||
onChange={(next) => change(next.includes(value()))}
|
||||
>
|
||||
<Accordion.Item value={value()}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="apply-patch-trigger-content">
|
||||
<div data-slot="apply-patch-file-info">
|
||||
<FileIcon node={{ path: props.path, type: "file" }} />
|
||||
<div data-slot="apply-patch-file-name-container">
|
||||
<Show when={props.path.includes("/")}>
|
||||
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(props.path)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="apply-patch-filename">{getFilename(props.path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="apply-patch-trigger-actions">
|
||||
{props.actions}
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
<FileAccordionGroup>
|
||||
<FileAccordionItem
|
||||
open={open()}
|
||||
onOpenChange={change}
|
||||
header={
|
||||
<div data-slot="apply-patch-trigger-content">
|
||||
<div data-slot="apply-patch-file-info">
|
||||
<FileIcon node={{ path: props.path, type: "file" }} />
|
||||
<div data-slot="apply-patch-file-name-container">
|
||||
<Show when={props.path.includes("/")}>
|
||||
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(props.path)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="apply-patch-filename">{getFilename(props.path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={!props.defer || visible()}>{props.children}</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<div data-slot="apply-patch-trigger-actions">
|
||||
{props.actions}
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={!props.defer || visible()}>{props.children}</Show>
|
||||
</FileAccordionItem>
|
||||
</FileAccordionGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1277,6 +1352,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="bullet-list"
|
||||
hasContent={!!props.output}
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.list"),
|
||||
subtitle: displayDirectory(typeof props.input.path === "string" ? props.input.path : "/"),
|
||||
@@ -1306,6 +1382,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="magnifying-glass-menu"
|
||||
hasContent={!!props.output}
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.glob"),
|
||||
subtitle: displayDirectory(typeof props.input.path === "string" ? props.input.path : "/"),
|
||||
@@ -1339,6 +1416,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="magnifying-glass-menu"
|
||||
hasContent={!!props.output}
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.grep"),
|
||||
subtitle: displayDirectory(typeof props.input.path === "string" ? props.input.path : "/"),
|
||||
@@ -1600,13 +1678,14 @@ ToolRegistry.register({
|
||||
const i18n = useI18n()
|
||||
const pending = () => props.status === "streaming" || props.status === "running"
|
||||
const code = createMemo(() => (typeof props.input.code === "string" ? props.input.code : ""))
|
||||
const output = createMemo(() => stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n"))
|
||||
const output = () => stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n")
|
||||
const sawPending = pending()
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="console"
|
||||
rail={false}
|
||||
hasContent
|
||||
compact
|
||||
allowOpenWhilePending
|
||||
trigger={(open) => (
|
||||
@@ -1616,7 +1695,7 @@ ToolRegistry.register({
|
||||
<TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!open() && code()}>
|
||||
<ShellSubmessage text={code().split("\n")[0]} animate={sawPending} />
|
||||
<ShellSubmessage text={code().split("\n", 1)[0]} animate={sawPending} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1642,36 +1721,44 @@ ToolRegistry.register({
|
||||
props.status === "running" ||
|
||||
(typeof props.metadata.shellID === "string" && data.shellRunning?.(props.metadata.shellID) === true)
|
||||
const sawStreaming = streaming()
|
||||
const [streamed, setStreamed] = createSignal("")
|
||||
// Direct-user terminal snapshots are authoritative; agent results can describe background shells.
|
||||
const saved = createMemo(() =>
|
||||
props.metadata.status === "exited" || props.metadata.status === "timeout" || props.metadata.status === "killed"
|
||||
? props.output
|
||||
: undefined,
|
||||
)
|
||||
createEffect(() => {
|
||||
if (saved() !== undefined) return
|
||||
const id = props.metadata.shellID
|
||||
const load = data.shellOutput
|
||||
if (typeof id !== "string" || !load) return
|
||||
onCleanup(followShellOutput({ id, directory: data.directory, running: pending(), load, onOutput: setStreamed }))
|
||||
})
|
||||
const command = () => {
|
||||
if (typeof props.input.command === "string") return props.input.command
|
||||
if (typeof props.metadata.command === "string") return props.metadata.command
|
||||
return ""
|
||||
}
|
||||
const output = createMemo(() =>
|
||||
stripAnsi(saved() ?? ((typeof props.metadata.shellID === "string" && streamed()) || props.output || "")).replace(
|
||||
/\r\n?/g,
|
||||
"\n",
|
||||
),
|
||||
)
|
||||
function Output() {
|
||||
const [streamed, setStreamed] = createSignal("")
|
||||
// Direct-user terminal snapshots are authoritative; agent results can describe background shells.
|
||||
const saved = createMemo(() =>
|
||||
props.metadata.status === "exited" || props.metadata.status === "timeout" || props.metadata.status === "killed"
|
||||
? props.output
|
||||
: undefined,
|
||||
)
|
||||
createEffect(() => {
|
||||
if (saved() !== undefined) return
|
||||
const id = props.metadata.shellID
|
||||
const load = data.shellOutput
|
||||
if (typeof id !== "string" || !load) return
|
||||
onCleanup(followShellOutput({ id, directory: data.directory, running: pending(), load, onOutput: setStreamed }))
|
||||
})
|
||||
const output = createMemo(() =>
|
||||
stripAnsi(
|
||||
saved() ?? ((typeof props.metadata.shellID === "string" && streamed()) || props.output || ""),
|
||||
).replace(/\r\n?/g, "\n"),
|
||||
)
|
||||
return (
|
||||
<ConsoleOutput copy={command()} variant="shell">
|
||||
<span data-slot="bash-command">{command()}</span>
|
||||
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
|
||||
</ConsoleOutput>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="console"
|
||||
rail={false}
|
||||
hasContent
|
||||
compact
|
||||
allowOpenWhilePending
|
||||
trigger={(open) => (
|
||||
@@ -1696,10 +1783,7 @@ ToolRegistry.register({
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<ConsoleOutput copy={command()} variant="shell">
|
||||
<span data-slot="bash-command">{command()}</span>
|
||||
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
|
||||
</ConsoleOutput>
|
||||
<Output />
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
@@ -1902,13 +1986,7 @@ ToolRegistry.register({
|
||||
<div data-component="apply-patch-tool">
|
||||
<FileTool {...props} title={title()} count={files().length}>
|
||||
<Show when={files().length > 0}>
|
||||
<Accordion
|
||||
multiple
|
||||
data-scope="apply-patch"
|
||||
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
|
||||
value={open()}
|
||||
onChange={change}
|
||||
>
|
||||
<FileAccordionGroup>
|
||||
<Index each={files()}>
|
||||
{(file) => {
|
||||
const value = () => file().path
|
||||
@@ -1928,66 +2006,68 @@ ToolRegistry.register({
|
||||
})
|
||||
|
||||
return (
|
||||
<Accordion.Item value={value()} data-type={file().type}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="apply-patch-trigger-content">
|
||||
<div data-slot="apply-patch-file-info">
|
||||
<FileIcon node={{ path: file().path, type: "file" }} />
|
||||
<div data-slot="apply-patch-file-name-container">
|
||||
<Show when={file().path.includes("/")}>
|
||||
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file().path)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="apply-patch-filename">{getFilename(file().path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="apply-patch-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={file().type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file().type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges
|
||||
appearance="standard"
|
||||
changes={{ additions: file().additions, deletions: file().deletions }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
<FileAccordionItem
|
||||
open={active()}
|
||||
onOpenChange={(expanded) =>
|
||||
change(expanded ? [...open(), value()] : open().filter((path) => path !== value()))
|
||||
}
|
||||
type={file().type}
|
||||
header={
|
||||
<div data-slot="apply-patch-trigger-content">
|
||||
<div data-slot="apply-patch-file-info">
|
||||
<FileIcon node={{ path: file().path, type: "file" }} />
|
||||
<div data-slot="apply-patch-file-name-container">
|
||||
<Show when={file().path.includes("/")}>
|
||||
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file().path)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="apply-patch-filename">{getFilename(file().path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={props.deferContent === false || visible()}>
|
||||
<For each={file().views}>
|
||||
{(view) => (
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={view.fileDiff}
|
||||
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
onRendered={props.onContentRendered}
|
||||
<div data-slot="apply-patch-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={file().type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file().type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges
|
||||
appearance="standard"
|
||||
changes={{ additions: file().additions, deletions: file().deletions }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.deferContent === false || visible()}>
|
||||
<For each={file().views}>
|
||||
{(view) => (
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={view.fileDiff}
|
||||
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</FileAccordionItem>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</Accordion>
|
||||
</FileAccordionGroup>
|
||||
</Show>
|
||||
</FileTool>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createMarkdownBase, parseSmallMarkdown } from "./marked-base"
|
||||
|
||||
test.each([
|
||||
"Plain response.",
|
||||
"The grade cost $0.5664, and the original run is preserved.",
|
||||
"The issue is fixed: all 13 queries succeeded.",
|
||||
"Completion is 100%…",
|
||||
"https://example.com",
|
||||
"Plain response. ",
|
||||
"Plain response.\t",
|
||||
"\nPlain response.\n",
|
||||
"\u00a0Plain response.\u00a0",
|
||||
"A paragraph\r---",
|
||||
"A paragraph \rwith a hard break.",
|
||||
"We can—Code Mode would be useful.\n\nI’ll enable the read-only tools.",
|
||||
"A paragraph\nwith a soft break.",
|
||||
"A paragraph\r\n\r\nAnother paragraph.",
|
||||
"A paragraph\n\n\nAnother paragraph.\n",
|
||||
"Quotes: \"yes\" and 'no'.",
|
||||
"She said \"yes\" and 'no'.",
|
||||
"日本語の文章。",
|
||||
"Простой текст.",
|
||||
"١. العربية",
|
||||
"Hello & goodbye <world>.",
|
||||
"A paragraph \nwith a hard break.",
|
||||
"Title\n---",
|
||||
"- A list\n- Another item",
|
||||
"1. A list\n2. Another item",
|
||||
"www.example.com",
|
||||
"Bold **text** and `code`.",
|
||||
"**Done.** The judge made **13 evidence queries**.",
|
||||
"The tool is `candidate_evidence`.\n\nIt can query messages.",
|
||||
"***Strong emphasis*** and _emphasis_.",
|
||||
"A [link](https://example.com) and .",
|
||||
"A reference [link][id].\n\n[id]: https://example.com",
|
||||
"A paragraph\n# Heading",
|
||||
"### Heading",
|
||||
"####### Paragraph",
|
||||
"***",
|
||||
"_ _ _",
|
||||
"**",
|
||||
"*",
|
||||
"+ Item",
|
||||
"> Quote",
|
||||
"A paragraph\n---",
|
||||
"A paragraph\n===",
|
||||
"a | b\n--- | ---\nc | d",
|
||||
"<div>HTML</div>",
|
||||
"A paragraph\n with indentation.",
|
||||
"~~Struck~~ text and www.example.com.",
|
||||
"A **bold\nsoft break**.",
|
||||
"An escaped \\*asterisk\\*.",
|
||||
" indented code",
|
||||
"\n indented code",
|
||||
" ",
|
||||
])("preserves the Markdown parser's meaning for %j", (text) => {
|
||||
const expected = createMarkdownBase().parse(text, { async: false })
|
||||
if (text.includes("indented code")) {
|
||||
expect(parseSmallMarkdown(text)).toBeUndefined()
|
||||
return
|
||||
}
|
||||
expect(parseSmallMarkdown(text)).toBe(expected)
|
||||
})
|
||||
|
||||
test("keeps code blocks, math, and large messages on the worker path", () => {
|
||||
expect(parseSmallMarkdown("```ts\nconst value = 1\n```")).toBeUndefined()
|
||||
expect(parseSmallMarkdown("~~~ts\nconst value = 1\n~~~")).toBeUndefined()
|
||||
expect(parseSmallMarkdown("\\(x + y\\)")).toBeUndefined()
|
||||
expect(parseSmallMarkdown("$$x + y$$")).toBeUndefined()
|
||||
expect(parseSmallMarkdown("a".repeat(1025))).toBeUndefined()
|
||||
})
|
||||
@@ -16,7 +16,35 @@ let smallParser: Marked | undefined
|
||||
export function parseSmallMarkdown(text: string) {
|
||||
// Any possible KaTeX delimiter stays on the worker, including escaped ones.
|
||||
if (text.length > 1024 || text.includes("\\(") || text.includes("$$")) return
|
||||
// Ordinary prose does not need the block/inline lexer's cold regular expressions.
|
||||
// Keep every possible Markdown construct, autolink, and hard break on that lexer.
|
||||
if (
|
||||
/^[\p{L}\p{N} \t\r\n,.;:!?'"’“”()\-–—…$%]+$/u.test(text) &&
|
||||
!/(?:^|[\r\n])(?:[ \t]|\d|[-])| {2}(?:\r\n?|\n)|www\./.test(text)
|
||||
) {
|
||||
return text
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/^\n+|\n+$/g, "")
|
||||
.split(/\n{2,}/)
|
||||
.filter(Boolean)
|
||||
.map((paragraph) => `<p>${paragraph.replace(/["']/g, (value) => (value === '"' ? """ : "'"))}</p>\n`)
|
||||
.join("")
|
||||
}
|
||||
const parser = (smallParser ??= createMarkdownBase())
|
||||
const paragraphs = text.replace(/\r\n?/g, "\n")
|
||||
// Inline formatting in ordinary paragraphs does not need the block lexer.
|
||||
// Any possible block opener, HTML, table, or reference definition stays on it.
|
||||
if (
|
||||
!/[<|]/.test(paragraphs) &&
|
||||
!/^(?:[ \t>\d\[\-=]|#{1,6}(?:[ \t]|$)|[`~]{3}|[+*](?:[ \t]|$)|(?:[*_][ \t]*){3,}$)/m.test(paragraphs)
|
||||
) {
|
||||
return paragraphs
|
||||
.replace(/^\n+|\n+$/g, "")
|
||||
.split(/\n{2,}/)
|
||||
.filter(Boolean)
|
||||
.map((paragraph) => `<p>${parser.parseInline(paragraph, { async: false })}</p>\n`)
|
||||
.join("")
|
||||
}
|
||||
const tokens = parser.lexer(text)
|
||||
let code = false
|
||||
parser.walkTokens(tokens, (token) => {
|
||||
|
||||
Reference in New Issue
Block a user