clay-server 4.0.0-beta.13 → 4.0.0-beta.15

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.
Files changed (52) hide show
  1. package/lib/capsule-display-floor.js +51 -0
  2. package/lib/capsule-frame-server.js +229 -0
  3. package/lib/capsule-pig-logic.js +268 -0
  4. package/lib/capsule-server-runtimes.js +41 -0
  5. package/lib/capsule-tictactoe-logic.js +260 -0
  6. package/lib/capsules/pig/display.js +190 -0
  7. package/lib/capsules/pig/manifest.json +9 -0
  8. package/lib/capsules/pig/ui.json +46 -0
  9. package/lib/capsules/tictactoe/manifest.json +9 -0
  10. package/lib/capsules/tictactoe/ui.json +203 -0
  11. package/lib/project-capsule-catalog.js +9 -1
  12. package/lib/project-connection.js +1 -1
  13. package/lib/project-log-feedback-delivery.js +76 -0
  14. package/lib/project-logs.js +4 -0
  15. package/lib/project-pair-lifecycle.js +7 -12
  16. package/lib/project-session-pair.js +29 -39
  17. package/lib/project-worker-proposal.js +203 -72
  18. package/lib/project.js +13 -0
  19. package/lib/public/css/capsule-ui.css +18 -0
  20. package/lib/public/css/home-session-actions.css +66 -0
  21. package/lib/public/css/home-sidebar.css +66 -0
  22. package/lib/public/css/mobile-nav.css +80 -0
  23. package/lib/public/css/sidebar.css +92 -0
  24. package/lib/public/css/worker-proposal.css +34 -1
  25. package/lib/public/modules/app-messages.js +14 -1
  26. package/lib/public/modules/home-conversations-sheet.js +102 -48
  27. package/lib/public/modules/home-session-actions.js +6 -0
  28. package/lib/public/modules/home-sidebar-chat-list.js +101 -41
  29. package/lib/public/modules/home-tool-frame.js +165 -0
  30. package/lib/public/modules/home-tools.js +129 -1
  31. package/lib/public/modules/session-hierarchy.js +54 -0
  32. package/lib/public/modules/sidebar-mobile.js +35 -6
  33. package/lib/public/modules/sidebar-session-hierarchy.js +206 -0
  34. package/lib/public/modules/sidebar-sessions.js +49 -7
  35. package/lib/public/modules/worker-proposal-state.js +16 -0
  36. package/lib/public/modules/worker-proposal.js +49 -12
  37. package/lib/sdk-bridge.js +16 -8
  38. package/lib/sdk-message-processor.js +34 -4
  39. package/lib/server-home-chat.js +12 -1
  40. package/lib/server-tools.js +97 -9
  41. package/lib/server.js +3 -0
  42. package/lib/session-driver-eligibility.js +20 -165
  43. package/lib/session-pair-factory.js +31 -28
  44. package/lib/session-pair-mcp-server.js +5 -9
  45. package/lib/session-pair-prompts.js +15 -15
  46. package/lib/session-provenance.js +119 -0
  47. package/lib/session-spawn-mcp-server.js +1 -1
  48. package/lib/sessions.js +40 -2
  49. package/lib/tools-registry.js +44 -10
  50. package/lib/ws-schema.js +8 -3
  51. package/lib/yoke/adapters/claude.js +12 -0
  52. package/package.json +1 -1
@@ -0,0 +1,165 @@
1
+ // home-tool-frame.js - host side of the sandboxed rich Display frame.
2
+ //
3
+ // A rich element is additive Display: it renders in an iframe on a separate
4
+ // anonymous origin with sandbox="allow-scripts" and a frame CSP that allows
5
+ // no network at all. Its only channel is postMessage, and the only thing it
6
+ // can request is an act, which the host routes through the exact callback the
7
+ // floor buttons use. The floor never depends on the frame: if the frame fails
8
+ // to load or go ready, the host falls back to the floor automatically.
9
+
10
+ import { getWs } from './ws-ref.js';
11
+
12
+ var READY_TIMEOUT_MS = 6000;
13
+ var MAX_ACTION_LENGTH = 64;
14
+
15
+ var frames = Object.create(null);
16
+
17
+ // One listener for every frame. Messages are matched to a frame by the
18
+ // window they came from, never by origin text: a sandboxed frame without
19
+ // allow-same-origin reports the opaque origin "null", so identity comes from
20
+ // the contentWindow reference the host itself created.
21
+ var listenerAttached = false;
22
+
23
+ function ensureListener() {
24
+ if (listenerAttached) return;
25
+ listenerAttached = true;
26
+ window.addEventListener("message", function (event) {
27
+ var toolIds = Object.keys(frames);
28
+ var entry = null;
29
+ for (var i = 0; i < toolIds.length; i++) {
30
+ var candidate = frames[toolIds[i]];
31
+ if (candidate.iframe && candidate.iframe.contentWindow === event.source) {
32
+ entry = candidate;
33
+ break;
34
+ }
35
+ }
36
+ if (!entry) return;
37
+ var data = event.data;
38
+ if (!data || data.clayCapsuleFrame !== 1 || typeof data.type !== "string") return;
39
+ if (data.type === "ready") {
40
+ markReady(entry);
41
+ return;
42
+ }
43
+ if (data.type === "act") {
44
+ // The frame asks; the host decides. Bounded validation here, then the
45
+ // same pipeline as a floor button click. Nothing else is accepted.
46
+ if (typeof data.actionId !== "string" || !data.actionId || data.actionId.length > MAX_ACTION_LENGTH) return;
47
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(data.actionId)) return;
48
+ var args = data.args && typeof data.args === "object" && !Array.isArray(data.args) ? data.args : {};
49
+ entry.onAct(data.actionId, args);
50
+ }
51
+ });
52
+ }
53
+
54
+ function markReady(entry) {
55
+ if (entry.ready) return;
56
+ entry.ready = true;
57
+ if (entry.readyTimer) {
58
+ clearTimeout(entry.readyTimer);
59
+ entry.readyTimer = null;
60
+ }
61
+ for (var i = 0; i < entry.queue.length; i++) {
62
+ postToFrame(entry, entry.queue[i]);
63
+ }
64
+ entry.queue.length = 0;
65
+ if (entry.onReady) entry.onReady();
66
+ }
67
+
68
+ function postToFrame(entry, payload) {
69
+ if (!entry.iframe || !entry.iframe.contentWindow) return;
70
+ // targetOrigin must be "*": a sandboxed frame without allow-same-origin has
71
+ // an opaque origin no origin string can name. Delivery is still exact,
72
+ // because the message goes to the one contentWindow this host created, and
73
+ // the payload is only the projection already chosen for this element.
74
+ entry.iframe.contentWindow.postMessage(payload, "*");
75
+ }
76
+
77
+ function failFrame(toolId, message) {
78
+ var entry = frames[toolId];
79
+ if (!entry) return;
80
+ disposeFrame(toolId);
81
+ if (entry.onUnavailable) entry.onUnavailable(message);
82
+ }
83
+
84
+ // Mounts the rich element for one Capsule. The frame URL is requested over
85
+ // the authenticated WebSocket and answered by handleToolFrameUrlState below.
86
+ export function mountRichDisplay(options) {
87
+ var toolId = options.toolId;
88
+ disposeFrame(toolId);
89
+ ensureListener();
90
+ frames[toolId] = {
91
+ toolId: toolId,
92
+ container: options.container,
93
+ onAct: options.onAct,
94
+ onReady: options.onReady || null,
95
+ onUnavailable: options.onUnavailable || null,
96
+ iframe: null,
97
+ ready: false,
98
+ queue: [],
99
+ readyTimer: null,
100
+ };
101
+ var ws = getWs();
102
+ if (!ws || ws.readyState !== 1) {
103
+ failFrame(toolId, "The connection to the server is not available.");
104
+ return;
105
+ }
106
+ ws.send(JSON.stringify({ type: "tool_frame_url", toolId: toolId }));
107
+ }
108
+
109
+ export function handleToolFrameUrlState(msg) {
110
+ if (!msg || !msg.toolId) return;
111
+ var entry = frames[msg.toolId];
112
+ if (!entry || entry.iframe) return;
113
+ if (msg.ok === false || !msg.frame || !msg.frame.port) {
114
+ failFrame(msg.toolId, msg.error || "The rich Display is unavailable.");
115
+ return;
116
+ }
117
+ var scheme = msg.frame.secure ? "https" : "http";
118
+ var url = scheme + "://" + window.location.hostname + ":" + msg.frame.port + msg.frame.path;
119
+ var iframe = document.createElement("iframe");
120
+ iframe.className = "home-tool-frame";
121
+ // allow-scripts only. No allow-same-origin, so the frame's origin is opaque
122
+ // and nothing of the host origin (DOM, cookies, storage) is reachable.
123
+ iframe.setAttribute("sandbox", "allow-scripts");
124
+ iframe.setAttribute("title", "Capsule rich display");
125
+ iframe.src = url;
126
+ entry.iframe = iframe;
127
+ entry.container.appendChild(iframe);
128
+ entry.readyTimer = setTimeout(function () {
129
+ failFrame(msg.toolId, "The rich Display did not become ready.");
130
+ }, READY_TIMEOUT_MS);
131
+ }
132
+
133
+ export function pushFrameState(toolId, state) {
134
+ var entry = frames[toolId];
135
+ if (!entry) return;
136
+ var payload = { clayCapsuleFrame: 1, type: "state", state: state || {} };
137
+ if (!entry.ready) {
138
+ entry.queue.push(payload);
139
+ return;
140
+ }
141
+ postToFrame(entry, payload);
142
+ }
143
+
144
+ export function pushFrameEvent(toolId, event) {
145
+ var entry = frames[toolId];
146
+ if (!entry || !event) return;
147
+ var payload = { clayCapsuleFrame: 1, type: "event", event: event };
148
+ if (!entry.ready) {
149
+ entry.queue.push(payload);
150
+ return;
151
+ }
152
+ postToFrame(entry, payload);
153
+ }
154
+
155
+ export function hasFrame(toolId) {
156
+ return !!frames[toolId];
157
+ }
158
+
159
+ export function disposeFrame(toolId) {
160
+ var entry = frames[toolId];
161
+ if (!entry) return;
162
+ if (entry.readyTimer) clearTimeout(entry.readyTimer);
163
+ if (entry.iframe && entry.iframe.parentNode) entry.iframe.parentNode.removeChild(entry.iframe);
164
+ delete frames[toolId];
165
+ }
@@ -9,6 +9,7 @@ import { showToast } from './utils.js';
9
9
  import { createToolLlmStatus, initialToolLlmAlias } from './tool-llm-status.js';
10
10
  import { isToolModelAlias, shouldInjectToolLlmStatus } from './tool-ui-tree.js';
11
11
  import { mountCapsuleHostControls, disposeCapsuleHostControls, handleToolSourceState as applyToolSourceState, handleToolMateAccessState as applyToolMateAccessState } from './home-capsule-source.js';
12
+ import { mountRichDisplay, pushFrameState, pushFrameEvent, disposeFrame } from './home-tool-frame.js';
12
13
 
13
14
  var definitions = Object.create(null);
14
15
  var runtimes = Object.create(null);
@@ -16,6 +17,8 @@ var registeredIds = Object.create(null);
16
17
  var errorSurfaces = Object.create(null);
17
18
  var displays = Object.create(null);
18
19
  var llmStatuses = Object.create(null);
20
+ var serverStates = Object.create(null);
21
+ var serverEventSeqs = Object.create(null);
19
22
 
20
23
  export function requestTools() {
21
24
  var ws = getWs();
@@ -29,6 +32,7 @@ function stopRuntime(toolId) {
29
32
  if (runtimes[toolId]) runtimes[toolId].stop();
30
33
  if (llmStatuses[toolId]) llmStatuses[toolId].dispose();
31
34
  if (displays[toolId]) disposeToolUi(displays[toolId]);
35
+ disposeFrame(toolId);
32
36
  delete runtimes[toolId];
33
37
  delete errorSurfaces[toolId];
34
38
  delete displays[toolId];
@@ -52,6 +56,125 @@ function renderRuntimeState(toolId, state) {
52
56
  }, display);
53
57
  }
54
58
 
59
+ // Server-runtime Capsules hold their Logic on the server, so the human's click
60
+ // travels the same act pipeline a Mate's clay_tool_act call travels. The
61
+ // browser only renders the state Logic hands back.
62
+ function sendServerControl(toolId, request) {
63
+ var ws = getWs();
64
+ if (!ws || ws.readyState !== 1) {
65
+ showRuntimeError(toolId, "The connection to the server is not available.");
66
+ return;
67
+ }
68
+ ws.send(JSON.stringify({
69
+ type: "tool_server_control",
70
+ toolId: toolId,
71
+ kind: request.kind,
72
+ actionId: request.actionId || null,
73
+ args: request.args || {},
74
+ }));
75
+ }
76
+
77
+ function renderServerState(toolId, state) {
78
+ var definition = definitions[toolId];
79
+ var display = displays[toolId];
80
+ if (!definition || !display) return;
81
+ renderToolUi(toolId, definition.uiTree, state || {}, function (actionName, args) {
82
+ sendServerControl(toolId, { kind: "act", actionId: actionName, args: args });
83
+ }, display);
84
+ }
85
+
86
+ // Applies a server-runtime Capsule state if it is not older than what is
87
+ // already rendered. States carry the Logic's monotonic eventSeq, so a slow
88
+ // snapshot response can never roll the Display back behind a pushed event.
89
+ function applyServerState(toolId, state) {
90
+ state = state || {};
91
+ // A successful read or event clears any stale refusal either way.
92
+ var surface = errorSurfaces[toolId];
93
+ if (surface) surface.classList.add("hidden");
94
+ var seq = Number.isInteger(state.eventSeq) ? state.eventSeq : null;
95
+ var lastSeq = serverEventSeqs[toolId];
96
+ if (seq !== null && lastSeq !== undefined && seq <= lastSeq) return false;
97
+ if (seq !== null) serverEventSeqs[toolId] = seq;
98
+ serverStates[toolId] = state;
99
+ renderServerState(toolId, state);
100
+ return true;
101
+ }
102
+
103
+ // Brief host-side attribution when the other seat acts: the Display flashes
104
+ // and the dock icon pulses, so a watching human can tell the change was the
105
+ // Mate operating the same interface. The meaning itself stays in state; this
106
+ // is only the host pointing at it.
107
+ function flashRemoteAct(toolId) {
108
+ pulseDockTool(toolId);
109
+ var display = displays[toolId];
110
+ if (!display) return;
111
+ display.classList.remove("capsule-remote-act");
112
+ void display.offsetWidth;
113
+ display.classList.add("capsule-remote-act");
114
+ setTimeout(function () { display.classList.remove("capsule-remote-act"); }, 900);
115
+ }
116
+
117
+ export function handleToolServerState(msg) {
118
+ if (!msg || !msg.toolId) return;
119
+ if (msg.ok === false) {
120
+ showRuntimeError(msg.toolId, msg.error || "The Capsule action failed.");
121
+ return;
122
+ }
123
+ if (applyServerState(msg.toolId, msg.state)) pushFrameState(msg.toolId, msg.state || {});
124
+ }
125
+
126
+ // The push half of the shared-interface effect: Logic changed, no matter whose
127
+ // act changed it, so every open Display for this user re-renders. Events are
128
+ // applied one by one in seq order, so consecutive acts replay as steps rather
129
+ // than collapsing into a final state. The rich frame receives the full causal
130
+ // event so it can animate and attribute the transition.
131
+ export function handleToolServerEvent(msg) {
132
+ if (!msg || !msg.toolId || !msg.event || !msg.event.next) return;
133
+ if (!applyServerState(msg.toolId, msg.event.next)) return;
134
+ pushFrameEvent(msg.toolId, msg.event);
135
+ if (msg.event.actor && msg.event.actor !== "user") flashRemoteAct(msg.toolId);
136
+ }
137
+
138
+ // Mounts the additive rich element above the floor. The floor is rendered
139
+ // first and stays a click away behind the toggle, and any frame failure
140
+ // (no URL, load error, never ready) falls back to the floor silently: the
141
+ // rich element may enrich the Capsule, never gate it.
142
+ function mountServerFrame(toolId, root, floorEl) {
143
+ var frameWrap = document.createElement("div");
144
+ frameWrap.className = "home-tool-frame-wrap hidden";
145
+ frameWrap.dataset.capsuleRuntimeSurface = "true";
146
+ var toggle = document.createElement("button");
147
+ toggle.type = "button";
148
+ toggle.className = "home-tool-frame-toggle hidden";
149
+ toggle.dataset.capsuleRuntimeSurface = "true";
150
+ var showingFrame = false;
151
+ function setView(rich) {
152
+ showingFrame = rich;
153
+ frameWrap.classList.toggle("hidden", !rich);
154
+ floorEl.classList.toggle("hidden", rich);
155
+ toggle.textContent = rich ? "Standard controls" : "Rich view";
156
+ }
157
+ toggle.addEventListener("click", function () { setView(!showingFrame); });
158
+ root.appendChild(frameWrap);
159
+ root.appendChild(toggle);
160
+ mountRichDisplay({
161
+ toolId: toolId,
162
+ container: frameWrap,
163
+ onAct: function (actionId, args) {
164
+ sendServerControl(toolId, { kind: "act", actionId: actionId, args: args });
165
+ },
166
+ onReady: function () {
167
+ toggle.classList.remove("hidden");
168
+ setView(true);
169
+ },
170
+ onUnavailable: function () {
171
+ toggle.classList.add("hidden");
172
+ setView(false);
173
+ },
174
+ });
175
+ pushFrameState(toolId, serverStates[toolId] || {});
176
+ }
177
+
55
178
  function ensureRuntime(toolId) {
56
179
  if (runtimes[toolId]) return runtimes[toolId];
57
180
  var definition = definitions[toolId];
@@ -102,8 +225,11 @@ function mountTool(toolId, contentEl, chromeEl) {
102
225
  var serverDisplay = document.createElement("div");
103
226
  serverDisplay.className = "home-tool-display";
104
227
  serverDisplay.dataset.capsuleRuntimeSurface = "true";
228
+ if (definition.hasRichDisplay) mountServerFrame(toolId, root, serverDisplay);
105
229
  root.appendChild(serverDisplay);
106
- renderToolUi(toolId, definition.uiTree, {}, function () {}, serverDisplay);
230
+ displays[toolId] = serverDisplay;
231
+ renderServerState(toolId, serverStates[toolId] || {});
232
+ sendServerControl(toolId, { kind: "snapshot" });
107
233
  return;
108
234
  }
109
235
  var runtime = ensureRuntime(toolId);
@@ -189,6 +315,8 @@ export function handleToolMateAccessState(msg) {
189
315
  export function handleToolRemoved(msg) {
190
316
  stopRuntime(msg.toolId);
191
317
  delete definitions[msg.toolId];
318
+ delete serverStates[msg.toolId];
319
+ delete serverEventSeqs[msg.toolId];
192
320
  delete registeredIds[msg.toolId];
193
321
  unregisterDockTool(msg.toolId);
194
322
  syncStore();
@@ -0,0 +1,54 @@
1
+ // Pure projection shared by desktop, mobile and Home session lists.
2
+
3
+ export function buildSessionHierarchy(sessions) {
4
+ var source = Array.isArray(sessions) ? sessions : [];
5
+ var byId = new Map();
6
+ var workersByParent = new Map();
7
+ var roots = [];
8
+ var orphans = [];
9
+ for (var i = 0; i < source.length; i++) byId.set(nodeId(source[i]), source[i]);
10
+ for (var j = 0; j < source.length; j++) {
11
+ var session = source[j];
12
+ if (session.sessionRole !== "worker") {
13
+ roots.push({ driver: session, workers: [] });
14
+ continue;
15
+ }
16
+ var parent = session.parentAvailable ? byId.get(parentNodeId(session)) : null;
17
+ if (!parent || parent.sessionRole === "worker") {
18
+ orphans.push(session);
19
+ continue;
20
+ }
21
+ if (!workersByParent.has(nodeId(parent))) workersByParent.set(nodeId(parent), []);
22
+ workersByParent.get(nodeId(parent)).push(session);
23
+ }
24
+ for (var k = 0; k < roots.length; k++) {
25
+ roots[k].workers = workersByParent.get(nodeId(roots[k].driver)) || [];
26
+ roots[k].workers.sort(compareWorkers);
27
+ roots[k].lastActivity = latestActivity(roots[k]);
28
+ }
29
+ orphans.sort(compareWorkers);
30
+ return { roots: roots, orphans: orphans };
31
+ }
32
+
33
+ function nodeId(session) {
34
+ return session && session.hierarchyId != null ? session.hierarchyId : session.id;
35
+ }
36
+
37
+ function parentNodeId(session) {
38
+ return session && session.parentHierarchyId != null ? session.parentHierarchyId : session.parentSessionId;
39
+ }
40
+
41
+ function compareWorkers(a, b) {
42
+ var ag = typeof a.workerGeneration === "number" ? a.workerGeneration : 0;
43
+ var bg = typeof b.workerGeneration === "number" ? b.workerGeneration : 0;
44
+ if (ag !== bg) return bg - ag;
45
+ return (b.lastActivity || 0) - (a.lastActivity || 0);
46
+ }
47
+
48
+ export function latestActivity(root) {
49
+ var latest = root.driver.lastActivity || 0;
50
+ for (var i = 0; i < root.workers.length; i++) {
51
+ if ((root.workers[i].lastActivity || 0) > latest) latest = root.workers[i].lastActivity || 0;
52
+ }
53
+ return latest;
54
+ }
@@ -37,6 +37,11 @@ import { openHomeChat } from './home-mate-chat.js';
37
37
  import { openTerminal } from './terminal.js';
38
38
  import { requestKnowledgeList } from './mate-knowledge.js';
39
39
  import { loadRootDirectory } from './filebrowser.js';
40
+ import {
41
+ prepareSidebarHierarchy,
42
+ renderMobileDriverHierarchy,
43
+ renderMobileOrphanHierarchy
44
+ } from './sidebar-session-hierarchy.js';
40
45
 
41
46
  // --- Mobile state ---
42
47
  var mobileChatSheetOpen = false;
@@ -374,9 +379,11 @@ function renderSheetSessions(listEl) {
374
379
  }
375
380
 
376
381
  // Helper: create a mobile session item element
377
- function createMobileSessionItem(s) {
382
+ function createMobileSessionItem(s, options) {
383
+ var itemOptions = options || {};
378
384
  var el = document.createElement("button");
379
385
  el.className = "mobile-session-item" + (s.active ? " active" : "");
386
+ if (itemOptions.worker) el.classList.add("mobile-worker-item");
380
387
 
381
388
  // Vendor badge (legacy sessions without a vendor are Claude)
382
389
  var mVendor = s.vendor || "claude";
@@ -398,6 +405,13 @@ function createMobileSessionItem(s) {
398
405
  titleSpan.appendChild(document.createTextNode(s.title || "New Session"));
399
406
  el.appendChild(titleSpan);
400
407
 
408
+ if (itemOptions.worker) {
409
+ var generation = document.createElement("span");
410
+ generation.className = "mobile-worker-generation" + (itemOptions.current ? " current" : "");
411
+ generation.textContent = itemOptions.current ? "Current" : (s.workerGeneration ? "Gen " + s.workerGeneration : "Worker");
412
+ el.appendChild(generation);
413
+ }
414
+
401
415
  // Unread badge (right side)
402
416
  if (s.unread > 0 && !s.active) {
403
417
  var badge = document.createElement("span");
@@ -720,10 +734,13 @@ function renderMobileSessionsInto(container) {
720
734
 
721
735
  // Partition: loop sessions vs normal sessions (same logic as desktop renderSessionList)
722
736
  var sessions = getCachedSessions();
737
+ var hierarchy = prepareSidebarHierarchy(sessions);
738
+ var hierarchyByDriver = hierarchy.byDriver;
723
739
  var loopGroups = {};
724
740
  var normalSessions = [];
725
741
  for (var i = 0; i < sessions.length; i++) {
726
742
  var s = sessions[i];
743
+ if (s.sessionRole === "worker") continue;
727
744
  if (s.loop && s.loop.loopId && s.loop.role === "crafting" && s.loop.source !== "ralph" && s.loop.source !== "debate") {
728
745
  continue;
729
746
  } else if (s.loop && s.loop.loopId) {
@@ -740,7 +757,14 @@ function renderMobileSessionsInto(container) {
740
757
  // Build virtual items
741
758
  var items = [];
742
759
  for (var j = 0; j < normalSessions.length; j++) {
743
- items.push({ type: "session", data: normalSessions[j], lastActivity: normalSessions[j].lastActivity || 0 });
760
+ var driverRoot = hierarchyByDriver.get(normalSessions[j].id);
761
+ if (driverRoot) items.push({ type: "driver-hierarchy", data: driverRoot.driver, root: driverRoot, lastActivity: driverRoot.lastActivity });
762
+ else items.push({ type: "session", data: normalSessions[j], lastActivity: normalSessions[j].lastActivity || 0 });
763
+ }
764
+ if (hierarchy.orphans.length) {
765
+ var orphanActivity = 0;
766
+ for (var oi = 0; oi < hierarchy.orphans.length; oi++) orphanActivity = Math.max(orphanActivity, hierarchy.orphans[oi].lastActivity || 0);
767
+ items.push({ type: "orphan-workers", workers: hierarchy.orphans, lastActivity: orphanActivity });
744
768
  }
745
769
  var groupKeys = Object.keys(loopGroups);
746
770
  for (var k = 0; k < groupKeys.length; k++) {
@@ -757,8 +781,8 @@ function renderMobileSessionsInto(container) {
757
781
 
758
782
  // Sort by lastActivity descending
759
783
  items.sort(function (a, b) {
760
- var aBookmarked = !!(a.type === "loop" ? false : a.data && a.data.bookmarked);
761
- var bBookmarked = !!(b.type === "loop" ? false : b.data && b.data.bookmarked);
784
+ var aBookmarked = !!(a.type === "loop" || a.type === "orphan-workers" ? false : a.data && a.data.bookmarked);
785
+ var bBookmarked = !!(b.type === "loop" || b.type === "orphan-workers" ? false : b.data && b.data.bookmarked);
762
786
  if (aBookmarked !== bBookmarked) return aBookmarked ? -1 : 1;
763
787
  return (b.lastActivity || 0) - (a.lastActivity || 0);
764
788
  });
@@ -767,7 +791,7 @@ function renderMobileSessionsInto(container) {
767
791
  var regularItems = [];
768
792
  for (var n = 0; n < items.length; n++) {
769
793
  var item = items[n];
770
- if (item.type === "session" && item.data && item.data.bookmarked) {
794
+ if ((item.type === "session" || item.type === "driver-hierarchy") && item.data && item.data.bookmarked) {
771
795
  bookmarkedItems.push(item);
772
796
  } else {
773
797
  regularItems.push(item);
@@ -781,7 +805,8 @@ function renderMobileSessionsInto(container) {
781
805
  container.appendChild(bookmarkedHeader);
782
806
 
783
807
  for (var bi = 0; bi < bookmarkedItems.length; bi++) {
784
- container.appendChild(createMobileSessionItem(bookmarkedItems[bi].data));
808
+ if (bookmarkedItems[bi].type === "driver-hierarchy") container.appendChild(renderMobileDriverHierarchy(bookmarkedItems[bi].root, createMobileSessionItem, refreshMobileChatSheet));
809
+ else container.appendChild(createMobileSessionItem(bookmarkedItems[bi].data));
785
810
  }
786
811
  }
787
812
 
@@ -800,6 +825,10 @@ function renderMobileSessionsInto(container) {
800
825
  }
801
826
  if (item.type === "loop") {
802
827
  container.appendChild(createMobileLoopGroup(item.loopId, item.children, item.groupKey));
828
+ } else if (item.type === "driver-hierarchy") {
829
+ container.appendChild(renderMobileDriverHierarchy(item.root, createMobileSessionItem, refreshMobileChatSheet));
830
+ } else if (item.type === "orphan-workers") {
831
+ container.appendChild(renderMobileOrphanHierarchy(item.workers, createMobileSessionItem, refreshMobileChatSheet));
803
832
  } else {
804
833
  container.appendChild(createMobileSessionItem(item.data));
805
834
  }