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