pilotswarm 0.5.2 → 0.5.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "PilotSwarm application package: terminal UI, browser portal + Web API server, and MCP server — one install, three bins.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -77,7 +77,7 @@
77
77
  "hono": "^4.12.10",
78
78
  "ink": "^6.8.0",
79
79
  "jose": "^6.2.2",
80
- "pilotswarm-sdk": "^0.5.2",
80
+ "pilotswarm-sdk": "^0.5.3",
81
81
  "react": "^19.2.4",
82
82
  "react-dom": "^19.2.4",
83
83
  "ws": "^8.18.2"
package/tui/src/app.js CHANGED
@@ -519,9 +519,20 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
519
519
  return;
520
520
  }
521
521
  if (!controller.getState().ui.prompt) {
522
- if (controller.cancelLatestQueuedOutbox) {
522
+ // Empty prompt: if a message is queued, Esc retracts the
523
+ // latest one and stays; otherwise Esc exits to navigation
524
+ // (ESC mode) — same as pressing Esc with text. Previously
525
+ // this returned unconditionally, so an empty prompt with
526
+ // nothing queued swallowed Esc and never blurred.
527
+ const activeId = controller.getState().sessions.activeSessionId;
528
+ const queued = controller.getQueuedOutboxItems
529
+ ? controller.getQueuedOutboxItems(activeId)
530
+ : [];
531
+ if (queued.length > 0 && controller.cancelLatestQueuedOutbox) {
523
532
  controller.cancelLatestQueuedOutbox().catch(() => {});
533
+ return;
524
534
  }
535
+ controller.handleCommand(UI_COMMANDS.FOCUS_SESSIONS).catch(() => {});
525
536
  return;
526
537
  }
527
538
  controller.setPrompt("");
@@ -5220,11 +5220,14 @@ export class PilotSwarmUiController {
5220
5220
 
5221
5221
  const contentWidth = Math.max(20, layout.leftWidth - 4);
5222
5222
  const contentHeight = Math.max(1, layout.chatPaneHeight - 2);
5223
- const lines = [
5224
- ...selectChatLines(state, contentWidth),
5223
+ const lines = selectChatLines(state, contentWidth);
5224
+ // The live "Working" strip and the queued-prompt overlay are both
5225
+ // pinned in the bottom-sticky region (matching the ChatPane render),
5226
+ // so the transcript scroll math must reserve them here — not inline.
5227
+ const bottomStickyLines = [
5228
+ ...selectOutboxOverlayLines(state, contentWidth),
5225
5229
  ...selectLiveActivityLines(state),
5226
5230
  ];
5227
- const bottomStickyLines = selectOutboxOverlayLines(state, contentWidth);
5228
5231
  const bottomStickyHeight = Math.min(
5229
5232
  Math.max(0, Math.floor(contentHeight * 0.34)),
5230
5233
  countWrappedRenderableLines(bottomStickyLines, contentWidth),
@@ -360,12 +360,16 @@ function buildChatMessage(event, role) {
360
360
 
361
361
  const text = extractVisibleChatText(rawText, role);
362
362
  if (!hasVisibleMessageText(text)) return null;
363
+ const clientMessageIds = Array.isArray(event?.data?.clientMessageIds)
364
+ ? event.data.clientMessageIds.filter((id) => typeof id === "string" && id)
365
+ : [];
363
366
  return {
364
367
  id: `${event.sessionId}:${event.seq}`,
365
368
  role: deriveChatRole(event, role, text),
366
369
  text,
367
370
  time: formatTimestamp(event.createdAt),
368
371
  createdAt: event.createdAt instanceof Date ? event.createdAt.getTime() : new Date(event.createdAt).getTime(),
372
+ ...(clientMessageIds.length > 0 ? { clientMessageIds } : {}),
369
373
  };
370
374
  }
371
375
 
@@ -873,13 +877,43 @@ export function appendEventToHistory(history, event) {
873
877
  loadedEventLimit,
874
878
  loadedEventCount: Math.max(Number(history?.loadedEventCount || 0), nextEvents.length),
875
879
  hasOlderEvents: Boolean(history?.hasOlderEvents),
880
+ // clientMessageIds whose turn was user-stopped mid-flight. Carried
881
+ // across appends so a prompt stays flagged even as the transcript
882
+ // clamps/reloads.
883
+ stoppedMessageIds: Array.isArray(history?.stoppedMessageIds) ? history.stoppedMessageIds : [],
876
884
  };
877
885
 
886
+ // A stopped turn leaves a durable session.turn_stopped carrying the
887
+ // interrupted prompt's clientMessageIds. Record them and retroactively
888
+ // flag any matching transcript message (the user.message usually lands
889
+ // first, at turn start; the stop lands at turn end).
890
+ if (event.eventType === "session.turn_stopped") {
891
+ const ids = Array.isArray(event?.data?.clientMessageIds)
892
+ ? event.data.clientMessageIds.filter((id) => typeof id === "string" && id)
893
+ : [];
894
+ if (ids.length > 0) {
895
+ const merged = new Set([...next.stoppedMessageIds, ...ids]);
896
+ next.stoppedMessageIds = Array.from(merged);
897
+ next.chat = next.chat.map((m) => (
898
+ Array.isArray(m.clientMessageIds) && m.clientMessageIds.some((id) => merged.has(id))
899
+ ? { ...m, stopped: true }
900
+ : m
901
+ ));
902
+ }
903
+ return next;
904
+ }
905
+
878
906
  if (event.eventType === "user.message") {
879
907
  next.activity.push(...buildEmbeddedSystemNoticeActivityItems(event, "user"));
880
908
  next.activity = clampHistoryItems(next.activity, loadedEventLimit);
881
909
  const message = buildChatMessage(event, "user");
882
910
  if (!message) return next;
911
+ // Prospective flag: covers a bulk load where the stop event arrived
912
+ // before this message in the reduce order.
913
+ if (Array.isArray(message.clientMessageIds)
914
+ && message.clientMessageIds.some((id) => next.stoppedMessageIds.includes(id))) {
915
+ message.stopped = true;
916
+ }
883
917
  next.chat = reconcileOptimisticMessage(next.chat, message);
884
918
  next.chat.push(message);
885
919
  next.chat = clampHistoryItems(dedupeChatMessages(next.chat), loadedEventLimit);
@@ -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"
@@ -4638,6 +4697,23 @@ function formatRelativeTime(ts) {
4638
4697
  return `${Math.floor(ms / 86_400_000)}d ago`;
4639
4698
  }
4640
4699
 
4700
+ // Coarse session-age buckets for the list. Seconds tick too fast and just
4701
+ // distract, so the smallest bucket is "<1min"; then whole minutes to an hour,
4702
+ // then hours+minutes, then days+hours, then weeks. No "ago" suffix — the age
4703
+ // column context makes it clear.
4704
+ function formatSessionAge(ts) {
4705
+ if (!ts) return "—";
4706
+ const ms = Date.now() - ts;
4707
+ if (ms < 60_000) return "<1min";
4708
+ const totalMin = Math.floor(ms / 60_000);
4709
+ if (totalMin < 60) return `${totalMin}min`;
4710
+ const totalHours = Math.floor(totalMin / 60);
4711
+ if (totalHours < 24) return `${totalHours}h${String(totalMin % 60).padStart(2, "0")}m`;
4712
+ const days = Math.floor(totalHours / 24);
4713
+ if (days < 14) return `${days}d${String(totalHours % 24).padStart(2, "0")}h`;
4714
+ return `${Math.floor(days / 7)}w`;
4715
+ }
4716
+
4641
4717
  function formatLocalTimestamp(ts) {
4642
4718
  if (!ts) return "—";
4643
4719
  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,
@@ -317,6 +318,7 @@ const ChatPane = React.memo(function ChatPane({ controller, width, height, frame
317
318
  activeSessionId,
318
319
  activeSession: activeSessionId ? state.sessions.byId[activeSessionId] || null : null,
319
320
  activeHistory: activeSessionId ? state.history.bySessionId.get(activeSessionId) || null : null,
321
+ activeOutbox: activeSessionId ? state.outbox?.bySessionId?.[activeSessionId] || null : null,
320
322
  branding: state.branding,
321
323
  connectionError: state.connection.error,
322
324
  connectionMode: state.connection.mode,
@@ -347,11 +349,21 @@ const ChatPane = React.memo(function ChatPane({ controller, width, height, frame
347
349
  history: {
348
350
  bySessionId: historyMap,
349
351
  },
352
+ // The queued-prompt overlay reads state.outbox; without it here the
353
+ // TUI ChatPane's synthetic selectorState always saw an empty outbox
354
+ // and rendered no "queued prompts: N" even though the item was
355
+ // durably queued in the real store.
356
+ outbox: {
357
+ bySessionId: chatView.activeSessionId && chatView.activeOutbox
358
+ ? { [chatView.activeSessionId]: chatView.activeOutbox }
359
+ : {},
360
+ },
350
361
  };
351
362
  }, [
352
363
  chatView.activeHistory,
353
364
  chatView.activeSessionId,
354
365
  chatView.activeSession,
366
+ chatView.activeOutbox,
355
367
  chatView.branding,
356
368
  chatView.chatViewMode,
357
369
  chatView.connectionError,
@@ -383,13 +395,19 @@ const ChatPane = React.memo(function ChatPane({ controller, width, height, frame
383
395
  () => (startupError ? [] : selectLiveActivityLines(selectorState, { spinnerFrame, maxWidth: contentWidth })),
384
396
  [selectorState, startupError, spinnerFrame, contentWidth],
385
397
  );
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],
398
+ const outboxLines = React.useMemo(
399
+ () => (startupError ? [] : selectOutboxOverlayLines(selectorState, contentWidth)),
400
+ [selectorState, startupError, contentWidth],
401
+ );
402
+ // The queued-prompt overlay and the live "Working" strip are pinned in the
403
+ // bottom-sticky region (outbox above, strip at the very bottom), so they
404
+ // stay put at the foot of the pane instead of scrolling inline with the
405
+ // transcript — matching the portal. The transcript itself stays crisp.
406
+ const bottomStickyLines = React.useMemo(
407
+ () => (outboxLines.length > 0 || liveActivityLines.length > 0
408
+ ? [...outboxLines, ...liveActivityLines]
409
+ : []),
410
+ [outboxLines, liveActivityLines],
393
411
  );
394
412
 
395
413
  return React.createElement(platform.Panel, {
@@ -399,7 +417,8 @@ const ChatPane = React.memo(function ChatPane({ controller, width, height, frame
399
417
  focused: chatView.focused,
400
418
  width,
401
419
  height,
402
- lines: chatLines,
420
+ lines: elements,
421
+ bottomStickyLines,
403
422
  scrollOffset: chatView.chatScroll,
404
423
  scrollMode: "bottom",
405
424
  paneId: "chat",
@@ -1747,20 +1747,25 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
1747
1747
  });
1748
1748
  }, []);
1749
1749
 
1750
- const [scrolledUp, setScrolledUp] = React.useState(false);
1751
- const updateScrolledUp = React.useCallback((el) => {
1750
+ // Edge fades follow real overflow: fade the TOP only when content is
1751
+ // clipped above (scrolled down) and the BOTTOM only when clipped below
1752
+ // (scrolled up). With content that fits, neither fades — the first and
1753
+ // last lines stay crisp.
1754
+ const [scrollShadow, setScrollShadow] = React.useState({ up: false, down: false });
1755
+ const updateScrollShadow = React.useCallback((el) => {
1752
1756
  if (!el) return;
1753
1757
  const up = el.scrollHeight - el.scrollTop - el.clientHeight > 4;
1754
- setScrolledUp((current) => (current === up ? current : up));
1758
+ const down = el.scrollTop > 4;
1759
+ setScrollShadow((cur) => (cur.up === up && cur.down === down ? cur : { up, down }));
1755
1760
  }, []);
1756
1761
  const handleBodyScroll = React.useCallback((event) => {
1757
1762
  onScroll();
1758
- updateScrolledUp(event.currentTarget);
1763
+ updateScrollShadow(event.currentTarget);
1759
1764
  if (!preserveHorizontalScroll || syncingHorizontalRef.current) return;
1760
1765
  syncScrollLeft(event.currentTarget, stickyRef.current);
1761
- }, [onScroll, preserveHorizontalScroll, syncScrollLeft, updateScrolledUp]);
1766
+ }, [onScroll, preserveHorizontalScroll, syncScrollLeft, updateScrollShadow]);
1762
1767
  React.useEffect(() => {
1763
- updateScrolledUp(ref.current);
1768
+ updateScrollShadow(ref.current);
1764
1769
  });
1765
1770
 
1766
1771
  const handleStickyScroll = React.useCallback((event) => {
@@ -1779,7 +1784,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
1779
1784
  normalizedSticky.map((line, index) => React.createElement(Line, { key: `sticky:${index}`, line, theme })),
1780
1785
  )
1781
1786
  : null,
1782
- React.createElement("div", { ref, className: `ps-scroll-panel ${className}${scrolledUp ? " is-scrolled-up" : ""}`.trim(), onScroll: handleBodyScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
1787
+ 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
1788
  typeof renderBody === "function"
1784
1789
  ? renderBody(normalizedLines, theme)
1785
1790
  : structuredBlocks
@@ -1804,25 +1809,27 @@ function SessionRowContent({ row, theme, structured = false }) {
1804
1809
  : row.text;
1805
1810
  }
1806
1811
 
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;
1812
+ // Dense row: the title takes one line (clamped by CSS) and the context %
1813
+ // is pinned to the right. The full id · time · model · ctx detail unfolds
1814
+ // only under the selected row.
1815
+ const ctxRuns = Array.isArray(row.ctxRuns) ? row.ctxRuns : [];
1816
+ const detailRuns = Array.isArray(row.detailRuns)
1817
+ ? row.detailRuns
1818
+ : (Array.isArray(row.selectedMetaRuns) ? row.selectedMetaRuns : []);
1819
+ const hasCtx = ctxRuns.length > 0;
1820
+ const hasDetail = row.active && detailRuns.length > 0;
1810
1821
 
1811
1822
  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 }))
1823
+ React.createElement("div", { className: "ps-session-row-line" },
1824
+ React.createElement("div", { className: "ps-session-row-title" },
1825
+ React.createElement(Runs, { runs: row.titleRuns, theme })),
1826
+ hasCtx
1827
+ ? React.createElement("div", { className: "ps-session-row-ctx" },
1828
+ React.createElement(Runs, { runs: ctxRuns, theme }))
1818
1829
  : 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 }))
1830
+ hasDetail
1831
+ ? React.createElement("div", { className: "ps-session-row-detail" },
1832
+ React.createElement(Runs, { runs: detailRuns, theme }))
1826
1833
  : null);
1827
1834
  }
1828
1835