@tangle-network/agent-app 0.44.40 → 0.44.42

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.
@@ -97,7 +97,7 @@ import {
97
97
  flattenHistory,
98
98
  readSandboxBinaryBytes,
99
99
  statSandboxFileSize
100
- } from "../chunk-JYHMNFFU.js";
100
+ } from "../chunk-HUBACCOP.js";
101
101
  import "../chunk-LWSJK546.js";
102
102
  import "../chunk-CQZSAR77.js";
103
103
  import "../chunk-ICOHEZK6.js";
@@ -0,0 +1,204 @@
1
+ // src/session-shell/index.ts
2
+ var UNTITLED_SESSION_LABEL = "Untitled chat";
3
+ function sessionLabel(session, untitled = UNTITLED_SESSION_LABEL) {
4
+ return session.title?.trim() || untitled;
5
+ }
6
+ function buildSessionSubItems({
7
+ sessions,
8
+ hrefForSession,
9
+ respondingSessionIds,
10
+ actions,
11
+ untitledLabel = UNTITLED_SESSION_LABEL,
12
+ prefetch = "intent",
13
+ overflow
14
+ }) {
15
+ const rows = sessions.map((session) => ({
16
+ id: session.id,
17
+ label: sessionLabel(session, untitledLabel),
18
+ href: hrefForSession(session.id),
19
+ prefetch,
20
+ isLoading: respondingSessionIds?.has(session.id) ?? false,
21
+ unread: Boolean(session.unread),
22
+ actions: actions?.canEdit ? [
23
+ {
24
+ id: "rename",
25
+ label: actions.renameLabel ?? "Rename",
26
+ icon: actions.renameIcon,
27
+ onSelect: () => actions.onRename(session)
28
+ },
29
+ {
30
+ id: "delete",
31
+ label: actions.deleteLabel ?? "Delete",
32
+ icon: actions.deleteIcon,
33
+ destructive: true,
34
+ onSelect: () => actions.onDelete(session)
35
+ }
36
+ ] : void 0
37
+ }));
38
+ if (!overflow) return rows;
39
+ return [
40
+ ...rows,
41
+ {
42
+ id: "view-all",
43
+ label: overflow.label ?? "View all chats",
44
+ href: overflow.href,
45
+ prefetch,
46
+ emphasis: true
47
+ }
48
+ ];
49
+ }
50
+ function buildSessionNavItem({
51
+ id = "history",
52
+ label = "History",
53
+ icon,
54
+ href,
55
+ activeSessionId,
56
+ emptyLabel = "No chats yet",
57
+ defaultOpen = true,
58
+ ...subItemOptions
59
+ }) {
60
+ return {
61
+ id,
62
+ icon,
63
+ label,
64
+ href,
65
+ expandable: true,
66
+ defaultOpen,
67
+ subItems: buildSessionSubItems(subItemOptions),
68
+ subActiveIds: activeSessionId ? [activeSessionId] : void 0,
69
+ emptyLabel,
70
+ prefetch: subItemOptions.prefetch ?? "intent"
71
+ };
72
+ }
73
+ function stripTrailingSlashes(value) {
74
+ return value.replace(/\/+$/, "");
75
+ }
76
+ function stripSlashes(value) {
77
+ return value.replace(/^\/+|\/+$/g, "");
78
+ }
79
+ function normalizePath(pathname) {
80
+ const withoutHash = pathname.split("#")[0] ?? "";
81
+ const withoutQuery = withoutHash.split("?")[0] ?? "";
82
+ return stripTrailingSlashes(withoutQuery);
83
+ }
84
+ function isUnderPrefix(path, prefix) {
85
+ const p = stripTrailingSlashes(prefix);
86
+ if (p === "") return true;
87
+ return path === p || path.startsWith(`${p}/`);
88
+ }
89
+ function activeSessionIdFromPath({
90
+ pathname,
91
+ base,
92
+ segment = "chat",
93
+ newSegment = "new",
94
+ reserved
95
+ }) {
96
+ const path = normalizePath(pathname);
97
+ const root = stripTrailingSlashes(base);
98
+ const prefix = segment ? `${root}/${segment}` : root;
99
+ if (!isUnderPrefix(path, prefix) || path === prefix) return null;
100
+ const id = path.slice(prefix.length + 1).split("/")[0] ?? "";
101
+ if (!id || id === newSegment) return null;
102
+ if (reserved?.some((name) => stripSlashes(name) === id)) return null;
103
+ return decodeURIComponent(id);
104
+ }
105
+ function resolveActiveNavId({
106
+ pathname,
107
+ base,
108
+ routes,
109
+ aliases,
110
+ claimsNothing
111
+ }) {
112
+ const path = normalizePath(pathname);
113
+ const root = stripTrailingSlashes(base);
114
+ let bestLength = -1;
115
+ let bestId;
116
+ const consider = (relative, id, winsTies = false) => {
117
+ const full = stripTrailingSlashes(`${root}${relative}`);
118
+ if (!isUnderPrefix(path, full)) return;
119
+ if (full.length > bestLength || winsTies && full.length === bestLength) {
120
+ bestLength = full.length;
121
+ bestId = id;
122
+ }
123
+ };
124
+ for (const route of routes) consider(route.path, route.id);
125
+ for (const [prefix, id] of Object.entries(aliases ?? {})) consider(prefix, id);
126
+ for (const prefix of claimsNothing ?? []) consider(prefix, void 0, true);
127
+ return bestId;
128
+ }
129
+ function resolveSessionUnread({
130
+ sessionId,
131
+ loaderUnread,
132
+ liveUnreadIds,
133
+ locallyReadIds,
134
+ currentSessionId
135
+ }) {
136
+ if (sessionId === currentSessionId) return false;
137
+ if (liveUnreadIds?.has(sessionId)) return true;
138
+ if (locallyReadIds?.has(sessionId)) return false;
139
+ return loaderUnread;
140
+ }
141
+ function composeSidebarSessions({
142
+ loaderSessions,
143
+ optimisticSessions = [],
144
+ limit,
145
+ totalCount,
146
+ liveUnreadIds,
147
+ locallyReadIds,
148
+ currentSessionId
149
+ }) {
150
+ const loaderIds = new Set(loaderSessions.map((session) => session.id));
151
+ const pendingNew = optimisticSessions.filter((session) => !loaderIds.has(session.id));
152
+ const merged = [...pendingNew, ...loaderSessions];
153
+ const sessions = merged.slice(0, Math.max(0, limit)).map((session) => ({
154
+ ...session,
155
+ unread: resolveSessionUnread({
156
+ sessionId: session.id,
157
+ loaderUnread: Boolean(session.unread),
158
+ liveUnreadIds,
159
+ locallyReadIds,
160
+ currentSessionId
161
+ })
162
+ }));
163
+ const known = (totalCount ?? loaderSessions.length) + pendingNew.length;
164
+ return { sessions, hasMore: known > sessions.length };
165
+ }
166
+ function mergeSessionPages(existing, incoming) {
167
+ const seen = new Set(existing.map((session) => session.id));
168
+ return [...existing, ...incoming.filter((session) => !seen.has(session.id))];
169
+ }
170
+ var DEFAULT_RAIL_COOKIE_NAME = "agent-sidebar-rail-collapsed";
171
+ function readRailCollapsedCookie(cookieHeader, name = DEFAULT_RAIL_COOKIE_NAME) {
172
+ for (const pair of (cookieHeader ?? "").split(";")) {
173
+ const eq = pair.indexOf("=");
174
+ if (eq === -1) continue;
175
+ if (pair.slice(0, eq).trim() !== name) continue;
176
+ return pair.slice(eq + 1).trim() === "1";
177
+ }
178
+ return false;
179
+ }
180
+ function railCollapsedCookie(collapsed, { name = DEFAULT_RAIL_COOKIE_NAME, maxAge = 31536e3, path = "/", secure } = {}) {
181
+ const isSecure = secure ?? (typeof location !== "undefined" && location.protocol === "https:");
182
+ return `${name}=${collapsed ? "1" : "0"}; path=${path}; max-age=${maxAge}; samesite=lax${isSecure ? "; secure" : ""}`;
183
+ }
184
+ function writeRailCollapsedCookie(collapsed, options = {}) {
185
+ if (typeof document === "undefined") return;
186
+ document.cookie = railCollapsedCookie(collapsed, options);
187
+ }
188
+
189
+ export {
190
+ UNTITLED_SESSION_LABEL,
191
+ sessionLabel,
192
+ buildSessionSubItems,
193
+ buildSessionNavItem,
194
+ activeSessionIdFromPath,
195
+ resolveActiveNavId,
196
+ resolveSessionUnread,
197
+ composeSidebarSessions,
198
+ mergeSessionPages,
199
+ DEFAULT_RAIL_COOKIE_NAME,
200
+ readRailCollapsedCookie,
201
+ railCollapsedCookie,
202
+ writeRailCollapsedCookie
203
+ };
204
+ //# sourceMappingURL=chunk-3E4MW5LR.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/session-shell/index.ts"],"sourcesContent":["/**\n * Session shell — the app-shell mechanism every agent product needs around the\n * chat surface: a list of past sessions in the rail, an entry point for a new\n * one, and a paged history view behind it.\n *\n * `/web-react` already owns the chat SURFACE (composer, transcript, cards); it\n * owned no session SHELL, so all four products hand-rolled one and drifted.\n * This module is the shell's pure half: no React, no DOM, no peer imports, so a\n * server loader can call `readRailCollapsedCookie` without dragging React into\n * a worker bundle (`/web-react` holds the rendered half).\n *\n * Domain stays a parameter. A \"session\" here is only an id, a title and a\n * timestamp — a gtm thread, a tax session and a legal matter are all the same\n * shape to the shell, and the product supplies routing through `hrefForSession`\n * rather than the shell knowing any URL.\n */\n\n/** One session as the shell needs to see it. Products map their own row\n * (thread / session / matter) onto this before handing it over. */\nexport interface SessionSummary {\n id: string\n /** `null`/empty renders as the untitled placeholder rather than a blank row. */\n title: string | null\n /** ISO-8601. `null` when the product has no timestamp to show. */\n updatedAt: string | null\n isPinned?: boolean\n /** Unread for the viewer. Use `resolveSessionUnread` to fold live overlays in. */\n unread?: boolean\n /** Free-form product label (gtm categories, legal matter types). Passed\n * through untouched — the shell never interprets it. */\n category?: string | null\n}\n\n/** One fetched page of sessions with an optional continuation cursor. */\nexport interface SessionPage {\n items: SessionSummary[]\n /** Opaque continuation token; absent/null ⇒ no further pages. */\n nextCursor?: string | null\n}\n\n/** Sort order for the history view. The product's fetcher decides what these\n * mean against its own storage; the shell only round-trips the value. */\nexport type SessionSort = 'newest' | 'oldest'\n\n// ---------------------------------------------------------------------------\n// Rail items — structurally assignable to sandbox-ui's SidebarLayout types\n// ---------------------------------------------------------------------------\n\n/**\n * These mirror `@tangle-network/sandbox-ui/dashboard`'s `SidebarLayoutNavItem`\n * / `RailExpandableSubItem` STRUCTURALLY rather than importing them, so this\n * module stays free of the optional peer (invariant 3 — structural over\n * hard-dep when the surface is small). `tests/session-shell/rail-contract.test.ts`\n * assigns the builder output to the real sandbox-ui types, so a drift in either\n * direction fails CI instead of silently dropping a field at runtime.\n *\n * `TIcon` is the product's icon component type (lucide, custom, anything) —\n * generic so this file needs no React types.\n */\nexport interface SessionRailAction<TIcon = unknown> {\n id: string\n label: string\n icon?: TIcon\n destructive?: boolean\n onSelect: () => void\n}\n\nexport type RailPrefetch = 'none' | 'intent' | 'render' | 'viewport'\n\nexport interface SessionRailSubItem<TIcon = unknown> {\n id: string\n label: string\n href: string\n prefetch?: RailPrefetch\n /** Live working indicator — the session is mid-turn. */\n isLoading?: boolean\n /** Bold + leading dot. sandbox-ui suppresses it while `isLoading`. */\n unread?: boolean\n /** Emphasised row, used for the trailing \"view all\" overflow link. */\n emphasis?: boolean\n actions?: SessionRailAction<TIcon>[]\n}\n\nexport interface SessionRailNavItem<TIcon = unknown> {\n id: string\n /** REQUIRED, mirroring sandbox-ui — the rail renders `<Icon />` unguarded, so\n * an omitted icon is a blank/crashing row rather than a styling nit. */\n icon: TIcon\n label: string\n href: string\n badge?: number\n expandable?: boolean\n defaultOpen?: boolean\n subItems?: SessionRailSubItem<TIcon>[]\n subActiveIds?: string[]\n emptyLabel?: string\n prefetch?: RailPrefetch\n}\n\n/** Per-row rename/delete wiring. Supplied by the layout that owns the dialogs;\n * omitted (or `canEdit: false`) leaves rows read-only. */\nexport interface SessionRowActions<TIcon = unknown> {\n canEdit: boolean\n renameIcon?: TIcon\n deleteIcon?: TIcon\n renameLabel?: string\n deleteLabel?: string\n onRename: (session: SessionSummary) => void\n onDelete: (session: SessionSummary) => void\n}\n\nexport const UNTITLED_SESSION_LABEL = 'Untitled chat'\n\n/** Display title for a session row — trims, and falls back rather than\n * rendering an empty row the user cannot aim at. */\nexport function sessionLabel(session: SessionSummary, untitled = UNTITLED_SESSION_LABEL): string {\n return session.title?.trim() || untitled\n}\n\nexport interface BuildSessionSubItemsOptions<TIcon = unknown> {\n sessions: SessionSummary[]\n /** The product's route for one session. The shell never builds a URL itself. */\n hrefForSession: (sessionId: string) => string\n /** Ids currently mid-turn — renders the working indicator. */\n respondingSessionIds?: ReadonlySet<string>\n actions?: SessionRowActions<TIcon>\n untitledLabel?: string\n prefetch?: RailPrefetch\n /** Trailing \"view all\" row, appended when the capped list hides sessions. */\n overflow?: { href: string; label?: string }\n}\n\n/** Session rows for the rail's expandable history item. */\nexport function buildSessionSubItems<TIcon = unknown>({\n sessions,\n hrefForSession,\n respondingSessionIds,\n actions,\n untitledLabel = UNTITLED_SESSION_LABEL,\n prefetch = 'intent',\n overflow,\n}: BuildSessionSubItemsOptions<TIcon>): SessionRailSubItem<TIcon>[] {\n const rows: SessionRailSubItem<TIcon>[] = sessions.map((session) => ({\n id: session.id,\n label: sessionLabel(session, untitledLabel),\n href: hrefForSession(session.id),\n prefetch,\n isLoading: respondingSessionIds?.has(session.id) ?? false,\n unread: Boolean(session.unread),\n actions: actions?.canEdit\n ? [\n {\n id: 'rename',\n label: actions.renameLabel ?? 'Rename',\n icon: actions.renameIcon,\n onSelect: () => actions.onRename(session),\n },\n {\n id: 'delete',\n label: actions.deleteLabel ?? 'Delete',\n icon: actions.deleteIcon,\n destructive: true,\n onSelect: () => actions.onDelete(session),\n },\n ]\n : undefined,\n }))\n if (!overflow) return rows\n return [\n ...rows,\n {\n id: 'view-all',\n label: overflow.label ?? 'View all chats',\n href: overflow.href,\n prefetch,\n emphasis: true,\n },\n ]\n}\n\nexport interface BuildSessionNavItemOptions<TIcon = unknown>\n extends BuildSessionSubItemsOptions<TIcon> {\n /** Nav id the product highlights against (`activeNavId === id`). */\n id?: string\n label?: string\n /** The product's icon component. Required — see `SessionRailNavItem.icon`. */\n icon: TIcon\n /** The expandable row's own destination — the full history page. */\n href: string\n /** Session currently open, highlighted inside the expandable. */\n activeSessionId?: string | null\n emptyLabel?: string\n defaultOpen?: boolean\n}\n\n/**\n * The rail's session entry: one expandable nav row whose sub-items are the\n * recent sessions. This is the structure the owner asked for — history lives IN\n * the rail, not in a second sidebar panel beside it.\n */\nexport function buildSessionNavItem<TIcon = unknown>({\n id = 'history',\n label = 'History',\n icon,\n href,\n activeSessionId,\n emptyLabel = 'No chats yet',\n defaultOpen = true,\n ...subItemOptions\n}: BuildSessionNavItemOptions<TIcon>): SessionRailNavItem<TIcon> {\n return {\n id,\n icon,\n label,\n href,\n expandable: true,\n defaultOpen,\n subItems: buildSessionSubItems<TIcon>(subItemOptions),\n subActiveIds: activeSessionId ? [activeSessionId] : undefined,\n emptyLabel,\n prefetch: subItemOptions.prefetch ?? 'intent',\n }\n}\n\n// ---------------------------------------------------------------------------\n// Routing / selection\n// ---------------------------------------------------------------------------\n\nfunction stripTrailingSlashes(value: string): string {\n return value.replace(/\\/+$/, '')\n}\n\n/** Bare segment name, so `reserved` accepts `'/settings'` or `'settings'`. */\nfunction stripSlashes(value: string): string {\n return value.replace(/^\\/+|\\/+$/g, '')\n}\n\n/** Path with query + fragment removed and trailing slashes trimmed. A caller\n * passing a full href instead of a pathname would otherwise match nothing. */\nfunction normalizePath(pathname: string): string {\n const withoutHash = pathname.split('#')[0] ?? ''\n const withoutQuery = withoutHash.split('?')[0] ?? ''\n return stripTrailingSlashes(withoutQuery)\n}\n\n/** True when `path` is `prefix` or a segment-aligned descendant of it, so\n * `/vault` never claims `/vault-archive`. */\nfunction isUnderPrefix(path: string, prefix: string): boolean {\n const p = stripTrailingSlashes(prefix)\n if (p === '') return true\n return path === p || path.startsWith(`${p}/`)\n}\n\nexport interface ActiveSessionIdOptions {\n pathname: string\n /** Workspace-scoped route base, e.g. `/app/ws_123`. */\n base: string\n /** Route segment sessions live under. Default `chat` ⇒ `${base}/chat/:id`.\n * Pass `''` when sessions sit DIRECTLY under the base (`/app/:sessionId`),\n * which is how one product routes them — then `reserved` is mandatory. */\n segment?: string\n /** Segment that means \"composing a new session\", not an id. Default `new`. */\n newSegment?: string\n /**\n * First segments that are OTHER routes, not session ids. Only meaningful\n * with `segment: ''`, where `/app/settings` is otherwise indistinguishable\n * from a session called `settings` — and resolving it as one would highlight\n * and prefetch a session that does not exist. Pass the product's own nav\n * paths; unknown-but-reserved is a routing bug, so this fails closed.\n */\n reserved?: readonly string[]\n}\n\n/**\n * The session id the current route has open, or `null` on the new-session\n * composer / anywhere else.\n *\n * Anchored at `base` on purpose. A bare `/\\/chat\\/([^/]+)/` scan — the shape\n * three products shipped — matches the FIRST `/chat/` anywhere in the path, so\n * a workspace or vault folder named `chat` resolves a neighbouring segment as a\n * session id and the rail highlights (and prefetches) a session the user is not\n * in. Same class as attaching to a stale box: it looks right and points at the\n * wrong row.\n */\nexport function activeSessionIdFromPath({\n pathname,\n base,\n segment = 'chat',\n newSegment = 'new',\n reserved,\n}: ActiveSessionIdOptions): string | null {\n const path = normalizePath(pathname)\n const root = stripTrailingSlashes(base)\n const prefix = segment ? `${root}/${segment}` : root\n if (!isUnderPrefix(path, prefix) || path === prefix) return null\n const id = path.slice(prefix.length + 1).split('/')[0] ?? ''\n if (!id || id === newSegment) return null\n // `/app/settings` under a segment-less route is a sibling page, not a\n // session named \"settings\".\n if (reserved?.some((name) => stripSlashes(name) === id)) return null\n return decodeURIComponent(id)\n}\n\n/** One rail destination. `path` is relative to the workspace base. */\nexport interface NavRouteDef {\n id: string\n path: string\n}\n\nexport interface ResolveActiveNavIdOptions {\n pathname: string\n base: string\n /** The product's rail rows, in any order — resolution is longest-prefix. */\n routes: NavRouteDef[]\n /** Extra prefixes that light an existing row: `{ '/agents': 'integrations' }`.\n * Participates in the same longest-prefix contest. */\n aliases?: Record<string, string>\n /** Prefixes that deliberately highlight NOTHING, beating any shorter match.\n * gtm uses this so an open chat lights no rail row while `/chat/new` still\n * lights \"New\". */\n claimsNothing?: string[]\n}\n\n/**\n * The rail row to highlight for the current route.\n *\n * Longest-prefix wins, so declaration order cannot change the answer. The\n * per-product versions this replaces were first-match over an array, which made\n * `/chat/new` vs `/chat` an ordering accident rather than a rule.\n */\nexport function resolveActiveNavId({\n pathname,\n base,\n routes,\n aliases,\n claimsNothing,\n}: ResolveActiveNavIdOptions): string | undefined {\n const path = normalizePath(pathname)\n const root = stripTrailingSlashes(base)\n let bestLength = -1\n let bestId: string | undefined\n const consider = (relative: string, id: string | undefined, winsTies = false) => {\n const full = stripTrailingSlashes(`${root}${relative}`)\n if (!isUnderPrefix(path, full)) return\n if (full.length > bestLength || (winsTies && full.length === bestLength)) {\n bestLength = full.length\n bestId = id\n }\n }\n for (const route of routes) consider(route.path, route.id)\n for (const [prefix, id] of Object.entries(aliases ?? {})) consider(prefix, id)\n // Declared last and wins an exact-length tie: naming a prefix in\n // `claimsNothing` is a deliberate override of the row that owns it, so\n // `claimsNothing: ['/chat']` beats a `{ id: 'chat', path: '/chat' }` row while\n // a longer `/chat/new` still wins on specificity.\n for (const prefix of claimsNothing ?? []) consider(prefix, undefined, true)\n return bestId\n}\n\n// ---------------------------------------------------------------------------\n// Sidebar list composition\n// ---------------------------------------------------------------------------\n\nexport interface ResolveSessionUnreadOptions {\n sessionId: string\n /** Server-computed unread from the route loader. */\n loaderUnread: boolean\n /** Live \"went unread\" ids from the workspace channel. */\n liveUnreadIds?: ReadonlySet<string>\n /** Ids this tab has already opened since the loader ran. */\n locallyReadIds?: ReadonlySet<string>\n /** The open session is never unread to its own viewer. */\n currentSessionId?: string | null\n}\n\n/**\n * Effective unread for one row. The loader's value can be stale — a layout\n * loader that survives same-workspace navigation keeps reporting a session as\n * unread after the user opened it — so live and local overlays win over it, and\n * the currently-open session always reads as read.\n */\nexport function resolveSessionUnread({\n sessionId,\n loaderUnread,\n liveUnreadIds,\n locallyReadIds,\n currentSessionId,\n}: ResolveSessionUnreadOptions): boolean {\n if (sessionId === currentSessionId) return false\n if (liveUnreadIds?.has(sessionId)) return true\n if (locallyReadIds?.has(sessionId)) return false\n return loaderUnread\n}\n\nexport interface ComposeSidebarSessionsOptions {\n /** Server-rendered rows, already ordered by the product's query. */\n loaderSessions: SessionSummary[]\n /** Optimistic rows from the live channel (a chat created in another tab). */\n optimisticSessions?: SessionSummary[]\n /** Rail cap. The full list lives on the history page. */\n limit: number\n /** Total sessions the product holds, used to decide the overflow row. */\n totalCount?: number\n liveUnreadIds?: ReadonlySet<string>\n locallyReadIds?: ReadonlySet<string>\n currentSessionId?: string | null\n}\n\nexport interface ComposedSidebarSessions {\n sessions: SessionSummary[]\n /** More sessions exist than the rail shows ⇒ render the \"view all\" row. */\n hasMore: boolean\n}\n\n/**\n * The rail's session list: optimistic rows first, then the loader's, capped,\n * with unread resolved per row.\n *\n * Optimistic rows are deduped against the loader by id — once a revalidation\n * brings a live-created session back from the server it must not appear twice\n * (duplicate React keys, and the row's actions would target the same session\n * from two places).\n */\nexport function composeSidebarSessions({\n loaderSessions,\n optimisticSessions = [],\n limit,\n totalCount,\n liveUnreadIds,\n locallyReadIds,\n currentSessionId,\n}: ComposeSidebarSessionsOptions): ComposedSidebarSessions {\n const loaderIds = new Set(loaderSessions.map((session) => session.id))\n const pendingNew = optimisticSessions.filter((session) => !loaderIds.has(session.id))\n const merged = [...pendingNew, ...loaderSessions]\n const sessions = merged.slice(0, Math.max(0, limit)).map((session) => ({\n ...session,\n unread: resolveSessionUnread({\n sessionId: session.id,\n loaderUnread: Boolean(session.unread),\n liveUnreadIds,\n locallyReadIds,\n currentSessionId,\n }),\n }))\n const known = (totalCount ?? loaderSessions.length) + pendingNew.length\n return { sessions, hasMore: known > sessions.length }\n}\n\n/**\n * Append a fetched page to held rows, dropping ids already shown. A session\n * bumped to the top between two page fetches otherwise arrives twice — once in\n * the page it moved out of and once in the page it moved into.\n */\nexport function mergeSessionPages(\n existing: SessionSummary[],\n incoming: SessionSummary[],\n): SessionSummary[] {\n const seen = new Set(existing.map((session) => session.id))\n return [...existing, ...incoming.filter((session) => !seen.has(session.id))]\n}\n\n// ---------------------------------------------------------------------------\n// Rail collapse cookie (SSR-seeded so the first paint matches the client)\n// ---------------------------------------------------------------------------\n\nexport const DEFAULT_RAIL_COOKIE_NAME = 'agent-sidebar-rail-collapsed'\n\n/**\n * Read the persisted rail-collapse state from a request's `Cookie` header, so\n * the server renders the rail in the state the user left it and the first\n * client render does not re-flow.\n *\n * Parses the header rather than building a `RegExp` from the cookie name (the\n * shape the products shipped): a name containing a regex metacharacter would\n * silently match the wrong cookie or none at all.\n */\nexport function readRailCollapsedCookie(\n cookieHeader: string | null | undefined,\n name: string = DEFAULT_RAIL_COOKIE_NAME,\n): boolean {\n for (const pair of (cookieHeader ?? '').split(';')) {\n const eq = pair.indexOf('=')\n if (eq === -1) continue\n if (pair.slice(0, eq).trim() !== name) continue\n return pair.slice(eq + 1).trim() === '1'\n }\n return false\n}\n\nexport interface RailCookieOptions {\n name?: string\n /** Seconds. Default one year. */\n maxAge?: number\n path?: string\n /** Omit to auto-detect: `secure` on https, off on http://localhost — a Secure\n * cookie is dropped there and the rail state would not persist in dev. */\n secure?: boolean\n}\n\n/** The cookie string for a collapse state. Usable as `document.cookie` or as a\n * `Set-Cookie` value. Exported separately so it is testable without a DOM. */\nexport function railCollapsedCookie(\n collapsed: boolean,\n { name = DEFAULT_RAIL_COOKIE_NAME, maxAge = 31_536_000, path = '/', secure }: RailCookieOptions = {},\n): string {\n const isSecure =\n secure ?? (typeof location !== 'undefined' && location.protocol === 'https:')\n return `${name}=${collapsed ? '1' : '0'}; path=${path}; max-age=${maxAge}; samesite=lax${isSecure ? '; secure' : ''}`\n}\n\n/** Persist the rail-collapse state from the browser. No-op without a document\n * so a shared toggle handler is safe to call during SSR. */\nexport function writeRailCollapsedCookie(collapsed: boolean, options: RailCookieOptions = {}): void {\n if (typeof document === 'undefined') return\n document.cookie = railCollapsedCookie(collapsed, options)\n}\n"],"mappings":"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","names":[]}
@@ -1411,6 +1411,7 @@ async function peekWorkspaceSandbox(shell, options) {
1411
1411
  return { status: "running", box: match };
1412
1412
  }
1413
1413
  async function finalizeExistingBox(shell, client, box, stage, name, workspaceId, userId, harness, scope) {
1414
+ await reconcileExistingBoxEgress(box, shell.egressPolicy, stage, name);
1414
1415
  const written = await materializeDeferredFilesForExistingBox(
1415
1416
  shell,
1416
1417
  client,
@@ -1433,6 +1434,40 @@ async function finalizeExistingBox(shell, client, box, stage, name, workspaceId,
1433
1434
  }
1434
1435
  return finalBox;
1435
1436
  }
1437
+ function normalizedEgressPolicy(policy) {
1438
+ if (policy.mode !== "strict") return policy.mode;
1439
+ const allowDomains = [...new Set(
1440
+ (policy.allowDomains ?? []).map((domain) => domain.trim().toLowerCase()).filter(Boolean)
1441
+ )].sort();
1442
+ return JSON.stringify({
1443
+ mode: policy.mode,
1444
+ allowDomains,
1445
+ includeImplicitDomains: policy.includeImplicitDomains !== false
1446
+ });
1447
+ }
1448
+ async function reconcileExistingBoxEgress(box, desired, stage, name) {
1449
+ if (!desired) return;
1450
+ let current;
1451
+ try {
1452
+ current = await box.egress.get();
1453
+ } catch (cause) {
1454
+ const error = cause instanceof Error ? cause : new Error(String(cause));
1455
+ throw new Error(`egress policy read failed on ${stage} box ${name}: ${error.message}`, {
1456
+ cause: error
1457
+ });
1458
+ }
1459
+ const matchingPolicy = normalizedEgressPolicy(current.policy) === normalizedEgressPolicy(desired);
1460
+ const effectiveSource = desired.mode !== "open" || current.source !== "platform";
1461
+ if (matchingPolicy && effectiveSource) return;
1462
+ try {
1463
+ await box.egress.update(desired);
1464
+ } catch (cause) {
1465
+ const error = cause instanceof Error ? cause : new Error(String(cause));
1466
+ throw new Error(`egress policy update failed on ${stage} box ${name}: ${error.message}`, {
1467
+ cause: error
1468
+ });
1469
+ }
1470
+ }
1436
1471
  async function ensureWorkspaceSandbox(shell, options) {
1437
1472
  const { workspaceId, userId, harness, forceNew, onProgress, billingOwnerId } = options;
1438
1473
  const resolved = await resolveWorkspaceSandboxClient(shell, workspaceId, userId);
@@ -1565,6 +1600,7 @@ async function ensureWorkspaceSandbox(shell, options) {
1565
1600
  backend: { type: harness, profile, ...model ? { model } : {} },
1566
1601
  ...storage ? { storage } : {},
1567
1602
  ...restore ? restore : {},
1603
+ ...shell.egressPolicy ? { egressPolicy: shell.egressPolicy } : {},
1568
1604
  ...shell.webTerminalEnabled ? { webTerminalEnabled: true } : {},
1569
1605
  maxLifetimeSeconds: resources.maxLifetimeSeconds,
1570
1606
  idleTimeoutSeconds: resources.idleTimeoutSeconds,
@@ -1978,4 +2014,4 @@ export {
1978
2014
  isTerminalPromptEvent,
1979
2015
  detectInteractiveQuestion
1980
2016
  };
1981
- //# sourceMappingURL=chunk-JYHMNFFU.js.map
2017
+ //# sourceMappingURL=chunk-HUBACCOP.js.map