pilotswarm 0.5.14 → 0.5.16

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 (34) hide show
  1. package/mcp/README.md +2 -2
  2. package/mcp/dist/src/server.d.ts.map +1 -1
  3. package/mcp/dist/src/server.js +5 -1
  4. package/mcp/dist/src/server.js.map +1 -1
  5. package/mcp/dist/src/tools/capabilities.d.ts +1 -0
  6. package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
  7. package/mcp/dist/src/tools/capabilities.js +5 -0
  8. package/mcp/dist/src/tools/capabilities.js.map +1 -1
  9. package/mcp/dist/src/tools/groups.d.ts +5 -4
  10. package/mcp/dist/src/tools/groups.d.ts.map +1 -1
  11. package/mcp/dist/src/tools/groups.js +43 -20
  12. package/mcp/dist/src/tools/groups.js.map +1 -1
  13. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  14. package/mcp/dist/src/tools/sessions.js +4 -2
  15. package/mcp/dist/src/tools/sessions.js.map +1 -1
  16. package/package.json +2 -2
  17. package/tui/plugins/.mcp.json +7 -1
  18. package/tui/plugins/agents/deepwiki.agent.md +44 -0
  19. package/tui/src/node-sdk-transport.js +61 -8
  20. package/ui/core/src/controller.js +264 -40
  21. package/ui/core/src/formatting.js +25 -0
  22. package/ui/core/src/reducer.js +117 -8
  23. package/ui/core/src/selectors.js +76 -3
  24. package/ui/core/src/state.js +3 -0
  25. package/ui/react/src/web-app.js +363 -127
  26. package/web/api/router.js +3 -1
  27. package/web/dist/assets/index-Df1mD9p0.js +24 -0
  28. package/web/dist/assets/index-whIft7M6.css +1 -0
  29. package/web/dist/assets/pilotswarm-DR5j-z8y.js +90 -0
  30. package/web/dist/index.html +3 -3
  31. package/web/runtime.js +114 -42
  32. package/web/dist/assets/index-BnxC8cNG.js +0 -24
  33. package/web/dist/assets/index-Bx6KHIaj.css +0 -1
  34. package/web/dist/assets/pilotswarm-NE7H63ha.js +0 -90
@@ -33,6 +33,7 @@ import {
33
33
  selectInspector,
34
34
  selectLogFilterModal,
35
35
  selectModelPickerModal,
36
+ selectNavigationError,
36
37
  selectReasoningEffortPickerModal,
37
38
  selectContextTierPickerModal,
38
39
  selectRenameSessionModal,
@@ -132,6 +133,39 @@ function supportsLocalFileOpen(controller) {
132
133
  return typeof controller?.transport?.openPathInDefaultApp === "function";
133
134
  }
134
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
+
135
169
  function clearBrowserPreferenceCache() {
136
170
  if (typeof window === "undefined") return;
137
171
  try {
@@ -153,7 +187,13 @@ function normalizeProfileSettings(settings) {
153
187
  normalized.themeId = candidate.themeId.trim();
154
188
  }
155
189
  if (hasOwn(candidate, "sessionOwnerFilter") && candidate.sessionOwnerFilter && typeof candidate.sessionOwnerFilter === "object") {
156
- 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 };
157
197
  }
158
198
  if (hasOwn(candidate, "layoutAdjustments")) {
159
199
  normalized.layoutAdjustments = normalizeStoredLayoutAdjustments(candidate.layoutAdjustments);
@@ -382,8 +422,9 @@ function normalizeLines(lines) {
382
422
  continue;
383
423
  }
384
424
  // Sentinel kinds preserved as-is so parseStructuredChatBlocks can
385
- // recognize and render them (e.g. markdownTable → HTML <table>).
386
- if (line?.kind === "markdownTable") {
425
+ // recognize and render them (e.g. markdownTable → HTML <table>,
426
+ // cardStart/cardEnd → styled card with structured body).
427
+ if (line?.kind === "markdownTable" || line?.kind === "cardStart" || line?.kind === "cardEnd") {
387
428
  normalized.push(line);
388
429
  continue;
389
430
  }
@@ -1375,6 +1416,30 @@ function parseStructuredChatBlocks(lines = []) {
1375
1416
  continue;
1376
1417
  }
1377
1418
 
1419
+ // Sentinel card bounds emitted by buildMessageCardLines in sentinel
1420
+ // mode. The body lines between the bounds are UNWRAPPED, so parse
1421
+ // them recursively — box-drawn/markdown tables inside the card
1422
+ // become real HTML tables instead of hard-wrapped box art.
1423
+ if (currentLine?.kind === "cardStart") {
1424
+ const innerLines = [];
1425
+ index += 1;
1426
+ while (index < lines.length && lines[index]?.kind !== "cardEnd") {
1427
+ innerLines.push(lines[index]);
1428
+ index += 1;
1429
+ }
1430
+ if (index < lines.length) index += 1;
1431
+ if (index < lines.length && lineText(lines[index]).trim().length === 0) {
1432
+ index += 1;
1433
+ }
1434
+ blocks.push({
1435
+ type: "card",
1436
+ headerRuns: Array.isArray(currentLine.runs) ? currentLine.runs : [],
1437
+ borderColor: currentLine.borderColor || "gray",
1438
+ blocks: parseStructuredChatBlocks(innerLines),
1439
+ });
1440
+ continue;
1441
+ }
1442
+
1378
1443
  // Sentinel markdown-table line emitted by parseMarkdownLines when
1379
1444
  // tableMode === "sentinel". Carries the raw header + rows so the
1380
1445
  // portal renders a real HTML table with markdown cell content (so
@@ -1523,9 +1588,15 @@ function parseStructuredChatBlocks(lines = []) {
1523
1588
 
1524
1589
  function StructuredChatBlocks({ lines, theme }) {
1525
1590
  const blocks = React.useMemo(() => parseStructuredChatBlocks(lines), [lines]);
1591
+ return React.createElement(StructuredBlockList, { blocks, theme });
1592
+ }
1526
1593
 
1594
+ // Renders parsed chat blocks; sentinel card blocks recurse through this list
1595
+ // so structured content (box/markdown tables, code fences) inside a card
1596
+ // renders exactly the same as it does at top level.
1597
+ function StructuredBlockList({ blocks, theme }) {
1527
1598
  return React.createElement(React.Fragment, null,
1528
- blocks.map((block, index) => {
1599
+ (blocks || []).map((block, index) => {
1529
1600
  if (block.type === "preserve") {
1530
1601
  const variantClass = block.splashVariant ? ` is-splash-${block.splashVariant}` : "";
1531
1602
  return React.createElement("div", { key: `preserve:${index}`, className: `ps-chat-preserve-block${variantClass}` },
@@ -1553,10 +1624,12 @@ function StructuredChatBlocks({ lines, theme }) {
1553
1624
  React.createElement("header", { className: "ps-chat-card-header" },
1554
1625
  React.createElement(Runs, { runs: block.headerRuns, theme })),
1555
1626
  React.createElement("div", { className: "ps-chat-card-body" },
1556
- (block.bodyLines || []).map((bodyRuns, bodyIndex) => React.createElement("div", {
1557
- key: `card:${index}:line:${bodyIndex}`,
1558
- className: "ps-chat-card-line",
1559
- }, React.createElement(Runs, { runs: bodyRuns, theme })) )));
1627
+ Array.isArray(block.blocks)
1628
+ ? React.createElement(StructuredBlockList, { blocks: block.blocks, theme })
1629
+ : (block.bodyLines || []).map((bodyRuns, bodyIndex) => React.createElement("div", {
1630
+ key: `card:${index}:line:${bodyIndex}`,
1631
+ className: "ps-chat-card-line",
1632
+ }, React.createElement(Runs, { runs: bodyRuns, theme })) )));
1560
1633
  }
1561
1634
 
1562
1635
  if (block.type === "table") {
@@ -1894,12 +1967,48 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
1894
1967
  const activeSession = viewState.activeSessionId
1895
1968
  ? viewState.sessionsById[viewState.activeSessionId] || null
1896
1969
  : null;
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);
1970
+ // "Manage session" combines rename, model, and sharing in one tabbed modal
1971
+ // (opened from the toolbar so the composer chrome stays minimal, esp. on
1972
+ // mobile). Rename and sharing are owner/admin-only (session:manage /
1973
+ // session:share), so the button is disabled for anyone else — the server
1974
+ // enforces too, but a disabled button is clearer than a 403.
1975
+ const [manageOpen, setManageOpen] = React.useState(false);
1976
+ const [linkModal, setLinkModal] = React.useState(null); // { url, warn } | null
1977
+ // The model picker is a separate (controller-owned) modal that would render
1978
+ // behind the Manage modal, so we close Manage while it is open and reopen
1979
+ // it when the picker closes — cancelling returns the user to Manage.
1980
+ const reopenManageRef = React.useRef(false);
1981
+ // The switch-model flow is multi-step (model → reasoning effort → context
1982
+ // tier); watch every step so Manage doesn't reopen between them. It reopens
1983
+ // only when the whole flow ends, and only if the switch was NOT applied
1984
+ // (cancel returns to Manage; confirm closes everything).
1985
+ const MODEL_FLOW_MODALS = ["modelPicker", "reasoningEffortPicker", "contextTierPicker"];
1986
+ const modelFlowOpen = useControllerSelector(controller, (state) => MODEL_FLOW_MODALS.includes(state.ui.modal?.type));
1987
+ React.useEffect(() => {
1988
+ if (!modelFlowOpen && reopenManageRef.current) {
1989
+ reopenManageRef.current = false;
1990
+ setManageOpen(true);
1991
+ }
1992
+ }, [modelFlowOpen]);
1993
+ const requestSwitchModel = () => {
1994
+ reopenManageRef.current = true;
1995
+ setManageOpen(false);
1996
+ controller.openSwitchModelPicker(() => {
1997
+ // Applied (not cancelled): close everything, don't reopen Manage.
1998
+ reopenManageRef.current = false;
1999
+ }).then(() => {
2000
+ // If the picker never actually opened (e.g. unsupported transport),
2001
+ // don't strand the Manage modal closed with no way back.
2002
+ if (!MODEL_FLOW_MODALS.includes(controller.getState().ui.modal?.type) && reopenManageRef.current) {
2003
+ reopenManageRef.current = false;
2004
+ setManageOpen(true);
2005
+ }
2006
+ }).catch((err) => {
2007
+ reopenManageRef.current = false;
2008
+ setManageOpen(true);
2009
+ controller.dispatch({ type: "ui/status", text: err?.message || String(err) || "Failed to switch model" });
2010
+ });
2011
+ };
1903
2012
  const authPrincipal = viewState.auth?.principal || null;
1904
2013
  const viewerRole = viewState.auth?.authorization?.role;
1905
2014
  const isAdminViewer = viewerRole === "admin" || viewerRole === "anonymous";
@@ -2009,23 +2118,27 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
2009
2118
  }),
2010
2119
  React.createElement(IconButton, {
2011
2120
  className: "ps-mini-button",
2012
- icon: React.createElement(ShareGlyph),
2121
+ icon: React.createElement(ManageGlyph),
2122
+ onClick: () => setManageOpen(true),
2123
+ disabled: !canModifyActiveSession || isBulkSelection,
2124
+ label: isBulkSelection ? "Disabled while multiple sessions are selected" : "Manage session — rename, switch model, and sharing",
2125
+ }),
2126
+ React.createElement(IconButton, {
2127
+ className: "ps-mini-button",
2128
+ icon: React.createElement(LinkGlyph),
2013
2129
  onClick: () => {
2014
2130
  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" });
2131
+ if (!sid) return;
2132
+ const url = buildSessionLinkUrl(sid);
2133
+ if (!url) return;
2134
+ copySessionLinkText(url);
2135
+ setLinkModal({ url, warn: false });
2136
+ resolveSessionLinkWarn(controller, sid).then((warn) => {
2137
+ setLinkModal((cur) => (cur && cur.url === url ? { ...cur, warn } : cur));
2138
+ }).catch(() => {});
2019
2139
  },
2020
2140
  disabled: !activeSession || activeSession.isGroup || isBulkSelection,
2021
- label: "Share — copy a direct link to this session",
2022
- }),
2023
- React.createElement(IconButton, {
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",
2141
+ label: "Copy link — copy a direct link to this session",
2029
2142
  }),
2030
2143
  React.createElement(IconButton, {
2031
2144
  className: "ps-mini-button",
@@ -2113,42 +2226,96 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
2113
2226
  },
2114
2227
  React.createElement("div", {
2115
2228
  className: "ps-line ps-session-row-content",
2116
- style: { paddingInlineStart: `${Math.max(0, row.depth) * 18}px` },
2229
+ style: { paddingInlineStart: `${Math.max(0, row.depth) * 4}px` },
2117
2230
  },
2118
2231
  React.createElement(SessionRowContent, { row, theme, structured: structuredRows })),
2119
2232
  )),
2120
2233
  )),
2121
- (modifyOpen && activeSession && !activeSession.isGroup)
2234
+ (manageOpen && activeSession && !activeSession.isGroup)
2122
2235
  ? React.createElement(SessionModifyModal, {
2123
2236
  controller,
2124
2237
  sessionId: activeSession.sessionId,
2125
2238
  initialTitle: activeSession.title || "",
2239
+ currentModel: activeSession.model || "",
2240
+ currentReasoningEffort: activeSession.reasoningEffort || "",
2126
2241
  principal: viewState.auth?.principal || null,
2127
- onClose: () => setModifyOpen(false),
2242
+ onClose: () => setManageOpen(false),
2243
+ onSwitchModel: requestSwitchModel,
2128
2244
  onChanged: () => {},
2129
2245
  })
2246
+ : null,
2247
+ linkModal
2248
+ ? React.createElement(SessionLinkModal, {
2249
+ url: linkModal.url,
2250
+ warn: linkModal.warn,
2251
+ onCopyAgain: () => {
2252
+ copySessionLinkText(linkModal.url);
2253
+ controller.dispatch({ type: "ui/status", text: SESSION_LINK_COPIED_STATUS });
2254
+ },
2255
+ onClose: () => setLinkModal(null),
2256
+ })
2130
2257
  : null);
2131
2258
  }
2132
2259
 
2260
+ // Compact confirmation dialog for the Copy link button: shows the copied URL
2261
+ // (selectable), confirms the copy, and warns when the link points at a private
2262
+ // session no one else can open yet.
2263
+ function SessionLinkModal({ url, warn, onCopyAgain, onClose }) {
2264
+ const inputRef = React.useRef(null);
2265
+ React.useEffect(() => {
2266
+ const node = inputRef.current;
2267
+ if (node) { node.focus(); node.select(); }
2268
+ }, []);
2269
+ const stop = (e) => e.stopPropagation();
2270
+ return React.createElement("div", { className: "ps-share-overlay", onClick: onClose },
2271
+ React.createElement("div", { className: "ps-link-modal", onClick: stop },
2272
+ React.createElement("div", { className: "ps-share-modal-head" },
2273
+ React.createElement("span", null, "Link copied"),
2274
+ React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
2275
+ React.createElement("div", { className: "ps-share-section-sub" },
2276
+ "Copied to your clipboard — anyone with access can open the session from this link."),
2277
+ React.createElement("div", { className: "ps-link-row" },
2278
+ React.createElement("input", {
2279
+ ref: inputRef, className: "ps-link-input", readOnly: true, value: url,
2280
+ onFocus: () => inputRef.current?.select(),
2281
+ }),
2282
+ React.createElement("button", { className: "ps-mini-button", onClick: onCopyAgain }, "Copy")),
2283
+ warn
2284
+ ? React.createElement("div", { className: "ps-link-warn" }, SESSION_LINK_PRIVATE_WARNING)
2285
+ : null));
2286
+ }
2287
+
2133
2288
  const VISIBILITY_META = {
2134
2289
  private: { glyph: "🔒", label: "Private" },
2135
2290
  shared_read: { glyph: "👁", label: "Shared · read" },
2136
2291
  shared_write: { glyph: "✎", label: "Shared · write" },
2137
2292
  };
2138
2293
 
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() {
2294
+ // The "manage" glyph (sliders) for the Manage session button — reads as
2295
+ // settings/controls rather than the narrower share-nodes glyph.
2296
+ function ManageGlyph() {
2142
2297
  return React.createElement("svg", {
2143
2298
  className: "ps-share-glyph", viewBox: "0 0 24 24", fill: "none",
2144
2299
  stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round",
2145
2300
  "aria-hidden": "true",
2146
2301
  },
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" }));
2302
+ React.createElement("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
2303
+ React.createElement("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
2304
+ React.createElement("line", { x1: "4", y1: "18", x2: "20", y2: "18" }),
2305
+ React.createElement("circle", { cx: "9", cy: "6", r: "2", fill: "currentColor" }),
2306
+ React.createElement("circle", { cx: "15", cy: "12", r: "2", fill: "currentColor" }),
2307
+ React.createElement("circle", { cx: "8", cy: "18", r: "2", fill: "currentColor" }));
2308
+ }
2309
+
2310
+ // The standard "link" glyph (two chain segments). Used for the Copy link button.
2311
+ function LinkGlyph() {
2312
+ return React.createElement("svg", {
2313
+ className: "ps-share-glyph", viewBox: "0 0 24 24", fill: "none",
2314
+ stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round",
2315
+ "aria-hidden": "true",
2316
+ },
2317
+ 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" }),
2318
+ 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" }));
2152
2319
  }
2153
2320
 
2154
2321
  /**
@@ -2177,13 +2344,16 @@ function useActiveSessionAccess(controller, activeSessionId, isGroup) {
2177
2344
  return { access: state.access, loading: state.loading, reload };
2178
2345
  }
2179
2346
 
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 }) {
2347
+ // Combined "Share & settings" modal opened from the session list toolbar:
2348
+ // rename, copy link, plus (for the owner/admin) sharing — visibility +
2349
+ // per-person grants. Fetches its own access snapshot so callers only pass the
2350
+ // session id + current title.
2351
+ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel, currentReasoningEffort, principal, onClose, onSwitchModel, onChanged }) {
2352
+ const [tab, setTab] = React.useState("general");
2184
2353
  const [access, setAccess] = React.useState(null);
2185
2354
  const [title, setTitle] = React.useState(initialTitle || "");
2186
- const [shares, setShares] = React.useState([]);
2355
+ const [shares, setShares] = React.useState([]); // committed baseline
2356
+ const [draftShares, setDraftShares] = React.useState([]); // staged, applied on Apply
2187
2357
  const [visibility, setVisibility] = React.useState("private");
2188
2358
  const [granteeQuery, setGranteeQuery] = React.useState("");
2189
2359
  const [granteeAccess, setGranteeAccess] = React.useState("write");
@@ -2207,8 +2377,8 @@ function SessionModifyModal({ controller, sessionId, initialTitle, principal, on
2207
2377
 
2208
2378
  const loadShares = React.useCallback(() => {
2209
2379
  controller.transport.listSessionShares(sessionId)
2210
- .then((rows) => setShares(Array.isArray(rows) ? rows : []))
2211
- .catch(() => setShares([]));
2380
+ .then((rows) => { const list = Array.isArray(rows) ? rows : []; setShares(list); setDraftShares(list); })
2381
+ .catch(() => { setShares([]); setDraftShares([]); });
2212
2382
  }, [controller, sessionId]);
2213
2383
  React.useEffect(() => { loadShares(); }, [loadShares]);
2214
2384
 
@@ -2222,10 +2392,6 @@ function SessionModifyModal({ controller, sessionId, initialTitle, principal, on
2222
2392
  const saveTitle = () => run(async () => {
2223
2393
  await controller.transport.renameSession(sessionId, title.trim());
2224
2394
  });
2225
- const applyVisibility = (value) => run(async () => {
2226
- await controller.transport.setSessionVisibility(sessionId, value);
2227
- setVisibility(value);
2228
- });
2229
2395
  // Resolve the typed text to a directory member (by name, email, or id).
2230
2396
  // Falls back to treating the text as a raw subject for a not-yet-seen user.
2231
2397
  const resolveGrantee = (text) => {
@@ -2238,67 +2404,136 @@ function SessionModifyModal({ controller, sessionId, initialTitle, principal, on
2238
2404
  if (match) return match;
2239
2405
  return { provider: principal?.provider || "dev", subject: text.trim(), email: null, displayName: null };
2240
2406
  };
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
- );
2407
+ const shareKey = (r) => `${r.provider}${r.subject}`;
2408
+ // Access edits are staged into draftShares/visibility and committed only
2409
+ // on Apply, so the button is a deliberate, satisfying confirmation.
2410
+ const stageGrant = (grantee) => {
2411
+ const key = shareKey(grantee);
2412
+ setDraftShares((cur) => [
2413
+ ...cur.filter((r) => shareKey(r) !== key),
2414
+ { provider: grantee.provider, subject: grantee.subject, email: grantee.email ?? null, displayName: grantee.displayName ?? null, access: granteeAccess },
2415
+ ]);
2247
2416
  setGranteeQuery("");
2248
- loadShares();
2249
- });
2417
+ };
2250
2418
  const addGrant = () => {
2251
2419
  const grantee = resolveGrantee(granteeQuery);
2252
- if (grantee) grantTo(grantee);
2420
+ if (grantee) stageGrant(grantee);
2421
+ };
2422
+ const stageRevoke = (row) => {
2423
+ const key = shareKey(row);
2424
+ setDraftShares((cur) => cur.filter((r) => shareKey(r) !== key));
2253
2425
  };
2254
2426
 
2255
2427
  // Autocomplete suggestions: directory members matching the query, minus
2256
2428
  // the owner and anyone already granted.
2257
- const grantedKeys = new Set(shares.map((r) => `${r.provider}${r.subject}`));
2429
+ const grantedKeys = new Set(draftShares.map(shareKey));
2258
2430
  const ownerKey = access?.owner ? `${access.owner.provider}${access.owner.subject}` : null;
2259
2431
  const q = granteeQuery.trim().toLowerCase();
2260
2432
  const suggestions = q
2261
2433
  ? directory.filter((u) => {
2262
- const key = `${u.provider}${u.subject}`;
2434
+ const key = shareKey(u);
2263
2435
  if (key === ownerKey || grantedKeys.has(key)) return false;
2264
2436
  return (u.displayName && u.displayName.toLowerCase().includes(q))
2265
2437
  || (u.email && u.email.toLowerCase().includes(q))
2266
2438
  || (u.subject && u.subject.toLowerCase().includes(q));
2267
2439
  }).slice(0, 25)
2268
2440
  : [];
2269
- const revoke = (row) => run(async () => {
2270
- await controller.transport.revokeSessionShare(sessionId, { provider: row.provider, subject: row.subject });
2271
- loadShares();
2272
- });
2273
-
2274
2441
  const canManage = Boolean(access?.canManage);
2442
+ // Dirty = staged access differs from the committed baseline (the `:level`
2443
+ // suffix catches a grant whose access level changed).
2444
+ const baselineVisibility = access?.visibility || "private";
2445
+ const draftSig = new Set(draftShares.map((r) => `${shareKey(r)}:${r.access}`));
2446
+ const baseSig = new Set(shares.map((r) => `${shareKey(r)}:${r.access}`));
2447
+ const sharesChanged = draftSig.size !== baseSig.size || [...draftSig].some((k) => !baseSig.has(k));
2448
+ const accessDirty = canManage && (visibility !== baselineVisibility || sharesChanged);
2449
+ const applyAccess = () => run(async () => {
2450
+ if (visibility !== baselineVisibility) {
2451
+ await controller.transport.setSessionVisibility(sessionId, visibility);
2452
+ }
2453
+ const baseByKey = new Map(shares.map((r) => [shareKey(r), r]));
2454
+ const draftByKey = new Map(draftShares.map((r) => [shareKey(r), r]));
2455
+ for (const [key, r] of draftByKey) {
2456
+ const b = baseByKey.get(key);
2457
+ if (!b || b.access !== r.access) {
2458
+ await controller.transport.grantSessionShare(
2459
+ sessionId,
2460
+ { provider: r.provider, subject: r.subject, email: r.email ?? null, displayName: r.displayName ?? null },
2461
+ r.access,
2462
+ );
2463
+ }
2464
+ }
2465
+ for (const [key, r] of baseByKey) {
2466
+ if (!draftByKey.has(key)) {
2467
+ await controller.transport.revokeSessionShare(sessionId, { provider: r.provider, subject: r.subject });
2468
+ }
2469
+ }
2470
+ const rows = await controller.transport.listSessionShares(sessionId).catch(() => null);
2471
+ if (Array.isArray(rows)) { setShares(rows); setDraftShares(rows); }
2472
+ const a = await controller.transport.getSessionAccess(sessionId).catch(() => null);
2473
+ if (a) { setAccess(a); setVisibility(a.visibility || "private"); }
2474
+ controller.dispatch({ type: "ui/status", text: "Access updated." });
2475
+ });
2476
+ const switchModel = onSwitchModel || (() => {
2477
+ onClose();
2478
+ controller.openSwitchModelPicker().catch((err) => {
2479
+ controller.dispatch({ type: "ui/status", text: err?.message || String(err) || "Failed to switch model" });
2480
+ });
2481
+ });
2482
+ const modelLabel = currentModel
2483
+ ? (currentReasoningEffort ? `${currentModel}:${currentReasoningEffort}` : currentModel)
2484
+ : "—";
2485
+ // Access is owner/admin-only; hide the tab entirely for viewers who can
2486
+ // only rename/switch model on their own session.
2487
+ const tabs = [
2488
+ { id: "general", label: "General" },
2489
+ ...(canManage ? [{ id: "access", label: "Access" }] : []),
2490
+ ];
2491
+ const activeTab = tabs.some((t) => t.id === tab) ? tab : "general";
2275
2492
  const stop = (e) => e.stopPropagation();
2276
2493
  return React.createElement("div", { className: "ps-share-overlay", onClick: onClose },
2277
2494
  React.createElement("div", { className: "ps-share-modal", onClick: stop },
2278
2495
  React.createElement("div", { className: "ps-share-modal-head" },
2279
- React.createElement("span", null, "Modify session"),
2496
+ React.createElement("span", null, "Manage session"),
2280
2497
  React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
2281
2498
 
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")),
2499
+ // ── Tab bar ───────────────────────────────────────────────
2500
+ React.createElement("div", { className: "ps-manage-tabs", role: "tablist" },
2501
+ tabs.map((t) => React.createElement("button", {
2502
+ key: t.id,
2503
+ type: "button",
2504
+ role: "tab",
2505
+ "aria-selected": activeTab === t.id ? "true" : "false",
2506
+ className: `ps-manage-tab${activeTab === t.id ? " is-active" : ""}`,
2507
+ onClick: () => setTab(t.id),
2508
+ }, t.label))),
2509
+
2510
+ // ── General: rename + model ───────────────────────────────
2511
+ activeTab === "general" ? React.createElement(React.Fragment, null,
2512
+ React.createElement("div", { className: "ps-share-section-label" }, "Name"),
2513
+ React.createElement("div", { className: "ps-share-add-row" },
2514
+ React.createElement("input", {
2515
+ className: "ps-share-add-input", placeholder: "Session title",
2516
+ value: title, disabled: busy,
2517
+ onChange: (e) => setTitle(e.target.value),
2518
+ onKeyDown: (e) => { if (e.key === "Enter") saveTitle(); },
2519
+ }),
2520
+ React.createElement("button", { className: "ps-mini-button", disabled: busy || !title.trim(), onClick: saveTitle }, "Save")),
2521
+ React.createElement("div", { className: "ps-share-section-label" }, "Model"),
2522
+ React.createElement("div", { className: "ps-share-section-sub" }, "The model this session uses on its next turn."),
2523
+ React.createElement("div", { className: "ps-share-add-row" },
2524
+ React.createElement("span", { className: "ps-manage-model-current" }, modelLabel),
2525
+ React.createElement("button", { className: "ps-mini-button", disabled: busy, onClick: switchModel }, "Switch model…")))
2526
+ : null,
2292
2527
 
2293
- // ── Sharing (owner / admin only) ──────────────────────────
2294
- canManage ? React.createElement(React.Fragment, null,
2528
+ // ── Access (owner / admin only) ───────────────────────────
2529
+ (activeTab === "access" && canManage) ? React.createElement(React.Fragment, null,
2295
2530
  React.createElement("div", { className: "ps-share-section-label" }, "General access"),
2296
2531
  React.createElement("div", { className: "ps-share-section-sub" }, "The baseline level for everyone signed in to this workspace."),
2297
2532
  ["private", "shared_read", "shared_write"].map((value) =>
2298
2533
  React.createElement("label", { key: value, className: `ps-share-radio${visibility === value ? " is-active" : ""}` },
2299
2534
  React.createElement("input", {
2300
2535
  type: "radio", name: "visibility", checked: visibility === value,
2301
- disabled: busy, onChange: () => applyVisibility(value),
2536
+ disabled: busy, onChange: () => setVisibility(value),
2302
2537
  }),
2303
2538
  React.createElement("span", { className: "ps-share-radio-glyph" }, VISIBILITY_META[value].glyph),
2304
2539
  React.createElement("span", null, VISIBILITY_META[value].label),
@@ -2308,12 +2543,12 @@ function SessionModifyModal({ controller, sessionId, initialTitle, principal, on
2308
2543
  : "everyone here can view and send"))),
2309
2544
  React.createElement("div", { className: "ps-share-section-label" }, "Special access"),
2310
2545
  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
2546
+ draftShares.length === 0
2312
2547
  ? 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" },
2548
+ : draftShares.map((row) => React.createElement("div", { key: `${row.provider}/${row.subject}`, className: "ps-share-grant-row" },
2314
2549
  React.createElement("span", { className: "ps-share-grant-name" }, row.displayName || row.subject),
2315
2550
  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"))),
2551
+ React.createElement("button", { className: "ps-mini-button", disabled: busy, onClick: () => stageRevoke(row) }, "Remove"))),
2317
2552
  React.createElement("div", { className: "ps-share-add-wrap" },
2318
2553
  React.createElement("div", { className: "ps-share-add-row" },
2319
2554
  React.createElement("input", {
@@ -2334,7 +2569,7 @@ function SessionModifyModal({ controller, sessionId, initialTitle, principal, on
2334
2569
  suggestions.map((u) => React.createElement("button", {
2335
2570
  key: `${u.provider}/${u.subject}`,
2336
2571
  type: "button", className: "ps-share-suggestion", disabled: busy,
2337
- onClick: () => grantTo(u),
2572
+ onClick: () => stageGrant(u),
2338
2573
  },
2339
2574
  React.createElement("span", { className: "ps-share-suggestion-name" }, u.displayName || u.subject),
2340
2575
  u.email ? React.createElement("span", { className: "ps-share-suggestion-email" }, u.email) : null)))
@@ -2342,7 +2577,15 @@ function SessionModifyModal({ controller, sessionId, initialTitle, principal, on
2342
2577
  React.createElement("div", { className: "ps-share-foot-hint" },
2343
2578
  "Sharing applies to this session and its sub-agents. Suggestions are people who have "
2344
2579
  + "signed in before — you can also grant by email to someone who hasn't; it takes effect "
2345
- + "when they first sign in."))
2580
+ + "when they first sign in."),
2581
+ React.createElement("div", { className: "ps-manage-apply-bar" },
2582
+ React.createElement("span", { className: "ps-share-section-sub" },
2583
+ accessDirty ? "Unsaved access changes." : "No changes to apply."),
2584
+ React.createElement("button", {
2585
+ className: "ps-mini-button ps-manage-apply",
2586
+ disabled: busy || !accessDirty,
2587
+ onClick: applyAccess,
2588
+ }, "Apply")))
2346
2589
  : null,
2347
2590
  error ? React.createElement("div", { className: "ps-share-error" }, error) : null));
2348
2591
  }
@@ -2451,6 +2694,7 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
2451
2694
  const { access } = useActiveSessionAccess(
2452
2695
  controller, viewState.activeSessionId, viewState.activeSessionIsGroup,
2453
2696
  );
2697
+ const navigationError = useControllerSelector(controller, selectNavigationError, shallowEqualObject);
2454
2698
  const composerBase = showComposer && !viewState.activeSessionIsGroup && viewState.chatViewMode !== "summary";
2455
2699
  const readOnly = Boolean(access) && access.canWrite === false;
2456
2700
  const composer = composerBase
@@ -2461,6 +2705,31 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
2461
2705
  : React.createElement(PromptComposer, { controller, mobile, active: true }))
2462
2706
  : null;
2463
2707
 
2708
+ // A failed deep-link intent with nothing else loaded replaces the pane
2709
+ // body with the nav-error empty state (the reducer refuses fallback
2710
+ // selection while the intent exists, so there is no transcript to show).
2711
+ const hasLoadableActiveSession = Boolean(
2712
+ viewState.activeSessionId && viewState.sessionsById[viewState.activeSessionId],
2713
+ );
2714
+ if (navigationError && !hasLoadableActiveSession) {
2715
+ return React.createElement(Panel, {
2716
+ title: chrome.title,
2717
+ color: chrome.color,
2718
+ focused: viewState.focused,
2719
+ theme,
2720
+ className: "ps-chat-panel",
2721
+ },
2722
+ React.createElement("div", { className: "ps-empty-state ps-nav-error-state" },
2723
+ React.createElement("div", { className: "ps-nav-error-message" }, navigationError.message),
2724
+ navigationError.retryable
2725
+ ? React.createElement("button", {
2726
+ type: "button",
2727
+ className: "ps-mini-button",
2728
+ onClick: () => controller.setNavigationIntent(navigationError.sessionId),
2729
+ }, "Retry")
2730
+ : null));
2731
+ }
2732
+
2464
2733
  return React.createElement(ScrollLinesPanel, {
2465
2734
  controller,
2466
2735
  title: mobile ? compactTitleRuns(chrome.title, 28) : chrome.title,
@@ -2482,31 +2751,14 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
2482
2751
  });
2483
2752
  }
2484
2753
 
2485
- function MobileWorkspace({ controller, sessionsCollapsed, setSessionsCollapsed }) {
2486
- const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
2487
- const theme = getTheme(themeId);
2488
- const sessionToggle = React.createElement("button", {
2489
- type: "button",
2490
- className: "ps-mini-button",
2491
- onClick: () => setSessionsCollapsed((current) => !current),
2492
- }, sessionsCollapsed ? "Show" : "Hide");
2493
-
2754
+ function MobileWorkspace({ controller }) {
2755
+ // The session list is always shown; use the toolbar Focus button to give
2756
+ // the chat the full screen (the old Show/Hide toggle was redundant with it).
2494
2757
  return React.createElement("div", { className: "ps-mobile-workspace" },
2495
- sessionsCollapsed
2496
- ? React.createElement(Panel, {
2497
- title: [{ text: "Sessions", color: "yellow", bold: true }],
2498
- color: "yellow",
2499
- focused: false,
2500
- theme,
2501
- actions: sessionToggle,
2502
- className: "ps-mobile-session-collapsed",
2503
- },
2504
- React.createElement("div", { className: "ps-mobile-session-summary" }, "Session list collapsed."))
2505
- : React.createElement(SessionPane, {
2506
- controller,
2507
- actions: sessionToggle,
2508
- panelClassName: "ps-mobile-session-pane",
2509
- }),
2758
+ React.createElement(SessionPane, {
2759
+ controller,
2760
+ panelClassName: "ps-mobile-session-pane",
2761
+ }),
2510
2762
  React.createElement("div", { className: "ps-mobile-chat-pane" },
2511
2763
  React.createElement(ChatPane, { controller, mobile: true, fullWidth: true })));
2512
2764
  }
@@ -3373,9 +3625,7 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
3373
3625
  const chatView = useControllerSelector(controller, (state) => ({
3374
3626
  mode: state.ui.chatViewMode || "transcript",
3375
3627
  activeSessionIsGroup: Boolean(state.sessions.activeSessionId && state.sessions.byId[state.sessions.activeSessionId]?.isGroup),
3376
- hasActiveSession: Boolean(state.sessions.activeSessionId && state.sessions.byId[state.sessions.activeSessionId]),
3377
3628
  }), shallowEqualObject);
3378
- const switchModelDisabled = !chatView.hasActiveSession || chatView.activeSessionIsGroup;
3379
3629
 
3380
3630
  // Icon-first toolbar: the glyph is the affordance, the label rides a
3381
3631
  // tooltip (desktop hover via title; mobile long-press via IconButton).
@@ -3392,15 +3642,6 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
3392
3642
  label: "New session + choose model",
3393
3643
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_MODEL_PICKER).catch(() => {}),
3394
3644
  },
3395
- {
3396
- key: "switch-model",
3397
- icon: "⇄",
3398
- label: switchModelDisabled ? "Select a session to switch its model" : "Switch the selected session's model",
3399
- onClick: () => controller.openSwitchModelPicker().catch((err) => {
3400
- controller.dispatch({ type: "ui/status", text: err?.message || String(err) || "Failed to switch model" });
3401
- }),
3402
- disabled: switchModelDisabled,
3403
- },
3404
3645
  {
3405
3646
  key: "filter",
3406
3647
  icon: "▾",
@@ -3911,7 +4152,7 @@ function ModalLayer({ controller }) {
3911
4152
  return React.createElement("button", {
3912
4153
  key: item?.id || `row:${rowIndex}`,
3913
4154
  type: "button",
3914
- className: `ps-list-button ps-modal-list-button${itemIndex === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}`,
4155
+ className: `ps-list-button ps-modal-list-button${itemIndex === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}${item?.disabled ? " is-disabled" : ""}`,
3915
4156
  onClick: () => controller.dispatch({ type: "ui/modalSelection", index: itemIndex }),
3916
4157
  },
3917
4158
  React.createElement("div", { className: "ps-line ps-modal-list-line" },
@@ -3920,7 +4161,7 @@ function ModalLayer({ controller }) {
3920
4161
  : (modal.items || []).map((item, index) => React.createElement("button", {
3921
4162
  key: item.id || index,
3922
4163
  type: "button",
3923
- className: `ps-list-button ps-modal-list-button${index === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}`,
4164
+ className: `ps-list-button ps-modal-list-button${index === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}${item?.disabled ? " is-disabled" : ""}`,
3924
4165
  onClick: () => controller.dispatch({ type: "ui/modalSelection", index }),
3925
4166
  },
3926
4167
  React.createElement("div", { className: "ps-line ps-modal-list-line" },
@@ -4735,7 +4976,6 @@ export function PilotSwarmWebApp({ controller }) {
4735
4976
  const appliedProfileSettingsJsonRef = React.useRef(null);
4736
4977
  const defaultProfileSettingsRef = React.useRef(null);
4737
4978
  const [mobilePane, setMobilePane] = React.useState("workspace");
4738
- const [mobileSessionsCollapsed, setMobileSessionsCollapsed] = React.useState(false);
4739
4979
  const mobile = (viewport.width || window.innerWidth || 0) < MOBILE_BREAKPOINT;
4740
4980
  const readOnlyChatPane = state.activeSessionIsGroup || state.chatViewMode === "summary";
4741
4981
  const effectivePromptRows = readOnlyChatPane ? 0 : state.promptRows;
@@ -4985,11 +5225,7 @@ export function PilotSwarmWebApp({ controller }) {
4985
5225
  React.createElement(InspectorPane, { controller, mobile: true }));
4986
5226
  else if (mobilePane === "activity") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
4987
5227
  React.createElement(ActivityPane, { controller }));
4988
- else mobileContent = React.createElement(MobileWorkspace, {
4989
- controller,
4990
- sessionsCollapsed: mobileSessionsCollapsed,
4991
- setSessionsCollapsed: setMobileSessionsCollapsed,
4992
- });
5228
+ else mobileContent = React.createElement(MobileWorkspace, { controller });
4993
5229
 
4994
5230
  return React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
4995
5231
  // Hide the top toolbar on mobile inspector/activity panes (and in