pilotswarm 0.5.57 → 0.5.58
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/node-sdk-transport.js +5 -0
- package/ui/core/src/controller.js +117 -4
- package/ui/core/src/history.js +112 -1
- package/ui/core/src/selectors.js +164 -9
- package/ui/react/src/web-app.js +162 -19
- package/web/api/live-plane.js +193 -0
- package/web/api/ws.js +118 -2
- package/web/dist/assets/{abnfDiagram-VRR7QNED-CJOR2hPi.js → abnfDiagram-VRR7QNED-CCTGbkH5.js} +1 -1
- package/web/dist/assets/{arc-Yh3kc_KB.js → arc-w8Hl2ORd.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DLRlUeyk.js → architectureDiagram-ZJ3FMSHR-DAFM73jL.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-x_TGH_fp.js → blockDiagram-677ZJIJ3-BYfTQFe7.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-C-j_bxeF.js → c4Diagram-LMCZKHZV-bNF2gRsg.js} +1 -1
- package/web/dist/assets/channel-s4nVWOVf.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-CaI5Xqwo.js → chunk-2Q5K7J3B-DhqJnJ4W.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-Bh_Crac6.js → chunk-32BRIVSS-Jg1a6n_3.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-XM-3ajKG.js → chunk-5VM5RSS4-y7NKpClZ.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-ZwkulfFp.js → chunk-EX3LRPZG-Lks3EhVc.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-CT3Q7T2A.js → chunk-JWPE2WC7-BjcSkfk2.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-_Uc0ksg3.js → chunk-MOJQB5TN-USDZsR-1.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-DosoId7u.js → chunk-RYQCIY6F--HnsBTqS.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-KRjq1cwM.js → chunk-V7JOEXUC-CUql3Oy3.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-B6Ul1z3S.js → chunk-VR4S4FIN-CpS-l3FG.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-AikdVXm4.js → chunk-XXDRQBXY-Bjb7ifTV.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-tj8tRjww.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-tj8tRjww.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-DTYwgEAa.js → cose-bilkent-JH36ORCC-CmY3-Xrd.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-C_8v2wIz.js → cynefin-VYW2F7L2-DVtcyDi5.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-1-nw1zbQ.js → cynefinDiagram-TSTJHNR4-BPqkLecJ.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-CAjwRpvT.js → dagre-VKFMJZFB-B73fSRpn.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-Bg41nQw_.js → diagram-FQU43EPY-uRCPZyuW.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-Cp6P-qMn.js → diagram-G47NLZAW-CHs_l9pw.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-DNdFuedg.js → diagram-NH7WQ7WH-BDs35ueJ.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-DcHpELG2.js → diagram-OA4YK3LP-Dsv48zWW.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-B2_kyMw3.js → diagram-WEI45ONY-Bty-lxLj.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DztuAo3o.js → ebnfDiagram-CCIWWBDH-DgaxsuDg.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-DNjKTOzp.js → erDiagram-Q63AITRT-CR6l4OsK.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-DWjfeUM1.js → flowDiagram-23GEKE2U-4y1ts4PX.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-Y0MgGVmy.js → ganttDiagram-NO4QXBWP-CyKdJ2e9.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX--lDKAKVz.js → gitGraphDiagram-IHSO6WYX-Ct5ShF3i.js} +1 -1
- package/web/dist/assets/index-DaRnh5NC.css +1 -0
- package/web/dist/assets/index-aFRNNWe9.js +24 -0
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-CKmH8_YO.js → infoDiagram-FWYZ7A6U-uWoTXOM3.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-D88R7zUc.js → ishikawaDiagram-FXEZZL3T-BTlDV_pc.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-Ck7EMrsY.js → journeyDiagram-5HDEW3XC-_R-XB78s.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-hc-mOfSG.js → kanban-definition-HUTT4EX6-D7iwBjoS.js} +1 -1
- package/web/dist/assets/{linear-BVhU4csP.js → linear-BugN16AP.js} +1 -1
- package/web/dist/assets/{mermaid.core-C9u4FOCX.js → mermaid.core-De_KIbl-.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-DatQu9Xp.js → mindmap-definition-LN4V7U3C-B72NDgcU.js} +1 -1
- package/web/dist/assets/{papaparse.min-BdnoXc8M.js → papaparse.min-BsD41u4P.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-Cr_nZ_jR.js → pegDiagram-2B236MQR-C6oifn4x.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-CcAbSIcY.js → pieDiagram-ENE6RG2P-Cpn0_Hp6.js} +1 -1
- package/web/dist/assets/pilotswarm-9Szh1sEC.js +99 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CgJw5laM.js → quadrantDiagram-ABIIQ3AL-CJT4OoDA.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-CHeun9SN.js → railroadDiagram-RFXS5EU6-BKtBN8XC.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-D1kpDj8W.js → requirementDiagram-TGXJPOKE-Dw7BCEzo.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-C5vUSUaX.js → sankeyDiagram-HTMAVEWB-Avmr1rvJ.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-T1aPSxk1.js → sequenceDiagram-DBY2YBRQ-BRNnslq1.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-j-wh2LVX.js → sizeCapture-X5ZJPWSS-BNmM8zTz.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-DfUZZR8N.js → stateDiagram-2N3HPSRC-CKXaP1Y6.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-Dr0juUsm.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-OpASJiRw.js → swimlanes-5IMT3BWC-Dkeka0gO.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-q-jLf0ZX.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-BBnc_z1U.js → timeline-definition-FHXFAJF6-5hTTq-SL.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-BlPQe9p4.js → vennDiagram-L72KCM5P-DL8MolqK.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-DBcBFUx2.js → wardleyDiagram-EHGQE667-CwGhy6eW.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-BQUSGL6g.js → xychartDiagram-FW5EYKEG-Dn4T-3Dx.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/runtime.js +8 -0
- package/web/server.js +8 -0
- package/web/dist/assets/channel-BIn92AzB.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-B7ktW6XM.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-B7ktW6XM.js +0 -1
- package/web/dist/assets/index-BxPfp0AO.css +0 -1
- package/web/dist/assets/index-rPji8Uud.js +0 -24
- package/web/dist/assets/pilotswarm--kvfB_6q.js +0 -99
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-ilKtTdHG.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-C1PthnB4.js +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pilotswarm",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.58",
|
|
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",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"ink": "^6.8.0",
|
|
82
82
|
"jose": "^6.2.2",
|
|
83
83
|
"mermaid": "^11.16.0",
|
|
84
|
-
"pilotswarm-sdk": "0.5.
|
|
84
|
+
"pilotswarm-sdk": "0.5.58",
|
|
85
85
|
"react": "^19.2.4",
|
|
86
86
|
"react-dom": "^19.2.4",
|
|
87
87
|
"ws": "^8.18.2"
|
|
@@ -1976,6 +1976,11 @@ export class NodeSdkTransport {
|
|
|
1976
1976
|
return this.mgmt.getCanvasLive(sessionId);
|
|
1977
1977
|
}
|
|
1978
1978
|
|
|
1979
|
+
async getLive(sessionId, topics) {
|
|
1980
|
+
if (typeof this.mgmt.getLive !== "function") return [];
|
|
1981
|
+
return this.mgmt.getLive(sessionId, topics);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1979
1984
|
// The canvas KV store. The portal runtime hands a resolved principal
|
|
1980
1985
|
// (door 1); the native TUI has no canvas surface and never calls these.
|
|
1981
1986
|
async readCanvasKv(sessionId, slot, principal, query = {}) {
|
|
@@ -4,8 +4,10 @@ import { parseAgentSourceLink } from "./repo-links.js";
|
|
|
4
4
|
import { importPackageFilesFromLink, readImportedPackageName } from "./repo-import.js";
|
|
5
5
|
import {
|
|
6
6
|
appendEventToHistory,
|
|
7
|
+
applyLiveTurnToHistory,
|
|
7
8
|
buildHistoryModel,
|
|
8
9
|
CHAT_HISTORY_EVENT_TYPES,
|
|
10
|
+
clearLiveTurnFromHistory,
|
|
9
11
|
DEFAULT_HISTORY_EVENT_LIMIT,
|
|
10
12
|
dedupeChatMessages,
|
|
11
13
|
getNextHistoryEventLimit,
|
|
@@ -2503,6 +2505,8 @@ export class PilotSwarmUiController {
|
|
|
2503
2505
|
}
|
|
2504
2506
|
|
|
2505
2507
|
async stop() {
|
|
2508
|
+
for (const timer of this._liveTurnIdleTimers?.values() || []) clearTimeout(timer);
|
|
2509
|
+
this._liveTurnIdleTimers?.clear();
|
|
2506
2510
|
if (this.catalogTimer) clearInterval(this.catalogTimer);
|
|
2507
2511
|
this.catalogTimer = null;
|
|
2508
2512
|
if (this.activeSessionDetailTimer) clearTimeout(this.activeSessionDetailTimer);
|
|
@@ -2518,6 +2522,16 @@ export class PilotSwarmUiController {
|
|
|
2518
2522
|
}
|
|
2519
2523
|
|
|
2520
2524
|
detachActiveSession() {
|
|
2525
|
+
// Timers belong to the subscription, not to a future visit to this session.
|
|
2526
|
+
const sessionId = this.activeSessionSubscriptionId;
|
|
2527
|
+
if (sessionId) {
|
|
2528
|
+
clearTimeout(this._liveTurnIdleTimers?.get(sessionId));
|
|
2529
|
+
this._liveTurnIdleTimers?.delete(sessionId);
|
|
2530
|
+
const history = this.getState().history.bySessionId.get(sessionId);
|
|
2531
|
+
if (history) this.dispatch({ type: "history/set", sessionId, history: {
|
|
2532
|
+
...history, chat: history.chat.filter((item) => !item.liveTurn),
|
|
2533
|
+
} });
|
|
2534
|
+
}
|
|
2521
2535
|
if (this.activeSessionUnsub) {
|
|
2522
2536
|
this.activeSessionUnsub();
|
|
2523
2537
|
this.activeSessionUnsub = null;
|
|
@@ -2866,8 +2880,9 @@ export class PilotSwarmUiController {
|
|
|
2866
2880
|
if (newEvents.length === 0) {
|
|
2867
2881
|
return existingHistory;
|
|
2868
2882
|
}
|
|
2869
|
-
let history = existingHistory;
|
|
2883
|
+
let history = this.getState().history.bySessionId.get(sessionId) || existingHistory;
|
|
2870
2884
|
for (const event of newEvents) {
|
|
2885
|
+
if (event.seq <= (history.lastSeq || 0)) continue;
|
|
2871
2886
|
history = appendEventToHistory(history, event);
|
|
2872
2887
|
}
|
|
2873
2888
|
history = {
|
|
@@ -2937,10 +2952,37 @@ export class PilotSwarmUiController {
|
|
|
2937
2952
|
}
|
|
2938
2953
|
throw error;
|
|
2939
2954
|
}
|
|
2940
|
-
const
|
|
2941
|
-
|
|
2942
|
-
|
|
2955
|
+
const current = this.getState().history.bySessionId.get(sessionId);
|
|
2956
|
+
// Durable events and previews can arrive while REST is pending.
|
|
2957
|
+
// Merge that newer truth before replacing the loaded window.
|
|
2958
|
+
const mergedEvents = [...new Map([...(events || []), ...(current?.events || [])]
|
|
2959
|
+
.map((event) => [event.seq, event])).values()].sort((a, b) => a.seq - b.seq);
|
|
2960
|
+
let history = {
|
|
2961
|
+
...buildHistoryModel(mergedEvents, { requestedLimit }),
|
|
2962
|
+
lastSeq: mergedEvents.at(-1)?.seq || 0,
|
|
2943
2963
|
};
|
|
2964
|
+
history.closedLiveKeys = current?.closedLiveKeys || [];
|
|
2965
|
+
history.closedLiveStreams = current?.closedLiveStreams || [];
|
|
2966
|
+
const newTerminal = mergedEvents.some((event) => event.seq > (current?.lastSeq || 0)
|
|
2967
|
+
&& ["session.turn_completed", "session.turn_stopped"].includes(event.eventType));
|
|
2968
|
+
for (const item of current?.chat || []) {
|
|
2969
|
+
if (item.streamSettling) {
|
|
2970
|
+
history.chat = history.chat.map((message) => message.messageId === item.messageId
|
|
2971
|
+
? { ...item, ...message } : message);
|
|
2972
|
+
} else if (item.liveTurn && !newTerminal) {
|
|
2973
|
+
// Reuse the item itself: its key, reveal clock and memoized
|
|
2974
|
+
// rendering belong to this visit, not the REST response.
|
|
2975
|
+
if (!history.chat.some((message) => message.messageId && message.messageId === item.messageId)) {
|
|
2976
|
+
history.chat.push(item);
|
|
2977
|
+
} else {
|
|
2978
|
+
const finalEvent = mergedEvents.find((event) => event.eventType === "assistant.message" && event.data?.messageId === item.messageId);
|
|
2979
|
+
if (finalEvent) {
|
|
2980
|
+
const reconciled = appendEventToHistory({ chat: [item], events: [], activity: [], lastSeq: 0 }, finalEvent).chat[0];
|
|
2981
|
+
history.chat = history.chat.map((message) => message.messageId === item.messageId ? { ...reconciled, ...message } : message);
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2944
2986
|
this.dispatch({
|
|
2945
2987
|
type: "history/set",
|
|
2946
2988
|
sessionId,
|
|
@@ -5708,6 +5750,71 @@ export class PilotSwarmUiController {
|
|
|
5708
5750
|
// history would corrupt the replay cursor — and they must never
|
|
5709
5751
|
// appear in the transcript.
|
|
5710
5752
|
if (event.transient === true) {
|
|
5753
|
+
if (event.eventType === "assistant.live_tick") {
|
|
5754
|
+
// A worker can die leaving a reasoning-only retained row.
|
|
5755
|
+
// On re-entry its message ID cannot match a durable final;
|
|
5756
|
+
// use database timestamps to reject snapshots older than an
|
|
5757
|
+
// already-loaded terminal event. Never use client wall time.
|
|
5758
|
+
const snapshotTime = event.liveUpdatedAt ? new Date(event.liveUpdatedAt).getTime() : NaN;
|
|
5759
|
+
if (event.data?.phase === "live" && Number.isFinite(snapshotTime)) {
|
|
5760
|
+
const history = this.getState().history.bySessionId.get(sessionId);
|
|
5761
|
+
if (history?.events?.some((entry) => ["session.turn_completed", "session.turn_stopped"].includes(entry.eventType)
|
|
5762
|
+
&& new Date(entry.createdAt).getTime() > snapshotTime)) return false;
|
|
5763
|
+
}
|
|
5764
|
+
if (!this._liveTurnIdleTimers) this._liveTurnIdleTimers = new Map();
|
|
5765
|
+
const pendingIdle = this._liveTurnIdleTimers.get(sessionId);
|
|
5766
|
+
if (pendingIdle && event.data?.phase === "idle") {
|
|
5767
|
+
clearTimeout(pendingIdle);
|
|
5768
|
+
this._liveTurnIdleTimers.delete(sessionId);
|
|
5769
|
+
}
|
|
5770
|
+
// The live plane and durable event stream are independent.
|
|
5771
|
+
// An idle tick commonly wins the race by a few milliseconds;
|
|
5772
|
+
// tearing down immediately makes the live answer flash out
|
|
5773
|
+
// before its durable replacement lands. Give reconciliation a
|
|
5774
|
+
// bounded grace window, then clear only if it is still live.
|
|
5775
|
+
if (event.data?.phase === "idle") {
|
|
5776
|
+
const ended = this.getState().history.bySessionId.get(sessionId);
|
|
5777
|
+
if (!ended) return true;
|
|
5778
|
+
const streamId = event.data?.streamId || null;
|
|
5779
|
+
const endedKeys = new Set(ended.chat.filter((item) => item.liveTurn && (
|
|
5780
|
+
!streamId || item.streamId === streamId
|
|
5781
|
+
|| (ended.closedLiveStreams || []).includes(item.streamId)
|
|
5782
|
+
|| (ended.closedLiveKeys || []).includes(item.liveKey)
|
|
5783
|
+
)).map((item) => item.liveKey));
|
|
5784
|
+
// Never reveal a preview that has already finished. Keep
|
|
5785
|
+
// only previews that crossed the reveal threshold, and
|
|
5786
|
+
// close their keys now so late packets cannot revive them.
|
|
5787
|
+
this.dispatch({ type: "history/set", sessionId, history: {
|
|
5788
|
+
...clearLiveTurnFromHistory(ended, null, streamId),
|
|
5789
|
+
chat: ended.chat.filter((item) => !item.liveTurn || !endedKeys.has(item.liveKey) || Date.now() - item.liveStartedAt >= 200),
|
|
5790
|
+
} });
|
|
5791
|
+
const timer = setTimeout(() => {
|
|
5792
|
+
this._liveTurnIdleTimers?.delete(sessionId);
|
|
5793
|
+
const current = this.getState().history.bySessionId.get(sessionId);
|
|
5794
|
+
if (!current) return;
|
|
5795
|
+
const chat = current.chat.filter((item) => !item.liveTurn || !endedKeys.has(item.liveKey));
|
|
5796
|
+
const cleared = chat.length === current.chat.length ? current : { ...current, chat };
|
|
5797
|
+
if (cleared !== current) {
|
|
5798
|
+
this.dispatch({ type: "history/set", sessionId, history: cleared });
|
|
5799
|
+
}
|
|
5800
|
+
}, 1_200);
|
|
5801
|
+
timer.unref?.();
|
|
5802
|
+
this._liveTurnIdleTimers.set(sessionId, timer);
|
|
5803
|
+
return true;
|
|
5804
|
+
}
|
|
5805
|
+
const existing = this.getState().history.bySessionId.get(sessionId)
|
|
5806
|
+
|| { chat: [], activity: [], events: [], lastSeq: 0 };
|
|
5807
|
+
this.dispatch({
|
|
5808
|
+
type: "history/set",
|
|
5809
|
+
sessionId,
|
|
5810
|
+
history: applyLiveTurnToHistory(existing, event.data, {
|
|
5811
|
+
sessionId,
|
|
5812
|
+
seq: event.liveSeq,
|
|
5813
|
+
createdAt: Date.now(),
|
|
5814
|
+
}),
|
|
5815
|
+
});
|
|
5816
|
+
return true;
|
|
5817
|
+
}
|
|
5711
5818
|
if (event.eventType === "session.canvas_data") {
|
|
5712
5819
|
this.applyCanvasDataEvent(sessionId, event);
|
|
5713
5820
|
}
|
|
@@ -5730,6 +5837,12 @@ export class PilotSwarmUiController {
|
|
|
5730
5837
|
}
|
|
5731
5838
|
return false;
|
|
5732
5839
|
}
|
|
5840
|
+
if (this._liveTurnIdleTimers?.has(sessionId)
|
|
5841
|
+
&& (event.eventType === "session.turn_completed"
|
|
5842
|
+
|| event.eventType === "session.turn_stopped")) {
|
|
5843
|
+
clearTimeout(this._liveTurnIdleTimers.get(sessionId));
|
|
5844
|
+
this._liveTurnIdleTimers.delete(sessionId);
|
|
5845
|
+
}
|
|
5733
5846
|
const state = this.getState();
|
|
5734
5847
|
const existing = state.history.bySessionId.get(sessionId) || { chat: [], activity: [], lastSeq: 0 };
|
|
5735
5848
|
if (event.seq <= (existing.lastSeq || 0)) return false;
|
package/ui/core/src/history.js
CHANGED
|
@@ -530,6 +530,9 @@ function buildChatMessage(event, role) {
|
|
|
530
530
|
const attachments = Array.isArray(event?.data?.attachments)
|
|
531
531
|
? event.data.attachments.filter((a) => a && typeof a.filename === "string" && a.filename)
|
|
532
532
|
: [];
|
|
533
|
+
const messageId = typeof event?.data?.messageId === "string" && event.data.messageId
|
|
534
|
+
? event.data.messageId
|
|
535
|
+
: null;
|
|
533
536
|
return {
|
|
534
537
|
id: `${event.sessionId}:${event.seq}`,
|
|
535
538
|
role: deriveChatRole(event, role, text),
|
|
@@ -539,9 +542,76 @@ function buildChatMessage(event, role) {
|
|
|
539
542
|
...(clientMessageIds.length > 0 ? { clientMessageIds } : {}),
|
|
540
543
|
...(sender ? { sender } : {}),
|
|
541
544
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
545
|
+
...(messageId ? { messageId } : {}),
|
|
542
546
|
};
|
|
543
547
|
}
|
|
544
548
|
|
|
549
|
+
/** Apply one transient `turn` topic snapshot without touching durable events/seq. */
|
|
550
|
+
export function applyLiveTurnToHistory(history, payload, meta = {}) {
|
|
551
|
+
if (!payload) return history;
|
|
552
|
+
if (payload.phase === "idle") return clearLiveTurnFromHistory(history, null, payload.streamId || null);
|
|
553
|
+
if (payload.phase !== "live") return history;
|
|
554
|
+
const messageId = typeof payload.messageId === "string" && payload.messageId ? payload.messageId : null;
|
|
555
|
+
const reasoningId = typeof payload.reasoningId === "string" && payload.reasoningId ? payload.reasoningId : null;
|
|
556
|
+
const liveKey = messageId || (reasoningId ? `reasoning:${reasoningId}` : "active-turn");
|
|
557
|
+
const createdAt = Number(meta.createdAt) || Date.now();
|
|
558
|
+
const current = history || { chat: [], activity: [], events: [], lastSeq: 0 };
|
|
559
|
+
const oldChat = current.chat || [];
|
|
560
|
+
// Durable truth wins even when a delayed retained snapshot arrives later.
|
|
561
|
+
if ((messageId && oldChat.some((item) => !item.liveTurn && item.messageId === messageId))
|
|
562
|
+
|| (current.closedLiveKeys || []).includes(liveKey)
|
|
563
|
+
|| (payload.streamId && (current.closedLiveStreams || []).includes(payload.streamId))) return current;
|
|
564
|
+
const matches = (item) => item?.liveTurn === true && (
|
|
565
|
+
item?.liveKey === liveKey
|
|
566
|
+
|| (messageId && item?.messageId === messageId)
|
|
567
|
+
|| (reasoningId && item?.reasoningId === reasoningId && (!item.messageId || !messageId))
|
|
568
|
+
);
|
|
569
|
+
const firstMatch = oldChat.findIndex(matches);
|
|
570
|
+
const previous = firstMatch >= 0 ? oldChat[firstMatch] : null;
|
|
571
|
+
const chat = oldChat.filter((item) => !matches(item));
|
|
572
|
+
const reasoningText = typeof payload.reasoningText === "string" ? payload.reasoningText : "";
|
|
573
|
+
const text = typeof payload.text === "string" ? payload.text : "";
|
|
574
|
+
const liveStartedAt = Number(previous?.liveStartedAt || previous?.createdAt) || createdAt;
|
|
575
|
+
// Reasoning and answer text are one visual turn, not two independently
|
|
576
|
+
// mounted transcript rows. Keeping them in one keyed item lets the browser
|
|
577
|
+
// collapse reasoning above the answer and lets the durable final settle
|
|
578
|
+
// into the exact same React component instead of flashing between nodes.
|
|
579
|
+
const replacement = (reasoningText || text) ? [{
|
|
580
|
+
id: previous?.id || `live:${meta.sessionId || ""}:${liveKey}`,
|
|
581
|
+
role: "assistant",
|
|
582
|
+
...(messageId ? { messageId } : {}),
|
|
583
|
+
...(reasoningId ? { reasoningId } : {}),
|
|
584
|
+
liveKey: previous?.liveKey || liveKey,
|
|
585
|
+
liveTurn: true,
|
|
586
|
+
streamId: payload.streamId || previous?.streamId || null,
|
|
587
|
+
streaming: true,
|
|
588
|
+
truncated: payload.truncated === true,
|
|
589
|
+
text,
|
|
590
|
+
reasoningText,
|
|
591
|
+
liveStartedAt,
|
|
592
|
+
createdAt: liveStartedAt,
|
|
593
|
+
time: "",
|
|
594
|
+
}] : [];
|
|
595
|
+
chat.splice(firstMatch < 0 ? chat.length : Math.min(firstMatch, chat.length), 0, ...replacement);
|
|
596
|
+
return { ...current, chat };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function clearLiveTurnFromHistory(history, messageId = null, streamId = null) {
|
|
600
|
+
if (!history?.chat) return history;
|
|
601
|
+
const chat = history.chat.filter((item) => !(
|
|
602
|
+
item?.liveTurn === true && (!messageId || item?.messageId === messageId) && (!streamId || item?.streamId === streamId)
|
|
603
|
+
));
|
|
604
|
+
const retired = history.chat.filter((item) => item.liveTurn && (!messageId || item.messageId === messageId) && (!streamId || item.streamId === streamId));
|
|
605
|
+
const closedLiveKeys = [...new Set([...(history.closedLiveKeys || []),
|
|
606
|
+
...retired.flatMap((item) => [item.liveKey, item.messageId, item.reasoningId && `reasoning:${item.reasoningId}`].filter(Boolean)),
|
|
607
|
+
])].slice(-256);
|
|
608
|
+
const closedLiveStreams = [...new Set([...(history.closedLiveStreams || []),
|
|
609
|
+
...(!messageId ? retired.map((item) => item.streamId).filter(Boolean) : []),
|
|
610
|
+
...(streamId ? [streamId] : []),
|
|
611
|
+
])].slice(-256);
|
|
612
|
+
return chat.length === history.chat.length && !streamId ? history : { ...history, chat, closedLiveKeys, closedLiveStreams };
|
|
613
|
+
}
|
|
614
|
+
|
|
545
615
|
// The Copilot SDK echoes the system prompt as a system.message on EVERY
|
|
546
616
|
// turn, and the worker keeps only a 120-char snippet of it (session-proxy.ts,
|
|
547
617
|
// "Full content omitted from CMS"). For one release (0.5.47) each echo also
|
|
@@ -765,6 +835,12 @@ function formatActivity(event) {
|
|
|
765
835
|
case "assistant.streaming_progress":
|
|
766
836
|
return null;
|
|
767
837
|
|
|
838
|
+
case "model.call_start": {
|
|
839
|
+
const model = String(event?.data?.model || event?.data?.modelId || "model").split(":").pop();
|
|
840
|
+
runs = buildLabeledActivityRuns(time, "[model]", "cyan", `calling ${model}`, "white");
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
|
|
768
844
|
case "session.artifact_presented": {
|
|
769
845
|
const data = (event?.data ?? {}) || {};
|
|
770
846
|
const filename = String(data.filename || "artifact");
|
|
@@ -1167,6 +1243,8 @@ export function appendEventToHistory(history, event) {
|
|
|
1167
1243
|
? existingEvents
|
|
1168
1244
|
: [...existingEvents, event].slice(-loadedEventLimit);
|
|
1169
1245
|
const next = {
|
|
1246
|
+
closedLiveKeys: history?.closedLiveKeys || [],
|
|
1247
|
+
closedLiveStreams: history?.closedLiveStreams || [],
|
|
1170
1248
|
chat: clampHistoryItems(history?.chat || [], loadedEventLimit),
|
|
1171
1249
|
activity: clampHistoryItems(history?.activity || [], loadedEventLimit),
|
|
1172
1250
|
events: nextEvents,
|
|
@@ -1201,6 +1279,7 @@ export function appendEventToHistory(history, event) {
|
|
|
1201
1279
|
: m
|
|
1202
1280
|
));
|
|
1203
1281
|
}
|
|
1282
|
+
Object.assign(next, clearLiveTurnFromHistory(next));
|
|
1204
1283
|
return next;
|
|
1205
1284
|
}
|
|
1206
1285
|
|
|
@@ -1241,10 +1320,42 @@ export function appendEventToHistory(history, event) {
|
|
|
1241
1320
|
next.activity = clampHistoryItems(next.activity, loadedEventLimit);
|
|
1242
1321
|
const message = buildChatMessage(event, "assistant");
|
|
1243
1322
|
if (!message) return next;
|
|
1244
|
-
|
|
1323
|
+
const liveIndex = message.messageId
|
|
1324
|
+
? next.chat.findIndex((item) => item?.liveTurn === true && item?.messageId === message.messageId)
|
|
1325
|
+
: -1;
|
|
1326
|
+
const liveItem = liveIndex >= 0 ? next.chat[liveIndex] : null;
|
|
1327
|
+
Object.assign(next, clearLiveTurnFromHistory(next, message.messageId || null));
|
|
1328
|
+
// Preserve the model-call slot when several assistant messages are
|
|
1329
|
+
// interleaved: the durable final replaces its own live bubble in
|
|
1330
|
+
// place instead of jumping behind a later streaming message.
|
|
1331
|
+
if (liveIndex >= 0) {
|
|
1332
|
+
const settledAt = Date.now();
|
|
1333
|
+
const liveStartedAt = Number(liveItem?.liveStartedAt || liveItem?.createdAt) || settledAt;
|
|
1334
|
+
const liveAgeMs = Math.max(0, settledAt - liveStartedAt);
|
|
1335
|
+
// A live surface that never crossed the reveal threshold remains
|
|
1336
|
+
// invisible and the durable answer renders normally. Once it was
|
|
1337
|
+
// eligible to be seen, preserve the key and reasoning long enough
|
|
1338
|
+
// for the browser to morph it smoothly into committed transcript.
|
|
1339
|
+
if (liveAgeMs >= 200) {
|
|
1340
|
+
const streamSettleDelayMs = Math.max(0, 900 - liveAgeMs);
|
|
1341
|
+
Object.assign(message, {
|
|
1342
|
+
streamSettling: true,
|
|
1343
|
+
liveKey: liveItem?.liveKey || message.messageId,
|
|
1344
|
+
liveStartedAt,
|
|
1345
|
+
liveReasoningText: liveItem?.reasoningText || "",
|
|
1346
|
+
streamSettleDelayMs,
|
|
1347
|
+
streamSettleAt: settledAt + streamSettleDelayMs + 180,
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
next.chat.splice(Math.min(liveIndex, next.chat.length), 0, message);
|
|
1351
|
+
}
|
|
1352
|
+
else next.chat.push(message);
|
|
1245
1353
|
next.chat = clampHistoryItems(dedupeChatMessages(next.chat), loadedEventLimit);
|
|
1246
1354
|
return next;
|
|
1247
1355
|
}
|
|
1356
|
+
if (event.eventType === "session.turn_completed" || event.eventType === "session.turn_stopped") {
|
|
1357
|
+
Object.assign(next, clearLiveTurnFromHistory(next));
|
|
1358
|
+
}
|
|
1248
1359
|
if (event.eventType === "system.message") {
|
|
1249
1360
|
if (shouldRenderSystemMessageAsActivity(event)) {
|
|
1250
1361
|
const activityItem = formatActivity(event);
|
package/ui/core/src/selectors.js
CHANGED
|
@@ -457,17 +457,65 @@ export function manualOrderContainerKey(session, pinned = false) {
|
|
|
457
457
|
* The one owner-badge descriptor both lists render from. Same person ⇒ same
|
|
458
458
|
* initials and same colour, wherever they appear.
|
|
459
459
|
*/
|
|
460
|
-
export function ownerBadgeFor(owner, { isMine = false } = {}) {
|
|
460
|
+
export function ownerBadgeFor(owner, { isMine = false, hueByKey = null } = {}) {
|
|
461
461
|
if (!owner) return null;
|
|
462
462
|
const name = ownerDisplayName(owner, owner?.email || "");
|
|
463
|
+
const key = ownerBadgeKey(owner, owner?.email);
|
|
463
464
|
return {
|
|
464
465
|
initials: ownerInitials(owner),
|
|
465
|
-
|
|
466
|
+
// The list-wide assignment when the caller has one (no two people in
|
|
467
|
+
// the same list share a colour); the bare hash otherwise.
|
|
468
|
+
hue: hueByKey?.get?.(key) ?? ownerBadgeHue(key),
|
|
466
469
|
name: name || "unknown user",
|
|
467
470
|
isMine: Boolean(isMine),
|
|
468
471
|
};
|
|
469
472
|
}
|
|
470
473
|
|
|
474
|
+
/**
|
|
475
|
+
* One colour per PERSON in a list, no two alike while the palette lasts.
|
|
476
|
+
*
|
|
477
|
+
* The hash alone puts different people on the same colour far too often
|
|
478
|
+
* (three people, twelve colours: one collision in four), and a badge whose
|
|
479
|
+
* colour does not tell people apart is not doing its job. So: everyone
|
|
480
|
+
* gets their hash hue first; anyone landing on a taken hue walks to the next
|
|
481
|
+
* free one. Walk order is the sorted identity key, so the outcome depends
|
|
482
|
+
* only on WHO is in the list — the same people get the same colours across
|
|
483
|
+
* reloads and in both panes, and a newcomer only ever takes a free colour.
|
|
484
|
+
*/
|
|
485
|
+
export function assignOwnerBadgeHues(keys) {
|
|
486
|
+
const sorted = [...new Set([...keys].map((k) => String(k || "").trim().toLowerCase()).filter(Boolean))].sort();
|
|
487
|
+
const taken = new Set();
|
|
488
|
+
const out = new Map();
|
|
489
|
+
for (const key of sorted) {
|
|
490
|
+
let hue = ownerBadgeHue(key);
|
|
491
|
+
if (taken.size < OWNER_BADGE_HUES) {
|
|
492
|
+
let steps = 0;
|
|
493
|
+
while (taken.has(hue) && steps < OWNER_BADGE_HUES) { hue = (hue + 1) % OWNER_BADGE_HUES; steps += 1; }
|
|
494
|
+
}
|
|
495
|
+
taken.add(hue);
|
|
496
|
+
out.set(key, hue);
|
|
497
|
+
}
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// The assignment for every human owner in the session catalog, memoised on
|
|
502
|
+
// the catalog's identity: the row selector runs per row and per dispatch.
|
|
503
|
+
let ownerHueMapCache = null;
|
|
504
|
+
export function ownerHueMapForSessions(byId) {
|
|
505
|
+
if (ownerHueMapCache && ownerHueMapCache.byId === byId) return ownerHueMapCache.map;
|
|
506
|
+
const keys = [];
|
|
507
|
+
for (const session of Object.values(byId || {})) {
|
|
508
|
+
if (!session || session.isSystem || session.isGroup) continue;
|
|
509
|
+
const owner = session.owner;
|
|
510
|
+
const ownerKey = ownerKeyForOwner(owner);
|
|
511
|
+
if (!ownerKey || ownerKey === SYSTEM_OWNER_KEY) continue;
|
|
512
|
+
keys.push(ownerBadgeKey(owner, owner?.email));
|
|
513
|
+
}
|
|
514
|
+
const map = assignOwnerBadgeHues(keys);
|
|
515
|
+
ownerHueMapCache = { byId, map };
|
|
516
|
+
return map;
|
|
517
|
+
}
|
|
518
|
+
|
|
471
519
|
function ownerDirectoryFromSessions(byId) {
|
|
472
520
|
const directory = new Map();
|
|
473
521
|
for (const session of Object.values(byId || {})) {
|
|
@@ -507,11 +555,11 @@ function resolvePackageOwner(pkg, directory) {
|
|
|
507
555
|
* the badge is telling owners APART at a glance, which two-letter initials
|
|
508
556
|
* alone do poorly (and did not do at all while they were all "?").
|
|
509
557
|
*
|
|
510
|
-
*
|
|
558
|
+
* Twelve hues, all legible on both the light and dark portal grounds; the
|
|
511
559
|
* viewer's own rows are styled separately by the caller, so this palette
|
|
512
560
|
* never has to encode "mine".
|
|
513
561
|
*/
|
|
514
|
-
const OWNER_BADGE_HUES =
|
|
562
|
+
export const OWNER_BADGE_HUES = 12;
|
|
515
563
|
|
|
516
564
|
export function ownerBadgeHue(key) {
|
|
517
565
|
const text = String(key || "").trim().toLowerCase();
|
|
@@ -1358,6 +1406,7 @@ export function selectSessionRows(state) {
|
|
|
1358
1406
|
? ownerBadgeFor(effectiveSessionOwner(session, state.sessions.byId), {
|
|
1359
1407
|
isMine: Boolean(ownerKeyForOwner(state?.auth?.principal)
|
|
1360
1408
|
&& ownerKeyForOwner(effectiveSessionOwner(session, state.sessions.byId)) === ownerKeyForOwner(state?.auth?.principal)),
|
|
1409
|
+
hueByKey: ownerHueMapForSessions(state.sessions.byId),
|
|
1361
1410
|
})
|
|
1362
1411
|
: null,
|
|
1363
1412
|
orderable: isManuallyOrderableSession(session),
|
|
@@ -1904,7 +1953,11 @@ function describeChatMessageHeader(message, options = {}) {
|
|
|
1904
1953
|
const sharedContext = options?.sharedContext === true;
|
|
1905
1954
|
const isUser = message?.role === "user";
|
|
1906
1955
|
const isSelf = isUser && senderKey && viewerKey && senderKey === viewerKey;
|
|
1907
|
-
|
|
1956
|
+
// A user-stamped message whose key is not the viewer's IS from someone
|
|
1957
|
+
// else, whatever the session's visibility says: a targeted share leaves
|
|
1958
|
+
// the session "private" but still puts another person's messages in the
|
|
1959
|
+
// transcript. Naming them never needs the shared-context gate.
|
|
1960
|
+
const fromOtherPerson = isUser && Boolean(senderKey) && Boolean(viewerKey) && !isSelf;
|
|
1908
1961
|
// Owner tag: the sender is the session owner (or, for a sender-less own
|
|
1909
1962
|
// message, the viewer themselves is the owner).
|
|
1910
1963
|
const senderIsOwner = sharedContext && isUser && ownerKey && (
|
|
@@ -2427,7 +2480,22 @@ function splashArtWidth(text) {
|
|
|
2427
2480
|
.reduce((widest, line) => Math.max(widest, stripTerminalMarkupTags(line).length), 0);
|
|
2428
2481
|
}
|
|
2429
2482
|
|
|
2483
|
+
// Immutable history items survive live ticks. Do not re-parse thousands of
|
|
2484
|
+
// committed lines because the last message gained a handful of characters.
|
|
2485
|
+
const chatMessageLinesCache = new WeakMap();
|
|
2430
2486
|
function buildChatMessageLines(message, maxWidth, options = {}) {
|
|
2487
|
+
if (!message || typeof message !== "object") return buildChatMessageLinesUncached(message, maxWidth, options);
|
|
2488
|
+
const key = JSON.stringify([maxWidth, options]);
|
|
2489
|
+
let entries = chatMessageLinesCache.get(message);
|
|
2490
|
+
if (entries?.has(key)) return entries.get(key);
|
|
2491
|
+
const lines = buildChatMessageLinesUncached(message, maxWidth, options);
|
|
2492
|
+
if (!entries) { entries = new Map(); chatMessageLinesCache.set(message, entries); }
|
|
2493
|
+
if (entries.size >= 4) entries.delete(entries.keys().next().value);
|
|
2494
|
+
entries.set(key, lines);
|
|
2495
|
+
return lines;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
function buildChatMessageLinesUncached(message, maxWidth, options = {}) {
|
|
2431
2499
|
if (message?.splash) {
|
|
2432
2500
|
// Swap in the narrow-viewport variant when the main art would
|
|
2433
2501
|
// overflow the pane (mobile portal, narrow terminals). When the
|
|
@@ -2447,6 +2515,66 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
|
|
|
2447
2515
|
return buildThinkingCardLines(message, maxWidth);
|
|
2448
2516
|
}
|
|
2449
2517
|
|
|
2518
|
+
// The browser gives an in-flight assistant answer its own stable surface.
|
|
2519
|
+
// Previously it was flattened into the ordinary transcript on every
|
|
2520
|
+
// snapshot. As partial markdown changed shape (plain lines -> table/code
|
|
2521
|
+
// block), React could replace several adjacent nodes and the whole answer
|
|
2522
|
+
// appeared to flash. One keyed card keeps the outer DOM and its shading in
|
|
2523
|
+
// place while only the body grows. The TUI deliberately keeps its existing
|
|
2524
|
+
// line-oriented rendering.
|
|
2525
|
+
if ((message?.liveTurn || message?.streamSettling) && options.tableMode === "sentinel") {
|
|
2526
|
+
const isLive = message?.liveTurn === true;
|
|
2527
|
+
const reasoningText = String(message?.reasoningText || message?.liveReasoningText || "");
|
|
2528
|
+
const cardLines = buildMessageCardLines({
|
|
2529
|
+
title: isLive
|
|
2530
|
+
? (message?.text ? "Agent is responding" : "Agent is thinking")
|
|
2531
|
+
: "Agent responded",
|
|
2532
|
+
body: decorateArtifactLinksForChat(message?.text || ""),
|
|
2533
|
+
width: Math.max(20, maxWidth),
|
|
2534
|
+
titleColor: "cyan",
|
|
2535
|
+
borderColor: "cyan",
|
|
2536
|
+
tableMode: "sentinel",
|
|
2537
|
+
});
|
|
2538
|
+
const cardEndIndex = cardLines.findIndex((line) => line?.kind === "cardEnd");
|
|
2539
|
+
if (cardEndIndex > 0) {
|
|
2540
|
+
const bodyLines = cardLines.slice(1, cardEndIndex);
|
|
2541
|
+
if (reasoningText) {
|
|
2542
|
+
bodyLines.unshift({
|
|
2543
|
+
...buildSystemNoticeLine({
|
|
2544
|
+
title: isLive ? "Thinking" : "Thought",
|
|
2545
|
+
summary: isLive ? "In progress" : "Completed",
|
|
2546
|
+
body: reasoningText,
|
|
2547
|
+
color: "gray",
|
|
2548
|
+
}),
|
|
2549
|
+
liveReasoning: isLive,
|
|
2550
|
+
streamReasoning: true,
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2553
|
+
// Keep the progress marker out of markdown syntax: appending it
|
|
2554
|
+
// to a closing fence or an empty row can change parsing/height.
|
|
2555
|
+
if (isLive) cardLines[0].runs.push({ text: " ▍", color: "cyan", role: "streamingCaret" });
|
|
2556
|
+
if (isLive && message.truncated) bodyLines.push([{ text: "Preview paused — the full answer will appear when complete.", color: "gray" }]);
|
|
2557
|
+
const liveKey = message?.liveKey || message?.messageId || message?.id || "active-turn";
|
|
2558
|
+
return [
|
|
2559
|
+
{
|
|
2560
|
+
...cardLines[0],
|
|
2561
|
+
cardVariant: isLive ? "live" : "settling",
|
|
2562
|
+
cardKey: `live:${liveKey}`,
|
|
2563
|
+
liveStartedAt: message?.liveStartedAt || message?.createdAt || Date.now(),
|
|
2564
|
+
settleDelayMs: Number(message?.streamSettleDelayMs) || 0,
|
|
2565
|
+
settleAt: Number(message?.streamSettleAt) || null,
|
|
2566
|
+
},
|
|
2567
|
+
...bodyLines,
|
|
2568
|
+
...cardLines.slice(cardEndIndex),
|
|
2569
|
+
];
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
if (message?.liveTurn && options.tableMode !== "sentinel" && message.reasoningText) {
|
|
2574
|
+
const reasoning = buildThinkingCardLines({ ...message, text: message.reasoningText }, maxWidth);
|
|
2575
|
+
return message.text ? [...reasoning, ...buildChatMessageLines({ ...message, reasoningText: "" }, maxWidth, options)] : reasoning;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2450
2578
|
// Chrome-less message: render the text as plain markdown directly into
|
|
2451
2579
|
// the chat pane (no card border, no speaker prefix, no timestamp). Used
|
|
2452
2580
|
// for the session summary view so the summary feels like the pane's
|
|
@@ -2589,6 +2717,7 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
|
|
|
2589
2717
|
|
|
2590
2718
|
if (startsWithStructuredBlock(tintedMarkdownLines)) {
|
|
2591
2719
|
const structured = prefix.length > 0 ? [prefix, ...tintedMarkdownLines] : tintedMarkdownLines;
|
|
2720
|
+
if (message?.streaming) appendStreamingCaret(structured);
|
|
2592
2721
|
return attachmentChipLines.length > 0 ? [...structured, ...attachmentChipLines] : structured;
|
|
2593
2722
|
}
|
|
2594
2723
|
|
|
@@ -2610,10 +2739,23 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
|
|
|
2610
2739
|
renderedLines.push(lineRuns);
|
|
2611
2740
|
}
|
|
2612
2741
|
}
|
|
2742
|
+
if (message?.streaming) {
|
|
2743
|
+
appendStreamingCaret(renderedLines);
|
|
2744
|
+
}
|
|
2613
2745
|
renderedLines.push(...attachmentChipLines);
|
|
2614
2746
|
return renderedLines;
|
|
2615
2747
|
}
|
|
2616
2748
|
|
|
2749
|
+
function appendStreamingCaret(lines) {
|
|
2750
|
+
const lastRuns = [...lines].reverse().find((line) => Array.isArray(line)
|
|
2751
|
+
&& line.some((run) => String(run?.text || "").trim()));
|
|
2752
|
+
if (lastRuns) {
|
|
2753
|
+
lastRuns.push({ text: " ▍", color: "cyan", role: "streamingCaret" });
|
|
2754
|
+
} else {
|
|
2755
|
+
lines.push([{ text: "▍", color: "cyan", role: "streamingCaret" }]);
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2617
2759
|
// Image attachment chips under a message. In sentinel mode (browser portal)
|
|
2618
2760
|
// a single structured line carries the refs so the web renderer can show
|
|
2619
2761
|
// authenticated thumbnails; everywhere else (TUI) each file renders as a
|
|
@@ -2730,6 +2872,10 @@ function liveActivityPhaseText(item) {
|
|
|
2730
2872
|
case "assistant.intent": return "planning\u2026";
|
|
2731
2873
|
case "assistant.message_start":
|
|
2732
2874
|
case "assistant.message": return "writing reply\u2026";
|
|
2875
|
+
case "model.call_start": {
|
|
2876
|
+
const model = String(item?.text || "").replace(/^.*\bcalling\s+/i, "").trim();
|
|
2877
|
+
return `calling ${model || "model"}\u2026`;
|
|
2878
|
+
}
|
|
2733
2879
|
case "session.compaction_start": return "compacting context\u2026";
|
|
2734
2880
|
case "session.compaction_complete": return "context compacted";
|
|
2735
2881
|
case "session.dehydrated": return "dehydrating\u2026";
|
|
@@ -2891,12 +3037,19 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
|
|
|
2891
3037
|
const activeSessionId = state?.sessions?.activeSessionId;
|
|
2892
3038
|
const activeSession = activeSessionId ? state?.sessions?.byId?.[activeSessionId] : null;
|
|
2893
3039
|
const ownerKey = ownerKeyForOwner(activeSession?.owner);
|
|
2894
|
-
// A shared context = the session is shared deployment-wide,
|
|
2895
|
-
//
|
|
2896
|
-
//
|
|
3040
|
+
// A shared context = the session is shared deployment-wide, the viewer is
|
|
3041
|
+
// not its owner (someone shared it with them), or someone other than the
|
|
3042
|
+
// viewer has written to it (the owner shared it with a writer; the session
|
|
3043
|
+
// row still says "private" — targeted shares are not on the row, but the
|
|
3044
|
+
// other person's stamped messages are in the transcript). Only then does
|
|
3045
|
+
// the transcript name speakers and tag the owner.
|
|
2897
3046
|
const sharedContext = Boolean(activeSession && (
|
|
2898
3047
|
(activeSession.visibility && activeSession.visibility !== "private")
|
|
2899
3048
|
|| (ownerKey && viewerKey && ownerKey !== viewerKey)
|
|
3049
|
+
|| (viewerKey && messages.some((message) => message?.role === "user"
|
|
3050
|
+
&& message?.sender?.kind === "user"
|
|
3051
|
+
&& ownerKeyForOwner(message.sender)
|
|
3052
|
+
&& ownerKeyForOwner(message.sender) !== viewerKey))
|
|
2900
3053
|
));
|
|
2901
3054
|
const buildOptions = {
|
|
2902
3055
|
...(options?.tableMode ? { tableMode: options.tableMode } : {}),
|
|
@@ -4165,6 +4318,8 @@ export function selectAdminConsole(state) {
|
|
|
4165
4318
|
const canEditPackage = (pkg) => ownsPackage(pkg) || Boolean(pkg?.canEdit);
|
|
4166
4319
|
// Built once per render, not per row.
|
|
4167
4320
|
const ownerDirectory = ownerDirectoryFromSessions(state?.sessions?.byId);
|
|
4321
|
+
// Same assignment as the session list, so the same person is the same colour in both panes.
|
|
4322
|
+
const packageHueByKey = ownerHueMapForSessions(state?.sessions?.byId);
|
|
4168
4323
|
// Same rule as the session list: the owner chip only earns its place when
|
|
4169
4324
|
// this list actually holds more than one owner. Shared packages belong to
|
|
4170
4325
|
// the deployment, not a person, so they neither carry a chip nor count
|
|
@@ -4212,7 +4367,7 @@ export function selectAdminConsole(state) {
|
|
|
4212
4367
|
// PERSON's, matching the session list exactly.
|
|
4213
4368
|
ownerBadge: (pkg.scope === "shared" || !decoratePackageOwners)
|
|
4214
4369
|
? null
|
|
4215
|
-
: ownerBadgeFor(resolvePackageOwner(pkg, ownerDirectory)),
|
|
4370
|
+
: ownerBadgeFor(resolvePackageOwner(pkg, ownerDirectory), { hueByKey: packageHueByKey }),
|
|
4216
4371
|
// Highlight only while the Agents section is active — otherwise the
|
|
4217
4372
|
// GitHub Keys screen shows a double selection.
|
|
4218
4373
|
selected: rowMatchesSelection(pkg),
|