From 9973a27f141d7102295d303450fed39b392d7448 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Mon, 22 Jun 2026 10:11:14 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(results):=20progressive=20loading=20?= =?UTF-8?q?=E2=80=94=20fix=20"Running=E2=80=A6null",=20add=20Cancel=20(Esc?= =?UTF-8?q?=20+=20KILL=20QUERY)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to how a slow/long query (repro: `select sleep(3)`) is shown, aligned to the design handoff §6b-running. 1. "Running…null" bug: setRunBtn passed `null` as a replaceChildren arg while running, and replaceChildren coerces null → a "null" text node (unlike the h() helper which skips it). Filter the array before replaceChildren. 2. Cancel a long query: a Cancel button in the results toolbar (replaces Copy/Export while running, red on hover) plus a global Esc handler. Cancel aborts the stream AND issues `KILL QUERY WHERE query_id=…` so the server stops too (best-effort; swallows errors). The Run button is just a disabled "Running…". On cancel the partial rows are kept and the result is flagged cancelled (not an error) → a red "Cancelled · partial" badge, Copy/Export re-enabled. 3. Progressive loading visuals: while running the results toolbar shows live accent counters — elapsed ms (ticking off a 100ms interval, so it advances even for row-less queries like sleep), rows-read, bytes-scanned — and the body shows a 2px streaming strip (determinate fill at read/total, else an indeterminate sweep) with a "Starting query…" spinner before the first batch. Partial rows stream into the table as they arrive. - core/stream.js: newResult adds `cancelled`. - net/ch-client.js: query_id on the run URL; killQuery(). - ui/app.js: setRunBtn null fix, run() (query_id + ms tick + cancelled), cancel(). - ui/shortcuts.js: Esc → cancel while running. - ui/results.js: running toolbar (live counters + Cancel), streaming strip, "Starting query…", cancelled badge. - ui/icons.js, styles.css: spinner icon + spin/runsweep animations and states. Tests cover all of it (gate green); verified live on otel with select sleep(3): button reads "Running…" with no null, live counters + Cancel + sweep strip appear, and Esc cancels → "Cancelled · partial". Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/core/stream.js | 1 + src/net/ch-client.js | 14 +++++++++++ src/styles.css | 42 ++++++++++++++++++++++--------- src/ui/app.js | 48 ++++++++++++++++++++++++++++-------- src/ui/icons.js | 1 + src/ui/results.js | 39 +++++++++++++++++++++++------ src/ui/shortcuts.js | 6 +++++ tests/helpers/fake-app.js | 2 ++ tests/unit/app.test.js | 47 +++++++++++++++++++++++++++++++---- tests/unit/ch-client.test.js | 24 +++++++++++++++++- tests/unit/results.test.js | 34 +++++++++++++++++++------ tests/unit/shortcuts.test.js | 9 +++++++ 12 files changed, 224 insertions(+), 43 deletions(-) diff --git a/src/core/stream.js b/src/core/stream.js index c50a598..b8a77e7 100644 --- a/src/core/stream.js +++ b/src/core/stream.js @@ -16,6 +16,7 @@ export function newResult(fmt) { rawFormat: fmt, progress: { rows: 0, bytes: 0, elapsed_ns: 0 }, error: null, + cancelled: false, pct: 0, }; } diff --git a/src/net/ch-client.js b/src/net/ch-client.js index c4a3e7c..d2171c8 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -75,6 +75,18 @@ export async function queryJson(ctx, sql) { return resp.json(); } +/** + * Best-effort `KILL QUERY` for the given query_id (the client also aborts the + * stream; this stops the server-side work). Swallows errors — cancellation must + * never throw at the call site, and the user lacking the privilege is non-fatal. + */ +export async function killQuery(ctx, queryId, sqlString) { + if (!queryId) return; + try { + await queryJson(ctx, 'KILL QUERY WHERE query_id = ' + sqlString(queryId) + ' ASYNC'); + } catch { /* best-effort */ } +} + /** Fetch `version()` + `uptime()`. Returns the version string ('' on shape miss). */ export async function loadServerVersion(ctx) { const json = await queryJson(ctx, 'SELECT version() AS v, uptime() AS u FORMAT JSON'); @@ -140,6 +152,8 @@ export async function runQuery(ctx, sql, o = {}) { const url = chUrl(ctx.origin, { format: fmtParam, extra: { wait_end_of_query: 1, add_http_cors_header: 1 }, + // Tagging the request with a query_id lets Cancel issue KILL QUERY for it. + params: o.queryId ? { query_id: o.queryId } : {}, }); const resp = await authedFetch(ctx, url, sql, o.signal); diff --git a/src/styles.css b/src/styles.css index 1ec65e6..aadad08 100644 --- a/src/styles.css +++ b/src/styles.css @@ -866,17 +866,37 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } border-radius: 4px; color: var(--fg-mute); } -/* progress */ -.progress-bar { - position: absolute; left: 0; right: 0; bottom: 0; - height: 2px; background: transparent; - overflow: hidden; pointer-events: none; -} -.progress-bar > i { - display: block; height: 100%; - background: var(--accent); - width: var(--progress, 0%); - transition: width .15s linear; +/* streaming progress strip + live run state */ +.stream-strip { + position: absolute; left: 0; right: 0; top: 0; + height: 2px; background: var(--bg-chip); + overflow: hidden; pointer-events: none; z-index: 2; +} +.stream-strip > i { display: block; height: 100%; background: var(--accent); } +.stream-strip > i.fill { transition: width .3s linear; } +.stream-strip > i.sweep { width: 40%; animation: runsweep 1.1s ease-in-out infinite; } +@keyframes runsweep { + 0% { transform: translateX(-110%); } + 100% { transform: translateX(310%); } +} +@keyframes spin { to { transform: rotate(360deg); } } +.spin { display: inline-flex; animation: spin .8s linear infinite; } +.placeholder.starting { color: var(--fg-mute); } +.placeholder.starting .spin { color: var(--accent); } +/* live run counters (accent) shown in the results toolbar while streaming */ +.stat.live, .stat.live .ic, .stat.live .v { color: var(--accent); } +.res-act.cancel-act:hover { + background: color-mix(in oklab, #ef4444 14%, transparent); + color: #ef4444; border-color: transparent; +} +.res-act kbd { + font-family: var(--mono); font-size: 9.5px; opacity: .7; + padding: 1px 4px; background: var(--bg-chip); border-radius: 3px; margin-left: 2px; +} +.cancelled-badge { + font-family: var(--mono); font-size: 10.5px; color: #ef4444; + padding: 2px 7px; border-radius: 4px; + background: color-mix(in oklab, #ef4444 12%, transparent); } /* scrollbars */ diff --git a/src/ui/app.js b/src/ui/app.js index 30b0883..db68e99 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -249,29 +249,41 @@ export function createApp(env = {}) { } // --- query run --------------------------------------------------------- + const now = () => (env.now || (() => win.performance.now()))(); + // Milliseconds since the running query started (0 when idle). Used for the + // live counter, computed fresh so each render/tick shows the current value. + app.elapsedMs = () => (app.state.runT0 != null ? now() - app.state.runT0 : 0); + // Update only the live elapsed-ms readout (no table re-render). Driven by an + // interval while running so it ticks even for queries that emit no rows (sleep). + function tickElapsed() { + if (app.dom.runElapsedEl) app.dom.runElapsedEl.textContent = app.elapsedMs().toFixed(0) + ' ms'; + } + app.tickElapsed = tickElapsed; + async function run() { - if (app.state.running) { - if (app.state.abortController) app.state.abortController.abort(); - return; - } + if (app.state.running) return; // already running — cancel via cancel()/Esc const tab = app.activeTab(); if (!tab.sql.trim()) return; await ensureConfig(); if (!(await getToken())) { chCtx.onSignedOut(); return; } const fmt = app.state.outputFormat || 'Table'; - const t0 = (env.now || (() => win.performance.now()))(); + const t0 = now(); tab.result = newResult(fmt); app.state.resultSort = { col: null, dir: 'asc' }; app.state.resultView = 'table'; app.state.running = true; + app.state.runT0 = t0; + app.state.runQueryId = cryptoObj.randomUUID ? cryptoObj.randomUUID() : 'q' + t0; setRunBtn(true); renderResults(app); app.state.abortController = new AbortController(); + app.state.runTick = setInterval(tickElapsed, 100); try { const out = await ch.runQuery(chCtx, tab.sql, { format: fmt, + queryId: app.state.runQueryId, signal: app.state.abortController.signal, onLine: (json) => applyStreamLine(json, tab.result), onChunk: () => renderResults(app), @@ -282,24 +294,39 @@ export function createApp(env = {}) { tab.result.progress.bytes = out.raw.length; } } catch (e) { - if (e.name === 'AbortError') tab.result.error = 'Query was cancelled'; + // Cancel = abort: keep whatever streamed in, flag it partial (no error). + if (e.name === 'AbortError') tab.result.cancelled = true; else if (e instanceof TypeError) tab.result.error = 'Network error'; else tab.result.error = String((e && e.message) || e); } finally { + clearInterval(app.state.runTick); + app.state.runTick = null; app.state.running = false; app.state.abortController = null; - tab.result.progress.elapsed_ns = ((env.now || (() => win.performance.now()))() - t0) * 1e6; + app.state.runQueryId = null; + app.state.runT0 = null; + tab.result.progress.elapsed_ns = (now() - t0) * 1e6; setRunBtn(false); renderResults(app); - if (!tab.result.error) app.recordHistory(tab); + if (!tab.result.error && !tab.result.cancelled) app.recordHistory(tab); } } + // Stop an in-flight query: abort the stream and KILL QUERY on the server. + function cancel() { + if (!app.state.running) return; + if (app.state.abortController) app.state.abortController.abort(); + ch.killQuery(chCtx, app.state.runQueryId, sqlString); + } function setRunBtn(running) { if (!app.dom.runBtn) return; app.dom.runBtn.disabled = running; - app.dom.runBtn.replaceChildren(Icon.play(), h('span', null, running ? 'Running…' : 'Run'), - running ? null : h('kbd', null, '⌘↵')); + // Build the children and drop the null (replaceChildren would otherwise + // coerce a null arg into a "null" text node → "Running…null"). + app.dom.runBtn.replaceChildren( + ...[Icon.play(), h('span', null, running ? 'Running…' : 'Run'), + running ? null : h('kbd', null, '⌘↵')].filter(Boolean)); } + app.setRunBtn = setRunBtn; // Pretty-print the editor's SQL via ClickHouse's formatQuery(), in place. async function formatQuery() { @@ -524,6 +551,7 @@ export function createApp(env = {}) { // --- actions registry -------------------------------------------------- app.actions = { run, + cancel, newTab: () => newTab(app), selectTab: (id) => selectTab(app, id), closeTab: (id) => closeTab(app, id), diff --git a/src/ui/icons.js b/src/ui/icons.js index 7ce7917..05d62b3 100644 --- a/src/ui/icons.js +++ b/src/ui/icons.js @@ -38,6 +38,7 @@ export const Icon = { play: () => svgFilled('M3 2l7 4-7 4z'), plus: () => svg('M6 2v8M2 6h8', 12, 12, { stroke: 1.6 }), close: () => svg('M2 2l6 6M8 2l-6 6', 10, 10, { stroke: 1.6 }), + spinner: () => svg('M6 1.2a4.8 4.8 0 1 1-4.8 4.8', 12, 12, { stroke: 1.6 }), search: () => iconEl('', 12, 12, 1.5), sun: () => iconEl(''), moon: () => svg('M11 7.5A4 4 0 1 1 6.5 3a3.2 3.2 0 0 0 4.5 4.5z', 14, 14), diff --git a/src/ui/results.js b/src/ui/results.js index 68ca836..602a450 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -78,12 +78,14 @@ export function renderResults(app) { body.appendChild(buildToolbar(app, r)); const inner = h('div', { class: 'res-body' }); + // While running, pin a streaming strip to the top of the body: a determinate + // fill at read/total when known, else an indeterminate sweep. + if (app.state.running) inner.appendChild(streamStrip(r)); const streamingBlank = app.state.running && (!r || (r.rows.length === 0 && r.rawText == null)); if (streamingBlank) { - inner.appendChild(h('div', { class: 'progress-bar', style: { '--progress': (r ? r.pct : 0) + '%' } }, h('i'))); - inner.appendChild(h('div', { class: 'placeholder' }, - h('div', null, 'Streaming results…'), - r ? h('code', null, formatRows(r.progress.rows) + ' rows · ' + formatBytes(r.progress.bytes)) : null)); + inner.appendChild(h('div', { class: 'placeholder starting' }, + h('span', { class: 'spin' }, Icon.spinner()), + h('div', null, 'Starting query…'))); } else if (!r) { inner.appendChild(h('div', { class: 'empty-results' }, h('div', { class: 'chip' }, Icon.play()), @@ -100,14 +102,19 @@ export function renderResults(app) { inner.appendChild(renderChart(r)); } else { inner.appendChild(renderTable(app, r)); - if (app.state.running) { - inner.appendChild(h('div', { class: 'progress-bar', style: { '--progress': r.pct + '%' } }, h('i'))); - } } body.appendChild(inner); region.replaceChildren(body); } +// 2px progress strip atop the results body while a query streams. +function streamStrip(r) { + return h('div', { class: 'stream-strip' }, + r && r.pct > 0 + ? h('i', { class: 'fill', style: { width: r.pct + '%' } }) + : h('i', { class: 'sweep' })); +} + function buildToolbar(app, r) { const isRaw = r && r.rawText != null; const toolbar = h('div', { class: 'res-toolbar' }); @@ -128,7 +135,23 @@ function buildToolbar(app, r) { } toolbar.appendChild(tabs); toolbar.appendChild(h('div', { style: { flex: '1' } })); - if (r) { + if (app.state.running) { + // Live counters (accent, mono) + Cancel — replaces the static stats while + // streaming. The ms element is updated in place by app.tickElapsed(). + app.dom.runElapsedEl = h('span', { class: 'v' }, app.elapsedMs().toFixed(0) + ' ms'); + toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic spin' }, Icon.spinner()), app.dom.runElapsedEl)); + toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic' }, Icon.rows()), + h('span', { class: 'v' }, formatRows(r ? r.progress.rows : 0) + ' rows'))); + toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic' }, Icon.bytes()), + h('span', { class: 'v' }, formatBytes(r ? r.progress.bytes : 0)))); + toolbar.appendChild(h('button', { + class: 'res-act cancel-act', title: 'Cancel query (Esc)', + onclick: () => app.actions.cancel(), + }, Icon.close(), h('span', null, 'Cancel'), h('kbd', null, 'Esc'))); + } else if (r) { + if (r.cancelled) { + toolbar.appendChild(h('span', { class: 'cancelled-badge' }, 'Cancelled · partial')); + } const ms = (r.progress.elapsed_ns / 1e6).toFixed(0); toolbar.appendChild(h('div', { class: 'stat' }, h('span', { class: 'ic' }, Icon.clock()), h('span', { class: 'v' }, ms + ' ms'))); toolbar.appendChild(h('div', { class: 'stat' }, h('span', { class: 'ic' }, Icon.rows()), diff --git a/src/ui/shortcuts.js b/src/ui/shortcuts.js index 8ee2045..3561b65 100644 --- a/src/ui/shortcuts.js +++ b/src/ui/shortcuts.js @@ -56,6 +56,12 @@ export function openShortcuts(app) { export function handleKeydown(e, app) { const mod = e.metaKey || e.ctrlKey; const signedIn = app.isSignedIn(); + // Esc cancels an in-flight query (aborts the stream + KILL QUERY). + if (e.key === 'Escape' && app.state.running) { + e.preventDefault(); + app.actions.cancel(); + return 'cancel'; + } if (mod && e.key === 'Enter') { // ⌘/Ctrl+Shift+Enter = format (gated by sign-in); ⌘/Ctrl+Enter = run. if (e.shiftKey) { diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index fe1b676..ad6a5d5 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -18,6 +18,7 @@ export function makeApp(over = {}) { savePref: vi.fn(), saveJSON: vi.fn(), updateSaveBtn: vi.fn(), + elapsedMs: () => 0, editingSavedId: null, showLogin: vi.fn(), signOut: vi.fn(), @@ -33,6 +34,7 @@ export function makeApp(over = {}) { }, actions: { run: vi.fn(), + cancel: vi.fn(), newTab: vi.fn(), selectTab: vi.fn(), closeTab: vi.fn(), diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 7683d75..c0506e6 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -206,12 +206,47 @@ describe('query run', () => { await app.actions.run(); expect(app.activeTab().result).toBeNull(); }); - it('aborts a running query on a second invocation', async () => { + it('run() while already running is a no-op (cancel is separate)', async () => { const { app } = appForRun([]); app.state.running = true; - app.state.abortController = { abort: vi.fn() }; + const ac = { abort: vi.fn() }; + app.state.abortController = ac; await app.actions.run(); - expect(app.state.abortController).toBeTruthy(); + expect(ac.abort).not.toHaveBeenCalled(); // re-running no longer aborts + expect(app.state.running).toBe(true); + }); + it('setRunBtn: "Running…" with no trailing "null"; "Run" + kbd when idle', () => { + const { app } = appForRun([]); + app.setRunBtn(true); + expect(app.dom.runBtn.disabled).toBe(true); + expect(app.dom.runBtn.textContent).toBe('Running…'); // regression: not "Running…null" + app.setRunBtn(false); + expect(app.dom.runBtn.disabled).toBe(false); + expect(app.dom.runBtn.textContent).toContain('Run'); + expect(app.dom.runBtn.querySelector('kbd')).not.toBeNull(); + }); + it('tickElapsed updates the live ms readout, and no-ops without the element', () => { + const { app } = appForRun([]); + app.state.runT0 = 0; + app.dom.runElapsedEl = document.createElement('span'); + app.tickElapsed(); // env.now → 0 + expect(app.dom.runElapsedEl.textContent).toBe('0 ms'); + app.dom.runElapsedEl = null; + expect(() => app.tickElapsed()).not.toThrow(); + }); + it('cancel() aborts + issues KILL QUERY when running; no-op when idle', async () => { + const { app, e } = appForRun([]); + app.actions.cancel(); // idle → no-op, no throw + const abort = vi.fn(); + app.state.running = true; + app.state.abortController = { abort, signal: {} }; + app.state.runQueryId = 'qid-1'; + app.actions.cancel(); + expect(abort).toHaveBeenCalled(); + await new Promise((r) => setTimeout(r)); // let the fire-and-forget KILL QUERY run + const kill = e.fetch.mock.calls.find((c) => /KILL QUERY/.test((c[1] && c[1].body) || '')); + expect(kill).toBeTruthy(); + expect(kill[1].body).toContain("query_id = 'qid-1'"); }); it('surfaces a query error', async () => { const { app } = appForRun([ @@ -589,13 +624,15 @@ describe('exhaustive controller coverage', () => { await app.actions.run(); expect(app.activeTab().result.error).toBe('Network error'); }); - it('run(): AbortError → "Query was cancelled"', async () => { + it('run(): AbortError marks the result cancelled (keeps partial rows, no error)', async () => { const e = env({ fetch: vi.fn(async () => { const err = new Error('x'); err.name = 'AbortError'; throw err; }) }); const app = createApp(e); app.renderApp(); app.activeTab().sql = 'SELECT 1'; await app.actions.run(); - expect(app.activeTab().result.error).toBe('Query was cancelled'); + expect(app.activeTab().result.cancelled).toBe(true); + expect(app.activeTab().result.error).toBeNull(); + expect(app.state.history.length).toBe(0); // cancelled runs are not recorded }); it('run(): generic error → message', async () => { const e = env({ fetch: vi.fn(async () => { throw new Error('weird'); }) }); diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index 4d1b37d..b74ed6e 100644 --- a/tests/unit/ch-client.test.js +++ b/tests/unit/ch-client.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { - chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, runQuery, + chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, runQuery, killQuery, } from '../../src/net/ch-client.js'; import { sqlString } from '../../src/core/format.js'; @@ -218,4 +218,26 @@ describe('runQuery', () => { await runQuery(ctx, 'x', { signal }); expect(ctx.fetch.mock.calls[0][1].signal).toBe(signal); }); + it('tags the run request with query_id when given', async () => { + const ctx = ctxWith(async () => streamResp(['{"row":{}}\n'])); + await runQuery(ctx, 'x', { queryId: 'abc-123' }); + expect(ctx.fetch.mock.calls[0][0]).toContain('query_id=abc-123'); + }); +}); + +describe('killQuery', () => { + it('POSTs KILL QUERY for the query_id', async () => { + const ctx = ctxWith(async () => jsonResp({ data: [] })); + await killQuery(ctx, 'abc-123', sqlString); + expect(ctx.fetch.mock.calls[0][1].body).toBe("KILL QUERY WHERE query_id = 'abc-123' ASYNC"); + }); + it('no-ops without a query_id', async () => { + const ctx = ctxWith(async () => jsonResp({ data: [] })); + await killQuery(ctx, null, sqlString); + expect(ctx.fetch).not.toHaveBeenCalled(); + }); + it('swallows errors (cancellation must never throw)', async () => { + const ctx = ctxWith(async () => { throw new Error('boom'); }); + await expect(killQuery(ctx, 'q', sqlString)).resolves.toBeUndefined(); + }); }); diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index aedaf46..96d1f7e 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -31,19 +31,28 @@ describe('renderResults states', () => { renderResults(app); expect(app.dom.resultsRegion.textContent).toContain('to run query'); }); - it('streaming-blank with a partial result shows progress', () => { + it('streaming-blank shows "Starting query…", a determinate strip, live counters + Cancel, and no "null"', () => { const r = newResult('Table'); r.pct = 40; r.progress = { rows: 10, bytes: 50, elapsed_ns: 0 }; const app = appWithResult(r, { running: true }); renderResults(app); - expect(app.dom.resultsRegion.querySelector('.progress-bar')).not.toBeNull(); - expect(app.dom.resultsRegion.textContent).toContain('Streaming results…'); - }); - it('streaming-blank with no result object', () => { + const region = app.dom.resultsRegion; + expect(region.querySelector('.stream-strip .fill')).not.toBeNull(); // pct>0 → determinate + expect(region.textContent).toContain('Starting query…'); + expect(region.textContent).not.toMatch(/null/i); // regression: no "Loading/Streaming null" + // live counters (rows/bytes) + Cancel in the toolbar + expect(region.textContent).toContain('10 rows'); + const cancel = region.querySelector('.cancel-act'); + expect(cancel).not.toBeNull(); + click(cancel); + expect(app.actions.cancel).toHaveBeenCalled(); + }); + it('streaming-blank with no result object uses an indeterminate sweep', () => { const app = appWithResult(null, { running: true }); renderResults(app); - expect(app.dom.resultsRegion.querySelector('.progress-bar')).not.toBeNull(); + expect(app.dom.resultsRegion.querySelector('.stream-strip .sweep')).not.toBeNull(); + expect(app.dom.resultsRegion.textContent).toContain('Starting query…'); }); it('renders an error', () => { const r = newResult('Table'); @@ -76,11 +85,20 @@ describe('renderResults states', () => { renderResults(app); expect(app.dom.resultsRegion.textContent).toContain('Query returned 0 rows.'); }); - it('table view (default) renders rows + progress bar while running', () => { + it('table view (default) renders partial rows + streaming strip while running', () => { const app = appWithResult(tableResult(), { running: true, resultView: 'table' }); renderResults(app); expect(app.dom.resultsRegion.querySelectorAll('.res-table tbody tr')).toHaveLength(2); - expect(app.dom.resultsRegion.querySelector('.progress-bar')).not.toBeNull(); + expect(app.dom.resultsRegion.querySelector('.stream-strip')).not.toBeNull(); + }); + it('a cancelled result shows the "Cancelled · partial" badge with Copy/Export', () => { + const r = tableResult(); + r.cancelled = true; + const app = appWithResult(r, { resultView: 'table' }); + renderResults(app); + const region = app.dom.resultsRegion; + expect(region.querySelector('.cancelled-badge').textContent).toContain('Cancelled · partial'); + expect([...region.querySelectorAll('.res-act')].some((b) => /Copy/.test(b.textContent))).toBe(true); }); it('json view', () => { const app = appWithResult(tableResult(), { resultView: 'json' }); diff --git a/tests/unit/shortcuts.test.js b/tests/unit/shortcuts.test.js index 38b0c14..e176927 100644 --- a/tests/unit/shortcuts.test.js +++ b/tests/unit/shortcuts.test.js @@ -61,6 +61,15 @@ describe('handleKeydown', () => { expect(handleKeydown(ev({ metaKey: true, key: 'Enter' }), app)).toBe('run'); expect(app.actions.run).toHaveBeenCalled(); }); + it('Escape cancels a running query, and is a no-op otherwise', () => { + const app = makeApp(); + app.state.running = false; + expect(handleKeydown(ev({ key: 'Escape' }), app)).toBeNull(); + expect(app.actions.cancel).not.toHaveBeenCalled(); + app.state.running = true; + expect(handleKeydown(ev({ key: 'Escape' }), app)).toBe('cancel'); + expect(app.actions.cancel).toHaveBeenCalled(); + }); it('⌘T / ⌘W are no longer intercepted (browser keeps them)', () => { const app = makeApp(); expect(handleKeydown(ev({ metaKey: true, key: 't' }), app)).toBeNull(); From 039d854ea96747437a6f6999571a5339a1d2ba7b Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Mon, 22 Jun 2026 10:20:14 +0200 Subject: [PATCH 2/2] =?UTF-8?q?perf(results):=20drop=20wait=5Fend=5Fof=5Fq?= =?UTF-8?q?uery=20on=20the=20streaming=20path=20(first=20rows=20~16s=20?= =?UTF-8?q?=E2=86=92=20~0.5s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming Table query was sent with wait_end_of_query=1, which buffers the entire response server-side and sends it only after the query finishes — so "progressive" loading wasn't progressive: on SELECT * FROM a 1.3M-row table the first rows appeared at ~16s. Measured against the same query with the flag off, the first block arrives at ~0.5s (~600 rows). Keep wait_end_of_query=1 only for the raw (TSV/JSON) modes — those read the whole body anyway, so they lose nothing and keep a clean HTTP error status. The streaming path drops it and already surfaces mid-stream errors via the in-band `exception` line (applyStreamLine), so error handling is unchanged. Verified live on otel: SELECT * FROM claude_otel.otel_logs now paints its first ~630 rows at ~473ms (was ~13–16s). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/net/ch-client.js | 7 ++++++- tests/unit/ch-client.test.js | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/net/ch-client.js b/src/net/ch-client.js index d2171c8..4cd0da2 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -151,7 +151,12 @@ export async function runQuery(ctx, sql, o = {}) { : 'JSONCompact'; const url = chUrl(ctx.origin, { format: fmtParam, - extra: { wait_end_of_query: 1, add_http_cors_header: 1 }, + // wait_end_of_query buffers the whole response server-side so the HTTP + // status reflects errors — but it defeats progressive streaming (first rows + // wait for the query to finish: ~16s vs ~0.5s on a 1.3M-row scan). Keep it + // only for raw modes (read whole anyway); the streaming Table path drops it + // and surfaces mid-stream errors via the in-band `exception` line instead. + extra: { ...(isStreaming ? {} : { wait_end_of_query: 1 }), add_http_cors_header: 1 }, // Tagging the request with a query_id lets Cancel issue KILL QUERY for it. params: o.queryId ? { query_id: o.queryId } : {}, }); diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index b74ed6e..20b6234 100644 --- a/tests/unit/ch-client.test.js +++ b/tests/unit/ch-client.test.js @@ -223,6 +223,14 @@ describe('runQuery', () => { await runQuery(ctx, 'x', { queryId: 'abc-123' }); expect(ctx.fetch.mock.calls[0][0]).toContain('query_id=abc-123'); }); + it('streams without wait_end_of_query; raw modes keep it for clean error status', async () => { + const s = ctxWith(async () => streamResp(['{"row":{}}\n'])); + await runQuery(s, 'x', { format: 'Table' }); + expect(s.fetch.mock.calls[0][0]).not.toContain('wait_end_of_query'); // progressive first rows + const raw = ctxWith(async () => textResp('a\tb')); + await runQuery(raw, 'x', { format: 'TSV' }); + expect(raw.fetch.mock.calls[0][0]).toContain('wait_end_of_query=1'); + }); }); describe('killQuery', () => {