mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-06 17:06:25 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff09490bcc | ||
|
|
b2cecc6350 | ||
|
|
63a1074c6c | ||
|
|
31ee07e3ae | ||
|
|
370b9965d3 | ||
|
|
8ea99ef9ad |
@@ -43,6 +43,7 @@ The suite contains:
|
||||
- home-session click timing split between content and titlebar-tab paint
|
||||
- single-session tab close timing through stable home restoration
|
||||
- cached session repaint and mutation tracing
|
||||
- large-session search scan, first-result reveal, and highlight stabilization
|
||||
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
|
||||
- retained renderer heap with a large model catalog across repeated session navigation
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { buildInitialStreamEvent, setupTimelineBenchmark, textPartID } from "./session-timeline-benchmark.fixture"
|
||||
import {
|
||||
collectTimelineSearchMetrics,
|
||||
installTimelineSearchProbe,
|
||||
waitForStableTimelineSearch,
|
||||
} from "./session-timeline-search-probe"
|
||||
|
||||
benchmark("searches a large virtualized session and reveals the first result", async ({ page, report }) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
const historyTurns = Number(process.env.TIMELINE_SEARCH_HISTORY_TURNS ?? 320)
|
||||
const completionTimeout = Number(process.env.TIMELINE_SEARCH_COMPLETION_TIMEOUT_MS ?? 60_000)
|
||||
const query = "Historical prompt"
|
||||
const targetPartID = "msg_0000_0000_a_user:text:0"
|
||||
const expectedCounter = `1/${historyTurns}`
|
||||
const fixture = await setupTimelineBenchmark(page, {
|
||||
historyTurns,
|
||||
eventBatch: 1,
|
||||
})
|
||||
|
||||
fixture.transport.enqueue(buildInitialStreamEvent(1))
|
||||
await expect(fixture.text).toContainText("Implementation plan")
|
||||
await fixture.scrollToBottom()
|
||||
await fixture.waitForStableGeometry()
|
||||
// Chromium reserves the physical shortcut for its native find overlay, so request the same controller path directly.
|
||||
await page.evaluate(() => document.dispatchEvent(new Event("opencode:timeline-search-open")))
|
||||
|
||||
const search = page.locator('[data-component="timeline-search-bar"]')
|
||||
const field = search.getByRole("searchbox", { name: "Find..." })
|
||||
const count = search.locator('[data-slot="timeline-search-count"]')
|
||||
const target = page.locator(`[data-timeline-part-id="${targetPartID}"]`)
|
||||
await expect(field).toBeVisible()
|
||||
await expect(field).toBeFocused()
|
||||
await installTimelineSearchProbe(page, { targetPartID })
|
||||
|
||||
await field.fill(query)
|
||||
await expect(count).toHaveText(expectedCounter)
|
||||
await expect(target).toBeVisible({ timeout: completionTimeout })
|
||||
await waitForStableTimelineSearch(page, { counter: expectedCounter, targetPartID, timeout: completionTimeout })
|
||||
const metrics = await collectTimelineSearchMetrics(page, { counter: expectedCounter, targetPartID })
|
||||
|
||||
expect(metrics.summary.handlerDurationMs).toBeDefined()
|
||||
expect(metrics.summary.firstCountObservedMs).toBeDefined()
|
||||
expect(metrics.summary.firstTargetVisibleMs).toBeDefined()
|
||||
expect(metrics.summary.firstActiveHighlightObservedMs).toBeDefined()
|
||||
expect(metrics.summary.stableResultObservedMs).toBeDefined()
|
||||
expect(metrics.summary.activeHighlightRanges).toBe(1)
|
||||
report(metrics, { historyTurns, query, expectedMatches: historyTurns })
|
||||
|
||||
// Check navigation and the V2 assistant content IDs outside the measured interval.
|
||||
await field.press("Enter")
|
||||
await expect(count).toHaveText(`2/${historyTurns}`)
|
||||
await field.press("Shift+Enter")
|
||||
await expect(count).toHaveText(expectedCounter)
|
||||
await field.fill("Implementation plan")
|
||||
await expect(count).toHaveText("1/1")
|
||||
await expect(fixture.text).toBeInViewport()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const range = [...(CSS.highlights.get("timeline-search-hit-active") ?? [])][0]
|
||||
return range?.startContainer.parentElement?.closest<HTMLElement>("[data-timeline-part-id]")?.dataset
|
||||
.timelinePartId
|
||||
}),
|
||||
)
|
||||
.toBe(textPartID)
|
||||
await field.press("Escape")
|
||||
await expect(search).toBeHidden()
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
export type TimelineSearchSample = {
|
||||
observedAtMs: number
|
||||
counter: string
|
||||
targetMounted: boolean
|
||||
targetVisible: boolean
|
||||
targetTopPx?: number
|
||||
activeRanges: number
|
||||
activePartID?: string
|
||||
activeVisible: boolean
|
||||
scrollTopPx: number
|
||||
}
|
||||
|
||||
type TimelineSearchProbe = {
|
||||
samples: TimelineSearchSample[]
|
||||
handlerDurationMs?: number
|
||||
initialScrollTopPx: number
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export async function installTimelineSearchProbe(page: Page, input: { targetPartID: string }) {
|
||||
await page.evaluate(({ targetPartID }) => {
|
||||
const search = document.querySelector<HTMLElement>('[data-component="timeline-search-bar"]')
|
||||
const field = search?.querySelector<HTMLInputElement>('[data-slot="text-input-v2-input"]')
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (!search || !field || !root) throw new Error("missing timeline search benchmark nodes")
|
||||
|
||||
const samples: TimelineSearchSample[] = []
|
||||
const initialScrollTopPx = root.scrollTop
|
||||
let startedAt: number | undefined
|
||||
let handlerDurationMs: number | undefined
|
||||
let frame: number | undefined
|
||||
let running = true
|
||||
|
||||
const visibleInRoot = (rect: DOMRect) => {
|
||||
const viewport = root.getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom
|
||||
}
|
||||
const sample = () => {
|
||||
if (!running || startedAt === undefined) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
setTimeout(() => {
|
||||
if (!running || startedAt === undefined) return
|
||||
const target = root.querySelector<HTMLElement>(`[data-timeline-part-id="${targetPartID}"]`)
|
||||
const targetRect = target?.getBoundingClientRect()
|
||||
const highlight = CSS.highlights.get("timeline-search-hit-active")
|
||||
const ranges = highlight ? [...highlight] : []
|
||||
const active = ranges.find((range): range is Range => range instanceof Range)
|
||||
const activeRect = active?.getBoundingClientRect()
|
||||
const activeElement =
|
||||
active?.startContainer instanceof Element ? active.startContainer : active?.startContainer.parentElement
|
||||
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - startedAt,
|
||||
counter:
|
||||
search.querySelector<HTMLElement>('[data-slot="timeline-search-count"]')?.textContent?.trim() ?? "",
|
||||
targetMounted: !!target,
|
||||
targetVisible: !!targetRect && visibleInRoot(targetRect),
|
||||
targetTopPx: targetRect?.top,
|
||||
activeRanges: ranges.length,
|
||||
activePartID: activeElement?.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId,
|
||||
activeVisible: !!activeRect && visibleInRoot(activeRect),
|
||||
scrollTopPx: root.scrollTop,
|
||||
})
|
||||
sample()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
const onInputCapture = (event: Event) => {
|
||||
if (event.target !== field || startedAt !== undefined) return
|
||||
startedAt = performance.now()
|
||||
sample()
|
||||
}
|
||||
const onInput = (event: Event) => {
|
||||
if (event.target !== field || startedAt === undefined || handlerDurationMs !== undefined) return
|
||||
handlerDurationMs = performance.now() - startedAt
|
||||
}
|
||||
document.addEventListener("input", onInputCapture, { capture: true })
|
||||
document.addEventListener("input", onInput)
|
||||
;(window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark = {
|
||||
samples,
|
||||
initialScrollTopPx,
|
||||
get handlerDurationMs() {
|
||||
return handlerDurationMs
|
||||
},
|
||||
stop: () => {
|
||||
running = false
|
||||
document.removeEventListener("input", onInputCapture, { capture: true })
|
||||
document.removeEventListener("input", onInput)
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
},
|
||||
}
|
||||
}, input)
|
||||
}
|
||||
|
||||
export async function waitForStableTimelineSearch(
|
||||
page: Page,
|
||||
input: { counter: string; targetPartID: string; timeout: number },
|
||||
) {
|
||||
await page.waitForFunction(
|
||||
({ counter, targetPartID }) => {
|
||||
const samples = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark
|
||||
?.samples
|
||||
if (!samples) return false
|
||||
return samples.some((_, index) => {
|
||||
const stable = samples.slice(index, index + 3)
|
||||
if (stable.length !== 3) return false
|
||||
return stable.every(
|
||||
(sample, sampleIndex) =>
|
||||
sample.counter === counter &&
|
||||
sample.targetVisible &&
|
||||
sample.activeRanges === 1 &&
|
||||
sample.activePartID === targetPartID &&
|
||||
sample.activeVisible &&
|
||||
(sampleIndex === 0 ||
|
||||
(Math.abs(sample.scrollTopPx - stable[sampleIndex - 1]!.scrollTopPx) <= 1 &&
|
||||
Math.abs((sample.targetTopPx ?? Infinity) - (stable[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)),
|
||||
)
|
||||
})
|
||||
},
|
||||
{ counter: input.counter, targetPartID: input.targetPartID },
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
}
|
||||
|
||||
export async function collectTimelineSearchMetrics(page: Page, input: { counter: string; targetPartID: string }) {
|
||||
const result = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark
|
||||
if (!probe) throw new Error("missing timeline search benchmark probe")
|
||||
probe.stop()
|
||||
return {
|
||||
samples: probe.samples,
|
||||
handlerDurationMs: probe.handlerDurationMs,
|
||||
initialScrollTopPx: probe.initialScrollTopPx,
|
||||
}
|
||||
})
|
||||
const first = (predicate: (sample: TimelineSearchSample) => boolean) => result.samples.find(predicate)?.observedAtMs
|
||||
const stable = result.samples.findIndex((_, index) => {
|
||||
const samples = result.samples.slice(index, index + 3)
|
||||
if (samples.length !== 3) return false
|
||||
return samples.every(
|
||||
(sample, sampleIndex) =>
|
||||
sample.counter === input.counter &&
|
||||
sample.targetVisible &&
|
||||
sample.activeRanges === 1 &&
|
||||
sample.activePartID === input.targetPartID &&
|
||||
sample.activeVisible &&
|
||||
(sampleIndex === 0 ||
|
||||
(Math.abs(sample.scrollTopPx - samples[sampleIndex - 1]!.scrollTopPx) <= 1 &&
|
||||
Math.abs((sample.targetTopPx ?? Infinity) - (samples[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)),
|
||||
)
|
||||
})
|
||||
const final = result.samples.at(-1)
|
||||
|
||||
return {
|
||||
summary: {
|
||||
handlerDurationMs: result.handlerDurationMs,
|
||||
firstCountObservedMs: first((sample) => sample.counter === input.counter),
|
||||
firstTargetMountedMs: first((sample) => sample.targetMounted),
|
||||
firstTargetVisibleMs: first((sample) => sample.targetVisible),
|
||||
firstActiveHighlightObservedMs: first(
|
||||
(sample) => sample.activeRanges === 1 && sample.activePartID === input.targetPartID && sample.activeVisible,
|
||||
),
|
||||
stableResultObservedMs: stable >= 0 ? result.samples[stable + 2]?.observedAtMs : undefined,
|
||||
initialScrollTopPx: result.initialScrollTopPx,
|
||||
finalScrollTopPx: final?.scrollTopPx,
|
||||
scrollDistancePx: final === undefined ? undefined : Math.abs(result.initialScrollTopPx - final.scrollTopPx),
|
||||
activeHighlightRanges: final?.activeRanges,
|
||||
},
|
||||
samples: result.samples,
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
::highlight(timeline-search-hit) {
|
||||
background-color: color-mix(in srgb, var(--v2-icon-icon-accent) 28%, transparent);
|
||||
}
|
||||
|
||||
::highlight(timeline-search-hit-active) {
|
||||
background-color: var(--v2-icon-icon-accent);
|
||||
color: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
[data-component="getting-started"] {
|
||||
container-type: inline-size;
|
||||
container-name: getting-started;
|
||||
|
||||
@@ -769,6 +769,8 @@ export const dict = {
|
||||
|
||||
"session.header.search.placeholder": "Search {{project}}",
|
||||
"session.header.searchFiles": "Search files",
|
||||
"session.search.placeholder": "Find...",
|
||||
"session.search.noResults": "No matches",
|
||||
"session.header.openIn": "Open in",
|
||||
"session.header.open.action": "Open {{app}}",
|
||||
"session.header.open.ariaLabel": "Open in {{app}}",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRequestQueue } from "./request-queue"
|
||||
import { createRequestQueue, isSlowRequest } from "./request-queue"
|
||||
|
||||
function setup(input?: { limit?: number; stallMs?: number; headersTimeoutMs?: number }) {
|
||||
function setup(input?: { limit?: number; slowLimit?: number; stallMs?: number; headersTimeoutMs?: number }) {
|
||||
const pending: Array<{ url: string; signal: AbortSignal; resolve: () => void }> = []
|
||||
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
|
||||
let clock = 0
|
||||
const queue = createRequestQueue({
|
||||
limit: input?.limit ?? 2,
|
||||
slowLimit: input?.slowLimit,
|
||||
stallMs: input?.stallMs,
|
||||
headersTimeoutMs: input?.headersTimeoutMs,
|
||||
now: () => clock,
|
||||
@@ -40,6 +41,36 @@ describe("createRequestQueue", () => {
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("slow endpoints hold at most their share of slots so small reads go first", async () => {
|
||||
const input = setup({ limit: 4, slowLimit: 2 })
|
||||
const paths = ["/api/vcs?location[directory]=%2Fa", "/api/vcs/diff?location[directory]=%2Fa", "/api/worktree", "/api/session/ses_1"]
|
||||
const responses = paths.map((path) => input.queue.fetch(`http://server${path}`))
|
||||
await input.settle()
|
||||
const started = () => input.pending.map((item) => new URL(item.url).pathname)
|
||||
// Two slow requests fill the slow share; the worktree read waits while the session read jumps ahead.
|
||||
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1"])
|
||||
expect(input.queue.inflight()).toBe(3)
|
||||
expect(input.queue.queued()).toBe(1)
|
||||
// A fast request finishing does not free a slow slot.
|
||||
input.pending[2]!.resolve()
|
||||
await input.settle()
|
||||
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1"])
|
||||
input.pending[0]!.resolve()
|
||||
await input.settle()
|
||||
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1", "/api/worktree"])
|
||||
input.pending.forEach((item) => item.resolve())
|
||||
await Promise.all(responses)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("classifies git and worktree endpoints as slow", () => {
|
||||
expect(isSlowRequest("/api/vcs")).toBe(true)
|
||||
expect(isSlowRequest("/api/vcs/branches")).toBe(true)
|
||||
expect(isSlowRequest("/api/worktree")).toBe(true)
|
||||
expect(isSlowRequest("/api/vcsx")).toBe(false)
|
||||
expect(isSlowRequest("/api/session")).toBe(false)
|
||||
})
|
||||
|
||||
test("never counts the event stream against the budget", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
void input.queue.fetch("http://server/api/session")
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
type Entry = { method: string; url: string; at: number }
|
||||
type Entry = { method: string; url: string; at: number; slow: boolean }
|
||||
|
||||
// Chromium allows six connections per origin. The event stream holds one for the life of the
|
||||
// connection and health probes use their own fetch, so the app's API calls stay below that or
|
||||
// a burst stalls probes and user actions inside the browser where nothing can observe it.
|
||||
export const requestQueueLimit = 4
|
||||
|
||||
// Endpoints that shell out to git or walk the filesystem take seconds on a large repository. They
|
||||
// may hold at most this many slots, so a session mount's small reads never queue behind them.
|
||||
export const requestQueueSlowLimit = 2
|
||||
export const slowRequestPaths = ["/api/vcs", "/api/worktree"]
|
||||
|
||||
// A mount legitimately fires a dozen requests at once; only a request that has waited this long
|
||||
// for a slot indicates the server is not keeping up.
|
||||
export const requestStallMs = 2_000
|
||||
@@ -14,15 +19,21 @@ export const requestStallMs = 2_000
|
||||
// instead of wedging every later API call; the body may still stream for as long as it needs.
|
||||
export const requestHeadersTimeoutMs = 60_000
|
||||
|
||||
export function isSlowRequest(pathname: string) {
|
||||
return slowRequestPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))
|
||||
}
|
||||
|
||||
export function createRequestQueue(input: {
|
||||
fetch: typeof globalThis.fetch
|
||||
limit?: number
|
||||
slowLimit?: number
|
||||
stallMs?: number
|
||||
headersTimeoutMs?: number
|
||||
log?: (message: string, data: Record<string, unknown>) => void
|
||||
now?: () => number
|
||||
}) {
|
||||
const limit = input.limit ?? requestQueueLimit
|
||||
const slowLimit = input.slowLimit ?? requestQueueSlowLimit
|
||||
const stallMs = input.stallMs ?? requestStallMs
|
||||
const headersTimeoutMs = input.headersTimeoutMs ?? requestHeadersTimeoutMs
|
||||
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
|
||||
@@ -50,9 +61,17 @@ export function createRequestQueue(input: {
|
||||
}
|
||||
watcher = setTimeout(watch, stallMs)
|
||||
}
|
||||
const canStart = (entry: Entry) => {
|
||||
if (inflight.size >= limit) return false
|
||||
if (!entry.slow) return true
|
||||
return [...inflight].filter((item) => item.slow).length < slowLimit
|
||||
}
|
||||
// FIFO, except a slow request waits its turn behind faster ones while the slow slots are full.
|
||||
const release = (entry: Entry) => {
|
||||
inflight.delete(entry)
|
||||
waiting.shift()?.start()
|
||||
const index = waiting.findIndex((item) => canStart(item.entry))
|
||||
if (index === -1) return
|
||||
waiting.splice(index, 1)[0]?.start()
|
||||
}
|
||||
const acquire = (entry: Entry) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -61,7 +80,7 @@ export function createRequestQueue(input: {
|
||||
inflight.add(entry)
|
||||
resolve()
|
||||
}
|
||||
if (inflight.size < limit) return start()
|
||||
if (canStart(entry)) return start()
|
||||
waiting.push({ entry, start })
|
||||
watcher ??= setTimeout(watch, stallMs)
|
||||
})
|
||||
@@ -69,9 +88,10 @@ export function createRequestQueue(input: {
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (resource: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(resource, init)
|
||||
const pathname = new URL(request.url).pathname
|
||||
// The event stream is long-lived; never count it against the request budget.
|
||||
if (new URL(request.url).pathname === "/api/event") return base(request)
|
||||
const entry = { method: request.method, url: request.url, at: now() }
|
||||
if (pathname === "/api/event") return base(request)
|
||||
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
|
||||
await acquire(entry)
|
||||
if (request.signal.aborted) {
|
||||
release(entry)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createConnectionSync } from "./connection"
|
||||
import { createConnectionSync, reconnectOrder } from "./connection"
|
||||
|
||||
test("invalidates disconnected data and synchronizes after the handshake", () => {
|
||||
const calls: string[] = []
|
||||
@@ -19,3 +19,9 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
test("held directories refresh before the rest, otherwise keeping their order", () => {
|
||||
const held = new Set(["/b", "/d"])
|
||||
expect(reconnectOrder(["/a", "/b", "/c", "/d"], (directory) => held.has(directory))).toEqual(["/b", "/d", "/a", "/c"])
|
||||
expect(reconnectOrder(["/a", "/c"], (directory) => held.has(directory))).toEqual(["/a", "/c"])
|
||||
})
|
||||
|
||||
@@ -20,3 +20,8 @@ export function createConnectionSync(input: {
|
||||
|
||||
return { handleEvent }
|
||||
}
|
||||
|
||||
// Directories a mounted view holds refresh first; the rest keep their existing order behind them.
|
||||
export function reconnectOrder(directories: string[], held: (directory: string) => boolean) {
|
||||
return [...directories.filter(held), ...directories.filter((directory) => !held(directory))]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { persisted } from "@/runtime/persistence/storage"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createConnectionSync } from "./server-sync/connection"
|
||||
import { createConnectionSync, reconnectOrder } from "./server-sync/connection"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
|
||||
@@ -162,12 +162,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
},
|
||||
connected: (info) => {
|
||||
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
||||
Object.keys(children.children)
|
||||
.filter(children.active)
|
||||
.forEach((directory) => {
|
||||
queue.push(directory)
|
||||
void data.location.sync({ directory }).catch(() => undefined)
|
||||
})
|
||||
// The refresh queue re-syncs two directories at a time, held ones first. Syncing every active
|
||||
// directory here as well sent the whole catalog fan-out for all of them at once.
|
||||
reconnectOrder(Object.keys(children.children).filter(children.active), children.pinned).forEach(
|
||||
(directory) => queue.push(directory),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import { createSessionReview } from "./review/model"
|
||||
import { SessionDesktopReview, SessionMobileReview, SessionMobileViewTabs } from "./review/view"
|
||||
import { SessionContextTab } from "./files/session-context-tab"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { createTimelineSearchController } from "./timeline/search-controller"
|
||||
import { TimelineSearchBar } from "./timeline/search-bar"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
@@ -47,6 +49,12 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
const isDesktop = session.isDesktop
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const timelineSearch = createTimelineSearchController({
|
||||
sessionID: session.identity.sessionID,
|
||||
scrollRef: timeline.scroller,
|
||||
revealMessage: timeline.actions.revealMessage,
|
||||
pauseAutoScroll: timeline.view.unpin,
|
||||
})
|
||||
const messagesReady = timeline.ready
|
||||
const [store, setStore] = createStore({
|
||||
deferRender: false,
|
||||
@@ -262,6 +270,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
setScrollToEnd={timeline.view.setScrollToEnd}
|
||||
search={<TimelineSearchBar controller={timelineSearch} />}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -24,6 +24,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
pinned: true,
|
||||
},
|
||||
refs: {
|
||||
scroller: undefined as HTMLDivElement | undefined,
|
||||
content: undefined as HTMLDivElement | undefined,
|
||||
dock: undefined as HTMLDivElement | undefined,
|
||||
},
|
||||
@@ -38,7 +39,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
}
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let dockHeight = 0
|
||||
let revealMessage = (_id: string) => {}
|
||||
let revealMessage = (_id: string, _partID?: string) => {}
|
||||
let scrollToEnd = () => {}
|
||||
let scrollMark = 0
|
||||
let messageMark = 0
|
||||
@@ -157,6 +158,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
}
|
||||
const setScrollRef = (element: HTMLDivElement | undefined) => {
|
||||
scroller = element
|
||||
setState("refs", "scroller", element)
|
||||
if (!element) return
|
||||
scheduleScrollState(element)
|
||||
fill()
|
||||
@@ -290,6 +292,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
return {
|
||||
actions: {
|
||||
navigateMessage,
|
||||
revealMessage: (id: string, partID?: string) => revealMessage(id, partID),
|
||||
resume,
|
||||
setActiveMessage,
|
||||
},
|
||||
@@ -297,7 +300,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
resource: timeline.resource,
|
||||
ready: timeline.ready,
|
||||
scroll: state.scroll,
|
||||
scroller: () => scroller,
|
||||
scroller: () => state.refs.scroller,
|
||||
view: {
|
||||
anchor,
|
||||
markUserScroll,
|
||||
@@ -313,7 +316,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
setDockRef: (element: HTMLDivElement | undefined) => {
|
||||
setState("refs", "dock", element)
|
||||
},
|
||||
setRevealMessage: (reveal: (id: string) => void) => {
|
||||
setRevealMessage: (reveal: (id: string, partID?: string) => void) => {
|
||||
revealMessage = reveal
|
||||
},
|
||||
setScrollRef,
|
||||
|
||||
@@ -353,8 +353,9 @@ type MessageTimelineProps = {
|
||||
workspaceMoveEligible: boolean
|
||||
onSummaryOpenChange: (open: boolean) => void
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
search?: JSX.Element
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: MessageTimelineProps) {
|
||||
@@ -789,6 +790,7 @@ function MessageTimelineView(
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{props.search}
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID() && project()}>
|
||||
{(project) => (
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[data-component="timeline-search-bar"] [data-component="text-input-v2"] {
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: inset 0 0 0 1px var(--v2-border-border-base);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-component="timeline-search-bar"] [data-component="text-input-v2"]:hover {
|
||||
background: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
[data-component="timeline-search-bar"] [data-component="text-input-v2"]:focus-within {
|
||||
box-shadow: inset 0 0 0 1px var(--v2-border-border-focus);
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import "@opencode-ai/ui/text-input.css"
|
||||
import { Show } from "solid-js"
|
||||
import type { TimelineSearchController } from "./search-controller"
|
||||
import "./search-bar.css"
|
||||
|
||||
export function TimelineSearchBar(props: { controller: TimelineSearchController }) {
|
||||
const c = props.controller
|
||||
|
||||
return (
|
||||
<Show when={c.visible()}>
|
||||
<div data-component="timeline-search-bar" class="h-7 w-[200px] max-w-[50vw] shrink-0">
|
||||
<div data-component="text-input-v2" data-appearance="base" data-leading-icon class="!h-7 !w-full max-w-full">
|
||||
<div data-slot="text-input-v2-value">
|
||||
<span data-slot="text-input-v2-leading-icon">
|
||||
<Icon name="magnifying-glass" size="small" />
|
||||
</span>
|
||||
<input
|
||||
ref={c.element.setInput}
|
||||
data-slot="text-input-v2-input"
|
||||
type="search"
|
||||
value={c.query.value()}
|
||||
placeholder={c.query.placeholder()}
|
||||
aria-label={c.query.placeholder()}
|
||||
onInput={(event) => c.query.setValue(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
c.query.close()
|
||||
return
|
||||
}
|
||||
if (event.altKey || event.metaKey || event.ctrlKey) return
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
c.result.move(event.shiftKey ? -1 : 1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowDown" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
c.result.move(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
c.result.move(-1)
|
||||
return
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Show when={c.query.value()}>
|
||||
<span
|
||||
data-slot="timeline-search-count"
|
||||
class="shrink-0 self-center text-[11px] text-v2-text-text-muted [font-weight:440] tabular-nums"
|
||||
>
|
||||
{c.result.count() > 0 ? c.result.activeIndex() + 1 : 0}/{c.result.count()}
|
||||
</span>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="-me-1 flex size-5 shrink-0 self-center items-center justify-center rounded-[2px] border-0 bg-transparent p-0 text-v2-icon-icon-muted outline outline-1 outline-transparent hover:bg-v2-overlay-simple-overlay-hover active:bg-v2-overlay-simple-overlay-pressed focus-visible:outline-v2-border-border-focus"
|
||||
aria-label={c.query.placeholder()}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => c.query.close()}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { Timeline } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export type TimelineSearchMatch = {
|
||||
messageID: string
|
||||
role: "user" | "assistant"
|
||||
revealID: string
|
||||
partID: string
|
||||
occurrence: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const HIGHLIGHT_HIT = "timeline-search-hit"
|
||||
const HIGHLIGHT_ACTIVE = "timeline-search-hit-active"
|
||||
const TEXT_SELECTORS = '[data-slot="text-part-body"], [data-slot="user-message-text"]'
|
||||
|
||||
function supportsHighlights() {
|
||||
return typeof CSS !== "undefined" && typeof CSS.highlights === "object" && CSS.highlights !== null
|
||||
}
|
||||
|
||||
function clearHighlights() {
|
||||
if (!supportsHighlights()) return
|
||||
CSS.highlights.delete(HIGHLIGHT_HIT)
|
||||
CSS.highlights.delete(HIGHLIGHT_ACTIVE)
|
||||
}
|
||||
|
||||
function collectRanges(
|
||||
root: HTMLElement,
|
||||
query: string,
|
||||
activePartID: string | undefined,
|
||||
activeOccurrence: number | undefined,
|
||||
) {
|
||||
const hits: Range[] = []
|
||||
const active: Range[] = []
|
||||
const lower = query.toLowerCase()
|
||||
const bodies = root.querySelectorAll<HTMLElement>(TEXT_SELECTORS)
|
||||
for (const body of bodies) {
|
||||
const part = body.closest("[data-timeline-part-id]")
|
||||
const partID = part?.getAttribute("data-timeline-part-id")
|
||||
const isActivePart = activePartID !== undefined && partID === activePartID
|
||||
let occurrenceInPart = 0
|
||||
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT)
|
||||
let node = walker.nextNode() as Text | null
|
||||
while (node) {
|
||||
const value = node.nodeValue ?? ""
|
||||
const lowerValue = value.toLowerCase()
|
||||
let from = 0
|
||||
let at = lowerValue.indexOf(lower, from)
|
||||
while (at !== -1) {
|
||||
const range = document.createRange()
|
||||
range.setStart(node, at)
|
||||
range.setEnd(node, at + query.length)
|
||||
if (isActivePart && activeOccurrence === occurrenceInPart) active.push(range)
|
||||
else hits.push(range)
|
||||
occurrenceInPart += 1
|
||||
from = at + query.length
|
||||
at = lowerValue.indexOf(lower, from)
|
||||
}
|
||||
node = walker.nextNode() as Text | null
|
||||
}
|
||||
}
|
||||
return { hits, active }
|
||||
}
|
||||
|
||||
function applyHighlights(
|
||||
root: HTMLElement,
|
||||
query: string,
|
||||
activePartID: string | undefined,
|
||||
activeOccurrence: number | undefined,
|
||||
) {
|
||||
if (!supportsHighlights()) return
|
||||
const { hits, active } = collectRanges(root, query, activePartID, activeOccurrence)
|
||||
CSS.highlights.set(HIGHLIGHT_HIT, new Highlight(...hits))
|
||||
CSS.highlights.set(HIGHLIGHT_ACTIVE, new Highlight(...active))
|
||||
}
|
||||
|
||||
export function createTimelineSearchController(input: {
|
||||
sessionID: () => string | undefined
|
||||
scrollRef: () => HTMLDivElement | undefined
|
||||
revealMessage: (id: string, partID?: string) => void
|
||||
pauseAutoScroll: () => void
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const [state, setState] = createStore({ value: "", active: 0, visible: false })
|
||||
const [focusTick, setFocusTick] = createSignal(0)
|
||||
let inputEl: HTMLInputElement | undefined
|
||||
|
||||
const query = createMemo(() => state.value.trim().toLowerCase())
|
||||
|
||||
const matches = createMemo<TimelineSearchMatch[]>(() => {
|
||||
const value = query()
|
||||
if (!value) return []
|
||||
const sessionID = input.sessionID()
|
||||
if (!sessionID) return []
|
||||
const messages = data.session.message.list(sessionID)
|
||||
const result: TimelineSearchMatch[] = []
|
||||
let revealID = ""
|
||||
for (const message of messages) {
|
||||
if (message.type === "user" || message.type === "shell") revealID = message.id
|
||||
if (message.type !== "user" && message.type !== "assistant") continue
|
||||
const visibleParts =
|
||||
message.type === "user"
|
||||
? [{ id: `${message.id}:text:0`, content: { type: "text" as const, text: message.text } }]
|
||||
: Timeline.contentEntries(message)
|
||||
for (const textPart of visibleParts) {
|
||||
if (textPart.content.type !== "text") continue
|
||||
const text = textPart.content.text
|
||||
if (!text) continue
|
||||
const lower = text.toLowerCase()
|
||||
let from = 0
|
||||
let occurrence = 0
|
||||
let at = lower.indexOf(value, from)
|
||||
while (at !== -1) {
|
||||
result.push({
|
||||
messageID: message.id,
|
||||
role: message.type,
|
||||
revealID,
|
||||
partID: textPart.id,
|
||||
occurrence,
|
||||
text,
|
||||
})
|
||||
occurrence += 1
|
||||
from = at + value.length
|
||||
at = lower.indexOf(value, from)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const activeIndex = createMemo(() => {
|
||||
const list = matches()
|
||||
if (list.length === 0) return 0
|
||||
if (state.active >= list.length) return 0
|
||||
if (state.active < 0) return 0
|
||||
return state.active
|
||||
})
|
||||
|
||||
const activePartID = createMemo(() => matches()[activeIndex()]?.partID)
|
||||
const activeOccurrence = createMemo(() => matches()[activeIndex()]?.occurrence)
|
||||
|
||||
createEffect(() => {
|
||||
const root = input.scrollRef()
|
||||
const q = query()
|
||||
if (!root || !state.visible || !q) {
|
||||
clearHighlights()
|
||||
return
|
||||
}
|
||||
applyHighlights(root, q, activePartID(), activeOccurrence())
|
||||
let frame: number | undefined
|
||||
const scheduleApply = () => {
|
||||
if (frame !== undefined) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
if (!state.visible) return
|
||||
applyHighlights(root, query(), activePartID(), activeOccurrence())
|
||||
})
|
||||
}
|
||||
const observer = new MutationObserver(scheduleApply)
|
||||
observer.observe(root, { childList: true, subtree: true, characterData: true })
|
||||
onCleanup(() => {
|
||||
observer.disconnect()
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
clearHighlights()
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(focusTick, () => {
|
||||
if (!state.visible) return
|
||||
requestAnimationFrame(() => {
|
||||
inputEl?.focus()
|
||||
inputEl?.select()
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
command.register("session.search", () => [
|
||||
{
|
||||
id: "session.search",
|
||||
title: language.t("session.search.placeholder"),
|
||||
keybind: "mod+f",
|
||||
hidden: true,
|
||||
onSelect: () => open(),
|
||||
},
|
||||
])
|
||||
const onOpenRequest = () => open()
|
||||
document.addEventListener("opencode:timeline-search-open", onOpenRequest)
|
||||
onCleanup(() => document.removeEventListener("opencode:timeline-search-open", onOpenRequest))
|
||||
|
||||
function open() {
|
||||
setState("visible", true)
|
||||
setFocusTick((t) => t + 1)
|
||||
}
|
||||
|
||||
function close() {
|
||||
setState({ value: "", active: 0, visible: false })
|
||||
inputEl?.blur()
|
||||
}
|
||||
|
||||
function setValue(value: string) {
|
||||
setState("value", value)
|
||||
const list = matches()
|
||||
const match = list[0]
|
||||
if (!value.trim() || !match) {
|
||||
setState("active", 0)
|
||||
return
|
||||
}
|
||||
setState("active", 0)
|
||||
input.pauseAutoScroll()
|
||||
input.revealMessage(match.revealID, match.partID)
|
||||
scrollToMatch(match)
|
||||
}
|
||||
|
||||
function scrollToMatch(match: TimelineSearchMatch) {
|
||||
let attempts = 0
|
||||
const seek = () => {
|
||||
if (!state.visible) return
|
||||
const root = input.scrollRef()
|
||||
if (!root) return
|
||||
const { active } = collectRanges(root, query(), match.partID, match.occurrence)
|
||||
if (active.length === 0) {
|
||||
if (attempts++ < 12) requestAnimationFrame(seek)
|
||||
return
|
||||
}
|
||||
const rect = active[0].getBoundingClientRect()
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const sticky = root.querySelector("[data-session-title]")
|
||||
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
|
||||
const top = rect.top - rootRect.top + root.scrollTop - inset - (rootRect.height - rect.height) / 2
|
||||
root.scrollTo({ top: Math.max(0, top), behavior: "auto" })
|
||||
}
|
||||
requestAnimationFrame(seek)
|
||||
}
|
||||
|
||||
function move(delta: number) {
|
||||
const list = matches()
|
||||
if (list.length === 0) return
|
||||
const next = (activeIndex() + delta + list.length) % list.length
|
||||
setState("active", next)
|
||||
const match = list[next]
|
||||
if (!match) return
|
||||
input.pauseAutoScroll()
|
||||
input.revealMessage(match.revealID, match.partID)
|
||||
scrollToMatch(match)
|
||||
}
|
||||
|
||||
return {
|
||||
visible: () => state.visible,
|
||||
query: {
|
||||
value: () => state.value,
|
||||
placeholder: () => language.t("session.search.placeholder"),
|
||||
noResults: () => language.t("session.search.noResults"),
|
||||
open,
|
||||
close,
|
||||
setValue,
|
||||
},
|
||||
result: {
|
||||
activeIndex,
|
||||
count: () => matches().length,
|
||||
move,
|
||||
},
|
||||
element: {
|
||||
setInput: (element: HTMLInputElement) => (inputEl = element),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type TimelineSearchController = ReturnType<typeof createTimelineSearchController>
|
||||
@@ -69,7 +69,7 @@ type Input = {
|
||||
row: TimelineRow.TimelineRow,
|
||||
disclosure: Readonly<Record<string, boolean | undefined>>,
|
||||
) => boolean
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
}
|
||||
|
||||
@@ -271,8 +271,13 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
|
||||
|
||||
createEffect(() => {
|
||||
input.setRevealMessage?.((id) => {
|
||||
const index = input.projection.messageRowIndex().get(id)
|
||||
input.setRevealMessage?.((id, partID) => {
|
||||
const partIndex = partID
|
||||
? rows().findIndex(
|
||||
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
|
||||
)
|
||||
: -1
|
||||
const index = partIndex >= 0 ? partIndex : input.projection.messageRowIndex().get(id)
|
||||
if (index === undefined) return
|
||||
virtualizer.scrollToIndex(index, { align: "center" })
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Bus } from "./bus.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
const isSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
@@ -18,6 +20,8 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
const clock = yield* Clock.Clock
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
|
||||
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
|
||||
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
|
||||
@@ -39,19 +43,21 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
yield* Effect.sleep(options.sweepInterval ?? "1 minute")
|
||||
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
|
||||
const cached = new Set(refs.map(key))
|
||||
yield* Effect.forEach(
|
||||
refs,
|
||||
(ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)),
|
||||
{ discard: true },
|
||||
)
|
||||
yield* Effect.forEach(refs, (ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)), { discard: true })
|
||||
for (const id of entries.keys()) {
|
||||
if (!cached.has(id)) entries.delete(id)
|
||||
}
|
||||
const now = clock.currentTimeMillisUnsafe()
|
||||
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
|
||||
if (expired.length === 0) return
|
||||
const active = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
|
||||
const occupied = new Set(active.flatMap((session) => (session ? [key(session.location)] : [])))
|
||||
yield* Effect.forEach(
|
||||
expired,
|
||||
(entry) => {
|
||||
// Waiting for a question or a long-running tool emits no activity.
|
||||
// Invalidating a borrowed graph would strand it behind a new cache entry.
|
||||
if (occupied.has(key(entry.ref))) return touch(entry.ref)
|
||||
entries.delete(key(entry.ref))
|
||||
return Effect.logInfo("location services evicted", {
|
||||
directory: entry.ref.directory,
|
||||
@@ -70,5 +76,5 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [Bus.node, LocationServiceMap.node],
|
||||
deps: [Bus.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, RcMap, Schema } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
// Keep real execution ownership, location caching, forms, and eviction. The fixture
|
||||
// runner waits on a form instead of making a model request before asking a question.
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
return yield* LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
// The fixture only exercises these three Location services.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
Layer.merge(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory },
|
||||
}),
|
||||
),
|
||||
Layer.effect(
|
||||
SessionRunner.Service,
|
||||
Effect.gen(function* () {
|
||||
const forms = yield* Form.Service
|
||||
return SessionRunner.Service.of({
|
||||
drain: ({ sessionID }) =>
|
||||
forms
|
||||
.ask({
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
fields: [{ key: "runtime", type: "string" }],
|
||||
})
|
||||
.pipe(Effect.orDie, Effect.as(SessionRunner.DrainResult.Complete())),
|
||||
})
|
||||
}),
|
||||
),
|
||||
).pipe(
|
||||
Layer.provideMerge(Form.layer),
|
||||
Layer.provide(Layer.succeed(Bus.Service, bus)),
|
||||
Layer.fresh,
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, SessionExecution.node, LocationActivity.node]),
|
||||
[
|
||||
LocationServiceMap.node.replace(
|
||||
makeGlobalNode({
|
||||
service: LocationServiceMap.Service,
|
||||
layer: locations,
|
||||
deps: [Bus.node],
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("LocationActivity active execution", () => {
|
||||
for (const settle of ["answer", "cancel", "interrupt"] as const) {
|
||||
it.effect(`keeps a waiting question reachable past the deadline until ${settle}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const map = yield* LocationServiceMap.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const sessionID = Session.ID.make("ses_waiting_question")
|
||||
const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/project") })
|
||||
const idle = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_idle") })
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: ref.directory, sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "question",
|
||||
directory: ref.directory,
|
||||
title: "Waiting question",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const created = yield* Deferred.make<Form.Info>()
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Form.Event.Created.type
|
||||
? Deferred.succeed(created, Schema.decodeUnknownSync(Form.Event.Created.data)(event.data).form).pipe(
|
||||
Effect.asVoid,
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const running = yield* execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped)
|
||||
const form = yield* Deferred.await(created)
|
||||
yield* Location.Service.pipe(Effect.provide(map.get(idle)), Effect.scoped)
|
||||
|
||||
// The first sweep discovers both cached graphs. No more Session events
|
||||
// are needed while the human is deciding how to answer.
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* TestClock.adjust("62 minutes")
|
||||
expect(yield* execution.isActive(sessionID)).toBe(true)
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
|
||||
const context = yield* map.contextEffect(ref).pipe(Effect.scoped)
|
||||
const forms = Context.get(context, Form.Service)
|
||||
expect(yield* forms.list({ sessionID })).toEqual([form])
|
||||
|
||||
if (settle === "answer") yield* forms.reply({ id: form.id, answer: { runtime: "Bun" } })
|
||||
if (settle === "cancel") yield* forms.cancel(form.id)
|
||||
if (settle === "interrupt") yield* execution.interrupt(sessionID)
|
||||
yield* Fiber.join(running)
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
expect(yield* forms.state(form.id)).toEqual(
|
||||
settle === "answer" ? { status: "answered", answer: { runtime: "Bun" } } : { status: "cancelled" },
|
||||
)
|
||||
|
||||
yield* TestClock.adjust("62 minutes")
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([])
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import { ApplicationLifecycle } from "../lifecycle"
|
||||
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding"
|
||||
import { BackgroundService } from "../service/background-service"
|
||||
import { DesktopCli } from "../service/desktop-cli"
|
||||
import { SidecarCredentials } from "../service/sidecar-credentials"
|
||||
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
|
||||
import { Updater } from "../updater"
|
||||
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
|
||||
@@ -29,8 +30,8 @@ export const appHandlers = AppRpcs.toLayer(
|
||||
const logging = yield* DesktopLogging.Service
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
return AppRpcs.of({
|
||||
AppAwaitInitialization: () => background.connection,
|
||||
AppReconnectService: () => background.reconnect,
|
||||
AppAwaitInitialization: () => background.connection.pipe(Effect.map(SidecarCredentials.ready)),
|
||||
AppReconnectService: () => background.reconnect.pipe(Effect.map(SidecarCredentials.ready)),
|
||||
AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks),
|
||||
AppGetDefaultServerUrl: () => Effect.sync(getDefaultServerUrl),
|
||||
AppSetDefaultServerUrl: ({ url }) => Effect.sync(() => setDefaultServerUrl(url)),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export * as BackgroundServiceState from "./background-service-state"
|
||||
|
||||
import { Effect, Exit, Ref } from "effect"
|
||||
import type { ServerReadyData } from "../../shared/ipc-contract"
|
||||
import type { SidecarCredentials } from "./sidecar-credentials"
|
||||
|
||||
export const make = Effect.fn("BackgroundServiceState.make")(function* (options: {
|
||||
readonly initial: Effect.Effect<ServerReadyData, unknown>
|
||||
readonly reconnect: Effect.Effect<ServerReadyData>
|
||||
readonly initial: Effect.Effect<SidecarCredentials.Data, unknown>
|
||||
readonly reconnect: Effect.Effect<SidecarCredentials.Data>
|
||||
}) {
|
||||
// Every Exit is an Effect, so the latest resolution replays directly for each consumer.
|
||||
const current = yield* Ref.make<Exit.Exit<ServerReadyData, unknown>>(yield* options.initial.pipe(Effect.exit))
|
||||
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown>>(yield* options.initial.pipe(Effect.exit))
|
||||
return {
|
||||
connection: Ref.get(current).pipe(Effect.flatten, Effect.orDie),
|
||||
reconnect: options.reconnect.pipe(Effect.tap((next) => Ref.set(current, Exit.succeed(next)))),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { app } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import type { ServerReadyData } from "../../shared/ipc-contract"
|
||||
import { BackgroundServiceState } from "./background-service-state"
|
||||
import { cleanStages, DesktopCli } from "./desktop-cli"
|
||||
import { SidecarCredentials } from "./sidecar-credentials"
|
||||
|
||||
export * as BackgroundService from "./background-service"
|
||||
|
||||
export interface Interface {
|
||||
readonly connection: Effect.Effect<ServerReadyData>
|
||||
readonly reconnect: Effect.Effect<ServerReadyData>
|
||||
readonly connection: Effect.Effect<SidecarCredentials.Data>
|
||||
readonly reconnect: Effect.Effect<SidecarCredentials.Data>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/BackgroundService") {}
|
||||
@@ -56,10 +56,9 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
|
||||
...endpoint(url.origin),
|
||||
})
|
||||
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
|
||||
return {
|
||||
url: url.origin,
|
||||
password: service.auth.password,
|
||||
} satisfies ServerReadyData
|
||||
const ready = { url: url.origin, password: service.auth.password } satisfies SidecarCredentials.Data
|
||||
SidecarCredentials.set(ready)
|
||||
return ready
|
||||
})
|
||||
|
||||
function endpoint(url: string | undefined) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { authorization, ready } from "./sidecar-credentials"
|
||||
|
||||
const sidecar = { url: "http://127.0.0.1:4096", password: "secret" }
|
||||
const expected = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
|
||||
|
||||
describe("sidecar authorization", () => {
|
||||
test("adds the Basic credential only for the sidecar origin", () => {
|
||||
expect(authorization(sidecar, "http://127.0.0.1:4096/api/session?limit=1")).toBe(expected)
|
||||
expect(authorization(sidecar, "http://127.0.0.1:4097/api/session")).toBeUndefined()
|
||||
expect(authorization(sidecar, "http://localhost:4096/api/session")).toBeUndefined()
|
||||
expect(authorization(sidecar, "https://127.0.0.1:4096/api/session")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("hands the renderer the origin only", () => {
|
||||
expect(ready(sidecar)).toEqual({ url: sidecar.url })
|
||||
})
|
||||
|
||||
test("adds nothing before the sidecar is known or when it has no password", () => {
|
||||
expect(authorization(undefined, "http://127.0.0.1:4096/api/session")).toBeUndefined()
|
||||
expect(authorization({ url: sidecar.url, password: null }, "http://127.0.0.1:4096/api/session")).toBeUndefined()
|
||||
expect(authorization(sidecar, "not a url")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
export * as SidecarCredentials from "./sidecar-credentials"
|
||||
|
||||
import type { ServerReadyData } from "../../shared/ipc-contract"
|
||||
|
||||
export type Data = ServerReadyData & { password: string | null }
|
||||
|
||||
// The renderer talks to the sidecar without an Authorization header; the main process adds it from
|
||||
// here so GET requests stay CORS-simple and skip the preflight round trip. Both the initial connection
|
||||
// and every reconnect publish the current endpoint.
|
||||
let current: Data | undefined
|
||||
|
||||
export function set(data: Data) {
|
||||
current = data
|
||||
}
|
||||
|
||||
export function get() {
|
||||
return current
|
||||
}
|
||||
|
||||
/** What the renderer learns about the sidecar: its origin, never its credential. */
|
||||
export function ready(data: Data): ServerReadyData {
|
||||
return { url: data.url }
|
||||
}
|
||||
|
||||
/** The Basic credential for a request to the sidecar origin, or undefined for any other URL. */
|
||||
export function authorization(sidecar: Data | undefined, url: string) {
|
||||
if (!sidecar?.password || !URL.canParse(url)) return
|
||||
if (new URL(url).origin !== sidecar.url) return
|
||||
return `Basic ${Buffer.from(`opencode:${sidecar.password}`).toString("base64")}`
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { addRendererHeaders } from "./headers"
|
||||
import { SidecarCredentials } from "../service/sidecar-credentials"
|
||||
import { addRendererHeaders, hasHeader, upsertHeader } from "./headers"
|
||||
import { isRendererUrl } from "./protocol"
|
||||
|
||||
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
|
||||
@@ -31,6 +32,25 @@ export function wireNavigationPolicy(win: BrowserWindow, openExternalURL: (url:
|
||||
}
|
||||
|
||||
export function wireRendererHeaders(win: BrowserWindow) {
|
||||
// The renderer sends sidecar requests without credentials, so its GETs are CORS-simple and need no
|
||||
// preflight. Electron applies these listeners in Chromium's extraHeaders mode, after the CORS
|
||||
// decision, so adding Authorization here does not reintroduce one.
|
||||
//
|
||||
// Only the renderer's own top-level frame is credentialed. Other content in this session (web views,
|
||||
// embedded pages) can reach the same loopback origin and must not inherit its access. Requests with
|
||||
// no frame, such as from a service worker, are not credentialed either; the renderer registers none.
|
||||
win.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
{ urls: ["http://127.0.0.1/*", "http://localhost/*"] },
|
||||
(details, callback) => {
|
||||
const frame = details.frame
|
||||
const renderer = !!frame && frame.parent === null && isRendererUrl(frame.url)
|
||||
const authorization = renderer && SidecarCredentials.authorization(SidecarCredentials.get(), details.url)
|
||||
if (authorization && !hasHeader(details.requestHeaders, "Authorization")) {
|
||||
upsertHeader(details.requestHeaders, "Authorization", authorization)
|
||||
}
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
},
|
||||
)
|
||||
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
const responseHeaders = details.responseHeaders ?? {}
|
||||
addRendererHeaders(responseHeaders, { document: isRendererUrl(details.url, true) })
|
||||
|
||||
@@ -49,12 +49,8 @@ export function MigrationStatus(props: { server: ServerReadyData }) {
|
||||
await wait(1_000, abort.signal)
|
||||
if (abort.signal.aborted) return
|
||||
|
||||
const client = OpenCode.make({
|
||||
baseUrl: props.server.url,
|
||||
headers: props.server.password
|
||||
? { Authorization: `Basic ${btoa(`opencode:${props.server.password}`)}` }
|
||||
: undefined,
|
||||
})
|
||||
// The main process credentials sidecar requests; see `wireRendererHeaders`.
|
||||
const client = OpenCode.make({ baseUrl: props.server.url })
|
||||
|
||||
void (async () => {
|
||||
while (true) {
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("desktop renderer initialization", () => {
|
||||
})
|
||||
|
||||
test("returns initialized sidecar data", () => {
|
||||
const sidecar = { url: "http://127.0.0.1:1234", password: "secret" }
|
||||
const sidecar = { url: "http://127.0.0.1:1234" }
|
||||
|
||||
expect(initializationData(Object.assign(() => sidecar, { error: undefined }))).toBe(sidecar)
|
||||
})
|
||||
@@ -47,7 +47,7 @@ describe("desktop renderer initialization", () => {
|
||||
})
|
||||
|
||||
test("refreshes the managed sidecar endpoint", async () => {
|
||||
const sidecar = { url: "http://127.0.0.1:4321", password: "next" }
|
||||
const sidecar = { url: "http://127.0.0.1:4321" }
|
||||
const updates: (typeof sidecar)[] = []
|
||||
const resolve = createSidecarResolver({
|
||||
api: { reconnectService: async () => sidecar },
|
||||
@@ -60,7 +60,7 @@ describe("desktop renderer initialization", () => {
|
||||
})
|
||||
|
||||
test("keeps the current sidecar when reconnection resolves the same endpoint", async () => {
|
||||
const sidecar = { url: "http://127.0.0.1:4321", password: "same" }
|
||||
const sidecar = { url: "http://127.0.0.1:4321" }
|
||||
const updates: (typeof sidecar)[] = []
|
||||
const resolve = createSidecarResolver({
|
||||
api: { reconnectService: async () => ({ ...sidecar }) },
|
||||
@@ -73,7 +73,7 @@ describe("desktop renderer initialization", () => {
|
||||
})
|
||||
|
||||
test("does not publish a sidecar resolved after cancellation", async () => {
|
||||
const sidecar = { url: "http://127.0.0.1:4321", password: "next" }
|
||||
const sidecar = { url: "http://127.0.0.1:4321" }
|
||||
const pending = Promise.withResolvers<typeof sidecar>()
|
||||
const updates: (typeof sidecar)[] = []
|
||||
const resolve = createSidecarResolver({
|
||||
|
||||
@@ -7,11 +7,10 @@ export function initializationData<A>(state: (() => A | undefined) & { error: un
|
||||
return state()
|
||||
}
|
||||
|
||||
// The main process adds Authorization to sidecar requests (`wireRendererHeaders`); the renderer never
|
||||
// holds the password, and its GETs carry only CORS-safelisted headers so they skip the preflight.
|
||||
export function sidecarHttp(data: SidecarData) {
|
||||
return {
|
||||
url: data.url,
|
||||
password: data.password ?? undefined,
|
||||
}
|
||||
return { url: data.url }
|
||||
}
|
||||
|
||||
export function createSidecarResolver(input: {
|
||||
@@ -29,7 +28,7 @@ export function createSidecarResolver(input: {
|
||||
}
|
||||
|
||||
function sameSidecar(current: SidecarData | undefined, next: SidecarData) {
|
||||
return current?.url === next.url && current.password === next.password
|
||||
return current?.url === next.url
|
||||
}
|
||||
|
||||
function markLocalServerStartup(error: unknown) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The sidecar password never crosses into the renderer; the main process adds it to sidecar requests.
|
||||
export type ServerReadyData = {
|
||||
url: string
|
||||
password: string | null
|
||||
}
|
||||
|
||||
export type TitlebarTheme = {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
|
||||
const ServerReadyData = Schema.Struct({
|
||||
url: Schema.String,
|
||||
password: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Spinner } from "./spinner"
|
||||
export function DialogUpdate(props: {
|
||||
check?: (signal: AbortSignal) => Promise<string | undefined>
|
||||
state: () => UpdateState | undefined
|
||||
skip: () => void
|
||||
install: () => Promise<void>
|
||||
restart: () => void
|
||||
}) {
|
||||
@@ -47,7 +48,16 @@ export function DialogUpdate(props: {
|
||||
: type === "installed"
|
||||
? { label: "Restart", run: props.restart }
|
||||
: undefined
|
||||
return [{ label: "Skip", run: () => dialog.clear() }, ...(confirm ? [confirm] : [])]
|
||||
return [
|
||||
{
|
||||
label: "Skip",
|
||||
run: () => {
|
||||
props.skip()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
...(confirm ? [confirm] : []),
|
||||
]
|
||||
})
|
||||
|
||||
createEffect(() => setActive(Math.max(0, buttons().length - 1)))
|
||||
|
||||
@@ -95,14 +95,12 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider
|
||||
// The notification can predate an installation through /update.
|
||||
if (known && active?.type !== "installing" && !(active?.type === "installed" && active.version === known.version))
|
||||
setState({ type: known.type, version: known.version })
|
||||
// Manual checks hide the current notice without marking the version as seen.
|
||||
if (origin === "manual") setNotification(undefined)
|
||||
if (origin === "notification") dismiss()
|
||||
const status = state()?.type
|
||||
dialog.replace(() => (
|
||||
<DialogUpdate
|
||||
check={status === undefined || status === "failed" ? check : undefined}
|
||||
state={state}
|
||||
skip={dismiss}
|
||||
install={install}
|
||||
restart={restart}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user