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 +2 -2
- package/tui/src/app.js +12 -1
- package/ui/core/src/controller.js +6 -3
- package/ui/core/src/history.js +34 -0
- package/ui/core/src/selectors.js +127 -51
- package/ui/react/src/components.js +27 -8
- package/ui/react/src/web-app.js +30 -23
- package/web/dist/assets/{index-BA1Gd5ZC.js → index-BKai0rVp.js} +2 -2
- package/web/dist/assets/index-BRbv6TsU.css +1 -0
- package/web/dist/assets/{pilotswarm-C6iSDexX.js → pilotswarm-PUVWBejV.js} +57 -57
- package/web/dist/assets/{react-CqzaLZob.js → react-BouHbnso.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/dist/assets/index-Jr_Wn0fg.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pilotswarm",
|
|
3
|
-
"version": "0.5.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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),
|
package/ui/core/src/history.js
CHANGED
|
@@ -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);
|
package/ui/core/src/selectors.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
]
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
676
|
+
// Flat runs for the TUI: title + (for titled rows) dim age + context.
|
|
628
677
|
const runs = [
|
|
629
678
|
...titleRuns,
|
|
630
|
-
...(
|
|
631
|
-
...(
|
|
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
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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:
|
|
420
|
+
lines: elements,
|
|
421
|
+
bottomStickyLines,
|
|
403
422
|
scrollOffset: chatView.chatScroll,
|
|
404
423
|
scrollMode: "bottom",
|
|
405
424
|
paneId: "chat",
|
package/ui/react/src/web-app.js
CHANGED
|
@@ -1747,20 +1747,25 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
1747
1747
|
});
|
|
1748
1748
|
}, []);
|
|
1749
1749
|
|
|
1750
|
-
|
|
1751
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1763
|
+
updateScrollShadow(event.currentTarget);
|
|
1759
1764
|
if (!preserveHorizontalScroll || syncingHorizontalRef.current) return;
|
|
1760
1765
|
syncScrollLeft(event.currentTarget, stickyRef.current);
|
|
1761
|
-
}, [onScroll, preserveHorizontalScroll, syncScrollLeft,
|
|
1766
|
+
}, [onScroll, preserveHorizontalScroll, syncScrollLeft, updateScrollShadow]);
|
|
1762
1767
|
React.useEffect(() => {
|
|
1763
|
-
|
|
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}${
|
|
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
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
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-
|
|
1813
|
-
React.createElement(
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
? React.createElement("
|
|
1817
|
-
React.createElement(Runs, { runs:
|
|
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
|
-
|
|
1820
|
-
? React.createElement("div", { className: "ps-session-row-
|
|
1821
|
-
React.createElement(Runs, { runs:
|
|
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
|
|