Add transaction accelerator (prioritisetransaction UI + API)

Full-stack feature for boosting transactions via bitcoind's
prioritisetransaction RPC:

Backend:
- New accelerator package with Accelerate, Cancel, MaxFeerate, List,
  and background Cleanup goroutine (removes confirmed/dropped txs)
- RPC wrappers: GetMempoolEntry, PrioritiseTransaction,
  GetRawMempoolVerbose, IsRPCError helpers
- SQLite boosted_txs table for persistence across restarts
- Revenue impact measured via getblocktemplate before/after comparison
- Hard cap at 2000 sat/vB; MaxFeerate uses fees.base (not modified)
  to ignore our own prior priority adjustments

Frontend:
- Rocket icon in header with 10s jiggle animation
- AcceleratorPage with flame-gradient border, txid input, feerate
  input, "Prioritize above all" button with spinner, boost list with
  cancel buttons, and dismissible error/success messages
- Hash-based routing (#/accelerator)
- SharesBar font size bump
This commit is contained in:
satoshi
2026-05-11 03:54:35 +03:00
parent 72890d900b
commit dc7da6ab19
12 changed files with 1247 additions and 25 deletions
+3
View File
@@ -11,6 +11,7 @@
import BestShares from "./lib/BestShares.svelte";
import UserDetailPage from "./lib/UserDetailPage.svelte";
import WorkerDetailPage from "./lib/WorkerDetailPage.svelte";
import AcceleratorPage from "./lib/AcceleratorPage.svelte";
import SharesBar from "./lib/SharesBar.svelte";
import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
@@ -34,6 +35,8 @@
<div class="stat-sub bad">{snap.error}</div>
{/if}
</div>
{:else if selection.page === "accelerator"}
<AcceleratorPage />
{:else if selection.worker}
<WorkerDetailPage />
{:else if selection.user}
+650
View File
@@ -0,0 +1,650 @@
<script lang="ts">
import { clearSelection } from "../stores/selection.svelte";
import { snap } from "../stores/snapshot.svelte";
import { formatHashrate } from "../format";
type BoostedTx = {
txid: string;
original_feerate: number;
boosted_feerate: number;
fee_delta: number;
vsize: number;
boosted_at: number;
};
let txid = $state("");
let feerateInput = $state("");
let loading = $state(false);
let error = $state("");
let success = $state("");
let feeLostSats = $state(0);
let boostedList = $state<BoostedTx[]>([]);
let loadingMax = $state(false);
const feerate = $derived(parseFloat(feerateInput) || 0);
const validTxid = $derived(/^[0-9a-fA-F]{64}$/.test(txid.trim()));
const overCap = $derived(feerate > 2000);
const explorerBase = $derived(
snap.data?.mempool_base_url || "https://mempool.space"
);
const poolHashrate = $derived(snap.data?.hashrate_hs_5m ?? 0);
const networkHashrate = $derived(snap.data?.network_hashrate_hs ?? 0);
const poolShare = $derived(
networkHashrate > 0 ? poolHashrate / networkHashrate : 0
);
async function fetchList() {
try {
const res = await fetch("/api/accelerate/list");
if (res.ok) {
boostedList = await res.json();
}
} catch { /* ignore */ }
}
async function handleBoost() {
if (!validTxid || feerate <= 0 || overCap) return;
loading = true;
error = "";
success = "";
feeLostSats = 0;
try {
const res = await fetch("/api/accelerate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ txid: txid.trim().toLowerCase(), fee_rate_satvb: feerate }),
});
const data = await res.json();
if (!res.ok) {
error = data.error || `HTTP ${res.status}`;
} else {
feeLostSats = data.fee_lost_sats ?? 0;
const from = data.original_feerate.toFixed(1);
const to = data.boosted_feerate.toFixed(1);
if (data.fee_lost_error) {
success = `Boosted from ${from} to ${to} sat/vB. Revenue impact could not be measured: ${data.fee_lost_error}`;
} else if (feeLostSats > 0) {
success = `Boosted from ${from} to ${to} sat/vB. Revenue impact: -${feeLostSats.toLocaleString()} sats (${(feeLostSats / 1e8).toFixed(8)} BTC) per block mined.`;
} else {
success = `Boosted from ${from} to ${to} sat/vB. No revenue impact — the displaced transaction's fee was equal or lower.`;
}
txid = "";
feerateInput = "";
fetchList();
}
} catch (e) {
error = (e as Error).message;
}
loading = false;
}
async function handleMaxPriority() {
loadingMax = true;
error = "";
try {
const res = await fetch("/api/accelerate/max", { method: "POST" });
const data = await res.json();
if (!res.ok) {
error = data.error || `HTTP ${res.status}`;
} else {
feerateInput = data.max_feerate_satvb.toFixed(1);
}
} catch (e) {
error = (e as Error).message;
}
loadingMax = false;
}
async function handleCancel(cancelTxid: string) {
error = "";
try {
const res = await fetch("/api/accelerate/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ txid: cancelTxid }),
});
const data = await res.json();
if (!res.ok) {
error = data.error || `HTTP ${res.status}`;
} else {
boostedList = boostedList.filter(t => t.txid !== cancelTxid);
}
} catch (e) {
error = (e as Error).message;
}
}
function fmtAgo(unix: number): string {
const diff = Math.floor(Date.now() / 1000 - unix);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
function onKey(ev: KeyboardEvent): void {
if (ev.key === "Escape") clearSelection();
}
$effect(() => { fetchList(); });
$effect(() => {
const id = setInterval(fetchList, 15000);
return () => clearInterval(id);
});
</script>
<svelte:window onkeydown={onKey} />
<section class="page">
<nav class="crumbs">
<button type="button" class="back" onclick={clearSelection}>
&larr; Back to dashboard
</button>
</nav>
<header class="accel-header">
<div class="flame-border" aria-hidden="true"></div>
<div class="header-content">
<h2 class="title">
<svg class="rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
</svg>
Transaction Accelerator
</h2>
<p class="explainer">
Boost a transaction's priority in your next block template using
<code>prioritisetransaction</code>. When your pool finds a block,
the boosted transaction will be included regardless of its real feerate.
</p>
<div class="info-box">
<p>
<strong>How it works:</strong> The boosted transaction takes the place of
the lowest-feerate transaction at the bottom of the block template. If the
mempool isn't full (all transactions already fit), there is <em>zero cost</em>.
</p>
<p>
<strong>Revenue impact:</strong> After boosting, the server compares the block template's
coinbase value before and after. The difference (if any) is the actual fee revenue
lost per block mined. This is shown after each boost.
</p>
<p>
Your pool: {formatHashrate(poolHashrate)} /
{formatHashrate(networkHashrate)} network
({poolShare > 0 ? `${(poolShare * 100).toFixed(6)}%` : "—"} of blocks).
</p>
</div>
</div>
</header>
<section class="card boost-form">
<h3>Boost a Transaction</h3>
<div class="field">
<label for="txid-input">Transaction ID</label>
<input
id="txid-input"
type="text"
class="mono"
placeholder="64-character hex txid"
bind:value={txid}
maxlength={64}
spellcheck={false}
/>
{#if txid && !validTxid}
<span class="field-error">Must be exactly 64 hex characters</span>
{/if}
</div>
<div class="field">
<label for="feerate-input">Target feerate (sat/vB) — max 2,000</label>
<div class="feerate-row">
<input
id="feerate-input"
type="number"
min="1"
max="2000"
step="0.1"
placeholder="e.g. 50"
bind:value={feerateInput}
/>
<button
type="button"
class="max-btn"
onclick={handleMaxPriority}
disabled={loadingMax}
title="Set feerate to 2x the highest in mempool (capped at 2000)"
>
{#if loadingMax}
<span class="spinner small"></span> Loading...
{:else}
Prioritize above all
{/if}
</button>
</div>
{#if overCap}
<span class="field-error">Cannot exceed 2,000 sat/vB</span>
{/if}
</div>
<button
type="button"
class="boost-btn"
onclick={handleBoost}
disabled={loading || !validTxid || feerate <= 0 || overCap}
>
{#if loading}
<span class="spinner"></span> Boosting...
{:else}
Boost Transaction
{/if}
</button>
{#if error}
<div class="msg error">
<span>{error}</span>
<button type="button" class="dismiss" onclick={() => error = ""}>&times;</button>
</div>
{/if}
{#if success}
<div class="msg success">
<span>{success}</span>
<button type="button" class="dismiss" onclick={() => success = ""}>&times;</button>
</div>
{/if}
</section>
<section class="card">
<h3>Boosted Transactions {#if boostedList.length > 0}<span class="count">{boostedList.length}</span>{/if}</h3>
{#if boostedList.length === 0}
<div class="empty">No transactions currently boosted. They are automatically removed when confirmed or dropped from the mempool.</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Transaction</th>
<th class="num">Original</th>
<th class="num">Boosted to</th>
<th class="num">Delta</th>
<th class="num">vsize</th>
<th>When</th>
<th></th>
</tr>
</thead>
<tbody>
{#each boostedList as tx (tx.txid)}
<tr>
<td>
<a
class="txid-link mono"
href="{explorerBase}/tx/{tx.txid}"
target="_blank"
rel="noopener noreferrer"
title={tx.txid}
>{tx.txid.slice(0, 10)}...{tx.txid.slice(-10)}</a>
</td>
<td class="num">{tx.original_feerate.toFixed(1)}<span class="unit"> sat/vB</span></td>
<td class="num boost-rate">{tx.boosted_feerate.toFixed(1)}<span class="unit"> sat/vB</span></td>
<td class="num delta">{tx.fee_delta.toLocaleString()}<span class="unit"> sats</span></td>
<td class="num">{tx.vsize.toLocaleString()}</td>
<td>{fmtAgo(tx.boosted_at)}</td>
<td>
<button
type="button"
class="cancel-btn"
onclick={() => handleCancel(tx.txid)}
title="Reverse the priority boost"
>Cancel</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</section>
<style>
.page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.crumbs {
margin-bottom: 0.25rem;
}
.back {
font: inherit;
font-size: 1rem;
color: var(--fg-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5em 1em;
cursor: pointer;
}
.back:hover {
color: var(--fg);
border-color: var(--accent);
background: var(--bg-hover);
}
/* ── Header with animated flame border ── */
.accel-header {
position: relative;
border-radius: 14px;
padding: 2px;
background: linear-gradient(135deg, #ff6a00, #ff2d2d, #ff6a00, #ffb347);
background-size: 300% 300%;
animation: flame-gradient 4s ease infinite;
}
@keyframes flame-gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.header-content {
background: var(--bg-card);
border-radius: 12px;
padding: 2rem;
}
.title {
display: flex;
align-items: center;
gap: 0.5em;
margin: 0 0 1rem;
font-size: 1.8rem;
font-weight: 700;
}
.rocket {
width: 1.3em;
height: 1.3em;
color: var(--accent);
}
.explainer {
margin: 0 0 1.25rem;
color: var(--fg-dim);
line-height: 1.6;
font-size: 1.1rem;
}
.explainer code {
background: var(--bg-alt);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
color: var(--fg);
}
.info-box {
background: rgba(255, 180, 71, 0.06);
border: 1px solid rgba(255, 180, 71, 0.2);
border-radius: 10px;
padding: 1.25rem 1.5rem;
font-size: 1rem;
line-height: 1.6;
color: var(--fg-dim);
}
.info-box p {
margin: 0 0 0.75rem;
}
.info-box p:last-child {
margin-bottom: 0;
}
.info-box strong {
color: var(--fg);
}
.info-box em {
color: var(--good);
font-style: normal;
font-weight: 600;
}
/* ── Boost form ── */
.boost-form {
padding: 2rem;
}
.boost-form h3 {
margin: 0 0 1.5rem;
font-size: 1.3rem;
font-weight: 700;
}
.field {
margin-bottom: 1.5rem;
}
.field label {
display: block;
font-size: 0.95rem;
font-weight: 500;
color: var(--fg-dim);
margin-bottom: 0.5em;
}
.field input {
width: 100%;
padding: 0.75em 1em;
font: inherit;
font-size: 1.1rem;
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--fg);
outline: none;
transition: border-color 0.15s;
}
.field input:focus {
border-color: var(--accent);
}
.field-error {
display: block;
color: var(--bad);
font-size: 0.9rem;
margin-top: 0.4em;
}
.feerate-row {
display: flex;
gap: 0.75rem;
align-items: stretch;
}
.feerate-row input {
flex: 1;
}
.max-btn {
display: inline-flex;
align-items: center;
gap: 0.5em;
font: inherit;
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
background: linear-gradient(135deg, #1a0a00, #2d1400);
border: 1px solid rgba(255, 122, 58, 0.4);
border-radius: 8px;
padding: 0.6em 1.25em;
color: var(--accent);
cursor: pointer;
transition: box-shadow 0.2s, border-color 0.2s;
}
.max-btn:hover:not(:disabled) {
border-color: var(--accent);
box-shadow: 0 0 14px rgba(255, 122, 58, 0.35);
}
.max-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.boost-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
width: 100%;
padding: 0.9em;
font: inherit;
font-size: 1.2rem;
font-weight: 700;
background: linear-gradient(135deg, #ff6a00, #ff2d2d);
border: none;
border-radius: 10px;
color: #fff;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.15s, opacity 0.2s;
}
.boost-btn:hover:not(:disabled) {
box-shadow: 0 4px 24px rgba(255, 106, 0, 0.45), 0 0 50px rgba(255, 45, 45, 0.15);
transform: translateY(-1px);
}
.boost-btn:active:not(:disabled) {
transform: translateY(0);
}
.boost-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.spinner {
display: inline-block;
width: 1.1em;
height: 1.1em;
border: 2.5px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.spinner.small {
width: 0.9em;
height: 0.9em;
border-width: 2px;
border-color: rgba(255, 122, 58, 0.3);
border-top-color: var(--accent);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Messages ── */
.msg {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-top: 1.25rem;
padding: 1rem 1.25rem;
border-radius: 8px;
font-size: 1.05rem;
line-height: 1.5;
}
.msg span {
flex: 1;
}
.msg.error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: var(--bad);
}
.msg.success {
background: rgba(92, 224, 168, 0.08);
border: 1px solid rgba(92, 224, 168, 0.3);
color: var(--good);
}
.dismiss {
flex-shrink: 0;
background: none;
border: none;
color: inherit;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
opacity: 0.6;
padding: 0 0.2em;
transition: opacity 0.15s;
}
.dismiss:hover {
opacity: 1;
}
/* ── Table ── */
h3 {
margin: 0 0 1.25rem;
font-size: 1.2rem;
font-weight: 700;
}
.count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6em;
height: 1.6em;
font-size: 0.65em;
font-weight: 700;
background: var(--accent);
color: #000;
border-radius: 999px;
margin-left: 0.4em;
vertical-align: middle;
}
.empty {
color: var(--fg-dim);
padding: 1.25rem 0;
font-size: 1.05rem;
}
.table-wrap {
overflow-x: auto;
font-size: 1.05rem;
}
.txid-link {
color: var(--accent);
text-decoration: none;
font-size: 1rem;
border-bottom: 1px dashed transparent;
}
.txid-link:hover {
color: #ff9a5f;
border-bottom-color: var(--accent);
}
.boost-rate {
color: var(--accent);
font-weight: 600;
}
.delta {
color: var(--bad);
}
.unit {
color: var(--fg-dim);
font-size: 0.85em;
font-weight: 400;
}
.cancel-btn {
font: inherit;
font-size: 0.9rem;
font-weight: 600;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.35em 0.8em;
color: var(--fg-dim);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.cancel-btn:hover {
color: var(--bad);
border-color: var(--bad);
}
@media (max-width: 600px) {
.header-content {
padding: 1.25rem;
}
.boost-form {
padding: 1.25rem;
}
.title {
font-size: 1.4rem;
}
.feerate-row {
flex-direction: column;
}
}
</style>
+46
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { snap } from "../stores/snapshot.svelte";
import { selectAccelerator } from "../stores/selection.svelte";
const statusClass = $derived.by(() => {
if (snap.status === "open") return "good";
@@ -20,6 +21,19 @@
<div class="brand">
<span class="logo">&#x1F525;</span>
<span class="name">Kamado Pool</span>
<button
type="button"
class="accel-btn"
onclick={selectAccelerator}
title="Transaction Accelerator"
>
<svg class="rocket-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
</svg>
</button>
</div>
<div class="meta" title="WebSocket status: {statusLabel}">
<span class="ws-dot {statusClass}" aria-hidden="true"></span>
@@ -46,6 +60,38 @@
.logo {
font-size: 1.4rem;
}
.accel-btn {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.45em;
cursor: pointer;
color: var(--fg-dim);
transition: color 0.2s, border-color 0.2s, box-shadow 0.2s, transform 0.2s;
animation: jiggle 10s ease-in-out infinite;
}
.accel-btn:hover {
color: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 12px rgba(255, 122, 58, 0.5);
transform: translateY(-2px) scale(1.05);
animation: none;
}
@keyframes jiggle {
0%, 94%, 100% { transform: rotate(0deg) scale(1); }
95% { transform: rotate(-12deg) scale(1.1); }
96.5% { transform: rotate(10deg) scale(1.1); }
97.5% { transform: rotate(-8deg) scale(1.05); }
98.5% { transform: rotate(6deg) scale(1.05); }
99.5% { transform: rotate(-3deg) scale(1); }
}
.rocket-icon {
width: 1.4em;
height: 1.4em;
}
.meta {
display: flex;
align-items: center;
+16 -16
View File
@@ -77,7 +77,7 @@
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
padding: 0.85rem 1.25rem;
padding: 1.1rem 1.5rem;
}
.content {
position: relative;
@@ -85,29 +85,29 @@
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
gap: 2rem;
}
.title {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
font-size: 1.1rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.05em;
color: var(--fg);
}
.stats {
display: flex;
align-items: center;
gap: 1.5rem;
gap: 2rem;
flex-wrap: wrap;
}
.group {
display: flex;
align-items: baseline;
gap: 0.4em;
gap: 0.5em;
}
.group-label {
font-size: 0.75rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fg-dim);
@@ -115,31 +115,31 @@
}
.accepted {
color: var(--good);
font-weight: 600;
font-size: 1.1rem;
font-weight: 700;
font-size: 1.4rem;
font-variant-numeric: tabular-nums;
}
.rejected {
color: var(--bad);
font-weight: 600;
font-size: 1.1rem;
font-weight: 700;
font-size: 1.4rem;
font-variant-numeric: tabular-nums;
}
.sep {
color: var(--fg-dim);
font-size: 0.9rem;
font-size: 1.1rem;
}
.pct {
font-size: 1.1rem;
font-size: 1.05rem;
color: var(--fg-dim);
margin-left: 0.3em;
margin-left: 0.4em;
}
.pct.warn {
color: var(--bad);
}
.divider {
width: 1px;
height: 1.6rem;
height: 2rem;
background: var(--border);
}
+17 -6
View File
@@ -3,6 +3,7 @@
// #/ -> dashboard
// #/user/<address> -> per-user page
// #/worker/<workername> -> per-worker page
// #/accelerator -> transaction accelerator
//
// selectUser / selectWorker / clearSelection just mutate the hash;
// a hashchange listener reads it back into reactive state so any
@@ -10,29 +11,34 @@
const USER_PREFIX = "#/user/";
const WORKER_PREFIX = "#/worker/";
const ACCELERATOR_HASH = "#/accelerator";
type Selection = { user: string | null; worker: string | null };
type Selection = { user: string | null; worker: string | null; page: string | null };
export const selection = $state<Selection>(readHash());
function readHash(): Selection {
if (typeof window === "undefined") return { user: null, worker: null };
if (typeof window === "undefined") return { user: null, worker: null, page: null };
const h = window.location.hash;
if (h === ACCELERATOR_HASH) {
return { user: null, worker: null, page: "accelerator" };
}
if (h.startsWith(WORKER_PREFIX)) {
const w = decodeURIComponent(h.slice(WORKER_PREFIX.length));
return { user: null, worker: w || null };
return { user: null, worker: w || null, page: null };
}
if (h.startsWith(USER_PREFIX)) {
const u = decodeURIComponent(h.slice(USER_PREFIX.length));
return { user: u || null, worker: null };
return { user: u || null, worker: null, page: null };
}
return { user: null, worker: null };
return { user: null, worker: null, page: null };
}
function syncFromHash(): void {
const parsed = readHash();
selection.user = parsed.user;
selection.worker = parsed.worker;
selection.page = parsed.page;
}
if (typeof window !== "undefined") {
@@ -47,11 +53,16 @@ export function selectWorker(workername: string): void {
window.location.hash = WORKER_PREFIX + encodeURIComponent(workername);
}
export function selectAccelerator(): void {
window.location.hash = ACCELERATOR_HASH;
}
export function clearSelection(): void {
if (
window.history.length > 1 &&
(window.location.hash.startsWith(USER_PREFIX) ||
window.location.hash.startsWith(WORKER_PREFIX))
window.location.hash.startsWith(WORKER_PREFIX) ||
window.location.hash === ACCELERATOR_HASH)
) {
window.history.back();
} else {