pilotswarm 0.5.13 → 0.5.15

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 (51) hide show
  1. package/README.md +6 -0
  2. package/mcp/README.md +14 -2
  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/server.d.ts.map +1 -1
  8. package/mcp/dist/src/server.js +5 -1
  9. package/mcp/dist/src/server.js.map +1 -1
  10. package/mcp/dist/src/tools/capabilities.d.ts +4 -0
  11. package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
  12. package/mcp/dist/src/tools/capabilities.js +13 -0
  13. package/mcp/dist/src/tools/capabilities.js.map +1 -1
  14. package/mcp/dist/src/tools/groups.d.ts +5 -4
  15. package/mcp/dist/src/tools/groups.d.ts.map +1 -1
  16. package/mcp/dist/src/tools/groups.js +43 -20
  17. package/mcp/dist/src/tools/groups.js.map +1 -1
  18. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  19. package/mcp/dist/src/tools/sessions.js +118 -2
  20. package/mcp/dist/src/tools/sessions.js.map +1 -1
  21. package/package.json +3 -2
  22. package/tui/src/app.js +19 -2
  23. package/tui/src/auth/cli.js +13 -0
  24. package/tui/src/node-sdk-transport.js +99 -13
  25. package/tui/tui-splash-mobile.txt +5 -7
  26. package/tui/tui-splash.txt +13 -9
  27. package/ui/core/src/commands.js +2 -0
  28. package/ui/core/src/controller.js +454 -35
  29. package/ui/core/src/history.js +19 -1
  30. package/ui/core/src/reducer.js +121 -8
  31. package/ui/core/src/selectors.js +204 -14
  32. package/ui/core/src/state.js +3 -0
  33. package/ui/core/src/themes/helpers.js +4 -0
  34. package/ui/react/src/components.js +95 -6
  35. package/ui/react/src/web-app.js +798 -157
  36. package/web/api/router.js +7 -6
  37. package/web/api/ws.js +9 -0
  38. package/web/auth/index.js +5 -0
  39. package/web/auth/providers/dev.js +119 -0
  40. package/web/authz.js +142 -0
  41. package/web/dist/assets/index-CZizkB5Z.js +24 -0
  42. package/web/dist/assets/index-D9e2TGjO.css +1 -0
  43. package/web/dist/assets/pilotswarm-KMqn3ZJs.js +90 -0
  44. package/web/dist/assets/react-l0sNRNKZ.js +1 -0
  45. package/web/dist/index.html +3 -4
  46. package/web/runtime.js +553 -37
  47. package/web/server.js +2 -2
  48. package/web/dist/assets/index-bQ2QInMX.js +0 -24
  49. package/web/dist/assets/index-oldX95Tp.css +0 -1
  50. package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
  51. 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,
@@ -29,6 +33,7 @@ import {
29
33
  selectInspector,
30
34
  selectLogFilterModal,
31
35
  selectModelPickerModal,
36
+ selectNavigationError,
32
37
  selectReasoningEffortPickerModal,
33
38
  selectContextTierPickerModal,
34
39
  selectRenameSessionModal,
@@ -86,6 +91,18 @@ const INSPECTOR_TAB_LABELS = {
86
91
  files: "Files",
87
92
  stats: "Stats",
88
93
  };
94
+ // Glyphs for the icon-only Inspector tab row (labels move into hover/long-press
95
+ // tooltips via IconButton, matching the session toolbar treatment).
96
+ // Monochrome line-art codepoints only — emoji-default glyphs (📄 📊 🗑 ⏹) get
97
+ // force-rendered as colored emoji on iOS and clash with the rest of the UI.
98
+ const INSPECTOR_TAB_ICONS = {
99
+ sequence: "⇶",
100
+ logs: "≣",
101
+ nodes: "⬡",
102
+ history: "⟲",
103
+ files: "⧉",
104
+ stats: "▁▄▇",
105
+ };
89
106
 
90
107
  function cycleTabs(tabs, current, delta) {
91
108
  const values = Array.isArray(tabs) ? tabs.filter(Boolean) : [];
@@ -116,6 +133,39 @@ function supportsLocalFileOpen(controller) {
116
133
  return typeof controller?.transport?.openPathInDefaultApp === "function";
117
134
  }
118
135
 
136
+ const SESSION_LINK_COPIED_STATUS = "Session link copied to clipboard";
137
+ const SESSION_LINK_PRIVATE_WARNING = "Only people with access can open this link.";
138
+
139
+ function buildSessionLinkUrl(sessionId) {
140
+ if (!sessionId || typeof window === "undefined" || !window.location) return null;
141
+ return `${window.location.origin}${window.location.pathname}?session=${encodeURIComponent(sessionId)}`;
142
+ }
143
+
144
+ function copySessionLinkText(url) {
145
+ if (navigator?.clipboard?.writeText) navigator.clipboard.writeText(url).catch(() => {});
146
+ }
147
+
148
+ // Best-effort probe: is this session's deep link openable only by the owner/
149
+ // admins right now (private, with no targeted grants)? Used to warn in the
150
+ // copy-link dialog. Failure resolves false (no warning).
151
+ async function resolveSessionLinkWarn(controller, sessionId) {
152
+ try {
153
+ const transport = controller.transport;
154
+ if (typeof transport?.getSessionAccess === "function") {
155
+ const access = await transport.getSessionAccess(sessionId);
156
+ if ((access?.visibility || "private") === "private") {
157
+ const shares = typeof transport.listSessionShares === "function"
158
+ ? await transport.listSessionShares(sessionId).catch(() => [])
159
+ : [];
160
+ return !Array.isArray(shares) || shares.length === 0;
161
+ }
162
+ }
163
+ } catch {
164
+ // Fall through to no warning.
165
+ }
166
+ return false;
167
+ }
168
+
119
169
  function clearBrowserPreferenceCache() {
120
170
  if (typeof window === "undefined") return;
121
171
  try {
@@ -137,7 +187,13 @@ function normalizeProfileSettings(settings) {
137
187
  normalized.themeId = candidate.themeId.trim();
138
188
  }
139
189
  if (hasOwn(candidate, "sessionOwnerFilter") && candidate.sessionOwnerFilter && typeof candidate.sessionOwnerFilter === "object") {
140
- normalized.sessionOwnerFilter = candidate.sessionOwnerFilter;
190
+ const storedFilter = candidate.sessionOwnerFilter;
191
+ // Profiles saved before the "Shared with me" bucket existed have no
192
+ // includeShared key; default it on so shared sessions stay visible for
193
+ // existing users. An explicit false (the user turned it off) is kept.
194
+ normalized.sessionOwnerFilter = hasOwn(storedFilter, "includeShared")
195
+ ? storedFilter
196
+ : { ...storedFilter, includeShared: true };
141
197
  }
142
198
  if (hasOwn(candidate, "layoutAdjustments")) {
143
199
  normalized.layoutAdjustments = normalizeStoredLayoutAdjustments(candidate.layoutAdjustments);
@@ -1878,7 +1934,61 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
1878
1934
  const activeSession = viewState.activeSessionId
1879
1935
  ? viewState.sessionsById[viewState.activeSessionId] || null
1880
1936
  : null;
1881
- const canRenameActiveSession = Boolean(activeSession && !activeSession.isSystem);
1937
+ // "Manage session" combines rename, model, and sharing in one tabbed modal
1938
+ // (opened from the toolbar so the composer chrome stays minimal, esp. on
1939
+ // mobile). Rename and sharing are owner/admin-only (session:manage /
1940
+ // session:share), so the button is disabled for anyone else — the server
1941
+ // enforces too, but a disabled button is clearer than a 403.
1942
+ const [manageOpen, setManageOpen] = React.useState(false);
1943
+ const [linkModal, setLinkModal] = React.useState(null); // { url, warn } | null
1944
+ // The model picker is a separate (controller-owned) modal that would render
1945
+ // behind the Manage modal, so we close Manage while it is open and reopen
1946
+ // it when the picker closes — cancelling returns the user to Manage.
1947
+ const reopenManageRef = React.useRef(false);
1948
+ // The switch-model flow is multi-step (model → reasoning effort → context
1949
+ // tier); watch every step so Manage doesn't reopen between them. It reopens
1950
+ // only when the whole flow ends, and only if the switch was NOT applied
1951
+ // (cancel returns to Manage; confirm closes everything).
1952
+ const MODEL_FLOW_MODALS = ["modelPicker", "reasoningEffortPicker", "contextTierPicker"];
1953
+ const modelFlowOpen = useControllerSelector(controller, (state) => MODEL_FLOW_MODALS.includes(state.ui.modal?.type));
1954
+ React.useEffect(() => {
1955
+ if (!modelFlowOpen && reopenManageRef.current) {
1956
+ reopenManageRef.current = false;
1957
+ setManageOpen(true);
1958
+ }
1959
+ }, [modelFlowOpen]);
1960
+ const requestSwitchModel = () => {
1961
+ reopenManageRef.current = true;
1962
+ setManageOpen(false);
1963
+ controller.openSwitchModelPicker(() => {
1964
+ // Applied (not cancelled): close everything, don't reopen Manage.
1965
+ reopenManageRef.current = false;
1966
+ }).then(() => {
1967
+ // If the picker never actually opened (e.g. unsupported transport),
1968
+ // don't strand the Manage modal closed with no way back.
1969
+ if (!MODEL_FLOW_MODALS.includes(controller.getState().ui.modal?.type) && reopenManageRef.current) {
1970
+ reopenManageRef.current = false;
1971
+ setManageOpen(true);
1972
+ }
1973
+ }).catch((err) => {
1974
+ reopenManageRef.current = false;
1975
+ setManageOpen(true);
1976
+ controller.dispatch({ type: "ui/status", text: err?.message || String(err) || "Failed to switch model" });
1977
+ });
1978
+ };
1979
+ const authPrincipal = viewState.auth?.principal || null;
1980
+ const viewerRole = viewState.auth?.authorization?.role;
1981
+ const isAdminViewer = viewerRole === "admin" || viewerRole === "anonymous";
1982
+ const ownsActiveSession = Boolean(
1983
+ activeSession?.owner
1984
+ && authPrincipal
1985
+ && String(activeSession.owner.provider) === String(authPrincipal.provider)
1986
+ && String(activeSession.owner.subject) === String(authPrincipal.subject),
1987
+ );
1988
+ const canModifyActiveSession = Boolean(
1989
+ activeSession && !activeSession.isSystem && !activeSession.isGroup
1990
+ && (isAdminViewer || ownsActiveSession),
1991
+ );
1882
1992
  const selectedCount = Array.isArray(viewState.selectedIds) ? viewState.selectedIds.length : 0;
1883
1993
  const isBulkSelection = selectedCount > 1;
1884
1994
  const canPinActiveSession = Boolean(
@@ -1948,55 +2058,72 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
1948
2058
  }, `${selectedCount} selected`)
1949
2059
  : null,
1950
2060
  isBulkSelection
1951
- ? React.createElement("button", {
1952
- type: "button",
2061
+ ? React.createElement(IconButton, {
1953
2062
  className: "ps-mini-button",
2063
+ icon: "✕",
2064
+ label: "Clear multi-selection",
1954
2065
  onClick: () => controller.handleCommand(UI_COMMANDS.CLEAR_SESSION_SELECTION).catch(() => {}),
1955
- title: "Clear multi-selection",
1956
- }, "Clear")
1957
- : React.createElement("button", {
1958
- type: "button",
2066
+ })
2067
+ : React.createElement(IconButton, {
1959
2068
  className: "ps-mini-button",
2069
+ icon: "📌",
1960
2070
  onClick: () => controller.handleCommand(UI_COMMANDS.PIN_SESSION).catch(() => {}),
1961
2071
  disabled: !canPinActiveSession,
1962
- title: canPinActiveSession
1963
- ? (isActivePinned
1964
- ? "Unpin this session"
1965
- : "Pin this session to the top of the list")
2072
+ active: isActivePinned,
2073
+ label: canPinActiveSession
2074
+ ? (isActivePinned ? "Unpin this session" : "Pin this session to the top of the list")
1966
2075
  : "Only top-level non-system sessions can be pinned",
1967
- }, isActivePinned ? "Unpin" : "Pin"),
1968
- React.createElement("button", {
1969
- type: "button",
2076
+ }),
2077
+ React.createElement(IconButton, {
1970
2078
  className: "ps-mini-button",
2079
+ icon: groupableIds.length > 1 ? `⊞${groupableIds.length}` : "⊞",
1971
2080
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_MOVE_TO_GROUP).catch(() => {}),
1972
2081
  disabled: !canMoveToGroup,
1973
- title: canMoveToGroup
2082
+ label: canMoveToGroup
1974
2083
  ? (groupableIds.length > 1 ? `Move ${groupableIds.length} selected sessions to a group` : "Move this session to a group")
1975
2084
  : "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",
2085
+ }),
2086
+ React.createElement(IconButton, {
1979
2087
  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",
2088
+ icon: React.createElement(ManageGlyph),
2089
+ onClick: () => setManageOpen(true),
2090
+ disabled: !canModifyActiveSession || isBulkSelection,
2091
+ label: isBulkSelection ? "Disabled while multiple sessions are selected" : "Manage session — rename, switch model, and sharing",
2092
+ }),
2093
+ React.createElement(IconButton, {
2094
+ className: "ps-mini-button",
2095
+ icon: React.createElement(LinkGlyph),
2096
+ onClick: () => {
2097
+ const sid = activeSession?.sessionId;
2098
+ if (!sid) return;
2099
+ const url = buildSessionLinkUrl(sid);
2100
+ if (!url) return;
2101
+ copySessionLinkText(url);
2102
+ setLinkModal({ url, warn: false });
2103
+ resolveSessionLinkWarn(controller, sid).then((warn) => {
2104
+ setLinkModal((cur) => (cur && cur.url === url ? { ...cur, warn } : cur));
2105
+ }).catch(() => {});
2106
+ },
2107
+ disabled: !activeSession || activeSession.isGroup || isBulkSelection,
2108
+ label: "Copy link — copy a direct link to this session",
2109
+ }),
2110
+ React.createElement(IconButton, {
1986
2111
  className: "ps-mini-button",
2112
+ icon: activeSession?.isSystem ? "↻" : "⊗",
1987
2113
  onClick: () => controller.handleCommand(activeSession?.isGroup ? UI_COMMANDS.DELETE_SESSION : UI_COMMANDS.OPEN_TERMINATE_PICKER).catch(() => {}),
1988
2114
  disabled: !canTerminate,
1989
- title: isBulkSelection
2115
+ label: isBulkSelection
1990
2116
  ? `Terminate ${selectedCount} selected sessions (Mark Completed, Cancel, or Delete)`
1991
2117
  : activeSession?.isGroup
1992
- ? activeGroupCanDelete ? "Delete this empty group" : "Show why this group cannot be deleted yet"
2118
+ ? (activeGroupCanDelete ? "Delete this empty group" : "This group cannot be deleted yet")
1993
2119
  : 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),
2120
+ ? "Restart this system session (complete, terminate, or hard delete)"
2121
+ : `${activeSessionActionLabel} — mark completed, cancel, or delete`,
2122
+ }),
1997
2123
  actions);
1998
2124
 
1999
- return React.createElement(Panel, {
2125
+ return React.createElement(React.Fragment, null,
2126
+ React.createElement(Panel, {
2000
2127
  title: [{ text: "Sessions", color: "yellow", bold: true }],
2001
2128
  color: "yellow",
2002
2129
  focused: viewState.focused,
@@ -2070,7 +2197,364 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
2070
2197
  },
2071
2198
  React.createElement(SessionRowContent, { row, theme, structured: structuredRows })),
2072
2199
  )),
2073
- ));
2200
+ )),
2201
+ (manageOpen && activeSession && !activeSession.isGroup)
2202
+ ? React.createElement(SessionModifyModal, {
2203
+ controller,
2204
+ sessionId: activeSession.sessionId,
2205
+ initialTitle: activeSession.title || "",
2206
+ currentModel: activeSession.model || "",
2207
+ currentReasoningEffort: activeSession.reasoningEffort || "",
2208
+ principal: viewState.auth?.principal || null,
2209
+ onClose: () => setManageOpen(false),
2210
+ onSwitchModel: requestSwitchModel,
2211
+ onChanged: () => {},
2212
+ })
2213
+ : null,
2214
+ linkModal
2215
+ ? React.createElement(SessionLinkModal, {
2216
+ url: linkModal.url,
2217
+ warn: linkModal.warn,
2218
+ onCopyAgain: () => {
2219
+ copySessionLinkText(linkModal.url);
2220
+ controller.dispatch({ type: "ui/status", text: SESSION_LINK_COPIED_STATUS });
2221
+ },
2222
+ onClose: () => setLinkModal(null),
2223
+ })
2224
+ : null);
2225
+ }
2226
+
2227
+ // Compact confirmation dialog for the Copy link button: shows the copied URL
2228
+ // (selectable), confirms the copy, and warns when the link points at a private
2229
+ // session no one else can open yet.
2230
+ function SessionLinkModal({ url, warn, onCopyAgain, onClose }) {
2231
+ const inputRef = React.useRef(null);
2232
+ React.useEffect(() => {
2233
+ const node = inputRef.current;
2234
+ if (node) { node.focus(); node.select(); }
2235
+ }, []);
2236
+ const stop = (e) => e.stopPropagation();
2237
+ return React.createElement("div", { className: "ps-share-overlay", onClick: onClose },
2238
+ React.createElement("div", { className: "ps-link-modal", onClick: stop },
2239
+ React.createElement("div", { className: "ps-share-modal-head" },
2240
+ React.createElement("span", null, "Link copied"),
2241
+ React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
2242
+ React.createElement("div", { className: "ps-share-section-sub" },
2243
+ "Copied to your clipboard — anyone with access can open the session from this link."),
2244
+ React.createElement("div", { className: "ps-link-row" },
2245
+ React.createElement("input", {
2246
+ ref: inputRef, className: "ps-link-input", readOnly: true, value: url,
2247
+ onFocus: () => inputRef.current?.select(),
2248
+ }),
2249
+ React.createElement("button", { className: "ps-mini-button", onClick: onCopyAgain }, "Copy")),
2250
+ warn
2251
+ ? React.createElement("div", { className: "ps-link-warn" }, SESSION_LINK_PRIVATE_WARNING)
2252
+ : null));
2253
+ }
2254
+
2255
+ const VISIBILITY_META = {
2256
+ private: { glyph: "🔒", label: "Private" },
2257
+ shared_read: { glyph: "👁", label: "Shared · read" },
2258
+ shared_write: { glyph: "✎", label: "Shared · write" },
2259
+ };
2260
+
2261
+ // The "manage" glyph (sliders) for the Manage session button — reads as
2262
+ // settings/controls rather than the narrower share-nodes glyph.
2263
+ function ManageGlyph() {
2264
+ return React.createElement("svg", {
2265
+ className: "ps-share-glyph", viewBox: "0 0 24 24", fill: "none",
2266
+ stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round",
2267
+ "aria-hidden": "true",
2268
+ },
2269
+ React.createElement("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
2270
+ React.createElement("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
2271
+ React.createElement("line", { x1: "4", y1: "18", x2: "20", y2: "18" }),
2272
+ React.createElement("circle", { cx: "9", cy: "6", r: "2", fill: "currentColor" }),
2273
+ React.createElement("circle", { cx: "15", cy: "12", r: "2", fill: "currentColor" }),
2274
+ React.createElement("circle", { cx: "8", cy: "18", r: "2", fill: "currentColor" }));
2275
+ }
2276
+
2277
+ // The standard "link" glyph (two chain segments). Used for the Copy link button.
2278
+ function LinkGlyph() {
2279
+ return React.createElement("svg", {
2280
+ className: "ps-share-glyph", viewBox: "0 0 24 24", fill: "none",
2281
+ stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round",
2282
+ "aria-hidden": "true",
2283
+ },
2284
+ React.createElement("path", { d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }),
2285
+ React.createElement("path", { d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" }));
2286
+ }
2287
+
2288
+ /**
2289
+ * Fetch the caller's effective access to the active session (security model).
2290
+ * Returns { access, loading, reload }. access is the getSessionAccess payload
2291
+ * ({ visibility, relation, canWrite, canManage, owner, isSystem, enforced }),
2292
+ * or null while loading / on error / when the transport lacks the method
2293
+ * (older deployments — treated as full access so the UI never over-restricts
2294
+ * a deployment that isn't enforcing).
2295
+ */
2296
+ function useActiveSessionAccess(controller, activeSessionId, isGroup) {
2297
+ const [state, setState] = React.useState({ access: null, loading: false });
2298
+ const reload = React.useCallback(() => {
2299
+ if (!activeSessionId || isGroup || typeof controller.transport.getSessionAccess !== "function") {
2300
+ setState({ access: null, loading: false });
2301
+ return;
2302
+ }
2303
+ let cancelled = false;
2304
+ setState((s) => ({ ...s, loading: true }));
2305
+ controller.transport.getSessionAccess(activeSessionId)
2306
+ .then((access) => { if (!cancelled) setState({ access, loading: false }); })
2307
+ .catch(() => { if (!cancelled) setState({ access: null, loading: false }); });
2308
+ return () => { cancelled = true; };
2309
+ }, [controller, activeSessionId, isGroup]);
2310
+ React.useEffect(() => reload(), [reload]);
2311
+ return { access: state.access, loading: state.loading, reload };
2312
+ }
2313
+
2314
+ // Combined "Share & settings" modal opened from the session list toolbar:
2315
+ // rename, copy link, plus (for the owner/admin) sharing — visibility +
2316
+ // per-person grants. Fetches its own access snapshot so callers only pass the
2317
+ // session id + current title.
2318
+ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel, currentReasoningEffort, principal, onClose, onSwitchModel, onChanged }) {
2319
+ const [tab, setTab] = React.useState("general");
2320
+ const [access, setAccess] = React.useState(null);
2321
+ const [title, setTitle] = React.useState(initialTitle || "");
2322
+ const [shares, setShares] = React.useState([]); // committed baseline
2323
+ const [draftShares, setDraftShares] = React.useState([]); // staged, applied on Apply
2324
+ const [visibility, setVisibility] = React.useState("private");
2325
+ const [granteeQuery, setGranteeQuery] = React.useState("");
2326
+ const [granteeAccess, setGranteeAccess] = React.useState("write");
2327
+ const [directory, setDirectory] = React.useState([]);
2328
+ const [busy, setBusy] = React.useState(false);
2329
+ const [error, setError] = React.useState(null);
2330
+
2331
+ React.useEffect(() => {
2332
+ let cancelled = false;
2333
+ controller.transport.getSessionAccess(sessionId)
2334
+ .then((a) => { if (!cancelled && a) { setAccess(a); setVisibility(a.visibility || "private"); } })
2335
+ .catch(() => {});
2336
+ // Member directory for name autocomplete (excludes synthetic principals).
2337
+ if (typeof controller.transport.listKnownUsers === "function") {
2338
+ controller.transport.listKnownUsers({ limit: 500 })
2339
+ .then((users) => { if (!cancelled) setDirectory(Array.isArray(users) ? users : []); })
2340
+ .catch(() => {});
2341
+ }
2342
+ return () => { cancelled = true; };
2343
+ }, [controller, sessionId]);
2344
+
2345
+ const loadShares = React.useCallback(() => {
2346
+ controller.transport.listSessionShares(sessionId)
2347
+ .then((rows) => { const list = Array.isArray(rows) ? rows : []; setShares(list); setDraftShares(list); })
2348
+ .catch(() => { setShares([]); setDraftShares([]); });
2349
+ }, [controller, sessionId]);
2350
+ React.useEffect(() => { loadShares(); }, [loadShares]);
2351
+
2352
+ const run = async (fn) => {
2353
+ setBusy(true); setError(null);
2354
+ try { await fn(); onChanged?.(); }
2355
+ catch (err) { setError(err?.message || String(err)); }
2356
+ finally { setBusy(false); }
2357
+ };
2358
+
2359
+ const saveTitle = () => run(async () => {
2360
+ await controller.transport.renameSession(sessionId, title.trim());
2361
+ });
2362
+ // Resolve the typed text to a directory member (by name, email, or id).
2363
+ // Falls back to treating the text as a raw subject for a not-yet-seen user.
2364
+ const resolveGrantee = (text) => {
2365
+ const q = text.trim().toLowerCase();
2366
+ if (!q) return null;
2367
+ const match = directory.find((u) =>
2368
+ (u.displayName && u.displayName.toLowerCase() === q)
2369
+ || (u.email && u.email.toLowerCase() === q)
2370
+ || (u.subject && u.subject.toLowerCase() === q));
2371
+ if (match) return match;
2372
+ return { provider: principal?.provider || "dev", subject: text.trim(), email: null, displayName: null };
2373
+ };
2374
+ const shareKey = (r) => `${r.provider}${r.subject}`;
2375
+ // Access edits are staged into draftShares/visibility and committed only
2376
+ // on Apply, so the button is a deliberate, satisfying confirmation.
2377
+ const stageGrant = (grantee) => {
2378
+ const key = shareKey(grantee);
2379
+ setDraftShares((cur) => [
2380
+ ...cur.filter((r) => shareKey(r) !== key),
2381
+ { provider: grantee.provider, subject: grantee.subject, email: grantee.email ?? null, displayName: grantee.displayName ?? null, access: granteeAccess },
2382
+ ]);
2383
+ setGranteeQuery("");
2384
+ };
2385
+ const addGrant = () => {
2386
+ const grantee = resolveGrantee(granteeQuery);
2387
+ if (grantee) stageGrant(grantee);
2388
+ };
2389
+ const stageRevoke = (row) => {
2390
+ const key = shareKey(row);
2391
+ setDraftShares((cur) => cur.filter((r) => shareKey(r) !== key));
2392
+ };
2393
+
2394
+ // Autocomplete suggestions: directory members matching the query, minus
2395
+ // the owner and anyone already granted.
2396
+ const grantedKeys = new Set(draftShares.map(shareKey));
2397
+ const ownerKey = access?.owner ? `${access.owner.provider}${access.owner.subject}` : null;
2398
+ const q = granteeQuery.trim().toLowerCase();
2399
+ const suggestions = q
2400
+ ? directory.filter((u) => {
2401
+ const key = shareKey(u);
2402
+ if (key === ownerKey || grantedKeys.has(key)) return false;
2403
+ return (u.displayName && u.displayName.toLowerCase().includes(q))
2404
+ || (u.email && u.email.toLowerCase().includes(q))
2405
+ || (u.subject && u.subject.toLowerCase().includes(q));
2406
+ }).slice(0, 25)
2407
+ : [];
2408
+ const canManage = Boolean(access?.canManage);
2409
+ // Dirty = staged access differs from the committed baseline (the `:level`
2410
+ // suffix catches a grant whose access level changed).
2411
+ const baselineVisibility = access?.visibility || "private";
2412
+ const draftSig = new Set(draftShares.map((r) => `${shareKey(r)}:${r.access}`));
2413
+ const baseSig = new Set(shares.map((r) => `${shareKey(r)}:${r.access}`));
2414
+ const sharesChanged = draftSig.size !== baseSig.size || [...draftSig].some((k) => !baseSig.has(k));
2415
+ const accessDirty = canManage && (visibility !== baselineVisibility || sharesChanged);
2416
+ const applyAccess = () => run(async () => {
2417
+ if (visibility !== baselineVisibility) {
2418
+ await controller.transport.setSessionVisibility(sessionId, visibility);
2419
+ }
2420
+ const baseByKey = new Map(shares.map((r) => [shareKey(r), r]));
2421
+ const draftByKey = new Map(draftShares.map((r) => [shareKey(r), r]));
2422
+ for (const [key, r] of draftByKey) {
2423
+ const b = baseByKey.get(key);
2424
+ if (!b || b.access !== r.access) {
2425
+ await controller.transport.grantSessionShare(
2426
+ sessionId,
2427
+ { provider: r.provider, subject: r.subject, email: r.email ?? null, displayName: r.displayName ?? null },
2428
+ r.access,
2429
+ );
2430
+ }
2431
+ }
2432
+ for (const [key, r] of baseByKey) {
2433
+ if (!draftByKey.has(key)) {
2434
+ await controller.transport.revokeSessionShare(sessionId, { provider: r.provider, subject: r.subject });
2435
+ }
2436
+ }
2437
+ const rows = await controller.transport.listSessionShares(sessionId).catch(() => null);
2438
+ if (Array.isArray(rows)) { setShares(rows); setDraftShares(rows); }
2439
+ const a = await controller.transport.getSessionAccess(sessionId).catch(() => null);
2440
+ if (a) { setAccess(a); setVisibility(a.visibility || "private"); }
2441
+ controller.dispatch({ type: "ui/status", text: "Access updated." });
2442
+ });
2443
+ const switchModel = onSwitchModel || (() => {
2444
+ onClose();
2445
+ controller.openSwitchModelPicker().catch((err) => {
2446
+ controller.dispatch({ type: "ui/status", text: err?.message || String(err) || "Failed to switch model" });
2447
+ });
2448
+ });
2449
+ const modelLabel = currentModel
2450
+ ? (currentReasoningEffort ? `${currentModel}:${currentReasoningEffort}` : currentModel)
2451
+ : "—";
2452
+ // Access is owner/admin-only; hide the tab entirely for viewers who can
2453
+ // only rename/switch model on their own session.
2454
+ const tabs = [
2455
+ { id: "general", label: "General" },
2456
+ ...(canManage ? [{ id: "access", label: "Access" }] : []),
2457
+ ];
2458
+ const activeTab = tabs.some((t) => t.id === tab) ? tab : "general";
2459
+ const stop = (e) => e.stopPropagation();
2460
+ return React.createElement("div", { className: "ps-share-overlay", onClick: onClose },
2461
+ React.createElement("div", { className: "ps-share-modal", onClick: stop },
2462
+ React.createElement("div", { className: "ps-share-modal-head" },
2463
+ React.createElement("span", null, "Manage session"),
2464
+ React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
2465
+
2466
+ // ── Tab bar ───────────────────────────────────────────────
2467
+ React.createElement("div", { className: "ps-manage-tabs", role: "tablist" },
2468
+ tabs.map((t) => React.createElement("button", {
2469
+ key: t.id,
2470
+ type: "button",
2471
+ role: "tab",
2472
+ "aria-selected": activeTab === t.id ? "true" : "false",
2473
+ className: `ps-manage-tab${activeTab === t.id ? " is-active" : ""}`,
2474
+ onClick: () => setTab(t.id),
2475
+ }, t.label))),
2476
+
2477
+ // ── General: rename + model ───────────────────────────────
2478
+ activeTab === "general" ? React.createElement(React.Fragment, null,
2479
+ React.createElement("div", { className: "ps-share-section-label" }, "Name"),
2480
+ React.createElement("div", { className: "ps-share-add-row" },
2481
+ React.createElement("input", {
2482
+ className: "ps-share-add-input", placeholder: "Session title",
2483
+ value: title, disabled: busy,
2484
+ onChange: (e) => setTitle(e.target.value),
2485
+ onKeyDown: (e) => { if (e.key === "Enter") saveTitle(); },
2486
+ }),
2487
+ React.createElement("button", { className: "ps-mini-button", disabled: busy || !title.trim(), onClick: saveTitle }, "Save")),
2488
+ React.createElement("div", { className: "ps-share-section-label" }, "Model"),
2489
+ React.createElement("div", { className: "ps-share-section-sub" }, "The model this session uses on its next turn."),
2490
+ React.createElement("div", { className: "ps-share-add-row" },
2491
+ React.createElement("span", { className: "ps-manage-model-current" }, modelLabel),
2492
+ React.createElement("button", { className: "ps-mini-button", disabled: busy, onClick: switchModel }, "Switch model…")))
2493
+ : null,
2494
+
2495
+ // ── Access (owner / admin only) ───────────────────────────
2496
+ (activeTab === "access" && canManage) ? React.createElement(React.Fragment, null,
2497
+ React.createElement("div", { className: "ps-share-section-label" }, "General access"),
2498
+ React.createElement("div", { className: "ps-share-section-sub" }, "The baseline level for everyone signed in to this workspace."),
2499
+ ["private", "shared_read", "shared_write"].map((value) =>
2500
+ React.createElement("label", { key: value, className: `ps-share-radio${visibility === value ? " is-active" : ""}` },
2501
+ React.createElement("input", {
2502
+ type: "radio", name: "visibility", checked: visibility === value,
2503
+ disabled: busy, onChange: () => setVisibility(value),
2504
+ }),
2505
+ React.createElement("span", { className: "ps-share-radio-glyph" }, VISIBILITY_META[value].glyph),
2506
+ React.createElement("span", null, VISIBILITY_META[value].label),
2507
+ React.createElement("span", { className: "ps-share-radio-hint" },
2508
+ value === "private" ? "only you and admins"
2509
+ : value === "shared_read" ? "everyone here can view"
2510
+ : "everyone here can view and send"))),
2511
+ React.createElement("div", { className: "ps-share-section-label" }, "Special access"),
2512
+ React.createElement("div", { className: "ps-share-section-sub" }, "Give specific people more than the general level. A person's grant wins over general access."),
2513
+ draftShares.length === 0
2514
+ ? React.createElement("div", { className: "ps-share-empty" }, "No individual grants — everyone has the general access above.")
2515
+ : draftShares.map((row) => React.createElement("div", { key: `${row.provider}/${row.subject}`, className: "ps-share-grant-row" },
2516
+ React.createElement("span", { className: "ps-share-grant-name" }, row.displayName || row.subject),
2517
+ React.createElement("span", { className: "ps-share-grant-access" }, `can ${row.access}`),
2518
+ React.createElement("button", { className: "ps-mini-button", disabled: busy, onClick: () => stageRevoke(row) }, "Remove"))),
2519
+ React.createElement("div", { className: "ps-share-add-wrap" },
2520
+ React.createElement("div", { className: "ps-share-add-row" },
2521
+ React.createElement("input", {
2522
+ className: "ps-share-add-input", placeholder: "Name, email, or id",
2523
+ value: granteeQuery, disabled: busy, autoComplete: "off",
2524
+ onChange: (e) => setGranteeQuery(e.target.value),
2525
+ onKeyDown: (e) => { if (e.key === "Enter") addGrant(); },
2526
+ }),
2527
+ React.createElement("select", {
2528
+ className: "ps-share-add-select", value: granteeAccess, disabled: busy,
2529
+ onChange: (e) => setGranteeAccess(e.target.value),
2530
+ },
2531
+ React.createElement("option", { value: "read" }, "can read"),
2532
+ React.createElement("option", { value: "write" }, "can write")),
2533
+ React.createElement("button", { className: "ps-mini-button", disabled: busy || !granteeQuery.trim(), onClick: addGrant }, "Add")),
2534
+ suggestions.length > 0
2535
+ ? React.createElement("div", { className: "ps-share-suggestions" },
2536
+ suggestions.map((u) => React.createElement("button", {
2537
+ key: `${u.provider}/${u.subject}`,
2538
+ type: "button", className: "ps-share-suggestion", disabled: busy,
2539
+ onClick: () => stageGrant(u),
2540
+ },
2541
+ React.createElement("span", { className: "ps-share-suggestion-name" }, u.displayName || u.subject),
2542
+ u.email ? React.createElement("span", { className: "ps-share-suggestion-email" }, u.email) : null)))
2543
+ : null),
2544
+ React.createElement("div", { className: "ps-share-foot-hint" },
2545
+ "Sharing applies to this session and its sub-agents. Suggestions are people who have "
2546
+ + "signed in before — you can also grant by email to someone who hasn't; it takes effect "
2547
+ + "when they first sign in."),
2548
+ React.createElement("div", { className: "ps-manage-apply-bar" },
2549
+ React.createElement("span", { className: "ps-share-section-sub" },
2550
+ accessDirty ? "Unsaved access changes." : "No changes to apply."),
2551
+ React.createElement("button", {
2552
+ className: "ps-mini-button ps-manage-apply",
2553
+ disabled: busy || !accessDirty,
2554
+ onClick: applyAccess,
2555
+ }, "Apply")))
2556
+ : null,
2557
+ error ? React.createElement("div", { className: "ps-share-error" }, error) : null));
2074
2558
  }
2075
2559
 
2076
2560
  function ChatPane({ controller, mobile = false, fullWidth = false, showComposer = true }) {
@@ -2099,12 +2583,16 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
2099
2583
  activeSessionStatus: activeSessionId ? String(state.sessions.byId[activeSessionId]?.status || "").toLowerCase() : "",
2100
2584
  focused: state.ui.focusRegion === "chat",
2101
2585
  scroll: state.ui.scroll.chat,
2586
+ // Viewer identity — so the transcript can say "You" for the viewer's
2587
+ // own messages and name others (with an "(owner)" tag).
2588
+ authPrincipal: state.auth?.principal || null,
2102
2589
  contentWidth,
2103
2590
  };
2104
2591
  }, shallowEqualObject);
2105
2592
  const selectorState = React.useMemo(() => ({
2106
2593
  branding: viewState.branding,
2107
2594
  connection: viewState.connection,
2595
+ auth: { principal: viewState.authPrincipal },
2108
2596
  sessions: {
2109
2597
  activeSessionId: viewState.activeSessionId,
2110
2598
  byId: viewState.sessionsById,
@@ -2128,6 +2616,7 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
2128
2616
  viewState.activeHistory,
2129
2617
  viewState.activeSessionId,
2130
2618
  viewState.activeOutbox,
2619
+ viewState.authPrincipal,
2131
2620
  viewState.branding,
2132
2621
  viewState.connection,
2133
2622
  viewState.chatViewMode,
@@ -2165,11 +2654,49 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
2165
2654
  () => (pinnedActivityLines.length > 0 ? [...outboxLines, ...pinnedActivityLines] : outboxLines),
2166
2655
  [outboxLines, pinnedActivityLines],
2167
2656
  );
2168
- const composer = showComposer && !viewState.activeSessionIsGroup && viewState.chatViewMode !== "summary"
2657
+ // Read-only gating: a view-only viewer (shared_read / read grant, no write)
2658
+ // gets an explanatory notice instead of the composer. The visibility chip
2659
+ // and Share affordance now live in the session list "Modify" modal and the
2660
+ // selected-session details, keeping the composer chrome minimal (mobile).
2661
+ const { access } = useActiveSessionAccess(
2662
+ controller, viewState.activeSessionId, viewState.activeSessionIsGroup,
2663
+ );
2664
+ const navigationError = useControllerSelector(controller, selectNavigationError, shallowEqualObject);
2665
+ const composerBase = showComposer && !viewState.activeSessionIsGroup && viewState.chatViewMode !== "summary";
2666
+ const readOnly = Boolean(access) && access.canWrite === false;
2667
+ const composer = composerBase
2169
2668
  ? React.createElement("div", { className: "ps-chat-composer" },
2170
- React.createElement(PromptComposer, { controller, mobile, active: true }))
2669
+ readOnly
2670
+ ? React.createElement("div", { className: "ps-composer-readonly" },
2671
+ `You have view access to this session. Ask ${access.owner?.displayName || access.owner?.email || "the owner"} for write access to participate.`)
2672
+ : React.createElement(PromptComposer, { controller, mobile, active: true }))
2171
2673
  : null;
2172
2674
 
2675
+ // A failed deep-link intent with nothing else loaded replaces the pane
2676
+ // body with the nav-error empty state (the reducer refuses fallback
2677
+ // selection while the intent exists, so there is no transcript to show).
2678
+ const hasLoadableActiveSession = Boolean(
2679
+ viewState.activeSessionId && viewState.sessionsById[viewState.activeSessionId],
2680
+ );
2681
+ if (navigationError && !hasLoadableActiveSession) {
2682
+ return React.createElement(Panel, {
2683
+ title: chrome.title,
2684
+ color: chrome.color,
2685
+ focused: viewState.focused,
2686
+ theme,
2687
+ className: "ps-chat-panel",
2688
+ },
2689
+ React.createElement("div", { className: "ps-empty-state ps-nav-error-state" },
2690
+ React.createElement("div", { className: "ps-nav-error-message" }, navigationError.message),
2691
+ navigationError.retryable
2692
+ ? React.createElement("button", {
2693
+ type: "button",
2694
+ className: "ps-mini-button",
2695
+ onClick: () => controller.setNavigationIntent(navigationError.sessionId),
2696
+ }, "Retry")
2697
+ : null));
2698
+ }
2699
+
2173
2700
  return React.createElement(ScrollLinesPanel, {
2174
2701
  controller,
2175
2702
  title: mobile ? compactTitleRuns(chrome.title, 28) : chrome.title,
@@ -2191,60 +2718,35 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
2191
2718
  });
2192
2719
  }
2193
2720
 
2194
- function MobileWorkspace({ controller, sessionsCollapsed, setSessionsCollapsed }) {
2195
- const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
2196
- const theme = getTheme(themeId);
2197
- const sessionToggle = React.createElement("button", {
2198
- type: "button",
2199
- className: "ps-mini-button",
2200
- onClick: () => setSessionsCollapsed((current) => !current),
2201
- }, sessionsCollapsed ? "Show" : "Hide");
2202
-
2721
+ function MobileWorkspace({ controller }) {
2722
+ // The session list is always shown; use the toolbar Focus button to give
2723
+ // the chat the full screen (the old Show/Hide toggle was redundant with it).
2203
2724
  return React.createElement("div", { className: "ps-mobile-workspace" },
2204
- sessionsCollapsed
2205
- ? React.createElement(Panel, {
2206
- title: [{ text: "Sessions", color: "yellow", bold: true }],
2207
- color: "yellow",
2208
- focused: false,
2209
- theme,
2210
- actions: sessionToggle,
2211
- className: "ps-mobile-session-collapsed",
2212
- },
2213
- React.createElement("div", { className: "ps-mobile-session-summary" }, "Session list collapsed."))
2214
- : React.createElement(SessionPane, {
2215
- controller,
2216
- actions: sessionToggle,
2217
- panelClassName: "ps-mobile-session-pane",
2218
- }),
2725
+ React.createElement(SessionPane, {
2726
+ controller,
2727
+ panelClassName: "ps-mobile-session-pane",
2728
+ }),
2219
2729
  React.createElement("div", { className: "ps-mobile-chat-pane" },
2220
2730
  React.createElement(ChatPane, { controller, mobile: true, fullWidth: true })));
2221
2731
  }
2222
2732
 
2223
2733
  function InspectorTabs({ activeTab, controller }) {
2224
2734
  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", {
2735
+ // Icon-only tabs: the full label (e.g. "Node Map") lives in the IconButton
2736
+ // tooltip, so there's no per-label width pressure and no mobile shortening.
2737
+ // Default IconButton className ("ps-toolbar-button") so these render
2738
+ // identically to the session toolbar icons.
2739
+ return React.createElement("div", { className: "ps-tab-row ps-tab-row-icons" },
2740
+ visibleTabs.map((tab) => React.createElement(IconButton, {
2238
2741
  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,
2742
+ icon: INSPECTOR_TAB_ICONS[tab] || "•",
2743
+ label: INSPECTOR_TAB_LABELS[tab] || tab,
2744
+ active: activeTab === tab,
2243
2745
  onClick: () => {
2244
2746
  controller.setFocus("inspector");
2245
2747
  controller.selectInspectorTab(tab).catch(() => {});
2246
2748
  },
2247
- }, labelFor(tab))));
2749
+ })));
2248
2750
  }
2249
2751
 
2250
2752
  function FilesPane({ controller, focused, mobile = false }) {
@@ -2338,40 +2840,40 @@ function FilesPane({ controller, focused, mobile = false }) {
2338
2840
  event.currentTarget.value = "";
2339
2841
  },
2340
2842
  }),
2341
- React.createElement("button", {
2342
- type: "button",
2343
- className: "ps-mini-button",
2843
+ React.createElement(IconButton, {
2844
+ icon: "↥",
2845
+ label: "Upload",
2344
2846
  onClick: openUploadPicker,
2345
2847
  disabled: !viewState.canBrowserUpload && !viewState.canPathUpload,
2346
- }, "Up"),
2347
- React.createElement("button", {
2348
- type: "button",
2349
- className: "ps-mini-button",
2848
+ }),
2849
+ React.createElement(IconButton, {
2850
+ icon: "↧",
2851
+ label: "Download",
2350
2852
  onClick: () => controller.handleCommand(UI_COMMANDS.DOWNLOAD_SELECTED_FILE).catch(() => {}),
2351
2853
  disabled: !hasSelection,
2352
- }, "Down"),
2353
- viewState.canDeleteArtifacts ? React.createElement("button", {
2354
- type: "button",
2355
- className: "ps-mini-button",
2854
+ }),
2855
+ viewState.canDeleteArtifacts ? React.createElement(IconButton, {
2856
+ icon: "✕",
2857
+ label: "Delete",
2356
2858
  onClick: () => controller.handleCommand(UI_COMMANDS.DELETE_SELECTED_FILE).catch(() => {}),
2357
2859
  disabled: !hasSelection,
2358
- }, "Delete") : null,
2359
- viewState.canOpenLocally ? React.createElement("button", {
2360
- type: "button",
2361
- className: "ps-mini-button",
2860
+ }) : null,
2861
+ viewState.canOpenLocally ? React.createElement(IconButton, {
2862
+ icon: "↗",
2863
+ label: "Open locally",
2362
2864
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_SELECTED_FILE).catch(() => {}),
2363
2865
  disabled: !hasSelection,
2364
- }, "Open") : null,
2365
- React.createElement("button", {
2366
- type: "button",
2367
- className: "ps-mini-button",
2866
+ }) : null,
2867
+ React.createElement(IconButton, {
2868
+ icon: "▾",
2869
+ label: "Filter",
2368
2870
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_FILES_FILTER).catch(() => {}),
2369
- }, "Filter"),
2370
- React.createElement("button", {
2371
- type: "button",
2372
- className: "ps-mini-button",
2871
+ }),
2872
+ React.createElement(IconButton, {
2873
+ icon: viewState.fullscreen ? "⇱" : "⛶",
2874
+ label: viewState.fullscreen ? "Exit fullscreen" : "Fullscreen",
2373
2875
  onClick: () => controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {}),
2374
- }, viewState.fullscreen ? "Close" : "FS"));
2876
+ }));
2375
2877
 
2376
2878
  const listContent = items.length === 0
2377
2879
  ? normalizeLines(filesView.listBodyLines || []).map((line, index) => React.createElement(Line, {
@@ -2555,39 +3057,42 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
2555
3057
 
2556
3058
  const actions = [];
2557
3059
  if (viewState.inspectorTab === "logs") {
2558
- actions.push(React.createElement("button", {
3060
+ actions.push(React.createElement(IconButton, {
2559
3061
  key: "tail",
2560
- type: "button",
2561
- className: "ps-mini-button",
3062
+ icon: viewState.logsTailing ? "■" : "⇣",
3063
+ label: viewState.logsTailing ? "Stop tailing" : "Tail (follow)",
3064
+ active: viewState.logsTailing,
2562
3065
  onClick: () => controller.handleCommand(UI_COMMANDS.TOGGLE_LOG_TAIL).catch(() => {}),
2563
- }, viewState.logsTailing ? "Stop Tail" : "Tail"));
2564
- actions.push(React.createElement("button", {
3066
+ }));
3067
+ actions.push(React.createElement(IconButton, {
2565
3068
  key: "filter",
2566
- type: "button",
2567
- className: "ps-mini-button",
3069
+ icon: "▾",
3070
+ label: "Filter",
2568
3071
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_LOG_FILTER).catch(() => {}),
2569
- }, "Filter"));
3072
+ }));
2570
3073
  } else if (viewState.inspectorTab === "history") {
2571
- actions.push(React.createElement("button", {
3074
+ actions.push(React.createElement(IconButton, {
2572
3075
  key: "refresh",
2573
- type: "button",
2574
- className: "ps-mini-button",
3076
+ icon: "↻",
3077
+ label: "Refresh",
2575
3078
  onClick: () => controller.handleCommand(UI_COMMANDS.REFRESH_EXECUTION_HISTORY).catch(() => {}),
2576
- }, "Refresh"));
2577
- actions.push(React.createElement("button", {
3079
+ }));
3080
+ actions.push(React.createElement(IconButton, {
2578
3081
  key: "save",
2579
- type: "button",
2580
- className: "ps-mini-button",
3082
+ icon: "⇩",
3083
+ label: "Export as artifact",
2581
3084
  onClick: () => controller.handleCommand(UI_COMMANDS.EXPORT_EXECUTION_HISTORY).catch(() => {}),
2582
- }, "Artifact"));
3085
+ }));
2583
3086
  } else if (viewState.inspectorTab === "stats") {
3087
+ const STATS_MODE_ICONS = { session: "◉", fleet: "⬢", users: "⚇" };
2584
3088
  for (const mode of ["session", "fleet", "users"]) {
2585
- actions.push(React.createElement("button", {
3089
+ actions.push(React.createElement(IconButton, {
2586
3090
  key: `stats-view:${mode}`,
2587
- type: "button",
2588
- className: `ps-mini-button${viewState.statsViewMode === mode ? " is-active" : ""}`,
3091
+ icon: STATS_MODE_ICONS[mode],
3092
+ label: `${mode.replace(/^./u, (char) => char.toUpperCase())} stats`,
3093
+ active: viewState.statsViewMode === mode,
2589
3094
  onClick: () => controller.setStatsViewMode(mode),
2590
- }, mode.replace(/^./u, (char) => char.toUpperCase())));
3095
+ }));
2591
3096
  }
2592
3097
  }
2593
3098
 
@@ -2936,88 +3441,229 @@ function StatusStrip({ controller }) {
2936
3441
  );
2937
3442
  }
2938
3443
 
3444
+ // A compact icon button whose meaning is revealed on demand via a custom
3445
+ // tooltip: desktop hover shows it after a fixed 1s (the native `title` delay is
3446
+ // browser-controlled and too long); touch devices get a long-press tooltip
3447
+ // (hold ~450ms to see the label, release to dismiss — the long-press does not
3448
+ // fire onClick). aria-label carries the meaning for assistive tech.
3449
+ const ICON_HOVER_TOOLTIP_MS = 1000;
3450
+ function IconButton({ icon, label, onClick, disabled = false, active = false, className = "ps-toolbar-button" }) {
3451
+ // The tooltip is portaled to <body> so it escapes the toolbar/pane
3452
+ // overflow-clipping and stacking contexts (nested tooltips were hidden
3453
+ // behind, or bled through by, the panes). Coordinates are computed from
3454
+ // the button rect; it flips above when there's no room below.
3455
+ const [tip, setTip] = React.useState(null); // { x, y, placement } | null
3456
+ const btnRef = React.useRef(null);
3457
+ const tipRef = React.useRef(null);
3458
+ const timerRef = React.useRef(null);
3459
+ const longPressRef = React.useRef(false);
3460
+
3461
+ // Keep the tooltip within the viewport horizontally — the leftmost/rightmost
3462
+ // buttons would otherwise clip off the edge (the tooltip is center-anchored).
3463
+ React.useLayoutEffect(() => {
3464
+ if (!tip || !tipRef.current || typeof window === "undefined") return;
3465
+ const half = tipRef.current.offsetWidth / 2;
3466
+ const margin = 6;
3467
+ const clampedX = Math.max(half + margin, Math.min(tip.x, window.innerWidth - half - margin));
3468
+ tipRef.current.style.left = `${clampedX}px`;
3469
+ }, [tip]);
3470
+
3471
+ const reveal = (preferAbove = false) => {
3472
+ const el = btnRef.current;
3473
+ if (!el || typeof window === "undefined") return;
3474
+ const r = el.getBoundingClientRect();
3475
+ // Touch prefers ABOVE (the finger covers anything below the button);
3476
+ // hover prefers below. Either flips when there's no room.
3477
+ const below = preferAbove
3478
+ ? r.top - 44 < 0
3479
+ : r.bottom + 44 < window.innerHeight;
3480
+ setTip({
3481
+ x: r.left + r.width / 2,
3482
+ y: below ? r.bottom + 6 : r.top - 6,
3483
+ placement: below ? "below" : "above",
3484
+ });
3485
+ };
3486
+
3487
+ // Touch and hover are handled through pointer events so each path can
3488
+ // filter on pointerType — the synthesized mouse events iOS fires after
3489
+ // touchend used to restart the hover timer and leave a ghost tooltip
3490
+ // stuck open (a finger never produces mouseleave).
3491
+ const hideTimerRef = React.useRef(null);
3492
+ const pressOriginRef = React.useRef(null);
3493
+
3494
+ const startHover = (e) => {
3495
+ if (e.pointerType !== "mouse") return;
3496
+ clearTimeout(timerRef.current);
3497
+ clearTimeout(hideTimerRef.current);
3498
+ timerRef.current = setTimeout(reveal, ICON_HOVER_TOOLTIP_MS);
3499
+ };
3500
+ const endHover = (e) => {
3501
+ if (e.pointerType !== "mouse") return;
3502
+ clearTimeout(timerRef.current);
3503
+ clearTimeout(hideTimerRef.current);
3504
+ setTip(null);
3505
+ };
3506
+ const startPress = (e) => {
3507
+ if (e.pointerType === "mouse") return;
3508
+ longPressRef.current = false;
3509
+ pressOriginRef.current = { x: e.clientX, y: e.clientY };
3510
+ clearTimeout(timerRef.current);
3511
+ clearTimeout(hideTimerRef.current);
3512
+ timerRef.current = setTimeout(() => { longPressRef.current = true; reveal(true); }, 450);
3513
+ };
3514
+ const movePress = (e) => {
3515
+ // Fingers jitter during a long-press; only real movement (a scroll
3516
+ // intent) cancels. A hide-on-any-move here is what made the bubble
3517
+ // vanish mid-press.
3518
+ if (e.pointerType === "mouse" || !pressOriginRef.current) return;
3519
+ const dx = e.clientX - pressOriginRef.current.x;
3520
+ const dy = e.clientY - pressOriginRef.current.y;
3521
+ if (dx * dx + dy * dy > 100) {
3522
+ pressOriginRef.current = null;
3523
+ clearTimeout(timerRef.current);
3524
+ setTip(null);
3525
+ }
3526
+ };
3527
+ const endPress = (e) => {
3528
+ if (e.pointerType === "mouse") return;
3529
+ pressOriginRef.current = null;
3530
+ clearTimeout(timerRef.current);
3531
+ if (longPressRef.current) {
3532
+ // Long-press: keep the bubble up while pressed, then linger 3s
3533
+ // after the finger lifts.
3534
+ clearTimeout(hideTimerRef.current);
3535
+ hideTimerRef.current = setTimeout(() => setTip(null), 3000);
3536
+ // The suppressed click usually fires right after pointerup and
3537
+ // consumes the flag; clear it shortly after in case it never
3538
+ // arrives, so the NEXT tap isn't swallowed.
3539
+ setTimeout(() => { longPressRef.current = false; }, 250);
3540
+ } else {
3541
+ setTip(null);
3542
+ }
3543
+ };
3544
+ React.useEffect(() => () => {
3545
+ clearTimeout(timerRef.current);
3546
+ clearTimeout(hideTimerRef.current);
3547
+ }, []);
3548
+
3549
+ const handleClick = (e) => {
3550
+ // Suppress the click that follows a long-press (tooltip reveal only).
3551
+ if (longPressRef.current) { longPressRef.current = false; e.preventDefault?.(); return; }
3552
+ if (!disabled) onClick?.(e);
3553
+ };
3554
+
3555
+ // iOS fires contextmenu on long-press; without this (plus the
3556
+ // touch-callout/user-select CSS on .ps-icon-button) the system
3557
+ // loupe/copy/zoom callout opens on top of our tooltip.
3558
+ const handleContextMenu = (e) => { e.preventDefault?.(); };
3559
+
3560
+ const tooltipNode = tip && typeof document !== "undefined" && document.body
3561
+ ? createPortal(
3562
+ React.createElement("span", {
3563
+ ref: tipRef,
3564
+ className: `ps-icon-tooltip is-${tip.placement}`,
3565
+ role: "tooltip",
3566
+ style: { left: `${tip.x}px`, top: `${tip.y}px` },
3567
+ }, label),
3568
+ document.body)
3569
+ : null;
3570
+
3571
+ return React.createElement("button", {
3572
+ ref: btnRef,
3573
+ type: "button",
3574
+ className: `${className} ps-icon-button${active ? " is-active" : ""}`,
3575
+ onClick: handleClick,
3576
+ disabled,
3577
+ "aria-label": label,
3578
+ onPointerEnter: startHover,
3579
+ onPointerLeave: endHover,
3580
+ onPointerDown: startPress,
3581
+ onPointerMove: movePress,
3582
+ onPointerUp: endPress,
3583
+ onPointerCancel: endPress,
3584
+ onContextMenu: handleContextMenu,
3585
+ },
3586
+ React.createElement("span", { className: "ps-icon-button-glyph", "aria-hidden": "true" }, icon),
3587
+ tooltipNode);
3588
+ }
3589
+
2939
3590
  function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus = null, chatFocusDisabled = false }) {
2940
3591
  const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
2941
3592
  const chatView = useControllerSelector(controller, (state) => ({
2942
3593
  mode: state.ui.chatViewMode || "transcript",
2943
3594
  activeSessionIsGroup: Boolean(state.sessions.activeSessionId && state.sessions.byId[state.sessions.activeSessionId]?.isGroup),
2944
- hasActiveSession: Boolean(state.sessions.activeSessionId && state.sessions.byId[state.sessions.activeSessionId]),
2945
3595
  }), shallowEqualObject);
2946
- const switchModelDisabled = !chatView.hasActiveSession || chatView.activeSessionIsGroup;
2947
3596
 
3597
+ // Icon-first toolbar: the glyph is the affordance, the label rides a
3598
+ // tooltip (desktop hover via title; mobile long-press via IconButton).
2948
3599
  const buttonDefs = [
2949
3600
  {
2950
3601
  key: "new",
2951
- label: "New",
3602
+ icon: "+",
3603
+ label: "New session",
2952
3604
  onClick: () => controller.handleCommand(UI_COMMANDS.NEW_SESSION).catch(() => {}),
2953
3605
  },
2954
3606
  {
2955
3607
  key: "model",
2956
- label: mobile ? "Model" : "New + Model",
3608
+ icon: "+⚙",
3609
+ label: "New session + choose model",
2957
3610
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_MODEL_PICKER).catch(() => {}),
2958
3611
  },
2959
- {
2960
- key: "switch-model",
2961
- label: mobile ? "Switch" : "Switch Model",
2962
- onClick: () => controller.openSwitchModelPicker().catch((err) => {
2963
- controller.dispatch({ type: "ui/status", text: err?.message || String(err) || "Failed to switch model" });
2964
- }),
2965
- disabled: switchModelDisabled,
2966
- title: switchModelDisabled ? "Select a session to switch its model" : "Switch the selected session model at the next turn boundary",
2967
- },
2968
3612
  {
2969
3613
  key: "filter",
2970
- label: "Filter",
3614
+ icon: "▾",
3615
+ label: "Filter sessions",
2971
3616
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_SESSION_FILTER).catch(() => {}),
2972
3617
  },
2973
3618
  {
2974
3619
  key: "theme",
3620
+ icon: "◑",
2975
3621
  label: "Theme",
2976
3622
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_THEME_PICKER).catch(() => {}),
2977
3623
  },
2978
3624
  {
2979
3625
  key: "summary",
2980
- label: chatView.mode === "summary" ? "Chat" : "Summary",
3626
+ icon: chatView.mode === "summary" ? "💬" : "≣",
3627
+ label: chatView.activeSessionIsGroup
3628
+ ? "Groups show group details"
3629
+ : (chatView.mode === "summary" ? "Show chat transcript" : "Show summary"),
2981
3630
  onClick: () => controller.setChatViewMode(chatView.mode === "summary" ? "transcript" : "summary"),
2982
3631
  disabled: chatView.activeSessionIsGroup,
2983
- title: chatView.activeSessionIsGroup ? "Groups show group details" : "Toggle Chat / Summary view",
2984
3632
  active: chatView.mode === "summary",
2985
3633
  },
2986
3634
  ...(onToggleChatFocus ? [{
2987
3635
  key: "focus",
2988
- label: mobile
2989
- ? (chatFocusMode ? "Exit Focus" : "Focus")
2990
- : (chatFocusMode ? "Exit Focus" : "Chat Focus"),
3636
+ icon: chatFocusMode ? "⇱" : "⛶",
3637
+ label: chatFocusMode ? "Exit focus mode" : "Focus the chat pane",
2991
3638
  onClick: onToggleChatFocus,
2992
3639
  disabled: chatFocusDisabled,
2993
3640
  active: chatFocusMode,
2994
3641
  }] : []),
2995
3642
  {
2996
3643
  key: "admin",
2997
- label: adminVisible ? "Close Admin" : "Admin",
3644
+ icon: "⚙",
3645
+ label: adminVisible ? "Close admin console" : "Admin console",
2998
3646
  onClick: () => controller.handleCommand(adminVisible ? UI_COMMANDS.CLOSE_ADMIN_CONSOLE : UI_COMMANDS.OPEN_ADMIN_CONSOLE).catch(() => {}),
2999
3647
  active: adminVisible,
3000
3648
  },
3001
3649
  ];
3002
3650
 
3003
- const renderButton = (def) => React.createElement("button", {
3651
+ const renderButton = (def) => React.createElement(IconButton, {
3004
3652
  key: def.key,
3005
- type: "button",
3006
- className: `ps-toolbar-button${def.active ? " is-active" : ""}`,
3653
+ icon: def.icon,
3654
+ label: def.label,
3007
3655
  onClick: def.onClick,
3008
3656
  disabled: Boolean(def.disabled),
3009
- title: def.title,
3010
- }, def.label);
3657
+ active: Boolean(def.active),
3658
+ });
3011
3659
 
3012
3660
  if (mobile) {
3013
- const firstRowButtons = buttonDefs.slice(0, 4);
3014
- const secondRowButtons = buttonDefs.slice(4);
3015
-
3661
+ // Single line: icon buttons are compact enough to fit one row; the
3662
+ // ps-toolbar-row-actions container scrolls horizontally (hidden
3663
+ // scrollbar) on the narrowest screens instead of wrapping.
3016
3664
  return React.createElement("div", { className: "ps-toolbar is-mobile" },
3017
3665
  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))),
3666
+ React.createElement("div", { className: "ps-toolbar-row-actions" }, buttonDefs.map(renderButton))),
3021
3667
  );
3022
3668
  }
3023
3669
 
@@ -4297,7 +4943,6 @@ export function PilotSwarmWebApp({ controller }) {
4297
4943
  const appliedProfileSettingsJsonRef = React.useRef(null);
4298
4944
  const defaultProfileSettingsRef = React.useRef(null);
4299
4945
  const [mobilePane, setMobilePane] = React.useState("workspace");
4300
- const [mobileSessionsCollapsed, setMobileSessionsCollapsed] = React.useState(false);
4301
4946
  const mobile = (viewport.width || window.innerWidth || 0) < MOBILE_BREAKPOINT;
4302
4947
  const readOnlyChatPane = state.activeSessionIsGroup || state.chatViewMode === "summary";
4303
4948
  const effectivePromptRows = readOnlyChatPane ? 0 : state.promptRows;
@@ -4547,11 +5192,7 @@ export function PilotSwarmWebApp({ controller }) {
4547
5192
  React.createElement(InspectorPane, { controller, mobile: true }));
4548
5193
  else if (mobilePane === "activity") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
4549
5194
  React.createElement(ActivityPane, { controller }));
4550
- else mobileContent = React.createElement(MobileWorkspace, {
4551
- controller,
4552
- sessionsCollapsed: mobileSessionsCollapsed,
4553
- setSessionsCollapsed: setMobileSessionsCollapsed,
4554
- });
5195
+ else mobileContent = React.createElement(MobileWorkspace, { controller });
4555
5196
 
4556
5197
  return React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
4557
5198
  // Hide the top toolbar on mobile inspector/activity panes (and in