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.
- package/README.md +6 -0
- package/mcp/README.md +14 -2
- package/mcp/dist/src/context.d.ts +13 -0
- package/mcp/dist/src/context.d.ts.map +1 -1
- package/mcp/dist/src/context.js +20 -0
- package/mcp/dist/src/context.js.map +1 -1
- package/mcp/dist/src/server.d.ts.map +1 -1
- package/mcp/dist/src/server.js +5 -1
- package/mcp/dist/src/server.js.map +1 -1
- package/mcp/dist/src/tools/capabilities.d.ts +4 -0
- package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
- package/mcp/dist/src/tools/capabilities.js +13 -0
- package/mcp/dist/src/tools/capabilities.js.map +1 -1
- package/mcp/dist/src/tools/groups.d.ts +5 -4
- package/mcp/dist/src/tools/groups.d.ts.map +1 -1
- package/mcp/dist/src/tools/groups.js +43 -20
- package/mcp/dist/src/tools/groups.js.map +1 -1
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +118 -2
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/package.json +3 -2
- package/tui/src/app.js +19 -2
- package/tui/src/auth/cli.js +13 -0
- package/tui/src/node-sdk-transport.js +99 -13
- package/tui/tui-splash-mobile.txt +5 -7
- package/tui/tui-splash.txt +13 -9
- package/ui/core/src/commands.js +2 -0
- package/ui/core/src/controller.js +454 -35
- package/ui/core/src/history.js +19 -1
- package/ui/core/src/reducer.js +121 -8
- package/ui/core/src/selectors.js +204 -14
- package/ui/core/src/state.js +3 -0
- package/ui/core/src/themes/helpers.js +4 -0
- package/ui/react/src/components.js +95 -6
- package/ui/react/src/web-app.js +798 -157
- package/web/api/router.js +7 -6
- package/web/api/ws.js +9 -0
- package/web/auth/index.js +5 -0
- package/web/auth/providers/dev.js +119 -0
- package/web/authz.js +142 -0
- package/web/dist/assets/index-CZizkB5Z.js +24 -0
- package/web/dist/assets/index-D9e2TGjO.css +1 -0
- package/web/dist/assets/pilotswarm-KMqn3ZJs.js +90 -0
- package/web/dist/assets/react-l0sNRNKZ.js +1 -0
- package/web/dist/index.html +3 -4
- package/web/runtime.js +553 -37
- package/web/server.js +2 -2
- package/web/dist/assets/index-bQ2QInMX.js +0 -24
- package/web/dist/assets/index-oldX95Tp.css +0 -1
- package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
- package/web/dist/assets/react-C9iQPS2h.js +0 -1
package/ui/react/src/web-app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1956
|
-
|
|
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
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
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
|
-
}
|
|
1968
|
-
React.createElement(
|
|
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
|
-
|
|
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
|
-
}
|
|
1977
|
-
React.createElement(
|
|
1978
|
-
type: "button",
|
|
2085
|
+
}),
|
|
2086
|
+
React.createElement(IconButton, {
|
|
1979
2087
|
className: "ps-mini-button",
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
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
|
-
|
|
2115
|
+
label: isBulkSelection
|
|
1990
2116
|
? `Terminate ${selectedCount} selected sessions (Mark Completed, Cancel, or Delete)`
|
|
1991
2117
|
: activeSession?.isGroup
|
|
1992
|
-
? activeGroupCanDelete ? "Delete this empty group" : "
|
|
2118
|
+
? (activeGroupCanDelete ? "Delete this empty group" : "This group cannot be deleted yet")
|
|
1993
2119
|
: activeSession?.isSystem
|
|
1994
|
-
? "Restart this system session
|
|
1995
|
-
:
|
|
1996
|
-
}
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2195
|
-
|
|
2196
|
-
|
|
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
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
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
|
-
//
|
|
2226
|
-
//
|
|
2227
|
-
//
|
|
2228
|
-
//
|
|
2229
|
-
|
|
2230
|
-
|
|
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
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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
|
-
}
|
|
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(
|
|
2342
|
-
|
|
2343
|
-
|
|
2843
|
+
React.createElement(IconButton, {
|
|
2844
|
+
icon: "↥",
|
|
2845
|
+
label: "Upload",
|
|
2344
2846
|
onClick: openUploadPicker,
|
|
2345
2847
|
disabled: !viewState.canBrowserUpload && !viewState.canPathUpload,
|
|
2346
|
-
}
|
|
2347
|
-
React.createElement(
|
|
2348
|
-
|
|
2349
|
-
|
|
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
|
-
}
|
|
2353
|
-
viewState.canDeleteArtifacts ? React.createElement(
|
|
2354
|
-
|
|
2355
|
-
|
|
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
|
-
}
|
|
2359
|
-
viewState.canOpenLocally ? React.createElement(
|
|
2360
|
-
|
|
2361
|
-
|
|
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
|
-
}
|
|
2365
|
-
React.createElement(
|
|
2366
|
-
|
|
2367
|
-
|
|
2866
|
+
}) : null,
|
|
2867
|
+
React.createElement(IconButton, {
|
|
2868
|
+
icon: "▾",
|
|
2869
|
+
label: "Filter",
|
|
2368
2870
|
onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_FILES_FILTER).catch(() => {}),
|
|
2369
|
-
}
|
|
2370
|
-
React.createElement(
|
|
2371
|
-
|
|
2372
|
-
|
|
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
|
-
}
|
|
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(
|
|
3060
|
+
actions.push(React.createElement(IconButton, {
|
|
2559
3061
|
key: "tail",
|
|
2560
|
-
|
|
2561
|
-
|
|
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
|
-
}
|
|
2564
|
-
actions.push(React.createElement(
|
|
3066
|
+
}));
|
|
3067
|
+
actions.push(React.createElement(IconButton, {
|
|
2565
3068
|
key: "filter",
|
|
2566
|
-
|
|
2567
|
-
|
|
3069
|
+
icon: "▾",
|
|
3070
|
+
label: "Filter",
|
|
2568
3071
|
onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_LOG_FILTER).catch(() => {}),
|
|
2569
|
-
}
|
|
3072
|
+
}));
|
|
2570
3073
|
} else if (viewState.inspectorTab === "history") {
|
|
2571
|
-
actions.push(React.createElement(
|
|
3074
|
+
actions.push(React.createElement(IconButton, {
|
|
2572
3075
|
key: "refresh",
|
|
2573
|
-
|
|
2574
|
-
|
|
3076
|
+
icon: "↻",
|
|
3077
|
+
label: "Refresh",
|
|
2575
3078
|
onClick: () => controller.handleCommand(UI_COMMANDS.REFRESH_EXECUTION_HISTORY).catch(() => {}),
|
|
2576
|
-
}
|
|
2577
|
-
actions.push(React.createElement(
|
|
3079
|
+
}));
|
|
3080
|
+
actions.push(React.createElement(IconButton, {
|
|
2578
3081
|
key: "save",
|
|
2579
|
-
|
|
2580
|
-
|
|
3082
|
+
icon: "⇩",
|
|
3083
|
+
label: "Export as artifact",
|
|
2581
3084
|
onClick: () => controller.handleCommand(UI_COMMANDS.EXPORT_EXECUTION_HISTORY).catch(() => {}),
|
|
2582
|
-
}
|
|
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(
|
|
3089
|
+
actions.push(React.createElement(IconButton, {
|
|
2586
3090
|
key: `stats-view:${mode}`,
|
|
2587
|
-
|
|
2588
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2989
|
-
|
|
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
|
-
|
|
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(
|
|
3651
|
+
const renderButton = (def) => React.createElement(IconButton, {
|
|
3004
3652
|
key: def.key,
|
|
3005
|
-
|
|
3006
|
-
|
|
3653
|
+
icon: def.icon,
|
|
3654
|
+
label: def.label,
|
|
3007
3655
|
onClick: def.onClick,
|
|
3008
3656
|
disabled: Boolean(def.disabled),
|
|
3009
|
-
|
|
3010
|
-
}
|
|
3657
|
+
active: Boolean(def.active),
|
|
3658
|
+
});
|
|
3011
3659
|
|
|
3012
3660
|
if (mobile) {
|
|
3013
|
-
|
|
3014
|
-
|
|
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
|
-
|
|
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
|