pilotswarm 0.5.36 → 0.5.38

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.
Files changed (86) hide show
  1. package/package.json +3 -3
  2. package/tui/src/app.js +0 -4
  3. package/tui/src/index.js +0 -9
  4. package/ui/core/src/canvas-actions.js +148 -0
  5. package/ui/core/src/commands.js +0 -1
  6. package/ui/core/src/controller.js +296 -21
  7. package/ui/core/src/history.js +134 -1
  8. package/ui/core/src/index.js +1 -0
  9. package/ui/core/src/reducer.js +407 -19
  10. package/ui/core/src/selectors.js +223 -154
  11. package/ui/core/src/state.js +139 -2
  12. package/ui/core/src/themes/index.js +1 -1
  13. package/ui/react/src/components.js +1 -7
  14. package/ui/react/src/web-app.js +1719 -366
  15. package/web/auth/config.js +3 -0
  16. package/web/auth/index.js +8 -1
  17. package/web/auth/normalize/proxy.js +48 -0
  18. package/web/auth/providers/proxy.js +141 -0
  19. package/web/dist/assets/{abnfDiagram-VRR7QNED-LOLCtsns.js → abnfDiagram-VRR7QNED-DrJhXhSQ.js} +1 -1
  20. package/web/dist/assets/{arc-BE-jVDEi.js → arc-BEaG9DWd.js} +1 -1
  21. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CdmUjwtj.js → architectureDiagram-ZJ3FMSHR-D9fc1Imx.js} +1 -1
  22. package/web/dist/assets/{blockDiagram-677ZJIJ3-NZ4TOa8L.js → blockDiagram-677ZJIJ3-rS5Eikvt.js} +1 -1
  23. package/web/dist/assets/{c4Diagram-LMCZKHZV-9ubX8uGA.js → c4Diagram-LMCZKHZV-HKLlX5cR.js} +1 -1
  24. package/web/dist/assets/channel-DRy7FvyF.js +1 -0
  25. package/web/dist/assets/{chunk-2Q5K7J3B-DNDnjbP6.js → chunk-2Q5K7J3B-kq0TyLSB.js} +1 -1
  26. package/web/dist/assets/{chunk-32BRIVSS-iilfiffr.js → chunk-32BRIVSS-O-LMarT5.js} +1 -1
  27. package/web/dist/assets/{chunk-5VM5RSS4-BakOMFmf.js → chunk-5VM5RSS4-Di4h0CAu.js} +1 -1
  28. package/web/dist/assets/{chunk-EX3LRPZG-BhluhGyZ.js → chunk-EX3LRPZG-C8rgqUOt.js} +1 -1
  29. package/web/dist/assets/{chunk-JWPE2WC7-B6GKLlNb.js → chunk-JWPE2WC7-BSH41q4T.js} +1 -1
  30. package/web/dist/assets/{chunk-MOJQB5TN-CXmt8msF.js → chunk-MOJQB5TN-BAFuQHAC.js} +1 -1
  31. package/web/dist/assets/{chunk-RYQCIY6F-Bu58-SDr.js → chunk-RYQCIY6F-DAg6JOs7.js} +1 -1
  32. package/web/dist/assets/{chunk-V7JOEXUC-AQdyoYHL.js → chunk-V7JOEXUC-DbVrT8BG.js} +1 -1
  33. package/web/dist/assets/{chunk-VR4S4FIN-DeM5iMsE.js → chunk-VR4S4FIN-C6f5_Grp.js} +1 -1
  34. package/web/dist/assets/{chunk-XXDRQBXY-DjGjajy1.js → chunk-XXDRQBXY-CqcCYpqA.js} +1 -1
  35. package/web/dist/assets/classDiagram-OUVF2IWQ-C35RhB8X.js +1 -0
  36. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C35RhB8X.js +1 -0
  37. package/web/dist/assets/{cose-bilkent-JH36ORCC-BGi4_egJ.js → cose-bilkent-JH36ORCC-DJw04eFh.js} +1 -1
  38. package/web/dist/assets/{cynefin-VYW2F7L2-CcgV1ve4.js → cynefin-VYW2F7L2-CpjwdEIU.js} +1 -1
  39. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BRDEsbBG.js → cynefinDiagram-TSTJHNR4-RqEIWoT0.js} +1 -1
  40. package/web/dist/assets/{dagre-VKFMJZFB-OjuC8dJB.js → dagre-VKFMJZFB-C1eGQNK2.js} +1 -1
  41. package/web/dist/assets/{diagram-FQU43EPY-CGdROWYh.js → diagram-FQU43EPY-BODLQ7dz.js} +1 -1
  42. package/web/dist/assets/{diagram-G47NLZAW-B5J0EUfS.js → diagram-G47NLZAW-DlhmukMC.js} +1 -1
  43. package/web/dist/assets/{diagram-NH7WQ7WH-zmb4-cP2.js → diagram-NH7WQ7WH-Ba_jZoCN.js} +1 -1
  44. package/web/dist/assets/{diagram-OA4YK3LP-j514bgaH.js → diagram-OA4YK3LP-DnSiAJBG.js} +1 -1
  45. package/web/dist/assets/{diagram-WEI45ONY-h5G459mZ.js → diagram-WEI45ONY-B3KoQ7_W.js} +1 -1
  46. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-GnMgbuFk.js → ebnfDiagram-CCIWWBDH-ChnFc5gf.js} +1 -1
  47. package/web/dist/assets/{erDiagram-Q63AITRT-BFgH3UQp.js → erDiagram-Q63AITRT-D2KkJXLZ.js} +1 -1
  48. package/web/dist/assets/{flowDiagram-23GEKE2U-BsbQ_VXV.js → flowDiagram-23GEKE2U-CmybZufj.js} +1 -1
  49. package/web/dist/assets/{ganttDiagram-NO4QXBWP-C_fjo2sf.js → ganttDiagram-NO4QXBWP-BzPurW_v.js} +1 -1
  50. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BUoXrAI9.js → gitGraphDiagram-IHSO6WYX-DAbXl4gO.js} +1 -1
  51. package/web/dist/assets/{index-ONJimzcx.js → index-CRY94nFp.js} +6 -6
  52. package/web/dist/assets/index-DtL0lbis.css +1 -0
  53. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CJiSANyk.js → infoDiagram-FWYZ7A6U-C6qGjNA1.js} +1 -1
  54. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-BKoQ-a2c.js → ishikawaDiagram-FXEZZL3T-D2d1tO-9.js} +1 -1
  55. package/web/dist/assets/{journeyDiagram-5HDEW3XC-Db6Crcd3.js → journeyDiagram-5HDEW3XC-fP13w1b7.js} +1 -1
  56. package/web/dist/assets/{kanban-definition-HUTT4EX6-B9GaB0UJ.js → kanban-definition-HUTT4EX6-BJfWAXZ0.js} +1 -1
  57. package/web/dist/assets/{linear-BXwRb8MN.js → linear-CltApkHf.js} +1 -1
  58. package/web/dist/assets/{mermaid.core-DwzXN6up.js → mermaid.core-B1Dwetk3.js} +4 -4
  59. package/web/dist/assets/{mindmap-definition-LN4V7U3C-DKC_C_OY.js → mindmap-definition-LN4V7U3C-CdRUD7LF.js} +1 -1
  60. package/web/dist/assets/{papaparse.min-Cm1Pr54l.js → papaparse.min-C1y3wBH1.js} +1 -1
  61. package/web/dist/assets/{pegDiagram-2B236MQR-IeawbZPt.js → pegDiagram-2B236MQR-CeQkqN6Q.js} +1 -1
  62. package/web/dist/assets/{pieDiagram-ENE6RG2P-BOdsYBt8.js → pieDiagram-ENE6RG2P-Dqgc87jA.js} +1 -1
  63. package/web/dist/assets/pilotswarm-DayYdi39.js +96 -0
  64. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-Cr5yoN7C.js → quadrantDiagram-ABIIQ3AL-CD7f-w6m.js} +1 -1
  65. package/web/dist/assets/{railroadDiagram-RFXS5EU6-E7wuFBuk.js → railroadDiagram-RFXS5EU6-CcsAijSC.js} +1 -1
  66. package/web/dist/assets/{requirementDiagram-TGXJPOKE-sZCKiuDs.js → requirementDiagram-TGXJPOKE-BkzF80pL.js} +1 -1
  67. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-B_EWziUR.js → sankeyDiagram-HTMAVEWB-D9XOFw7Y.js} +1 -1
  68. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-D2mN7onX.js → sequenceDiagram-DBY2YBRQ-BFfaS_Sz.js} +1 -1
  69. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CEL9Ly2T.js → sizeCapture-X5ZJPWSS-CDJV16tP.js} +1 -1
  70. package/web/dist/assets/{stateDiagram-2N3HPSRC-C7ejJoeN.js → stateDiagram-2N3HPSRC-C0E-MPXO.js} +1 -1
  71. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BMqXeMJu.js +1 -0
  72. package/web/dist/assets/{swimlanes-5IMT3BWC-Ck3pbFxk.js → swimlanes-5IMT3BWC-n5Vsv7n9.js} +2 -2
  73. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CGl4gRnH.js +8 -0
  74. package/web/dist/assets/{timeline-definition-FHXFAJF6-K3H8Yuyr.js → timeline-definition-FHXFAJF6-DW2gV8-n.js} +1 -1
  75. package/web/dist/assets/{vennDiagram-L72KCM5P-C3NKjSv9.js → vennDiagram-L72KCM5P-D9dbDm08.js} +1 -1
  76. package/web/dist/assets/{wardleyDiagram-EHGQE667-CeeVuFiO.js → wardleyDiagram-EHGQE667-CIcG7fiC.js} +1 -1
  77. package/web/dist/assets/{xychartDiagram-FW5EYKEG-CF-vCvEq.js → xychartDiagram-FW5EYKEG-elEIFsc9.js} +1 -1
  78. package/web/dist/index.html +4 -4
  79. package/web/runtime.js +20 -1
  80. package/web/dist/assets/channel-BhHZnKdH.js +0 -1
  81. package/web/dist/assets/classDiagram-OUVF2IWQ-CM_bKBKz.js +0 -1
  82. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CM_bKBKz.js +0 -1
  83. package/web/dist/assets/index-CnH1Yv_d.css +0 -1
  84. package/web/dist/assets/pilotswarm-kCQFVjJm.js +0 -99
  85. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-UaVDt_gU.js +0 -1
  86. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CqO_fF5t.js +0 -8
@@ -1,4 +1,5 @@
1
1
  import { INSPECTOR_TABS, FOCUS_REGIONS } from "./commands.js";
2
+ import { canvasKey as canvasSlotKey, parseCanvasKey } from "./state.js";
2
3
  import { isManuallyOrderableSession } from "./session-tree.js";
3
4
  import {
4
5
  createSplashCard,
@@ -106,11 +107,6 @@ function sessionStatusLabelForVisualStatus(session, status) {
106
107
  return status;
107
108
  }
108
109
 
109
- function getSessionSummaryStatusLabel(session) {
110
- const status = getSessionVisualStatus(session);
111
- if (!status || status === "unknown") return "";
112
- return sessionStatusLabelForVisualStatus(session, status);
113
- }
114
110
 
115
111
  /**
116
112
  * The label + timestamp the session-detail box shows, as ONE derivation shared
@@ -1130,6 +1126,10 @@ export function selectSessionRows(state) {
1130
1126
  // built under one branding to a caller using another, and the root
1131
1127
  // session's name would flip between them.
1132
1128
  state.branding?.title,
1129
+ // The canvas marker below reads these. Without them the memo hands
1130
+ // back a row built before the draw and the marker never appears.
1131
+ state.canvas?.bySessionId?.[entry.sessionId],
1132
+ state.canvas?.prefs?.[entry.sessionId],
1133
1133
  ];
1134
1134
  const cached = memo.get(entry.sessionId);
1135
1135
  if (cached && sameRowDeps(cached.deps, deps)) return cached.row;
@@ -1165,6 +1165,15 @@ export function selectSessionRows(state) {
1165
1165
  : null,
1166
1166
  orderable: isManuallyOrderableSession(session),
1167
1167
  orderContainer: manualOrderContainerKey(session, pinned),
1168
+ // Canvas state for THIS row. The toolbar badge answers the same
1169
+ // question for the active session only; the list has to answer it
1170
+ // for every session at once, which is the point — a canvas drawn
1171
+ // in a session you are not looking at is otherwise invisible.
1172
+ //
1173
+ // null no canvas, or one that was cleared
1174
+ // "canvas" a canvas exists and nothing is new
1175
+ // "unseen" it changed since this user last looked at it
1176
+ canvasMark: sessionCanvasMark(state, entry.sessionId),
1168
1177
  };
1169
1178
  memo.set(entry.sessionId, { deps, row });
1170
1179
  return row;
@@ -1176,7 +1185,7 @@ export function selectSessionRows(state) {
1176
1185
  const ownerSearchText = effectiveOwner
1177
1186
  ? `${ownerDisplayName(effectiveOwner, "")} ${effectiveOwner.email || ""}`
1178
1187
  : "";
1179
- const summarySearchText = `${session?.shortSummary || ""} ${session?.summaryState?.intent || ""} ${session?.summaryState?.summary || ""}`;
1188
+ const summarySearchText = "";
1180
1189
  return matchesSearchQuery(row.text, query)
1181
1190
  || matchesSearchQuery(row.sessionId, query)
1182
1191
  || matchesSearchQuery(ownerSearchText, query)
@@ -1557,7 +1566,7 @@ export function selectActiveChat(state) {
1557
1566
  .replace(/[\r\n]+/g, " ")
1558
1567
  .trim() || " ";
1559
1568
  const memberRows = memberSessions.map((candidate) => (
1560
- `| ${markdownCell(candidate.title || candidate.sessionId)} | ${markdownCell(candidate.status || "unknown")} | ${markdownCell(candidate.shortSummary || "")} |`
1569
+ `| ${markdownCell(candidate.title || candidate.sessionId)} | ${markdownCell(candidate.status || "unknown")} |`
1561
1570
  ));
1562
1571
  const lines = [
1563
1572
  `# ${session.title || session.groupId}`,
@@ -1575,8 +1584,8 @@ export function selectActiveChat(state) {
1575
1584
  "## Members",
1576
1585
  ...(memberRows.length > 0
1577
1586
  ? [
1578
- "| Session | Status | Summary |",
1579
- "|---|---|---|",
1587
+ "| Session | Status |",
1588
+ "|---|---|",
1580
1589
  ...memberRows,
1581
1590
  ]
1582
1591
  : ["_No top-level members._"]),
@@ -1589,9 +1598,6 @@ export function selectActiveChat(state) {
1589
1598
  createdAt: session.updatedAt || Date.now(),
1590
1599
  }];
1591
1600
  }
1592
- if (state.ui?.chatViewMode === "summary") {
1593
- return buildSessionSummaryCards(session);
1594
- }
1595
1601
  const history = state.history.bySessionId.get(sessionId);
1596
1602
  const chat = history?.chat || [];
1597
1603
  const pendingQuestionMessage = session?.pendingQuestion?.question
@@ -1618,123 +1624,6 @@ export function selectActiveChat(state) {
1618
1624
  return messages;
1619
1625
  }
1620
1626
 
1621
- function buildSessionSummaryCards(session) {
1622
- const normalizeSummaryMarkdownText = (value) => {
1623
- const source = String(value || "");
1624
- if (!source.includes("\\n")) return source;
1625
- return source
1626
- .replace(/\\\\n/g, "\n")
1627
- .replace(/\\n/g, "\n");
1628
- };
1629
-
1630
- const summaryState = session?.summaryState && typeof session.summaryState === "object" && !Array.isArray(session.summaryState)
1631
- ? session.summaryState
1632
- : null;
1633
- const stateJson = summaryState?.state && typeof summaryState.state === "object" && !Array.isArray(summaryState.state)
1634
- ? summaryState.state
1635
- : null;
1636
- const title = buildSessionDisplayTitle(session) || session?.title || session?.sessionId || "Session";
1637
- const lines = [];
1638
- lines.push(`# ${title}`);
1639
- lines.push("");
1640
-
1641
- // Compact metadata header — status / intent / updated on their own
1642
- // lines, each as a bold-labeled value. Skipped when empty.
1643
- const metaPairs = [];
1644
- const statusLabel = getSessionSummaryStatusLabel(session);
1645
- if (statusLabel) metaPairs.push(["Status", statusLabel]);
1646
- if (summaryState?.intent) metaPairs.push(["Intent", String(summaryState.intent)]);
1647
- if (session?.summaryUpdatedAt) {
1648
- const ts = formatTimestamp(session.summaryUpdatedAt);
1649
- if (ts) metaPairs.push(["Updated", ts]);
1650
- }
1651
- for (const [label, value] of metaPairs) {
1652
- lines.push(`**${label}:** ${value}`);
1653
- }
1654
- if (metaPairs.length > 0) lines.push("");
1655
-
1656
- // Free-form summary paragraph (uses the LLM-authored summary if present,
1657
- // otherwise the short summary the CLI rolls up).
1658
- const narrative = normalizeSummaryMarkdownText(summaryState?.summary || session?.shortSummary || "").trim();
1659
- if (narrative) {
1660
- lines.push(narrative);
1661
- lines.push("");
1662
- }
1663
-
1664
- // Structured state — each key:value renders as a bold-labeled bullet.
1665
- // Lists/objects are rendered as nested bullets so arrays of "progress"
1666
- // steps don't appear as JSON blobs.
1667
- if (stateJson) {
1668
- const entries = Object.entries(stateJson)
1669
- .filter(([, value]) => value !== null && value !== undefined && !(typeof value === "string" && !value.trim()));
1670
- if (entries.length > 0) {
1671
- lines.push("## State");
1672
- for (const [key, value] of entries) {
1673
- if (Array.isArray(value)) {
1674
- if (value.length === 0) {
1675
- lines.push(`- **${key}:** _(empty)_`);
1676
- } else {
1677
- lines.push(`- **${key}:**`);
1678
- for (const item of value.slice(0, 16)) {
1679
- const text = typeof item === "string"
1680
- ? normalizeSummaryMarkdownText(item)
1681
- : JSON.stringify(item);
1682
- lines.push(` - ${text}`);
1683
- }
1684
- }
1685
- } else if (value && typeof value === "object") {
1686
- lines.push(`- **${key}:**`);
1687
- for (const [subKey, subValue] of Object.entries(value).slice(0, 16)) {
1688
- const text = typeof subValue === "string"
1689
- ? normalizeSummaryMarkdownText(subValue)
1690
- : JSON.stringify(subValue);
1691
- lines.push(` - **${subKey}:** ${text}`);
1692
- }
1693
- } else {
1694
- lines.push(`- **${key}:** ${normalizeSummaryMarkdownText(String(value))}`);
1695
- }
1696
- }
1697
- lines.push("");
1698
- }
1699
- }
1700
-
1701
- // Structured tail sections. Each section renders only if it actually
1702
- // has items; skipped sections do not occupy vertical space.
1703
- for (const [heading, field] of [["Blockers", "blockers"], ["Open Questions", "openQuestions"], ["Next Actions", "nextActions"]]) {
1704
- const items = Array.isArray(summaryState?.[field]) ? summaryState[field].filter(Boolean).slice(0, 12) : [];
1705
- if (items.length === 0) continue;
1706
- lines.push(`## ${heading}`);
1707
- for (const item of items) {
1708
- lines.push(`- ${typeof item === "string" ? normalizeSummaryMarkdownText(item) : JSON.stringify(item)}`);
1709
- }
1710
- lines.push("");
1711
- }
1712
-
1713
- if (!summaryState && !narrative) {
1714
- lines.push("_No summary has been written yet for this session._");
1715
- }
1716
-
1717
- // Collapse trailing blank lines so the pane doesn't render empty rows
1718
- // at the bottom.
1719
- while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
1720
-
1721
- return [{
1722
- id: `summary:${session?.sessionId || "none"}`,
1723
- role: "system",
1724
- noChrome: true,
1725
- text: lines.join("\n"),
1726
- createdAt: session?.summaryUpdatedAt || session?.updatedAt || Date.now(),
1727
- }];
1728
- }
1729
-
1730
- export function selectActiveOutboxMessages(state) {
1731
- const sessionId = state.sessions.activeSessionId;
1732
- if (!sessionId) return [];
1733
- return Array.isArray(state.outbox?.bySessionId?.[sessionId])
1734
- ? state.outbox.bySessionId[sessionId].map((item) => buildPendingOutboxMessage(sessionId, item)).filter(Boolean)
1735
- : [];
1736
- }
1737
-
1738
1627
  function prefixRuns(text, color = "gray", options = {}) {
1739
1628
  return [{
1740
1629
  text,
@@ -2593,7 +2482,6 @@ function liveActivityPhaseText(item) {
2593
2482
  // so visibility is governed by session status rather than transcript role.
2594
2483
  // `options.spinnerFrame` animates the marker.
2595
2484
  export function selectLiveActivityLines(state, options = {}) {
2596
- if (state?.ui?.chatViewMode === "summary") return [];
2597
2485
  const session = selectActiveSession(state);
2598
2486
  if (!session || session.isGroup) return [];
2599
2487
  const status = String(session?.status || "").toLowerCase();
@@ -2665,7 +2553,7 @@ export function selectLiveActivityLines(state, options = {}) {
2665
2553
  // Keyed on identities rather than fields on purpose: selectActiveChat is NOT a
2666
2554
  // pure function of the chat array — it also appends a pending-question message,
2667
2555
  // an answered-question message and a session-error message, and branches on
2668
- // ui.chatViewMode and branding. Anything derived from `session` therefore has
2556
+ // branding. Anything derived from `session` therefore has
2669
2557
  // to invalidate when `session` does, which identity comparison gives for free.
2670
2558
  //
2671
2559
  // `byId` is in the key for two reasons found by adversarial review, both of
@@ -2695,7 +2583,6 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2695
2583
  session: memoSessionId ? state?.sessions?.byId?.[memoSessionId] ?? null : null,
2696
2584
  byId: state?.sessions?.byId ?? null,
2697
2585
  chat: state?.history?.bySessionId?.get?.(memoSessionId)?.chat ?? null,
2698
- viewMode: state?.ui?.chatViewMode ?? null,
2699
2586
  branding: state?.branding ?? null,
2700
2587
  principal: state?.auth?.principal ?? null,
2701
2588
  maxWidth,
@@ -2754,6 +2641,10 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2754
2641
  lines.push(buildRegenRefusedLine(message, maxWidth));
2755
2642
  } else if (message?.kind === "regen-failed") {
2756
2643
  lines.push(buildRegenFailedLine(message, maxWidth));
2644
+ } else if (message?.kind === "canvas-update") {
2645
+ lines.push(buildCanvasUpdateLine(message, message.sessionId || memoSessionId));
2646
+ } else if (message?.kind === "canvas-action") {
2647
+ lines.push(buildCanvasActionLine(message));
2757
2648
  } else {
2758
2649
  const messageLines = buildChatMessageLines(message, maxWidth, buildOptions);
2759
2650
  appendChatBlockLines(lines, messageLines);
@@ -2770,6 +2661,158 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2770
2661
  return memoize(lines.length > 0 ? lines : [{ text: "No messages yet.", color: "gray" }]);
2771
2662
  }
2772
2663
 
2664
+ /**
2665
+ * The canvas-update transcript line — carried by BOTH hosts, rendered by one.
2666
+ *
2667
+ * The line is flagged `canvasUpdate: true`; the portal's chat renderer skips
2668
+ * flagged lines outright (there, the canvas pane updating is the whole
2669
+ * signal), while the TUI renders it as an ordinary artifact link, press-a
2670
+ * affordance and all — a host that cannot render the canvas needs the link.
2671
+ */
2672
+ /**
2673
+ * Everything the Canvas toggle and pane need about the ACTIVE session's
2674
+ * canvas, in one read: whether one exists, its revision and caption, and
2675
+ * whether the yellow unseen-changes badge should light
2676
+ * (latestRev > lastViewedRev, and only while the canvas is not on screen).
2677
+ */
2678
+ /**
2679
+ * The canvas marker for one session row.
2680
+ *
2681
+ * Same rules selectCanvasView applies to the active session, minus the
2682
+ * on-screen check: a row's marker describes the SESSION, not the layout, so it
2683
+ * does not care which column happens to be open.
2684
+ *
2685
+ * A cleared canvas (draw_canvas("") -> sizeBytes 0) no longer exists. Unknown
2686
+ * size (null, from a degraded snapshot) is presumed drawn — a marker that is
2687
+ * occasionally early beats one that silently never shows.
2688
+ */
2689
+ export function sessionCanvasMark(state, sessionId) {
2690
+ if (!sessionId) return null;
2691
+ // Aggregate across the session's slots: ANY unseen slot lights the dot;
2692
+ // any drawn slot at all marks the row. Slot 1 is the bare id key.
2693
+ let mark = null;
2694
+ for (let slot = 1; slot <= 5; slot += 1) {
2695
+ const key = canvasSlotKey(sessionId, slot);
2696
+ const entry = state?.canvas?.bySessionId?.[key];
2697
+ const prefs = state?.canvas?.prefs?.[key];
2698
+ if (!entry) {
2699
+ // No live state loaded (cold page). canvasPrefs persist per user,
2700
+ // so a slot this user has ever VIEWED still marks — plain, never
2701
+ // "unseen": whether it changed since is unknowable client-side.
2702
+ if ((prefs?.lastViewedRev || 0) > 0) mark = mark || "canvas";
2703
+ continue;
2704
+ }
2705
+ const latestRev = entry.latestRev || 0;
2706
+ if (latestRev <= 0 || entry.sizeBytes === 0) continue;
2707
+ const unseen = latestRev > (prefs?.lastViewedRev || 0)
2708
+ || (entry.latestDataRev || 0) > (prefs?.lastViewedDataRev || 0);
2709
+ if (unseen) return "unseen";
2710
+ mark = "canvas";
2711
+ }
2712
+ return mark;
2713
+ }
2714
+
2715
+ export function selectCanvasView(state) {
2716
+ const sessionId = state?.sessions?.activeSessionId || null;
2717
+ // The pane shows ONE slot at a time. An out-of-range or never-drawn slot
2718
+ // falls back to 1 so a stale selection can never strand the pane.
2719
+ const wantedSlot = Number(state?.ui?.canvasSlot) || 1;
2720
+ const bySessionId = state?.canvas?.bySessionId || {};
2721
+ // Every drawn slot of the active session, for the selector and arrows.
2722
+ const slots = [];
2723
+ if (sessionId) {
2724
+ for (let slot = 1; slot <= 5; slot += 1) {
2725
+ const e = bySessionId[canvasSlotKey(sessionId, slot)];
2726
+ if (!e || (e.latestRev || 0) <= 0 || e.sizeBytes === 0) continue;
2727
+ const p = state?.canvas?.prefs?.[canvasSlotKey(sessionId, slot)];
2728
+ slots.push({
2729
+ slot,
2730
+ name: typeof e.name === "string" ? e.name : "",
2731
+ latestRev: e.latestRev || 0,
2732
+ unseen: (e.latestRev || 0) > (p?.lastViewedRev || 0)
2733
+ || (e.latestDataRev || 0) > (p?.lastViewedDataRev || 0),
2734
+ });
2735
+ }
2736
+ }
2737
+ const slot = slots.some((s2) => s2.slot === wantedSlot) ? wantedSlot : 1;
2738
+ const key = sessionId ? canvasSlotKey(sessionId, slot) : null;
2739
+ const entry = key ? bySessionId[key] : null;
2740
+ const prefs = key ? state?.canvas?.prefs?.[key] : null;
2741
+ const latestRev = entry?.latestRev || 0;
2742
+ const lastViewedRev = prefs?.lastViewedRev || 0;
2743
+ const latestDataRev = entry?.latestDataRev || 0;
2744
+ const lastViewedDataRev = prefs?.lastViewedDataRev || 0;
2745
+ // Read from canvasOpen, not the retired rightPaneMode enum. Diagnostics
2746
+ // moving to its own column means "not canvas" no longer implies "panes",
2747
+ // and this drives the unseen badge, which must light whenever the canvas
2748
+ // is off screen.
2749
+ const mode = state?.ui?.canvasOpen ? "canvas" : "panes";
2750
+ // A cleared canvas (draw_canvas("") → sizeBytes 0) no longer exists for
2751
+ // affordance purposes: no phone tab, no badge, blank teaching state.
2752
+ // Unknown size (null — snapshot degrade) is presumed drawn.
2753
+ const exists = latestRev > 0 && entry?.sizeBytes !== 0;
2754
+ return {
2755
+ sessionId,
2756
+ slot,
2757
+ slots,
2758
+ name: typeof entry?.name === "string" ? entry.name : "",
2759
+ mode,
2760
+ exists,
2761
+ latestRev,
2762
+ note: entry?.note || "",
2763
+ sizeBytes: entry?.sizeBytes ?? null,
2764
+ snapshotLoaded: Boolean(entry?.snapshotLoaded),
2765
+ optedOut: Boolean(prefs?.optedOut),
2766
+ lastViewedRev,
2767
+ flipSeq: state?.canvas?.flipSeq || 0,
2768
+ responseContract: entry?.responseContract || null,
2769
+ latestDataRev,
2770
+ lastViewedDataRev,
2771
+ dataPayload: entry?.dataPayload || null,
2772
+ unseen: exists && (latestRev > lastViewedRev || latestDataRev > lastViewedDataRev) && mode !== "canvas",
2773
+ };
2774
+ }
2775
+
2776
+ /**
2777
+ * A validated canvas response on its way to the agent. Flagged so the PORTAL
2778
+ * chat skips it (the canvas redraw is the visible half of the loop) while the
2779
+ * TUI — which renders no canvas and would otherwise show a silent gap in the
2780
+ * conversation — gets a compact dim line.
2781
+ */
2782
+ function buildCanvasActionLine(message) {
2783
+ const dataKeys = message?.data && typeof message.data === "object" ? Object.keys(message.data) : [];
2784
+ const detail = dataKeys.length === 1 && typeof message.data[dataKeys[0]] === "string"
2785
+ ? message.data[dataKeys[0]].slice(0, 120)
2786
+ : (dataKeys.length ? JSON.stringify(message.data).slice(0, 120) : "");
2787
+ return {
2788
+ runs: [
2789
+ { text: "[canvas] ", color: "cyan", bold: true },
2790
+ { text: `${message.action || "action"}`, color: "white" },
2791
+ ...(detail ? [{ text: ` — ${detail}`, color: "gray" }] : []),
2792
+ ],
2793
+ canvasAction: true,
2794
+ };
2795
+ }
2796
+
2797
+ function buildCanvasUpdateLine(message, sessionId) {
2798
+ const rev = Number(message?.rev) || 0;
2799
+ const note = typeof message?.note === "string" && message.note.trim() ? ` — ${message.note.trim()}` : "";
2800
+ const runs = [
2801
+ { text: "[canvas] ", color: "cyan", bold: true },
2802
+ { text: `rev ${rev}${note} `, color: "white" },
2803
+ ];
2804
+ if (sessionId) {
2805
+ runs.push({
2806
+ text: "[artifact: canvas.html]",
2807
+ color: "cyan",
2808
+ underline: true,
2809
+ href: `artifact://${sessionId}/canvas.html`,
2810
+ });
2811
+ runs.push({ text: " (press a to download)", color: "gray" });
2812
+ }
2813
+ return { runs, canvasUpdate: true };
2814
+ }
2815
+
2773
2816
  function buildRuleLine(label, color, maxWidth) {
2774
2817
  const safeWidth = Math.max(24, Number(maxWidth) || 80);
2775
2818
  const room = safeWidth - label.length;
@@ -2825,6 +2868,14 @@ function buildRegenFailedLine(message, maxWidth) {
2825
2868
  return buildRuleLine(label, "red", maxWidth);
2826
2869
  }
2827
2870
 
2871
+ export function selectActiveOutboxMessages(state) {
2872
+ const sessionId = state.sessions.activeSessionId;
2873
+ if (!sessionId) return [];
2874
+ return Array.isArray(state.outbox?.bySessionId?.[sessionId])
2875
+ ? state.outbox.bySessionId[sessionId].map((item) => buildPendingOutboxMessage(sessionId, item)).filter(Boolean)
2876
+ : [];
2877
+ }
2878
+
2828
2879
  export function selectOutboxOverlayLines(state, maxWidth = 80, options = {}) {
2829
2880
  const messages = selectActiveOutboxMessages(state);
2830
2881
  if (!messages || messages.length === 0) return [];
@@ -2956,9 +3007,7 @@ export function selectChatPaneChrome(state, options = {}) {
2956
3007
  const outboxItems = Array.isArray(state.outbox?.bySessionId?.[session.sessionId])
2957
3008
  ? state.outbox.bySessionId[session.sessionId]
2958
3009
  : [];
2959
- const progress = state.ui?.chatViewMode === "summary"
2960
- ? null
2961
- : buildLiveProgressState(session, history, history?.chat || [], outboxItems);
3010
+ const progress = buildLiveProgressState(session, history, history?.chat || [], outboxItems);
2962
3011
 
2963
3012
  // Border top-right: status · model · context (mirrors the session-row meta),
2964
3013
  // with the live turn-progress label appended while a turn is running.
@@ -4266,11 +4315,9 @@ export function selectStatusBar(state) {
4266
4315
  right: "up/down move · space toggle · ctrl-g group · c cancel · d complete · D hard delete · V/esc exit",
4267
4316
  };
4268
4317
  }
4269
- const chatViewMode = state.ui?.chatViewMode === "summary" ? "summary" : "transcript";
4270
- const chatViewHint = chatViewMode === "summary" ? "s transcript" : "s summary";
4271
4318
  const hints = {
4272
4319
  [FOCUS_REGIONS.SESSIONS]: `up/down switch · ctrl-u/ctrl-d page · ctrl-g move group · f filter · P pin · V select · d done · D delete · r refresh · t title · v visibility · S share · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane · p prompt`,
4273
- [FOCUS_REGIONS.CHAT]: `${chatViewHint} · j/k scroll · ctrl-u/ctrl-d page · e older history · g/G top/bottom · d done · ${fullscreenHint} · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane · p prompt`,
4320
+ [FOCUS_REGIONS.CHAT]: `j/k scroll · ctrl-u/ctrl-d page · e older history · g/G top/bottom · d done · ${fullscreenHint} · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane · p prompt`,
4274
4321
  [FOCUS_REGIONS.INSPECTOR]: state.ui.inspectorTab === "logs"
4275
4322
  ? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · t tail · f filter · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane`
4276
4323
  : state.ui.inspectorTab === "stats"
@@ -4855,13 +4902,8 @@ function buildSequenceStatsLines(state, session, maxWidth) {
4855
4902
  if (!body) body = "orchestration stats unavailable";
4856
4903
  }
4857
4904
 
4858
- if (maxWidth <= 52) {
4859
- return [fitRuns([
4860
- { text: "Stats ", color: "cyan", bold: true },
4861
- { text: body, color: "white" },
4862
- ], Math.max(18, maxWidth))];
4863
- }
4864
-
4905
+ // Narrow panes used to degrade to a single truncated line; a box whose
4906
+ // content wraps reads better on a phone and costs two rows of chrome.
4865
4907
  return buildMessageCardLines({
4866
4908
  title: "Stats",
4867
4909
  body,
@@ -5884,8 +5926,10 @@ export function selectSessionGroupNameModal(state, maxWidth = 76) {
5884
5926
  const value = String(modal.value || "");
5885
5927
  const sessionCount = Array.isArray(modal.sessionIds) ? modal.sessionIds.length : 0;
5886
5928
  const previewTitle = value.trim() || "…";
5929
+ const renaming = modal.mode === "rename";
5887
5930
  return {
5888
- title: modal.title || "New Group",
5931
+ title: modal.title || (renaming ? "Rename Group" : "New Group"),
5932
+ mode: renaming ? "rename" : "create",
5889
5933
  value,
5890
5934
  cursorIndex: Math.max(0, Math.min(Number(modal.cursorIndex) || 0, value.length)),
5891
5935
  placeholder: "Type a group name",
@@ -5893,15 +5937,17 @@ export function selectSessionGroupNameModal(state, maxWidth = 76) {
5893
5937
  helpLines: [
5894
5938
  [
5895
5939
  { text: "Enter", color: "cyan", bold: true },
5896
- { text: " create and move ", color: "gray" },
5940
+ { text: renaming ? " rename " : " create and move ", color: "gray" },
5897
5941
  { text: "Esc", color: "cyan", bold: true },
5898
5942
  { text: " cancel", color: "gray" },
5899
5943
  ],
5900
5944
  [{ text: "", color: "gray" }],
5901
- [{ text: `The new group will contain ${sessionCount} selected session${sessionCount === 1 ? "" : "s"}.`, color: "gray" }],
5945
+ renaming
5946
+ ? [{ text: "Only the group's name changes; its sessions stay put.", color: "gray" }]
5947
+ : [{ text: `The new group will contain ${sessionCount} selected session${sessionCount === 1 ? "" : "s"}.`, color: "gray" }],
5902
5948
  ],
5903
5949
  detailsLines: [
5904
- [{ text: "New group: ", color: "gray" }, { text: previewTitle, color: "white", bold: true }],
5950
+ [{ text: renaming ? "Rename to: " : "New group: ", color: "gray" }, { text: previewTitle, color: "white", bold: true }],
5905
5951
  [{ text: "Groups are containers only; session actions remain per-session.", color: "gray" }],
5906
5952
  ],
5907
5953
  idealWidth: Math.min(Math.max(56, displayLength(previewTitle) + 18), maxWidth),
@@ -6328,6 +6374,20 @@ function formatSessionAge(ts) {
6328
6374
  return `${Math.floor(days / 7)}w`;
6329
6375
  }
6330
6376
 
6377
+ // Card-row timestamp: month, day, clock — no year, no "at", no timezone.
6378
+ // Cards live inside fitted boxes on a ~44-column phone; the full form is 29
6379
+ // characters and its padStart alignment dragged EVERY row in the table past
6380
+ // the card interior, wrapping all of them. Recency is what these rows convey;
6381
+ // the year and zone are noise there (the full form remains on Created/Updated).
6382
+ function formatCardTimestamp(ts) {
6383
+ if (!ts) return "—";
6384
+ const date = ts instanceof Date ? ts : new Date(ts);
6385
+ if (Number.isNaN(date.getTime())) return "—";
6386
+ const day = date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
6387
+ const clock = date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit" });
6388
+ return `${day}, ${clock}`;
6389
+ }
6390
+
6331
6391
  function formatLocalTimestamp(ts) {
6332
6392
  if (!ts) return "—";
6333
6393
  const date = ts instanceof Date ? ts : new Date(ts);
@@ -6470,7 +6530,7 @@ function buildSessionStatsLines(state, session, maxWidth) {
6470
6530
  ["Cache Read", formatCompactNumber(summary.tokensCacheRead)],
6471
6531
  ["Cache Write", formatCompactNumber(summary.tokensCacheWrite)],
6472
6532
  ["Hit Ratio", hitRatioLabel],
6473
- ]),
6533
+ ], { maxWidth: Math.max(12, w - 4) }),
6474
6534
  width: w,
6475
6535
  titleColor: "green",
6476
6536
  borderColor: "gray",
@@ -6506,9 +6566,9 @@ function buildSessionStatsLines(state, session, maxWidth) {
6506
6566
  ["Dehydrations", String(summary.dehydrationCount)],
6507
6567
  ["Hydrations", String(summary.hydrationCount)],
6508
6568
  ["Lossy Handoffs", String(summary.lossyHandoffCount)],
6509
- ["Last Dehydrated", summary.lastDehydratedAt ? formatLocalTimestamp(summary.lastDehydratedAt) : null],
6510
- ["Last Hydrated", summary.lastHydratedAt ? formatLocalTimestamp(summary.lastHydratedAt) : null],
6511
- ]),
6569
+ ["Last Dehydrated", summary.lastDehydratedAt ? formatCardTimestamp(summary.lastDehydratedAt) : null],
6570
+ ["Last Hydrated", summary.lastHydratedAt ? formatCardTimestamp(summary.lastHydratedAt) : null],
6571
+ ], { maxWidth: Math.max(12, w - 4) }),
6512
6572
  width: w,
6513
6573
  titleColor: "yellow",
6514
6574
  borderColor: "gray",
@@ -6636,8 +6696,17 @@ function formatKeyValueTable(pairs, options = {}) {
6636
6696
  const labelWidth = rows.reduce((max, [label]) => Math.max(max, String(label).length), 0);
6637
6697
  const valueWidth = rows.reduce((max, [, value]) => Math.max(max, String(value).length), 0);
6638
6698
  const filler = " ".repeat(gap);
6699
+ // One oversized value must not wrap every row: the aligned grid pads each
6700
+ // row to labelWidth+gap+valueWidth, so a single 29-char timestamp used to
6701
+ // push ALL rows past a narrow card's interior. When the grid cannot fit,
6702
+ // keep label alignment but let each value sit flush after its label —
6703
+ // short rows stay whole and only the long row wraps.
6704
+ const maxWidth = Number(options.maxWidth);
6705
+ const aligned = !Number.isFinite(maxWidth) || labelWidth + gap + valueWidth <= maxWidth;
6639
6706
  return rows
6640
- .map(([label, value]) => `${String(label).padEnd(labelWidth)}${filler}${String(value).padStart(valueWidth)}`)
6707
+ .map(([label, value]) => (aligned
6708
+ ? `${String(label).padEnd(labelWidth)}${filler}${String(value).padStart(valueWidth)}`
6709
+ : `${String(label).padEnd(labelWidth)}${filler}${String(value)}`))
6641
6710
  .join("\n");
6642
6711
  }
6643
6712