pilotswarm 0.5.2 → 0.5.4

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.
@@ -10,7 +10,6 @@ import {
10
10
  decorateArtifactLinksForChat,
11
11
  extractArtifactLinks,
12
12
  extractHttpLinks,
13
- formatDisplayDateTime,
14
13
  formatHumanDurationSeconds,
15
14
  formatTimestamp,
16
15
  padRunsToDisplayWidth,
@@ -21,9 +20,9 @@ import {
21
20
  wrapRunsToDisplayWidth,
22
21
  } from "./formatting.js";
23
22
  import {
23
+ computeContextPercent,
24
24
  getContextCompactionBadge,
25
25
  getContextHeaderBadge,
26
- getContextListBadge,
27
26
  } from "./context-usage.js";
28
27
  import { canonicalSystemTitle } from "./system-titles.js";
29
28
  import { normalizeArtifactEntries } from "./state.js";
@@ -217,19 +216,22 @@ function ownerInitials(owner) {
217
216
  }
218
217
 
219
218
  function shouldDecorateSessionOwners(state) {
220
- if (state.auth?.principal) return true;
219
+ // Decorate owners only when the list actually surfaces more than one
220
+ // distinct human owner — otherwise the owner chip is pure noise on every
221
+ // row (you are always "you"). A narrowed owner filter is an explicit
222
+ // multi-user context, so honor that too.
221
223
  if (state.sessions?.ownerFilter && state.sessions.ownerFilter.all !== true) return true;
222
- if (Object.values(state.sessions?.byId || {}).some((session) => session?.owner)) return true;
224
+ const owners = new Set();
225
+ for (const session of Object.values(state.sessions?.byId || {})) {
226
+ if (!session || session.isSystem || session.isGroup) continue;
227
+ const key = ownerKeyForOwner(session.owner);
228
+ if (!key) continue;
229
+ owners.add(key);
230
+ if (owners.size > 1) return true;
231
+ }
223
232
  return false;
224
233
  }
225
234
 
226
- function buildSessionListTitle(session, brandingTitle, decorateOwners = false) {
227
- const title = buildSessionTitle(session, brandingTitle);
228
- if (!decorateOwners || session?.isSystem) return title;
229
- const prefix = session?.owner ? ownerInitials(session.owner) : "?";
230
- return `(${prefix}) ${title}`;
231
- }
232
-
233
235
  function groupMemberSessions(group, byId = {}) {
234
236
  if (!group?.isGroup || !group?.groupId) return [];
235
237
  return Object.values(byId || {}).filter((session) => (
@@ -575,68 +577,118 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
575
577
 
576
578
  const mainColor = session?.isGroup ? "cyan" : session?.isSystem ? "yellow" : sessionStatusColor(session, mode);
577
579
  const effectiveOwner = effectiveSessionOwner(session, state.sessions?.byId || {});
578
- const titleText = buildSessionListTitle(
579
- effectiveOwner !== (session?.owner ?? null) ? { ...session, owner: effectiveOwner } : session,
580
- state.branding?.title || "PilotSwarm",
581
- shouldDecorateSessionOwners(state),
582
- );
583
- // Show the session's last-updated time at the end of the row. For groups,
584
- // keep the member-count badge but append the last-updated timestamp so the
585
- // row reflects the same value the list is sorted by. `formatDisplayDateTime`
586
- // uses the system locale/timezone, so the timestamp renders in the
587
- // browser/client local zone.
580
+
581
+ const shortId = shortSessionId(session?.sessionId);
582
+
583
+ // Row age — last-updated (the value the list is sorted by). Coarse buckets
584
+ // so it doesn't tick every second: <1min · Nmin · NhMMm · NdHHh · Nw.
588
585
  const rowTimestampMs = session?.updatedAt
589
586
  || session?.summaryUpdatedAt
590
587
  || session?.latestSummaryUpdatedAt
591
588
  || session?.createdAt
592
589
  || 0;
593
- const formattedRowTimestamp = rowTimestampMs ? formatDisplayDateTime(rowTimestampMs) : "";
594
- const titleRuns = [
595
- ...prefixRuns,
596
- {
597
- text: titleText,
598
- color: mainColor,
599
- bold: Boolean(session?.isSystem),
600
- },
601
- ];
590
+ const relTime = rowTimestampMs ? formatSessionAge(rowTimestampMs) : "";
591
+ const modelLabel = shortModelReasoningLabel(session?.model, session?.reasoningEffort);
592
+
593
+ // A regular session with no human title otherwise renders as a bare
594
+ // "(guid)" — an empty, ugly line. For those, pull the meta (id · age ·
595
+ // model) UP onto the main line so it carries information. Titled rows keep
596
+ // the title on the line and expand id·age·model·ctx only when selected.
597
+ const rawTitle = session?.isSystem
598
+ ? canonicalSystemTitle(session, state.branding?.title || "PilotSwarm")
599
+ : buildSessionDisplayTitle(session);
600
+ const hasRealTitle = Boolean(session?.isSystem || session?.isGroup || (rawTitle && rawTitle.trim()));
601
+
602
+ const titleRuns = [...prefixRuns];
603
+ // Owner chip — only when the list actually surfaces more than one human
604
+ // owner (shouldDecorateSessionOwners). Otherwise it's noise on every row.
605
+ if (shouldDecorateSessionOwners(state) && !session?.isSystem && !session?.isGroup) {
606
+ const initials = effectiveOwner ? ownerInitials(effectiveOwner) : "?";
607
+ titleRuns.push({ text: `${initials} · `, color: "cyan" });
608
+ }
609
+ if (hasRealTitle) {
610
+ titleRuns.push({ text: rawTitle, color: mainColor, bold: Boolean(session?.isSystem || session?.isGroup) });
611
+ } else {
612
+ // Untitled → id · age · model on one line (no wasted title line).
613
+ titleRuns.push({ text: shortId, color: mainColor });
614
+ if (relTime) { titleRuns.push({ text: " · ", color: "gray" }); titleRuns.push({ text: relTime, color: "gray" }); }
615
+ if (modelLabel) { titleRuns.push({ text: " · ", color: "gray" }); titleRuns.push({ text: modelLabel, color: "green" }); }
616
+ }
617
+
602
618
  const collapseBadge = getCollapseBadge(session?.sessionId, entry, totalDescendantCounts, visibleDescendantCounts);
603
619
  if (collapseBadge) {
604
620
  titleRuns.push({ text: ` ${collapseBadge.text}`, color: collapseBadge.color, bold: collapseBadge.bold });
605
621
  }
622
+ // Scheduled sessions keep a compact clock glyph on the title; the full
623
+ // cron cadence rides in the detail line.
624
+ const cronBadge = getCronBadge(session);
625
+ if (cronBadge) {
626
+ titleRuns.push({ text: " ⏱", color: "magenta" });
627
+ }
606
628
 
629
+ // Right-column context %: on every row that has usage. Compaction in
630
+ // flight takes precedence; else green normally, amber ≥70, red ≥85.
631
+ const ctxRuns = [];
632
+ const compactionState = session?.contextUsage?.compaction?.state;
633
+ const ctxPercent = computeContextPercent(session?.contextUsage);
634
+ if (session?.isGroup) {
635
+ if (session?.memberCount != null) ctxRuns.push({ text: `${session.memberCount}`, color: "gray" });
636
+ } else if (compactionState === "running") {
637
+ ctxRuns.push({ text: "⇊", color: "magenta" });
638
+ } else if (compactionState === "failed") {
639
+ ctxRuns.push({ text: "!", color: "red" });
640
+ } else if (ctxPercent != null) {
641
+ ctxRuns.push({ text: `${ctxPercent}%`, color: ctxPercent >= 85 ? "red" : ctxPercent >= 70 ? "yellow" : "green" });
642
+ } else {
643
+ ctxRuns.push({ text: "—", color: "gray" });
644
+ }
645
+
646
+ // Kept for backward-compat consumers; groups carry a member/time meta.
607
647
  const metaRuns = [];
608
648
  if (session?.isGroup && session?.memberCount != null) {
609
649
  metaRuns.push({ text: `${session.memberCount} member${session.memberCount === 1 ? "" : "s"}`, color: "gray" });
650
+ if (relTime) { metaRuns.push({ text: " · ", color: "gray" }); metaRuns.push({ text: relTime, color: "gray" }); }
610
651
  }
611
- if (formattedRowTimestamp) {
612
- if (metaRuns.length > 0) metaRuns.push({ text: " · ", color: "gray" });
613
- metaRuns.push({ text: formattedRowTimestamp, color: "gray" });
614
- }
615
-
616
- const badgeRuns = [];
617
652
 
618
- for (const badge of [
619
- getCronBadge(session),
620
- getContextListBadge(session?.contextUsage),
621
- ]) {
622
- if (!badge) continue;
623
- if (badgeRuns.length > 0) badgeRuns.push({ text: " ", color: "gray" });
624
- badgeRuns.push({ text: badge.text, color: badge.color, bold: badge.bold });
653
+ // Detail expanded under the SELECTED row. Titled rows repeat
654
+ // id · age · model here; untitled rows already carry those on the main
655
+ // line, so their detail starts at the ctx breakdown to avoid repetition.
656
+ const detailRuns = [];
657
+ const pushSep = () => { if (detailRuns.length) detailRuns.push({ text: " · ", color: "gray" }); };
658
+ if (session?.isGroup) {
659
+ detailRuns.push(...metaRuns);
660
+ } else {
661
+ if (hasRealTitle) {
662
+ detailRuns.push({ text: shortId, color: "cyan" });
663
+ if (relTime) { pushSep(); detailRuns.push({ text: relTime, color: "gray" }); }
664
+ if (modelLabel) { pushSep(); detailRuns.push({ text: modelLabel, color: "green" }); }
665
+ }
666
+ const cu = session?.contextUsage;
667
+ if (cu && Number.isFinite(cu.currentTokens) && Number.isFinite(cu.tokenLimit) && cu.tokenLimit > 0) {
668
+ pushSep();
669
+ detailRuns.push({ text: `ctx ${formatCompactNumber(cu.currentTokens)}/${formatCompactNumber(cu.tokenLimit)}`, color: "gray" });
670
+ }
671
+ const childCount = totalDescendantCounts?.[session?.sessionId];
672
+ if (childCount) { pushSep(); detailRuns.push({ text: `${childCount} child${childCount === 1 ? "" : "ren"}`, color: "gray" }); }
673
+ if (cronBadge) { pushSep(); detailRuns.push({ text: cronBadge.text, color: cronBadge.color }); }
625
674
  }
626
675
 
627
- const selectedMetaRuns = buildSelectedSessionMetaRuns(session, mode);
676
+ // Flat runs for the TUI: title + (for titled rows) dim age + context.
628
677
  const runs = [
629
678
  ...titleRuns,
630
- ...(metaRuns.length > 0 ? [{ text: " ", color: "gray" }, ...metaRuns] : []),
631
- ...(badgeRuns.length > 0 ? [{ text: " ", color: "gray" }, ...badgeRuns] : []),
679
+ ...(hasRealTitle && relTime && !session?.isGroup ? [{ text: " ", color: "gray" }, { text: relTime, color: "gray" }] : []),
680
+ ...(ctxRuns.length && !session?.isGroup ? [{ text: " · ", color: "gray" }, ...ctxRuns] : []),
681
+ ...(session?.isGroup && metaRuns.length ? [{ text: " ", color: "gray" }, ...metaRuns] : []),
632
682
  ];
633
683
 
634
684
  return {
635
685
  runs,
636
686
  titleRuns,
687
+ ctxRuns,
637
688
  metaRuns,
638
- badgeRuns,
639
- selectedMetaRuns,
689
+ badgeRuns: [],
690
+ selectedMetaRuns: detailRuns,
691
+ detailRuns,
640
692
  };
641
693
  }
642
694
 
@@ -1245,9 +1297,16 @@ function buildChatMessagePrefix(message) {
1245
1297
  glyph = "x";
1246
1298
  glyphColor = "red";
1247
1299
  } else if (message?.role === "user" && !message?.optimistic && !message?.pendingPhase) {
1248
- // Real durable user.message in transcript — show the "sent" double-check.
1249
- glyph = "✓✓";
1250
- glyphColor = "green";
1300
+ if (message?.stopped) {
1301
+ // Delivered, but its turn was user-stopped mid-flight — the model
1302
+ // may not have acted on it. Amber prohibition ("no parking") sign.
1303
+ glyph = "⊘";
1304
+ glyphColor = "yellow";
1305
+ } else {
1306
+ // Real durable user.message in transcript — show the "sent" double-check.
1307
+ glyph = "✓✓";
1308
+ glyphColor = "green";
1309
+ }
1251
1310
  }
1252
1311
 
1253
1312
  const roleColor = message?.pendingPhase === "pending"
@@ -4072,6 +4131,55 @@ export function selectReasoningEffortPickerModal(state, maxWidth = 64) {
4072
4131
  };
4073
4132
  }
4074
4133
 
4134
+ export function selectContextTierPickerModal(state, maxWidth = 64) {
4135
+ const modal = state.ui.modal;
4136
+ if (!modal || modal.type !== "contextTierPicker") return null;
4137
+
4138
+ const items = Array.isArray(modal.items) ? modal.items : [];
4139
+ const selectedIndex = Math.max(0, Number(modal.selectedIndex) || 0);
4140
+ const contentWidth = Math.max(24, maxWidth - 4);
4141
+
4142
+ const rows = items.map((item, index) => {
4143
+ const isSelected = index === selectedIndex;
4144
+ const labelRuns = fitRuns([
4145
+ { text: "· ", color: "gray" },
4146
+ { text: String(item?.label || item?.id || "tier"), color: "white", bold: true },
4147
+ ...(item?.isDefault ? [{ text: " ← model default", color: "gray" }] : []),
4148
+ ], contentWidth);
4149
+ return isSelected
4150
+ ? buildActiveHighlightLine(labelRuns.map((run) => run.text).join("").padEnd(contentWidth, " "))
4151
+ : labelRuns;
4152
+ });
4153
+
4154
+ const selectedItem = items[selectedIndex] || null;
4155
+ const modelItem = modal.modelItem || null;
4156
+ const detailsLines = [
4157
+ [{ text: modelItem?.modelName || modelItem?.qualifiedName || "Selected model", color: "white", bold: true }],
4158
+ [{ text: `${modelItem?.providerId || "provider"} (${modelItem?.providerType || "provider"})`, color: "gray" }],
4159
+ [{ text: "", color: "gray" }],
4160
+ [{ text: selectedItem?.id ? `Using context window: ${selectedItem.id}` : "Choose a context window.", color: "white" }],
4161
+ [{ text: "Long context costs more per token.", color: "gray" }],
4162
+ ];
4163
+
4164
+ return {
4165
+ title: modal.title || "Select context window",
4166
+ rows,
4167
+ selectedRowIndex: selectedIndex,
4168
+ detailsTitle: "Context Window",
4169
+ detailsLines,
4170
+ idealWidth: Math.min(
4171
+ Math.max(
4172
+ 46,
4173
+ rows.reduce((max, row) => {
4174
+ if (Array.isArray(row)) return Math.max(max, flattenRunsLength(row));
4175
+ return Math.max(max, String(row?.text || "").length);
4176
+ }, 0) + 4,
4177
+ ),
4178
+ maxWidth,
4179
+ ),
4180
+ };
4181
+ }
4182
+
4075
4183
  export function selectSessionGroupPickerModal(state, maxWidth = 72) {
4076
4184
  const modal = state.ui.modal;
4077
4185
  if (!modal || modal.type !== "sessionGroupPicker") return null;
@@ -4638,6 +4746,23 @@ function formatRelativeTime(ts) {
4638
4746
  return `${Math.floor(ms / 86_400_000)}d ago`;
4639
4747
  }
4640
4748
 
4749
+ // Coarse session-age buckets for the list. Seconds tick too fast and just
4750
+ // distract, so the smallest bucket is "<1min"; then whole minutes to an hour,
4751
+ // then hours+minutes, then days+hours, then weeks. No "ago" suffix — the age
4752
+ // column context makes it clear.
4753
+ function formatSessionAge(ts) {
4754
+ if (!ts) return "—";
4755
+ const ms = Date.now() - ts;
4756
+ if (ms < 60_000) return "<1min";
4757
+ const totalMin = Math.floor(ms / 60_000);
4758
+ if (totalMin < 60) return `${totalMin}min`;
4759
+ const totalHours = Math.floor(totalMin / 60);
4760
+ if (totalHours < 24) return `${totalHours}h${String(totalMin % 60).padStart(2, "0")}m`;
4761
+ const days = Math.floor(totalHours / 24);
4762
+ if (days < 14) return `${days}d${String(totalHours % 24).padStart(2, "0")}h`;
4763
+ return `${Math.floor(days / 7)}w`;
4764
+ }
4765
+
4641
4766
  function formatLocalTimestamp(ts) {
4642
4767
  if (!ts) return "—";
4643
4768
  const date = ts instanceof Date ? ts : new Date(ts);
@@ -10,6 +10,7 @@ import {
10
10
  selectChatPaneChrome,
11
11
  selectLiveActivityLines,
12
12
  selectChatLines,
13
+ selectOutboxOverlayLines,
13
14
  selectActivityPane,
14
15
  selectArtifactUploadModal,
15
16
  selectArtifactPickerModal,
@@ -21,6 +22,7 @@ import {
21
22
  selectLogFilterModal,
22
23
  selectModelPickerModal,
23
24
  selectReasoningEffortPickerModal,
25
+ selectContextTierPickerModal,
24
26
  selectRenameSessionModal,
25
27
  selectSessionAgentPickerModal,
26
28
  selectSessionGroupNameModal,
@@ -317,6 +319,7 @@ const ChatPane = React.memo(function ChatPane({ controller, width, height, frame
317
319
  activeSessionId,
318
320
  activeSession: activeSessionId ? state.sessions.byId[activeSessionId] || null : null,
319
321
  activeHistory: activeSessionId ? state.history.bySessionId.get(activeSessionId) || null : null,
322
+ activeOutbox: activeSessionId ? state.outbox?.bySessionId?.[activeSessionId] || null : null,
320
323
  branding: state.branding,
321
324
  connectionError: state.connection.error,
322
325
  connectionMode: state.connection.mode,
@@ -347,11 +350,21 @@ const ChatPane = React.memo(function ChatPane({ controller, width, height, frame
347
350
  history: {
348
351
  bySessionId: historyMap,
349
352
  },
353
+ // The queued-prompt overlay reads state.outbox; without it here the
354
+ // TUI ChatPane's synthetic selectorState always saw an empty outbox
355
+ // and rendered no "queued prompts: N" even though the item was
356
+ // durably queued in the real store.
357
+ outbox: {
358
+ bySessionId: chatView.activeSessionId && chatView.activeOutbox
359
+ ? { [chatView.activeSessionId]: chatView.activeOutbox }
360
+ : {},
361
+ },
350
362
  };
351
363
  }, [
352
364
  chatView.activeHistory,
353
365
  chatView.activeSessionId,
354
366
  chatView.activeSession,
367
+ chatView.activeOutbox,
355
368
  chatView.branding,
356
369
  chatView.chatViewMode,
357
370
  chatView.connectionError,
@@ -383,13 +396,19 @@ const ChatPane = React.memo(function ChatPane({ controller, width, height, frame
383
396
  () => (startupError ? [] : selectLiveActivityLines(selectorState, { spinnerFrame, maxWidth: contentWidth })),
384
397
  [selectorState, startupError, spinnerFrame, contentWidth],
385
398
  );
386
- // Concat the isolated live-activity block so the spinner tick only recomputes
387
- // the small block, not the whole transcript.
388
- const chatLines = React.useMemo(
389
- () => (liveActivityLines.length > 0
390
- ? [...elements, [{ text: "", color: null }], ...liveActivityLines]
391
- : elements),
392
- [elements, liveActivityLines],
399
+ const outboxLines = React.useMemo(
400
+ () => (startupError ? [] : selectOutboxOverlayLines(selectorState, contentWidth)),
401
+ [selectorState, startupError, contentWidth],
402
+ );
403
+ // The queued-prompt overlay and the live "Working" strip are pinned in the
404
+ // bottom-sticky region (outbox above, strip at the very bottom), so they
405
+ // stay put at the foot of the pane instead of scrolling inline with the
406
+ // transcript — matching the portal. The transcript itself stays crisp.
407
+ const bottomStickyLines = React.useMemo(
408
+ () => (outboxLines.length > 0 || liveActivityLines.length > 0
409
+ ? [...outboxLines, ...liveActivityLines]
410
+ : []),
411
+ [outboxLines, liveActivityLines],
393
412
  );
394
413
 
395
414
  return React.createElement(platform.Panel, {
@@ -399,7 +418,8 @@ const ChatPane = React.memo(function ChatPane({ controller, width, height, frame
399
418
  focused: chatView.focused,
400
419
  width,
401
420
  height,
402
- lines: chatLines,
421
+ lines: elements,
422
+ bottomStickyLines,
403
423
  scrollOffset: chatView.chatScroll,
404
424
  scrollMode: "bottom",
405
425
  paneId: "chat",
@@ -1059,6 +1079,60 @@ function ReasoningEffortPickerModalContainer({ controller }) {
1059
1079
  return React.createElement(ReasoningEffortPickerModal, { state });
1060
1080
  }
1061
1081
 
1082
+ function ContextTierPickerModal({ state }) {
1083
+ const platform = useUiPlatform();
1084
+ const modal = selectContextTierPickerModal(state);
1085
+ if (!modal) return null;
1086
+
1087
+ const viewport = typeof platform.getViewport === "function"
1088
+ ? platform.getViewport()
1089
+ : { width: 120, height: 40 };
1090
+ const width = Math.max(46, Math.min(modal.idealWidth || 64, (viewport.width || 120) - 16));
1091
+ const listHeight = Math.max(7, Math.min(modal.rows.length + 2, 10, (viewport.height || 40) - 16));
1092
+ const detailsHeight = Math.max(6, Math.min(8, (viewport.height || 40) - listHeight - 10));
1093
+ const lines = modal.rows.length > 0
1094
+ ? modal.rows
1095
+ : [{ text: "No context windows available.", color: "gray" }];
1096
+ const contentRows = Math.max(1, listHeight - 2);
1097
+ const scrollOffset = Math.max(0, modal.selectedRowIndex - Math.floor(contentRows / 2));
1098
+
1099
+ return React.createElement(platform.Overlay, null,
1100
+ React.createElement(platform.Column, { width },
1101
+ React.createElement(platform.Panel, {
1102
+ title: modal.title,
1103
+ color: "cyan",
1104
+ focused: false,
1105
+ width,
1106
+ height: listHeight,
1107
+ lines,
1108
+ scrollOffset,
1109
+ scrollMode: "top",
1110
+ marginBottom: 1,
1111
+ fillColor: "surface",
1112
+ }),
1113
+ React.createElement(platform.Panel, {
1114
+ title: modal.detailsTitle || "Context Window",
1115
+ color: "cyan",
1116
+ focused: false,
1117
+ width,
1118
+ height: detailsHeight,
1119
+ lines: modal.detailsLines,
1120
+ scrollOffset: 0,
1121
+ scrollMode: "top",
1122
+ fillColor: "surface",
1123
+ }),
1124
+ ));
1125
+ }
1126
+
1127
+ function ContextTierPickerModalContainer({ controller }) {
1128
+ const state = useControllerSelector(controller, (rootState) => ({
1129
+ ui: {
1130
+ modal: rootState.ui.modal,
1131
+ },
1132
+ }), shallowEqualObject);
1133
+ return React.createElement(ContextTierPickerModal, { state });
1134
+ }
1135
+
1062
1136
  function SessionAgentPickerModal({ state }) {
1063
1137
  const platform = useUiPlatform();
1064
1138
  const modal = selectSessionAgentPickerModal(state);
@@ -2037,6 +2111,7 @@ export function SharedPilotSwarmApp({ controller, versionLabel = null }) {
2037
2111
  React.createElement(HelpModalContainer, { controller }),
2038
2112
  React.createElement(ModelPickerModalContainer, { controller }),
2039
2113
  React.createElement(ReasoningEffortPickerModalContainer, { controller }),
2114
+ React.createElement(ContextTierPickerModalContainer, { controller }),
2040
2115
  React.createElement(ThemePickerModalContainer, { controller }),
2041
2116
  React.createElement(SessionAgentPickerModalContainer, { controller }),
2042
2117
  React.createElement(SessionGroupPickerModalContainer, { controller }),
@@ -30,6 +30,7 @@ import {
30
30
  selectLogFilterModal,
31
31
  selectModelPickerModal,
32
32
  selectReasoningEffortPickerModal,
33
+ selectContextTierPickerModal,
33
34
  selectRenameSessionModal,
34
35
  selectSessionAgentPickerModal,
35
36
  selectSessionGroupNameModal,
@@ -1747,20 +1748,25 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
1747
1748
  });
1748
1749
  }, []);
1749
1750
 
1750
- const [scrolledUp, setScrolledUp] = React.useState(false);
1751
- const updateScrolledUp = React.useCallback((el) => {
1751
+ // Edge fades follow real overflow: fade the TOP only when content is
1752
+ // clipped above (scrolled down) and the BOTTOM only when clipped below
1753
+ // (scrolled up). With content that fits, neither fades — the first and
1754
+ // last lines stay crisp.
1755
+ const [scrollShadow, setScrollShadow] = React.useState({ up: false, down: false });
1756
+ const updateScrollShadow = React.useCallback((el) => {
1752
1757
  if (!el) return;
1753
1758
  const up = el.scrollHeight - el.scrollTop - el.clientHeight > 4;
1754
- setScrolledUp((current) => (current === up ? current : up));
1759
+ const down = el.scrollTop > 4;
1760
+ setScrollShadow((cur) => (cur.up === up && cur.down === down ? cur : { up, down }));
1755
1761
  }, []);
1756
1762
  const handleBodyScroll = React.useCallback((event) => {
1757
1763
  onScroll();
1758
- updateScrolledUp(event.currentTarget);
1764
+ updateScrollShadow(event.currentTarget);
1759
1765
  if (!preserveHorizontalScroll || syncingHorizontalRef.current) return;
1760
1766
  syncScrollLeft(event.currentTarget, stickyRef.current);
1761
- }, [onScroll, preserveHorizontalScroll, syncScrollLeft, updateScrolledUp]);
1767
+ }, [onScroll, preserveHorizontalScroll, syncScrollLeft, updateScrollShadow]);
1762
1768
  React.useEffect(() => {
1763
- updateScrolledUp(ref.current);
1769
+ updateScrollShadow(ref.current);
1764
1770
  });
1765
1771
 
1766
1772
  const handleStickyScroll = React.useCallback((event) => {
@@ -1779,7 +1785,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
1779
1785
  normalizedSticky.map((line, index) => React.createElement(Line, { key: `sticky:${index}`, line, theme })),
1780
1786
  )
1781
1787
  : null,
1782
- React.createElement("div", { ref, className: `ps-scroll-panel ${className}${scrolledUp ? " is-scrolled-up" : ""}`.trim(), onScroll: handleBodyScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
1788
+ React.createElement("div", { ref, className: `ps-scroll-panel ${className}${scrollShadow.down ? " is-scrolled-down" : ""}${scrollShadow.up ? " is-scrolled-up" : ""}`.trim(), onScroll: handleBodyScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
1783
1789
  typeof renderBody === "function"
1784
1790
  ? renderBody(normalizedLines, theme)
1785
1791
  : structuredBlocks
@@ -1804,25 +1810,27 @@ function SessionRowContent({ row, theme, structured = false }) {
1804
1810
  : row.text;
1805
1811
  }
1806
1812
 
1807
- const hasMeta = Array.isArray(row.metaRuns) && row.metaRuns.length > 0;
1808
- const hasBadges = Array.isArray(row.badgeRuns) && row.badgeRuns.length > 0;
1809
- const hasSelectedMeta = row.active && Array.isArray(row.selectedMetaRuns) && row.selectedMetaRuns.length > 0;
1813
+ // Dense row: the title takes one line (clamped by CSS) and the context %
1814
+ // is pinned to the right. The full id · time · model · ctx detail unfolds
1815
+ // only under the selected row.
1816
+ const ctxRuns = Array.isArray(row.ctxRuns) ? row.ctxRuns : [];
1817
+ const detailRuns = Array.isArray(row.detailRuns)
1818
+ ? row.detailRuns
1819
+ : (Array.isArray(row.selectedMetaRuns) ? row.selectedMetaRuns : []);
1820
+ const hasCtx = ctxRuns.length > 0;
1821
+ const hasDetail = row.active && detailRuns.length > 0;
1810
1822
 
1811
1823
  return React.createElement(React.Fragment, null,
1812
- React.createElement("div", { className: "ps-session-row-title" },
1813
- React.createElement(Runs, { runs: row.titleRuns, theme }),
1814
- // Meta (timestamp / member count) sits inline on the title line.
1815
- hasMeta
1816
- ? React.createElement("span", { className: "ps-session-row-meta" },
1817
- React.createElement(Runs, { runs: [{ text: " ", color: "gray" }, ...row.metaRuns], theme }))
1824
+ React.createElement("div", { className: "ps-session-row-line" },
1825
+ React.createElement("div", { className: "ps-session-row-title" },
1826
+ React.createElement(Runs, { runs: row.titleRuns, theme })),
1827
+ hasCtx
1828
+ ? React.createElement("div", { className: "ps-session-row-ctx" },
1829
+ React.createElement(Runs, { runs: ctxRuns, theme }))
1818
1830
  : null),
1819
- hasBadges
1820
- ? React.createElement("div", { className: "ps-session-row-badges" },
1821
- React.createElement(Runs, { runs: row.badgeRuns, theme }))
1822
- : null,
1823
- hasSelectedMeta
1824
- ? React.createElement("div", { className: "ps-session-row-selected-meta" },
1825
- React.createElement(Runs, { runs: row.selectedMetaRuns, theme }))
1831
+ hasDetail
1832
+ ? React.createElement("div", { className: "ps-session-row-detail" },
1833
+ React.createElement(Runs, { runs: detailRuns, theme }))
1826
1834
  : null);
1827
1835
  }
1828
1836
 
@@ -3345,6 +3353,7 @@ function ModalLayer({ controller }) {
3345
3353
  themePicker: selectThemePickerModal(state),
3346
3354
  modelPicker: selectModelPickerModal(state),
3347
3355
  reasoningEffortPicker: selectReasoningEffortPickerModal(state),
3356
+ contextTierPicker: selectContextTierPickerModal(state),
3348
3357
  sessionAgentPicker: selectSessionAgentPickerModal(state),
3349
3358
  sessionGroupPicker: selectSessionGroupPickerModal(state),
3350
3359
  sessionGroupName: selectSessionGroupNameModal(state),
@@ -3399,6 +3408,7 @@ function ModalLayer({ controller }) {
3399
3408
  "themePicker",
3400
3409
  "modelPicker",
3401
3410
  "reasoningEffortPicker",
3411
+ "contextTierPicker",
3402
3412
  "sessionAgentPicker",
3403
3413
  "sessionGroupPicker",
3404
3414
  "artifactPicker",
@@ -3422,6 +3432,7 @@ function ModalLayer({ controller }) {
3422
3432
  modalState.themePicker?.selectedRowIndex,
3423
3433
  modalState.modelPicker?.selectedRowIndex,
3424
3434
  modalState.reasoningEffortPicker?.selectedRowIndex,
3435
+ modalState.contextTierPicker?.selectedRowIndex,
3425
3436
  modalState.sessionAgentPicker?.selectedRowIndex,
3426
3437
  modalState.sessionGroupPicker?.selectedRowIndex,
3427
3438
  modalState.artifactPicker?.selectedRowIndex,
@@ -3438,7 +3449,7 @@ function ModalLayer({ controller }) {
3438
3449
  const renderListModal = (presentation, confirmLabel = "Apply") => {
3439
3450
  const rows = Array.isArray(presentation.rows) ? presentation.rows : [];
3440
3451
  const rowItemIndexes = Array.isArray(presentation.rowItemIndexes) ? presentation.rowItemIndexes : null;
3441
- const usesHangingIndent = modal.type === "modelPicker" || modal.type === "reasoningEffortPicker" || modal.type === "sessionAgentPicker";
3452
+ const usesHangingIndent = modal.type === "modelPicker" || modal.type === "reasoningEffortPicker" || modal.type === "contextTierPicker" || modal.type === "sessionAgentPicker";
3442
3453
  const renderedList = rowItemIndexes && rowItemIndexes.length === rows.length
3443
3454
  ? rows.map((row, rowIndex) => {
3444
3455
  const itemIndex = rowItemIndexes[rowIndex];
@@ -3533,6 +3544,9 @@ function ModalLayer({ controller }) {
3533
3544
  if (modal.type === "reasoningEffortPicker" && modalState.reasoningEffortPicker) {
3534
3545
  return renderListModal(modalState.reasoningEffortPicker, pickerConfirmLabel);
3535
3546
  }
3547
+ if (modal.type === "contextTierPicker" && modalState.contextTierPicker) {
3548
+ return renderListModal(modalState.contextTierPicker, pickerConfirmLabel);
3549
+ }
3536
3550
  if (modal.type === "sessionAgentPicker" && modalState.sessionAgentPicker) {
3537
3551
  return renderListModal(modalState.sessionAgentPicker, "Create Session");
3538
3552
  }