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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.15.28",
3
+ "version": "0.15.30",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test --test-timeout=120000 test/*.ts context/*/test/*.test.mjs",
@@ -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
- const group = { id, el, headEl: head, bodyEl: body, nameEl: gname };
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
- const t = createTab();
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) ──────────────────────────────