pilotswarm 0.5.13 → 0.5.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.
- package/README.md +6 -0
- package/mcp/README.md +14 -2
- package/mcp/dist/src/context.d.ts +13 -0
- package/mcp/dist/src/context.d.ts.map +1 -1
- package/mcp/dist/src/context.js +20 -0
- package/mcp/dist/src/context.js.map +1 -1
- package/mcp/dist/src/server.d.ts.map +1 -1
- package/mcp/dist/src/server.js +5 -1
- package/mcp/dist/src/server.js.map +1 -1
- package/mcp/dist/src/tools/capabilities.d.ts +4 -0
- package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
- package/mcp/dist/src/tools/capabilities.js +13 -0
- package/mcp/dist/src/tools/capabilities.js.map +1 -1
- package/mcp/dist/src/tools/groups.d.ts +5 -4
- package/mcp/dist/src/tools/groups.d.ts.map +1 -1
- package/mcp/dist/src/tools/groups.js +43 -20
- package/mcp/dist/src/tools/groups.js.map +1 -1
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +118 -2
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/package.json +3 -2
- package/tui/src/app.js +19 -2
- package/tui/src/auth/cli.js +13 -0
- package/tui/src/node-sdk-transport.js +99 -13
- package/tui/tui-splash-mobile.txt +5 -7
- package/tui/tui-splash.txt +13 -9
- package/ui/core/src/commands.js +2 -0
- package/ui/core/src/controller.js +454 -35
- package/ui/core/src/history.js +19 -1
- package/ui/core/src/reducer.js +121 -8
- package/ui/core/src/selectors.js +204 -14
- package/ui/core/src/state.js +3 -0
- package/ui/core/src/themes/helpers.js +4 -0
- package/ui/react/src/components.js +95 -6
- package/ui/react/src/web-app.js +798 -157
- package/web/api/router.js +7 -6
- package/web/api/ws.js +9 -0
- package/web/auth/index.js +5 -0
- package/web/auth/providers/dev.js +119 -0
- package/web/authz.js +142 -0
- package/web/dist/assets/index-CZizkB5Z.js +24 -0
- package/web/dist/assets/index-D9e2TGjO.css +1 -0
- package/web/dist/assets/pilotswarm-KMqn3ZJs.js +90 -0
- package/web/dist/assets/react-l0sNRNKZ.js +1 -0
- package/web/dist/index.html +3 -4
- package/web/runtime.js +553 -37
- package/web/server.js +2 -2
- package/web/dist/assets/index-bQ2QInMX.js +0 -24
- package/web/dist/assets/index-oldX95Tp.css +0 -1
- package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
- package/web/dist/assets/react-C9iQPS2h.js +0 -1
package/ui/core/src/history.js
CHANGED
|
@@ -353,16 +353,33 @@ export function dedupeChatMessages(chat = []) {
|
|
|
353
353
|
return deduped;
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
+
// In a multi-writer session the runtime prepends a `[FROM: name (relation)]`
|
|
357
|
+
// attribution line to the prompt so the agent knows who is speaking. The chat
|
|
358
|
+
// pane conveys the same thing through the message's speaker label + color, so
|
|
359
|
+
// strip the raw marker from the DISPLAY text (the structured `sender` below
|
|
360
|
+
// drives the label). Only a leading marker is removed.
|
|
361
|
+
function stripLeadingSenderMarker(text) {
|
|
362
|
+
return typeof text === "string"
|
|
363
|
+
? text.replace(/^\[FROM:[^\]\n]*\]\n?/, "")
|
|
364
|
+
: text;
|
|
365
|
+
}
|
|
366
|
+
|
|
356
367
|
function buildChatMessage(event, role) {
|
|
357
368
|
const rawText = messageTextFromEvent(event);
|
|
358
369
|
const sessionMessageCard = buildSessionMessageChatCard(event, rawText);
|
|
359
370
|
if (sessionMessageCard) return sessionMessageCard;
|
|
360
371
|
|
|
361
|
-
const text = extractVisibleChatText(rawText, role);
|
|
372
|
+
const text = stripLeadingSenderMarker(extractVisibleChatText(rawText, role));
|
|
362
373
|
if (!hasVisibleMessageText(text)) return null;
|
|
363
374
|
const clientMessageIds = Array.isArray(event?.data?.clientMessageIds)
|
|
364
375
|
? event.data.clientMessageIds.filter((id) => typeof id === "string" && id)
|
|
365
376
|
: [];
|
|
377
|
+
// Structured sender identity (security model): who sent this message. The
|
|
378
|
+
// chat selector uses it to label the line with the sender's name and a
|
|
379
|
+
// distinct color when it is not the current viewer.
|
|
380
|
+
const sender = event?.data?.sender && typeof event.data.sender === "object"
|
|
381
|
+
? event.data.sender
|
|
382
|
+
: null;
|
|
366
383
|
return {
|
|
367
384
|
id: `${event.sessionId}:${event.seq}`,
|
|
368
385
|
role: deriveChatRole(event, role, text),
|
|
@@ -370,6 +387,7 @@ function buildChatMessage(event, role) {
|
|
|
370
387
|
time: formatTimestamp(event.createdAt),
|
|
371
388
|
createdAt: event.createdAt instanceof Date ? event.createdAt.getTime() : new Date(event.createdAt).getTime(),
|
|
372
389
|
...(clientMessageIds.length > 0 ? { clientMessageIds } : {}),
|
|
390
|
+
...(sender ? { sender } : {}),
|
|
373
391
|
};
|
|
374
392
|
}
|
|
375
393
|
|
package/ui/core/src/reducer.js
CHANGED
|
@@ -324,6 +324,12 @@ function resolveVisibleActiveSessionId(state, fallbackSessions = []) {
|
|
|
324
324
|
if (currentSessionId && visibleRows.some((row) => row.sessionId === currentSessionId)) {
|
|
325
325
|
return currentSessionId;
|
|
326
326
|
}
|
|
327
|
+
// A navigation intent (deep link) owns selection until it is cleared by
|
|
328
|
+
// manual navigation or a filter change. While one exists — pending target
|
|
329
|
+
// still loading, or failed — never fall back to a default selection.
|
|
330
|
+
if (state.sessions?.navigationIntent) {
|
|
331
|
+
return currentSessionId;
|
|
332
|
+
}
|
|
327
333
|
if (currentSessionId && state.sessions?.byId?.[currentSessionId] && !hasSessionVisibilityFilter(state.sessions)) {
|
|
328
334
|
return currentSessionId;
|
|
329
335
|
}
|
|
@@ -361,20 +367,67 @@ function updateUiForSessionSelection(state, nextActiveSessionId) {
|
|
|
361
367
|
}
|
|
362
368
|
|
|
363
369
|
function applyVisibleSessionSelection(state, nextSessions) {
|
|
370
|
+
let sessions = nextSessions;
|
|
371
|
+
|
|
372
|
+
// Navigation intent (deep link) outranks in-memory selection and profile
|
|
373
|
+
// activeSessionId: once its target session is present, latch the selection
|
|
374
|
+
// onto it and mark the intent resolved.
|
|
375
|
+
const intent = sessions.navigationIntent || null;
|
|
376
|
+
const intentTargetId = intent && intent.status !== "failed" && sessions.byId?.[intent.sessionId]
|
|
377
|
+
? intent.sessionId
|
|
378
|
+
: null;
|
|
379
|
+
if (intentTargetId) {
|
|
380
|
+
sessions = {
|
|
381
|
+
...sessions,
|
|
382
|
+
activeSessionId: intentTargetId,
|
|
383
|
+
navigationIntent: intent.status === "resolved"
|
|
384
|
+
? intent
|
|
385
|
+
: { sessionId: intent.sessionId, status: "resolved" },
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Expand ancestors BEFORE resolving visibility: a selected session inside
|
|
390
|
+
// a collapsed group/parent is absent from the flat tree, so resolving
|
|
391
|
+
// first would drop the selection instead of revealing it.
|
|
392
|
+
const selectionCandidateId = sessions.activeSessionId || null;
|
|
393
|
+
if (selectionCandidateId && sessions.byId?.[selectionCandidateId]) {
|
|
394
|
+
const expandedCollapsedIds = reconcileCollapsedIdsForActiveSession(sessions.collapsedIds, sessions.byId, selectionCandidateId);
|
|
395
|
+
if (!setsEqual(expandedCollapsedIds, sessions.collapsedIds)) {
|
|
396
|
+
sessions = {
|
|
397
|
+
...sessions,
|
|
398
|
+
collapsedIds: expandedCollapsedIds,
|
|
399
|
+
flat: buildSessionTree(Object.values(sessions.byId || {}), expandedCollapsedIds, sessions.orderById, sessions.pinnedIds),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// A resolved intent target excluded by the current filters gets a
|
|
405
|
+
// transient exception so the linked session is shown anyway. Never
|
|
406
|
+
// persisted; cleared on manual navigation or filter change.
|
|
407
|
+
if (intentTargetId && sessions.filterExceptionId !== intentTargetId) {
|
|
408
|
+
const probeRows = selectSessionRows({ ...state, sessions });
|
|
409
|
+
if (!probeRows.some((row) => row.sessionId === intentTargetId)) {
|
|
410
|
+
sessions = {
|
|
411
|
+
...sessions,
|
|
412
|
+
filterExceptionId: intentTargetId,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
364
417
|
const nextState = {
|
|
365
418
|
...state,
|
|
366
|
-
sessions
|
|
419
|
+
sessions,
|
|
367
420
|
};
|
|
368
|
-
const nextActiveSessionId = resolveVisibleActiveSessionId(nextState, Object.values(
|
|
369
|
-
const nextCollapsedIds = reconcileCollapsedIdsForActiveSession(
|
|
370
|
-
const collapsedChanged = !setsEqual(nextCollapsedIds,
|
|
421
|
+
const nextActiveSessionId = resolveVisibleActiveSessionId(nextState, Object.values(sessions.byId || {}));
|
|
422
|
+
const nextCollapsedIds = reconcileCollapsedIdsForActiveSession(sessions.collapsedIds, sessions.byId, nextActiveSessionId);
|
|
423
|
+
const collapsedChanged = !setsEqual(nextCollapsedIds, sessions.collapsedIds);
|
|
371
424
|
const reconciledSessions = collapsedChanged
|
|
372
425
|
? {
|
|
373
|
-
...
|
|
426
|
+
...sessions,
|
|
374
427
|
collapsedIds: nextCollapsedIds,
|
|
375
|
-
flat: buildSessionTree(Object.values(
|
|
428
|
+
flat: buildSessionTree(Object.values(sessions.byId || {}), nextCollapsedIds, sessions.orderById, sessions.pinnedIds),
|
|
376
429
|
}
|
|
377
|
-
:
|
|
430
|
+
: sessions;
|
|
378
431
|
return {
|
|
379
432
|
sessions: {
|
|
380
433
|
...reconciledSessions,
|
|
@@ -515,7 +568,15 @@ export function appReducer(state, action) {
|
|
|
515
568
|
&& (settings.chatViewMode === "summary" || settings.chatViewMode === "transcript");
|
|
516
569
|
const hasPins = Object.prototype.hasOwnProperty.call(settings, "pinnedSessionIds");
|
|
517
570
|
const hasCollapsed = Object.prototype.hasOwnProperty.call(settings, "collapsedSessionIds");
|
|
518
|
-
|
|
571
|
+
// A pending/resolved deep-link intent outranks the profile's
|
|
572
|
+
// persisted activeSessionId — a remote profile poll must not
|
|
573
|
+
// yank selection away from the linked session.
|
|
574
|
+
const navigationIntentLatched = Boolean(
|
|
575
|
+
state.sessions.navigationIntent
|
|
576
|
+
&& state.sessions.navigationIntent.status !== "failed",
|
|
577
|
+
);
|
|
578
|
+
const hasActive = Object.prototype.hasOwnProperty.call(settings, "activeSessionId")
|
|
579
|
+
&& !navigationIntentLatched;
|
|
519
580
|
const hasLoadedSessions = Object.keys(state.sessions.byId || {}).length > 0;
|
|
520
581
|
const nextLayout = hasLayout
|
|
521
582
|
? {
|
|
@@ -693,6 +754,10 @@ export function appReducer(state, action) {
|
|
|
693
754
|
const nextSessions = {
|
|
694
755
|
...state.sessions,
|
|
695
756
|
filterQuery: typeof action.query === "string" ? action.query : "",
|
|
757
|
+
// A filter change is an explicit user action: it releases
|
|
758
|
+
// the deep-link latch and its transient filter exception.
|
|
759
|
+
navigationIntent: null,
|
|
760
|
+
filterExceptionId: null,
|
|
696
761
|
};
|
|
697
762
|
const selection = applyVisibleSessionSelection(state, nextSessions);
|
|
698
763
|
return {
|
|
@@ -708,6 +773,8 @@ export function appReducer(state, action) {
|
|
|
708
773
|
...state.sessions,
|
|
709
774
|
ownerFilterExplicit: true,
|
|
710
775
|
ownerFilter: normalizeSessionOwnerFilter(action.filter),
|
|
776
|
+
navigationIntent: null,
|
|
777
|
+
filterExceptionId: null,
|
|
711
778
|
};
|
|
712
779
|
const selection = applyVisibleSessionSelection(state, nextSessions);
|
|
713
780
|
return {
|
|
@@ -717,6 +784,39 @@ export function appReducer(state, action) {
|
|
|
717
784
|
};
|
|
718
785
|
}
|
|
719
786
|
|
|
787
|
+
case "sessions/navigationIntent": {
|
|
788
|
+
const sessionId = String(action.sessionId || "").trim();
|
|
789
|
+
if (!sessionId) return state;
|
|
790
|
+
const nextSessions = {
|
|
791
|
+
...state.sessions,
|
|
792
|
+
navigationIntent: { sessionId, status: "pending" },
|
|
793
|
+
filterExceptionId: null,
|
|
794
|
+
};
|
|
795
|
+
const selection = applyVisibleSessionSelection(state, nextSessions);
|
|
796
|
+
return {
|
|
797
|
+
...state,
|
|
798
|
+
sessions: selection.sessions,
|
|
799
|
+
ui: selection.ui,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
case "sessions/navigationIntentFailed": {
|
|
804
|
+
const intent = state.sessions.navigationIntent;
|
|
805
|
+
const sessionId = String(action.sessionId || "").trim();
|
|
806
|
+
if (!intent || (sessionId && intent.sessionId !== sessionId)) return state;
|
|
807
|
+
return {
|
|
808
|
+
...state,
|
|
809
|
+
sessions: {
|
|
810
|
+
...state.sessions,
|
|
811
|
+
navigationIntent: {
|
|
812
|
+
sessionId: intent.sessionId,
|
|
813
|
+
status: "failed",
|
|
814
|
+
errorKind: action.errorKind === "not_found" ? "not_found" : "network",
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
720
820
|
case "ui/modalSelection": {
|
|
721
821
|
const modal = state.ui.modal;
|
|
722
822
|
if (!modal) return state;
|
|
@@ -991,15 +1091,28 @@ export function appReducer(state, action) {
|
|
|
991
1091
|
if (previousActiveId && previousActiveId !== action.sessionId) {
|
|
992
1092
|
savedChatScroll[previousActiveId] = Number(state.ui.scroll?.chat) || 0;
|
|
993
1093
|
}
|
|
1094
|
+
// Manual navigation to a different session releases the deep-link
|
|
1095
|
+
// latch and its transient filter exception.
|
|
1096
|
+
const releasesNavigationLatch = Boolean(
|
|
1097
|
+
state.sessions.navigationIntent
|
|
1098
|
+
&& state.sessions.navigationIntent.sessionId !== action.sessionId,
|
|
1099
|
+
);
|
|
994
1100
|
return {
|
|
995
1101
|
...state,
|
|
996
1102
|
sessions: {
|
|
997
1103
|
...state.sessions,
|
|
998
1104
|
activeSessionId: action.sessionId,
|
|
1105
|
+
...(releasesNavigationLatch
|
|
1106
|
+
? { navigationIntent: null, filterExceptionId: null }
|
|
1107
|
+
: {}),
|
|
999
1108
|
},
|
|
1000
1109
|
ui: {
|
|
1001
1110
|
...state.ui,
|
|
1002
1111
|
chatScrollBySession: savedChatScroll,
|
|
1112
|
+
// Status notices are per-moment, usually per-session (send
|
|
1113
|
+
// refusals, access hints) — a stale one must not follow the
|
|
1114
|
+
// user to the next session.
|
|
1115
|
+
statusText: previousActiveId !== action.sessionId ? "" : state.ui.statusText,
|
|
1003
1116
|
scroll: {
|
|
1004
1117
|
...state.ui.scroll,
|
|
1005
1118
|
chat: Number(savedChatScroll[action.sessionId]) || 0,
|
package/ui/core/src/selectors.js
CHANGED
|
@@ -32,6 +32,8 @@ export const ACTIVE_HIGHLIGHT_BACKGROUND = "activeHighlightBackground";
|
|
|
32
32
|
export const ACTIVE_HIGHLIGHT_FOREGROUND = "activeHighlightForeground";
|
|
33
33
|
const USER_CHAT_COLOR = "userChat";
|
|
34
34
|
const USER_CHAT_LABEL_COLOR = "userChatLabel";
|
|
35
|
+
// Speaker label for a message from another person in a shared session.
|
|
36
|
+
const OTHER_PERSON_CHAT_LABEL_COLOR = "otherUserChatLabel";
|
|
35
37
|
|
|
36
38
|
const totalDescendantCountsCache = new WeakMap();
|
|
37
39
|
const visibleDescendantCountsCache = new WeakMap();
|
|
@@ -348,6 +350,10 @@ function matchesOwnerFilterDirect(session, ownerFilter = {}, auth = {}, ownerOve
|
|
|
348
350
|
if (ownerKey === SYSTEM_OWNER_KEY) return ownerFilter.includeSystem === true;
|
|
349
351
|
const currentUserKey = ownerKeyForOwner(auth?.principal);
|
|
350
352
|
if (ownerFilter.includeMe && currentUserKey && ownerKey === currentUserKey) return true;
|
|
353
|
+
// "Shared with me": any non-system session owned by someone else. The
|
|
354
|
+
// catalog is viewer-scoped server-side, so a foreign owner implies the
|
|
355
|
+
// session was shared with (or is otherwise readable by) the viewer.
|
|
356
|
+
if (ownerFilter.includeShared === true && ownerKey !== currentUserKey) return true;
|
|
351
357
|
return Array.isArray(ownerFilter.ownerKeys) && ownerFilter.ownerKeys.includes(ownerKey);
|
|
352
358
|
}
|
|
353
359
|
|
|
@@ -613,9 +619,13 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
|
|
|
613
619
|
const titleRuns = [...prefixRuns];
|
|
614
620
|
// Owner chip — only when the list actually surfaces more than one human
|
|
615
621
|
// owner (shouldDecorateSessionOwners). Otherwise it's noise on every row.
|
|
622
|
+
// Bracketed + bold so it reads as an owner badge, not part of the title.
|
|
623
|
+
// The current viewer's own sessions get a distinct color so "mine" pops.
|
|
616
624
|
if (shouldDecorateSessionOwners(state) && !session?.isSystem && !session?.isGroup) {
|
|
617
625
|
const initials = effectiveOwner ? ownerInitials(effectiveOwner) : "?";
|
|
618
|
-
|
|
626
|
+
const viewerKey = ownerKeyForOwner(state?.auth?.principal);
|
|
627
|
+
const isMine = viewerKey && ownerKeyForOwner(effectiveOwner) === viewerKey;
|
|
628
|
+
titleRuns.push({ text: `[${initials}] `, color: isMine ? "green" : "cyan", bold: true });
|
|
619
629
|
}
|
|
620
630
|
if (hasRealTitle) {
|
|
621
631
|
titleRuns.push({ text: rawTitle, color: mainColor, bold: Boolean(session?.isSystem || session?.isGroup) });
|
|
@@ -682,6 +692,15 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
|
|
|
682
692
|
const childCount = totalDescendantCounts?.[session?.sessionId];
|
|
683
693
|
if (childCount) { pushSep(); detailRuns.push({ text: `${childCount} child${childCount === 1 ? "" : "ren"}`, color: "gray" }); }
|
|
684
694
|
if (cronBadge) { pushSep(); detailRuns.push({ text: cronBadge.text, color: cronBadge.color }); }
|
|
695
|
+
// Shared-session marker (security model): private needs no marker;
|
|
696
|
+
// shared_read/shared_write surface here in the selected-row details.
|
|
697
|
+
if (session?.visibility === "shared_read" || session?.visibility === "shared_write") {
|
|
698
|
+
pushSep();
|
|
699
|
+
detailRuns.push({
|
|
700
|
+
text: session.visibility === "shared_write" ? "shared·write" : "shared·read",
|
|
701
|
+
color: "magenta",
|
|
702
|
+
});
|
|
703
|
+
}
|
|
685
704
|
}
|
|
686
705
|
|
|
687
706
|
// Flat runs for the TUI: title + (for titled rows) dim age + context.
|
|
@@ -713,6 +732,31 @@ function matchesSearchQuery(value, query) {
|
|
|
713
732
|
return String(value || "").toLowerCase().includes(normalizedQuery);
|
|
714
733
|
}
|
|
715
734
|
|
|
735
|
+
// The transient deep-link filter exception covers the linked session AND its
|
|
736
|
+
// ancestor chain (real parents plus the synthetic group:<id> row) so the
|
|
737
|
+
// linked row renders with its tree context instead of as an orphan.
|
|
738
|
+
function collectFilterExceptionIds(sessions) {
|
|
739
|
+
const exceptionId = sessions?.filterExceptionId || null;
|
|
740
|
+
const byId = sessions?.byId || {};
|
|
741
|
+
if (!exceptionId || !byId[exceptionId]) return null;
|
|
742
|
+
const ids = new Set([exceptionId]);
|
|
743
|
+
let current = byId[exceptionId];
|
|
744
|
+
let hops = 0;
|
|
745
|
+
while (current && hops < 16) {
|
|
746
|
+
let parentId = null;
|
|
747
|
+
if (current.parentSessionId && byId[current.parentSessionId]) {
|
|
748
|
+
parentId = current.parentSessionId;
|
|
749
|
+
} else if (!current.isGroup && current.groupId && byId[`group:${current.groupId}`]) {
|
|
750
|
+
parentId = `group:${current.groupId}`;
|
|
751
|
+
}
|
|
752
|
+
if (!parentId || ids.has(parentId)) break;
|
|
753
|
+
ids.add(parentId);
|
|
754
|
+
current = byId[parentId];
|
|
755
|
+
hops += 1;
|
|
756
|
+
}
|
|
757
|
+
return ids;
|
|
758
|
+
}
|
|
759
|
+
|
|
716
760
|
export function selectSessionRows(state) {
|
|
717
761
|
const totalDescendantCounts = getTotalDescendantCounts(state.sessions.byId);
|
|
718
762
|
const visibleDescendantCounts = getVisibleDescendantCounts(state.sessions.flat, state.sessions.byId);
|
|
@@ -721,6 +765,7 @@ export function selectSessionRows(state) {
|
|
|
721
765
|
const auth = state.auth || {};
|
|
722
766
|
const pinnedSet = new Set(Array.isArray(state.sessions?.pinnedIds) ? state.sessions.pinnedIds : []);
|
|
723
767
|
const selectedSet = new Set(Array.isArray(state.sessions?.selectedIds) ? state.sessions.selectedIds : []);
|
|
768
|
+
const filterExceptionIds = collectFilterExceptionIds(state.sessions);
|
|
724
769
|
|
|
725
770
|
return state.sessions.flat.map((entry) => {
|
|
726
771
|
const session = state.sessions.byId[entry.sessionId];
|
|
@@ -742,6 +787,7 @@ export function selectSessionRows(state) {
|
|
|
742
787
|
canPin: canPinSessionRow(session),
|
|
743
788
|
};
|
|
744
789
|
}).filter((row) => {
|
|
790
|
+
if (filterExceptionIds?.has(row.sessionId)) return true;
|
|
745
791
|
const session = state.sessions.byId[row.sessionId];
|
|
746
792
|
if (!matchesOwnerFilter(session, ownerFilter, auth, state.sessions.byId)) return false;
|
|
747
793
|
const effectiveOwner = effectiveSessionOwner(session, state.sessions.byId);
|
|
@@ -791,6 +837,39 @@ export function selectActiveSession(state) {
|
|
|
791
837
|
return sessionId ? state.sessions.byId[sessionId] || null : null;
|
|
792
838
|
}
|
|
793
839
|
|
|
840
|
+
export function selectNavigationIntent(state) {
|
|
841
|
+
return state.sessions?.navigationIntent || null;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Deep-link failure state for renderers. `not_found` deliberately covers both
|
|
846
|
+
* unknown and inaccessible sessions (no existence oracle); network/server
|
|
847
|
+
* failures keep a retryable flavor.
|
|
848
|
+
*/
|
|
849
|
+
export function selectNavigationError(state) {
|
|
850
|
+
const intent = state.sessions?.navigationIntent;
|
|
851
|
+
if (!intent || intent.status !== "failed") return null;
|
|
852
|
+
const errorKind = intent.errorKind === "not_found" ? "not_found" : "network";
|
|
853
|
+
return {
|
|
854
|
+
sessionId: intent.sessionId,
|
|
855
|
+
errorKind,
|
|
856
|
+
retryable: errorKind === "network",
|
|
857
|
+
message: errorKind === "not_found"
|
|
858
|
+
? "This session was not found or has not been shared with you."
|
|
859
|
+
: "Could not load the linked session. Check your connection and try again.",
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Status copy for the transient deep-link filter exception — non-null while a
|
|
865
|
+
* linked session is being shown despite the current filters excluding it.
|
|
866
|
+
*/
|
|
867
|
+
export function selectSessionFilterExceptionNotice(state) {
|
|
868
|
+
const exceptionId = state.sessions?.filterExceptionId || null;
|
|
869
|
+
if (!exceptionId || !state.sessions?.byId?.[exceptionId]) return null;
|
|
870
|
+
return "Showing linked session outside your current filters.";
|
|
871
|
+
}
|
|
872
|
+
|
|
794
873
|
/**
|
|
795
874
|
* True when the session row is actively running a turn that Stop can target.
|
|
796
875
|
* Applies to user AND system sessions; group/container rows are not sessions.
|
|
@@ -868,9 +947,11 @@ function buildPendingOutboxMessage(sessionId, item) {
|
|
|
868
947
|
createdAt: Number(item.createdAt) || Date.now(),
|
|
869
948
|
pendingPhase: item.phase === "cancelling"
|
|
870
949
|
? "cancelling"
|
|
871
|
-
: item.phase === "
|
|
872
|
-
? "
|
|
873
|
-
: "
|
|
950
|
+
: item.phase === "rejected"
|
|
951
|
+
? "rejected"
|
|
952
|
+
: item.phase === "queued"
|
|
953
|
+
? "queued"
|
|
954
|
+
: "pending",
|
|
874
955
|
};
|
|
875
956
|
}
|
|
876
957
|
|
|
@@ -1281,20 +1362,44 @@ function prefixRuns(text, color = "gray", options = {}) {
|
|
|
1281
1362
|
}];
|
|
1282
1363
|
}
|
|
1283
1364
|
|
|
1284
|
-
function buildChatMessagePrefix(message) {
|
|
1365
|
+
function buildChatMessagePrefix(message, options = {}) {
|
|
1285
1366
|
const time = formatTimestamp(message?.createdAt || message?.time);
|
|
1286
|
-
|
|
1287
|
-
|
|
1367
|
+
// A user.message is labeled from the CURRENT VIEWER's perspective: "You"
|
|
1368
|
+
// for the viewer's own messages, the sender's name for anyone else. When
|
|
1369
|
+
// the sender is the session owner, an "(owner)" tag is appended — so the
|
|
1370
|
+
// viewing owner sees "You (owner):" and everyone else sees "Alice (owner):".
|
|
1371
|
+
// A distinct color marks messages from other people.
|
|
1372
|
+
const sender = message?.sender;
|
|
1373
|
+
const senderKey = sender?.kind === "user" ? ownerKeyForOwner(sender) : null;
|
|
1374
|
+
const viewerKey = options?.viewerKey || null;
|
|
1375
|
+
const ownerKey = options?.ownerKey || null;
|
|
1376
|
+
// Only distinguish speakers / show the owner tag in a SHARED session — a
|
|
1377
|
+
// private solo session stays plain "You" / "Agent" (no noise).
|
|
1378
|
+
const sharedContext = options?.sharedContext === true;
|
|
1379
|
+
const isUser = message?.role === "user";
|
|
1380
|
+
const isSelf = isUser && senderKey && viewerKey && senderKey === viewerKey;
|
|
1381
|
+
const fromOtherPerson = sharedContext && isUser && senderKey && !isSelf;
|
|
1382
|
+
// Owner tag: the sender is the session owner (or, for a sender-less own
|
|
1383
|
+
// message, the viewer themselves is the owner).
|
|
1384
|
+
const senderIsOwner = sharedContext && isUser && ownerKey && (
|
|
1385
|
+
(senderKey && senderKey === ownerKey)
|
|
1386
|
+
|| (!senderKey && viewerKey && viewerKey === ownerKey)
|
|
1387
|
+
);
|
|
1388
|
+
const ownerSuffix = senderIsOwner ? " (owner)" : "";
|
|
1389
|
+
|
|
1390
|
+
const roleLabel = isUser
|
|
1391
|
+
? ((fromOtherPerson ? (sender.display || sender.subject || "User") : "You") + ownerSuffix)
|
|
1288
1392
|
: message?.role === "assistant"
|
|
1289
1393
|
? "Agent"
|
|
1290
1394
|
: message?.role === "system"
|
|
1291
1395
|
? "System"
|
|
1292
1396
|
: "PilotSwarm";
|
|
1293
1397
|
|
|
1294
|
-
//
|
|
1398
|
+
// Delivery glyph for user messages:
|
|
1295
1399
|
// ○ pending — client outbox, not yet durable
|
|
1296
1400
|
// ✓ queued — durably enqueued, waiting for orchestration to drain
|
|
1297
1401
|
// x cancelling — durable cancel requested, waiting for runtime outcome
|
|
1402
|
+
// x rejected — server refused the send (authz); auto-dropped shortly
|
|
1298
1403
|
// ✓✓ sent — persisted as user.message in CMS, LLM has it
|
|
1299
1404
|
let glyph = null;
|
|
1300
1405
|
let glyphColor = null;
|
|
@@ -1304,7 +1409,7 @@ function buildChatMessagePrefix(message) {
|
|
|
1304
1409
|
} else if (message?.pendingPhase === "queued") {
|
|
1305
1410
|
glyph = "✓";
|
|
1306
1411
|
glyphColor = "cyan";
|
|
1307
|
-
} else if (message?.pendingPhase === "cancelling") {
|
|
1412
|
+
} else if (message?.pendingPhase === "cancelling" || message?.pendingPhase === "rejected") {
|
|
1308
1413
|
glyph = "x";
|
|
1309
1414
|
glyphColor = "red";
|
|
1310
1415
|
} else if (message?.role === "user" && !message?.optimistic && !message?.pendingPhase) {
|
|
@@ -1324,8 +1429,10 @@ function buildChatMessagePrefix(message) {
|
|
|
1324
1429
|
? "yellow"
|
|
1325
1430
|
: message?.pendingPhase === "queued"
|
|
1326
1431
|
? "cyan"
|
|
1327
|
-
: message?.pendingPhase === "cancelling"
|
|
1432
|
+
: message?.pendingPhase === "cancelling" || message?.pendingPhase === "rejected"
|
|
1328
1433
|
? "red"
|
|
1434
|
+
: fromOtherPerson
|
|
1435
|
+
? OTHER_PERSON_CHAT_LABEL_COLOR
|
|
1329
1436
|
: message?.role === "user"
|
|
1330
1437
|
? USER_CHAT_LABEL_COLOR
|
|
1331
1438
|
: message?.role === "assistant"
|
|
@@ -1907,7 +2014,7 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
|
|
|
1907
2014
|
? "red"
|
|
1908
2015
|
: message?.role === "user" ? USER_CHAT_COLOR : null,
|
|
1909
2016
|
);
|
|
1910
|
-
const prefix = options.skipPrefix ? [] : buildChatMessagePrefix(message);
|
|
2017
|
+
const prefix = options.skipPrefix ? [] : buildChatMessagePrefix(message, options);
|
|
1911
2018
|
|
|
1912
2019
|
if (tintedMarkdownLines.length === 0) {
|
|
1913
2020
|
return prefix.length > 0 ? [prefix] : [];
|
|
@@ -2120,7 +2227,26 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
|
|
|
2120
2227
|
return [{ text: "No messages yet.", color: "gray" }];
|
|
2121
2228
|
}
|
|
2122
2229
|
|
|
2123
|
-
|
|
2230
|
+
// The current viewer's identity key — a user.message whose sender differs
|
|
2231
|
+
// is labeled with the sender's name (others) vs "You" (the viewer). The
|
|
2232
|
+
// session owner's messages additionally carry an "(owner)" tag.
|
|
2233
|
+
const viewerKey = ownerKeyForOwner(state?.auth?.principal);
|
|
2234
|
+
const activeSessionId = state?.sessions?.activeSessionId;
|
|
2235
|
+
const activeSession = activeSessionId ? state?.sessions?.byId?.[activeSessionId] : null;
|
|
2236
|
+
const ownerKey = ownerKeyForOwner(activeSession?.owner);
|
|
2237
|
+
// A shared context = the session is shared deployment-wide, or the viewer
|
|
2238
|
+
// is not its owner (someone shared it with them). Only then does the
|
|
2239
|
+
// transcript name speakers and tag the owner.
|
|
2240
|
+
const sharedContext = Boolean(activeSession && (
|
|
2241
|
+
(activeSession.visibility && activeSession.visibility !== "private")
|
|
2242
|
+
|| (ownerKey && viewerKey && ownerKey !== viewerKey)
|
|
2243
|
+
));
|
|
2244
|
+
const buildOptions = {
|
|
2245
|
+
...(options?.tableMode ? { tableMode: options.tableMode } : {}),
|
|
2246
|
+
...(viewerKey ? { viewerKey } : {}),
|
|
2247
|
+
...(ownerKey ? { ownerKey } : {}),
|
|
2248
|
+
...(sharedContext ? { sharedContext: true } : {}),
|
|
2249
|
+
};
|
|
2124
2250
|
const lines = [];
|
|
2125
2251
|
for (const [index, message] of messages.entries()) {
|
|
2126
2252
|
const messageLines = buildChatMessageLines(message, maxWidth, buildOptions);
|
|
@@ -2145,10 +2271,12 @@ export function selectOutboxOverlayLines(state, maxWidth = 80, options = {}) {
|
|
|
2145
2271
|
const queuedCount = messages.filter((message) => message.pendingPhase === "queued").length;
|
|
2146
2272
|
const pendingCount = messages.filter((message) => message.pendingPhase === "pending").length;
|
|
2147
2273
|
const cancellingCount = messages.filter((message) => message.pendingPhase === "cancelling").length;
|
|
2274
|
+
const rejectedCount = messages.filter((message) => message.pendingPhase === "rejected").length;
|
|
2148
2275
|
const parts = [];
|
|
2149
2276
|
if (pendingCount > 0) parts.push(`${pendingCount} pending`);
|
|
2150
2277
|
if (queuedCount > 0) parts.push(`${queuedCount} queued`);
|
|
2151
2278
|
if (cancellingCount > 0) parts.push(`${cancellingCount} cancelling`);
|
|
2279
|
+
if (rejectedCount > 0) parts.push(`${rejectedCount} rejected`);
|
|
2152
2280
|
const label = parts.length > 0 ? parts.join(" · ") : "queued prompts";
|
|
2153
2281
|
const labelText = ` queued prompts: ${label} `;
|
|
2154
2282
|
const rightRule = Math.max(1, safeWidth - labelText.length);
|
|
@@ -2969,6 +3097,12 @@ export function selectStatusBar(state) {
|
|
|
2969
3097
|
right: "type title · left/right move · enter save · esc cancel",
|
|
2970
3098
|
};
|
|
2971
3099
|
}
|
|
3100
|
+
if (state.ui.modal?.type === "shareSession") {
|
|
3101
|
+
return {
|
|
3102
|
+
left: "Share the selected session",
|
|
3103
|
+
right: "name [r|w] grants · -name revokes · enter apply · esc close",
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
2972
3106
|
if (state.ui.modal?.type === "artifactPicker") {
|
|
2973
3107
|
return {
|
|
2974
3108
|
left: "Select a linked artifact or URL",
|
|
@@ -3040,7 +3174,7 @@ export function selectStatusBar(state) {
|
|
|
3040
3174
|
const chatViewMode = state.ui?.chatViewMode === "summary" ? "summary" : "transcript";
|
|
3041
3175
|
const chatViewHint = chatViewMode === "summary" ? "s transcript" : "s summary";
|
|
3042
3176
|
const hints = {
|
|
3043
|
-
[FOCUS_REGIONS.SESSIONS]: `up/down switch · ctrl-u/ctrl-d page · ctrl-g move group · f filter · P pin · V select · d done · D delete · r refresh · t title ·
|
|
3177
|
+
[FOCUS_REGIONS.SESSIONS]: `up/down switch · ctrl-u/ctrl-d page · ctrl-g move group · f filter · P pin · V select · d done · D delete · r refresh · t title · v visibility · S share · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane · p prompt`,
|
|
3044
3178
|
[FOCUS_REGIONS.CHAT]: `${chatViewHint} · j/k scroll · ctrl-u/ctrl-d page · e older history · g/G top/bottom · d done · ${fullscreenHint} · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane · p prompt`,
|
|
3045
3179
|
[FOCUS_REGIONS.INSPECTOR]: state.ui.inspectorTab === "logs"
|
|
3046
3180
|
? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · t tail · f filter · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane`
|
|
@@ -4324,6 +4458,59 @@ export function selectRenameSessionModal(state, maxWidth = 76) {
|
|
|
4324
4458
|
};
|
|
4325
4459
|
}
|
|
4326
4460
|
|
|
4461
|
+
export function selectShareSessionModal(state, maxWidth = 76) {
|
|
4462
|
+
const modal = state.ui.modal;
|
|
4463
|
+
if (!modal || modal.type !== "shareSession") return null;
|
|
4464
|
+
|
|
4465
|
+
const value = String(modal.value || "");
|
|
4466
|
+
const shares = Array.isArray(modal.shares) ? modal.shares : [];
|
|
4467
|
+
const detailsLines = [
|
|
4468
|
+
[{
|
|
4469
|
+
text: "General: ",
|
|
4470
|
+
color: "gray",
|
|
4471
|
+
}, {
|
|
4472
|
+
text: String(modal.visibility || "private"),
|
|
4473
|
+
color: "white",
|
|
4474
|
+
bold: true,
|
|
4475
|
+
}],
|
|
4476
|
+
...(shares.length === 0
|
|
4477
|
+
? [[{ text: "No individual grants.", color: "gray" }]]
|
|
4478
|
+
: shares.map((row) => [{
|
|
4479
|
+
text: `${row.displayName || row.subject} `,
|
|
4480
|
+
color: "white",
|
|
4481
|
+
}, {
|
|
4482
|
+
text: `can ${row.access}`,
|
|
4483
|
+
color: "gray",
|
|
4484
|
+
}])),
|
|
4485
|
+
];
|
|
4486
|
+
|
|
4487
|
+
return {
|
|
4488
|
+
title: modal.title || "Share Session",
|
|
4489
|
+
value,
|
|
4490
|
+
cursorIndex: Math.max(0, Math.min(Number(modal.cursorIndex) || 0, value.length)),
|
|
4491
|
+
placeholder: "Name, email, or id",
|
|
4492
|
+
helpTitle: "Share Rules",
|
|
4493
|
+
helpLines: [
|
|
4494
|
+
[{
|
|
4495
|
+
text: "name-or-email [r|w] grants · -name revokes · Enter apply · Esc close",
|
|
4496
|
+
color: "gray",
|
|
4497
|
+
}],
|
|
4498
|
+
[{
|
|
4499
|
+
text: "Grantee needn't have signed in — an email grant binds at their first sign-in.",
|
|
4500
|
+
color: "gray",
|
|
4501
|
+
}],
|
|
4502
|
+
],
|
|
4503
|
+
detailsLines,
|
|
4504
|
+
idealWidth: Math.min(
|
|
4505
|
+
Math.max(
|
|
4506
|
+
56,
|
|
4507
|
+
...detailsLines.map((line) => flattenRunsLength(line) + 6),
|
|
4508
|
+
),
|
|
4509
|
+
maxWidth,
|
|
4510
|
+
),
|
|
4511
|
+
};
|
|
4512
|
+
}
|
|
4513
|
+
|
|
4327
4514
|
export function selectSessionGroupNameModal(state, maxWidth = 76) {
|
|
4328
4515
|
const modal = state.ui.modal;
|
|
4329
4516
|
if (!modal || modal.type !== "sessionGroupName") return null;
|
|
@@ -4517,6 +4704,7 @@ function isOwnerFilterItemSelected(item, ownerFilter = {}, auth = {}) {
|
|
|
4517
4704
|
if (item.kind === "system") return ownerFilter?.includeSystem === true;
|
|
4518
4705
|
if (item.kind === "unowned") return ownerFilter?.includeUnowned === true;
|
|
4519
4706
|
if (item.kind === "me") return ownerFilter?.includeMe === true && Boolean(ownerKeyForOwner(auth?.principal));
|
|
4707
|
+
if (item.kind === "shared") return ownerFilter?.includeShared === true;
|
|
4520
4708
|
if (item.kind === "owner") return Array.isArray(ownerFilter?.ownerKeys) && ownerFilter.ownerKeys.includes(item.ownerKey);
|
|
4521
4709
|
return false;
|
|
4522
4710
|
}
|
|
@@ -5825,7 +6013,7 @@ const KEYBINDING_HELP = [
|
|
|
5825
6013
|
["p", "focus the prompt"],
|
|
5826
6014
|
["[ ]", "shrink / grow the focused pane"],
|
|
5827
6015
|
["{ }", "grow left / right column"],
|
|
5828
|
-
["v", "fullscreen the focused pane"],
|
|
6016
|
+
["v", "fullscreen the focused pane (sessions pane: cycle visibility)"],
|
|
5829
6017
|
["n / r", "new session / refresh"],
|
|
5830
6018
|
["a", "linked items — artifacts to download, links to open"],
|
|
5831
6019
|
["m", "cycle inspector tab"],
|
|
@@ -5843,6 +6031,8 @@ const KEYBINDING_HELP = [
|
|
|
5843
6031
|
["+ / -", "expand / collapse subtree"],
|
|
5844
6032
|
["t", "rename"],
|
|
5845
6033
|
["P", "pin / unpin"],
|
|
6034
|
+
["v", "cycle visibility (private / shared read / shared write)"],
|
|
6035
|
+
["S", "share — grant / revoke individual access"],
|
|
5846
6036
|
["V / space", "select mode / toggle selection"],
|
|
5847
6037
|
["f", "filter"],
|
|
5848
6038
|
] },
|
package/ui/core/src/state.js
CHANGED
|
@@ -13,6 +13,7 @@ export function normalizeSessionOwnerFilter(filter) {
|
|
|
13
13
|
includeSystem: filter?.includeSystem === true,
|
|
14
14
|
includeUnowned: filter?.includeUnowned === true,
|
|
15
15
|
includeMe: filter?.includeMe === true,
|
|
16
|
+
includeShared: filter?.includeShared === true,
|
|
16
17
|
ownerKeys,
|
|
17
18
|
};
|
|
18
19
|
}
|
|
@@ -180,6 +181,8 @@ export function createInitialState({ mode = "local", branding = null, themeId =
|
|
|
180
181
|
filterQuery: "",
|
|
181
182
|
ownerFilterExplicit: hasStoredSessionOwnerFilter,
|
|
182
183
|
ownerFilter: normalizeSessionOwnerFilter(sessionOwnerFilter),
|
|
184
|
+
navigationIntent: null,
|
|
185
|
+
filterExceptionId: null,
|
|
183
186
|
},
|
|
184
187
|
history: {
|
|
185
188
|
bySessionId: new Map(),
|
|
@@ -57,6 +57,10 @@ export function createTheme({ id, label, description, page, terminal, tui = {} }
|
|
|
57
57
|
cyan: terminal?.cyan || "#55ffff",
|
|
58
58
|
userChat: "#ffd866",
|
|
59
59
|
userChatLabel: "#ffec99",
|
|
60
|
+
// Speaker label for a message from ANOTHER person in a shared session
|
|
61
|
+
// (distinct from the viewer's gold "You", the agent's green, and
|
|
62
|
+
// System's yellow). Every theme inherits this; a theme may override it.
|
|
63
|
+
otherUserChatLabel: terminal?.brightMagenta || terminal?.magenta || "#c9a3ff",
|
|
60
64
|
activeHighlightBackground: terminal?.blue || "#5555ff",
|
|
61
65
|
activeHighlightForeground: terminal?.background || "#000000",
|
|
62
66
|
selectionBackground: terminal?.cursor || terminal?.blue || "#5555ff",
|