@nordbyte/nordrelay 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +4 -0
  2. package/dist/access/audit-log.js +30 -13
  3. package/dist/channels/discord/discord-bot.js +12 -27
  4. package/dist/channels/shared/channel-bridge-controller.js +1 -1
  5. package/dist/channels/shared/channel-prompt-queue.js +37 -0
  6. package/dist/channels/shared/channel-turn-service.js +23 -9
  7. package/dist/channels/slack/slack-bot.js +12 -15
  8. package/dist/channels/telegram/bot.js +18 -4
  9. package/dist/core/pagination.js +22 -0
  10. package/dist/peers/peer-store.js +16 -0
  11. package/dist/peers/peer-types.js +19 -0
  12. package/dist/peers/peer-web-proxy-contract.js +2 -0
  13. package/dist/runtime/relay-external-activity-monitor.js +15 -0
  14. package/dist/runtime/relay-queue-service.js +1 -0
  15. package/dist/runtime/relay-runtime-dashboard.js +3 -0
  16. package/dist/runtime/relay-runtime-helpers.js +3 -0
  17. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +14 -10
  18. package/dist/runtime/relay-runtime-sessions.js +8 -0
  19. package/dist/runtime/relay-runtime-trace.js +92 -0
  20. package/dist/runtime/relay-runtime-updates-jobs.js +11 -5
  21. package/dist/runtime/relay-runtime.js +16 -6
  22. package/dist/state/prompt-store.js +13 -1
  23. package/dist/web/web-api-contract.js +2 -0
  24. package/dist/web/web-dashboard-access-routes.js +15 -12
  25. package/dist/web/web-dashboard-artifact-routes.js +6 -2
  26. package/dist/web/web-dashboard-assets.js +1 -0
  27. package/dist/web/web-dashboard-pages.js +58 -20
  28. package/dist/web/web-dashboard-peer-routes.js +19 -0
  29. package/dist/web/web-dashboard-runtime-routes.js +8 -1
  30. package/dist/web/web-dashboard-session-routes.js +17 -12
  31. package/dist/web/web-dashboard-ui.js +46 -10
  32. package/dist/web/web-performance.js +2 -0
  33. package/dist/web/web-state.js +33 -4
  34. package/dist/webui-assets/dashboard.css +227 -39
  35. package/dist/webui-assets/dashboard.js +728 -58
  36. package/package.json +4 -2
  37. package/plugins/nordrelay/scripts/nordrelay.mjs +333 -8
  38. package/plugins/nordrelay/scripts/service-installer.mjs +183 -0
  39. package/scripts/postinstall.mjs +122 -0
@@ -171,6 +171,25 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
171
171
  options.auditPeerAction?.("peer_tls_repinned", `${updated.name} (${updated.id})`);
172
172
  return true;
173
173
  }
174
+ const rotateMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/rotate$/);
175
+ if (rotateMatch?.[1] && req.method === "POST") {
176
+ const body = await readJsonBody(req);
177
+ const readiness = await buildPeerReadiness(options.config);
178
+ const created = store.createRotationInvitation(decodeURIComponent(rotateMatch[1]), {
179
+ expiresInMs: (optionalNumberField(body, "expiresMinutes") ?? 10) * 60 * 1000,
180
+ });
181
+ const command = `nordrelay peer add ${readiness.listenUrl} --code ${created.code}`;
182
+ sendJson(res, 201, {
183
+ peer: created.peer,
184
+ invitation: created.invitation,
185
+ code: created.code,
186
+ command,
187
+ readiness,
188
+ warnings: readiness.warnings,
189
+ });
190
+ options.auditPeerAction?.("peer_rotation_invite_created", `${created.peer.name} (${created.peer.id})`);
191
+ return true;
192
+ }
174
193
  if (req.method === "GET" && url.pathname === "/api/peers/global-sessions") {
175
194
  const query = optionalStringField(Object.fromEntries(url.searchParams), "query") ?? "";
176
195
  const agent = parseAgent(optionalStringField(Object.fromEntries(url.searchParams), "agent"));
@@ -63,7 +63,14 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
63
63
  return true;
64
64
  }
65
65
  if (req.method === "GET" && url.pathname === "/api/jobs") {
66
- sendJson(res, 200, await runtime.jobs());
66
+ sendJson(res, 200, await runtime.jobs({
67
+ limit: numberParam(url, "limit", 100),
68
+ cursor: url.searchParams.get("cursor") || undefined,
69
+ }));
70
+ return true;
71
+ }
72
+ if (req.method === "GET" && url.pathname === "/api/trace") {
73
+ sendJson(res, 200, await runtime.trace(stringField(Object.fromEntries(url.searchParams), "correlationId")));
67
74
  return true;
68
75
  }
69
76
  const jobLogMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/log$/);
@@ -1,5 +1,6 @@
1
1
  import { isAgentId } from "../agents/shared/agent.js";
2
2
  import { numberParam, optionalBooleanField, optionalStringField, parseUploadFiles, readJsonBody, requiredSearch, sendJson, stringField, } from "./web-dashboard-http.js";
3
+ import { cursorPage, normalizeCursorLimit } from "../core/pagination.js";
3
4
  export async function handleDashboardSessionRoute(req, res, url, options) {
4
5
  const { runtime, authUser } = options;
5
6
  if (req.method === "GET" && url.pathname === "/api/locks") {
@@ -207,19 +208,23 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
207
208
  return true;
208
209
  }
209
210
  if (req.method === "GET" && url.pathname === "/api/activity") {
211
+ const limit = normalizeCursorLimit(numberParam(url, "limit", 100), 100, 500);
212
+ const scoped = options.filterActivityByScope(authUser, runtime.activity({
213
+ limit: 500,
214
+ source: (url.searchParams.get("source") || "all"),
215
+ status: (url.searchParams.get("status") || "all"),
216
+ category: (url.searchParams.get("category") || "all"),
217
+ actor: url.searchParams.get("actor") || undefined,
218
+ agentId: url.searchParams.get("agent") || "all",
219
+ threadId: url.searchParams.get("thread") || undefined,
220
+ workspace: url.searchParams.get("workspace") || undefined,
221
+ type: url.searchParams.get("type") || undefined,
222
+ since: url.searchParams.get("since") || undefined,
223
+ }));
224
+ const scopedPage = cursorPage(scoped, url.searchParams.get("cursor") || undefined, limit, (event) => event.id);
210
225
  sendJson(res, 200, {
211
- events: options.filterActivityByScope(authUser, runtime.activity({
212
- limit: numberParam(url, "limit", 100),
213
- source: (url.searchParams.get("source") || "all"),
214
- status: (url.searchParams.get("status") || "all"),
215
- category: (url.searchParams.get("category") || "all"),
216
- actor: url.searchParams.get("actor") || undefined,
217
- agentId: url.searchParams.get("agent") || "all",
218
- threadId: url.searchParams.get("thread") || undefined,
219
- workspace: url.searchParams.get("workspace") || undefined,
220
- type: url.searchParams.get("type") || undefined,
221
- since: url.searchParams.get("since") || undefined,
222
- })),
226
+ events: scopedPage.items,
227
+ pagination: scopedPage.pagination,
223
228
  });
224
229
  return true;
225
230
  }
@@ -1,20 +1,56 @@
1
- export const DASHBOARD_PAGES = [
1
+ export const DASHBOARD_PRIMARY_NAV_PAGES = [
2
2
  { id: "overview", label: "Overview", permission: "inspect" },
3
3
  { id: "chat", label: "Chat", permission: "sessions.read" },
4
4
  { id: "sessions", label: "Sessions", permission: "sessions.read" },
5
5
  { id: "queue", label: "Queue", permission: "queue.read" },
6
6
  { id: "tasks", label: "Tasks", permission: "inspect" },
7
- { id: "metrics", label: "Metrics", permission: "inspect" },
8
7
  { id: "activity", label: "Activity", permission: "sessions.read" },
8
+ { id: "trace", label: "Trace", permission: "sessions.read" },
9
9
  { id: "artifacts", label: "Artifacts", permission: "files.read" },
10
- { id: "adapters", label: "Adapters", permission: "inspect" },
11
- { id: "peers", label: "Peers", permission: "peers.read" },
12
- { id: "access", label: "Users", permission: "users.read" },
13
- { id: "version", label: "Version", permission: "inspect" },
14
- { id: "settings", label: "Settings", permission: "settings.read" },
15
- { id: "logs", label: "Logs", permission: "logs.read" },
16
- { id: "diagnostics", label: "Diagnostics", permission: "diagnostics.read" },
17
10
  ];
11
+ export const DASHBOARD_NAV_SECTIONS = [
12
+ {
13
+ id: "operations",
14
+ label: "Operations",
15
+ pages: [
16
+ { id: "metrics", label: "Metrics", permission: "inspect" },
17
+ { id: "adapters", label: "Adapters", permission: "inspect" },
18
+ { id: "version", label: "Version", permission: "inspect" },
19
+ { id: "logs", label: "Logs", permission: "logs.read" },
20
+ { id: "diagnostics", label: "Diagnostics", permission: "diagnostics.read" },
21
+ ],
22
+ },
23
+ {
24
+ id: "administration",
25
+ label: "Administration",
26
+ pages: [
27
+ { id: "access", label: "Users", permission: "users.read" },
28
+ { id: "settings", label: "Settings", permission: "settings.read" },
29
+ { id: "peers", label: "Peers", permission: "peers.read" },
30
+ ],
31
+ },
32
+ ];
33
+ export const DASHBOARD_PAGES = [
34
+ ...DASHBOARD_PRIMARY_NAV_PAGES,
35
+ ...DASHBOARD_NAV_SECTIONS.flatMap((section) => section.pages),
36
+ ];
37
+ function renderDashboardPageButton(page, activePage) {
38
+ return `<button type="button" data-page="${page.id}" data-permission="${page.permission}"${page.id === activePage ? ' class="active"' : ""}>${page.label}</button>`;
39
+ }
40
+ function renderDashboardNavSection(section, activePage) {
41
+ const isOpen = section.defaultOpen === true || section.pages.some((page) => page.id === activePage);
42
+ const itemsId = `nav-section-${section.id}`;
43
+ return `<div class="nav-section" data-nav-section="${section.id}" data-nav-open="${isOpen ? "true" : "false"}" data-nav-default-open="${section.defaultOpen === true ? "true" : "false"}">
44
+ <button type="button" class="nav-section-toggle" data-nav-toggle="${section.id}" aria-expanded="${isOpen ? "true" : "false"}" aria-controls="${itemsId}">${section.label}</button>
45
+ <div class="nav-section-items" id="${itemsId}"${isOpen ? "" : " hidden"}>
46
+ ${section.pages.map((page) => renderDashboardPageButton(page, activePage)).join("\n ")}
47
+ </div>
48
+ </div>`;
49
+ }
18
50
  export function renderDashboardNav(activePage = "overview") {
19
- return DASHBOARD_PAGES.map((page) => `<button data-page="${page.id}" data-permission="${page.permission}"${page.id === activePage ? ' class="active"' : ""}>${page.label}</button>`).join("\n ");
51
+ const primary = `<div class="nav-primary">
52
+ ${DASHBOARD_PRIMARY_NAV_PAGES.map((page) => renderDashboardPageButton(page, activePage)).join("\n ")}
53
+ </div>`;
54
+ const sections = DASHBOARD_NAV_SECTIONS.map((section) => renderDashboardNavSection(section, activePage)).join("\n ");
55
+ return `${primary}\n ${sections}`;
20
56
  }
@@ -51,8 +51,10 @@ function routeKey(path) {
51
51
  .replace(/\/api\/peers\/[^/]+\/events$/, "/api/peers/:id/events")
52
52
  .replace(/\/api\/peers\/[^/]+\/health$/, "/api/peers/:id/health")
53
53
  .replace(/\/api\/peers\/[^/]+\/repin$/, "/api/peers/:id/repin")
54
+ .replace(/\/api\/peers\/[^/]+\/rotate$/, "/api/peers/:id/rotate")
54
55
  .replace(/\/api\/agent-update\/[^/]+\/(log|input|cancel)$/, "/api/agent-update/:id/$1")
55
56
  .replace(/\/api\/jobs\/[^/]+\/(log|action)$/, "/api/jobs/:id/$1")
57
+ .replace(/\/api\/trace$/, "/api/trace")
56
58
  .replace(/\/api\/users\/[^/]+\/sessions\/[^/]+$/, "/api/users/:id/sessions/:sessionId")
57
59
  .replace(/\/api\/users\/[^/]+\/(password|telegram|discord|slack|sessions)$/, "/api/users/:id/$1")
58
60
  .replace(/\/api\/peers\/discovery-jobs\/[^/]+\/(cancel|log)$/, "/api/peers/discovery-jobs/:id/$1")
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { activityActorLabel, activityCategoryForType, } from "../core/activity-events.js";
3
+ import { cursorPage, normalizeCursorLimit } from "../core/pagination.js";
3
4
  import { createDocumentStore } from "../state/state-backend.js";
4
5
  const DEFAULT_CHAT_LIMIT = 300;
5
6
  const DEFAULT_ACTIVITY_LIMIT = 1000;
@@ -50,6 +51,7 @@ export class WebChatStore {
50
51
  existing.role = input.role;
51
52
  existing.text = input.text;
52
53
  existing.source = input.source;
54
+ existing.correlationId = input.correlationId;
53
55
  existing.turnId = input.turnId;
54
56
  existing.timestamp = input.timestamp ?? now;
55
57
  existing.key = input.key;
@@ -74,6 +76,17 @@ export class WebChatStore {
74
76
  const messages = this.readPayload().messagesByThread[threadId || "pending"] ?? [];
75
77
  return messages.slice(-Math.max(1, Math.min(this.maxMessages, limit)));
76
78
  }
79
+ findByCorrelationId(correlationId, limit = 100) {
80
+ const needle = correlationId.trim();
81
+ if (!needle) {
82
+ return [];
83
+ }
84
+ return Object.values(this.readPayload().messagesByThread)
85
+ .flat()
86
+ .filter((message) => message.correlationId === needle)
87
+ .sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp))
88
+ .slice(-Math.max(1, Math.min(this.maxMessages, limit)));
89
+ }
77
90
  clear(threadId) {
78
91
  const payload = this.readPayload();
79
92
  const key = threadId || "pending";
@@ -124,7 +137,25 @@ export class WebActivityStore {
124
137
  return event;
125
138
  }
126
139
  list(options = {}) {
127
- const limit = Math.max(1, Math.min(500, options.limit ?? 100));
140
+ return this.listPage(options).items;
141
+ }
142
+ listPage(options = {}) {
143
+ const limit = normalizeCursorLimit(options.limit, 100, 500);
144
+ const events = this.filteredEvents(options)
145
+ .sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp));
146
+ return cursorPage(events, options.cursor, limit, (event) => event.id);
147
+ }
148
+ findByCorrelationId(correlationId, limit = 100) {
149
+ const needle = correlationId.trim();
150
+ if (!needle) {
151
+ return [];
152
+ }
153
+ return this.readPayload().events
154
+ .filter((event) => event.correlationId === needle)
155
+ .sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp))
156
+ .slice(-Math.max(1, Math.min(500, limit)));
157
+ }
158
+ filteredEvents(options) {
128
159
  const since = normalizeSince(options.since);
129
160
  return this.readPayload().events
130
161
  .filter((event) => !options.source || options.source === "all" || event.source === options.source)
@@ -135,9 +166,7 @@ export class WebActivityStore {
135
166
  .filter((event) => !options.workspace || event.workspace === options.workspace)
136
167
  .filter((event) => !options.type || event.type.toLowerCase().includes(options.type.toLowerCase()))
137
168
  .filter((event) => !options.actor || activityActorMatches(event.actor, options.actor))
138
- .filter((event) => !since || Date.parse(event.timestamp) >= since)
139
- .slice(-limit)
140
- .reverse();
169
+ .filter((event) => !since || Date.parse(event.timestamp) >= since);
141
170
  }
142
171
  readPayload() {
143
172
  const payload = this.store.read();
@@ -44,48 +44,68 @@
44
44
  --shadow:0 10px 28px rgba(0,0,0,.22);
45
45
  --link:#75c99a;
46
46
  }
47
- .agent-settings-nav {
47
+ .access-tab {
48
+ display: none;
49
+ }
50
+ .access-tab.active {
51
+ display: block;
52
+ }
53
+ .section-header {
48
54
  display: flex;
49
- align-items: center;
50
- gap: 8px;
51
- flex-wrap: wrap;
52
- margin: 0 0 12px;
53
- padding: 10px;
54
- border: 1px solid var(--border-soft);
55
- border-radius: 8px;
56
- background: var(--surface);
55
+ align-items: flex-end;
56
+ justify-content: space-between;
57
+ gap: 16px;
58
+ margin: -12px -16px 0;
59
+ padding: 0 16px;
57
60
  }
58
- .agent-settings-nav strong {
59
- font-size: 13px;
60
- color: var(--muted);
61
- margin-right: 4px;
61
+ .access-section-header,
62
+ .settings-section-header {
63
+ margin-bottom: 12px;
62
64
  }
63
- .agent-settings-nav button {
64
- background: var(--surface);
65
- color: var(--text);
66
- border-color: var(--border);
67
- min-height: 32px;
68
- height: 32px;
69
- line-height: 1;
65
+ .section-tabs {
66
+ display: flex;
67
+ align-items: flex-end;
68
+ gap: 2px;
69
+ width: 100%;
70
+ min-width: 0;
71
+ min-height: 43px;
72
+ overflow-x: auto;
73
+ overflow-y: hidden;
74
+ border-bottom: 1px solid var(--border);
70
75
  }
71
- .agent-settings-nav button.active {
72
- background: var(--accent);
73
- color: white;
74
- border-color: var(--accent);
76
+ .section-tabs button {
77
+ appearance: none;
78
+ -webkit-appearance: none;
79
+ display: inline-flex;
80
+ align-items: center;
81
+ justify-content: center;
82
+ min-height: 43px;
83
+ height: 43px;
84
+ margin: 0;
85
+ padding: 0 14px;
86
+ border: 0;
87
+ border-radius: 0;
88
+ background: transparent;
89
+ color: var(--muted);
90
+ font-weight: 650;
91
+ line-height: 1;
92
+ white-space: nowrap;
93
+ cursor: pointer;
94
+ box-shadow: inset 0 -2px 0 transparent;
75
95
  }
76
- @media (max-width: 560px) {
77
- .agent-settings-nav {
78
- align-items: stretch;
79
- }
80
- .agent-settings-nav button {
81
- width: 100%;
82
- }
96
+ .section-tabs button:hover {
97
+ background: color-mix(in srgb, var(--accent) 8%, transparent);
98
+ color: var(--text);
83
99
  }
84
- .access-tab {
85
- display: none;
100
+ .section-tabs button.active,
101
+ .section-tabs button[aria-selected=true] {
102
+ box-shadow: inset 0 -2px 0 var(--accent);
103
+ background: transparent;
104
+ color: var(--text);
86
105
  }
87
- .access-tab.active {
88
- display: block;
106
+ .section-tabs button:focus-visible {
107
+ outline: 2px solid var(--accent);
108
+ outline-offset: -3px;
89
109
  }
90
110
  .access-tab-heading {
91
111
  display: flex;
@@ -94,24 +114,130 @@
94
114
  gap: 12px;
95
115
  margin: 0 0 10px;
96
116
  }
97
- .access-tab-heading h2 {
98
- margin: 0;
99
- }
100
117
  .access-tab-heading input {
101
118
  max-width: 320px;
102
119
  }
120
+ .settings-subnav {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 10px;
124
+ margin: 0 0 14px;
125
+ }
126
+ .settings-subnav[hidden] {
127
+ display: none;
128
+ }
129
+ .settings-subnav label {
130
+ display: inline-flex;
131
+ align-items: center;
132
+ gap: 8px;
133
+ color: var(--muted);
134
+ font-size: 13px;
135
+ line-height: 1;
136
+ }
137
+ .settings-subnav select {
138
+ min-width: 220px;
139
+ }
140
+ .settings-actions {
141
+ margin: 0 0 14px;
142
+ }
143
+ .access-filter-row {
144
+ display: flex;
145
+ align-items: center;
146
+ gap: 8px;
147
+ flex-wrap: wrap;
148
+ }
149
+ .access-filter-row input,
150
+ .access-filter-row select {
151
+ min-width: 170px;
152
+ }
103
153
  .access-id-row {
104
154
  display: flex;
105
155
  align-items: center;
106
156
  gap: 8px;
107
157
  flex-wrap: wrap;
108
158
  }
159
+ .user-list {
160
+ gap: 10px;
161
+ }
162
+ .user-card-main {
163
+ display: grid;
164
+ grid-template-columns: minmax(0, 1fr) auto;
165
+ gap: 12px;
166
+ align-items: start;
167
+ }
168
+ .user-card-meta {
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: flex-end;
172
+ gap: 6px;
173
+ flex-wrap: wrap;
174
+ max-width: 460px;
175
+ }
176
+ .identity-actions {
177
+ margin-bottom: 10px;
178
+ }
179
+ .user-detail-tabs {
180
+ margin: 14px 0 12px;
181
+ }
182
+ .user-detail-panel {
183
+ display: none;
184
+ }
185
+ .user-detail-panel.active {
186
+ display: block;
187
+ }
188
+ .access-effective-grid {
189
+ display: grid;
190
+ grid-template-columns: repeat(2, minmax(0, 1fr));
191
+ gap: 12px;
192
+ }
193
+ .permission-category-grid {
194
+ display: grid;
195
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
196
+ gap: 10px;
197
+ }
198
+ .permission-section {
199
+ min-width: 0;
200
+ margin: 0;
201
+ border: 1px solid var(--border-soft);
202
+ border-radius: 8px;
203
+ padding: 10px;
204
+ background: var(--surface-soft);
205
+ }
206
+ .permission-section legend {
207
+ font-size: 12px;
208
+ font-weight: 700;
209
+ color: var(--muted);
210
+ padding: 0 4px;
211
+ }
212
+ .permission-section label {
213
+ display: flex !important;
214
+ margin: 5px 0;
215
+ }
216
+ .scope-section {
217
+ display: grid;
218
+ gap: 6px;
219
+ }
220
+ .scope-section small {
221
+ color: var(--muted);
222
+ }
223
+ @media (max-width: 760px) {
224
+ .user-card-main,
225
+ .access-effective-grid {
226
+ grid-template-columns: 1fr;
227
+ }
228
+ .user-card-meta {
229
+ justify-content: flex-start;
230
+ max-width: none;
231
+ }
232
+ }
109
233
  @media (max-width: 560px) {
110
234
  .access-tab-heading {
111
235
  align-items: stretch;
112
236
  flex-direction: column;
113
237
  }
114
- .access-tab-heading input {
238
+ .access-tab-heading input,
239
+ .access-filter-row input,
240
+ .access-filter-row select {
115
241
  max-width: none;
116
242
  width: 100%;
117
243
  }
@@ -395,6 +521,10 @@
395
521
  .log-line {
396
522
  display: block;
397
523
  }
524
+ .log-line.INFO {
525
+ color: var(--pre-text);
526
+ font-weight: 400;
527
+ }
398
528
  .log-line.ERROR {
399
529
  color: var(--danger);
400
530
  font-weight: 700;
@@ -626,6 +756,62 @@ nav button.active,
626
756
  nav button:hover {
627
757
  background: color-mix(in srgb, var(--accent) 35%, transparent);
628
758
  }
759
+ .nav-section {
760
+ display: flex;
761
+ flex-direction: column;
762
+ gap: 4px;
763
+ }
764
+ .nav-section[hidden],
765
+ .nav-section-items[hidden] {
766
+ display: none;
767
+ }
768
+ .nav-primary {
769
+ display: flex;
770
+ flex-direction: column;
771
+ gap: 4px;
772
+ }
773
+ .nav-primary + .nav-section,
774
+ .nav-section + .nav-section {
775
+ margin-top: 8px;
776
+ padding-top: 10px;
777
+ border-top: 1px solid color-mix(in srgb, var(--sidebar-border) 72%, transparent);
778
+ }
779
+ .nav-section-items {
780
+ display: flex;
781
+ flex-direction: column;
782
+ gap: 4px;
783
+ }
784
+ .nav-section-toggle {
785
+ min-height: 30px !important;
786
+ height: 30px !important;
787
+ padding: 0 8px !important;
788
+ color: var(--sidebar-muted);
789
+ font-size: 11px;
790
+ font-weight: 760;
791
+ line-height: 1;
792
+ text-transform: uppercase;
793
+ letter-spacing: .04em;
794
+ justify-content: space-between !important;
795
+ }
796
+ .nav-section-toggle::after {
797
+ content: "";
798
+ width: 7px;
799
+ height: 7px;
800
+ border-right: 1.5px solid currentColor;
801
+ border-bottom: 1.5px solid currentColor;
802
+ transform: rotate(45deg) translateY(-1px);
803
+ opacity: .8;
804
+ }
805
+ .nav-section[data-nav-open=true] .nav-section-toggle::after {
806
+ transform: rotate(225deg) translateY(-1px);
807
+ }
808
+ .nav-section-toggle.active {
809
+ color: var(--sidebar-text);
810
+ }
811
+ .nav-section-toggle.active,
812
+ .nav-section-toggle:hover {
813
+ background: color-mix(in srgb, var(--accent) 24%, transparent);
814
+ }
629
815
  main {
630
816
  min-width: 0;
631
817
  display: flex;
@@ -1073,6 +1259,8 @@ input[type=file] {
1073
1259
  border-radius: 8px;
1074
1260
  padding: 12px;
1075
1261
  background: var(--surface-soft);
1262
+ content-visibility: auto;
1263
+ contain-intrinsic-size: 96px;
1076
1264
  }
1077
1265
  .item strong {
1078
1266
  display: block;