pilotswarm 0.5.13 → 0.5.14

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 (42) hide show
  1. package/README.md +6 -0
  2. package/mcp/README.md +12 -0
  3. package/mcp/dist/src/context.d.ts +13 -0
  4. package/mcp/dist/src/context.d.ts.map +1 -1
  5. package/mcp/dist/src/context.js +20 -0
  6. package/mcp/dist/src/context.js.map +1 -1
  7. package/mcp/dist/src/tools/capabilities.d.ts +3 -0
  8. package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
  9. package/mcp/dist/src/tools/capabilities.js +8 -0
  10. package/mcp/dist/src/tools/capabilities.js.map +1 -1
  11. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  12. package/mcp/dist/src/tools/sessions.js +114 -0
  13. package/mcp/dist/src/tools/sessions.js.map +1 -1
  14. package/package.json +3 -2
  15. package/tui/src/app.js +19 -2
  16. package/tui/src/auth/cli.js +13 -0
  17. package/tui/src/node-sdk-transport.js +53 -8
  18. package/tui/tui-splash-mobile.txt +5 -7
  19. package/tui/tui-splash.txt +13 -9
  20. package/ui/core/src/commands.js +2 -0
  21. package/ui/core/src/controller.js +275 -6
  22. package/ui/core/src/history.js +19 -1
  23. package/ui/core/src/reducer.js +4 -0
  24. package/ui/core/src/selectors.js +139 -14
  25. package/ui/core/src/themes/helpers.js +4 -0
  26. package/ui/react/src/components.js +95 -6
  27. package/ui/react/src/web-app.js +555 -117
  28. package/web/api/router.js +7 -6
  29. package/web/api/ws.js +9 -0
  30. package/web/auth/index.js +5 -0
  31. package/web/auth/providers/dev.js +119 -0
  32. package/web/authz.js +142 -0
  33. package/web/dist/assets/index-BnxC8cNG.js +24 -0
  34. package/web/dist/assets/{index-oldX95Tp.css → index-Bx6KHIaj.css} +1 -1
  35. package/web/dist/assets/pilotswarm-NE7H63ha.js +90 -0
  36. package/web/dist/assets/react-l0sNRNKZ.js +1 -0
  37. package/web/dist/index.html +3 -4
  38. package/web/runtime.js +453 -9
  39. package/web/server.js +2 -2
  40. package/web/dist/assets/index-bQ2QInMX.js +0 -24
  41. package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
  42. package/web/dist/assets/react-C9iQPS2h.js +0 -1
@@ -1,4 +1,8 @@
1
1
  import React from "react";
2
+ // createPortal is only invoked when an IconButton tooltip renders (portal only,
3
+ // never in the TUI); the import itself is side-effect-free and react-dom is a
4
+ // dependency wherever this file loads, so it is safe in the shared module.
5
+ import { createPortal } from "react-dom";
2
6
  import { appendAnimatedDotsToRuns, useAnimatedDots, useSpinnerFrame } from "./chat-status.js";
3
7
  import {
4
8
  UI_COMMANDS,
@@ -86,6 +90,18 @@ const INSPECTOR_TAB_LABELS = {
86
90
  files: "Files",
87
91
  stats: "Stats",
88
92
  };
93
+ // Glyphs for the icon-only Inspector tab row (labels move into hover/long-press
94
+ // tooltips via IconButton, matching the session toolbar treatment).
95
+ // Monochrome line-art codepoints only — emoji-default glyphs (📄 📊 🗑 ⏹) get
96
+ // force-rendered as colored emoji on iOS and clash with the rest of the UI.
97
+ const INSPECTOR_TAB_ICONS = {
98
+ sequence: "⇶",
99
+ logs: "≣",
100
+ nodes: "⬡",
101
+ history: "⟲",
102
+ files: "⧉",
103
+ stats: "▁▄▇",
104
+ };
89
105
 
90
106
  function cycleTabs(tabs, current, delta) {
91
107
  const values = Array.isArray(tabs) ? tabs.filter(Boolean) : [];
@@ -1878,7 +1894,25 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
1878
1894
  const activeSession = viewState.activeSessionId
1879
1895
  ? viewState.sessionsById[viewState.activeSessionId] || null
1880
1896
  : null;
1881
- const canRenameActiveSession = Boolean(activeSession && !activeSession.isSystem);
1897
+ // "Modify" combines rename + sharing in one modal (opened from the toolbar
1898
+ // so the composer chrome stays minimal, esp. on mobile). Rename AND sharing
1899
+ // are owner/admin-only (session:manage / session:share), so the button is
1900
+ // disabled for anyone else — the server enforces too, but a disabled button
1901
+ // is clearer than a 403.
1902
+ const [modifyOpen, setModifyOpen] = React.useState(false);
1903
+ const authPrincipal = viewState.auth?.principal || null;
1904
+ const viewerRole = viewState.auth?.authorization?.role;
1905
+ const isAdminViewer = viewerRole === "admin" || viewerRole === "anonymous";
1906
+ const ownsActiveSession = Boolean(
1907
+ activeSession?.owner
1908
+ && authPrincipal
1909
+ && String(activeSession.owner.provider) === String(authPrincipal.provider)
1910
+ && String(activeSession.owner.subject) === String(authPrincipal.subject),
1911
+ );
1912
+ const canModifyActiveSession = Boolean(
1913
+ activeSession && !activeSession.isSystem && !activeSession.isGroup
1914
+ && (isAdminViewer || ownsActiveSession),
1915
+ );
1882
1916
  const selectedCount = Array.isArray(viewState.selectedIds) ? viewState.selectedIds.length : 0;
1883
1917
  const isBulkSelection = selectedCount > 1;
1884
1918
  const canPinActiveSession = Boolean(
@@ -1948,55 +1982,68 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
1948
1982
  }, `${selectedCount} selected`)
1949
1983
  : null,
1950
1984
  isBulkSelection
1951
- ? React.createElement("button", {
1952
- type: "button",
1985
+ ? React.createElement(IconButton, {
1953
1986
  className: "ps-mini-button",
1987
+ icon: "✕",
1988
+ label: "Clear multi-selection",
1954
1989
  onClick: () => controller.handleCommand(UI_COMMANDS.CLEAR_SESSION_SELECTION).catch(() => {}),
1955
- title: "Clear multi-selection",
1956
- }, "Clear")
1957
- : React.createElement("button", {
1958
- type: "button",
1990
+ })
1991
+ : React.createElement(IconButton, {
1959
1992
  className: "ps-mini-button",
1993
+ icon: "📌",
1960
1994
  onClick: () => controller.handleCommand(UI_COMMANDS.PIN_SESSION).catch(() => {}),
1961
1995
  disabled: !canPinActiveSession,
1962
- title: canPinActiveSession
1963
- ? (isActivePinned
1964
- ? "Unpin this session"
1965
- : "Pin this session to the top of the list")
1996
+ active: isActivePinned,
1997
+ label: canPinActiveSession
1998
+ ? (isActivePinned ? "Unpin this session" : "Pin this session to the top of the list")
1966
1999
  : "Only top-level non-system sessions can be pinned",
1967
- }, isActivePinned ? "Unpin" : "Pin"),
1968
- React.createElement("button", {
1969
- type: "button",
2000
+ }),
2001
+ React.createElement(IconButton, {
1970
2002
  className: "ps-mini-button",
2003
+ icon: groupableIds.length > 1 ? `⊞${groupableIds.length}` : "⊞",
1971
2004
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_MOVE_TO_GROUP).catch(() => {}),
1972
2005
  disabled: !canMoveToGroup,
1973
- title: canMoveToGroup
2006
+ label: canMoveToGroup
1974
2007
  ? (groupableIds.length > 1 ? `Move ${groupableIds.length} selected sessions to a group` : "Move this session to a group")
1975
2008
  : "Select a top-level non-system session to move to a group",
1976
- }, groupableIds.length > 1 ? `Group (${groupableIds.length})` : "Group"),
1977
- React.createElement("button", {
1978
- type: "button",
2009
+ }),
2010
+ React.createElement(IconButton, {
1979
2011
  className: "ps-mini-button",
1980
- onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_RENAME_SESSION).catch(() => {}),
1981
- disabled: !canRenameActiveSession || isBulkSelection,
1982
- title: isBulkSelection ? "Disabled while multiple sessions are selected" : undefined,
1983
- }, "Rename"),
1984
- React.createElement("button", {
1985
- type: "button",
2012
+ icon: React.createElement(ShareGlyph),
2013
+ onClick: () => {
2014
+ const sid = activeSession?.sessionId;
2015
+ if (!sid || typeof window === "undefined" || !window.location) return;
2016
+ const url = `${window.location.origin}${window.location.pathname}?session=${encodeURIComponent(sid)}`;
2017
+ if (navigator?.clipboard?.writeText) navigator.clipboard.writeText(url).catch(() => {});
2018
+ controller.dispatch({ type: "ui/status", text: "Session link copied to clipboard" });
2019
+ },
2020
+ disabled: !activeSession || activeSession.isGroup || isBulkSelection,
2021
+ label: "Share — copy a direct link to this session",
2022
+ }),
2023
+ React.createElement(IconButton, {
1986
2024
  className: "ps-mini-button",
2025
+ icon: "✎",
2026
+ onClick: () => setModifyOpen(true),
2027
+ disabled: !canModifyActiveSession || isBulkSelection,
2028
+ label: isBulkSelection ? "Disabled while multiple sessions are selected" : "Modify — rename and share access",
2029
+ }),
2030
+ React.createElement(IconButton, {
2031
+ className: "ps-mini-button",
2032
+ icon: activeSession?.isSystem ? "↻" : "⊗",
1987
2033
  onClick: () => controller.handleCommand(activeSession?.isGroup ? UI_COMMANDS.DELETE_SESSION : UI_COMMANDS.OPEN_TERMINATE_PICKER).catch(() => {}),
1988
2034
  disabled: !canTerminate,
1989
- title: isBulkSelection
2035
+ label: isBulkSelection
1990
2036
  ? `Terminate ${selectedCount} selected sessions (Mark Completed, Cancel, or Delete)`
1991
2037
  : activeSession?.isGroup
1992
- ? activeGroupCanDelete ? "Delete this empty group" : "Show why this group cannot be deleted yet"
2038
+ ? (activeGroupCanDelete ? "Delete this empty group" : "This group cannot be deleted yet")
1993
2039
  : activeSession?.isSystem
1994
- ? "Restart this system session; choose complete, terminate, or hard delete disposition"
1995
- : "Mark Completed, Cancel, or Delete the active session",
1996
- }, activeSessionActionLabel),
2040
+ ? "Restart this system session (complete, terminate, or hard delete)"
2041
+ : `${activeSessionActionLabel} — mark completed, cancel, or delete`,
2042
+ }),
1997
2043
  actions);
1998
2044
 
1999
- return React.createElement(Panel, {
2045
+ return React.createElement(React.Fragment, null,
2046
+ React.createElement(Panel, {
2000
2047
  title: [{ text: "Sessions", color: "yellow", bold: true }],
2001
2048
  color: "yellow",
2002
2049
  focused: viewState.focused,
@@ -2070,7 +2117,234 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
2070
2117
  },
2071
2118
  React.createElement(SessionRowContent, { row, theme, structured: structuredRows })),
2072
2119
  )),
2073
- ));
2120
+ )),
2121
+ (modifyOpen && activeSession && !activeSession.isGroup)
2122
+ ? React.createElement(SessionModifyModal, {
2123
+ controller,
2124
+ sessionId: activeSession.sessionId,
2125
+ initialTitle: activeSession.title || "",
2126
+ principal: viewState.auth?.principal || null,
2127
+ onClose: () => setModifyOpen(false),
2128
+ onChanged: () => {},
2129
+ })
2130
+ : null);
2131
+ }
2132
+
2133
+ const VISIBILITY_META = {
2134
+ private: { glyph: "🔒", label: "Private" },
2135
+ shared_read: { glyph: "👁", label: "Shared · read" },
2136
+ shared_write: { glyph: "✎", label: "Shared · write" },
2137
+ };
2138
+
2139
+ // The standard "share" glyph (three connected nodes). Inherits the button's
2140
+ // text color via currentColor. Used for the session deep-link Share button.
2141
+ function ShareGlyph() {
2142
+ return React.createElement("svg", {
2143
+ className: "ps-share-glyph", viewBox: "0 0 24 24", fill: "none",
2144
+ stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round",
2145
+ "aria-hidden": "true",
2146
+ },
2147
+ React.createElement("circle", { cx: "18", cy: "5", r: "3" }),
2148
+ React.createElement("circle", { cx: "6", cy: "12", r: "3" }),
2149
+ React.createElement("circle", { cx: "18", cy: "19", r: "3" }),
2150
+ React.createElement("line", { x1: "8.6", y1: "10.5", x2: "15.4", y2: "6.5" }),
2151
+ React.createElement("line", { x1: "8.6", y1: "13.5", x2: "15.4", y2: "17.5" }));
2152
+ }
2153
+
2154
+ /**
2155
+ * Fetch the caller's effective access to the active session (security model).
2156
+ * Returns { access, loading, reload }. access is the getSessionAccess payload
2157
+ * ({ visibility, relation, canWrite, canManage, owner, isSystem, enforced }),
2158
+ * or null while loading / on error / when the transport lacks the method
2159
+ * (older deployments — treated as full access so the UI never over-restricts
2160
+ * a deployment that isn't enforcing).
2161
+ */
2162
+ function useActiveSessionAccess(controller, activeSessionId, isGroup) {
2163
+ const [state, setState] = React.useState({ access: null, loading: false });
2164
+ const reload = React.useCallback(() => {
2165
+ if (!activeSessionId || isGroup || typeof controller.transport.getSessionAccess !== "function") {
2166
+ setState({ access: null, loading: false });
2167
+ return;
2168
+ }
2169
+ let cancelled = false;
2170
+ setState((s) => ({ ...s, loading: true }));
2171
+ controller.transport.getSessionAccess(activeSessionId)
2172
+ .then((access) => { if (!cancelled) setState({ access, loading: false }); })
2173
+ .catch(() => { if (!cancelled) setState({ access: null, loading: false }); });
2174
+ return () => { cancelled = true; };
2175
+ }, [controller, activeSessionId, isGroup]);
2176
+ React.useEffect(() => reload(), [reload]);
2177
+ return { access: state.access, loading: state.loading, reload };
2178
+ }
2179
+
2180
+ // Combined "Modify" modal opened from the session list toolbar: rename plus
2181
+ // (for the owner/admin) sharing — visibility + per-person grants. Fetches its
2182
+ // own access snapshot so callers only pass the session id + current title.
2183
+ function SessionModifyModal({ controller, sessionId, initialTitle, principal, onClose, onChanged }) {
2184
+ const [access, setAccess] = React.useState(null);
2185
+ const [title, setTitle] = React.useState(initialTitle || "");
2186
+ const [shares, setShares] = React.useState([]);
2187
+ const [visibility, setVisibility] = React.useState("private");
2188
+ const [granteeQuery, setGranteeQuery] = React.useState("");
2189
+ const [granteeAccess, setGranteeAccess] = React.useState("write");
2190
+ const [directory, setDirectory] = React.useState([]);
2191
+ const [busy, setBusy] = React.useState(false);
2192
+ const [error, setError] = React.useState(null);
2193
+
2194
+ React.useEffect(() => {
2195
+ let cancelled = false;
2196
+ controller.transport.getSessionAccess(sessionId)
2197
+ .then((a) => { if (!cancelled && a) { setAccess(a); setVisibility(a.visibility || "private"); } })
2198
+ .catch(() => {});
2199
+ // Member directory for name autocomplete (excludes synthetic principals).
2200
+ if (typeof controller.transport.listKnownUsers === "function") {
2201
+ controller.transport.listKnownUsers({ limit: 500 })
2202
+ .then((users) => { if (!cancelled) setDirectory(Array.isArray(users) ? users : []); })
2203
+ .catch(() => {});
2204
+ }
2205
+ return () => { cancelled = true; };
2206
+ }, [controller, sessionId]);
2207
+
2208
+ const loadShares = React.useCallback(() => {
2209
+ controller.transport.listSessionShares(sessionId)
2210
+ .then((rows) => setShares(Array.isArray(rows) ? rows : []))
2211
+ .catch(() => setShares([]));
2212
+ }, [controller, sessionId]);
2213
+ React.useEffect(() => { loadShares(); }, [loadShares]);
2214
+
2215
+ const run = async (fn) => {
2216
+ setBusy(true); setError(null);
2217
+ try { await fn(); onChanged?.(); }
2218
+ catch (err) { setError(err?.message || String(err)); }
2219
+ finally { setBusy(false); }
2220
+ };
2221
+
2222
+ const saveTitle = () => run(async () => {
2223
+ await controller.transport.renameSession(sessionId, title.trim());
2224
+ });
2225
+ const applyVisibility = (value) => run(async () => {
2226
+ await controller.transport.setSessionVisibility(sessionId, value);
2227
+ setVisibility(value);
2228
+ });
2229
+ // Resolve the typed text to a directory member (by name, email, or id).
2230
+ // Falls back to treating the text as a raw subject for a not-yet-seen user.
2231
+ const resolveGrantee = (text) => {
2232
+ const q = text.trim().toLowerCase();
2233
+ if (!q) return null;
2234
+ const match = directory.find((u) =>
2235
+ (u.displayName && u.displayName.toLowerCase() === q)
2236
+ || (u.email && u.email.toLowerCase() === q)
2237
+ || (u.subject && u.subject.toLowerCase() === q));
2238
+ if (match) return match;
2239
+ return { provider: principal?.provider || "dev", subject: text.trim(), email: null, displayName: null };
2240
+ };
2241
+ const grantTo = (grantee) => run(async () => {
2242
+ await controller.transport.grantSessionShare(
2243
+ sessionId,
2244
+ { provider: grantee.provider, subject: grantee.subject, email: grantee.email ?? null, displayName: grantee.displayName ?? null },
2245
+ granteeAccess,
2246
+ );
2247
+ setGranteeQuery("");
2248
+ loadShares();
2249
+ });
2250
+ const addGrant = () => {
2251
+ const grantee = resolveGrantee(granteeQuery);
2252
+ if (grantee) grantTo(grantee);
2253
+ };
2254
+
2255
+ // Autocomplete suggestions: directory members matching the query, minus
2256
+ // the owner and anyone already granted.
2257
+ const grantedKeys = new Set(shares.map((r) => `${r.provider}${r.subject}`));
2258
+ const ownerKey = access?.owner ? `${access.owner.provider}${access.owner.subject}` : null;
2259
+ const q = granteeQuery.trim().toLowerCase();
2260
+ const suggestions = q
2261
+ ? directory.filter((u) => {
2262
+ const key = `${u.provider}${u.subject}`;
2263
+ if (key === ownerKey || grantedKeys.has(key)) return false;
2264
+ return (u.displayName && u.displayName.toLowerCase().includes(q))
2265
+ || (u.email && u.email.toLowerCase().includes(q))
2266
+ || (u.subject && u.subject.toLowerCase().includes(q));
2267
+ }).slice(0, 25)
2268
+ : [];
2269
+ const revoke = (row) => run(async () => {
2270
+ await controller.transport.revokeSessionShare(sessionId, { provider: row.provider, subject: row.subject });
2271
+ loadShares();
2272
+ });
2273
+
2274
+ const canManage = Boolean(access?.canManage);
2275
+ const stop = (e) => e.stopPropagation();
2276
+ return React.createElement("div", { className: "ps-share-overlay", onClick: onClose },
2277
+ React.createElement("div", { className: "ps-share-modal", onClick: stop },
2278
+ React.createElement("div", { className: "ps-share-modal-head" },
2279
+ React.createElement("span", null, "Modify session"),
2280
+ React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
2281
+
2282
+ // ── Rename ────────────────────────────────────────────────
2283
+ React.createElement("div", { className: "ps-share-section-label" }, "Name"),
2284
+ React.createElement("div", { className: "ps-share-add-row" },
2285
+ React.createElement("input", {
2286
+ className: "ps-share-add-input", placeholder: "Session title",
2287
+ value: title, disabled: busy,
2288
+ onChange: (e) => setTitle(e.target.value),
2289
+ onKeyDown: (e) => { if (e.key === "Enter") saveTitle(); },
2290
+ }),
2291
+ React.createElement("button", { className: "ps-mini-button", disabled: busy || !title.trim(), onClick: saveTitle }, "Save")),
2292
+
2293
+ // ── Sharing (owner / admin only) ──────────────────────────
2294
+ canManage ? React.createElement(React.Fragment, null,
2295
+ React.createElement("div", { className: "ps-share-section-label" }, "General access"),
2296
+ React.createElement("div", { className: "ps-share-section-sub" }, "The baseline level for everyone signed in to this workspace."),
2297
+ ["private", "shared_read", "shared_write"].map((value) =>
2298
+ React.createElement("label", { key: value, className: `ps-share-radio${visibility === value ? " is-active" : ""}` },
2299
+ React.createElement("input", {
2300
+ type: "radio", name: "visibility", checked: visibility === value,
2301
+ disabled: busy, onChange: () => applyVisibility(value),
2302
+ }),
2303
+ React.createElement("span", { className: "ps-share-radio-glyph" }, VISIBILITY_META[value].glyph),
2304
+ React.createElement("span", null, VISIBILITY_META[value].label),
2305
+ React.createElement("span", { className: "ps-share-radio-hint" },
2306
+ value === "private" ? "only you and admins"
2307
+ : value === "shared_read" ? "everyone here can view"
2308
+ : "everyone here can view and send"))),
2309
+ React.createElement("div", { className: "ps-share-section-label" }, "Special access"),
2310
+ React.createElement("div", { className: "ps-share-section-sub" }, "Give specific people more than the general level. A person's grant wins over general access."),
2311
+ shares.length === 0
2312
+ ? React.createElement("div", { className: "ps-share-empty" }, "No individual grants — everyone has the general access above.")
2313
+ : shares.map((row) => React.createElement("div", { key: `${row.provider}/${row.subject}`, className: "ps-share-grant-row" },
2314
+ React.createElement("span", { className: "ps-share-grant-name" }, row.displayName || row.subject),
2315
+ React.createElement("span", { className: "ps-share-grant-access" }, `can ${row.access}`),
2316
+ React.createElement("button", { className: "ps-mini-button", disabled: busy, onClick: () => revoke(row) }, "Revoke"))),
2317
+ React.createElement("div", { className: "ps-share-add-wrap" },
2318
+ React.createElement("div", { className: "ps-share-add-row" },
2319
+ React.createElement("input", {
2320
+ className: "ps-share-add-input", placeholder: "Name, email, or id",
2321
+ value: granteeQuery, disabled: busy, autoComplete: "off",
2322
+ onChange: (e) => setGranteeQuery(e.target.value),
2323
+ onKeyDown: (e) => { if (e.key === "Enter") addGrant(); },
2324
+ }),
2325
+ React.createElement("select", {
2326
+ className: "ps-share-add-select", value: granteeAccess, disabled: busy,
2327
+ onChange: (e) => setGranteeAccess(e.target.value),
2328
+ },
2329
+ React.createElement("option", { value: "read" }, "can read"),
2330
+ React.createElement("option", { value: "write" }, "can write")),
2331
+ React.createElement("button", { className: "ps-mini-button", disabled: busy || !granteeQuery.trim(), onClick: addGrant }, "Add")),
2332
+ suggestions.length > 0
2333
+ ? React.createElement("div", { className: "ps-share-suggestions" },
2334
+ suggestions.map((u) => React.createElement("button", {
2335
+ key: `${u.provider}/${u.subject}`,
2336
+ type: "button", className: "ps-share-suggestion", disabled: busy,
2337
+ onClick: () => grantTo(u),
2338
+ },
2339
+ React.createElement("span", { className: "ps-share-suggestion-name" }, u.displayName || u.subject),
2340
+ u.email ? React.createElement("span", { className: "ps-share-suggestion-email" }, u.email) : null)))
2341
+ : null),
2342
+ React.createElement("div", { className: "ps-share-foot-hint" },
2343
+ "Sharing applies to this session and its sub-agents. Suggestions are people who have "
2344
+ + "signed in before — you can also grant by email to someone who hasn't; it takes effect "
2345
+ + "when they first sign in."))
2346
+ : null,
2347
+ error ? React.createElement("div", { className: "ps-share-error" }, error) : null));
2074
2348
  }
2075
2349
 
2076
2350
  function ChatPane({ controller, mobile = false, fullWidth = false, showComposer = true }) {
@@ -2099,12 +2373,16 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
2099
2373
  activeSessionStatus: activeSessionId ? String(state.sessions.byId[activeSessionId]?.status || "").toLowerCase() : "",
2100
2374
  focused: state.ui.focusRegion === "chat",
2101
2375
  scroll: state.ui.scroll.chat,
2376
+ // Viewer identity — so the transcript can say "You" for the viewer's
2377
+ // own messages and name others (with an "(owner)" tag).
2378
+ authPrincipal: state.auth?.principal || null,
2102
2379
  contentWidth,
2103
2380
  };
2104
2381
  }, shallowEqualObject);
2105
2382
  const selectorState = React.useMemo(() => ({
2106
2383
  branding: viewState.branding,
2107
2384
  connection: viewState.connection,
2385
+ auth: { principal: viewState.authPrincipal },
2108
2386
  sessions: {
2109
2387
  activeSessionId: viewState.activeSessionId,
2110
2388
  byId: viewState.sessionsById,
@@ -2128,6 +2406,7 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
2128
2406
  viewState.activeHistory,
2129
2407
  viewState.activeSessionId,
2130
2408
  viewState.activeOutbox,
2409
+ viewState.authPrincipal,
2131
2410
  viewState.branding,
2132
2411
  viewState.connection,
2133
2412
  viewState.chatViewMode,
@@ -2165,9 +2444,21 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
2165
2444
  () => (pinnedActivityLines.length > 0 ? [...outboxLines, ...pinnedActivityLines] : outboxLines),
2166
2445
  [outboxLines, pinnedActivityLines],
2167
2446
  );
2168
- const composer = showComposer && !viewState.activeSessionIsGroup && viewState.chatViewMode !== "summary"
2447
+ // Read-only gating: a view-only viewer (shared_read / read grant, no write)
2448
+ // gets an explanatory notice instead of the composer. The visibility chip
2449
+ // and Share affordance now live in the session list "Modify" modal and the
2450
+ // selected-session details, keeping the composer chrome minimal (mobile).
2451
+ const { access } = useActiveSessionAccess(
2452
+ controller, viewState.activeSessionId, viewState.activeSessionIsGroup,
2453
+ );
2454
+ const composerBase = showComposer && !viewState.activeSessionIsGroup && viewState.chatViewMode !== "summary";
2455
+ const readOnly = Boolean(access) && access.canWrite === false;
2456
+ const composer = composerBase
2169
2457
  ? React.createElement("div", { className: "ps-chat-composer" },
2170
- React.createElement(PromptComposer, { controller, mobile, active: true }))
2458
+ readOnly
2459
+ ? React.createElement("div", { className: "ps-composer-readonly" },
2460
+ `You have view access to this session. Ask ${access.owner?.displayName || access.owner?.email || "the owner"} for write access to participate.`)
2461
+ : React.createElement(PromptComposer, { controller, mobile, active: true }))
2171
2462
  : null;
2172
2463
 
2173
2464
  return React.createElement(ScrollLinesPanel, {
@@ -2222,29 +2513,21 @@ function MobileWorkspace({ controller, sessionsCollapsed, setSessionsCollapsed }
2222
2513
 
2223
2514
  function InspectorTabs({ activeTab, controller }) {
2224
2515
  const visibleTabs = React.useMemo(() => getVisibleInspectorTabs(controller), [controller]);
2225
- // Tab labels in mobile fall on a single row only when each label is
2226
- // short. "Node Map" is the only multi-word label, so we shorten it
2227
- // to "Map" below the mobile breakpoint to keep all six tabs on one
2228
- // line.
2229
- const isMobile = typeof window !== "undefined"
2230
- && (window.innerWidth || 0) > 0
2231
- && (window.innerWidth || 0) < MOBILE_BREAKPOINT;
2232
- const labelFor = (tab) => {
2233
- if (isMobile && tab === "nodes") return "Map";
2234
- return INSPECTOR_TAB_LABELS[tab] || tab;
2235
- };
2236
- return React.createElement("div", { className: "ps-tab-row" },
2237
- visibleTabs.map((tab) => React.createElement("button", {
2516
+ // Icon-only tabs: the full label (e.g. "Node Map") lives in the IconButton
2517
+ // tooltip, so there's no per-label width pressure and no mobile shortening.
2518
+ // Default IconButton className ("ps-toolbar-button") so these render
2519
+ // identically to the session toolbar icons.
2520
+ return React.createElement("div", { className: "ps-tab-row ps-tab-row-icons" },
2521
+ visibleTabs.map((tab) => React.createElement(IconButton, {
2238
2522
  key: tab,
2239
- type: "button",
2240
- className: `ps-tab${activeTab === tab ? " is-active" : ""}`,
2241
- title: `Switch to ${INSPECTOR_TAB_LABELS[tab] || tab}`,
2242
- "aria-pressed": activeTab === tab,
2523
+ icon: INSPECTOR_TAB_ICONS[tab] || "",
2524
+ label: INSPECTOR_TAB_LABELS[tab] || tab,
2525
+ active: activeTab === tab,
2243
2526
  onClick: () => {
2244
2527
  controller.setFocus("inspector");
2245
2528
  controller.selectInspectorTab(tab).catch(() => {});
2246
2529
  },
2247
- }, labelFor(tab))));
2530
+ })));
2248
2531
  }
2249
2532
 
2250
2533
  function FilesPane({ controller, focused, mobile = false }) {
@@ -2338,40 +2621,40 @@ function FilesPane({ controller, focused, mobile = false }) {
2338
2621
  event.currentTarget.value = "";
2339
2622
  },
2340
2623
  }),
2341
- React.createElement("button", {
2342
- type: "button",
2343
- className: "ps-mini-button",
2624
+ React.createElement(IconButton, {
2625
+ icon: "",
2626
+ label: "Upload",
2344
2627
  onClick: openUploadPicker,
2345
2628
  disabled: !viewState.canBrowserUpload && !viewState.canPathUpload,
2346
- }, "Up"),
2347
- React.createElement("button", {
2348
- type: "button",
2349
- className: "ps-mini-button",
2629
+ }),
2630
+ React.createElement(IconButton, {
2631
+ icon: "",
2632
+ label: "Download",
2350
2633
  onClick: () => controller.handleCommand(UI_COMMANDS.DOWNLOAD_SELECTED_FILE).catch(() => {}),
2351
2634
  disabled: !hasSelection,
2352
- }, "Down"),
2353
- viewState.canDeleteArtifacts ? React.createElement("button", {
2354
- type: "button",
2355
- className: "ps-mini-button",
2635
+ }),
2636
+ viewState.canDeleteArtifacts ? React.createElement(IconButton, {
2637
+ icon: "",
2638
+ label: "Delete",
2356
2639
  onClick: () => controller.handleCommand(UI_COMMANDS.DELETE_SELECTED_FILE).catch(() => {}),
2357
2640
  disabled: !hasSelection,
2358
- }, "Delete") : null,
2359
- viewState.canOpenLocally ? React.createElement("button", {
2360
- type: "button",
2361
- className: "ps-mini-button",
2641
+ }) : null,
2642
+ viewState.canOpenLocally ? React.createElement(IconButton, {
2643
+ icon: "",
2644
+ label: "Open locally",
2362
2645
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_SELECTED_FILE).catch(() => {}),
2363
2646
  disabled: !hasSelection,
2364
- }, "Open") : null,
2365
- React.createElement("button", {
2366
- type: "button",
2367
- className: "ps-mini-button",
2647
+ }) : null,
2648
+ React.createElement(IconButton, {
2649
+ icon: "",
2650
+ label: "Filter",
2368
2651
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_FILES_FILTER).catch(() => {}),
2369
- }, "Filter"),
2370
- React.createElement("button", {
2371
- type: "button",
2372
- className: "ps-mini-button",
2652
+ }),
2653
+ React.createElement(IconButton, {
2654
+ icon: viewState.fullscreen ? "" : "⛶",
2655
+ label: viewState.fullscreen ? "Exit fullscreen" : "Fullscreen",
2373
2656
  onClick: () => controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {}),
2374
- }, viewState.fullscreen ? "Close" : "FS"));
2657
+ }));
2375
2658
 
2376
2659
  const listContent = items.length === 0
2377
2660
  ? normalizeLines(filesView.listBodyLines || []).map((line, index) => React.createElement(Line, {
@@ -2555,39 +2838,42 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
2555
2838
 
2556
2839
  const actions = [];
2557
2840
  if (viewState.inspectorTab === "logs") {
2558
- actions.push(React.createElement("button", {
2841
+ actions.push(React.createElement(IconButton, {
2559
2842
  key: "tail",
2560
- type: "button",
2561
- className: "ps-mini-button",
2843
+ icon: viewState.logsTailing ? "" : "⇣",
2844
+ label: viewState.logsTailing ? "Stop tailing" : "Tail (follow)",
2845
+ active: viewState.logsTailing,
2562
2846
  onClick: () => controller.handleCommand(UI_COMMANDS.TOGGLE_LOG_TAIL).catch(() => {}),
2563
- }, viewState.logsTailing ? "Stop Tail" : "Tail"));
2564
- actions.push(React.createElement("button", {
2847
+ }));
2848
+ actions.push(React.createElement(IconButton, {
2565
2849
  key: "filter",
2566
- type: "button",
2567
- className: "ps-mini-button",
2850
+ icon: "",
2851
+ label: "Filter",
2568
2852
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_LOG_FILTER).catch(() => {}),
2569
- }, "Filter"));
2853
+ }));
2570
2854
  } else if (viewState.inspectorTab === "history") {
2571
- actions.push(React.createElement("button", {
2855
+ actions.push(React.createElement(IconButton, {
2572
2856
  key: "refresh",
2573
- type: "button",
2574
- className: "ps-mini-button",
2857
+ icon: "",
2858
+ label: "Refresh",
2575
2859
  onClick: () => controller.handleCommand(UI_COMMANDS.REFRESH_EXECUTION_HISTORY).catch(() => {}),
2576
- }, "Refresh"));
2577
- actions.push(React.createElement("button", {
2860
+ }));
2861
+ actions.push(React.createElement(IconButton, {
2578
2862
  key: "save",
2579
- type: "button",
2580
- className: "ps-mini-button",
2863
+ icon: "",
2864
+ label: "Export as artifact",
2581
2865
  onClick: () => controller.handleCommand(UI_COMMANDS.EXPORT_EXECUTION_HISTORY).catch(() => {}),
2582
- }, "Artifact"));
2866
+ }));
2583
2867
  } else if (viewState.inspectorTab === "stats") {
2868
+ const STATS_MODE_ICONS = { session: "◉", fleet: "⬢", users: "⚇" };
2584
2869
  for (const mode of ["session", "fleet", "users"]) {
2585
- actions.push(React.createElement("button", {
2870
+ actions.push(React.createElement(IconButton, {
2586
2871
  key: `stats-view:${mode}`,
2587
- type: "button",
2588
- className: `ps-mini-button${viewState.statsViewMode === mode ? " is-active" : ""}`,
2872
+ icon: STATS_MODE_ICONS[mode],
2873
+ label: `${mode.replace(/^./u, (char) => char.toUpperCase())} stats`,
2874
+ active: viewState.statsViewMode === mode,
2589
2875
  onClick: () => controller.setStatsViewMode(mode),
2590
- }, mode.replace(/^./u, (char) => char.toUpperCase())));
2876
+ }));
2591
2877
  }
2592
2878
  }
2593
2879
 
@@ -2936,6 +3222,152 @@ function StatusStrip({ controller }) {
2936
3222
  );
2937
3223
  }
2938
3224
 
3225
+ // A compact icon button whose meaning is revealed on demand via a custom
3226
+ // tooltip: desktop hover shows it after a fixed 1s (the native `title` delay is
3227
+ // browser-controlled and too long); touch devices get a long-press tooltip
3228
+ // (hold ~450ms to see the label, release to dismiss — the long-press does not
3229
+ // fire onClick). aria-label carries the meaning for assistive tech.
3230
+ const ICON_HOVER_TOOLTIP_MS = 1000;
3231
+ function IconButton({ icon, label, onClick, disabled = false, active = false, className = "ps-toolbar-button" }) {
3232
+ // The tooltip is portaled to <body> so it escapes the toolbar/pane
3233
+ // overflow-clipping and stacking contexts (nested tooltips were hidden
3234
+ // behind, or bled through by, the panes). Coordinates are computed from
3235
+ // the button rect; it flips above when there's no room below.
3236
+ const [tip, setTip] = React.useState(null); // { x, y, placement } | null
3237
+ const btnRef = React.useRef(null);
3238
+ const tipRef = React.useRef(null);
3239
+ const timerRef = React.useRef(null);
3240
+ const longPressRef = React.useRef(false);
3241
+
3242
+ // Keep the tooltip within the viewport horizontally — the leftmost/rightmost
3243
+ // buttons would otherwise clip off the edge (the tooltip is center-anchored).
3244
+ React.useLayoutEffect(() => {
3245
+ if (!tip || !tipRef.current || typeof window === "undefined") return;
3246
+ const half = tipRef.current.offsetWidth / 2;
3247
+ const margin = 6;
3248
+ const clampedX = Math.max(half + margin, Math.min(tip.x, window.innerWidth - half - margin));
3249
+ tipRef.current.style.left = `${clampedX}px`;
3250
+ }, [tip]);
3251
+
3252
+ const reveal = (preferAbove = false) => {
3253
+ const el = btnRef.current;
3254
+ if (!el || typeof window === "undefined") return;
3255
+ const r = el.getBoundingClientRect();
3256
+ // Touch prefers ABOVE (the finger covers anything below the button);
3257
+ // hover prefers below. Either flips when there's no room.
3258
+ const below = preferAbove
3259
+ ? r.top - 44 < 0
3260
+ : r.bottom + 44 < window.innerHeight;
3261
+ setTip({
3262
+ x: r.left + r.width / 2,
3263
+ y: below ? r.bottom + 6 : r.top - 6,
3264
+ placement: below ? "below" : "above",
3265
+ });
3266
+ };
3267
+
3268
+ // Touch and hover are handled through pointer events so each path can
3269
+ // filter on pointerType — the synthesized mouse events iOS fires after
3270
+ // touchend used to restart the hover timer and leave a ghost tooltip
3271
+ // stuck open (a finger never produces mouseleave).
3272
+ const hideTimerRef = React.useRef(null);
3273
+ const pressOriginRef = React.useRef(null);
3274
+
3275
+ const startHover = (e) => {
3276
+ if (e.pointerType !== "mouse") return;
3277
+ clearTimeout(timerRef.current);
3278
+ clearTimeout(hideTimerRef.current);
3279
+ timerRef.current = setTimeout(reveal, ICON_HOVER_TOOLTIP_MS);
3280
+ };
3281
+ const endHover = (e) => {
3282
+ if (e.pointerType !== "mouse") return;
3283
+ clearTimeout(timerRef.current);
3284
+ clearTimeout(hideTimerRef.current);
3285
+ setTip(null);
3286
+ };
3287
+ const startPress = (e) => {
3288
+ if (e.pointerType === "mouse") return;
3289
+ longPressRef.current = false;
3290
+ pressOriginRef.current = { x: e.clientX, y: e.clientY };
3291
+ clearTimeout(timerRef.current);
3292
+ clearTimeout(hideTimerRef.current);
3293
+ timerRef.current = setTimeout(() => { longPressRef.current = true; reveal(true); }, 450);
3294
+ };
3295
+ const movePress = (e) => {
3296
+ // Fingers jitter during a long-press; only real movement (a scroll
3297
+ // intent) cancels. A hide-on-any-move here is what made the bubble
3298
+ // vanish mid-press.
3299
+ if (e.pointerType === "mouse" || !pressOriginRef.current) return;
3300
+ const dx = e.clientX - pressOriginRef.current.x;
3301
+ const dy = e.clientY - pressOriginRef.current.y;
3302
+ if (dx * dx + dy * dy > 100) {
3303
+ pressOriginRef.current = null;
3304
+ clearTimeout(timerRef.current);
3305
+ setTip(null);
3306
+ }
3307
+ };
3308
+ const endPress = (e) => {
3309
+ if (e.pointerType === "mouse") return;
3310
+ pressOriginRef.current = null;
3311
+ clearTimeout(timerRef.current);
3312
+ if (longPressRef.current) {
3313
+ // Long-press: keep the bubble up while pressed, then linger 3s
3314
+ // after the finger lifts.
3315
+ clearTimeout(hideTimerRef.current);
3316
+ hideTimerRef.current = setTimeout(() => setTip(null), 3000);
3317
+ // The suppressed click usually fires right after pointerup and
3318
+ // consumes the flag; clear it shortly after in case it never
3319
+ // arrives, so the NEXT tap isn't swallowed.
3320
+ setTimeout(() => { longPressRef.current = false; }, 250);
3321
+ } else {
3322
+ setTip(null);
3323
+ }
3324
+ };
3325
+ React.useEffect(() => () => {
3326
+ clearTimeout(timerRef.current);
3327
+ clearTimeout(hideTimerRef.current);
3328
+ }, []);
3329
+
3330
+ const handleClick = (e) => {
3331
+ // Suppress the click that follows a long-press (tooltip reveal only).
3332
+ if (longPressRef.current) { longPressRef.current = false; e.preventDefault?.(); return; }
3333
+ if (!disabled) onClick?.(e);
3334
+ };
3335
+
3336
+ // iOS fires contextmenu on long-press; without this (plus the
3337
+ // touch-callout/user-select CSS on .ps-icon-button) the system
3338
+ // loupe/copy/zoom callout opens on top of our tooltip.
3339
+ const handleContextMenu = (e) => { e.preventDefault?.(); };
3340
+
3341
+ const tooltipNode = tip && typeof document !== "undefined" && document.body
3342
+ ? createPortal(
3343
+ React.createElement("span", {
3344
+ ref: tipRef,
3345
+ className: `ps-icon-tooltip is-${tip.placement}`,
3346
+ role: "tooltip",
3347
+ style: { left: `${tip.x}px`, top: `${tip.y}px` },
3348
+ }, label),
3349
+ document.body)
3350
+ : null;
3351
+
3352
+ return React.createElement("button", {
3353
+ ref: btnRef,
3354
+ type: "button",
3355
+ className: `${className} ps-icon-button${active ? " is-active" : ""}`,
3356
+ onClick: handleClick,
3357
+ disabled,
3358
+ "aria-label": label,
3359
+ onPointerEnter: startHover,
3360
+ onPointerLeave: endHover,
3361
+ onPointerDown: startPress,
3362
+ onPointerMove: movePress,
3363
+ onPointerUp: endPress,
3364
+ onPointerCancel: endPress,
3365
+ onContextMenu: handleContextMenu,
3366
+ },
3367
+ React.createElement("span", { className: "ps-icon-button-glyph", "aria-hidden": "true" }, icon),
3368
+ tooltipNode);
3369
+ }
3370
+
2939
3371
  function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus = null, chatFocusDisabled = false }) {
2940
3372
  const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
2941
3373
  const chatView = useControllerSelector(controller, (state) => ({
@@ -2945,79 +3377,85 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
2945
3377
  }), shallowEqualObject);
2946
3378
  const switchModelDisabled = !chatView.hasActiveSession || chatView.activeSessionIsGroup;
2947
3379
 
3380
+ // Icon-first toolbar: the glyph is the affordance, the label rides a
3381
+ // tooltip (desktop hover via title; mobile long-press via IconButton).
2948
3382
  const buttonDefs = [
2949
3383
  {
2950
3384
  key: "new",
2951
- label: "New",
3385
+ icon: "",
3386
+ label: "New session",
2952
3387
  onClick: () => controller.handleCommand(UI_COMMANDS.NEW_SESSION).catch(() => {}),
2953
3388
  },
2954
3389
  {
2955
3390
  key: "model",
2956
- label: mobile ? "Model" : "New + Model",
3391
+ icon: "+⚙",
3392
+ label: "New session + choose model",
2957
3393
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_MODEL_PICKER).catch(() => {}),
2958
3394
  },
2959
3395
  {
2960
3396
  key: "switch-model",
2961
- label: mobile ? "Switch" : "Switch Model",
3397
+ icon: "",
3398
+ label: switchModelDisabled ? "Select a session to switch its model" : "Switch the selected session's model",
2962
3399
  onClick: () => controller.openSwitchModelPicker().catch((err) => {
2963
3400
  controller.dispatch({ type: "ui/status", text: err?.message || String(err) || "Failed to switch model" });
2964
3401
  }),
2965
3402
  disabled: switchModelDisabled,
2966
- title: switchModelDisabled ? "Select a session to switch its model" : "Switch the selected session model at the next turn boundary",
2967
3403
  },
2968
3404
  {
2969
3405
  key: "filter",
2970
- label: "Filter",
3406
+ icon: "",
3407
+ label: "Filter sessions",
2971
3408
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_SESSION_FILTER).catch(() => {}),
2972
3409
  },
2973
3410
  {
2974
3411
  key: "theme",
3412
+ icon: "◑",
2975
3413
  label: "Theme",
2976
3414
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_THEME_PICKER).catch(() => {}),
2977
3415
  },
2978
3416
  {
2979
3417
  key: "summary",
2980
- label: chatView.mode === "summary" ? "Chat" : "Summary",
3418
+ icon: chatView.mode === "summary" ? "💬" : "",
3419
+ label: chatView.activeSessionIsGroup
3420
+ ? "Groups show group details"
3421
+ : (chatView.mode === "summary" ? "Show chat transcript" : "Show summary"),
2981
3422
  onClick: () => controller.setChatViewMode(chatView.mode === "summary" ? "transcript" : "summary"),
2982
3423
  disabled: chatView.activeSessionIsGroup,
2983
- title: chatView.activeSessionIsGroup ? "Groups show group details" : "Toggle Chat / Summary view",
2984
3424
  active: chatView.mode === "summary",
2985
3425
  },
2986
3426
  ...(onToggleChatFocus ? [{
2987
3427
  key: "focus",
2988
- label: mobile
2989
- ? (chatFocusMode ? "Exit Focus" : "Focus")
2990
- : (chatFocusMode ? "Exit Focus" : "Chat Focus"),
3428
+ icon: chatFocusMode ? "⇱" : "⛶",
3429
+ label: chatFocusMode ? "Exit focus mode" : "Focus the chat pane",
2991
3430
  onClick: onToggleChatFocus,
2992
3431
  disabled: chatFocusDisabled,
2993
3432
  active: chatFocusMode,
2994
3433
  }] : []),
2995
3434
  {
2996
3435
  key: "admin",
2997
- label: adminVisible ? "Close Admin" : "Admin",
3436
+ icon: "",
3437
+ label: adminVisible ? "Close admin console" : "Admin console",
2998
3438
  onClick: () => controller.handleCommand(adminVisible ? UI_COMMANDS.CLOSE_ADMIN_CONSOLE : UI_COMMANDS.OPEN_ADMIN_CONSOLE).catch(() => {}),
2999
3439
  active: adminVisible,
3000
3440
  },
3001
3441
  ];
3002
3442
 
3003
- const renderButton = (def) => React.createElement("button", {
3443
+ const renderButton = (def) => React.createElement(IconButton, {
3004
3444
  key: def.key,
3005
- type: "button",
3006
- className: `ps-toolbar-button${def.active ? " is-active" : ""}`,
3445
+ icon: def.icon,
3446
+ label: def.label,
3007
3447
  onClick: def.onClick,
3008
3448
  disabled: Boolean(def.disabled),
3009
- title: def.title,
3010
- }, def.label);
3449
+ active: Boolean(def.active),
3450
+ });
3011
3451
 
3012
3452
  if (mobile) {
3013
- const firstRowButtons = buttonDefs.slice(0, 4);
3014
- const secondRowButtons = buttonDefs.slice(4);
3015
-
3453
+ // Single line: icon buttons are compact enough to fit one row; the
3454
+ // ps-toolbar-row-actions container scrolls horizontally (hidden
3455
+ // scrollbar) on the narrowest screens instead of wrapping.
3016
3456
  return React.createElement("div", { className: "ps-toolbar is-mobile" },
3017
3457
  React.createElement("div", { className: "ps-toolbar-row ps-toolbar-row-primary" },
3018
- firstRowButtons.map(renderButton)),
3019
- React.createElement("div", { className: "ps-toolbar-row ps-toolbar-row-secondary" },
3020
- React.createElement("div", { className: "ps-toolbar-row-actions" }, secondRowButtons.map(renderButton))),
3458
+ React.createElement("div", { className: "ps-toolbar-row-actions" }, buttonDefs.map(renderButton))),
3021
3459
  );
3022
3460
  }
3023
3461