Merge branch 'master' into feat/turn-running-time
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 会话,不会更改已打开的会话。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
58
apps/cli/src/process-shutdown.ts
Normal file
58
apps/cli/src/process-shutdown.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
18
apps/cli/tests/fixtures/never-dispose.mjs
vendored
Normal file
18
apps/cli/tests/fixtures/never-dispose.mjs
vendored
Normal 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(() => {})
|
||||
})
|
||||
}
|
||||
121
apps/cli/tests/headless-shutdown.e2e.ts
Normal file
121
apps/cli/tests/headless-shutdown.e2e.ts
Normal 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)
|
||||
})
|
||||
131
apps/cli/tests/process-shutdown.spec.ts
Normal file
131
apps/cli/tests/process-shutdown.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -77,12 +77,14 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
|
||||
// The composer's own text cap, measured on the live textarea before the
|
||||
// takeover replaces it. The panel's scroll region must stop at the same
|
||||
// height (the designer's requirement: one cap for the composer seat), and
|
||||
// measuring it here keeps the assertion free of the px value itself.
|
||||
// The composer's own text cap, measured on the live draft scrollport before
|
||||
// the takeover replaces it — the box that carries the cap, while the
|
||||
// textarea inside it is as tall as the whole draft. The panel's scroll
|
||||
// region must stop at the same height (the designer's requirement: one cap
|
||||
// for the composer seat), and measuring it here keeps the assertion free of
|
||||
// the px value itself.
|
||||
await input.fill(CAP_PROBE)
|
||||
const composerCap = await input.evaluate(el => el.clientHeight)
|
||||
const composerCap = await input.evaluate(el => el.closest('[data-input-scroll]')?.clientHeight ?? 0)
|
||||
expect(composerCap).toBeGreaterThan(0)
|
||||
await input.fill('')
|
||||
|
||||
|
||||
@@ -1,36 +1,31 @@
|
||||
// Web e2e scenario: a composer draft longer than the 14-line cap scrolls its
|
||||
// GLYPHS, not just its caret.
|
||||
// GLYPHS AND ITS CARET AS ONE.
|
||||
//
|
||||
// The composer paints its text in two stacked layers (see
|
||||
// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the
|
||||
// `<textarea>` carries the value, the selection and the caret but renders its
|
||||
// own glyphs `color: transparent`, and every visible character is painted by the
|
||||
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
|
||||
// highlight, the chips and the ghost hint. The backdrop is `position: absolute;
|
||||
// inset: 0; overflow: hidden` — it is CLIPPED, not scrolled, and nothing in the
|
||||
// browser links its scroll offset to the textarea's.
|
||||
// highlight, the chips and the ghost hint.
|
||||
//
|
||||
// So past the cap the textarea scrolled and the words did not: the caret walked
|
||||
// off the bottom of a block of text frozen at line 1, and no gesture — wheel,
|
||||
// drag, arrow key — moved it. `InputBar` now mirrors the offset onto the
|
||||
// backdrop on every textarea `scroll`, which is the one event every way of
|
||||
// moving the box ends in.
|
||||
// Two layers can only stay together by moving together. They now do: both sit
|
||||
// inside `[data-input-scroll]`, the composer's single scrolling box, and are as
|
||||
// tall as the whole draft — so one offset, applied by the browser, moves the
|
||||
// caret and the words in the same frame. Scrolling the textarea and assigning
|
||||
// its offset to the backdrop looks equivalent and is not: a wheel gesture is
|
||||
// composited off the main thread, so the assignment lands frames late and the
|
||||
// caret visibly flies ahead of the text it belongs to.
|
||||
//
|
||||
// Mirroring an offset is only correct while both layers can reach it, so the
|
||||
// geometry underneath is asserted here alongside the visible outcome: the
|
||||
// backdrop's trailing-line sentinel (a textarea reserves a line box for the
|
||||
// caret after a final newline; `pre-wrap` collapses one), and one wrap width
|
||||
// across all three layers (only the textarea scrolls, so only it can lose
|
||||
// width to a scrollbar that consumes layout space). Either breaks the extent
|
||||
// equality, and an unreachable offset clamps the glyphs below the caret.
|
||||
// That failure is what the same-task measurement below pins. Every metric here
|
||||
// is read through the caret's own coordinate frame — where the textarea puts
|
||||
// line n — against where the backdrop paints line n, because that difference is
|
||||
// the defect a user sees, and it is the one number a mirror between two boxes
|
||||
// cannot hold at zero.
|
||||
//
|
||||
// Only a real engine can show this. Scrolling is layout: jsdom reports
|
||||
// Only a real engine can show any of this. Scrolling is layout: jsdom reports
|
||||
// `scrollHeight === clientHeight` for every element and never scrolls one, so
|
||||
// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx has
|
||||
// to stub both offsets and can only prove the mirroring code path runs. What is
|
||||
// asserted here instead is the user-visible fact that path exists for — after
|
||||
// scrolling to the end of a long draft, the LAST line is the one on screen —
|
||||
// measured with a DOM Range over the backdrop's own text.
|
||||
// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx can
|
||||
// only assert that one scrollport contains both layers.
|
||||
//
|
||||
// Zero model calls: a fresh workspace's blank session already carries a live
|
||||
// composer, and the scenario only types into it. A stray stream would fail loud
|
||||
@@ -49,10 +44,10 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-draft-scroll', import.meta.url))
|
||||
/**
|
||||
* Committed golden of the composer's two-layer scroll geometry. The change
|
||||
* alters no DOM and no accessible name, so the aria goldens the other scenarios
|
||||
* commit are byte-identical with and without it; this records the relations
|
||||
* instead, which makes a shift in the cap or in the layer coupling a reviewable
|
||||
* diff rather than an assertion someone has to reconstruct.
|
||||
* alters no accessible name, so the aria goldens the other scenarios commit are
|
||||
* byte-identical with and without it; this records the relations instead, which
|
||||
* makes a shift in the cap or in the layer coupling a reviewable diff rather
|
||||
* than an assertion someone has to reconstruct.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -69,41 +64,54 @@ const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
|
||||
}).join('\n')
|
||||
|
||||
/**
|
||||
* A draft ending in a newline: the shape whose layer extents diverge without
|
||||
* the backdrop's trailing-line sentinel. A textarea reserves a line box for the
|
||||
* caret after a final newline; `white-space: pre-wrap` collapses a text node's
|
||||
* trailing newline and generates none, so the backdrop would come out exactly
|
||||
* one line shorter and the mirrored offset would clamp a line above the caret.
|
||||
* A draft ending in a newline: the shape where the two layers reserve their
|
||||
* final line box on different terms. A textarea keeps one for the caret after a
|
||||
* final newline; `white-space: pre-wrap` collapses a text node's trailing
|
||||
* newline and generates none. The hidden auto-grow mirror carries the newline
|
||||
* and so decides the height for both, which is why the backdrop needs no
|
||||
* padding of its own — but only a draft of this shape can show it.
|
||||
*/
|
||||
const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
|
||||
|
||||
/** The composer's two text layers as the browser lays them out. */
|
||||
/** The composer's text layers as the browser lays them out. */
|
||||
interface ComposerMetrics {
|
||||
/** True when the draft is taller than the capped box — the situation under test. */
|
||||
overflows: boolean
|
||||
/** Visible height of the textarea's content box: the cap in pixels. */
|
||||
/** Visible height of the scrollport's content box: the cap in pixels. */
|
||||
clientHeight: number
|
||||
/** Whole lines that fit in the visible box, at the composer's own line-height. */
|
||||
visibleLines: number
|
||||
/** The textarea's scroll offset, which the caret and the selection follow. */
|
||||
inputScrollTop: number
|
||||
/** The backdrop's scroll offset, which every visible glyph follows. */
|
||||
backdropScrollTop: number
|
||||
/** True when the two layers agree — the coupling this scenario exists for. */
|
||||
layersAgree: boolean
|
||||
/** The composer's one scroll offset, which the caret and the glyphs both follow. */
|
||||
scrollTop: number
|
||||
/** Furthest that offset can go. */
|
||||
scrollMax: number
|
||||
/**
|
||||
* Scrollable overflow the textarea holds on its own — 0, or a second offset
|
||||
* exists that nothing keeps equal to this one.
|
||||
*/
|
||||
inputScrollable: number
|
||||
/**
|
||||
* Distance between where the caret sits for a draft line and where the
|
||||
* backdrop paints that line, in pixels. A fixed value (the difference between
|
||||
* a line box's top and its glyph box's) is alignment; a value that CHANGES
|
||||
* with the scroll offset is the defect — the words trailing the caret.
|
||||
*/
|
||||
caretGlyphGap: number
|
||||
/**
|
||||
* How much that gap moves when the offset changes inside a single task: 0
|
||||
* here, because one box carries both layers. Assigning one box's offset to
|
||||
* another cannot be 0 — a scroll event is dispatched after the task that
|
||||
* moved the box, so between the two there is a frame with the caret at the
|
||||
* new offset and the glyphs at the old one.
|
||||
*/
|
||||
gapShiftOnScroll: number
|
||||
/**
|
||||
* Top of the LAST draft line relative to the visible box's top, in pixels: at
|
||||
* most `clientHeight` when that line is on screen. This is the reported
|
||||
* symptom as a number — with the layers uncoupled the backdrop stays at offset
|
||||
* 0, so the last line sits a full draft-height below the box.
|
||||
* most `clientHeight` when that line is on screen.
|
||||
*/
|
||||
lastLineOffset: number
|
||||
/** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */
|
||||
firstLineOffset: number
|
||||
/** Furthest the textarea can scroll. */
|
||||
inputMax: number
|
||||
/** Furthest the backdrop can scroll — equal to `inputMax`, or the mirror clamps below the caret. */
|
||||
backdropMax: number
|
||||
/** Content width the textarea wraps at. */
|
||||
inputWrapWidth: number
|
||||
/** Content width the backdrop wraps at — equal, or the layers break lines in different places. */
|
||||
@@ -113,14 +121,16 @@ interface ComposerMetrics {
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure both composer layers in the page.
|
||||
* Measure the composer's layers in the page, in the caret's coordinate frame.
|
||||
* @param page - the page under test.
|
||||
* @returns the two layers' offsets and where the draft's first and last lines sit.
|
||||
* @returns the offset, the caret-to-glyph gap, and where the draft's first and last lines sit.
|
||||
*/
|
||||
function measureComposer(page: Page): Promise<ComposerMetrics> {
|
||||
return page.evaluate(({ first, last }) => {
|
||||
const input = document.querySelector<HTMLTextAreaElement>('textarea:enabled')
|
||||
if (input === null) throw new Error('no live composer textarea in the DOM')
|
||||
const scroll = input.closest<HTMLElement>('[data-input-scroll]')
|
||||
if (scroll === null) throw new Error('the composer textarea is not inside a draft scrollport')
|
||||
const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
|
||||
if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
|
||||
// The hidden auto-grow mirror: the textarea's next sibling, and the layer
|
||||
@@ -128,47 +138,50 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
|
||||
// two that carry glyphs.
|
||||
const mirror = input.nextElementSibling
|
||||
if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea')
|
||||
const box = input.getBoundingClientRect()
|
||||
// The draft carries no chips or claim token, so the decoration walk emits it
|
||||
// as one text node — the backdrop's first, ahead of the trailing-line
|
||||
// sentinel React renders as a second one. Both markers live in that first
|
||||
// node, which is what the Range below needs.
|
||||
// as a single text node, which is what the Range below needs.
|
||||
const text = backdrop.firstChild
|
||||
if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node')
|
||||
const offsetOf = (marker: string): number => {
|
||||
const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
|
||||
/** Where the backdrop paints the line holding `marker`, in viewport coordinates. */
|
||||
const glyphTop = (marker: string): number => {
|
||||
const at = text.data.indexOf(marker)
|
||||
if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
|
||||
const range = document.createRange()
|
||||
range.setStart(text, at)
|
||||
range.setEnd(text, at + marker.length)
|
||||
return range.getBoundingClientRect().top - box.top
|
||||
return range.getBoundingClientRect().top
|
||||
}
|
||||
const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
|
||||
// Each layer's own maximum, probed by asking for an impossible offset and
|
||||
// reading back what it clamped to, then restored. Reading scrollHeight -
|
||||
// clientHeight instead would compute the maximum rather than observe it.
|
||||
const restore = input.scrollTop
|
||||
const restoreBackdrop = backdrop.scrollTop
|
||||
input.scrollTop = 1e7
|
||||
backdrop.scrollTop = 1e7
|
||||
const inputMax = input.scrollTop
|
||||
const backdropMax = backdrop.scrollTop
|
||||
input.scrollTop = restore
|
||||
backdrop.scrollTop = restoreBackdrop
|
||||
const paddingTop = Number.parseFloat(getComputedStyle(input).paddingTop)
|
||||
// Where the CARET sits on the draft's first line: the textarea lays its own
|
||||
// (transparent) glyphs out from its border box, shifted by any offset it
|
||||
// holds itself. Reading the caret's frame this way rather than the
|
||||
// scrollport's is what makes the gap the user-visible quantity — it stays
|
||||
// honest if the textarea ever starts scrolling on its own again.
|
||||
const gap = (): number =>
|
||||
Math.round(input.getBoundingClientRect().top + paddingTop - input.scrollTop - glyphTop(first))
|
||||
// The same-task probe: move the offset and re-read the gap before the task
|
||||
// ends, which is before any scroll event could have run a listener.
|
||||
const before = gap()
|
||||
const restore = scroll.scrollTop
|
||||
scroll.scrollTop = restore === 0 ? 120 : 0
|
||||
const gapShiftOnScroll = Math.abs(gap() - before)
|
||||
scroll.scrollTop = restore
|
||||
const box = scroll.getBoundingClientRect()
|
||||
return {
|
||||
inputMax,
|
||||
backdropMax,
|
||||
inputWrapWidth: input.clientWidth,
|
||||
backdropWrapWidth: backdrop.clientWidth,
|
||||
mirrorWrapWidth: mirror.clientWidth,
|
||||
overflows: input.scrollHeight > input.clientHeight,
|
||||
clientHeight: input.clientHeight,
|
||||
visibleLines: Math.floor(input.clientHeight / lineHeight),
|
||||
inputScrollTop: input.scrollTop,
|
||||
backdropScrollTop: backdrop.scrollTop,
|
||||
layersAgree: input.scrollTop === backdrop.scrollTop,
|
||||
lastLineOffset: offsetOf(last),
|
||||
firstLineOffset: offsetOf(first),
|
||||
overflows: scroll.scrollHeight > scroll.clientHeight,
|
||||
clientHeight: scroll.clientHeight,
|
||||
visibleLines: Math.floor(scroll.clientHeight / lineHeight),
|
||||
scrollTop: scroll.scrollTop,
|
||||
scrollMax: scroll.scrollHeight - scroll.clientHeight,
|
||||
inputScrollable: input.scrollHeight - input.clientHeight,
|
||||
caretGlyphGap: before,
|
||||
gapShiftOnScroll,
|
||||
lastLineOffset: glyphTop(last) - box.top,
|
||||
firstLineOffset: glyphTop(first) - box.top,
|
||||
}
|
||||
}, { first: FIRST_MARKER, last: LAST_MARKER })
|
||||
}
|
||||
@@ -179,43 +192,56 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
|
||||
* Absolute glyph coordinates are deliberately absent: they depend on font
|
||||
* metrics and would make the fixture fail on a machine that measures text
|
||||
* differently — a golden that needs re-recording per platform documents the
|
||||
* platform, not the change. What is recorded is the cap, the layer agreement,
|
||||
* and which lines are on screen, each a comparison that survives any layout
|
||||
* keeping the coupling.
|
||||
* platform, not the change. What is recorded is the cap, the caret-to-glyph
|
||||
* relation, and which lines are on screen, each a comparison that survives any
|
||||
* layout keeping the coupling.
|
||||
* @param top - metrics with the draft scrolled to its start.
|
||||
* @param bottom - metrics with the draft scrolled to its end.
|
||||
* @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
|
||||
* @param pasted - metrics right after a long block was pasted at the draft's end.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics): string {
|
||||
function renderGeometry(
|
||||
top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics,
|
||||
): string {
|
||||
return [
|
||||
'# Composer draft scrolling (14-line cap, two text layers)',
|
||||
'# Composer draft scrolling (14-line cap, two text layers, one scrollport)',
|
||||
'',
|
||||
'## At the start of the draft',
|
||||
'',
|
||||
`- draft overflows the capped box: ${String(top.overflows)}`,
|
||||
`- visible lines: ${String(top.visibleLines)}`,
|
||||
`- both layers share one scroll extent: ${String(top.inputMax === top.backdropMax)}`,
|
||||
`- the textarea holds no scroll offset of its own: ${String(top.inputScrollable === 0)}`,
|
||||
`- all three layers wrap at one width: ${String(
|
||||
top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth,
|
||||
)}`,
|
||||
`- textarea scroll offset: ${String(top.inputScrollTop)}px`,
|
||||
`- glyph layer tracks it: ${String(top.layersAgree)}`,
|
||||
`- scroll offset: ${String(top.scrollTop)}px`,
|
||||
`- caret and glyphs stay level when the offset changes: ${String(top.gapShiftOnScroll === 0)}`,
|
||||
`- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
|
||||
`- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
|
||||
'',
|
||||
'## Scrolled to the end of the draft',
|
||||
'',
|
||||
`- textarea moved: ${String(bottom.inputScrollTop > 0)}`,
|
||||
`- glyph layer tracks it: ${String(bottom.layersAgree)}`,
|
||||
`- offset moved: ${String(bottom.scrollTop > 0)}`,
|
||||
`- caret sits on its own glyphs: ${String(bottom.caretGlyphGap === top.caretGlyphGap)}`,
|
||||
`- caret and glyphs stay level when the offset changes: ${String(bottom.gapShiftOnScroll === 0)}`,
|
||||
`- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
|
||||
`- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
|
||||
'',
|
||||
'## Draft ending in a newline, scrolled to the end',
|
||||
'',
|
||||
`- both layers share one scroll extent: ${String(trailingNewline.inputMax === trailingNewline.backdropMax)}`,
|
||||
`- glyph layer tracks the caret: ${String(trailingNewline.layersAgree)}`,
|
||||
`- last draft line is on screen: ${String(trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight)}`,
|
||||
`- caret sits on its own glyphs: ${String(trailingNewline.caretGlyphGap === top.caretGlyphGap)}`,
|
||||
`- the draft's own last line is on screen: ${String(
|
||||
trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight,
|
||||
)}`,
|
||||
'',
|
||||
'## Right after pasting a long block at the end',
|
||||
'',
|
||||
`- the composer scrolled to the caret it left: ${String(pasted.scrollTop > 0)}`,
|
||||
`- caret and glyphs stay level when the offset changes: ${String(pasted.gapShiftOnScroll === 0)}`,
|
||||
`- the pasted block's last line is on screen: ${String(
|
||||
pasted.lastLineOffset >= 0 && pasted.lastLineOffset < pasted.clientHeight,
|
||||
)}`,
|
||||
].join('\n').trimEnd()
|
||||
}
|
||||
|
||||
@@ -251,17 +277,16 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
// case below.
|
||||
await page.locator('textarea:enabled').first().hover()
|
||||
await page.mouse.wheel(0, -2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
|
||||
const metrics = await measureComposer(page)
|
||||
// The cap is the composer seat's `--dsh-composer-text-max-height` (336px =
|
||||
// 14 x 24px lines). The count, not the pixels: it is the figma constant and
|
||||
// survives a device-pixel-ratio change.
|
||||
expect(metrics.visibleLines).toBe(14)
|
||||
// Resting state: the draft's head is what a 40-line draft shows, and its
|
||||
// tail is far below the box. Both layers sit at the origin, which is why the
|
||||
// uncoupled build looks correct until something scrolls.
|
||||
expect(metrics.inputScrollTop).toBe(0)
|
||||
expect(metrics.layersAgree).toBe(true)
|
||||
// One scrolling box: the textarea is as tall as the draft, so there is no
|
||||
// second offset for the caret to hold while the glyphs hold another.
|
||||
expect(metrics.inputScrollable).toBe(0)
|
||||
expect(metrics.scrollTop).toBe(0)
|
||||
expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
|
||||
expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight)
|
||||
@@ -270,19 +295,12 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
|
||||
it('lays out all three text layers at one wrap width', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
|
||||
// The premise under the mirror, asserted rather than assumed. Only .input
|
||||
// scrolls, so only .input can lose content width to a scrollbar that
|
||||
// consumes layout space; a narrower .input wraps a long draft onto more
|
||||
// lines, ends up taller, and its larger maximum makes the mirrored offset
|
||||
// clamp below the caret. Measured on a standalone harness, an 8px width
|
||||
// difference is worth 2 to 5 lines on a wrap-sensitive draft.
|
||||
//
|
||||
// This holds on the lane's engine and is what a regression would break —
|
||||
// it is NOT vacuous: measured on the same app, WebKit reports 768 against
|
||||
// 776 here, which is the divergence the Agent Note records as a
|
||||
// pre-existing, engine-specific limitation. The mirror is unaffected there
|
||||
// today because the extents still agree; this assertion is what would
|
||||
// notice if the lane's engine ever moved into the same state.
|
||||
// A layer that breaks lines somewhere else puts the words under the wrong
|
||||
// caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive
|
||||
// draft. The three now share a containing block — the scrollport — so a
|
||||
// scrollbar that consumes layout space costs them the same width; before,
|
||||
// only the textarea scrolled, and WebKit reserved gutter space for it alone
|
||||
// (768 against 776) while chromium and firefox did not.
|
||||
const metrics = await measureComposer(page)
|
||||
expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
|
||||
// The mirror decides the box height, so it belongs in the same equality —
|
||||
@@ -292,66 +310,112 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('the glyphs cannot lag the caret: one task moves both', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-lag'))
|
||||
// The reported symptom, isolated. A scroll offset changes and the caret's
|
||||
// distance to its own glyphs is re-read before the task ends — before any
|
||||
// `scroll` listener could have run. With the layers on one scrollport the
|
||||
// browser moved both, so the distance is unchanged; with the glyph layer
|
||||
// catching up in a listener it is off by the whole delta until a later
|
||||
// frame, which is a caret flying away from its text mid-gesture.
|
||||
const metrics = await measureComposer(page)
|
||||
expect(metrics.gapShiftOnScroll).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('a wheel gesture over a long draft moves the words, not only the caret', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.hover()
|
||||
// One delta past the whole draft: the textarea clamps at its own end, and
|
||||
// the wheel-chaining handler leaves it native because the box is not yet at
|
||||
// its edge when the gesture starts (the chaining itself is owned by the
|
||||
// unit spec).
|
||||
const resting = (await measureComposer(page)).caretGlyphGap
|
||||
// One delta past the whole draft: the box clamps at its own end, and the
|
||||
// wheel-chaining handler leaves it native because the box is not yet at its
|
||||
// edge when the gesture starts (the chaining itself is owned by the unit spec).
|
||||
await page.mouse.wheel(0, 2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0)
|
||||
const metrics = await measureComposer(page)
|
||||
// The coupling, stated directly.
|
||||
expect(metrics.layersAgree).toBe(true)
|
||||
// The caret is still on its own glyphs after the gesture.
|
||||
expect(metrics.caretGlyphGap).toBe(resting)
|
||||
// The reported symptom, stated as what the user sees: the end of the draft
|
||||
// is on screen and its beginning is not. On the uncoupled build the glyph
|
||||
// layer stays at offset 0, so `lastLineOffset` is still a full draft below
|
||||
// the box and `firstLineOffset` is still 0 — the text never moved.
|
||||
// is on screen and its beginning is not.
|
||||
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
|
||||
expect(metrics.firstLineOffset).toBeLessThan(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('typing at the end of a scrolled draft keeps the layers together', async () => {
|
||||
it('typing at the end of a scrolled draft brings the caret back into view', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
|
||||
// The other way the box moves. Typing at the caret — parked at the draft's
|
||||
// end by the wheel gesture — scrolls it into view, which is a `scroll` like
|
||||
// any other; this pins that an edit is not a separate case needing its own
|
||||
// mirror, which is why one listener is the whole implementation.
|
||||
// The other way the box moves, and the one that depends on the browser: the
|
||||
// textarea no longer scrolls, so revealing the caret after an edit is a
|
||||
// scroll-into-view that has to walk up to the scrollport. Scroll away from
|
||||
// the caret first, so the edit has somewhere to bring it back from.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.press('End')
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, -2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
|
||||
await input.pressSequentially(' tail')
|
||||
const metrics = await measureComposer(page)
|
||||
expect(metrics.layersAgree).toBe(true)
|
||||
expect(metrics.scrollTop).toBeGreaterThan(0)
|
||||
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('pasting a long block scrolls to the caret it leaves at the end', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste'))
|
||||
// The composer suppresses the native paste — the machine owns the draft and
|
||||
// the undo log — and restores the caret programmatically, which reveals
|
||||
// nothing on its own: measured in chromium and WebKit, the view stayed
|
||||
// where it was while the caret sat at the end of the pasted block. The
|
||||
// restore now scrolls it into view, and this is the case that proves it.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.fill('one short line')
|
||||
await input.press('End')
|
||||
// A real `paste` event carrying real clipboard data, dispatched at the
|
||||
// textarea: the same event a Cmd-V delivers, and it runs the same handler.
|
||||
await input.evaluate((el, text) => {
|
||||
const data = new DataTransfer()
|
||||
data.setData('text/plain', text)
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
|
||||
// Ending in a newline is the shape the engines disagree on: the caret
|
||||
// lands on a line with nothing on it, where chromium reports no client
|
||||
// rects at all for the collapsed position.
|
||||
}, `\n${DRAFT}\n`)
|
||||
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
||||
// The restore lands one frame after the machine commits the draft, so the
|
||||
// box overflows before it moves; waiting on the offset is waiting for the
|
||||
// behavior itself, and its absence fails this poll.
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
const metrics = await measureComposer(page)
|
||||
// The caret is at the end of what was pasted, so the draft's last line is
|
||||
// what has to be on screen.
|
||||
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
|
||||
expect(metrics.gapShiftOnScroll).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
|
||||
// The layers reserve a final line box on different terms, so this shape is
|
||||
// the one that separates equal extents from a mirror that clamps early.
|
||||
// the one that separates a height every layer agrees on from a box measured
|
||||
// one line short of the caret's own last position.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.fill(DRAFT_TRAILING_NEWLINE)
|
||||
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
||||
const extents = await measureComposer(page)
|
||||
// The invariant the sentinel exists for. Without it the textarea measured
|
||||
// 652 against the backdrop's 628 — one 24px line apart.
|
||||
expect(extents.backdropMax).toBe(extents.inputMax)
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, 4000)
|
||||
await expect.poll(async () => {
|
||||
const m = await measureComposer(page)
|
||||
return m.inputScrollTop === m.inputMax
|
||||
return m.scrollTop === m.scrollMax
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
const bottom = await measureComposer(page)
|
||||
// At the very bottom the glyphs are level with the caret, not a line behind.
|
||||
expect(bottom.layersAgree).toBe(true)
|
||||
// At the very bottom the glyphs are level with the caret, and the draft's
|
||||
// own last line — the one before the empty final line — is on screen.
|
||||
expect(bottom.gapShiftOnScroll).toBe(0)
|
||||
expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
@@ -365,11 +429,11 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
await input.fill(DRAFT)
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, -2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
|
||||
const top = await measureComposer(page)
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, 2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0)
|
||||
const bottom = await measureComposer(page)
|
||||
await input.fill(DRAFT_TRAILING_NEWLINE)
|
||||
@@ -377,10 +441,25 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
await page.mouse.wheel(0, 4000)
|
||||
await expect.poll(async () => {
|
||||
const m = await measureComposer(page)
|
||||
return m.inputScrollTop === m.inputMax
|
||||
return m.scrollTop === m.scrollMax
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
const trailingNewline = await measureComposer(page)
|
||||
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline), MODE)
|
||||
// The paste path, measured the way a user meets it: a short draft, the
|
||||
// caret at its end, one long block pasted in.
|
||||
await input.fill('one short line')
|
||||
await input.press('End')
|
||||
await input.evaluate((el, text) => {
|
||||
const data = new DataTransfer()
|
||||
data.setData('text/plain', text)
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
|
||||
// The ordinary shape — not ending in a newline — so the collapsed branch
|
||||
// of the reveal keeps a real engine under it; the case above owns the
|
||||
// after-newline branch.
|
||||
}, `\n${DRAFT}`)
|
||||
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
const pasted = await measureComposer(page)
|
||||
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline, pasted), MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -106,6 +106,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. */
|
||||
@@ -133,6 +137,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
|
||||
@@ -167,6 +173,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,
|
||||
@@ -177,9 +186,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
|
||||
@@ -188,7 +199,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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -222,6 +254,8 @@ function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
|
||||
`- --dsh-scrollbar-thumb-hover: ${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)}`,
|
||||
@@ -299,6 +333,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
|
||||
@@ -317,6 +353,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)
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
# Composer draft scrolling (14-line cap, two text layers)
|
||||
# Composer draft scrolling (14-line cap, two text layers, one scrollport)
|
||||
|
||||
## At the start of the draft
|
||||
|
||||
- draft overflows the capped box: true
|
||||
- visible lines: 14
|
||||
- both layers share one scroll extent: true
|
||||
- the textarea holds no scroll offset of its own: true
|
||||
- all three layers wrap at one width: true
|
||||
- textarea scroll offset: 0px
|
||||
- glyph layer tracks it: true
|
||||
- scroll offset: 0px
|
||||
- caret and glyphs stay level when the offset changes: true
|
||||
- first draft line is on screen: true
|
||||
- last draft line is on screen: false
|
||||
|
||||
## Scrolled to the end of the draft
|
||||
|
||||
- textarea moved: true
|
||||
- glyph layer tracks it: true
|
||||
- offset moved: true
|
||||
- caret sits on its own glyphs: true
|
||||
- caret and glyphs stay level when the offset changes: true
|
||||
- first draft line has scrolled out above: true
|
||||
- last draft line is on screen: true
|
||||
|
||||
## Draft ending in a newline, scrolled to the end
|
||||
|
||||
- both layers share one scroll extent: true
|
||||
- glyph layer tracks the caret: true
|
||||
- last draft line is on screen: true
|
||||
- caret sits on its own glyphs: true
|
||||
- the draft's own last line is on screen: true
|
||||
|
||||
## Right after pasting a long block at the end
|
||||
|
||||
- the composer scrolled to the caret it left: true
|
||||
- caret and glyphs stay level when the offset changes: true
|
||||
- the pasted block's last line is on screen: true
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
- --dsh-scrollbar-thumb-hover: 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
|
||||
@@ -28,6 +30,8 @@
|
||||
- --dsh-scrollbar-thumb-hover: 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
|
||||
|
||||
Reference in New Issue
Block a user