Merge origin/master into worktree/sidebar-scrollbar-reveal

#1381 landed the bar's horizontal position; this branch decides when it is
drawn. The e2e keeps both scenarios and the golden carries both palettes'
pointer-state readings alongside the new edge-offset lines.
This commit is contained in:
creatixchu
2026-08-04 15:46:53 +08:00
90 changed files with 1732 additions and 377 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 360ab26ec3dfecf2841a012fda8947d6a84fdfec
README.zh.md: 3ffa5d7726a798b784c67fdb8c4154fddbdea7a4
README.md: 6fdca68eed11dffe46bf2fbde9a7899359690dca
README.zh.md: d8d7122729df1dd8aaed8207ddfb0a0470778b01

View File

@@ -49,6 +49,8 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags.
Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution.
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.

View File

@@ -49,6 +49,8 @@ dsh web --dump-config
`dsh -p "task"` 使用相同的 base 与 Web 组合及启动时个人配置,在由操作系统分配的端口上启动 Web 宿主,运行一个全新的持久会话,打印最终答案后退出。它不接受 `--config` 或原始配置输出标志。
Web 与 headless 的进程关闭流程最多给插件树 5 秒执行 dispose。第一次 `SIGINT`/`SIGTERM` 会启动这次优雅排空;第二次信号会立即强制退出。如果 headless 的正常完成流程已经卡在 dispose 中,第一次 `Ctrl+C` 就会触发强制退出:进程立即结束,该信号不再被吞掉。
两种模式都以调用目录作为默认 workspace 根目录,加载适用的 `AGENTS.md``CLAUDE.md` 指令,渲染预算为 65,536 字节,并使用内存 SQLite 会话内容索引。Web 会持续应用有效的个人配置编辑headless 只在启动时读取该文件一次。层次优先级、凭据存储、实时更新失败行为与 `$DSH_HOME` 解析均由 [app-boot 个人配置契约](../../packages/ui/app-boot/README.md#personal-config) 统一定义。
新会话默认使用 `workspace-write` 权限 preset。Bash 和文件系统写操作受限于会话 workspace 与平台临时根目录;读取、网络访问与进程可见性不受限制。`DSH_PERMISSION_MODE` 会改变进程回退值。已存储的常规设置权限会影响之后的 Web 会话,不会更改已打开的会话。

View File

@@ -111,17 +111,19 @@
# process out (the launchers patch the row disabled; config cannot disable
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
# random UUID; delete the file to reset the identity) as the Resource's
# user.id. The exporter/processor values bound the shutdown drain to ~1s
# against an unreachable collector: exporter.timeoutMillis is both the
# per-attempt socket timeout and the retry deadline (1s effectively
# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
# (both explicit) makes the drain a single batch, and exportTimeoutMillis
# is the processor's own cap on that one export cycle — the second bound
# when the exporter's clock alone does not fire. Every CLI exit path drains it
# by disposing the root on SIGINT/SIGTERM.
# user.id. The exporter/processor values normally bound the shutdown drain
# to ~1s against an unreachable collector: exporter.timeoutMillis is both
# the per-attempt socket timeout and the retry deadline (1s effectively
# disables the SDK's 5-try backoff), while maxExportBatchSize == maxQueueSize
# (both explicit) makes the drain a single batch. The SDK awaits
# exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s
# shutdownTimeoutMillis is the load-bearing outer bound when a transport
# promise never settles. Every CLI exit path drains it by disposing the root
# on SIGINT/SIGTERM.
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
shutdownTimeoutMillis: 3000
exporter:
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
compression: gzip

View File

@@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AppCLIEntry } from './app-cli-entry.ts'
import { createProcessShutdown } from './process-shutdown.ts'
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
interface TurnOutcome {
@@ -21,12 +22,12 @@ interface TurnOutcome {
reason: string
}
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (dispose first). */
async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>): Promise<T> {
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (shutdown first). */
async function unwrap<T>(response: RpcResponse<T>, shutdown: () => Promise<void>): Promise<T> {
if (response.result.ok) return response.result.value
const { code, message } = response.result.error
process.stderr.write(`dsh: ${code}: ${message}\n`)
await dispose()
await shutdown()
process.exit(1)
}
@@ -82,23 +83,16 @@ export async function runHeadless(task: string): Promise<void> {
port: 0,
})
const { ctx, port } = await entry.run()
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
// Signal exits must still dispose the tree: the composition mounts
// exit-drained plugins (telemetry's queued tail and shutdown marker would
// otherwise be lost), and Node's default signal exit skips disposal.
let signalled = false
const disposeAndExit = (code: number): void => {
if (signalled) return
signalled = true
void dispose().finally(() => { process.exit(code) })
}
process.on('SIGTERM', () => { disposeAndExit(143) })
process.on('SIGINT', () => { disposeAndExit(130) })
// Normal completion and signals share one bounded drain. A signal received
// during that drain escalates immediately instead of becoming a no-op.
const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
process.on('SIGTERM', () => { shutdown.interrupt(143) })
process.on('SIGINT', () => { shutdown.interrupt(130) })
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const created = await unwrap(await api.sessions.create({}), dispose)
const created = await unwrap(await api.sessions.create({}), () => shutdown.shutdown(1))
// Open the stream before prompting so no frame is lost — kept in this order
// even though in-process delivery has no race, so the code survives a move
@@ -111,11 +105,10 @@ export async function runHeadless(task: string): Promise<void> {
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: task }],
}), dispose)
}), () => shutdown.shutdown(1))
const outcome = await done
process.stdout.write(outcome.text + '\n')
abort.abort()
await dispose()
process.exit(outcome.reason === 'completed' ? 0 : 1)
await shutdown.shutdown(outcome.reason === 'completed' ? 0 : 1)
}

View File

@@ -0,0 +1,58 @@
/** Bounded, escalating process shutdown for the long-lived CLI surfaces. */
/** Maximum grace allowed for the application tree to dispose before process exit. */
export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000
/** Process-exit controller shared by normal completion and Unix signal handlers. */
export interface ProcessShutdown {
/** Start or join graceful disposal before exiting with `code`. */
shutdown(code: number): Promise<void>
/** Start graceful disposal, or force exit when a shutdown is already running. */
interrupt(code: number): void
}
/**
* Create one process-exit controller around an application disposer.
* @param dispose - Whole-application teardown that resolves at quiescence.
* @param exit - Process exit boundary, replaceable by tests.
* @param timeoutMs - Grace before forced exit, replaceable by tests.
* @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
*/
export function createProcessShutdown(
dispose: () => Promise<void>,
exit: (code: number) => void = (code) => { process.exit(code) },
timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS,
): ProcessShutdown {
let pending: Promise<void> | undefined
let timeout: ReturnType<typeof setTimeout> | undefined
let exited = false
const exitOnce = (code: number): void => {
if (exited) return
exited = true
/* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */
if (timeout !== undefined) clearTimeout(timeout)
exit(code)
}
const shutdown = (code: number): Promise<void> => {
if (pending !== undefined) return pending
timeout = setTimeout(() => { exitOnce(code) }, timeoutMs)
pending = Promise.resolve().then(dispose).then(
() => { exitOnce(code) },
() => { exitOnce(code) },
)
return pending
}
return {
shutdown,
interrupt(code) {
if (pending !== undefined) {
exitOnce(code)
return
}
void shutdown(code)
},
}
}

View File

@@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tool-bash'
import { AppCLIEntry } from './app-cli-entry.ts'
import { createProcessShutdown } from './process-shutdown.ts'
// The shipped base plus the Web application's overlay.
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
@@ -118,17 +119,12 @@ export async function runWeb(
const { ctx, port: boundPort } = await entry.run()
const resolvedLocalWebUrl = localWebUrl(ctx)
let exiting = false
const shutdown = (code: number): void => {
if (exiting) return
exiting = true
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
// Install shutdown handling before publishing readiness: supervisors may
// send a signal as soon as they observe the URL line.
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })
process.on('SIGTERM', () => { shutdown.interrupt(0) })
process.on('SIGINT', () => { shutdown.interrupt(130) })
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
// must name an address the /api trust fence was configured with.

View File

@@ -0,0 +1,18 @@
/** Test-only Cordis plugin whose disposer announces entry and never settles. */
import { existsSync } from 'node:fs'
/**
* Register a disposer that keeps process shutdown pending until it is forced.
* @param {import('cordis').Context} ctx - loader-mounted test plugin context.
*/
export function apply(ctx) {
const keepAlive = setInterval(() => {}, 60_000)
ctx.effect(() => async () => {
clearInterval(keepAlive)
const armFile = process.env.DSH_TEST_SHUTDOWN_ARM_FILE
if (armFile === undefined || !existsSync(armFile)) return
process.stderr.write('dsh-test: never-dispose started\n')
await new Promise(() => {})
})
}

View File

@@ -0,0 +1,121 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const neverDisposePlugin = pathToFileURL(
fileURLToPath(new URL('./fixtures/never-dispose.mjs', import.meta.url)),
).href
const POSIX_HEADLESS_PTY_DRIVER = String.raw`
import errno, json, os, pty, select, signal, sys, time
node, launch_args_json, launch_env_json, cwd, timeout_seconds = sys.argv[1:]
env = os.environ.copy()
env.update(json.loads(launch_env_json))
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
markers = [b"dsh: observing at ", b"dsh-test: never-dispose started"]
output = bytearray()
marker_index = 0
deadline = time.monotonic() + float(timeout_seconds)
status = None
while time.monotonic() < deadline:
ready, _, _ = select.select([fd], [], [], 0.05)
if ready:
try:
chunk = os.read(fd, 65536)
except OSError as error:
if error.errno != errno.EIO:
raise
chunk = b""
if chunk:
output.extend(chunk)
while marker_index < len(markers) and markers[marker_index] in output:
if marker_index == 0:
open(os.path.join(cwd, "shutdown-armed"), "w").close()
os.write(fd, b"\x03")
marker_index += 1
waited, candidate = os.waitpid(pid, os.WNOHANG)
if waited == pid:
status = candidate
break
if status is None:
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
sys.stdout.buffer.write(output)
if marker_index != len(markers):
sys.stderr.write(f"completed {marker_index}/{len(markers)} PTY actions before timeout\n")
sys.exit(124)
actual_exit = os.waitstatus_to_exitcode(status)
if actual_exit != 130:
sys.stderr.write(f"expected exit 130, got {actual_exit}\n")
sys.exit(125)
`
async function runHeadlessPtySmoke(): Promise<string> {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-headless-shutdown-'))
try {
const home = join(cwd, '.dsh')
await mkdir(home, { recursive: true })
await writeFile(join(home, 'config.yaml'), [
'- insert:',
' - id: never-dispose',
` name: '${neverDisposePlugin}'`,
'',
].join('\n'))
const launch = resolveExampleLaunch({
srcBin: dshBinScript,
configArgs: ['-p', 'never complete'],
tsconfigPath,
env: {
DSH_HOME: home,
DSH_AGENTS_HOME: join(cwd, '.agents'),
DEEPSEEK_API_KEY: 'keyless-shutdown-no-call',
DSH_TELEMETRY_DISABLED: '1',
DSH_TEST_SHUTDOWN_ARM_FILE: join(cwd, 'shutdown-armed'),
},
})
const timeoutMs = 15_000
const result = await execa('python3', [
'-c',
POSIX_HEADLESS_PTY_DRIVER,
launch.command,
JSON.stringify(launch.args),
JSON.stringify(launch.env),
cwd,
String(timeoutMs / 1_000),
], {
stdin: 'ignore',
timeout: timeoutMs + 5_000,
killSignal: 'SIGKILL',
reject: false,
stripFinalNewline: false,
})
if (result.timedOut) {
throw new Error(`dsh headless PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
if (result.failed) {
throw new Error(`dsh headless PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
return result.stdout
} finally {
await rm(cwd, { recursive: true, force: true })
}
}
describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
const output = await runHeadlessPtySmoke()
expect(output).toContain('dsh: observing at ')
expect(output).toContain('dsh-test: never-dispose started')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createProcessShutdown,
PROCESS_SHUTDOWN_TIMEOUT_MS,
} from '../src/process-shutdown.ts'
function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
let resolve!: () => void
let reject!: (error: Error) => void
const promise = new Promise<void>((accept, fail) => {
resolve = accept
reject = fail
})
return { promise, resolve, reject }
}
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('process shutdown', () => {
it('exits once after graceful disposal resolves or rejects', async () => {
const resolvedExit = vi.fn()
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
await resolved.shutdown(0)
expect(resolvedExit).toHaveBeenCalledOnce()
expect(resolvedExit).toHaveBeenCalledWith(0)
const rejectedExit = vi.fn()
const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
await rejected.shutdown(1)
expect(rejectedExit).toHaveBeenCalledOnce()
expect(rejectedExit).toHaveBeenCalledWith(1)
})
it('uses process.exit as the default process boundary', async () => {
const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
const shutdown = createProcessShutdown(() => Promise.resolve())
await shutdown.shutdown(7)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(7)
})
it('forces exit when graceful disposal reaches its bound', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
expect(exit).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(0)
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
})
it('honors a caller-supplied grace period', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(24)
expect(exit).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(exit).toHaveBeenCalledOnce()
disposal.resolve()
await pending
})
it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const pending = shutdown.shutdown(0)
shutdown.interrupt(130)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(130)
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
})
it('drains on the first signal and forces on the second signal', async () => {
const disposal = deferred()
const dispose = vi.fn(() => disposal.promise)
const exit = vi.fn()
const shutdown = createProcessShutdown(dispose, exit)
shutdown.interrupt(143)
await Promise.resolve()
expect(dispose).toHaveBeenCalledOnce()
expect(exit).not.toHaveBeenCalled()
shutdown.interrupt(130)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(130)
disposal.resolve()
await shutdown.shutdown(0)
expect(exit).toHaveBeenCalledOnce()
})
it('coalesces normal shutdown calls without treating them as escalation', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const first = shutdown.shutdown(0)
const second = shutdown.shutdown(1)
expect(second).toBe(first)
expect(exit).not.toHaveBeenCalled()
disposal.resolve()
await first
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(0)
})
})

View File

@@ -0,0 +1,153 @@
/**
* Opt-in browser stress reproduction for reasoning-stream renderer stalls.
* The fixture emits 100,000 individual chunks through the normal async
* carrier; the test measures event-loop and scheduled-interaction delay while
* the assembled React surface keeps a collapsed Think row live.
*/
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { expect, it, onTestFailed } from 'vitest'
import { launchWebScaffold, watchConsole, type WebScaffold } from '../tests/scaffold.ts'
import { newEnglishPage, saveFailureShot } from '../tests/support.ts'
const CHUNK_COUNT = 100_000
const CHUNKS_PER_INTERVAL = 128
const CHUNK_INTERVAL_MS = 16
const MAIN_THREAD_DELAY_BUDGET_MS = 250
interface ReasoningChunkStormState {
sessionId: string
chunkCount: number
chunksPerInterval: number
intervalMs: number
emitted: number
marker: string
emitting: boolean
}
interface StressProbe {
intervalId: number
intervalMs: number
lastTickAt: number
maxDelayMs: number
samples: number
interactionDueAt: number
interactionHandledAt: number | null
}
interface StressWindow extends Window {
__fxTiming?: {
startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
reasoningChunkStormState(): ReasoningChunkStormState | null
}
__reasoningStressProbe?: StressProbe
}
it('keeps the browser responsive while rendering 100,000 reasoning chunks', async () => {
let scaffold: WebScaffold | undefined
let browser: Browser | undefined
let page: Page | undefined
try {
scaffold = await launchWebScaffold()
browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== '1' })
page = await newEnglishPage(browser)
const activePage = page
await activePage.addInitScript(() => {
localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: 'fx-alpha' }))
})
const tripwire = watchConsole(activePage)
onTestFailed(() => saveFailureShot(activePage, 'web-stress-reasoning-chunks'))
await activePage.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fixture settings deliberately reject writes, so its welcome notice
// cannot acknowledge. Hide only that test overlay; the assembled chat
// tree beneath it remains mounted and exercises the production renderer.
await activePage.addStyleTag({ content: '[class*="onboardingOverlay"] { display: none !important; }' })
await activePage.locator('[data-sample="bash"]').first().waitFor({ timeout: 30_000 })
await activePage.evaluate(() => {
const intervalMs = 50
const now = performance.now()
const probe: StressProbe = {
intervalId: 0,
intervalMs,
lastTickAt: now,
maxDelayMs: 0,
samples: 0,
interactionDueAt: now + 1_000,
interactionHandledAt: null,
}
probe.intervalId = window.setInterval(() => {
const tickAt = performance.now()
probe.maxDelayMs = Math.max(probe.maxDelayMs, tickAt - probe.lastTickAt - intervalMs)
probe.lastTickAt = tickAt
probe.samples++
}, intervalMs)
document.body.addEventListener('reasoning-stress-interaction', () => {
probe.interactionHandledAt = performance.now()
}, { once: true })
window.setTimeout(() => {
document.body.dispatchEvent(new CustomEvent('reasoning-stress-interaction'))
}, 1_000)
;(window as StressWindow).__reasoningStressProbe = probe
})
const marker = await activePage.evaluate(({ chunkCount, chunksPerInterval, intervalMs }) => {
const hooks = (window as StressWindow).__fxTiming
if (hooks === undefined) throw new Error('reasoning stress fixture hooks unavailable')
return hooks.startReasoningChunkStorm('fx-alpha', chunkCount, chunksPerInterval, intervalMs)
}, {
chunkCount: CHUNK_COUNT,
chunksPerInterval: CHUNKS_PER_INTERVAL,
intervalMs: CHUNK_INTERVAL_MS,
})
const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last()
await liveThink.waitFor({ timeout: 60_000 })
await expect.poll(async () => await activePage.evaluate(() => {
const hooks = (window as StressWindow).__fxTiming
return hooks?.reasoningChunkStormState()?.emitted ?? 0
}), { timeout: 540_000, interval: 100 }).toBe(CHUNK_COUNT)
await expect.poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }).toContain(marker)
const report = await activePage.evaluate(() => {
const win = window as StressWindow
const probe = win.__reasoningStressProbe
const state = win.__fxTiming?.reasoningChunkStormState()
if (probe === undefined || state === undefined || state === null) {
throw new Error('reasoning stress metrics unavailable')
}
window.clearInterval(probe.intervalId)
const interactionDelayMs = probe.interactionHandledAt === null
? null
: probe.interactionHandledAt - probe.interactionDueAt
return {
chunkCount: state.chunkCount,
chunksPerInterval: state.chunksPerInterval,
intervalMs: state.intervalMs,
emitted: state.emitted,
maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs),
interactionDelayMs,
heartbeatSamples: probe.samples,
}
})
process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`)
expect(report).toMatchObject({
chunkCount: CHUNK_COUNT,
chunksPerInterval: CHUNKS_PER_INTERVAL,
intervalMs: CHUNK_INTERVAL_MS,
emitted: CHUNK_COUNT,
})
expect(report.heartbeatSamples).toBeGreaterThan(0)
const interactionDelayMs = report.interactionDelayMs
if (interactionDelayMs === null) throw new Error(`scheduled interaction was not handled: ${JSON.stringify(report)}`)
expect(report.maxMainThreadDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} finally {
await browser?.close()
await scaffold?.close()
}
}, 600_000)

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the
// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's
// ModuleLoader path (fetchBundle/executeBundle) and proves the boot graph
// ModuleLoader path (loadBundle) and proves the boot graph
// assembles — staged activation across the immediately tier and the inject
// layers, per-plugin CSS injection, and a rendered journey reaching chat
// content from the keyless FixtureApiClient transport.
@@ -91,11 +91,11 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
loadBundle: async (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
if (code === undefined) throw new Error(`missing built bundle ${url}`)
;(0, eval)(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }

View File

@@ -33,6 +33,7 @@ const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
const MODE = webSnapshotMode()
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
const REPLAY_PACE_MS = 100
describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
let scaffold: WebScaffold
@@ -42,7 +43,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)

View File

@@ -128,11 +128,11 @@ describe('assembled search card', () => {
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
loadBundle: async (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
if (code === undefined) throw new Error(`missing built bundle ${url}`)
;(0, eval)(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }

View File

@@ -112,6 +112,10 @@ interface ListMetrics {
overflows: boolean
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number
/** Distance from the scrollbar's right edge to the sidebar edge. */
scrollbarEdgeOffset: number
/** Distance from the first row background's right edge to the sidebar edge. */
rowEdgeInset: number
/** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
clientRight: number
/** Border-box right edge in viewport coordinates. */
@@ -139,6 +143,8 @@ function measureList(page: Page): Promise<ListMetrics> {
if (list === null) throw new Error('sidebar session list not in the DOM')
const time = list.querySelector<HTMLElement>('[class*="time"]')
if (time === null) throw new Error('no row relative-time element in the sidebar list')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
// Each indirection variable is resolved through its own throwaway probe
// appended to the list: `var()` substitution then happens where the list
// sits in the cascade, which is the claim, and `color` normalizes whatever
@@ -173,6 +179,9 @@ function measureList(page: Page): Promise<ListMetrics> {
const style = getComputedStyle(list)
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
const listRect = list.getBoundingClientRect()
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
return {
gutter: style.scrollbarGutter,
width: pseudoWidth,
@@ -183,9 +192,11 @@ function measureList(page: Page): Promise<ListMetrics> {
token: resolve('--dsh-scrollbar-thumb'),
hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
overflows: list.scrollHeight > list.clientHeight,
band: list.getBoundingClientRect().width - list.clientWidth,
clientRight: list.getBoundingClientRect().left + list.clientWidth,
borderRight: list.getBoundingClientRect().right,
band: listRect.width - list.clientWidth,
scrollbarEdgeOffset: sidebarEdge - listRect.right,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
clientRight: listRect.left + list.clientWidth,
borderRight: listRect.right,
timeRight: time.getBoundingClientRect().right,
// The bar is drawn in the rightmost `barWidth` of the border box, whether
// or not that space was reserved. Its width comes from the sheet where the
@@ -194,7 +205,28 @@ function measureList(page: Page): Promise<ListMetrics> {
// absent. Taking the UA width as the fallback is what keeps the assertion
// honest: assuming 0 there would report no occlusion precisely in the
// state that has it.
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (list.getBoundingClientRect().right - barWidth)),
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
}
})
}
/**
* Measure only overflow and row inset, which remain observable when every
* session is hidden under a collapsed workspace group.
* @param page - the page under test.
* @returns the list overflow state and first row's trailing inset.
*/
function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset'>> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
return {
overflows: list.scrollHeight > list.clientHeight,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
}
})
}
@@ -257,6 +289,8 @@ function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
`- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`,
`- list overflows: ${String(metrics.overflows)}`,
`- reserved band: ${String(metrics.band)}px`,
`- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
`- row background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
@@ -380,6 +414,8 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
// drawn over it. Removing the declaration makes it exactly 0. The value
// itself is not pinned — it tracks `scrollbar-width` and the platform.
expect(metrics.band).toBeGreaterThan(0)
expect(metrics.scrollbarEdgeOffset).toBe(2)
expect(metrics.rowEdgeInset).toBe(12)
// The reported symptom, stated directly: no part of the row's relative time
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
// covered part. Unlike the client-edge comparison below it does not go
@@ -426,6 +462,20 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the row background inset when overflow disappears', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 })
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
await bucket.click()
try {
await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false)
expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 12 })
} finally {
await expandSeededSessions(page)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('renders the themed thumb through the WebKit path in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
const light = await measureList(page)

View File

@@ -13,6 +13,8 @@
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(212, 212, 212)
- list overflows: true
- reserved band: 8px
- scrollbar inset from the sidebar edge: 2px
- row background inset from the sidebar edge: 12px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true
@@ -30,6 +32,8 @@
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(84, 85, 87)
- list overflows: true
- reserved band: 8px
- scrollbar inset from the sidebar edge: 2px
- row background inset from the sidebar edge: 12px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true

View File

@@ -20,6 +20,9 @@ function rejectStandaloneServe(): Plugin {
export default defineConfig({
plugins: [rejectStandaloneServe(), react()],
build: {
sourcemap: true,
},
resolve: {
// Workspace packages resolve to SOURCE: package.json exports point at lib
// for Node/type consumers, but the browser bundle must compile src directly