shadok-ai 0.15.53 → 0.15.55
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 +7 -0
- package/package.json +1 -1
- package/public/env-ask.js +57 -0
- package/public/index.html +237 -4
package/README.md
CHANGED
|
@@ -204,6 +204,13 @@ Enter to skip; you can add it later from the web UI).
|
|
|
204
204
|
browser, since shell mode sits outside the agent's guardrails. The command and
|
|
205
205
|
its output stay in the chat, reload included — as does one you typed yourself
|
|
206
206
|
in the terminal view.
|
|
207
|
+
- **Provide the variable an agent asks for** — when an agent names a missing
|
|
208
|
+
credential in code (`` `HUBSPOT_TOKEN missing from the environment` ``), a
|
|
209
|
+
**+** sits right after it: one click opens the secret vault with the name
|
|
210
|
+
filled in and the agent's profile ticked, so you only paste the value (then
|
|
211
|
+
reload the agent when offered — a value reaches a process at spawn). Only
|
|
212
|
+
names that look like a credential or a connection setting get one (`…_TOKEN`,
|
|
213
|
+
`…_KEY`, `…_URL`, `…_ID`…), never `NODE_ENV` nor shadok's own `SHADOK_*`.
|
|
207
214
|
- **Files you hand an agent** — paste or drop a file into the composer, or pick
|
|
208
215
|
one with the 📎 button beside it (an image, a PDF, a CSV…), and it becomes a **chip** above the text box: a
|
|
209
216
|
thumbnail for an image, a named tag for anything else, each with a ✕ to
|
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Which environment variables an agent's message asks the human to provide.
|
|
2
|
+
//
|
|
3
|
+
// Loaded as-is by the browser (ESM, bridged to window.envVarAsks) and imported
|
|
4
|
+
// by the tests, like public/bang.js. The agent answers "the CLI stops on
|
|
5
|
+
// `HUBSPOT_TOKEN missing`" and the human then has to open the vault, type the
|
|
6
|
+
// name back, tick the profile… The "+" this feeds turns that into one click.
|
|
7
|
+
//
|
|
8
|
+
// Kept narrow on purpose: a "+" on every SCREAMING_CASE word an agent writes
|
|
9
|
+
// (`NODE_ENV`, `PATH`, `SHADOK_SESSION_ID`) would be noise, so a name must be
|
|
10
|
+
// marked as code by the agent AND look like a credential or a connection
|
|
11
|
+
// setting.
|
|
12
|
+
|
|
13
|
+
/** At most this many "+" under one message. More is a wall, not a help. */
|
|
14
|
+
export const MAX_ENV_ASKS_PER_MESSAGE = 6;
|
|
15
|
+
|
|
16
|
+
/** An env var name as a shell would export it, with at least one underscore. */
|
|
17
|
+
const ENV_NAME = /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g;
|
|
18
|
+
|
|
19
|
+
/** The segments that make a name something you HAND an agent, not a setting it owns. */
|
|
20
|
+
const CREDENTIAL_PART = new Set([
|
|
21
|
+
"TOKEN", "TOKENS", "KEY", "KEYS", "APIKEY", "SECRET", "SECRETS", "PASSWORD", "PASSWD",
|
|
22
|
+
"PASS", "PWD", "AUTH", "CREDENTIAL", "CREDENTIALS", "CREDS", "DSN", "URL", "URI",
|
|
23
|
+
"HOST", "USER", "USERNAME", "LOGIN", "ACCOUNT", "ID", "CLIENT", "WEBHOOK", "JSON",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Names the cockpit itself sets: asking the human for one of these is never the fix. */
|
|
27
|
+
const OWN_PREFIX = /^(SHADOK|CLAUDE|CLAUDECODE|ANTHROPIC|AI_AGENT|NODE|NPM|GIT|XDG|TMUX)_/;
|
|
28
|
+
|
|
29
|
+
/** Pure: is this an env var name worth offering a "+" for? */
|
|
30
|
+
export function isEnvAsk(name) {
|
|
31
|
+
const n = String(name || "");
|
|
32
|
+
if (!/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(n) || OWN_PREFIX.test(n)) return false;
|
|
33
|
+
return n.split("_").some((p) => CREDENTIAL_PART.has(p));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pure: the env var names a message offers to add, in order, de-duplicated.
|
|
38
|
+
*
|
|
39
|
+
* Only names inside inline code or a fenced block count — that is how an agent
|
|
40
|
+
* writes a variable it means (`HUBSPOT_TOKEN`, `` `HUBSPOT_TOKEN missing from
|
|
41
|
+
* the environment` ``, `export MATOMO_TOKEN=…`). A capitalised word in prose is
|
|
42
|
+
* not an ask.
|
|
43
|
+
*/
|
|
44
|
+
export function envVarAsks(text) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const src = String(text || "");
|
|
47
|
+
// One pass, fenced blocks first in the alternation so their backticks are not
|
|
48
|
+
// read as inline spans — and the names come out in the order they were written.
|
|
49
|
+
for (const span of src.matchAll(/```[^\n]*\n([\s\S]*?)```|`([^`\n]+)`/g)) {
|
|
50
|
+
const s = span[1] ?? span[2];
|
|
51
|
+
for (const m of s.matchAll(ENV_NAME)) {
|
|
52
|
+
const n = m[0];
|
|
53
|
+
if (isEnvAsk(n) && !out.includes(n) && out.length < MAX_ENV_ASKS_PER_MESSAGE) out.push(n);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
package/public/index.html
CHANGED
|
@@ -943,6 +943,12 @@
|
|
|
943
943
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left;
|
|
944
944
|
}
|
|
945
945
|
.bang-btn:disabled { opacity: .5; cursor: default; }
|
|
946
|
+
.env-plus {
|
|
947
|
+
font: inherit; font-size: 11px; line-height: 1; cursor: pointer; vertical-align: 1px;
|
|
948
|
+
margin-left: 3px; padding: 2px 5px; border-radius: 5px;
|
|
949
|
+
border: 1px solid var(--amber); background: var(--amber-soft); color: var(--text);
|
|
950
|
+
}
|
|
951
|
+
.env-plus:hover, .env-plus:focus-visible { background: var(--amber); color: var(--bg); }
|
|
946
952
|
.bang-card { font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; margin: 0; }
|
|
947
953
|
.bang-card .bang-cmd { color: var(--amber); }
|
|
948
954
|
.bang-card .bang-out.err { color: var(--err); }
|
|
@@ -2306,6 +2312,71 @@
|
|
|
2306
2312
|
#composer { background: transparent; padding: 8px 20px calc(16px + env(safe-area-inset-bottom, 0px)); }
|
|
2307
2313
|
#composer textarea { background: var(--bg-raised); border: 1px solid var(--line); border-bottom: 2px solid var(--amber); border-radius: 6px; }
|
|
2308
2314
|
#composer textarea:focus-visible { box-shadow: none; border-color: var(--line); border-bottom-color: var(--amber); }
|
|
2315
|
+
|
|
2316
|
+
/* ── Motion: switching views, and what loading looks like ──────────────
|
|
2317
|
+
Short on purpose (≤ 220ms, staggers capped): a transition that makes you
|
|
2318
|
+
wait for the list you just asked for is worse than none. Everything here
|
|
2319
|
+
is decoration — the content is in place from the first frame, the
|
|
2320
|
+
animation only eases it in — and all of it is off under reduced motion. */
|
|
2321
|
+
:root { --ease-out: cubic-bezier(.2, .8, .2, 1); }
|
|
2322
|
+
@keyframes view-in-down { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
|
2323
|
+
@keyframes view-in-up { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
|
|
2324
|
+
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
|
2325
|
+
@keyframes chat-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
|
2326
|
+
/* Picking a group: its agents slide in from the side the group sits on in
|
|
2327
|
+
the rail (below → up, above → down), one after the other. */
|
|
2328
|
+
#tabbar.view-in-down :is(#ungrouped, #groups) .tab { animation: view-in-down 200ms var(--ease-out) both; animation-delay: calc(var(--i, 0) * 22ms); }
|
|
2329
|
+
#tabbar.view-in-up :is(#ungrouped, #groups) .tab { animation: view-in-up 200ms var(--ease-out) both; animation-delay: calc(var(--i, 0) * 22ms); }
|
|
2330
|
+
#tabbar.view-in-down .side-label, #tabbar.view-in-up .side-label,
|
|
2331
|
+
#tabbar.view-in-down #viewEmpty, #tabbar.view-in-up #viewEmpty { animation: fade-in 160ms ease-out both; }
|
|
2332
|
+
/* Picking an agent: its conversation eases in instead of snapping. */
|
|
2333
|
+
.transcript.chat-in { animation: chat-in 170ms var(--ease-out) both; }
|
|
2334
|
+
/* The rail's selection is ONE pill that glides to the picked group, instead
|
|
2335
|
+
of a highlight that blinks from one row to the next. */
|
|
2336
|
+
#railList { position: relative; }
|
|
2337
|
+
#railList .rail-item { z-index: 1; transition: color 160ms ease; }
|
|
2338
|
+
#railList.has-pill .rail-item.active { background: transparent; border-color: transparent; }
|
|
2339
|
+
.rail-pill {
|
|
2340
|
+
position: absolute; left: 0; right: 0; top: 0; z-index: 0; pointer-events: none;
|
|
2341
|
+
background: var(--bg-raised); border: 1px solid var(--line); border-radius: 6px;
|
|
2342
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, .12);
|
|
2343
|
+
transition: transform 200ms var(--ease-out), height 200ms var(--ease-out);
|
|
2344
|
+
}
|
|
2345
|
+
.rail-pill.no-anim { transition: none; }
|
|
2346
|
+
|
|
2347
|
+
/* Skeletons: the SHAPE of what is coming, shimmering, in place of a bare
|
|
2348
|
+
"loading…" — the layout does not jump when the real content lands. */
|
|
2349
|
+
@keyframes skel-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
|
|
2350
|
+
.skel {
|
|
2351
|
+
display: block; border-radius: 6px; height: 12px;
|
|
2352
|
+
/* Fallback first: a browser without color-mix() drops the gradient below
|
|
2353
|
+
and would otherwise draw no skeleton at all. */
|
|
2354
|
+
background: linear-gradient(90deg, rgba(128,128,128,.13) 25%, rgba(128,128,128,.26) 50%, rgba(128,128,128,.13) 75%);
|
|
2355
|
+
background: linear-gradient(90deg,
|
|
2356
|
+
color-mix(in srgb, var(--text-dim) 12%, transparent) 25%,
|
|
2357
|
+
color-mix(in srgb, var(--text-dim) 24%, transparent) 50%,
|
|
2358
|
+
color-mix(in srgb, var(--text-dim) 12%, transparent) 75%);
|
|
2359
|
+
background-size: 300% 100%;
|
|
2360
|
+
animation: skel-shimmer 1.3s linear infinite;
|
|
2361
|
+
}
|
|
2362
|
+
.skel-chat { display: flex; flex-direction: column; gap: 18px; padding-top: 4px; animation: fade-in 200ms ease-out both; }
|
|
2363
|
+
.skel-turn { display: flex; flex-direction: column; gap: 7px; max-width: 70%; }
|
|
2364
|
+
.skel-turn.mine { align-self: flex-end; align-items: flex-end; width: 42%; }
|
|
2365
|
+
.skel-turn .skel-label { width: 64px; height: 9px; }
|
|
2366
|
+
.skel-turn .skel-bubble { width: 100%; height: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; background: rgba(128,128,128,.07); background: color-mix(in srgb, var(--text-dim) 7%, transparent); animation: none; border-radius: 10px; }
|
|
2367
|
+
.skel-turn .skel-bubble .skel { height: 10px; }
|
|
2368
|
+
.skel-row { display: flex; flex-direction: column; gap: 7px; padding: 10px 8px; border-bottom: 1px solid var(--line); }
|
|
2369
|
+
.skel-row:last-child { border-bottom: none; }
|
|
2370
|
+
.skel-card { height: 92px; border-radius: 8px; }
|
|
2371
|
+
.skel-diff { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; }
|
|
2372
|
+
.skel-diff .skel { height: 10px; }
|
|
2373
|
+
|
|
2374
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2375
|
+
#tabbar.view-in-down .tab, #tabbar.view-in-up .tab, #tabbar .side-label, #tabbar #viewEmpty,
|
|
2376
|
+
.transcript.chat-in, .skel-chat { animation: none !important; }
|
|
2377
|
+
.rail-pill { transition: none; }
|
|
2378
|
+
.skel { animation: none; }
|
|
2379
|
+
}
|
|
2309
2380
|
</style>
|
|
2310
2381
|
<!-- Light/dark mode is a per-browser choice (localStorage). Set it BEFORE the
|
|
2311
2382
|
body paints — here in <head> — so a light-preferring viewer never sees a
|
|
@@ -2548,7 +2619,7 @@
|
|
|
2548
2619
|
<span class="label">Which agent?</span>
|
|
2549
2620
|
<button type="button" class="linkish" id="editProfilesLink" title="Create or edit agent profiles">✎ edit profiles</button>
|
|
2550
2621
|
</div>
|
|
2551
|
-
<div id="profileGrid" role="radiogroup" aria-label="Agent profile"></div>
|
|
2622
|
+
<div id="profileGrid" role="radiogroup" aria-label="Agent profile" aria-busy="true"><span class="skel skel-card"></span><span class="skel skel-card"></span><span class="skel skel-card"></span><span class="skel skel-card"></span></div>
|
|
2552
2623
|
<span class="check-hint" id="profileNaNote" hidden>Applies to new sessions only.</span>
|
|
2553
2624
|
</div>
|
|
2554
2625
|
<!-- The model this ONE agent runs on. Its default sends nothing at all,
|
|
@@ -3090,6 +3161,8 @@
|
|
|
3090
3161
|
import { bangCommands, blockCommand } from "/bang.js?v=__ASSET_V__";
|
|
3091
3162
|
window.bangCommands = bangCommands;
|
|
3092
3163
|
window.blockCommand = blockCommand;
|
|
3164
|
+
import { envVarAsks } from "/env-ask.js?v=__ASSET_V__";
|
|
3165
|
+
window.envVarAsks = envVarAsks;
|
|
3093
3166
|
import { claudeUpdateState } from "/update-ready.js?v=__ASSET_V__";
|
|
3094
3167
|
window.claudeUpdateState = claudeUpdateState;
|
|
3095
3168
|
import { buildMessageLink, parseMessageLink, samePrompt } from "/deep-link.js?v=__ASSET_V__";
|
|
@@ -3218,6 +3291,8 @@
|
|
|
3218
3291
|
bangCommands: () => [],
|
|
3219
3292
|
// No module → no block is runnable, so no inline run button (copy only).
|
|
3220
3293
|
blockCommand: () => null,
|
|
3294
|
+
// No module → no "+" next to an env var name; the vault is still one click away.
|
|
3295
|
+
envVarAsks: () => [],
|
|
3221
3296
|
// Boot-critical (runs in restore before the module might land): a fulfilled
|
|
3222
3297
|
// /channels wins even when empty; only a failed fetch uses the cache.
|
|
3223
3298
|
pickChannelSource: (settled, cached) =>
|
|
@@ -3387,6 +3462,37 @@
|
|
|
3387
3462
|
if (label.textContent !== text) label.textContent = text;
|
|
3388
3463
|
}
|
|
3389
3464
|
|
|
3465
|
+
// ── Skeletons: the shape of what is loading ─────────────
|
|
3466
|
+
// Static markup only (no user data), so innerHTML is safe here.
|
|
3467
|
+
const skelLine = (w) => `<span class="skel" style="width:${w}%"></span>`;
|
|
3468
|
+
function skeletonHTML(kind, n = 3) {
|
|
3469
|
+
if (kind === "chat") {
|
|
3470
|
+
const turn = (mine, widths) =>
|
|
3471
|
+
`<div class="skel-turn${mine ? " mine" : ""}"><span class="skel skel-label"></span>` +
|
|
3472
|
+
`<div class="skel skel-bubble">${widths.map(skelLine).join("")}</div></div>`;
|
|
3473
|
+
return `<div class="skel-chat" aria-busy="true" aria-label="Loading the conversation">` +
|
|
3474
|
+
turn(true, [80]) + turn(false, [92, 78, 54]) + turn(true, [64]) + turn(false, [88, 60]) + `</div>`;
|
|
3475
|
+
}
|
|
3476
|
+
if (kind === "cards") return Array.from({ length: n }, () => `<span class="skel skel-card"></span>`).join("");
|
|
3477
|
+
if (kind === "diff") {
|
|
3478
|
+
const w = [38, 72, 64, 85, 30, 58, 77, 46, 69, 52];
|
|
3479
|
+
return `<div class="skel-diff" aria-busy="true">${w.map(skelLine).join("")}</div>`;
|
|
3480
|
+
}
|
|
3481
|
+
// "rows": a list of two-line entries (sessions, ledger).
|
|
3482
|
+
return Array.from({ length: n }, (_, i) =>
|
|
3483
|
+
`<div class="skel-row" aria-busy="true">${skelLine(30 + (i * 17) % 25)}${skelLine(60 + (i * 23) % 35)}</div>`).join("");
|
|
3484
|
+
}
|
|
3485
|
+
/** A tab waiting for its history shows a chat skeleton — only when it has
|
|
3486
|
+
* nothing at all yet (a reconnect keeps what is already on screen). */
|
|
3487
|
+
function showChatSkeleton(t) {
|
|
3488
|
+
if (t.transcriptEl.childElementCount) return;
|
|
3489
|
+
t.transcriptEl.innerHTML = skeletonHTML("chat");
|
|
3490
|
+
}
|
|
3491
|
+
function clearChatSkeleton(t) {
|
|
3492
|
+
const s = t.transcriptEl.querySelector(":scope > .skel-chat");
|
|
3493
|
+
if (s) s.remove();
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3390
3496
|
function createTab(groupId, { focus = true } = {}) {
|
|
3391
3497
|
seq++;
|
|
3392
3498
|
const el = document.createElement("div");
|
|
@@ -4594,10 +4700,29 @@
|
|
|
4594
4700
|
const tabsInView = (v) =>
|
|
4595
4701
|
tabs.filter((t) => !t.btnEl.closest("#tweakSlot") && viewOfTab(t) === v);
|
|
4596
4702
|
|
|
4703
|
+
/** Eases the newly shown agents in, from the side the picked group sits on
|
|
4704
|
+
* in the rail. Purely cosmetic: the list is already correct underneath. */
|
|
4705
|
+
let viewAnimTimer = 0;
|
|
4706
|
+
function animateViewSwitch(from, to) {
|
|
4707
|
+
if (from === to || !railShown()) return;
|
|
4708
|
+
const order = [...railList.querySelectorAll(".rail-item")].map((i) => i.dataset.view);
|
|
4709
|
+
const dir = order.indexOf(to) >= order.indexOf(from) ? "view-in-down" : "view-in-up";
|
|
4710
|
+
const shown = [...tabbar.querySelectorAll("#ungrouped .tab, #groups .group.in-view .tab")];
|
|
4711
|
+
// Cap the stagger: past ~8 agents the tail would only make you wait.
|
|
4712
|
+
shown.forEach((el, i) => el.style.setProperty("--i", Math.min(i, 8)));
|
|
4713
|
+
tabbar.classList.remove("view-in-down", "view-in-up");
|
|
4714
|
+
void tabbar.offsetWidth; // restart the animation on a quick re-click
|
|
4715
|
+
tabbar.classList.add(dir);
|
|
4716
|
+
clearTimeout(viewAnimTimer);
|
|
4717
|
+
viewAnimTimer = setTimeout(() => tabbar.classList.remove("view-in-down", "view-in-up"), 420);
|
|
4718
|
+
}
|
|
4719
|
+
|
|
4597
4720
|
function setView(v, { follow = true } = {}) {
|
|
4721
|
+
const from = currentView();
|
|
4598
4722
|
groupView = String(v);
|
|
4599
4723
|
localStorage.setItem(VIEW_KEY(), groupView);
|
|
4600
4724
|
applyView();
|
|
4725
|
+
animateViewSwitch(from, currentView());
|
|
4601
4726
|
// Picking a group shows one of ITS agents: staying on an agent that is no
|
|
4602
4727
|
// longer in the list would leave the chat and the list disagreeing.
|
|
4603
4728
|
if (follow && active && !active.btnEl.closest("#tweakSlot") && viewOfTab(active) !== currentView()) {
|
|
@@ -4753,6 +4878,33 @@
|
|
|
4753
4878
|
return item;
|
|
4754
4879
|
}
|
|
4755
4880
|
|
|
4881
|
+
// The selection pill: one element that GLIDES to the active group (the rows
|
|
4882
|
+
// themselves are rebuilt on every change, so they cannot transition).
|
|
4883
|
+
const railPill = document.createElement("div");
|
|
4884
|
+
railPill.className = "rail-pill";
|
|
4885
|
+
railPill.setAttribute("aria-hidden", "true");
|
|
4886
|
+
let pillView = null;
|
|
4887
|
+
function placeRailPill({ animate = true } = {}) {
|
|
4888
|
+
const item = railList.querySelector(".rail-item.active");
|
|
4889
|
+
if (!item || !item.offsetHeight) {
|
|
4890
|
+
railList.classList.remove("has-pill");
|
|
4891
|
+
railPill.hidden = true;
|
|
4892
|
+
pillView = null;
|
|
4893
|
+
return;
|
|
4894
|
+
}
|
|
4895
|
+
// First placement (or a layout change) jumps; only a change of group glides.
|
|
4896
|
+
const glide = animate && pillView !== null && pillView !== item.dataset.view;
|
|
4897
|
+
railPill.classList.toggle("no-anim", !glide);
|
|
4898
|
+
railPill.hidden = false;
|
|
4899
|
+
railPill.style.height = item.offsetHeight + "px";
|
|
4900
|
+
railPill.style.transform = `translateY(${item.offsetTop}px)`;
|
|
4901
|
+
railList.classList.add("has-pill");
|
|
4902
|
+
pillView = item.dataset.view;
|
|
4903
|
+
}
|
|
4904
|
+
// Collapsing the rail, resizing the window, or a group renamed to a longer
|
|
4905
|
+
// name moves the rows: follow without gliding.
|
|
4906
|
+
if (window.ResizeObserver) new ResizeObserver(() => placeRailPill({ animate: false })).observe(railList);
|
|
4907
|
+
|
|
4756
4908
|
let railFrame = 0;
|
|
4757
4909
|
let railSig = "";
|
|
4758
4910
|
function renderRail() {
|
|
@@ -4781,8 +4933,9 @@
|
|
|
4781
4933
|
return;
|
|
4782
4934
|
}
|
|
4783
4935
|
railSig = sig;
|
|
4784
|
-
railList.replaceChildren(...entries.map(railItem));
|
|
4936
|
+
railList.replaceChildren(...entries.map(railItem), railPill);
|
|
4785
4937
|
}
|
|
4938
|
+
placeRailPill();
|
|
4786
4939
|
if (groupIconsMinted && !restoring) {
|
|
4787
4940
|
groupIconsMinted = false;
|
|
4788
4941
|
persistGroups();
|
|
@@ -5121,6 +5274,7 @@
|
|
|
5121
5274
|
}
|
|
5122
5275
|
|
|
5123
5276
|
function activate(tab) {
|
|
5277
|
+
const prev = active;
|
|
5124
5278
|
if (active && active !== tab) {
|
|
5125
5279
|
active.draft = promptInput.value;
|
|
5126
5280
|
persistDraft(active);
|
|
@@ -5132,10 +5286,19 @@
|
|
|
5132
5286
|
if (!restoring && tab.sessionId) localStorage.setItem("cp.activeSession", tab.sessionId);
|
|
5133
5287
|
// Viewing the channel clears its "unread response" highlight.
|
|
5134
5288
|
if (tab.btnEl.classList.contains("unread")) setTabMood(tab, null);
|
|
5289
|
+
const switched = !!prev && prev !== tab && !restoring;
|
|
5135
5290
|
tabs.forEach((t) => {
|
|
5136
5291
|
t.btnEl.classList.toggle("active", t === tab);
|
|
5137
5292
|
t.transcriptEl.hidden = t !== tab;
|
|
5138
5293
|
});
|
|
5294
|
+
// Ease the new conversation in rather than snapping (only on a real switch).
|
|
5295
|
+
if (switched) {
|
|
5296
|
+
const el = tab.transcriptEl;
|
|
5297
|
+
el.classList.remove("chat-in");
|
|
5298
|
+
void el.offsetWidth;
|
|
5299
|
+
el.classList.add("chat-in");
|
|
5300
|
+
el.addEventListener("animationend", () => el.classList.remove("chat-in"), { once: true });
|
|
5301
|
+
}
|
|
5139
5302
|
promptInput.value = tab.draft;
|
|
5140
5303
|
autosizePrompt();
|
|
5141
5304
|
renderAttachments(tab); // each tab keeps its own pending chips
|
|
@@ -5993,6 +6156,7 @@
|
|
|
5993
6156
|
window.addEventListener("shadok-bridge-ready", resideAllPrompts);
|
|
5994
6157
|
|
|
5995
6158
|
function addTurn(tab, role, who, text, extraClass, at) {
|
|
6159
|
+
clearChatSkeleton(tab);
|
|
5996
6160
|
// Time of the last message in the channel list, relative ("3m ago"). Every
|
|
5997
6161
|
// message (replayed history or live) carries `at`; the last one wins.
|
|
5998
6162
|
if (Number.isFinite(at)) {
|
|
@@ -6234,6 +6398,43 @@
|
|
|
6234
6398
|
bubble.appendChild(row);
|
|
6235
6399
|
}
|
|
6236
6400
|
|
|
6401
|
+
/**
|
|
6402
|
+
* Put a "+" next to each env var an agent asks for (`HUBSPOT_TOKEN missing…`),
|
|
6403
|
+
* so providing it is one click instead of a trip to the vault and a retyped
|
|
6404
|
+
* name. Placed right after the inline code that names it; a name with no
|
|
6405
|
+
* inline span of its own (a fenced `export X=…`) falls back to a row below.
|
|
6406
|
+
*/
|
|
6407
|
+
function decorateEnvAsks(tab, bubble, text) {
|
|
6408
|
+
const names = window.envVarAsks(text);
|
|
6409
|
+
if (!names.length || !bubble) return;
|
|
6410
|
+
const placed = new Set();
|
|
6411
|
+
const plus = (name, cls, label) => {
|
|
6412
|
+
const b = document.createElement("button");
|
|
6413
|
+
b.type = "button";
|
|
6414
|
+
b.className = cls;
|
|
6415
|
+
b.textContent = label;
|
|
6416
|
+
b.title = "Add " + name + " to the secret vault" + (tab.profile ? " and inject it into " + tab.profile : "");
|
|
6417
|
+
// never onclick=: the CSP nonce does not cover inline handlers
|
|
6418
|
+
b.addEventListener("click", (e) => { e.stopPropagation(); openSecretFor(tab, name); });
|
|
6419
|
+
return b;
|
|
6420
|
+
};
|
|
6421
|
+
for (const code of bubble.querySelectorAll("code")) {
|
|
6422
|
+
if (code.closest("pre")) continue;
|
|
6423
|
+
for (const name of names) {
|
|
6424
|
+
if (placed.has(name) || !new RegExp("\\b" + name + "\\b").test(code.textContent)) continue;
|
|
6425
|
+
code.after(plus(name, "env-plus", "+"));
|
|
6426
|
+
placed.add(name);
|
|
6427
|
+
break;
|
|
6428
|
+
}
|
|
6429
|
+
}
|
|
6430
|
+
const rest = names.filter((n) => !placed.has(n));
|
|
6431
|
+
if (!rest.length) return;
|
|
6432
|
+
const row = document.createElement("div");
|
|
6433
|
+
row.className = "bang-row";
|
|
6434
|
+
for (const name of rest) row.appendChild(plus(name, "bang-btn env-btn", "+ " + name));
|
|
6435
|
+
bubble.appendChild(row);
|
|
6436
|
+
}
|
|
6437
|
+
|
|
6237
6438
|
/** A shell-mode exchange: the command, then its output once it arrives. */
|
|
6238
6439
|
function addBashCard(tab, h, at) {
|
|
6239
6440
|
if (h.command !== undefined || !tab.lastBashCard) {
|
|
@@ -6782,6 +6983,7 @@
|
|
|
6782
6983
|
// Reattach to the SAME session once we know its id; a brand-new tab still
|
|
6783
6984
|
// starts fresh (no sessionId yet).
|
|
6784
6985
|
const start = t.sessionId ? { ...t.launchMsg, resume: t.sessionId } : t.launchMsg;
|
|
6986
|
+
if (t.sessionId) showChatSkeleton(t); // a resume has history on its way
|
|
6785
6987
|
ws.send(JSON.stringify({ type: "start", origin: "web", ...start }));
|
|
6786
6988
|
});
|
|
6787
6989
|
ws.addEventListener("close", () => {
|
|
@@ -7465,6 +7667,9 @@
|
|
|
7465
7667
|
}
|
|
7466
7668
|
|
|
7467
7669
|
function handleMessage(t, msg) {
|
|
7670
|
+
// `history` comes BEFORE `ready` when there is one, so by `ready` (or any
|
|
7671
|
+
// ending) the skeleton has nothing left to stand in for.
|
|
7672
|
+
if (["ready", "error", "exited", "gone", "stopped"].includes(msg.type)) clearChatSkeleton(t);
|
|
7468
7673
|
switch (msg.type) {
|
|
7469
7674
|
case "version":
|
|
7470
7675
|
handleVersion(msg);
|
|
@@ -7500,6 +7705,7 @@
|
|
|
7500
7705
|
} else {
|
|
7501
7706
|
const b = addTurn(t, "claude", "claude", h.text, "hist", h.at);
|
|
7502
7707
|
decorateBang(t, b, h.text);
|
|
7708
|
+
decorateEnvAsks(t, b, h.text);
|
|
7503
7709
|
stampMsgIds(t, b.parentElement, h.ids);
|
|
7504
7710
|
// A hidden prompt (cron / notification) ran before this turn: keep
|
|
7505
7711
|
// its own label instead of merging it onto the previous answer
|
|
@@ -7690,6 +7896,7 @@
|
|
|
7690
7896
|
// spares `.after-gap`).
|
|
7691
7897
|
if (msg.afterInternal) b.parentElement.classList.add("after-gap");
|
|
7692
7898
|
decorateBang(t, b, msg.text);
|
|
7899
|
+
decorateEnvAsks(t, b, msg.text);
|
|
7693
7900
|
if (msg.msgId) stampMsgIds(t, b.parentElement, [msg.msgId]);
|
|
7694
7901
|
markUnreadBoundary(t, b.parentElement);
|
|
7695
7902
|
}
|
|
@@ -7899,7 +8106,7 @@
|
|
|
7899
8106
|
/** Lists the directory's resumable sessions; a click fills the id field. */
|
|
7900
8107
|
async function refreshSessionList() {
|
|
7901
8108
|
const listEl = $("sessionList");
|
|
7902
|
-
listEl.innerHTML =
|
|
8109
|
+
listEl.innerHTML = skeletonHTML("rows", 4);
|
|
7903
8110
|
let sessions = [];
|
|
7904
8111
|
try {
|
|
7905
8112
|
const cwd = $("cwdInput").value.trim();
|
|
@@ -8605,7 +8812,7 @@
|
|
|
8605
8812
|
if (!t || !t.sessionId) return;
|
|
8606
8813
|
$("diffBranch").textContent = t.branch || "working tree";
|
|
8607
8814
|
$("diffStatus").textContent = "loading…";
|
|
8608
|
-
$("diffBody").innerHTML = "";
|
|
8815
|
+
$("diffBody").innerHTML = skeletonHTML("diff");
|
|
8609
8816
|
try {
|
|
8610
8817
|
const d = await (await fetch("/diff?session=" + encodeURIComponent(t.sessionId))).json();
|
|
8611
8818
|
$("diffStatus").textContent = d.status || (d.diff ? "" : "no changes");
|
|
@@ -8822,6 +9029,26 @@
|
|
|
8822
9029
|
$("secretsOverlay").hidden = false;
|
|
8823
9030
|
$("secretKey").focus();
|
|
8824
9031
|
});
|
|
9032
|
+
/** The vault, opened on ONE name an agent asked for: the name filled in (or the
|
|
9033
|
+
* existing secret in edit mode), the asking agent's profile pre-ticked so the
|
|
9034
|
+
* value actually reaches it, the cursor on the value. Saving then goes through
|
|
9035
|
+
* the ordinary submit — and its reload offer, since a value lands at spawn. */
|
|
9036
|
+
async function openSecretFor(tab, name) {
|
|
9037
|
+
await Promise.all([loadVault(), loadProfilesInto()]);
|
|
9038
|
+
renderSecrets();
|
|
9039
|
+
$("secretsReload").hidden = true;
|
|
9040
|
+
if (vaultNames.includes(name)) startEditSecret(name);
|
|
9041
|
+
else {
|
|
9042
|
+
resetSecretForm();
|
|
9043
|
+
$("secretKey").value = name;
|
|
9044
|
+
}
|
|
9045
|
+
const cb = tab && tab.profile
|
|
9046
|
+
? [...$("secretProfiles").querySelectorAll("input")].find((c) => c.value === tab.profile)
|
|
9047
|
+
: null;
|
|
9048
|
+
if (cb) cb.checked = true;
|
|
9049
|
+
$("secretsOverlay").hidden = false;
|
|
9050
|
+
$("secretVal").focus();
|
|
9051
|
+
}
|
|
8825
9052
|
$("secretEditCancel").addEventListener("click", resetSecretForm);
|
|
8826
9053
|
// The missing link: from the secrets, go and attach them to a profile.
|
|
8827
9054
|
$("secretsToProfiles").addEventListener("click", () => {
|
|
@@ -8961,6 +9188,11 @@
|
|
|
8961
9188
|
applyLedgerFilters();
|
|
8962
9189
|
});
|
|
8963
9190
|
$("ledgerBtn").addEventListener("click", async () => {
|
|
9191
|
+
// Open at once on a skeleton: the table can be hundreds of rows, and a
|
|
9192
|
+
// click that does nothing until it lands reads as a dead button.
|
|
9193
|
+
$("ledgerList").innerHTML = skeletonHTML("rows", 6);
|
|
9194
|
+
$("ledgerCount").textContent = "";
|
|
9195
|
+
$("ledgerOverlay").hidden = false;
|
|
8964
9196
|
try { ledgerAll = (await (await fetch("/ledger")).json()).entries || []; } catch { ledgerAll = []; }
|
|
8965
9197
|
// The option lists come from the module, so the control and the filter it
|
|
8966
9198
|
// drives cannot drift apart.
|
|
@@ -9237,6 +9469,7 @@
|
|
|
9237
9469
|
* keyboard) lives here only, in a single copy. */
|
|
9238
9470
|
function renderProfileCards(container, selected, onPick, failed) {
|
|
9239
9471
|
container.innerHTML = "";
|
|
9472
|
+
container.removeAttribute("aria-busy"); // the boot skeleton, if still there
|
|
9240
9473
|
if (failed) {
|
|
9241
9474
|
const note = document.createElement("div");
|
|
9242
9475
|
note.className = "grid-note";
|