shadok-ai 0.15.28 → 0.15.30
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 +10 -0
- package/package.json +1 -1
- package/public/group-nav.js +60 -0
- package/public/index.html +409 -6
package/README.md
CHANGED
|
@@ -76,6 +76,16 @@ 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 column** — on a desktop the groups get a column of their own, left
|
|
80
|
+
of the agents: pick a group and the agents column shows only its agents
|
|
81
|
+
(*Ungrouped* holds the rest, the home agent included unless you file it). Each
|
|
82
|
+
group has an icon — drawn at random, click it to pick another or type any
|
|
83
|
+
emoji — and a **+** that creates the new agent straight into that group.
|
|
84
|
+
Dragging an agent onto a group still files it there, and dragging groups
|
|
85
|
+
reorders them. Double-click a name to rename it; **«** folds the column to
|
|
86
|
+
its icons. A dot on a group you are not looking at says one of its agents
|
|
87
|
+
is waiting for you, has an unread answer, or is working. On a phone nothing
|
|
88
|
+
changes: the agents sheet still lists every group.
|
|
79
89
|
- **Worktree isolation** — spawned agents get their own git worktree and branch
|
|
80
90
|
by default, so parallel agents never collide. Work is never auto-discarded:
|
|
81
91
|
an empty worktree is reclaimed on close, anything with changes or commits
|
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two-column navigation: groups on the left (the "rail"), the selected
|
|
3
|
+
* group's agents beside it. Pure, so the browser and test/group-nav.test.ts
|
|
4
|
+
* share it.
|
|
5
|
+
*
|
|
6
|
+
* The rail is a VIEW over the agents column, never a second copy of the groups:
|
|
7
|
+
* the order, names and membership still live in the agents column's DOM, which
|
|
8
|
+
* is what `persistGroups` / `persistChannels` read. So nothing here owns state.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Icons a new group draws from; any emoji can be typed in instead. */
|
|
12
|
+
export const GROUP_ICONS = [
|
|
13
|
+
"📁", "🚀", "🧪", "🛠", "📦", "🎯", "🧭", "🔬", "🌱", "⚡",
|
|
14
|
+
"🧩", "📊", "🔒", "🎨", "📣", "🐛", "🗂", "🛰", "🔥", "💡",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** The view a group is selected under; the agents with no group have their own. */
|
|
18
|
+
export const UNGROUPED = "ungrouped";
|
|
19
|
+
|
|
20
|
+
/** A random icon from GROUP_ICONS. `rand` is injectable for the tests. */
|
|
21
|
+
export function randomGroupIcon(rand = Math.random) {
|
|
22
|
+
const i = Math.floor(rand() * GROUP_ICONS.length);
|
|
23
|
+
return GROUP_ICONS[Math.min(Math.max(i, 0), GROUP_ICONS.length - 1)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The view to show, from what was saved and the groups that exist now. A saved
|
|
28
|
+
* group that was dissolved (here or on another device) falls back to the
|
|
29
|
+
* ungrouped view rather than to an empty column.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveGroupView(saved, groupIds) {
|
|
32
|
+
const ids = (groupIds || []).map(String);
|
|
33
|
+
return saved != null && ids.includes(String(saved)) ? String(saved) : UNGROUPED;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One mood for a whole group, from its agents' moods — what the rail shows for
|
|
38
|
+
* a group that is not on screen. Ordered by how much it needs you: a question
|
|
39
|
+
* waiting beats an unread answer, which beats work in progress.
|
|
40
|
+
*/
|
|
41
|
+
export function groupMood(moods) {
|
|
42
|
+
const set = new Set(moods || []);
|
|
43
|
+
for (const m of ["needs-answer", "unread", "working"]) if (set.has(m)) return m;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A typed icon, trimmed and kept short: one emoji may be several code points
|
|
49
|
+
* (a flag, a skin tone, a ZWJ family), so the cap is on graphemes when the
|
|
50
|
+
* runtime can count them. Empty → null, so the caller keeps the old one.
|
|
51
|
+
*/
|
|
52
|
+
export function cleanGroupIcon(text) {
|
|
53
|
+
const s = String(text ?? "").trim();
|
|
54
|
+
if (!s) return null;
|
|
55
|
+
if (typeof Intl !== "undefined" && Intl.Segmenter) {
|
|
56
|
+
const seg = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)];
|
|
57
|
+
return seg.slice(0, 2).map((x) => x.segment).join("");
|
|
58
|
+
}
|
|
59
|
+
return [...s].slice(0, 2).join("");
|
|
60
|
+
}
|
package/public/index.html
CHANGED
|
@@ -702,6 +702,104 @@
|
|
|
702
702
|
border-radius: 4px;
|
|
703
703
|
}
|
|
704
704
|
|
|
705
|
+
/* ── Groups column (two-column navigation, desktop) ─────
|
|
706
|
+
The rail lists the groups; the agents column shows the selected one. The
|
|
707
|
+
agents column keeps its DOM (groups, bodies, tabs) because that is what is
|
|
708
|
+
persisted, so the filtering below is pure CSS over it: group heads go (the
|
|
709
|
+
rail is the header now), bodies are always unfolded, and every tab outside
|
|
710
|
+
the selected group is hidden, the home agent included (it lives in its own
|
|
711
|
+
group like any other). Phones (≤640px) keep the single-column sheet. */
|
|
712
|
+
#groupRail { display: none; }
|
|
713
|
+
.icon-picker[hidden] { display: none; }
|
|
714
|
+
@media (min-width: 641px) {
|
|
715
|
+
#groupRail {
|
|
716
|
+
width: 190px; flex: none;
|
|
717
|
+
display: flex; flex-direction: column; gap: 2px;
|
|
718
|
+
background: var(--bg-inset);
|
|
719
|
+
border-right: 1px solid var(--line);
|
|
720
|
+
padding: 12px 8px; overflow-y: auto;
|
|
721
|
+
}
|
|
722
|
+
.rail-head { display: flex; align-items: center; justify-content: space-between; padding: 0 4px 8px 8px; }
|
|
723
|
+
#railMini { border: none; background: none; padding: 2px 6px; color: var(--text-dim); font-size: 13px; }
|
|
724
|
+
#railMini:hover { color: var(--amber); }
|
|
725
|
+
#railList { display: flex; flex-direction: column; gap: 2px; }
|
|
726
|
+
.rail-item {
|
|
727
|
+
position: relative;
|
|
728
|
+
display: flex; align-items: center; gap: 8px;
|
|
729
|
+
padding: 6px 8px; border-radius: 6px; border: 1px solid transparent;
|
|
730
|
+
cursor: pointer; user-select: none; color: var(--text-dim); font-size: 13px;
|
|
731
|
+
}
|
|
732
|
+
.rail-item:hover { background: var(--bg-raised); color: var(--text); }
|
|
733
|
+
.rail-item.active { background: var(--bg-raised); border-color: var(--line); color: var(--text); }
|
|
734
|
+
.rail-item.drop-target { border-color: var(--amber); border-style: dashed; }
|
|
735
|
+
.rail-item.dragging { opacity: 0.4; }
|
|
736
|
+
.rail-icon {
|
|
737
|
+
flex: none; width: 24px; height: 24px; padding: 0;
|
|
738
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
739
|
+
border: 1px solid transparent; border-radius: 5px; background: none; font-size: 15px; line-height: 1;
|
|
740
|
+
}
|
|
741
|
+
.rail-item.editable .rail-icon:hover { border-color: var(--line); background: var(--bg-inset); }
|
|
742
|
+
.rail-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
743
|
+
.rail-item input.rename {
|
|
744
|
+
flex: 1; min-width: 0; font-size: 12px;
|
|
745
|
+
background: var(--bg-inset); border: 1px solid var(--amber); border-radius: 3px;
|
|
746
|
+
color: var(--text); padding: 2px 5px;
|
|
747
|
+
}
|
|
748
|
+
.rail-count { flex: none; font-family: var(--mono); font-size: 10px; color: var(--text-dim); }
|
|
749
|
+
/* What a group that is not on screen asks of you — the single place it shows. */
|
|
750
|
+
.rail-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; display: none; }
|
|
751
|
+
.rail-item.needs-answer .rail-dot { display: block; background: var(--amber); animation: breathe 1.2s ease-in-out infinite; }
|
|
752
|
+
.rail-item.unread .rail-dot { display: block; background: var(--phosphor); }
|
|
753
|
+
.rail-item.working .rail-dot { display: block; background: var(--text-dim); animation: breathe 1.8s ease-in-out infinite; }
|
|
754
|
+
@media (prefers-reduced-motion: reduce) { .rail-dot { animation: none !important; } }
|
|
755
|
+
.rail-add, .rail-del {
|
|
756
|
+
flex: none; display: none; border: none; background: none;
|
|
757
|
+
padding: 0 3px; font-size: 13px; line-height: 1; color: var(--text-dim);
|
|
758
|
+
}
|
|
759
|
+
.rail-item:hover .rail-add, .rail-item:hover .rail-del { display: inline-block; }
|
|
760
|
+
.rail-item:hover .rail-count { display: none; }
|
|
761
|
+
.rail-add:hover { color: var(--amber); }
|
|
762
|
+
.rail-del:hover { color: var(--err); }
|
|
763
|
+
#groupRail #newGroup {
|
|
764
|
+
margin-top: 8px; border: 1px dashed var(--line);
|
|
765
|
+
font-size: 12px; color: var(--text-dim); padding: 8px;
|
|
766
|
+
}
|
|
767
|
+
#groupRail #newGroup:hover { color: var(--amber); border-color: var(--amber); }
|
|
768
|
+
/* Collapsed to icons, like a dock: the names live in the tooltips. */
|
|
769
|
+
body.rail-mini #groupRail { width: 52px; padding: 12px 6px; }
|
|
770
|
+
body.rail-mini .rail-head { justify-content: center; padding: 0 0 8px; }
|
|
771
|
+
body.rail-mini .rail-head .label,
|
|
772
|
+
body.rail-mini .rail-name, body.rail-mini .rail-count,
|
|
773
|
+
body.rail-mini .rail-add, body.rail-mini .rail-del,
|
|
774
|
+
body.rail-mini .rail-txt { display: none !important; }
|
|
775
|
+
body.rail-mini .rail-item { justify-content: center; padding: 6px 0; }
|
|
776
|
+
body.rail-mini .rail-dot { position: absolute; top: 4px; right: 4px; }
|
|
777
|
+
|
|
778
|
+
/* The agents column, narrowed to the selected group. */
|
|
779
|
+
#tabbar .side-label .sl-all { display: none; }
|
|
780
|
+
#tabbar .group-head { display: none; }
|
|
781
|
+
#tabbar .group { margin-top: 0; }
|
|
782
|
+
#tabbar .group-body { padding-left: 0; }
|
|
783
|
+
#tabbar .group.collapsed .group-body { display: flex; }
|
|
784
|
+
/* The home agent is an agent like the others here: it shows in ITS group
|
|
785
|
+
(usually Ungrouped), not pinned into every one. */
|
|
786
|
+
#tabbar:not([data-view="ungrouped"]) #ungrouped,
|
|
787
|
+
#tabbar[data-view="ungrouped"] #groups,
|
|
788
|
+
#tabbar .group:not(.in-view) { display: none; }
|
|
789
|
+
}
|
|
790
|
+
@media (max-width: 640px) {
|
|
791
|
+
#tabbar .side-label .sl-view, #viewEmpty { display: none; }
|
|
792
|
+
}
|
|
793
|
+
.icon-picker {
|
|
794
|
+
position: fixed; z-index: 60;
|
|
795
|
+
background: var(--menu-bg); border: 1px solid var(--line); border-radius: 8px;
|
|
796
|
+
padding: 8px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); width: 212px;
|
|
797
|
+
}
|
|
798
|
+
.icon-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; margin-bottom: 8px; }
|
|
799
|
+
.icon-grid button { border: 1px solid transparent; background: none; font-size: 18px; padding: 4px 0; border-radius: 5px; }
|
|
800
|
+
.icon-grid button:hover, .icon-grid button.current { border-color: var(--line); background: var(--menu-hover); }
|
|
801
|
+
.icon-picker input { width: 100%; font-size: 13px; }
|
|
802
|
+
|
|
705
803
|
/* ── Body ────────────────────────────────────────────── */
|
|
706
804
|
main { flex: 1; display: flex; min-height: 0; }
|
|
707
805
|
/* min-height:0 so the transcript (flex:1) bounds the height instead of pushing
|
|
@@ -2441,12 +2539,28 @@
|
|
|
2441
2539
|
</div>
|
|
2442
2540
|
|
|
2443
2541
|
<main>
|
|
2542
|
+
<!-- The groups column (desktop only): a VIEW over the agents column beside it,
|
|
2543
|
+
which still holds the groups, their order and their members — that is what
|
|
2544
|
+
is persisted. Picking a group narrows the agents column to it. On a phone
|
|
2545
|
+
this column is hidden and the agents sheet shows every group, as before. -->
|
|
2546
|
+
<nav id="groupRail" aria-label="Groups">
|
|
2547
|
+
<div class="rail-head">
|
|
2548
|
+
<span class="label">Groups</span>
|
|
2549
|
+
<button id="railMini" type="button" title="Collapse the groups column">«</button>
|
|
2550
|
+
</div>
|
|
2551
|
+
<div id="railList"></div>
|
|
2552
|
+
<button id="newGroup" title="New agent group">+ <span class="rail-txt">new group</span></button>
|
|
2553
|
+
</nav>
|
|
2554
|
+
<div id="iconPicker" class="icon-picker" hidden>
|
|
2555
|
+
<div id="iconGrid" class="icon-grid"></div>
|
|
2556
|
+
<input id="iconInput" placeholder="or type an emoji…" autocomplete="off" spellcheck="false">
|
|
2557
|
+
</div>
|
|
2444
2558
|
<nav id="tabbar" aria-label="Agents">
|
|
2445
|
-
<div class="label side-label">Agents</div>
|
|
2559
|
+
<div class="label side-label"><span class="sl-all">Agents</span><span class="sl-view" id="viewLabel"></span></div>
|
|
2446
2560
|
<div id="ungrouped"></div>
|
|
2447
2561
|
<div id="groups"></div>
|
|
2562
|
+
<p id="viewEmpty" class="check-hint" hidden>No agent in this group yet — create one, or drag one onto the group.</p>
|
|
2448
2563
|
<button id="newTab" title="New agent">+ new agent</button>
|
|
2449
|
-
<button id="newGroup" title="New agent group">+ new group</button>
|
|
2450
2564
|
<div id="tweakSlot">
|
|
2451
2565
|
<button id="tweakCta" type="button"
|
|
2452
2566
|
title="Change shadok-ai itself — the agent works on a branch and opens a pull request">
|
|
@@ -2628,6 +2742,13 @@
|
|
|
2628
2742
|
window.buildMessageLink = buildMessageLink;
|
|
2629
2743
|
window.parseMessageLink = parseMessageLink;
|
|
2630
2744
|
window.samePrompt = samePrompt;
|
|
2745
|
+
import { GROUP_ICONS, UNGROUPED, randomGroupIcon, resolveGroupView, groupMood, cleanGroupIcon } from "/group-nav.js?v=__ASSET_V__";
|
|
2746
|
+
window.GROUP_ICONS = GROUP_ICONS;
|
|
2747
|
+
window.UNGROUPED = UNGROUPED;
|
|
2748
|
+
window.randomGroupIcon = randomGroupIcon;
|
|
2749
|
+
window.resolveGroupView = resolveGroupView;
|
|
2750
|
+
window.groupMood = groupMood;
|
|
2751
|
+
window.cleanGroupIcon = cleanGroupIcon;
|
|
2631
2752
|
import { uploadFailure } from "/upload.js?v=__ASSET_V__";
|
|
2632
2753
|
window.uploadFailure = uploadFailure;
|
|
2633
2754
|
import { fmtExpiry, expiryWarning } from "/auth-expiry.js?v=__ASSET_V__";
|
|
@@ -2758,6 +2879,12 @@
|
|
|
2758
2879
|
buildMessageLink: () => null,
|
|
2759
2880
|
parseMessageLink: () => null,
|
|
2760
2881
|
samePrompt: () => false,
|
|
2882
|
+
// The rail must paint even before the module lands: a plain folder icon, the
|
|
2883
|
+
// saved view if it still names a group, and the mood logic as in the module.
|
|
2884
|
+
randomGroupIcon: () => "📁",
|
|
2885
|
+
resolveGroupView: (saved, ids) => ((ids || []).map(String).includes(String(saved)) ? String(saved) : "ungrouped"),
|
|
2886
|
+
groupMood: (moods) => ["needs-answer", "unread", "working"].find((m) => (moods || []).includes(m)) || null,
|
|
2887
|
+
cleanGroupIcon: (t) => String(t ?? "").trim() || null,
|
|
2761
2888
|
uploadFailure: (status, contentType) =>
|
|
2762
2889
|
/json/i.test(contentType || "") ? null : status === 413 ? "too large (over the server's upload limit)" : `upload failed (HTTP ${status || "?"})`,
|
|
2763
2890
|
};
|
|
@@ -3847,6 +3974,7 @@
|
|
|
3847
3974
|
|
|
3848
3975
|
// ── Tab groups ──────────────────────────────────────────
|
|
3849
3976
|
let groups = [];
|
|
3977
|
+
let groupIconsMinted = false;
|
|
3850
3978
|
let groupSeq = Number(localStorage.getItem("cp.groupSeq") || 0);
|
|
3851
3979
|
|
|
3852
3980
|
function groupIdOf(tabEl) {
|
|
@@ -3859,6 +3987,7 @@
|
|
|
3859
3987
|
const list = [...groupsEl.children].map((el) => ({
|
|
3860
3988
|
id: el._group.id,
|
|
3861
3989
|
name: el._group.nameEl.textContent,
|
|
3990
|
+
icon: el._group.icon,
|
|
3862
3991
|
collapsed: el.classList.contains("collapsed"),
|
|
3863
3992
|
}));
|
|
3864
3993
|
localStorage.setItem(dirKey("cp.groups", instanceKey), JSON.stringify(list));
|
|
@@ -3898,7 +4027,11 @@
|
|
|
3898
4027
|
el.append(head, body);
|
|
3899
4028
|
groupsEl.appendChild(el);
|
|
3900
4029
|
|
|
3901
|
-
|
|
4030
|
+
// Every group carries an icon for the rail; one restored without (made before
|
|
4031
|
+
// icons existed) gets a random one, persisted once the restore is over.
|
|
4032
|
+
const icon = opts.icon || window.randomGroupIcon();
|
|
4033
|
+
if (!opts.icon) groupIconsMinted = true;
|
|
4034
|
+
const group = { id, el, headEl: head, bodyEl: body, nameEl: gname, icon, iconDrawn: !opts.icon };
|
|
3902
4035
|
el._group = group;
|
|
3903
4036
|
const paintCaret = () => {
|
|
3904
4037
|
caret.textContent = el.classList.contains("collapsed") ? "▸" : "▾";
|
|
@@ -3941,11 +4074,16 @@
|
|
|
3941
4074
|
let dragGroupEl = null;
|
|
3942
4075
|
|
|
3943
4076
|
function endDrag() {
|
|
4077
|
+
const wasGroup = !!dragGroupEl;
|
|
3944
4078
|
if (dragTabEl) dragTabEl.classList.remove("dragging");
|
|
3945
4079
|
if (dragGroupEl) dragGroupEl.classList.remove("dragging");
|
|
3946
4080
|
dragTabEl = dragGroupEl = null;
|
|
3947
4081
|
document.body.classList.remove("dragging-tab");
|
|
4082
|
+
document.querySelectorAll(".rail-item.drop-target, .rail-item.dragging").forEach((r) => r.classList.remove("drop-target", "dragging"));
|
|
3948
4083
|
persistChannels();
|
|
4084
|
+
if (wasGroup) persistGroups();
|
|
4085
|
+
railSig = "";
|
|
4086
|
+
renderRail();
|
|
3949
4087
|
}
|
|
3950
4088
|
|
|
3951
4089
|
tabbar.addEventListener("dragover", (e) => {
|
|
@@ -3988,6 +4126,267 @@
|
|
|
3988
4126
|
if (dragTabEl || dragGroupEl) e.preventDefault();
|
|
3989
4127
|
});
|
|
3990
4128
|
|
|
4129
|
+
// ── Groups column (two-column navigation) ──────────────
|
|
4130
|
+
// A VIEW over the agents column: it reads the groups from `groups` / the DOM
|
|
4131
|
+
// and writes back through the same calls the column always used
|
|
4132
|
+
// (persistGroups, persistChannels), so there is one source of truth.
|
|
4133
|
+
const railEl = $("groupRail"), railList = $("railList");
|
|
4134
|
+
const VIEW_KEY = () => dirKey("cp.groupView", instanceKey);
|
|
4135
|
+
let groupView = localStorage.getItem(VIEW_KEY()) || "ungrouped";
|
|
4136
|
+
|
|
4137
|
+
/** Is the two-column layout on screen? (Hidden on a phone.) */
|
|
4138
|
+
const railShown = () => getComputedStyle(railEl).display !== "none";
|
|
4139
|
+
|
|
4140
|
+
function currentView() {
|
|
4141
|
+
return window.resolveGroupView(groupView, groups.map((g) => g.id));
|
|
4142
|
+
}
|
|
4143
|
+
/** The group a new agent goes into: the one on screen, none on a phone. */
|
|
4144
|
+
function viewGroupId() {
|
|
4145
|
+
if (!railShown()) return null;
|
|
4146
|
+
const v = currentView();
|
|
4147
|
+
return v === "ungrouped" ? null : Number(v);
|
|
4148
|
+
}
|
|
4149
|
+
const viewOfTab = (tab) => {
|
|
4150
|
+
const id = groupIdOf(tab.btnEl);
|
|
4151
|
+
return id == null ? "ungrouped" : String(id);
|
|
4152
|
+
};
|
|
4153
|
+
const tabsInView = (v) =>
|
|
4154
|
+
tabs.filter((t) => !t.btnEl.closest("#tweakSlot") && viewOfTab(t) === v);
|
|
4155
|
+
|
|
4156
|
+
function setView(v, { follow = true } = {}) {
|
|
4157
|
+
groupView = String(v);
|
|
4158
|
+
localStorage.setItem(VIEW_KEY(), groupView);
|
|
4159
|
+
applyView();
|
|
4160
|
+
// Picking a group shows one of ITS agents: staying on an agent that is no
|
|
4161
|
+
// longer in the list would leave the chat and the list disagreeing.
|
|
4162
|
+
if (follow && active && !active.btnEl.closest("#tweakSlot") && viewOfTab(active) !== currentView()) {
|
|
4163
|
+
const first = tabsInView(currentView())[0];
|
|
4164
|
+
if (first) activate(first);
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
/** Whatever opens an agent (a click, a link, a notification) brings its group. */
|
|
4168
|
+
function showTabGroup(tab) {
|
|
4169
|
+
if (!tab || tab.btnEl.closest("#tweakSlot")) return;
|
|
4170
|
+
const v = viewOfTab(tab);
|
|
4171
|
+
if (v !== currentView()) setView(v, { follow: false });
|
|
4172
|
+
}
|
|
4173
|
+
|
|
4174
|
+
function applyView() {
|
|
4175
|
+
const v = currentView();
|
|
4176
|
+
tabbar.dataset.view = v;
|
|
4177
|
+
for (const g of groups) g.el.classList.toggle("in-view", String(g.id) === v);
|
|
4178
|
+
const g = groups.find((x) => String(x.id) === v);
|
|
4179
|
+
$("viewLabel").textContent = g ? `${g.icon} ${g.nameEl.textContent}` : "Ungrouped";
|
|
4180
|
+
$("viewEmpty").hidden = tabsInView(v).length > 0 || v === "ungrouped";
|
|
4181
|
+
renderRail();
|
|
4182
|
+
}
|
|
4183
|
+
|
|
4184
|
+
const moodOf = (t) =>
|
|
4185
|
+
["needs-answer", "unread", "working"].find((m) => t.btnEl.classList.contains(m)) || null;
|
|
4186
|
+
|
|
4187
|
+
function railItem({ view, icon, name, group }) {
|
|
4188
|
+
const members = tabsInView(view);
|
|
4189
|
+
const item = document.createElement("div");
|
|
4190
|
+
item.className = "rail-item" + (group ? " editable" : "");
|
|
4191
|
+
item.dataset.view = view;
|
|
4192
|
+
item.title = name;
|
|
4193
|
+
if (view === currentView()) item.classList.add("active");
|
|
4194
|
+
// The mood of a group you are NOT looking at — the one you are looking at
|
|
4195
|
+
// already shows it on each agent.
|
|
4196
|
+
const mood = view === currentView() ? null : window.groupMood(members.map(moodOf));
|
|
4197
|
+
if (mood) item.classList.add(mood);
|
|
4198
|
+
const ic = document.createElement("button");
|
|
4199
|
+
ic.type = "button";
|
|
4200
|
+
ic.className = "rail-icon";
|
|
4201
|
+
ic.textContent = icon;
|
|
4202
|
+
if (group) ic.title = "Change the icon";
|
|
4203
|
+
const nm = document.createElement("span");
|
|
4204
|
+
nm.className = "rail-name";
|
|
4205
|
+
nm.textContent = name;
|
|
4206
|
+
const count = document.createElement("span");
|
|
4207
|
+
count.className = "rail-count";
|
|
4208
|
+
count.textContent = members.length || "";
|
|
4209
|
+
const dot = document.createElement("span");
|
|
4210
|
+
dot.className = "rail-dot";
|
|
4211
|
+
const add = document.createElement("button");
|
|
4212
|
+
add.type = "button";
|
|
4213
|
+
add.className = "rail-add";
|
|
4214
|
+
add.textContent = "+";
|
|
4215
|
+
add.title = "New agent in " + name;
|
|
4216
|
+
item.append(ic, nm, dot, count, add);
|
|
4217
|
+
if (group) {
|
|
4218
|
+
const del = document.createElement("button");
|
|
4219
|
+
del.type = "button";
|
|
4220
|
+
del.className = "rail-del";
|
|
4221
|
+
del.textContent = "×";
|
|
4222
|
+
del.title = "Ungroup (its agents move to Ungrouped)";
|
|
4223
|
+
item.appendChild(del);
|
|
4224
|
+
del.addEventListener("click", (e) => {
|
|
4225
|
+
e.stopPropagation();
|
|
4226
|
+
dissolveGroup(group);
|
|
4227
|
+
persistGroups();
|
|
4228
|
+
applyView();
|
|
4229
|
+
});
|
|
4230
|
+
ic.addEventListener("click", (e) => {
|
|
4231
|
+
e.stopPropagation();
|
|
4232
|
+
openIconPicker(group, ic);
|
|
4233
|
+
});
|
|
4234
|
+
nm.addEventListener("dblclick", (e) => {
|
|
4235
|
+
e.stopPropagation();
|
|
4236
|
+
inlineRename(item, nm, (v) => {
|
|
4237
|
+
group.nameEl.textContent = v;
|
|
4238
|
+
persistGroups();
|
|
4239
|
+
applyView();
|
|
4240
|
+
});
|
|
4241
|
+
});
|
|
4242
|
+
// Reorder groups by dragging them in the rail; the agents column's order
|
|
4243
|
+
// follows, since that is the order persisted.
|
|
4244
|
+
item.draggable = true;
|
|
4245
|
+
item.addEventListener("dragstart", (e) => {
|
|
4246
|
+
e.dataTransfer.effectAllowed = "move";
|
|
4247
|
+
e.dataTransfer.setData("text/plain", "group");
|
|
4248
|
+
dragGroupEl = group.el;
|
|
4249
|
+
requestAnimationFrame(() => item.classList.add("dragging"));
|
|
4250
|
+
});
|
|
4251
|
+
item.addEventListener("dragend", () => endDrag());
|
|
4252
|
+
}
|
|
4253
|
+
item.addEventListener("click", () => setView(view));
|
|
4254
|
+
add.addEventListener("click", (e) => {
|
|
4255
|
+
e.stopPropagation();
|
|
4256
|
+
setView(view, { follow: false });
|
|
4257
|
+
openSetup();
|
|
4258
|
+
});
|
|
4259
|
+
// A dragged AGENT dropped on a group is filed into it (the drag in the
|
|
4260
|
+
// agents column still works too).
|
|
4261
|
+
item.addEventListener("dragover", (e) => {
|
|
4262
|
+
if (dragTabEl) {
|
|
4263
|
+
if (dragTabEl.closest("#tweakSlot")) return;
|
|
4264
|
+
e.preventDefault();
|
|
4265
|
+
e.dataTransfer.dropEffect = "move";
|
|
4266
|
+
item.classList.add("drop-target");
|
|
4267
|
+
} else if (dragGroupEl && group && dragGroupEl !== group.el) {
|
|
4268
|
+
e.preventDefault();
|
|
4269
|
+
const from = railList.querySelector(".rail-item.dragging");
|
|
4270
|
+
const r = item.getBoundingClientRect();
|
|
4271
|
+
const after = e.clientY > r.top + r.height / 2;
|
|
4272
|
+
groupsEl.insertBefore(dragGroupEl, after ? group.el.nextSibling : group.el);
|
|
4273
|
+
if (from) railList.insertBefore(from, after ? item.nextSibling : item);
|
|
4274
|
+
}
|
|
4275
|
+
});
|
|
4276
|
+
item.addEventListener("dragleave", () => item.classList.remove("drop-target"));
|
|
4277
|
+
item.addEventListener("drop", (e) => {
|
|
4278
|
+
e.preventDefault();
|
|
4279
|
+
if (!dragTabEl) return;
|
|
4280
|
+
const body = group ? group.bodyEl : ungroupedEl;
|
|
4281
|
+
if (dragTabEl.parentNode !== body) body.appendChild(dragTabEl);
|
|
4282
|
+
endDrag();
|
|
4283
|
+
});
|
|
4284
|
+
return item;
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
let railFrame = 0;
|
|
4288
|
+
let railSig = "";
|
|
4289
|
+
function renderRail() {
|
|
4290
|
+
// Not while a group is being dragged (the dragged item must survive until its
|
|
4291
|
+
// dragend) nor while a name is being typed (a re-render would eat the input).
|
|
4292
|
+
if (dragGroupEl || railList.querySelector("input.rename")) return;
|
|
4293
|
+
const entries = [
|
|
4294
|
+
{ view: "ungrouped", icon: "📥", name: "Ungrouped" },
|
|
4295
|
+
...[...groupsEl.children].map((el) =>
|
|
4296
|
+
({ view: String(el._group.id), icon: el._group.icon, name: el._group.nameEl.textContent, group: el._group })),
|
|
4297
|
+
];
|
|
4298
|
+
// The column mutates all the time (LEDs, timers, reconnects): rebuild only
|
|
4299
|
+
// when what the rail SHOWS changed, or every click would land on an item
|
|
4300
|
+
// replaced under the pointer.
|
|
4301
|
+
const v = currentView();
|
|
4302
|
+
const sig = v + "|" + entries.map((e) => {
|
|
4303
|
+
const members = tabsInView(e.view);
|
|
4304
|
+
return [e.view, e.icon, e.name, members.length, members.map(moodOf).join(",")].join("/");
|
|
4305
|
+
}).join("|");
|
|
4306
|
+
if (sig !== railSig) {
|
|
4307
|
+
railSig = sig;
|
|
4308
|
+
railList.replaceChildren(...entries.map(railItem));
|
|
4309
|
+
}
|
|
4310
|
+
if (groupIconsMinted && !restoring) {
|
|
4311
|
+
groupIconsMinted = false;
|
|
4312
|
+
persistGroups();
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
// Any change in the agents column — a tab added, moved or closed, a group
|
|
4316
|
+
// created or dissolved, a mood class — repaints the rail, once per frame.
|
|
4317
|
+
// The rail lives outside the column, so painting it never re-triggers this.
|
|
4318
|
+
new MutationObserver(() => {
|
|
4319
|
+
if (railFrame) return;
|
|
4320
|
+
railFrame = requestAnimationFrame(() => {
|
|
4321
|
+
railFrame = 0;
|
|
4322
|
+
applyView();
|
|
4323
|
+
});
|
|
4324
|
+
}).observe(tabbar, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
|
|
4325
|
+
|
|
4326
|
+
// The icon picker: the shortlist, or any emoji typed in.
|
|
4327
|
+
const iconPicker = $("iconPicker"), iconInput = $("iconInput");
|
|
4328
|
+
let iconTarget = null;
|
|
4329
|
+
function setGroupIcon(icon) {
|
|
4330
|
+
const clean = window.cleanGroupIcon(icon);
|
|
4331
|
+
if (iconTarget && clean) {
|
|
4332
|
+
iconTarget.icon = clean;
|
|
4333
|
+
persistGroups();
|
|
4334
|
+
applyView();
|
|
4335
|
+
}
|
|
4336
|
+
closeIconPicker();
|
|
4337
|
+
}
|
|
4338
|
+
function openIconPicker(group, anchor) {
|
|
4339
|
+
iconTarget = group;
|
|
4340
|
+
const grid = $("iconGrid");
|
|
4341
|
+
grid.replaceChildren(
|
|
4342
|
+
...(window.GROUP_ICONS || ["📁"]).map((ic) => {
|
|
4343
|
+
const b = document.createElement("button");
|
|
4344
|
+
b.type = "button";
|
|
4345
|
+
b.textContent = ic;
|
|
4346
|
+
if (ic === group.icon) b.classList.add("current");
|
|
4347
|
+
b.addEventListener("click", (e) => { e.stopPropagation(); setGroupIcon(ic); });
|
|
4348
|
+
return b;
|
|
4349
|
+
}),
|
|
4350
|
+
);
|
|
4351
|
+
iconInput.value = "";
|
|
4352
|
+
iconPicker.hidden = false;
|
|
4353
|
+
const r = anchor.getBoundingClientRect();
|
|
4354
|
+
iconPicker.style.left = Math.min(r.left, innerWidth - iconPicker.offsetWidth - 8) + "px";
|
|
4355
|
+
iconPicker.style.top = Math.min(r.bottom + 4, innerHeight - iconPicker.offsetHeight - 8) + "px";
|
|
4356
|
+
iconInput.focus();
|
|
4357
|
+
}
|
|
4358
|
+
function closeIconPicker() {
|
|
4359
|
+
iconPicker.hidden = true;
|
|
4360
|
+
iconTarget = null;
|
|
4361
|
+
}
|
|
4362
|
+
iconPicker.addEventListener("click", (e) => e.stopPropagation());
|
|
4363
|
+
iconInput.addEventListener("keydown", (e) => {
|
|
4364
|
+
e.stopPropagation();
|
|
4365
|
+
if (e.key === "Enter") { e.preventDefault(); setGroupIcon(iconInput.value); }
|
|
4366
|
+
if (e.key === "Escape") closeIconPicker();
|
|
4367
|
+
});
|
|
4368
|
+
document.addEventListener("click", () => { if (!iconPicker.hidden) closeIconPicker(); });
|
|
4369
|
+
|
|
4370
|
+
// Collapse the rail to its icons; remembered per browser.
|
|
4371
|
+
function applyRailMini(on) {
|
|
4372
|
+
document.body.classList.toggle("rail-mini", on);
|
|
4373
|
+
$("railMini").textContent = on ? "»" : "«";
|
|
4374
|
+
$("railMini").title = on ? "Expand the groups column" : "Collapse the groups column";
|
|
4375
|
+
}
|
|
4376
|
+
applyRailMini(localStorage.getItem("cp.railMini") === "1");
|
|
4377
|
+
$("railMini").addEventListener("click", () => {
|
|
4378
|
+
const on = !document.body.classList.contains("rail-mini");
|
|
4379
|
+
localStorage.setItem("cp.railMini", on ? "1" : "0");
|
|
4380
|
+
applyRailMini(on);
|
|
4381
|
+
});
|
|
4382
|
+
// A group restored before the module landed drew the stub's plain folder:
|
|
4383
|
+
// draw again for real now (re-drawing a real draw is harmless — none of them
|
|
4384
|
+
// has been persisted or shown for more than an instant).
|
|
4385
|
+
window.addEventListener("shadok-bridge-ready", () => {
|
|
4386
|
+
for (const g of groups) if (g.iconDrawn) { g.icon = window.randomGroupIcon(); g.iconDrawn = false; groupIconsMinted = true; }
|
|
4387
|
+
applyView();
|
|
4388
|
+
});
|
|
4389
|
+
|
|
3991
4390
|
/** State line shown while the auto-continue waits for the pace to recover. */
|
|
3992
4391
|
const PACE_HOLD_STATE = "waiting on pace";
|
|
3993
4392
|
|
|
@@ -4224,6 +4623,7 @@
|
|
|
4224
4623
|
persistDraft(active);
|
|
4225
4624
|
}
|
|
4226
4625
|
active = tab;
|
|
4626
|
+
showTabGroup(tab);
|
|
4227
4627
|
// A reload must come back to the channel you were watching: remember its
|
|
4228
4628
|
// sessionId (outside the initial restore, which replays the activates itself).
|
|
4229
4629
|
if (!restoring && tab.sessionId) localStorage.setItem("cp.activeSession", tab.sessionId);
|
|
@@ -6169,8 +6569,9 @@
|
|
|
6169
6569
|
}
|
|
6170
6570
|
$("startBtn").disabled = true;
|
|
6171
6571
|
closeSetup();
|
|
6172
|
-
// createTab activates the created tab; launchTab opens the link.
|
|
6173
|
-
|
|
6572
|
+
// createTab activates the created tab; launchTab opens the link. A new agent
|
|
6573
|
+
// lands in the group on screen — that is what the group's + relies on.
|
|
6574
|
+
const t = createTab(viewGroupId());
|
|
6174
6575
|
// The name is set BEFORE launchTab, which would otherwise overwrite it with
|
|
6175
6576
|
// basename(cwd). customName also protects it from `ready` and from the
|
|
6176
6577
|
// channel restore.
|
|
@@ -9325,8 +9726,10 @@
|
|
|
9325
9726
|
launchTab(t, { cwd: data.cwd, worktree: true, profile: "Shadok-Tweak" });
|
|
9326
9727
|
});
|
|
9327
9728
|
newGroupBtn.addEventListener("click", () => {
|
|
9328
|
-
createGroup();
|
|
9729
|
+
const g = createGroup();
|
|
9329
9730
|
persistGroups();
|
|
9731
|
+
// A new group is where you are going next: select it (it starts empty).
|
|
9732
|
+
if (railShown()) setView(g.id, { follow: false });
|
|
9330
9733
|
});
|
|
9331
9734
|
|
|
9332
9735
|
// ── Quota gauges (5h / 7d) ──────────────────────────────
|