pilotswarm 0.5.35 → 0.5.37

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 (83) hide show
  1. package/package.json +2 -2
  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 +217 -19
  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 +209 -19
  10. package/ui/core/src/selectors.js +143 -154
  11. package/ui/core/src/state.js +51 -2
  12. package/ui/core/src/themes/duroxide.js +6 -1
  13. package/ui/core/src/themes/win95.js +4 -1
  14. package/ui/react/src/components.js +1 -7
  15. package/ui/react/src/web-app.js +853 -260
  16. package/web/dist/assets/{abnfDiagram-VRR7QNED-BwJEohLG.js → abnfDiagram-VRR7QNED-DlvPH-8O.js} +1 -1
  17. package/web/dist/assets/{arc-CWUJ_S5s.js → arc-r1liz5e5.js} +1 -1
  18. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-i6mrLxuE.js → architectureDiagram-ZJ3FMSHR-Bci5dNH3.js} +1 -1
  19. package/web/dist/assets/{blockDiagram-677ZJIJ3-C_KG-qPQ.js → blockDiagram-677ZJIJ3-Bug32AXp.js} +1 -1
  20. package/web/dist/assets/{c4Diagram-LMCZKHZV-BBJH7hbB.js → c4Diagram-LMCZKHZV-DwxSFqgE.js} +1 -1
  21. package/web/dist/assets/channel-jVpSYat3.js +1 -0
  22. package/web/dist/assets/{chunk-2Q5K7J3B-CXwaFcaM.js → chunk-2Q5K7J3B-B_dbI_xg.js} +1 -1
  23. package/web/dist/assets/{chunk-32BRIVSS-C8ujjkK-.js → chunk-32BRIVSS-BkF68W-R.js} +1 -1
  24. package/web/dist/assets/{chunk-5VM5RSS4-M9rz7YYk.js → chunk-5VM5RSS4-BSbNIbJu.js} +1 -1
  25. package/web/dist/assets/{chunk-EX3LRPZG-bk6tRxOA.js → chunk-EX3LRPZG-Blv_Iqa2.js} +1 -1
  26. package/web/dist/assets/{chunk-JWPE2WC7-DZ4WSHGH.js → chunk-JWPE2WC7-Dk_dBFKd.js} +1 -1
  27. package/web/dist/assets/{chunk-MOJQB5TN-B5-y6tMF.js → chunk-MOJQB5TN-DqNFBrfw.js} +1 -1
  28. package/web/dist/assets/{chunk-RYQCIY6F-yKcnHxm0.js → chunk-RYQCIY6F-Dlv7Svpb.js} +1 -1
  29. package/web/dist/assets/{chunk-V7JOEXUC-CpoNcATD.js → chunk-V7JOEXUC-DozGikwu.js} +1 -1
  30. package/web/dist/assets/{chunk-VR4S4FIN-Bf1yD2fn.js → chunk-VR4S4FIN-CmkqvGT4.js} +1 -1
  31. package/web/dist/assets/{chunk-XXDRQBXY-EnPvDVhP.js → chunk-XXDRQBXY-DyByai5N.js} +1 -1
  32. package/web/dist/assets/classDiagram-OUVF2IWQ-C78zSajE.js +1 -0
  33. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C78zSajE.js +1 -0
  34. package/web/dist/assets/{cose-bilkent-JH36ORCC-qhKoA2qj.js → cose-bilkent-JH36ORCC-Bv-gRVvA.js} +1 -1
  35. package/web/dist/assets/{cynefin-VYW2F7L2-jcyYelJS.js → cynefin-VYW2F7L2-DRnAsyZU.js} +1 -1
  36. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-D4RLufLP.js → cynefinDiagram-TSTJHNR4-B1fnUtjo.js} +1 -1
  37. package/web/dist/assets/{dagre-VKFMJZFB-C9BA3GTO.js → dagre-VKFMJZFB-DM6-QENB.js} +1 -1
  38. package/web/dist/assets/{diagram-FQU43EPY-DbSSrVUJ.js → diagram-FQU43EPY-BPxxnLdU.js} +1 -1
  39. package/web/dist/assets/{diagram-G47NLZAW-3egISQXQ.js → diagram-G47NLZAW-BgtJT3qF.js} +1 -1
  40. package/web/dist/assets/{diagram-NH7WQ7WH-8IKEMGRg.js → diagram-NH7WQ7WH-uekeIzsy.js} +1 -1
  41. package/web/dist/assets/{diagram-OA4YK3LP-VnaogqtK.js → diagram-OA4YK3LP-BVy3JZWE.js} +1 -1
  42. package/web/dist/assets/{diagram-WEI45ONY-DxigXH0T.js → diagram-WEI45ONY-BWdjUr32.js} +1 -1
  43. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-BSkNwJ9x.js → ebnfDiagram-CCIWWBDH-DD4fwhNK.js} +1 -1
  44. package/web/dist/assets/{erDiagram-Q63AITRT-CKN8JXl6.js → erDiagram-Q63AITRT-BllD_6Kh.js} +1 -1
  45. package/web/dist/assets/{flowDiagram-23GEKE2U-DDaD5ExP.js → flowDiagram-23GEKE2U-QEYKkAUN.js} +1 -1
  46. package/web/dist/assets/{ganttDiagram-NO4QXBWP-tOrBOuoI.js → ganttDiagram-NO4QXBWP-uZcfKTSW.js} +1 -1
  47. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-Cdp867ON.js → gitGraphDiagram-IHSO6WYX-XPdbPaH-.js} +1 -1
  48. package/web/dist/assets/index-BuDSoRIL.css +1 -0
  49. package/web/dist/assets/{index-BmwU43_u.js → index-D1itTxvB.js} +4 -4
  50. package/web/dist/assets/{infoDiagram-FWYZ7A6U-DwcKHD2h.js → infoDiagram-FWYZ7A6U-DXCnQOJs.js} +1 -1
  51. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CWffK5kz.js → ishikawaDiagram-FXEZZL3T-687_4FBy.js} +1 -1
  52. package/web/dist/assets/{journeyDiagram-5HDEW3XC-BEAgS-EZ.js → journeyDiagram-5HDEW3XC-GXlKuda8.js} +1 -1
  53. package/web/dist/assets/{kanban-definition-HUTT4EX6-xDm-mRRj.js → kanban-definition-HUTT4EX6-C38-2DvN.js} +1 -1
  54. package/web/dist/assets/{linear-oP9MA0as.js → linear-lGPXiBcz.js} +1 -1
  55. package/web/dist/assets/{mermaid.core-DgXXt1OS.js → mermaid.core-C_neEtab.js} +4 -4
  56. package/web/dist/assets/{mindmap-definition-LN4V7U3C-BBQgVrYC.js → mindmap-definition-LN4V7U3C-BaJ9tpEG.js} +1 -1
  57. package/web/dist/assets/{papaparse.min-CR6JmBWm.js → papaparse.min-rDwKYbBf.js} +1 -1
  58. package/web/dist/assets/{pegDiagram-2B236MQR-C2O0cf3j.js → pegDiagram-2B236MQR-BdXJLYgY.js} +1 -1
  59. package/web/dist/assets/{pieDiagram-ENE6RG2P-D08pUtYU.js → pieDiagram-ENE6RG2P-DU19t6fM.js} +1 -1
  60. package/web/dist/assets/pilotswarm-BNKGG936.js +96 -0
  61. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-D-Z7YoEK.js → quadrantDiagram-ABIIQ3AL-CBkOdPp0.js} +1 -1
  62. package/web/dist/assets/{railroadDiagram-RFXS5EU6-ClkdveLI.js → railroadDiagram-RFXS5EU6-D2DMcGDw.js} +1 -1
  63. package/web/dist/assets/{requirementDiagram-TGXJPOKE-IY4xbn-Q.js → requirementDiagram-TGXJPOKE-Bx9tF1pr.js} +1 -1
  64. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-Cpt5i7KM.js → sankeyDiagram-HTMAVEWB-DcLlRfsi.js} +1 -1
  65. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-c07lALcz.js → sequenceDiagram-DBY2YBRQ-xr6UVRBO.js} +1 -1
  66. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CmCDiNbI.js → sizeCapture-X5ZJPWSS-0fUkSXmg.js} +1 -1
  67. package/web/dist/assets/{stateDiagram-2N3HPSRC-Dueu17zX.js → stateDiagram-2N3HPSRC-BWgJGGpg.js} +1 -1
  68. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DmAC8DdB.js +1 -0
  69. package/web/dist/assets/{swimlanes-5IMT3BWC-Csc2Fup8.js → swimlanes-5IMT3BWC-CZKya4_i.js} +2 -2
  70. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CVYx20_X.js +8 -0
  71. package/web/dist/assets/{timeline-definition-FHXFAJF6-4-lf702e.js → timeline-definition-FHXFAJF6-Df8BpeAu.js} +1 -1
  72. package/web/dist/assets/{vennDiagram-L72KCM5P-CmlaKpLI.js → vennDiagram-L72KCM5P-ChX5bjT6.js} +1 -1
  73. package/web/dist/assets/{wardleyDiagram-EHGQE667-B9Qe44pi.js → wardleyDiagram-EHGQE667-Dr48BgaJ.js} +1 -1
  74. package/web/dist/assets/{xychartDiagram-FW5EYKEG-BFcc0CLn.js → xychartDiagram-FW5EYKEG-Cia_MFfc.js} +1 -1
  75. package/web/dist/index.html +4 -4
  76. package/web/runtime.js +20 -1
  77. package/web/dist/assets/channel-5Ddqi9ib.js +0 -1
  78. package/web/dist/assets/classDiagram-OUVF2IWQ-B8u1fZiB.js +0 -1
  79. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B8u1fZiB.js +0 -1
  80. package/web/dist/assets/index-D-nA7gUe.css +0 -1
  81. package/web/dist/assets/pilotswarm-D7NLCbuc.js +0 -99
  82. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-AGpIV7wW.js +0 -1
  83. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTGZSV0N.js +0 -8
@@ -106,11 +106,6 @@ function sessionStatusLabelForVisualStatus(session, status) {
106
106
  return status;
107
107
  }
108
108
 
109
- function getSessionSummaryStatusLabel(session) {
110
- const status = getSessionVisualStatus(session);
111
- if (!status || status === "unknown") return "";
112
- return sessionStatusLabelForVisualStatus(session, status);
113
- }
114
109
 
115
110
  /**
116
111
  * The label + timestamp the session-detail box shows, as ONE derivation shared
@@ -1176,7 +1171,7 @@ export function selectSessionRows(state) {
1176
1171
  const ownerSearchText = effectiveOwner
1177
1172
  ? `${ownerDisplayName(effectiveOwner, "")} ${effectiveOwner.email || ""}`
1178
1173
  : "";
1179
- const summarySearchText = `${session?.shortSummary || ""} ${session?.summaryState?.intent || ""} ${session?.summaryState?.summary || ""}`;
1174
+ const summarySearchText = "";
1180
1175
  return matchesSearchQuery(row.text, query)
1181
1176
  || matchesSearchQuery(row.sessionId, query)
1182
1177
  || matchesSearchQuery(ownerSearchText, query)
@@ -1557,7 +1552,7 @@ export function selectActiveChat(state) {
1557
1552
  .replace(/[\r\n]+/g, " ")
1558
1553
  .trim() || " ";
1559
1554
  const memberRows = memberSessions.map((candidate) => (
1560
- `| ${markdownCell(candidate.title || candidate.sessionId)} | ${markdownCell(candidate.status || "unknown")} | ${markdownCell(candidate.shortSummary || "")} |`
1555
+ `| ${markdownCell(candidate.title || candidate.sessionId)} | ${markdownCell(candidate.status || "unknown")} |`
1561
1556
  ));
1562
1557
  const lines = [
1563
1558
  `# ${session.title || session.groupId}`,
@@ -1575,8 +1570,8 @@ export function selectActiveChat(state) {
1575
1570
  "## Members",
1576
1571
  ...(memberRows.length > 0
1577
1572
  ? [
1578
- "| Session | Status | Summary |",
1579
- "|---|---|---|",
1573
+ "| Session | Status |",
1574
+ "|---|---|",
1580
1575
  ...memberRows,
1581
1576
  ]
1582
1577
  : ["_No top-level members._"]),
@@ -1589,9 +1584,6 @@ export function selectActiveChat(state) {
1589
1584
  createdAt: session.updatedAt || Date.now(),
1590
1585
  }];
1591
1586
  }
1592
- if (state.ui?.chatViewMode === "summary") {
1593
- return buildSessionSummaryCards(session);
1594
- }
1595
1587
  const history = state.history.bySessionId.get(sessionId);
1596
1588
  const chat = history?.chat || [];
1597
1589
  const pendingQuestionMessage = session?.pendingQuestion?.question
@@ -1618,123 +1610,6 @@ export function selectActiveChat(state) {
1618
1610
  return messages;
1619
1611
  }
1620
1612
 
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
1613
  function prefixRuns(text, color = "gray", options = {}) {
1739
1614
  return [{
1740
1615
  text,
@@ -2593,7 +2468,6 @@ function liveActivityPhaseText(item) {
2593
2468
  // so visibility is governed by session status rather than transcript role.
2594
2469
  // `options.spinnerFrame` animates the marker.
2595
2470
  export function selectLiveActivityLines(state, options = {}) {
2596
- if (state?.ui?.chatViewMode === "summary") return [];
2597
2471
  const session = selectActiveSession(state);
2598
2472
  if (!session || session.isGroup) return [];
2599
2473
  const status = String(session?.status || "").toLowerCase();
@@ -2665,7 +2539,7 @@ export function selectLiveActivityLines(state, options = {}) {
2665
2539
  // Keyed on identities rather than fields on purpose: selectActiveChat is NOT a
2666
2540
  // pure function of the chat array — it also appends a pending-question message,
2667
2541
  // an answered-question message and a session-error message, and branches on
2668
- // ui.chatViewMode and branding. Anything derived from `session` therefore has
2542
+ // branding. Anything derived from `session` therefore has
2669
2543
  // to invalidate when `session` does, which identity comparison gives for free.
2670
2544
  //
2671
2545
  // `byId` is in the key for two reasons found by adversarial review, both of
@@ -2695,7 +2569,6 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2695
2569
  session: memoSessionId ? state?.sessions?.byId?.[memoSessionId] ?? null : null,
2696
2570
  byId: state?.sessions?.byId ?? null,
2697
2571
  chat: state?.history?.bySessionId?.get?.(memoSessionId)?.chat ?? null,
2698
- viewMode: state?.ui?.chatViewMode ?? null,
2699
2572
  branding: state?.branding ?? null,
2700
2573
  principal: state?.auth?.principal ?? null,
2701
2574
  maxWidth,
@@ -2754,6 +2627,10 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2754
2627
  lines.push(buildRegenRefusedLine(message, maxWidth));
2755
2628
  } else if (message?.kind === "regen-failed") {
2756
2629
  lines.push(buildRegenFailedLine(message, maxWidth));
2630
+ } else if (message?.kind === "canvas-update") {
2631
+ lines.push(buildCanvasUpdateLine(message, message.sessionId || memoSessionId));
2632
+ } else if (message?.kind === "canvas-action") {
2633
+ lines.push(buildCanvasActionLine(message));
2757
2634
  } else {
2758
2635
  const messageLines = buildChatMessageLines(message, maxWidth, buildOptions);
2759
2636
  appendChatBlockLines(lines, messageLines);
@@ -2770,6 +2647,92 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2770
2647
  return memoize(lines.length > 0 ? lines : [{ text: "No messages yet.", color: "gray" }]);
2771
2648
  }
2772
2649
 
2650
+ /**
2651
+ * The canvas-update transcript line — carried by BOTH hosts, rendered by one.
2652
+ *
2653
+ * The line is flagged `canvasUpdate: true`; the portal's chat renderer skips
2654
+ * flagged lines outright (there, the canvas pane updating is the whole
2655
+ * signal), while the TUI renders it as an ordinary artifact link, press-a
2656
+ * affordance and all — a host that cannot render the canvas needs the link.
2657
+ */
2658
+ /**
2659
+ * Everything the Canvas toggle and pane need about the ACTIVE session's
2660
+ * canvas, in one read: whether one exists, its revision and caption, and
2661
+ * whether the yellow unseen-changes badge should light
2662
+ * (latestRev > lastViewedRev, and only while the canvas is not on screen).
2663
+ */
2664
+ export function selectCanvasView(state) {
2665
+ const sessionId = state?.sessions?.activeSessionId || null;
2666
+ const entry = sessionId ? state?.canvas?.bySessionId?.[sessionId] : null;
2667
+ const prefs = sessionId ? state?.canvas?.prefs?.[sessionId] : null;
2668
+ const latestRev = entry?.latestRev || 0;
2669
+ const lastViewedRev = prefs?.lastViewedRev || 0;
2670
+ const latestDataRev = entry?.latestDataRev || 0;
2671
+ const lastViewedDataRev = prefs?.lastViewedDataRev || 0;
2672
+ const mode = state?.ui?.rightPaneMode === "canvas" ? "canvas" : "panes";
2673
+ // A cleared canvas (draw_canvas("") → sizeBytes 0) no longer exists for
2674
+ // affordance purposes: no phone tab, no badge, blank teaching state.
2675
+ // Unknown size (null — snapshot degrade) is presumed drawn.
2676
+ const exists = latestRev > 0 && entry?.sizeBytes !== 0;
2677
+ return {
2678
+ sessionId,
2679
+ mode,
2680
+ exists,
2681
+ latestRev,
2682
+ note: entry?.note || "",
2683
+ sizeBytes: entry?.sizeBytes ?? null,
2684
+ snapshotLoaded: Boolean(entry?.snapshotLoaded),
2685
+ optedOut: Boolean(prefs?.optedOut),
2686
+ lastViewedRev,
2687
+ flipSeq: state?.canvas?.flipSeq || 0,
2688
+ responseContract: entry?.responseContract || null,
2689
+ latestDataRev,
2690
+ lastViewedDataRev,
2691
+ dataPayload: entry?.dataPayload || null,
2692
+ unseen: exists && (latestRev > lastViewedRev || latestDataRev > lastViewedDataRev) && mode !== "canvas",
2693
+ };
2694
+ }
2695
+
2696
+ /**
2697
+ * A validated canvas response on its way to the agent. Flagged so the PORTAL
2698
+ * chat skips it (the canvas redraw is the visible half of the loop) while the
2699
+ * TUI — which renders no canvas and would otherwise show a silent gap in the
2700
+ * conversation — gets a compact dim line.
2701
+ */
2702
+ function buildCanvasActionLine(message) {
2703
+ const dataKeys = message?.data && typeof message.data === "object" ? Object.keys(message.data) : [];
2704
+ const detail = dataKeys.length === 1 && typeof message.data[dataKeys[0]] === "string"
2705
+ ? message.data[dataKeys[0]].slice(0, 120)
2706
+ : (dataKeys.length ? JSON.stringify(message.data).slice(0, 120) : "");
2707
+ return {
2708
+ runs: [
2709
+ { text: "[canvas] ", color: "cyan", bold: true },
2710
+ { text: `${message.action || "action"}`, color: "white" },
2711
+ ...(detail ? [{ text: ` — ${detail}`, color: "gray" }] : []),
2712
+ ],
2713
+ canvasAction: true,
2714
+ };
2715
+ }
2716
+
2717
+ function buildCanvasUpdateLine(message, sessionId) {
2718
+ const rev = Number(message?.rev) || 0;
2719
+ const note = typeof message?.note === "string" && message.note.trim() ? ` — ${message.note.trim()}` : "";
2720
+ const runs = [
2721
+ { text: "[canvas] ", color: "cyan", bold: true },
2722
+ { text: `rev ${rev}${note} `, color: "white" },
2723
+ ];
2724
+ if (sessionId) {
2725
+ runs.push({
2726
+ text: "[artifact: canvas.html]",
2727
+ color: "cyan",
2728
+ underline: true,
2729
+ href: `artifact://${sessionId}/canvas.html`,
2730
+ });
2731
+ runs.push({ text: " (press a to download)", color: "gray" });
2732
+ }
2733
+ return { runs, canvasUpdate: true };
2734
+ }
2735
+
2773
2736
  function buildRuleLine(label, color, maxWidth) {
2774
2737
  const safeWidth = Math.max(24, Number(maxWidth) || 80);
2775
2738
  const room = safeWidth - label.length;
@@ -2825,6 +2788,14 @@ function buildRegenFailedLine(message, maxWidth) {
2825
2788
  return buildRuleLine(label, "red", maxWidth);
2826
2789
  }
2827
2790
 
2791
+ export function selectActiveOutboxMessages(state) {
2792
+ const sessionId = state.sessions.activeSessionId;
2793
+ if (!sessionId) return [];
2794
+ return Array.isArray(state.outbox?.bySessionId?.[sessionId])
2795
+ ? state.outbox.bySessionId[sessionId].map((item) => buildPendingOutboxMessage(sessionId, item)).filter(Boolean)
2796
+ : [];
2797
+ }
2798
+
2828
2799
  export function selectOutboxOverlayLines(state, maxWidth = 80, options = {}) {
2829
2800
  const messages = selectActiveOutboxMessages(state);
2830
2801
  if (!messages || messages.length === 0) return [];
@@ -2956,9 +2927,7 @@ export function selectChatPaneChrome(state, options = {}) {
2956
2927
  const outboxItems = Array.isArray(state.outbox?.bySessionId?.[session.sessionId])
2957
2928
  ? state.outbox.bySessionId[session.sessionId]
2958
2929
  : [];
2959
- const progress = state.ui?.chatViewMode === "summary"
2960
- ? null
2961
- : buildLiveProgressState(session, history, history?.chat || [], outboxItems);
2930
+ const progress = buildLiveProgressState(session, history, history?.chat || [], outboxItems);
2962
2931
 
2963
2932
  // Border top-right: status · model · context (mirrors the session-row meta),
2964
2933
  // with the live turn-progress label appended while a turn is running.
@@ -4266,11 +4235,9 @@ export function selectStatusBar(state) {
4266
4235
  right: "up/down move · space toggle · ctrl-g group · c cancel · d complete · D hard delete · V/esc exit",
4267
4236
  };
4268
4237
  }
4269
- const chatViewMode = state.ui?.chatViewMode === "summary" ? "summary" : "transcript";
4270
- const chatViewHint = chatViewMode === "summary" ? "s transcript" : "s summary";
4271
4238
  const hints = {
4272
4239
  [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`,
4240
+ [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
4241
  [FOCUS_REGIONS.INSPECTOR]: state.ui.inspectorTab === "logs"
4275
4242
  ? `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
4243
  : state.ui.inspectorTab === "stats"
@@ -4855,13 +4822,8 @@ function buildSequenceStatsLines(state, session, maxWidth) {
4855
4822
  if (!body) body = "orchestration stats unavailable";
4856
4823
  }
4857
4824
 
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
-
4825
+ // Narrow panes used to degrade to a single truncated line; a box whose
4826
+ // content wraps reads better on a phone and costs two rows of chrome.
4865
4827
  return buildMessageCardLines({
4866
4828
  title: "Stats",
4867
4829
  body,
@@ -5884,8 +5846,10 @@ export function selectSessionGroupNameModal(state, maxWidth = 76) {
5884
5846
  const value = String(modal.value || "");
5885
5847
  const sessionCount = Array.isArray(modal.sessionIds) ? modal.sessionIds.length : 0;
5886
5848
  const previewTitle = value.trim() || "…";
5849
+ const renaming = modal.mode === "rename";
5887
5850
  return {
5888
- title: modal.title || "New Group",
5851
+ title: modal.title || (renaming ? "Rename Group" : "New Group"),
5852
+ mode: renaming ? "rename" : "create",
5889
5853
  value,
5890
5854
  cursorIndex: Math.max(0, Math.min(Number(modal.cursorIndex) || 0, value.length)),
5891
5855
  placeholder: "Type a group name",
@@ -5893,15 +5857,17 @@ export function selectSessionGroupNameModal(state, maxWidth = 76) {
5893
5857
  helpLines: [
5894
5858
  [
5895
5859
  { text: "Enter", color: "cyan", bold: true },
5896
- { text: " create and move ", color: "gray" },
5860
+ { text: renaming ? " rename " : " create and move ", color: "gray" },
5897
5861
  { text: "Esc", color: "cyan", bold: true },
5898
5862
  { text: " cancel", color: "gray" },
5899
5863
  ],
5900
5864
  [{ text: "", color: "gray" }],
5901
- [{ text: `The new group will contain ${sessionCount} selected session${sessionCount === 1 ? "" : "s"}.`, color: "gray" }],
5865
+ renaming
5866
+ ? [{ text: "Only the group's name changes; its sessions stay put.", color: "gray" }]
5867
+ : [{ text: `The new group will contain ${sessionCount} selected session${sessionCount === 1 ? "" : "s"}.`, color: "gray" }],
5902
5868
  ],
5903
5869
  detailsLines: [
5904
- [{ text: "New group: ", color: "gray" }, { text: previewTitle, color: "white", bold: true }],
5870
+ [{ text: renaming ? "Rename to: " : "New group: ", color: "gray" }, { text: previewTitle, color: "white", bold: true }],
5905
5871
  [{ text: "Groups are containers only; session actions remain per-session.", color: "gray" }],
5906
5872
  ],
5907
5873
  idealWidth: Math.min(Math.max(56, displayLength(previewTitle) + 18), maxWidth),
@@ -6328,6 +6294,20 @@ function formatSessionAge(ts) {
6328
6294
  return `${Math.floor(days / 7)}w`;
6329
6295
  }
6330
6296
 
6297
+ // Card-row timestamp: month, day, clock — no year, no "at", no timezone.
6298
+ // Cards live inside fitted boxes on a ~44-column phone; the full form is 29
6299
+ // characters and its padStart alignment dragged EVERY row in the table past
6300
+ // the card interior, wrapping all of them. Recency is what these rows convey;
6301
+ // the year and zone are noise there (the full form remains on Created/Updated).
6302
+ function formatCardTimestamp(ts) {
6303
+ if (!ts) return "—";
6304
+ const date = ts instanceof Date ? ts : new Date(ts);
6305
+ if (Number.isNaN(date.getTime())) return "—";
6306
+ const day = date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
6307
+ const clock = date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit" });
6308
+ return `${day}, ${clock}`;
6309
+ }
6310
+
6331
6311
  function formatLocalTimestamp(ts) {
6332
6312
  if (!ts) return "—";
6333
6313
  const date = ts instanceof Date ? ts : new Date(ts);
@@ -6470,7 +6450,7 @@ function buildSessionStatsLines(state, session, maxWidth) {
6470
6450
  ["Cache Read", formatCompactNumber(summary.tokensCacheRead)],
6471
6451
  ["Cache Write", formatCompactNumber(summary.tokensCacheWrite)],
6472
6452
  ["Hit Ratio", hitRatioLabel],
6473
- ]),
6453
+ ], { maxWidth: Math.max(12, w - 4) }),
6474
6454
  width: w,
6475
6455
  titleColor: "green",
6476
6456
  borderColor: "gray",
@@ -6506,9 +6486,9 @@ function buildSessionStatsLines(state, session, maxWidth) {
6506
6486
  ["Dehydrations", String(summary.dehydrationCount)],
6507
6487
  ["Hydrations", String(summary.hydrationCount)],
6508
6488
  ["Lossy Handoffs", String(summary.lossyHandoffCount)],
6509
- ["Last Dehydrated", summary.lastDehydratedAt ? formatLocalTimestamp(summary.lastDehydratedAt) : null],
6510
- ["Last Hydrated", summary.lastHydratedAt ? formatLocalTimestamp(summary.lastHydratedAt) : null],
6511
- ]),
6489
+ ["Last Dehydrated", summary.lastDehydratedAt ? formatCardTimestamp(summary.lastDehydratedAt) : null],
6490
+ ["Last Hydrated", summary.lastHydratedAt ? formatCardTimestamp(summary.lastHydratedAt) : null],
6491
+ ], { maxWidth: Math.max(12, w - 4) }),
6512
6492
  width: w,
6513
6493
  titleColor: "yellow",
6514
6494
  borderColor: "gray",
@@ -6636,8 +6616,17 @@ function formatKeyValueTable(pairs, options = {}) {
6636
6616
  const labelWidth = rows.reduce((max, [label]) => Math.max(max, String(label).length), 0);
6637
6617
  const valueWidth = rows.reduce((max, [, value]) => Math.max(max, String(value).length), 0);
6638
6618
  const filler = " ".repeat(gap);
6619
+ // One oversized value must not wrap every row: the aligned grid pads each
6620
+ // row to labelWidth+gap+valueWidth, so a single 29-char timestamp used to
6621
+ // push ALL rows past a narrow card's interior. When the grid cannot fit,
6622
+ // keep label alignment but let each value sit flush after its label —
6623
+ // short rows stay whole and only the long row wraps.
6624
+ const maxWidth = Number(options.maxWidth);
6625
+ const aligned = !Number.isFinite(maxWidth) || labelWidth + gap + valueWidth <= maxWidth;
6639
6626
  return rows
6640
- .map(([label, value]) => `${String(label).padEnd(labelWidth)}${filler}${String(value).padStart(valueWidth)}`)
6627
+ .map(([label, value]) => (aligned
6628
+ ? `${String(label).padEnd(labelWidth)}${filler}${String(value).padStart(valueWidth)}`
6629
+ : `${String(label).padEnd(labelWidth)}${filler}${String(value)}`))
6641
6630
  .join("\n");
6642
6631
  }
6643
6632
 
@@ -128,13 +128,48 @@ export function normalizeStoredCollapsedSessionIds(value) {
128
128
  return out;
129
129
  }
130
130
 
131
+ /**
132
+ * Right-column mode: the inspector/activity split, or the session canvas.
133
+ * Anything unrecognized falls back to panes — a stored value from a newer
134
+ * build must not strand an older portal in a mode it cannot render.
135
+ */
136
+ export function normalizeStoredRightPaneMode(value) {
137
+ return value === "canvas" ? "canvas" : "panes";
138
+ }
139
+
140
+ /**
141
+ * Per-session canvas preferences: what this user has chosen and seen.
142
+ *
143
+ * `optedOut` — the user manually left canvas mode this session, so agent
144
+ * draws badge instead of flipping. `lastViewedRev` — the highest revision
145
+ * this user has had on screen; the yellow badge is latestRev > lastViewedRev.
146
+ * Persisted (like pinned ids) so the overnight case lights the badge on
147
+ * reopen with no extra query.
148
+ */
149
+ export function normalizeStoredCanvasPrefs(value) {
150
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
151
+ const out = {};
152
+ for (const [sessionId, entry] of Object.entries(value)) {
153
+ const id = String(sessionId || "").trim();
154
+ if (!id || !entry || typeof entry !== "object") continue;
155
+ const lastViewedRev = Number(entry.lastViewedRev);
156
+ const lastViewedDataRev = Number(entry.lastViewedDataRev);
157
+ out[id] = {
158
+ optedOut: entry.optedOut === true,
159
+ lastViewedRev: Number.isFinite(lastViewedRev) && lastViewedRev > 0 ? Math.floor(lastViewedRev) : 0,
160
+ lastViewedDataRev: Number.isFinite(lastViewedDataRev) && lastViewedDataRev > 0 ? Math.floor(lastViewedDataRev) : 0,
161
+ };
162
+ }
163
+ return out;
164
+ }
165
+
131
166
  export function normalizeStoredActiveSessionId(value) {
132
167
  if (value == null) return null;
133
168
  const id = String(value).trim();
134
169
  return id ? id : null;
135
170
  }
136
171
 
137
- export function createInitialState({ mode = "local", branding = null, docs = null, themeId = null, chatViewMode = null, sessionOwnerFilter = null, layoutAdjustments = null, pinnedSessionIds = null, collapsedSessionIds = null, activeSessionId = null, sessionOrder = null } = {}) {
172
+ export function createInitialState({ mode = "local", branding = null, docs = null, themeId = null, sessionOwnerFilter = null, layoutAdjustments = null, pinnedSessionIds = null, collapsedSessionIds = null, activeSessionId = null, sessionOrder = null, rightPaneMode = null, canvasPrefs = null } = {}) {
138
173
  const hasStoredSessionOwnerFilter = sessionOwnerFilter != null;
139
174
  const hasStoredCollapsedSessionIds = collapsedSessionIds != null;
140
175
  const initialLayoutAdjustments = normalizeStoredLayoutAdjustments(layoutAdjustments);
@@ -164,7 +199,9 @@ export function createInitialState({ mode = "local", branding = null, docs = nul
164
199
  sequenceSelectedTurn: null,
165
200
  // "rich" was retired as a view mode; a persisted "rich" falls
166
201
  // through to the transcript.
167
- chatViewMode: chatViewMode === "summary" ? "summary" : "transcript",
202
+ // Which face the right column wears: the inspector/activity panes
203
+ // or the session canvas. Persisted.
204
+ rightPaneMode: normalizeStoredRightPaneMode(rightPaneMode),
168
205
  statsViewMode: "session",
169
206
  prompt: "",
170
207
  promptCursor: 0,
@@ -301,6 +338,18 @@ export function createInitialState({ mode = "local", branding = null, docs = nul
301
338
  query: "",
302
339
  },
303
340
  },
341
+ canvas: {
342
+ // Runtime knowledge of each session's canvas, fed by the
343
+ // selection-burst snapshot and then by canvas_updated events.
344
+ // { latestRev, note, sizeBytes, snapshotLoaded } per session.
345
+ bySessionId: {},
346
+ // Persisted per-user attention state — see normalizeStoredCanvasPrefs.
347
+ prefs: normalizeStoredCanvasPrefs(canvasPrefs),
348
+ // Ticks on every agent-driven flip. Hosts whose pane state lives
349
+ // outside ui.rightPaneMode (the phone's tab strip) watch this to
350
+ // bring their canvas surface forward.
351
+ flipSeq: 0,
352
+ },
304
353
  logs: {
305
354
  available: false,
306
355
  availabilityReason: "Log tailing disabled: no K8S_CONTEXT configured in the env file.",
@@ -119,7 +119,12 @@ const duroxideTheme = createTheme({
119
119
  brightWhite: "#fdf7f2",
120
120
  },
121
121
  tui: {
122
- border: "#46403d",
122
+ // 2.49:1 on the surface. The old #46403d was 1.48:1 — enough to
123
+ // suggest an edge on a flat panel, and nothing at all once the felt
124
+ // texture ran over it or it was drawn as a one-pixel seam. Every
125
+ // structural line in the theme reads from this: panel edges, the
126
+ // joined-workspace seams, and the table rules.
127
+ border: "#6a615b",
123
128
  // The human speaks in the warning register, as in the Rust theme: the
124
129
  // accent stays reserved for the machine and what it produces.
125
130
  userChat: "#e0b070",
@@ -24,7 +24,10 @@ const win95Theme = createTheme({
24
24
  overlayForeground: "#000000",
25
25
  // The classic disabled/secondary grey; darker than the face so it
26
26
  // stays legible on #c0c0c0 rather than vanishing into it.
27
- hintColor: "#5a5a5a",
27
+ // Darker than the authentic #808080 system grey, which sat at 2.2:1
28
+ // on the #c0c0c0 chrome. Even #5a5a5a only reached 3.79:1; this
29
+ // clears 4.5:1 so muted text is readable wherever the chrome shows.
30
+ hintColor: "#4a4a4a",
28
31
  modalBackdrop: "rgba(0, 0, 0, 0.45)",
29
32
  modalBackground: "#c0c0c0",
30
33
  modalBorder: "#808080",
@@ -353,7 +353,6 @@ const ChatPane = React.memo(function ChatPane({ controller, width, height, frame
353
353
  branding: state.branding,
354
354
  connectionError: state.connection.error,
355
355
  connectionMode: state.connection.mode,
356
- chatViewMode: state.ui.chatViewMode || "transcript",
357
356
  chatScroll: state.ui.scroll.chat,
358
357
  focused: state.ui.focusRegion === "chat",
359
358
  };
@@ -371,9 +370,7 @@ const ChatPane = React.memo(function ChatPane({ controller, width, height, frame
371
370
  error: chatView.connectionError,
372
371
  mode: chatView.connectionMode,
373
372
  },
374
- ui: {
375
- chatViewMode: chatView.chatViewMode,
376
- },
373
+ ui: {},
377
374
  sessions: {
378
375
  activeSessionId: chatView.activeSessionId,
379
376
  byId: buildSingleSessionMap(chatView.activeSessionId, chatView.activeSession),
@@ -398,7 +395,6 @@ const ChatPane = React.memo(function ChatPane({ controller, width, height, frame
398
395
  chatView.activeOutbox,
399
396
  chatView.authPrincipal,
400
397
  chatView.branding,
401
- chatView.chatViewMode,
402
398
  chatView.connectionError,
403
399
  chatView.connectionMode,
404
400
  ]);
@@ -966,7 +962,6 @@ const StatusBar = React.memo(function StatusBar({ controller }) {
966
962
  workersOnline: state.connection.workersOnline,
967
963
  focusRegion: state.ui.focusRegion,
968
964
  inspectorTab: state.ui.inspectorTab,
969
- chatViewMode: state.ui.chatViewMode || "transcript",
970
965
  fullscreenPane: state.ui.fullscreenPane || null,
971
966
  logsAvailable: state.logs.available,
972
967
  logsTailing: state.logs.tailing,
@@ -988,7 +983,6 @@ const StatusBar = React.memo(function StatusBar({ controller }) {
988
983
  ui: {
989
984
  focusRegion: statusState.focusRegion,
990
985
  inspectorTab: statusState.inspectorTab,
991
- chatViewMode: statusState.chatViewMode,
992
986
  fullscreenPane: statusState.fullscreenPane,
993
987
  statusText: statusState.statusText,
994
988
  modal: statusState.modal,