pilotswarm 0.5.32 → 0.5.34

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 (98) hide show
  1. package/package.json +2 -2
  2. package/tui/src/app.js +10 -0
  3. package/tui/src/node-sdk-transport.js +11 -0
  4. package/ui/core/src/controller.js +381 -12
  5. package/ui/core/src/reducer.js +125 -28
  6. package/ui/core/src/selectors.js +158 -268
  7. package/ui/core/src/state.js +6 -1
  8. package/ui/core/src/themes/catppuccin-mocha.js +2 -1
  9. package/ui/core/src/themes/cobalt2.js +2 -1
  10. package/ui/core/src/themes/doom.js +2 -1
  11. package/ui/core/src/themes/dracula.js +2 -1
  12. package/ui/core/src/themes/github-dark.js +2 -1
  13. package/ui/core/src/themes/github-light.js +2 -1
  14. package/ui/core/src/themes/gruvbox-dark.js +2 -1
  15. package/ui/core/src/themes/hacker-x-matrix.js +2 -1
  16. package/ui/core/src/themes/hacker-x-orion-prime.js +2 -1
  17. package/ui/core/src/themes/helpers.js +22 -4
  18. package/ui/core/src/themes/high-contrast-mono.js +2 -1
  19. package/ui/core/src/themes/index.js +24 -5
  20. package/ui/core/src/themes/ms-dos.js +2 -1
  21. package/ui/core/src/themes/noctis-obscuro.js +2 -1
  22. package/ui/core/src/themes/noctis.js +2 -1
  23. package/ui/core/src/themes/rust.js +125 -0
  24. package/ui/core/src/themes/spongebob.js +110 -0
  25. package/ui/core/src/themes/terminal-green.js +2 -1
  26. package/ui/core/src/themes/win95.js +2 -1
  27. package/ui/core/src/themes/winamp.js +2 -1
  28. package/ui/core/src/themes/workspace-dark.js +2 -1
  29. package/ui/react/src/web-app.js +240 -266
  30. package/web/dist/assets/{abnfDiagram-VRR7QNED-mXFQ4a3c.js → abnfDiagram-VRR7QNED-BFOimVWK.js} +1 -1
  31. package/web/dist/assets/{arc-BPuuvbge.js → arc-uUHjvwEo.js} +1 -1
  32. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-Dt4uUYIe.js → architectureDiagram-ZJ3FMSHR-CxeX_vea.js} +1 -1
  33. package/web/dist/assets/{blockDiagram-677ZJIJ3-Ax8Q22vV.js → blockDiagram-677ZJIJ3-Cno8kltG.js} +1 -1
  34. package/web/dist/assets/{c4Diagram-LMCZKHZV-CPiukyHt.js → c4Diagram-LMCZKHZV-viONVybf.js} +1 -1
  35. package/web/dist/assets/channel-IURmSsjn.js +1 -0
  36. package/web/dist/assets/{chunk-2Q5K7J3B-VJLqCZJX.js → chunk-2Q5K7J3B-CKfICIGD.js} +1 -1
  37. package/web/dist/assets/{chunk-32BRIVSS-DqOshxhf.js → chunk-32BRIVSS-CxmwEY56.js} +1 -1
  38. package/web/dist/assets/{chunk-5VM5RSS4-KPqV21jV.js → chunk-5VM5RSS4-_eVYLGll.js} +1 -1
  39. package/web/dist/assets/{chunk-EX3LRPZG-DfhiCVl_.js → chunk-EX3LRPZG-C4aqbjrf.js} +1 -1
  40. package/web/dist/assets/{chunk-JWPE2WC7-H6khJPuW.js → chunk-JWPE2WC7-Bh8ZxrI8.js} +1 -1
  41. package/web/dist/assets/{chunk-MOJQB5TN-BKMAzQxj.js → chunk-MOJQB5TN-BXgLlWVP.js} +1 -1
  42. package/web/dist/assets/{chunk-RYQCIY6F-DNXRTxtj.js → chunk-RYQCIY6F-B1rMIdfF.js} +1 -1
  43. package/web/dist/assets/{chunk-V7JOEXUC-B56PM6Gv.js → chunk-V7JOEXUC-C3AnSHXk.js} +1 -1
  44. package/web/dist/assets/{chunk-VR4S4FIN-Boi3hGkB.js → chunk-VR4S4FIN-CeoMJIBs.js} +1 -1
  45. package/web/dist/assets/{chunk-XXDRQBXY-DmJyn890.js → chunk-XXDRQBXY-DzaxQ9iP.js} +1 -1
  46. package/web/dist/assets/classDiagram-OUVF2IWQ-FsfpM3iu.js +1 -0
  47. package/web/dist/assets/classDiagram-v2-EOCWNBFH-FsfpM3iu.js +1 -0
  48. package/web/dist/assets/{cose-bilkent-JH36ORCC-BRdFlFWt.js → cose-bilkent-JH36ORCC-G46fvUOx.js} +1 -1
  49. package/web/dist/assets/{cynefin-VYW2F7L2-DqE-xib2.js → cynefin-VYW2F7L2-DXwqVhWA.js} +1 -1
  50. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-D-QBZjIL.js → cynefinDiagram-TSTJHNR4-DNNFmaXb.js} +1 -1
  51. package/web/dist/assets/{dagre-VKFMJZFB-DYu9oQV6.js → dagre-VKFMJZFB-BCfh-h3o.js} +1 -1
  52. package/web/dist/assets/{diagram-FQU43EPY-DOwYccCg.js → diagram-FQU43EPY-CyG9b6bj.js} +1 -1
  53. package/web/dist/assets/{diagram-G47NLZAW-DTSEmrPr.js → diagram-G47NLZAW-DdxDahK3.js} +1 -1
  54. package/web/dist/assets/{diagram-NH7WQ7WH-OqbzzquA.js → diagram-NH7WQ7WH-CjrZ_eja.js} +1 -1
  55. package/web/dist/assets/{diagram-OA4YK3LP-BMVG6A8I.js → diagram-OA4YK3LP-5NAjQpk2.js} +1 -1
  56. package/web/dist/assets/{diagram-WEI45ONY-Rn2Q5xaz.js → diagram-WEI45ONY-fLuDb54-.js} +1 -1
  57. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-aL32_v5y.js → ebnfDiagram-CCIWWBDH-DL9qnZpa.js} +1 -1
  58. package/web/dist/assets/{erDiagram-Q63AITRT-DKCpyczx.js → erDiagram-Q63AITRT-C_ynfrWY.js} +1 -1
  59. package/web/dist/assets/{flowDiagram-23GEKE2U-CrZTPgR_.js → flowDiagram-23GEKE2U-dYuH8HYv.js} +1 -1
  60. package/web/dist/assets/{ganttDiagram-NO4QXBWP-DnSE1OtC.js → ganttDiagram-NO4QXBWP-e8Meh3f3.js} +1 -1
  61. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-C5SeYPUR.js → gitGraphDiagram-IHSO6WYX-D_tZ4VL9.js} +1 -1
  62. package/web/dist/assets/index-Ct7SQv1M.css +1 -0
  63. package/web/dist/assets/{index-B-TEKrRB.js → index-DcQes9h8.js} +6 -6
  64. package/web/dist/assets/{infoDiagram-FWYZ7A6U-DDnJZp0X.js → infoDiagram-FWYZ7A6U-Dffhqp73.js} +1 -1
  65. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-ewhzfFkq.js → ishikawaDiagram-FXEZZL3T-DYt9H7kU.js} +1 -1
  66. package/web/dist/assets/{journeyDiagram-5HDEW3XC-T0iWWkLM.js → journeyDiagram-5HDEW3XC-Qv1vVu0F.js} +1 -1
  67. package/web/dist/assets/{kanban-definition-HUTT4EX6-D1MFQZjb.js → kanban-definition-HUTT4EX6-CTAUo40G.js} +1 -1
  68. package/web/dist/assets/{linear-BKfPK9wE.js → linear-CqLsWFLj.js} +1 -1
  69. package/web/dist/assets/{mermaid.core-CyzD7dTc.js → mermaid.core-okRCGPQQ.js} +4 -4
  70. package/web/dist/assets/{mindmap-definition-LN4V7U3C-CWXtcTlP.js → mindmap-definition-LN4V7U3C-DVOOrIwD.js} +1 -1
  71. package/web/dist/assets/{papaparse.min-DLUoV5k5.js → papaparse.min-Ddflu8oC.js} +1 -1
  72. package/web/dist/assets/{pegDiagram-2B236MQR-BFjZun-r.js → pegDiagram-2B236MQR-BLJlC0MZ.js} +1 -1
  73. package/web/dist/assets/{pieDiagram-ENE6RG2P-DMDtW4g7.js → pieDiagram-ENE6RG2P-DdOwICOg.js} +1 -1
  74. package/web/dist/assets/pilotswarm-CKh2MxfL.js +99 -0
  75. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CAwPVaYL.js → quadrantDiagram-ABIIQ3AL-B0Th1YO5.js} +1 -1
  76. package/web/dist/assets/{railroadDiagram-RFXS5EU6-Co102PkQ.js → railroadDiagram-RFXS5EU6-BF2XKMKf.js} +1 -1
  77. package/web/dist/assets/{requirementDiagram-TGXJPOKE-D19VlRSl.js → requirementDiagram-TGXJPOKE-Mr1iSwbS.js} +1 -1
  78. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-Sd_a3ukL.js → sankeyDiagram-HTMAVEWB-CrOjVZyg.js} +1 -1
  79. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-CojDRK1c.js → sequenceDiagram-DBY2YBRQ-H0X4Lydk.js} +1 -1
  80. package/web/dist/assets/{sizeCapture-X5ZJPWSS-XqXcxnDo.js → sizeCapture-X5ZJPWSS-Dus1QEqh.js} +1 -1
  81. package/web/dist/assets/{stateDiagram-2N3HPSRC-BcN_w699.js → stateDiagram-2N3HPSRC-BOBUSwdR.js} +1 -1
  82. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DABTDwFh.js +1 -0
  83. package/web/dist/assets/{swimlanes-5IMT3BWC-BcO6ai_I.js → swimlanes-5IMT3BWC-BoqT-Lh7.js} +2 -2
  84. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BF5mFN4t.js +8 -0
  85. package/web/dist/assets/{timeline-definition-FHXFAJF6-DOPfYxUq.js → timeline-definition-FHXFAJF6-BOrqUQHX.js} +1 -1
  86. package/web/dist/assets/{vennDiagram-L72KCM5P-BSKLkEOC.js → vennDiagram-L72KCM5P-DDkNL-Oo.js} +1 -1
  87. package/web/dist/assets/{wardleyDiagram-EHGQE667-BdcYAVw2.js → wardleyDiagram-EHGQE667-XUAJQ2X0.js} +1 -1
  88. package/web/dist/assets/{xychartDiagram-FW5EYKEG-C6EJT4jB.js → xychartDiagram-FW5EYKEG-ph69PV9y.js} +1 -1
  89. package/web/dist/index.html +3 -3
  90. package/ui/core/src/themes/dark-high-contrast.js +0 -56
  91. package/ui/core/src/themes/workspace-dark-rich.js +0 -65
  92. package/web/dist/assets/channel-BCzDctYg.js +0 -1
  93. package/web/dist/assets/classDiagram-OUVF2IWQ-ChqjVZil.js +0 -1
  94. package/web/dist/assets/classDiagram-v2-EOCWNBFH-ChqjVZil.js +0 -1
  95. package/web/dist/assets/index-D2B5bzSN.css +0 -1
  96. package/web/dist/assets/pilotswarm-DA0u7Cq_.js +0 -99
  97. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BV_w1D3C.js +0 -1
  98. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BnYdP0qP.js +0 -8
@@ -28,7 +28,6 @@ import {
28
28
  selectArtifactPickerModal,
29
29
  selectArtifactUploadModal,
30
30
  selectLiveActivityLines,
31
- selectChatBlocks,
32
31
  selectChatLines,
33
32
  selectChatPaneChrome,
34
33
  selectOutboxOverlayLines,
@@ -49,6 +48,7 @@ import {
49
48
  selectSessionGroupPickerModal,
50
49
  selectSessionOwnerFilterModal,
51
50
  selectSessionRows,
51
+ selectSessionStatusSummary,
52
52
  selectStatusBar,
53
53
  selectThemePickerModal,
54
54
  selectConfirmModal,
@@ -199,6 +199,9 @@ function normalizeProfileSettings(settings) {
199
199
  if (typeof candidate.themeId === "string" && candidate.themeId.trim()) {
200
200
  normalized.themeId = candidate.themeId.trim();
201
201
  }
202
+ if (typeof candidate.touchScale === "boolean") {
203
+ normalized.touchScale = candidate.touchScale;
204
+ }
202
205
  if (hasOwn(candidate, "sessionOwnerFilter") && candidate.sessionOwnerFilter && typeof candidate.sessionOwnerFilter === "object") {
203
206
  const storedFilter = candidate.sessionOwnerFilter;
204
207
  // Profiles saved before the "Shared with me" bucket existed have no
@@ -287,6 +290,7 @@ function hasOwn(value, key) {
287
290
  function profileSettingsFromViewState(state, preservedOtherChatViewMode = null) {
288
291
  return normalizeProfileSettings({
289
292
  themeId: state.themeId,
293
+ touchScale: state.touchScale,
290
294
  sessionOwnerFilter: state.ownerFilter,
291
295
  layoutAdjustments: {
292
296
  paneAdjust: state.paneAdjust,
@@ -311,6 +315,7 @@ function profileSettingsFromViewState(state, preservedOtherChatViewMode = null)
311
315
  function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode = null) {
312
316
  return normalizeProfileSettings({
313
317
  themeId: state?.ui?.themeId,
318
+ touchScale: state?.ui?.touchScale,
314
319
  // Derive the owner-filter default from the RESOLVED principal, never a
315
320
  // snapshot of state.sessions.ownerFilter — at mount that snapshot is
316
321
  // still {all:true} (principal not yet applied), and using it as the
@@ -593,6 +598,15 @@ function compactTitleRuns(title, maxWidth = 40) {
593
598
  return compactRuns.length > 0 ? compactRuns : title;
594
599
  }
595
600
 
601
+ function applyTouchScale(enabled) {
602
+ if (typeof document === "undefined") return;
603
+ // One attribute, one CSS block. Everything the scale touches — type ramp,
604
+ // control padding, hit targets — is already expressed as a custom property
605
+ // or a class the stylesheet owns, so this needs no per-component wiring.
606
+ if (enabled) document.documentElement.dataset.psTouch = "1";
607
+ else delete document.documentElement.dataset.psTouch;
608
+ }
609
+
596
610
  function applyDocumentTheme(themeId) {
597
611
  const theme = getTheme(themeId);
598
612
  if (!theme || typeof document === "undefined") return;
@@ -2508,108 +2522,6 @@ function StructuredChatBlocks({ lines, theme, controller = null }) {
2508
2522
  return React.createElement(StructuredBlockList, { blocks, theme, controller });
2509
2523
  }
2510
2524
 
2511
- // Rich (desktop-style) chat transcript. Consumes selectChatBlocks: plain
2512
- // user/assistant prose renders as proportional-type markdown messages;
2513
- // every other block (splash, thinking cards, system cards, epoch dividers)
2514
- // reuses the structured line renderer so the terminal-era special cases
2515
- // keep exactly one implementation.
2516
- /**
2517
- * One rich message, memoized.
2518
- *
2519
- * The body runs markdown through MarkdownPreviewContent — the single most
2520
- * expensive thing in the transcript — and a pane resize used to re-run it for
2521
- * every loaded message. `selectChatBlocks` now returns identity-stable blocks,
2522
- * so this bails out instead. Every prop must stay referentially stable for
2523
- * that to hold: `theme` comes from a Map, `controller` is a singleton, and
2524
- * `continued` is hoisted to a boolean because it depends on the PREVIOUS block
2525
- * and would otherwise have to be recomputed inside.
2526
- */
2527
- const RichChatMessage = React.memo(function RichChatMessage({ block, theme, controller, continued }) {
2528
- const header = block.header || {};
2529
- const roleClass = block.role === "user" ? " is-user" : " is-assistant";
2530
- const pendingClass = block.pendingPhase ? ` is-${block.pendingPhase}` : "";
2531
- const text = String(block.text || "");
2532
- // A speaker label only earns its place when it disambiguates: another human
2533
- // in a shared session. The viewer's own turns and the agent's are obvious
2534
- // from side and styling.
2535
- const showLabel = block.role === "user" && header.fromOtherPerson;
2536
- return React.createElement("article", {
2537
- className: `ps-rich-msg${roleClass}${pendingClass}${continued ? " is-continued" : ""}`,
2538
- },
2539
- React.createElement("header", { className: "ps-rich-msg-head" },
2540
- showLabel
2541
- ? React.createElement("span", {
2542
- className: "ps-rich-msg-label",
2543
- style: { color: resolveColor(theme, header.roleColor) || undefined },
2544
- }, header.roleLabel)
2545
- : null,
2546
- // Delivery glyph + timestamp are on-demand detail:
2547
- // revealed on hover so a quiet transcript stays quiet.
2548
- React.createElement("span", { className: "ps-rich-msg-meta" },
2549
- header.glyph
2550
- ? React.createElement("span", {
2551
- className: "ps-rich-msg-glyph",
2552
- title: block.pendingPhase || undefined,
2553
- style: { color: resolveColor(theme, header.glyphColor) || undefined },
2554
- }, header.glyph)
2555
- : null,
2556
- header.time
2557
- ? React.createElement("span", { className: "ps-rich-msg-time" }, header.time)
2558
- : null)),
2559
- text.trim()
2560
- ? React.createElement("div", { className: "ps-rich-msg-body" },
2561
- React.createElement(MarkdownPreviewContent, { content: text, theme }))
2562
- : null,
2563
- block.attachments
2564
- ? React.createElement(ArtifactImageStrip, {
2565
- controller,
2566
- sessionId: block.attachments.sessionId,
2567
- attachments: block.attachments.attachments,
2568
- })
2569
- : null);
2570
- });
2571
-
2572
- /** A non-message block (splash, system card, divider), memoized the same way. */
2573
- const RichChatLineBlock = React.memo(function RichChatLineBlock({ block, theme, controller }) {
2574
- const lines = React.useMemo(() => normalizeLines(block.lines || []), [block.lines]);
2575
- return React.createElement("div", {
2576
- className: `ps-rich-lineblock is-${block.variant || "event"}`,
2577
- }, React.createElement(StructuredChatBlocks, { lines, theme, controller }));
2578
- });
2579
-
2580
- function RichChatBlocks({ blocks, theme, controller = null }) {
2581
- return React.createElement("div", { className: "ps-rich-chat" },
2582
- (blocks || []).map((block, index) => {
2583
- if (block.kind === "message") {
2584
- // Consecutive turns from the same speaker read as one
2585
- // continuous passage — no repeated header, tighter spacing.
2586
- // Computed here because it reads the previous block; passing it
2587
- // down as a boolean is what keeps the child memoizable.
2588
- const previous = blocks[index - 1];
2589
- const header = block.header || {};
2590
- const showLabel = block.role === "user" && header.fromOtherPerson;
2591
- const continued = Boolean(previous
2592
- && previous.kind === "message"
2593
- && previous.role === block.role
2594
- && !(previous.header && previous.header.fromOtherPerson)
2595
- && !showLabel);
2596
- return React.createElement(RichChatMessage, {
2597
- key: block.id != null ? `msg:${block.id}` : `msg:${index}`,
2598
- block,
2599
- theme,
2600
- controller,
2601
- continued,
2602
- });
2603
- }
2604
- return React.createElement(RichChatLineBlock, {
2605
- key: `lines:${index}`,
2606
- block,
2607
- theme,
2608
- controller,
2609
- });
2610
- }));
2611
- }
2612
-
2613
2525
  // Authenticated artifact thumbnails: bytes are fetched through the transport
2614
2526
  // (Bearer token) and served to <img> as object URLs. Module-level cache so
2615
2527
  // scrolling the transcript doesn't refetch; oldest entries are revoked at cap.
@@ -2820,19 +2732,6 @@ function StructuredBlockList({ blocks, theme, controller = null }) {
2820
2732
  }));
2821
2733
  }
2822
2734
 
2823
- // Pane titles carry terminal-style bracketed runs (" [+3]", " [5f086c7c]").
2824
- // The rich view keeps the information but drops the brackets — a web title
2825
- // bar shows a count and an id, it does not draw them in ASCII delimiters.
2826
- function unbracketTitleRuns(runs) {
2827
- if (!Array.isArray(runs)) return runs;
2828
- return runs.map((run) => {
2829
- const text = String(run?.text ?? "");
2830
- const match = /^(\s*)\[(.+)\](\s*)$/.exec(text);
2831
- if (!match) return run;
2832
- return { ...run, text: `${match[1]}${match[2]}${match[3]}` };
2833
- });
2834
- }
2835
-
2836
2735
  function Panel({ title, titleRight = null, color = "gray", focused = false, actions = null, children, theme, className = "" }) {
2837
2736
  const accent = resolveColor(theme, color);
2838
2737
  // A panel with nothing to put in its header should not render one. Nested
@@ -3118,7 +3017,6 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
3118
3017
  // title in proportional type, and the id/age/model metadata demoted to a
3119
3018
  // muted second line under the selected row. Depth becomes real indentation
3120
3019
  // with a guide rail rather than an ASCII "└" prefix.
3121
- const SESSION_KIND_GLYPHS = { group: "🗂", system: "⚙", service: "⚗" };
3122
3020
 
3123
3021
  /**
3124
3022
  * Selected-session details, pinned to the bottom of the Sessions panel.
@@ -3188,6 +3086,8 @@ function SessionDetailBox({ session, childCount = 0 }) {
3188
3086
  ? (session.memberCount == null ? null : String(session.memberCount))
3189
3087
  : (childCount > 0 ? String(childCount) : null);
3190
3088
 
3089
+ const statusSummary = selectSessionStatusSummary(session);
3090
+
3191
3091
  return React.createElement("div", { className: "ps-session-detail-box" },
3192
3092
  // The row above ellipsizes to one line, so this is the only place the
3193
3093
  // whole name is legible. Two lines are RESERVED whether or not they are
@@ -3198,7 +3098,10 @@ function SessionDetailBox({ session, childCount = 0 }) {
3198
3098
  field("Context", context, percent != null && percent >= 85 ? "is-hot" : percent != null && percent >= 70 ? "is-warm" : ""),
3199
3099
  field("Cron", cron, session?.cronActive === true ? "is-armed" : ""),
3200
3100
  field("Agent", session?.agentId),
3201
- field("Status", session?.status),
3101
+ // "when, and what it is doing" in one row. The status comes from the
3102
+ // shared derivation rather than the raw field — see
3103
+ // selectSessionStatusSummary for why raw made this flip idle/waiting.
3104
+ field("Updated", statusSummary ? `${statusSummary.relative} (${statusSummary.status})` : null),
3202
3105
  field("Children", children),
3203
3106
  field("Access", access));
3204
3107
  }
@@ -3248,7 +3151,7 @@ function railNodeInsetPx(depth) {
3248
3151
  }
3249
3152
 
3250
3153
  const SessionListRow = React.memo(function SessionListRow({
3251
- row, theme, rich, structuredRows, mobile, onRowClick, setRef, drag,
3154
+ row, theme, structuredRows, mobile, onRowClick, setRef, drag,
3252
3155
  }) {
3253
3156
  const depth = Math.max(0, row.depth);
3254
3157
  const railStyle = React.useMemo(() => {
@@ -3340,77 +3243,18 @@ const SessionListRow = React.memo(function SessionListRow({
3340
3243
  },
3341
3244
  },
3342
3245
  React.createElement("div", {
3343
- className: rich ? "ps-session-row-content is-rich" : "ps-line ps-session-row-content",
3246
+ className: "ps-line ps-session-row-content",
3344
3247
  style: {
3345
3248
  // Nested content starts where its node mark does, so the mark
3346
3249
  // lands centred on the deepest rail and the text clears it.
3347
3250
  paddingInlineStart: depth > 0 ? `${railNodeInsetPx(depth)}px` : "0px",
3348
3251
  },
3349
3252
  },
3350
- rich
3351
- ? React.createElement(RichSessionRow, { row, theme, showDetail: mobile })
3352
- : React.createElement(SessionRowContent, {
3353
- row, theme, structured: structuredRows, showInlineDetail: mobile,
3354
- })));
3253
+ React.createElement(SessionRowContent, {
3254
+ row, theme, structured: structuredRows, showInlineDetail: mobile,
3255
+ })));
3355
3256
  });
3356
3257
 
3357
- function RichSessionRow({ row, theme, showDetail = false }) {
3358
- const chrome = row.chrome;
3359
- if (!chrome) return React.createElement(SessionRowContent, { row, theme, structured: true, showInlineDetail: showDetail });
3360
-
3361
- const kindGlyph = SESSION_KIND_GLYPHS[chrome.kind] || null;
3362
- const accent = resolveColor(theme, chrome.accentColor) || undefined;
3363
- // Mobile has no room for a detail box, so it keeps the inline detail line
3364
- // it always had. Desktop moves that content to the panel footer.
3365
- const detailRuns = showDetail && Array.isArray(row.detailRuns) ? row.detailRuns : [];
3366
-
3367
- return React.createElement("div", { className: "ps-rich-session-row" },
3368
- React.createElement("div", { className: "ps-rich-session-main" },
3369
- chrome.selectMode
3370
- ? React.createElement("span", {
3371
- className: `ps-rich-session-check${chrome.checked ? " is-checked" : ""}`,
3372
- }, chrome.checked ? "✓" : "")
3373
- : null,
3374
- kindGlyph
3375
- ? React.createElement("span", { className: "ps-rich-session-kind" }, kindGlyph)
3376
- : React.createElement("span", {
3377
- className: `ps-rich-session-dot${row.depth > 0 ? " is-node" : ""}`,
3378
- style: row.depth > 0
3379
- ? { borderColor: resolveColor(theme, chrome.statusColor) || undefined }
3380
- : { background: resolveColor(theme, chrome.statusColor) || undefined },
3381
- title: row.status || undefined,
3382
- }),
3383
- chrome.owner
3384
- ? React.createElement("span", {
3385
- className: `ps-rich-session-owner${chrome.owner.isMine ? " is-mine" : ""}`,
3386
- title: chrome.owner.initials,
3387
- }, chrome.owner.initials)
3388
- : null,
3389
- React.createElement("span", {
3390
- className: `ps-rich-session-title${chrome.untitled ? " is-untitled" : ""}`,
3391
- style: chrome.kind === "session" ? undefined : { color: accent },
3392
- title: chrome.title,
3393
- }, chrome.title),
3394
- chrome.pinned ? React.createElement("span", { className: "ps-rich-session-pin", title: "Pinned" }, React.createElement(PinGlyph)) : null,
3395
- chrome.cron ? React.createElement("span", { className: "ps-rich-session-cron", title: "Scheduled" }, "⏱") : null,
3396
- chrome.childBadge
3397
- ? React.createElement("span", {
3398
- className: "ps-rich-session-count",
3399
- style: { color: resolveColor(theme, chrome.childBadge.color) || undefined },
3400
- }, chrome.childBadge.text)
3401
- : null,
3402
- chrome.ctx
3403
- ? React.createElement("span", {
3404
- className: "ps-rich-session-ctx",
3405
- style: { color: resolveColor(theme, chrome.ctx.color) || undefined },
3406
- }, chrome.ctx.text)
3407
- : null),
3408
- detailRuns.length > 0
3409
- ? React.createElement("div", { className: "ps-rich-session-detail" },
3410
- React.createElement(Runs, { runs: detailRuns, theme }))
3411
- : null);
3412
- }
3413
-
3414
3258
  /**
3415
3259
  * Portal row prefix: drop the "└ " depth glyph (the guide rail says it, and
3416
3260
  * better — a rail shows a branch that CONTINUES, which the glyph cannot), and
@@ -3426,6 +3270,9 @@ function portalRowRuns(runs, theme) {
3426
3270
  const rest = [];
3427
3271
  for (const run of runs) {
3428
3272
  if (run?.role === "depth") continue;
3273
+ // The row's own styling already marks selection; drawing the bar too
3274
+ // would cost a column and truncate the row early.
3275
+ if (run?.role === "selection") continue;
3429
3276
  if (run?.role === "status") {
3430
3277
  statusColor = resolveColor(theme, run.color) || null;
3431
3278
  continue;
@@ -3643,11 +3490,6 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
3643
3490
  connectionMode: state.connection?.mode || "local",
3644
3491
  modalOpen: Boolean(state.ui.modal),
3645
3492
  focused: state.ui.focusRegion === "sessions",
3646
- // The rich chat view also restyles the session list, so the portal
3647
- // reads as one product rather than half terminal / half desktop.
3648
- // TODO: promote this to its own `ui.richUi` setting if the two are
3649
- // ever wanted independently.
3650
- rich: Boolean(getTheme(state.ui.themeId)?.richChat),
3651
3493
  // Clicking empty space clears the list highlight; the row VM reads it,
3652
3494
  // so the reconstruction below must carry it or the click does nothing
3653
3495
  // visible (the same omission that blinded the Node Map).
@@ -3736,9 +3578,21 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
3736
3578
  && String(activeSession.owner.provider) === String(authPrincipal.provider)
3737
3579
  && String(activeSession.owner.subject) === String(authPrincipal.subject),
3738
3580
  );
3581
+ // System sessions are ADMIN-modifiable and read-only for everyone else —
3582
+ // which is exactly what the server already enforces in
3583
+ // evaluateSessionAccess: an admin passes every class, and for a non-admin
3584
+ // a system session allows read and refuses every write with "System
3585
+ // sessions are managed by administrators."
3586
+ //
3587
+ // This gate used to be STRICTER than the server, excluding isSystem for
3588
+ // everybody, so an admin who was entitled to rename or re-model the
3589
+ // sweeper had the button disabled with no way to reach the capability.
3590
+ // Client and server now agree; the server remains the enforcement point.
3739
3591
  const canModifyActiveSession = Boolean(
3740
- activeSession && !activeSession.isSystem && !activeSession.isGroup
3741
- && (isAdminViewer || ownsActiveSession),
3592
+ activeSession && !activeSession.isGroup
3593
+ && (activeSession.isSystem
3594
+ ? isAdminViewer
3595
+ : (isAdminViewer || ownsActiveSession)),
3742
3596
  );
3743
3597
  const selectedCount = Array.isArray(viewState.selectedIds) ? viewState.selectedIds.length : 0;
3744
3598
  const isBulkSelection = selectedCount > 1;
@@ -3775,7 +3629,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
3775
3629
  : (activeIsGroupable ? [activeSession.sessionId] : []);
3776
3630
  // The folder button is never disabled: with nothing to move it makes an
3777
3631
  // empty folder to drag into, which is the natural way to get one at all.
3778
- const combinedPanelClassName = `ps-session-pane${viewState.rich ? " is-rich" : ""}${panelClassName ? ` ${panelClassName}` : ""}`;
3632
+ const combinedPanelClassName = `ps-session-pane${panelClassName ? ` ${panelClassName}` : ""}`;
3779
3633
  // The click handler must be referentially stable or every memoized row
3780
3634
  // re-renders on each keypress, defeating the point. It reads the current
3781
3635
  // rows/viewState from a ref that is refreshed on every render instead of
@@ -3800,15 +3654,40 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
3800
3654
  cancelAnimationFrame(autoScrollRef.current);
3801
3655
  autoScrollRef.current = null;
3802
3656
  }, []);
3657
+ /**
3658
+ * The element that actually scrolls the session list.
3659
+ *
3660
+ * Walked up from the list itself rather than resolved from the pointer:
3661
+ * `[data-session-scroll]` is only stamped on the pane while it holds
3662
+ * focus, and the pane and the list inside it BOTH declare overflow — which
3663
+ * of the two really scrolls depends on how the flex layout resolved. The
3664
+ * old lookup could land on the one whose scrollHeight equals its
3665
+ * clientHeight, where `scrollTop +=` is silently a no-op and the list just
3666
+ * sat there while you dragged past its edge.
3667
+ */
3668
+ const findSessionScroller = React.useCallback(() => {
3669
+ let node = sessionListRef.current;
3670
+ while (node && node !== document.body) {
3671
+ const overflowY = window.getComputedStyle(node).overflowY;
3672
+ if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight - node.clientHeight > 1) {
3673
+ return node;
3674
+ }
3675
+ node = node.parentElement;
3676
+ }
3677
+ return null;
3678
+ }, []);
3679
+
3803
3680
  const updateAutoScroll = React.useCallback((x, y) => {
3804
3681
  const EDGE_PX = 48;
3805
3682
  const MAX_STEP_PX = 14;
3806
- const el = document.elementFromPoint(x, y);
3807
- const scroller = el?.closest?.("[data-session-scroll]")
3808
- || document.querySelector("[data-session-scroll]");
3683
+ const scroller = findSessionScroller();
3809
3684
  if (!scroller) { stopAutoScroll(); return; }
3810
3685
  const box = scroller.getBoundingClientRect();
3811
3686
  let step = 0;
3687
+ // Each branch's own guard already makes its numerator positive, and
3688
+ // Math.min caps the ramp at full speed — so dragging PAST the edge, the
3689
+ // case this exists for, keeps scrolling at MAX_STEP rather than
3690
+ // accelerating without bound.
3812
3691
  if (y < box.top + EDGE_PX) {
3813
3692
  step = -MAX_STEP_PX * Math.min(1, (box.top + EDGE_PX - y) / EDGE_PX);
3814
3693
  } else if (y > box.bottom - EDGE_PX) {
@@ -3827,7 +3706,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
3827
3706
  autoScrollRef.current = requestAnimationFrame(tick);
3828
3707
  };
3829
3708
  autoScrollRef.current = requestAnimationFrame(tick);
3830
- }, [stopAutoScroll]);
3709
+ }, [findSessionScroller, stopAutoScroll]);
3831
3710
 
3832
3711
  const finishDrag = React.useCallback((commit) => {
3833
3712
  const pending = dragRef.current;
@@ -3913,6 +3792,18 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
3913
3792
  */
3914
3793
  const resolveDropIntent = React.useCallback((x, y, pending) => {
3915
3794
  const overGroupId = resolveDropGroup(x, y);
3795
+ // A FOLDER has a groupId of its own — its own id. Feeding that through
3796
+ // the filing branches below meant every position in the list resolved
3797
+ // to "file into that other folder" or "take it out of itself", and a
3798
+ // folder could never once be reordered. Folders nest in nothing, so
3799
+ // reorder is the only gesture they have.
3800
+ if (pending?.isGroup) {
3801
+ const target = pending.ids.length === 1
3802
+ ? resolveReorderTarget(x, y, pending.container, pending.ids[0])
3803
+ : undefined;
3804
+ if (target === undefined) return { kind: "none", overGroupId: null, before: undefined, after: null };
3805
+ return { kind: "reorder", overGroupId: null, before: target.before, after: target.after };
3806
+ }
3916
3807
  const ownGroupId = pending?.ownGroupId || null;
3917
3808
  if (overGroupId && overGroupId !== ownGroupId) {
3918
3809
  return { kind: "file", overGroupId, before: undefined, after: null };
@@ -4033,6 +3924,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4033
3924
  beforeSessionId: undefined,
4034
3925
  orderable: Boolean(row.orderable),
4035
3926
  container: row.orderContainer ?? null,
3927
+ isGroup: Boolean(row.isGroup),
4036
3928
  // The folder the row currently lives in: filing into the
4037
3929
  // folder it is ALREADY in is a reorder, not a move.
4038
3930
  ownGroupId: row.groupId || null,
@@ -4131,21 +4023,56 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4131
4023
  }
4132
4024
  }, []);
4133
4025
 
4026
+ // Which (session, focus, modal) combination has already been pulled into
4027
+ // view. Anything else re-arms the reveal; a bare list refresh does not.
4028
+ const revealedRowKeyRef = React.useRef(null);
4029
+ const activeRowRevealKey = `${viewState.activeSessionId || ""}${viewState.focused ? 1 : 0}${viewState.modalOpen ? 1 : 0}`;
4030
+
4134
4031
  React.useEffect(() => {
4135
- if (viewState.modalOpen || !viewState.focused || !viewState.activeSessionId) return;
4032
+ if (viewState.modalOpen || !viewState.focused || !viewState.activeSessionId) {
4033
+ // Disarm, so returning from a modal (or refocusing the pane) counts
4034
+ // as a fresh arming. Without this the ref still holds the key from
4035
+ // before the modal opened, the comparison below matches, and the
4036
+ // row is never brought back into view on the way out.
4037
+ revealedRowKeyRef.current = null;
4038
+ return;
4039
+ }
4136
4040
  const activeButton = sessionButtonRefs.current.get(viewState.activeSessionId);
4041
+ // The row is not rendered yet. On a page reload the active session id
4042
+ // is restored from the profile BEFORE the listing arrives, so the first
4043
+ // run lands here — leaving the key un-consumed is what lets the reveal
4044
+ // retry once `sessionsFlat` brings the row in.
4137
4045
  if (!activeButton) return;
4138
4046
 
4139
- if (document.activeElement !== activeButton) {
4047
+ // Everything below runs ONCE per arming. This effect is woken by every
4048
+ // list refresh (~4×/sec while a session streams), and moving DOM focus
4049
+ // on each of those was actively dangerous: the Manage and Copy-link
4050
+ // dialogs are local React state rather than `ui.modal`, so
4051
+ // `viewState.modalOpen` is false while they are open — focus was ripped
4052
+ // out of their text inputs mid-keystroke, and because the global
4053
+ // shortcut handler decides "am I editable?" from the event target, the
4054
+ // rest of what the user typed ran as commands (d = complete,
4055
+ // D = delete). Scrolling on every refresh has a milder failure but the
4056
+ // same shape: the list yanks back to the active row while the user is
4057
+ // deliberately scrolled away browsing older sessions.
4058
+ if (revealedRowKeyRef.current === activeRowRevealKey) return;
4059
+ revealedRowKeyRef.current = activeRowRevealKey;
4060
+
4061
+ // Never take focus off something the user is typing into. Belt and
4062
+ // braces on top of the arming guard, for any future caller of this
4063
+ // effect that fires while a local dialog is up.
4064
+ const focused = document.activeElement;
4065
+ const isTypingTarget = Boolean(focused && (
4066
+ focused.tagName === "INPUT"
4067
+ || focused.tagName === "TEXTAREA"
4068
+ || focused.tagName === "SELECT"
4069
+ || focused.isContentEditable
4070
+ ));
4071
+ if (focused !== activeButton && !isTypingTarget) {
4140
4072
  activeButton.focus({ preventScroll: true });
4141
4073
  }
4142
- // Only pull the active row into view when the active session itself
4143
- // changes (or on focus/modal transitions). Re-running scrollIntoView
4144
- // on every `sessions/loaded` refresh would yank the list back to the
4145
- // active row even when the user has deliberately scrolled away to
4146
- // browse older sessions in a long list.
4147
4074
  activeButton.scrollIntoView({ block: "nearest" });
4148
- }, [viewState.activeSessionId, viewState.focused, viewState.modalOpen]);
4075
+ }, [activeRowRevealKey, viewState.activeSessionId, viewState.focused, viewState.modalOpen, viewState.sessionsFlat]);
4149
4076
 
4150
4077
  const panelActions = React.createElement(React.Fragment, null,
4151
4078
  isBulkSelection
@@ -4191,7 +4118,20 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4191
4118
  icon: React.createElement(ManageGlyph),
4192
4119
  onClick: () => setManageOpen(true),
4193
4120
  disabled: !canModifyActiveSession || isBulkSelection,
4194
- label: isBulkSelection ? "Disabled while multiple sessions are selected" : "Manage session — rename, switch model, and sharing",
4121
+ // A disabled control has to say WHY. This label used to describe
4122
+ // the ENABLED behaviour in every disabled case except bulk
4123
+ // selection, so on a system session the tooltip promised "rename,
4124
+ // switch model, and sharing" while the click did nothing — which
4125
+ // reads as a broken dialog rather than an unavailable action.
4126
+ label: isBulkSelection
4127
+ ? "Disabled while multiple sessions are selected"
4128
+ : activeSession?.isSystem
4129
+ ? "System sessions are managed by administrators"
4130
+ : activeSession?.isGroup
4131
+ ? "Folders cannot be managed here — use the folder controls"
4132
+ : !canModifyActiveSession
4133
+ ? "Only this session's owner or an admin can manage it"
4134
+ : "Manage session — rename, switch model, and sharing",
4195
4135
  }),
4196
4136
  React.createElement(IconButton, {
4197
4137
  className: "ps-mini-button",
@@ -4241,7 +4181,14 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4241
4181
  ? `${dragState.ids.length} sessions`
4242
4182
  : (dragState.titles[0] || "session"))),
4243
4183
  React.createElement("div", { className: "ps-drag-ghost__hint" },
4244
- dragState.overGroupId ? "Release to file here" : "Release to remove from folder"))
4184
+ // Read the resolved INTENT, not just the folder under the
4185
+ // pointer. Folders always resolve to reorder with a null
4186
+ // overGroupId, so the old test labelled every folder drag
4187
+ // "Release to remove from folder" — a folder is in no folder.
4188
+ dragState.intent === "file" ? "Release to file here"
4189
+ : dragState.intent === "unfile" ? "Release to remove from folder"
4190
+ : dragState.intent === "reorder" ? "Release to move here"
4191
+ : "Release to cancel"))
4245
4192
  : null;
4246
4193
 
4247
4194
  return React.createElement(React.Fragment, null,
@@ -4275,7 +4222,6 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4275
4222
  key: row.sessionId,
4276
4223
  row,
4277
4224
  theme,
4278
- rich: viewState.rich,
4279
4225
  structuredRows,
4280
4226
  mobile: isMobilePane,
4281
4227
  onRowClick: handleRowClick,
@@ -4977,7 +4923,6 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
4977
4923
  () => appendAnimatedDotsToRuns(chrome.titleRight, chrome.animateTitleRight ? animatedDots : ""),
4978
4924
  [animatedDots, chrome.animateTitleRight, chrome.titleRight],
4979
4925
  );
4980
- const richMode = Boolean(theme?.richChat) && !viewState.activeSessionIsGroup;
4981
4926
  const [loadingOlder, setLoadingOlder] = React.useState(false);
4982
4927
  // Scroll-up expands the transcript automatically until the soft cap, then
4983
4928
  // refuses — and the portal had no control to ask for more, so a busy
@@ -4987,23 +4932,10 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
4987
4932
  viewState.activeHistory?.hasOlderEvents
4988
4933
  && Number(viewState.activeHistory?.loadedEventCount || 0) >= AUTO_HISTORY_EVENT_SOFT_CAP,
4989
4934
  );
4990
- const richBlocks = React.useMemo(
4991
- () => (richMode ? selectChatBlocks(selectorState, viewState.contentWidth, { tableMode: "sentinel" }) : null),
4992
- [richMode, selectorState, viewState.contentWidth],
4935
+ const lines = React.useMemo(
4936
+ () => selectChatLines(selectorState, viewState.contentWidth, { tableMode: "sentinel" }),
4937
+ [selectorState, viewState.contentWidth],
4993
4938
  );
4994
- const lines = React.useMemo(() => {
4995
- if (richBlocks) {
4996
- // Rich mode paints from `richBlocks` via renderBody; these
4997
- // pseudo-lines exist only as the scroll-sync change signal
4998
- // (autoscroll-to-bottom fires when a block grows or arrives).
4999
- return richBlocks.map((block, index) => ({
5000
- text: block.kind === "message"
5001
- ? `m:${block.id ?? index}:${(block.text || "").length}:${block.pendingPhase || ""}`
5002
- : `l:${index}:${(block.lines || []).length}`,
5003
- }));
5004
- }
5005
- return selectChatLines(selectorState, viewState.contentWidth, { tableMode: "sentinel" });
5006
- }, [richBlocks, selectorState, viewState.contentWidth]);
5007
4939
  const spinnerFrame = useSpinnerFrame(viewState.activeSessionStatus === "running");
5008
4940
  const liveActivityLines = React.useMemo(
5009
4941
  () => selectLiveActivityLines(selectorState, { spinnerFrame, maxWidth: viewState.contentWidth }),
@@ -5072,10 +5004,7 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5072
5004
 
5073
5005
  return React.createElement(ScrollLinesPanel, {
5074
5006
  controller,
5075
- title: (() => {
5076
- const baseTitle = mobile ? compactTitleRuns(chrome.title, 28) : chrome.title;
5077
- return richMode ? unbracketTitleRuns(baseTitle) : baseTitle;
5078
- })(),
5007
+ title: mobile ? compactTitleRuns(chrome.title, 28) : chrome.title,
5079
5008
  titleRight: mobile && titleRight ? compactTitleRuns(titleRight, 18) : titleRight,
5080
5009
  // Chat/Summary toggling lives on the top toolbar so the pane chrome
5081
5010
  // stays clean. The toolbar button is disabled for group sessions.
@@ -5087,8 +5016,8 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5087
5016
  scrollOffset: viewState.scroll,
5088
5017
  scrollMode: "bottom",
5089
5018
  paneKey: "chat",
5090
- className: richMode ? "is-wrapped is-rich" : "is-wrapped",
5091
- panelClassName: richMode ? "ps-chat-panel is-rich" : "ps-chat-panel",
5019
+ className: "is-wrapped",
5020
+ panelClassName: "ps-chat-panel",
5092
5021
  bottomContent: composer,
5093
5022
  topContent: showLoadOlder
5094
5023
  ? React.createElement("div", { className: "ps-load-older-bar" },
@@ -5105,13 +5034,7 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5105
5034
  }, loadingOlder ? "Loading older messages…" : "↑ Load older messages"))
5106
5035
  : null,
5107
5036
  structuredBlocks: true,
5108
- renderBody: richBlocks
5109
- ? (_bodyLines, bodyTheme) => React.createElement(RichChatBlocks, {
5110
- blocks: richBlocks,
5111
- theme: bodyTheme,
5112
- controller,
5113
- })
5114
- : null,
5037
+ renderBody: null,
5115
5038
  });
5116
5039
  }
5117
5040
 
@@ -6429,7 +6352,6 @@ function IconButton({ icon, label, onClick, disabled = false, active = false, cl
6429
6352
  }
6430
6353
 
6431
6354
  function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus = null, chatFocusDisabled = false }) {
6432
- const richUi = useControllerSelector(controller, (state) => Boolean(getTheme(state.ui.themeId)?.richChat));
6433
6355
  const [headerSlot, setHeaderSlot] = React.useState(null);
6434
6356
  React.useEffect(() => {
6435
6357
  if (typeof document === "undefined") return;
@@ -6438,7 +6360,7 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
6438
6360
  // rich view had already handed that row back to the panes. Mobile keeps
6439
6361
  // the inline strip; there is no header room for it on a phone.
6440
6362
  setHeaderSlot(!mobile ? document.getElementById("ps-header-toolbar-slot") : null);
6441
- }, [richUi, mobile]);
6363
+ }, [mobile]);
6442
6364
  const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
6443
6365
  const chatView = useControllerSelector(controller, (state) => ({
6444
6366
  mode: state.ui.chatViewMode || "transcript",
@@ -6874,6 +6796,7 @@ function MobileNav({ activePane, setActivePane, controller }) {
6874
6796
 
6875
6797
  function ModalLayer({ controller }) {
6876
6798
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
6799
+ const touchScale = useControllerSelector(controller, (state) => Boolean(state.ui.touchScale));
6877
6800
  const theme = getTheme(themeId);
6878
6801
  const modalState = useControllerSelector(controller, (state) => ({
6879
6802
  rawModal: state.ui.modal,
@@ -6977,7 +6900,22 @@ function ModalLayer({ controller }) {
6977
6900
 
6978
6901
  const close = () => controller.handleCommand(UI_COMMANDS.CLOSE_MODAL).catch(() => {});
6979
6902
 
6980
- const renderListModal = (presentation, confirmLabel = "Apply") => {
6903
+ // Display preferences that belong beside the theme rather than in a
6904
+ // settings screen of their own. Rendered in the theme picker's footer: it
6905
+ // is the "how this looks" surface, and a checkbox there is discoverable in
6906
+ // a way a keyboard-only command never is.
6907
+ const renderTouchScaleToggle = () => React.createElement("label", {
6908
+ className: "ps-modal-toggle",
6909
+ title: "Larger type and bigger hit targets, for phones and touch screens.",
6910
+ },
6911
+ React.createElement("input", {
6912
+ type: "checkbox",
6913
+ checked: touchScale,
6914
+ onChange: (event) => controller.dispatch({ type: "ui/touchScale", enabled: event.target.checked }),
6915
+ }),
6916
+ React.createElement("span", null, "Mobile"));
6917
+
6918
+ const renderListModal = (presentation, confirmLabel = "Apply", footerExtras = null) => {
6981
6919
  const rows = Array.isArray(presentation.rows) ? presentation.rows : [];
6982
6920
  const rowItemIndexes = Array.isArray(presentation.rowItemIndexes) ? presentation.rowItemIndexes : null;
6983
6921
  const usesHangingIndent = modal.type === "modelPicker" || modal.type === "reasoningEffortPicker" || modal.type === "contextTierPicker" || modal.type === "sessionAgentPicker";
@@ -6998,24 +6936,48 @@ function ModalLayer({ controller }) {
6998
6936
  key: item?.id || `row:${rowIndex}`,
6999
6937
  type: "button",
7000
6938
  className: `ps-list-button ps-modal-list-button${itemIndex === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}${item?.disabled ? " is-disabled" : ""}`,
7001
- onClick: () => controller.dispatch({ type: "ui/modalSelection", index: itemIndex }),
6939
+ // A section header is a disclosure control: one click opens
6940
+ // or closes it. Selecting it and making the user hit the
6941
+ // footer button would be a two-step for something that
6942
+ // reads as a twisty.
6943
+ onClick: () => {
6944
+ controller.dispatch({ type: "ui/modalSelection", index: itemIndex });
6945
+ if (item?.kind === "section") controller.toggleAgentPickerSection(item.sectionKey);
6946
+ },
7002
6947
  },
7003
6948
  React.createElement("div", { className: "ps-line ps-modal-list-line" },
7004
6949
  React.createElement(Runs, { runs, theme })));
7005
6950
  })
7006
- : (modal.items || []).map((item, index) => React.createElement("button", {
7007
- key: item.id || index,
7008
- type: "button",
7009
- className: `ps-list-button ps-modal-list-button${index === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}${item?.disabled ? " is-disabled" : ""}`,
7010
- onClick: () => controller.dispatch({ type: "ui/modalSelection", index }),
7011
- },
7012
- React.createElement("div", { className: "ps-line ps-modal-list-line" },
7013
- React.createElement(Runs, {
7014
- runs: Array.isArray(rows?.[index])
7015
- ? rows[index]
7016
- : normalizeLines([rows?.[index]])[0]?.runs || [{ text: rows?.[index]?.text || "", color: rows?.[index]?.color }],
7017
- theme,
7018
- }))));
6951
+ : (modal.items || []).flatMap((item, index, allItems) => {
6952
+ const button = React.createElement("button", {
6953
+ key: item.id || index,
6954
+ type: "button",
6955
+ className: `ps-list-button ps-modal-list-button${index === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}${item?.disabled ? " is-disabled" : ""}`,
6956
+ onClick: () => controller.dispatch({ type: "ui/modalSelection", index }),
6957
+ },
6958
+ React.createElement("div", { className: "ps-line ps-modal-list-line" },
6959
+ React.createElement(Runs, {
6960
+ runs: Array.isArray(rows?.[index])
6961
+ ? rows[index]
6962
+ : normalizeLines([rows?.[index]])[0]?.runs || [{ text: rows?.[index]?.text || "", color: rows?.[index]?.color }],
6963
+ theme,
6964
+ })));
6965
+ // A group heading is emitted BETWEEN buttons, never as one of
6966
+ // them: `index` still addresses items 1:1, so selectedIndex,
6967
+ // arrow-key navigation and the scroll-into-view math are all
6968
+ // untouched by grouping. Only lists whose items carry a
6969
+ // `group` (today: the theme picker) get headings at all.
6970
+ const startsGroup = item?.group && item.group !== allItems[index - 1]?.group;
6971
+ if (!startsGroup) return [button];
6972
+ return [
6973
+ React.createElement("div", {
6974
+ key: `group:${item.group}`,
6975
+ className: "ps-modal-list-group",
6976
+ "aria-hidden": "true",
6977
+ }, item.group),
6978
+ button,
6979
+ ];
6980
+ });
7019
6981
 
7020
6982
  return React.createElement("div", { className: "ps-modal-backdrop", onClick: close },
7021
6983
  React.createElement("div", { className: `ps-modal is-list${modal.type === "themePicker" ? " is-theme-picker" : ""}`, onClick: (event) => event.stopPropagation() },
@@ -7033,6 +6995,7 @@ function ModalLayer({ controller }) {
7033
6995
  ),
7034
6996
  ),
7035
6997
  React.createElement("div", { className: "ps-modal-footer" },
6998
+ footerExtras,
7036
6999
  React.createElement("button", { type: "button", className: "ps-modal-button", onClick: close }, "Cancel"),
7037
7000
  React.createElement("button", {
7038
7001
  type: "button",
@@ -7097,7 +7060,7 @@ function ModalLayer({ controller }) {
7097
7060
  ));
7098
7061
  }
7099
7062
  if (modal.type === "themePicker" && modalState.themePicker) {
7100
- return renderListModal(modalState.themePicker, "Apply Theme");
7063
+ return renderListModal(modalState.themePicker, "Apply Theme", renderTouchScaleToggle());
7101
7064
  }
7102
7065
  // In switch-model mode the model/reasoning pickers retarget an existing
7103
7066
  // session, so the confirm action is "Switch Model", not "Create Session".
@@ -7112,7 +7075,14 @@ function ModalLayer({ controller }) {
7112
7075
  return renderListModal(modalState.contextTierPicker, pickerConfirmLabel);
7113
7076
  }
7114
7077
  if (modal.type === "sessionAgentPicker" && modalState.sessionAgentPicker) {
7115
- return renderListModal(modalState.sessionAgentPicker, "Create Session");
7078
+ // The primary button follows the selection: on a section it is the
7079
+ // disclosure, not a create, and labelling it "Create Session" would
7080
+ // promise something Enter does not do there.
7081
+ const picked = modal.items?.[modal.selectedIndex || 0];
7082
+ const confirmLabel = picked?.kind === "section"
7083
+ ? (picked.collapsed ? "Open" : "Close")
7084
+ : "Create Session";
7085
+ return renderListModal(modalState.sessionAgentPicker, confirmLabel);
7116
7086
  }
7117
7087
  if (modal.type === "sessionGroupPicker" && modalState.sessionGroupPicker) {
7118
7088
  return renderListModal(modalState.sessionGroupPicker, "Move");
@@ -7500,6 +7470,18 @@ function useKeyboardShortcuts(controller, mobile) {
7500
7470
  controller.handleCommand(UI_COMMANDS.MODAL_PANE_NEXT).catch(() => {});
7501
7471
  return;
7502
7472
  }
7473
+ // Disclosure keys for the agent picker. Scoped to that modal:
7474
+ // every other list modal is flat, and left/right there would
7475
+ // either do nothing or fight the Tab pane-cycling. Without
7476
+ // this the picker's own hint ("Enter or → to open") named a
7477
+ // key nothing listened for.
7478
+ if (modal.type === "sessionAgentPicker" && (event.key === "ArrowRight" || event.key === "ArrowLeft")) {
7479
+ event.preventDefault();
7480
+ controller.handleCommand(
7481
+ event.key === "ArrowRight" ? UI_COMMANDS.MODAL_PANE_NEXT : UI_COMMANDS.MODAL_PANE_PREV,
7482
+ ).catch(() => {});
7483
+ return;
7484
+ }
7503
7485
  if (event.key === "ArrowUp" || event.key === "k") {
7504
7486
  event.preventDefault();
7505
7487
  controller.handleCommand(UI_COMMANDS.MODAL_PREV).catch(() => {});
@@ -8499,16 +8481,6 @@ export function createWebPilotSwarmController({ transport, mode = "remote", bran
8499
8481
  }
8500
8482
 
8501
8483
  export function PilotSwarmWebApp({ controller }) {
8502
- // The rich UI restyles chrome that lives OUTSIDE this tree (the portal
8503
- // header in App.jsx), so the flag rides on <body> and every surface
8504
- // keys off `body.ps-rich-ui` in CSS rather than threading a prop.
8505
- const richUi = useControllerSelector(controller, (state) => Boolean(getTheme(state.ui.themeId)?.richChat));
8506
- React.useEffect(() => {
8507
- if (typeof document === "undefined") return undefined;
8508
- document.body.classList.toggle("ps-rich-ui", richUi);
8509
- return () => document.body.classList.remove("ps-rich-ui");
8510
- }, [richUi]);
8511
-
8512
8484
  const viewportRef = React.useRef(null);
8513
8485
  const mainGridRef = React.useRef(null);
8514
8486
  const viewport = useMeasuredViewport(viewportRef);
@@ -8553,6 +8525,7 @@ export function PilotSwarmWebApp({ controller }) {
8553
8525
  const [chatFocusPane, setChatFocusPane] = React.useState(null);
8554
8526
  const state = useControllerSelector(controller, (rootState) => ({
8555
8527
  themeId: rootState.ui.themeId,
8528
+ touchScale: Boolean(rootState.ui.touchScale),
8556
8529
  ownerFilter: rootState.sessions.ownerFilter,
8557
8530
  pinnedIds: rootState.sessions.pinnedIds,
8558
8531
  // Without this the profile-save effect reads undefined and a reorder
@@ -8727,11 +8700,12 @@ export function PilotSwarmWebApp({ controller }) {
8727
8700
  });
8728
8701
  }, 400);
8729
8702
  return undefined;
8730
- }, [controller, state.activeSessionId, state.activityPaneAdjust, state.chatViewMode, state.collapsedSessionIds, state.ownerFilter, state.paneAdjust, state.manualOrder, state.pinnedIds, state.portalSessionColumnAdjust, state.sessionPaneAdjust, state.themeId]);
8703
+ }, [controller, state.activeSessionId, state.activityPaneAdjust, state.chatViewMode, state.collapsedSessionIds, state.ownerFilter, state.paneAdjust, state.manualOrder, state.pinnedIds, state.portalSessionColumnAdjust, state.sessionPaneAdjust, state.themeId, state.touchScale]);
8731
8704
 
8732
8705
  React.useEffect(() => {
8733
8706
  applyDocumentTheme(state.themeId);
8734
- }, [state.themeId]);
8707
+ applyTouchScale(state.touchScale);
8708
+ }, [state.themeId, state.touchScale]);
8735
8709
 
8736
8710
  // Follow focus only on an actual TRANSITION, never on the first run.
8737
8711
  //