shadok-ai 0.15.44 → 0.15.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -10
- package/package.json +1 -1
- package/public/index.html +16 -134
package/README.md
CHANGED
|
@@ -76,20 +76,18 @@ Enter to skip; you can add it later from the web UI).
|
|
|
76
76
|
- **Channels** — one `claude` process each, running in parallel. Renaming,
|
|
77
77
|
grouping, and closing all sync live between the browser and Telegram: it's
|
|
78
78
|
one list, server-owned, not two copies.
|
|
79
|
-
- **Groups** — on a desktop the
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
match), and an agents pane where **every group is a collapsible section**, each
|
|
84
|
-
agent a round avatar with a presence dot (green ready, red working, hollow
|
|
79
|
+
- **Groups** — on a desktop the groups live in their own **left column**: pick
|
|
80
|
+
one to narrow the agents pane to it, and the global tools (Profiles, Ledger,
|
|
81
|
+
Secrets, Users, Security, Telegram, Sound) stay in the header at the top. Each
|
|
82
|
+
agent is a round avatar with a presence dot (green ready, red working, hollow
|
|
85
83
|
offline). A group's icon opens the icon picker: choose from the grid, type any
|
|
86
84
|
emoji, or **type a word** (`rocket`, `bug`, `money`…) to search for one; its
|
|
87
85
|
**+** creates the new agent straight into that group. Dragging an agent onto a
|
|
88
86
|
group files it there, dragging groups reorders them, and double-clicking a name
|
|
89
|
-
renames it.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
every group.
|
|
87
|
+
renames it. A question the agent asks shows as an *Approval requested* card. The
|
|
88
|
+
whole cockpit is skinned like Microsoft Teams (grey light mode / near-black dark
|
|
89
|
+
mode, the Shadok mark in the header). On a phone the tools sit in the ⋯ menu and
|
|
90
|
+
the agents sheet lists every group.
|
|
93
91
|
- **Worktree isolation** — spawned agents get their own git worktree and branch
|
|
94
92
|
by default, so parallel agents never collide. Work is never auto-discarded:
|
|
95
93
|
an empty worktree is reclaimed on close, anything with changes or commits
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -2139,27 +2139,18 @@
|
|
|
2139
2139
|
.sec-reload button:hover { background: color-mix(in srgb, var(--amber) 15%, transparent); }
|
|
2140
2140
|
|
|
2141
2141
|
/* ══ Teams layer ═════════════════════════════════════════════════════════
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
specificity; the phone layout (≤640px) keeps its
|
|
2142
|
+
A Teams-style SKIN over the cockpit's original layout: round avatars with a
|
|
2143
|
+
presence dot, a channel header, bubbles, and dialogs as approval cards.
|
|
2144
|
+
Colours stay tokens, so light mode reads as Teams and dark mode as the
|
|
2145
|
+
neutral near-black. The layout itself is unchanged — the global tools live
|
|
2146
|
+
in the header, and the groups keep their own left column (#groupRail). Placed
|
|
2147
|
+
LAST so it wins on equal specificity; the phone layout (≤640px) keeps its
|
|
2148
|
+
sheet and its ⋯ menu. */
|
|
2148
2149
|
:root { --mine: color-mix(in srgb, var(--amber) 20%, var(--bg-raised)); --pane-main: color-mix(in srgb, var(--bg) 55%, var(--bg-raised)); }
|
|
2149
2150
|
:root[data-mode="light"] { --mine: color-mix(in srgb, var(--amber) 13%, #fff); }
|
|
2150
2151
|
|
|
2151
2152
|
/* Title bar */
|
|
2152
2153
|
header { padding: 8px 14px; gap: 14px; }
|
|
2153
|
-
#agentSearch {
|
|
2154
|
-
flex: 0 1 520px; margin: 0 auto; display: flex; align-items: center; gap: 8px;
|
|
2155
|
-
height: 32px; padding: 0 12px; border-radius: 6px; cursor: text;
|
|
2156
|
-
background: var(--bg-raised); color: var(--text-dim); border: 1px solid var(--line);
|
|
2157
|
-
}
|
|
2158
|
-
#agentSearch:focus-within { border-color: var(--amber); }
|
|
2159
|
-
#agentSearch input { flex: 1; min-width: 0; border: 0; outline: none; background: none; color: var(--text); font: inherit; font-size: 13.5px; padding: 0; }
|
|
2160
|
-
#agentSearch input::placeholder { color: var(--text-dim); }
|
|
2161
|
-
#agentSearch + .spacer { display: none; }
|
|
2162
|
-
@media (max-width: 640px) { #agentSearch { display: none; } #agentSearch + .spacer { display: block; } }
|
|
2163
2154
|
@media (min-width: 641px) { header { flex-wrap: nowrap; } }
|
|
2164
2155
|
/* The star pill is the widest tool; under 1100px it lives in the ⋯ menu. */
|
|
2165
2156
|
@media (min-width: 641px) and (max-width: 1100px) { #starBtn { display: none; } #moreMenu #starMenuItem { display: block; } }
|
|
@@ -2183,7 +2174,6 @@
|
|
|
2183
2174
|
}
|
|
2184
2175
|
.tav .led { position: absolute; right: -2px; bottom: -2px; width: 12px; height: 12px; margin: 0; border: 2px solid var(--bg); }
|
|
2185
2176
|
.ab-txt, .ct-txt { display: none; }
|
|
2186
|
-
#appBar { display: none; }
|
|
2187
2177
|
@media (max-width: 640px) {
|
|
2188
2178
|
/* A phone's single header row has no room for the mark beside the name,
|
|
2189
2179
|
the version and the dials; the favicon carries it there. */
|
|
@@ -2203,8 +2193,6 @@
|
|
|
2203
2193
|
:root:not([data-mode="light"]) .tab.active { background: var(--bg-inset); box-shadow: inset 0 0 0 1px var(--line); }
|
|
2204
2194
|
.tab.active .tav .led { border-color: var(--bg-raised); }
|
|
2205
2195
|
.tab.unread { border-left: 0; border-radius: 6px; font-weight: 700; }
|
|
2206
|
-
.tab.filtered-out { display: none !important; }
|
|
2207
|
-
body.filtering .group.collapsed .group-body { display: flex !important; }
|
|
2208
2196
|
.group-head { gap: 10px; padding: 8px; font-size: 14px; color: var(--text); border-radius: 6px; }
|
|
2209
2197
|
.group-head .gicon {
|
|
2210
2198
|
flex: none; width: 30px; height: 30px; border-radius: 6px; display: grid; place-items: center;
|
|
@@ -2215,62 +2203,25 @@
|
|
|
2215
2203
|
|
|
2216
2204
|
@media (min-width: 641px) {
|
|
2217
2205
|
main { padding: 0; gap: 0; }
|
|
2218
|
-
/* App bar */
|
|
2219
|
-
#appBar { width: 68px; flex: none; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 4px 0 12px; background: var(--page); overflow-y: auto; }
|
|
2220
|
-
#appTools { display: contents; }
|
|
2221
|
-
#appBar button {
|
|
2222
|
-
position: relative; width: 62px; padding: 8px 0 6px; display: flex; flex-direction: column; align-items: center; gap: 3px;
|
|
2223
|
-
border: 0; border-radius: 6px; background: none; color: var(--text-dim); font-size: 10.5px; line-height: 1.1;
|
|
2224
|
-
}
|
|
2225
|
-
#appBar button:hover { color: var(--amber); background: none; }
|
|
2226
|
-
#appBar button.on { color: var(--amber); font-weight: 600; }
|
|
2227
|
-
#appBar button.on::before { content: ""; position: absolute; left: -2px; top: 10px; bottom: 10px; width: 3px; border-radius: 2px; background: var(--amber); }
|
|
2228
|
-
#appBar button[hidden] { display: none; }
|
|
2229
|
-
#appBar svg { width: 20px !important; height: 20px !important; vertical-align: 0 !important; }
|
|
2230
|
-
#appBar .ab-txt { display: block; }
|
|
2231
|
-
#appBar .ab-n, #appBar .sec-badge {
|
|
2232
|
-
position: absolute; top: 3px; right: 12px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px;
|
|
2233
|
-
background: var(--err); color: #fff; font-size: 10px; font-weight: 600; line-height: 16px; text-align: center;
|
|
2234
|
-
}
|
|
2235
|
-
#appBar .ab-n[hidden] { display: none; }
|
|
2236
|
-
|
|
2237
|
-
/* Every group is a section of the agents pane; the rail goes. */
|
|
2238
|
-
body.has-groups #groupRail { display: none; }
|
|
2239
|
-
#tabbar, body.has-groups #tabbar {
|
|
2240
|
-
width: 300px; border: 0; border-radius: 8px 0 0 0; background: var(--bg); padding: 12px 8px;
|
|
2241
|
-
}
|
|
2242
|
-
#tabbar .side-label { font-size: 18px; font-weight: 700; padding: 4px 8px 12px; }
|
|
2243
|
-
body.has-groups #tabbar .side-label .sl-all { display: inline; }
|
|
2244
|
-
#tabbar .side-label .sl-view, #viewEmpty { display: none !important; }
|
|
2245
|
-
#tabbar .group-head { display: flex; }
|
|
2246
|
-
#tabbar .group { margin-top: 6px; }
|
|
2247
|
-
#tabbar .group-body { padding-left: 20px; }
|
|
2248
|
-
#tabbar .group.collapsed .group-body { display: none; }
|
|
2249
|
-
#tabbar:not([data-view="ungrouped"]) #ungrouped,
|
|
2250
|
-
#tabbar[data-view="ungrouped"] #groups { display: flex; flex-direction: column; }
|
|
2251
|
-
#tabbar .group:not(.in-view) { display: flex; }
|
|
2252
|
-
#newGroupBare, body.has-groups #newGroupBare { display: block; }
|
|
2253
|
-
|
|
2254
2206
|
/* Conversation */
|
|
2255
2207
|
.chat-pane { border: 0; border-radius: 0; background: var(--pane-main); }
|
|
2256
2208
|
#machine, #diffpanel { border-radius: 0; border-top: 0; border-bottom: 0; border-right: 0; }
|
|
2257
2209
|
}
|
|
2258
2210
|
|
|
2259
|
-
/* Channel header: avatar, name, status, then
|
|
2211
|
+
/* Channel header: avatar, name, status, then actions. */
|
|
2260
2212
|
#chanBar { gap: 12px; padding: 0 20px; min-height: 58px; background: var(--bg-raised); border-bottom: 1px solid var(--line); }
|
|
2261
2213
|
#chanBar .tav { width: 36px; height: 36px; font-size: 13px; }
|
|
2262
2214
|
#chanBar #chanBarName { font-size: 16px; font-weight: 700; }
|
|
2263
2215
|
#chanBar .gauge .label { display: none; }
|
|
2264
2216
|
#chanBar .gauge .value { color: var(--text-dim); font-size: 12px; }
|
|
2265
2217
|
#chanBar .gauge:nth-of-type(3) { display: none; } /* the session id: in the tooltip of the menu */
|
|
2266
|
-
.chan-
|
|
2267
|
-
.chan-tabs button, #chanBar .chan-actions #chanCron, #chanBar .chan-actions #chanTerm {
|
|
2218
|
+
#chanBar .chan-actions #chanCron, #chanBar .chan-actions #chanTerm {
|
|
2268
2219
|
position: relative; align-self: stretch; display: inline-flex; align-items: center; gap: 6px;
|
|
2269
2220
|
border: 0; border-radius: 0; background: none; padding: 0 10px; color: var(--text-dim); font-size: 14px;
|
|
2270
2221
|
}
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2222
|
+
#chanBar .chan-actions #chanCron:hover, #chanBar .chan-actions #chanTerm:hover { color: var(--text); background: none; }
|
|
2223
|
+
#chanBar .chan-actions #chanTerm.on { color: var(--text); font-weight: 600; background: none; border: 0; }
|
|
2224
|
+
#chanBar .chan-actions #chanTerm.on::after {
|
|
2274
2225
|
content: ""; position: absolute; left: 10px; right: 10px; bottom: 0; height: 3px; border-radius: 3px 3px 0 0; background: var(--amber);
|
|
2275
2226
|
}
|
|
2276
2227
|
#chanBar .chan-actions { align-self: stretch; }
|
|
@@ -2278,7 +2229,7 @@
|
|
|
2278
2229
|
#chanBar .chan-actions #chanStop, #chanBar .chan-actions #chanMenu { align-self: center; }
|
|
2279
2230
|
@media (max-width: 640px) {
|
|
2280
2231
|
#chanBar { min-height: 0; padding: 8px 12px; }
|
|
2281
|
-
#chanBar .tav,
|
|
2232
|
+
#chanBar .tav, #chanBar .chan-actions .ct-txt { display: none; }
|
|
2282
2233
|
}
|
|
2283
2234
|
|
|
2284
2235
|
/* Messages: Teams bubbles. */
|
|
@@ -2308,8 +2259,6 @@
|
|
|
2308
2259
|
|
|
2309
2260
|
<header>
|
|
2310
2261
|
<span class="brand"><span class="brand-logo" aria-hidden="true"><svg viewBox="0 0 40 40"><g fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="17" cy="17" rx="10" ry="8.5"/><path d="M25 12.5c3-3 7.5-3 10-1.2-2.8.4-5 1.9-6.6 4"/><path d="M14 25.3l-2.4 10M20 25.3l1.6 10"/></g></svg></span><span class="brand-name" id="brandName" title="Rename this cockpit">shadok-ai</span><span class="brand-edit" id="brandEdit" title="Rename this cockpit" aria-hidden="true">✎</span> <span class="ver" id="verView" title="Running version"></span><span class="ver-chan" id="verChan" aria-hidden="true"></span></span>
|
|
2311
|
-
<!-- Teams-style search: filters the agents list as you type (desktop). -->
|
|
2312
|
-
<label id="agentSearch"><svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M15 15l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg><input id="agentFilter" type="search" placeholder="Search agents" autocomplete="off" spellcheck="false" aria-label="Search agents"></label>
|
|
2313
2262
|
<span class="spacer"></span>
|
|
2314
2263
|
<div class="gauge quota" id="quota5h" data-window="5h" title="5h rolling limit">
|
|
2315
2264
|
<svg class="dial" viewBox="0 0 56 44" role="img" aria-label="5h rolling limit">
|
|
@@ -2873,14 +2822,6 @@
|
|
|
2873
2822
|
</div>
|
|
2874
2823
|
|
|
2875
2824
|
<main>
|
|
2876
|
-
<!-- The app bar (desktop): Teams' left column. Activity jumps to an agent that
|
|
2877
|
-
waits for you; the global tools are MOVED here by reflowHeaderTools (the
|
|
2878
|
-
real buttons, handlers included) and back to the header/⋯ on a phone. -->
|
|
2879
|
-
<nav id="appBar" aria-label="Apps">
|
|
2880
|
-
<button id="abActivity" type="button" title="Jump to an agent waiting for you"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" d="M6 16V11a6 6 0 1 1 12 0v5l1.5 2h-15zM10 20.5a2 2 0 0 0 4 0"/></svg><span class="ab-txt">Activity</span><span id="abActivityN" class="ab-n" hidden></span></button>
|
|
2881
|
-
<button id="abAgents" type="button" class="on" title="Agents"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="9" cy="9" r="3.2"/><circle cx="17" cy="10" r="2.4"/><path d="M3.5 19c.6-3 2.8-4.6 5.5-4.6s4.9 1.6 5.5 4.6M14.5 15c2.6-.6 5 .6 5.8 3.4"/></g></svg><span class="ab-txt">Agents</span></button>
|
|
2882
|
-
<div id="appTools"></div>
|
|
2883
|
-
</nav>
|
|
2884
2825
|
<!-- The groups column (desktop only): a VIEW over the agents column beside it,
|
|
2885
2826
|
which still holds the groups, their order and their members — that is what
|
|
2886
2827
|
is persisted. Picking a group narrows the agents column to it. On a phone
|
|
@@ -2950,10 +2891,6 @@
|
|
|
2950
2891
|
<span class="label">Branch</span>
|
|
2951
2892
|
<span class="value" id="branchView">—</span>
|
|
2952
2893
|
</div>
|
|
2953
|
-
<span class="chan-tabs" role="tablist">
|
|
2954
|
-
<button id="chanChat" type="button" role="tab">Chat</button>
|
|
2955
|
-
<button id="chanDiff" type="button" role="tab">Changes</button>
|
|
2956
|
-
</span>
|
|
2957
2894
|
<span class="chan-actions">
|
|
2958
2895
|
<button id="chanUpdate" class="icon" title="Claude Code updated — reload this agent to apply it" aria-label="Reload to apply update" hidden>↻ update</button>
|
|
2959
2896
|
<button id="chanCron" class="icon" title="Scheduled prompts for this agent (monitoring / reporting)" aria-label="Schedule"><svg viewBox="0 0 24 24" width="15" height="15" style="vertical-align:-2px" aria-hidden="true"><circle cx="12" cy="13" r="6.3" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M12 9.8V13l2.2 1.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.8 6.6 7.2 4.6M19.2 6.6 16.8 4.6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg><span class="ct-txt">Schedule</span></button>
|
|
@@ -8493,21 +8430,17 @@
|
|
|
8493
8430
|
// the real button elements, so their click handlers travel with them.
|
|
8494
8431
|
// Recomputed on load, on resize, and when the instance name changes.
|
|
8495
8432
|
const OVERFLOW_TOOLS = ["secretsBtn", "ledgerBtn", "profilesBtn", "telegramBtn", "muteNotif"];
|
|
8496
|
-
// Every tool the desktop app bar carries, in its order.
|
|
8497
|
-
const APP_TOOLS = ["profilesBtn", "ledgerBtn", "secretsBtn", "usersBtn", "securityBtn", "telegramBtn", "muteNotif"];
|
|
8498
8433
|
function reflowHeaderTools() {
|
|
8499
8434
|
const hdrTools = document.querySelector(".hdr-tools");
|
|
8500
8435
|
const moreWrap = document.querySelector(".more-wrap");
|
|
8501
8436
|
const overflow = $("overflowTools");
|
|
8502
8437
|
if (!hdrTools || !moreWrap || !overflow) return;
|
|
8503
8438
|
const mobile = matchMedia("(max-width: 640px)").matches;
|
|
8504
|
-
const
|
|
8505
|
-
for (const id of APP_TOOLS) {
|
|
8439
|
+
for (const id of OVERFLOW_TOOLS) {
|
|
8506
8440
|
const el = $(id);
|
|
8507
8441
|
if (!el) continue;
|
|
8508
|
-
if (
|
|
8509
|
-
else
|
|
8510
|
-
else hdrTools.insertBefore(el, moreWrap); // phone: stays in the header
|
|
8442
|
+
if (mobile) overflow.appendChild(el); // all into the ⋯ menu
|
|
8443
|
+
else hdrTools.insertBefore(el, moreWrap); // back in the header, in order
|
|
8511
8444
|
}
|
|
8512
8445
|
}
|
|
8513
8446
|
let reflowPending = false;
|
|
@@ -8520,57 +8453,6 @@
|
|
|
8520
8453
|
document.addEventListener("DOMContentLoaded", reflowHeaderTools);
|
|
8521
8454
|
reflowHeaderTools();
|
|
8522
8455
|
|
|
8523
|
-
// ── Teams layer: app bar, search, channel tabs ─────────────────────────────
|
|
8524
|
-
// Activity: how many agents wait for an answer; a click opens the next one.
|
|
8525
|
-
function paintActivity() {
|
|
8526
|
-
const n = tabs.filter((t) => t.btnEl.classList.contains("needs-answer")).length;
|
|
8527
|
-
const badge = $("abActivityN");
|
|
8528
|
-
badge.hidden = !n;
|
|
8529
|
-
badge.textContent = n || "";
|
|
8530
|
-
}
|
|
8531
|
-
new MutationObserver(paintActivity).observe($("tabbar"), { subtree: true, attributes: true, attributeFilter: ["class"], childList: true });
|
|
8532
|
-
$("abActivity").addEventListener("click", () => {
|
|
8533
|
-
const waiting = tabs.filter((t) => t.btnEl.classList.contains("needs-answer"));
|
|
8534
|
-
if (!waiting.length) return;
|
|
8535
|
-
activate(waiting[(waiting.indexOf(active) + 1) % waiting.length]);
|
|
8536
|
-
});
|
|
8537
|
-
$("abAgents").addEventListener("click", () => $("agentFilter").focus());
|
|
8538
|
-
|
|
8539
|
-
// Search: narrows the agents list to the names that match; groups unfold
|
|
8540
|
-
// while a query is typed so nothing matching hides in a collapsed one.
|
|
8541
|
-
function applyAgentFilter() {
|
|
8542
|
-
const q = $("agentFilter").value.trim().toLowerCase();
|
|
8543
|
-
document.body.classList.toggle("filtering", !!q);
|
|
8544
|
-
for (const t of tabs) {
|
|
8545
|
-
const hit = !q || (t.nameEl ? t.nameEl.textContent : "").toLowerCase().includes(q);
|
|
8546
|
-
t.btnEl.classList.toggle("filtered-out", !hit);
|
|
8547
|
-
}
|
|
8548
|
-
}
|
|
8549
|
-
$("agentFilter").addEventListener("input", applyAgentFilter);
|
|
8550
|
-
$("agentFilter").addEventListener("keydown", (e) => {
|
|
8551
|
-
if (e.key === "Escape") { e.target.value = ""; applyAgentFilter(); e.target.blur(); }
|
|
8552
|
-
if (e.key === "Enter") {
|
|
8553
|
-
const first = tabs.find((t) => !t.btnEl.classList.contains("filtered-out"));
|
|
8554
|
-
if (first) { activate(first); e.target.value = ""; applyAgentFilter(); }
|
|
8555
|
-
}
|
|
8556
|
-
});
|
|
8557
|
-
|
|
8558
|
-
// Channel tabs: Chat leaves the terminal and the diff; Changes toggles the diff.
|
|
8559
|
-
$("chanChat").addEventListener("click", () => {
|
|
8560
|
-
if (chatPane.classList.contains("terminal-mode")) $("chanTerm").click();
|
|
8561
|
-
if ($("diffpanel").classList.contains("open")) $("toggleDiff").click();
|
|
8562
|
-
});
|
|
8563
|
-
$("chanDiff").addEventListener("click", () => $("toggleDiff").click());
|
|
8564
|
-
const paintChanTabs = () => {
|
|
8565
|
-
const term = chatPane.classList.contains("terminal-mode");
|
|
8566
|
-
const diff = $("diffpanel").classList.contains("open");
|
|
8567
|
-
$("chanChat").classList.toggle("on", !term && !diff);
|
|
8568
|
-
$("chanDiff").classList.toggle("on", diff);
|
|
8569
|
-
};
|
|
8570
|
-
new MutationObserver(paintChanTabs).observe(chatPane, { attributes: true, attributeFilter: ["class"] });
|
|
8571
|
-
new MutationObserver(paintChanTabs).observe($("diffpanel"), { attributes: true, attributeFilter: ["class"] });
|
|
8572
|
-
paintChanTabs();
|
|
8573
|
-
|
|
8574
8456
|
// ── Diff panel ──────────────────────────────────────────
|
|
8575
8457
|
function colorDiff(text) {
|
|
8576
8458
|
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|