@k2b/cloud 0.38.0 → 0.40.0
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 +8 -0
- package/package.json +12 -5
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +63 -16
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +32 -7
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/background-review.ts +72 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +398 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +669 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +45 -163
- package/src/ai/chat/view-block.tsx +22 -7
- package/src/ai/chat-task-contracts.ts +31 -3
- package/src/ai/chat-task-reasons-migrate.ts +75 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +438 -78
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +14 -0
- package/src/ai/migrate.ts +39 -4
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +172 -2
- package/src/ai/runtime.ts +13 -27
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +232 -27
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +67 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +40 -4
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +6 -2
- package/src/api/app-approval.ts +54 -6
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +3 -1
- package/src/api/auth.ts +25 -6
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +7 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +60 -6
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +145 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +160 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +35 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +756 -0
- package/src/browser/panel.ts +65 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +159 -12
- package/src/contracts/app.ts +14 -0
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +2 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +533 -90
- package/src/services/approvals/device.ts +9 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +32 -3
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/services/webauthn.ts +12 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared/redirect.ts +12 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +95 -10
- package/src/ssr/Layout.tsx +66 -10
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +35 -0
- package/src/ssr/PanelRuntime.island.tsx +94 -0
- package/src/ssr/PanelSlot.tsx +158 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +69 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +105 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +28 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +439 -0
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +159 -0
- package/src/ssr/platform-messages.ts +48 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +5 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +201 -11
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +314 -63
- package/src/styles/panel.css +416 -0
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +36 -13
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +22 -43
package/src/shared/email-html.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const EMAIL_HTML_TAGS = [
|
|
|
10
10
|
"h1",
|
|
11
11
|
"h2",
|
|
12
12
|
"h3",
|
|
13
|
+
"hr",
|
|
13
14
|
"li",
|
|
14
15
|
"ol",
|
|
15
16
|
"p",
|
|
@@ -33,6 +34,7 @@ export const EMAIL_HTML_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
|
|
33
34
|
h1: ["style"],
|
|
34
35
|
h2: ["style"],
|
|
35
36
|
h3: ["style"],
|
|
37
|
+
hr: ["style"],
|
|
36
38
|
li: ["style"],
|
|
37
39
|
ol: ["style"],
|
|
38
40
|
p: ["style"],
|
package/src/shared/help.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { localeFallbackChain } from "./locale";
|
|
2
2
|
|
|
3
|
+
/** Maximum number of page-specific Help articles shared with the panel. */
|
|
4
|
+
export const HELP_VIEW_LIMIT = 3;
|
|
5
|
+
|
|
3
6
|
export const HELP_KINDS = ["start", "task", "reference", "troubleshoot"] as const;
|
|
4
7
|
export type HelpKind = (typeof HELP_KINDS)[number];
|
|
5
8
|
/** The glossary's access levels; Help names only these, never app-specific permissions. */
|
package/src/shared/redirect.ts
CHANGED
|
@@ -17,6 +17,18 @@ export const normalizeRedirectTo = (value: string | null | undefined): string |
|
|
|
17
17
|
}
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
/** Default landing page when no valid home path is configured. */
|
|
21
|
+
export const DEFAULT_HOME_PATH = "/app/dashboard";
|
|
22
|
+
|
|
23
|
+
/** Resolve a local home path, falling back for root, auth, admin, or invalid targets. */
|
|
24
|
+
export const resolveHomePath = (value: unknown): string => {
|
|
25
|
+
const path = typeof value === "string" ? normalizeRedirectTo(value) : undefined;
|
|
26
|
+
if (!path || path === "/" || path === "/auth" || path.startsWith("/auth/") || path === "/admin" || path.startsWith("/admin/")) {
|
|
27
|
+
return DEFAULT_HOME_PATH;
|
|
28
|
+
}
|
|
29
|
+
return path;
|
|
30
|
+
};
|
|
31
|
+
|
|
20
32
|
/** Extract a safe post-login redirect target from an incoming request URL. */
|
|
21
33
|
export const redirectPathFromRequestUrl = (requestUrl: string): string => {
|
|
22
34
|
const url = new URL(requestUrl);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const STREAM_CURSOR_REGEX = /^s6t\.[A-Za-z0-9_-]+\.\d+$/;
|
|
2
|
+
|
|
3
|
+
export const parseStreamCursor = (cursor: string | null | undefined): { resource: string; seq: number } | null => {
|
|
4
|
+
if (!cursor || !STREAM_CURSOR_REGEX.test(cursor)) return null;
|
|
5
|
+
const [, resource, sequence] = cursor.split(".");
|
|
6
|
+
const seq = Number(sequence);
|
|
7
|
+
if (!resource || !Number.isSafeInteger(seq) || seq < 0) return null;
|
|
8
|
+
return { resource, seq };
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const compareStreamCursor = (left: string, right: string): number => {
|
|
12
|
+
const l = parseStreamCursor(left);
|
|
13
|
+
const r = parseStreamCursor(right);
|
|
14
|
+
if (!l || !r || l.resource !== r.resource) throw new Error(`Invalid stream cursor comparison: "${left}" vs "${right}"`);
|
|
15
|
+
return l.seq - r.seq;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const maxStreamCursor = (a: string | null, b: string | null): string | null => {
|
|
19
|
+
if (!a) return b;
|
|
20
|
+
if (!b) return a;
|
|
21
|
+
return compareStreamCursor(a, b) >= 0 ? a : b;
|
|
22
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Topic } from "@k2b/sync";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { compareStreamCursor } from "./cursor";
|
|
4
|
+
import { SHARED_TEXT_NAME, sharedTextNodeId, toBase64 } from "./events";
|
|
5
|
+
import { minimalTextDiff } from "./text-diff";
|
|
6
|
+
import type {
|
|
7
|
+
SharedTextActor,
|
|
8
|
+
SharedTextContributor,
|
|
9
|
+
SharedTextEditResult,
|
|
10
|
+
SharedTextSnapshot,
|
|
11
|
+
SharedTextStore,
|
|
12
|
+
SharedTextStoredState,
|
|
13
|
+
SharedTextSyncEvent,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
type DocumentLog = {
|
|
17
|
+
topic(): Pick<Topic<SharedTextSyncEvent>, "latestCursor" | "publish">;
|
|
18
|
+
replay(config: {
|
|
19
|
+
docId: string;
|
|
20
|
+
after: string | null;
|
|
21
|
+
until: string;
|
|
22
|
+
doc: Y.Doc;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}): Promise<SharedTextContributor[]>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fallback text becomes the same Yjs items in every copy: an update published
|
|
29
|
+
* against one copy then applies to the next one, which a random client ID
|
|
30
|
+
* would leave pending and silently drop from the saved state.
|
|
31
|
+
*/
|
|
32
|
+
const FALLBACK_CLIENT_ID = 0;
|
|
33
|
+
|
|
34
|
+
const createDoc = (stored: SharedTextStoredState): Y.Doc => {
|
|
35
|
+
const doc = new Y.Doc({ gc: true });
|
|
36
|
+
try {
|
|
37
|
+
if (stored.state) Y.applyUpdate(doc, stored.state, "snapshot");
|
|
38
|
+
else if (stored.text) {
|
|
39
|
+
const clientID = doc.clientID;
|
|
40
|
+
doc.clientID = FALLBACK_CLIENT_ID;
|
|
41
|
+
doc.getText(SHARED_TEXT_NAME).insert(0, stored.text);
|
|
42
|
+
doc.clientID = clientID;
|
|
43
|
+
}
|
|
44
|
+
return doc;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
doc.destroy();
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const bindDocuments = <Saved, Context>({ topic, replay }: DocumentLog, store: SharedTextStore<Saved, Context>) => {
|
|
52
|
+
return {
|
|
53
|
+
async read(docId: string, options: { signal?: AbortSignal } = {}): Promise<SharedTextSnapshot | null> {
|
|
54
|
+
const stored = await store.load(docId, options);
|
|
55
|
+
if (!stored) return null;
|
|
56
|
+
const latest = await topic().latestCursor({ tenantId: docId });
|
|
57
|
+
if (!latest || (stored.cursor && compareStreamCursor(latest, stored.cursor) <= 0))
|
|
58
|
+
return { state: stored.state, text: stored.text, cursor: stored.cursor };
|
|
59
|
+
const doc = createDoc(stored);
|
|
60
|
+
try {
|
|
61
|
+
await replay({ docId, after: stored.cursor, until: latest, doc, signal: options.signal });
|
|
62
|
+
return { state: Y.encodeStateAsUpdate(doc), text: doc.getText(SHARED_TEXT_NAME).toString(), cursor: latest };
|
|
63
|
+
} finally {
|
|
64
|
+
doc.destroy();
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async edit(
|
|
68
|
+
docId: string,
|
|
69
|
+
options: { actor: SharedTextActor | null; context?: Context; signal?: AbortSignal },
|
|
70
|
+
update: (current: { text: string; contributors: SharedTextContributor[]; state(): Uint8Array }) => string | Promise<string>,
|
|
71
|
+
): Promise<SharedTextEditResult<Saved>> {
|
|
72
|
+
const requestedAt = Date.now();
|
|
73
|
+
const stored = await store.load(docId, { signal: options.signal });
|
|
74
|
+
if (!stored) return { status: "notFound" };
|
|
75
|
+
const handle = topic();
|
|
76
|
+
const target = await handle.latestCursor({ tenantId: docId });
|
|
77
|
+
const doc = createDoc(stored);
|
|
78
|
+
try {
|
|
79
|
+
let contributors: SharedTextContributor[] = [];
|
|
80
|
+
try {
|
|
81
|
+
if (target) contributors = await replay({ docId, after: stored.cursor, until: target, doc, signal: options.signal });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// A cancelled request is not a sync failure: it throws like every other abort before publication.
|
|
84
|
+
options.signal?.throwIfAborted();
|
|
85
|
+
return { status: "syncFailed", error };
|
|
86
|
+
}
|
|
87
|
+
options.signal?.throwIfAborted();
|
|
88
|
+
const ytext = doc.getText(SHARED_TEXT_NAME);
|
|
89
|
+
const text = ytext.toString();
|
|
90
|
+
const next = await update({ text, contributors, state: () => Y.encodeStateAsUpdate(doc) });
|
|
91
|
+
if (next === text) return { status: "unchanged", text };
|
|
92
|
+
// A request cancelled while the callback ran must not publish a write.
|
|
93
|
+
options.signal?.throwIfAborted();
|
|
94
|
+
const diff = minimalTextDiff(text, next);
|
|
95
|
+
const beforeEditState = Y.encodeStateVector(doc);
|
|
96
|
+
doc.transact(() => {
|
|
97
|
+
if (diff.deleteCount > 0) ytext.delete(diff.index, diff.deleteCount);
|
|
98
|
+
if (diff.insert.length > 0) ytext.insert(diff.index, diff.insert);
|
|
99
|
+
}, "cli-edit");
|
|
100
|
+
const actor = options.actor;
|
|
101
|
+
const published = await handle.publish({
|
|
102
|
+
tenantId: docId,
|
|
103
|
+
data: {
|
|
104
|
+
kind: "sync",
|
|
105
|
+
payload: toBase64(Y.encodeStateAsUpdate(doc, beforeEditState)),
|
|
106
|
+
originNodeId: sharedTextNodeId,
|
|
107
|
+
originPeerId: null,
|
|
108
|
+
...(actor ? { actor } : {}),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
let persistedDoc: Y.Doc | undefined;
|
|
112
|
+
try {
|
|
113
|
+
persistedDoc = createDoc(stored);
|
|
114
|
+
await replay({ docId, after: stored.cursor, until: published.cursor, doc: persistedDoc, signal: options.signal });
|
|
115
|
+
const persistedText = persistedDoc.getText(SHARED_TEXT_NAME).toString();
|
|
116
|
+
const credited = new Map(contributors.map((contributor) => [`${contributor.kind}:${contributor.id}`, contributor]));
|
|
117
|
+
if (actor) credited.set(`${actor.kind}:${actor.id}`, { ...actor, lastContributedAt: new Date(requestedAt) });
|
|
118
|
+
const saved = await store.save(
|
|
119
|
+
docId,
|
|
120
|
+
{
|
|
121
|
+
state: Y.encodeStateAsUpdate(persistedDoc),
|
|
122
|
+
text: persistedText,
|
|
123
|
+
cursor: published.cursor,
|
|
124
|
+
restoreRevision: stored.restoreRevision,
|
|
125
|
+
requestedAt,
|
|
126
|
+
actor,
|
|
127
|
+
contributors: [...credited.values()],
|
|
128
|
+
},
|
|
129
|
+
options.context,
|
|
130
|
+
);
|
|
131
|
+
return { status: "edited", text: persistedText, cursor: published.cursor, saved };
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return { status: "persistFailed", cursor: published.cursor, error };
|
|
134
|
+
} finally {
|
|
135
|
+
persistedDoc?.destroy();
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
doc.destroy();
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { fromBase64Strict } from "@k2b/stdlib";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import type { SharedTextEvent } from "./types";
|
|
4
|
+
|
|
5
|
+
export const SHARED_TEXT_NAME = "codemirror";
|
|
6
|
+
export const sharedTextNodeId = crypto.randomUUID();
|
|
7
|
+
export const toBase64 = (data: Uint8Array): string => Buffer.from(data).toString("base64");
|
|
8
|
+
export const fromBase64 = fromBase64Strict;
|
|
9
|
+
|
|
10
|
+
/** A retained event cannot be decoded as a Yjs update: retrying never helps, no replay can pass it. */
|
|
11
|
+
export class MalformedSharedTextEventError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly docId: string,
|
|
14
|
+
readonly cursor: string,
|
|
15
|
+
cause: unknown,
|
|
16
|
+
) {
|
|
17
|
+
super(`Malformed sync event for document ${docId} at cursor ${cursor}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
18
|
+
this.name = "MalformedSharedTextEventError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Ingress check: a publish is accepted only when it is base64 of a decodable Yjs update. */
|
|
23
|
+
export const isValidSharedTextUpdate = (payload: string): boolean => {
|
|
24
|
+
try {
|
|
25
|
+
Y.decodeUpdate(fromBase64(payload));
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const applySharedTextEvent = (doc: Y.Doc, event: { cursor: string; data: SharedTextEvent }, docId: string): void => {
|
|
33
|
+
if (event.data.kind !== "sync") return;
|
|
34
|
+
try {
|
|
35
|
+
const update = fromBase64(event.data.payload);
|
|
36
|
+
// Decode completely before mutating the document: malformed bytes must not
|
|
37
|
+
// partially integrate state before recovery skips the invalid event.
|
|
38
|
+
Y.decodeUpdate(update);
|
|
39
|
+
Y.applyUpdate(doc, update, `replay:${event.cursor}`);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new MalformedSharedTextEventError(docId, event.cursor, error);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { compareStreamCursor, maxStreamCursor, parseStreamCursor } from "./cursor";
|
|
2
|
+
export {
|
|
3
|
+
applySharedTextEvent,
|
|
4
|
+
fromBase64,
|
|
5
|
+
isValidSharedTextUpdate,
|
|
6
|
+
MalformedSharedTextEventError,
|
|
7
|
+
SHARED_TEXT_NAME,
|
|
8
|
+
sharedTextNodeId,
|
|
9
|
+
toBase64,
|
|
10
|
+
} from "./events";
|
|
11
|
+
export { sharedText } from "./log";
|
|
12
|
+
export * from "./types";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { TopicConfig } from "@k2b/sync";
|
|
2
|
+
import type * as Y from "yjs";
|
|
3
|
+
import { getProcessSync } from "../_internal/process-sync";
|
|
4
|
+
import { compareStreamCursor } from "./cursor";
|
|
5
|
+
import { bindDocuments } from "./documents";
|
|
6
|
+
import { applySharedTextEvent } from "./events";
|
|
7
|
+
import type { SharedTextContributor, SharedTextStore, SharedTextSyncEvent } from "./types";
|
|
8
|
+
|
|
9
|
+
const define = (topicConfig: Readonly<Pick<TopicConfig, "id" | "retention" | "deadLetterRetention" | "maxPayloadBytes">>) => {
|
|
10
|
+
const topic = () => getProcessSync().topic<SharedTextSyncEvent>(topicConfig);
|
|
11
|
+
const replay = async (config: {
|
|
12
|
+
docId: string;
|
|
13
|
+
after: string | null;
|
|
14
|
+
until: string;
|
|
15
|
+
doc: Y.Doc;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}): Promise<SharedTextContributor[]> => {
|
|
18
|
+
const contributors = new Map<string, SharedTextContributor>();
|
|
19
|
+
const handle = topic();
|
|
20
|
+
const after = config.after ?? handle.cursorAt(0);
|
|
21
|
+
if (compareStreamCursor(after, config.until) >= 0) return [];
|
|
22
|
+
let reachedTarget = false;
|
|
23
|
+
for await (const event of handle.replay({ tenantId: config.docId, after, until: config.until, signal: config.signal })) {
|
|
24
|
+
applySharedTextEvent(config.doc, event, config.docId);
|
|
25
|
+
if (event.data.actor)
|
|
26
|
+
contributors.set(`${event.data.actor.kind}:${event.data.actor.id}`, {
|
|
27
|
+
...event.data.actor,
|
|
28
|
+
lastContributedAt: event.publishedAt,
|
|
29
|
+
});
|
|
30
|
+
reachedTarget = event.cursor === config.until;
|
|
31
|
+
}
|
|
32
|
+
if (!reachedTarget) throw new Error(`Target cursor "${config.until}" was not reached during replay`);
|
|
33
|
+
return [...contributors.values()];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
id: topicConfig.id,
|
|
38
|
+
topicConfig,
|
|
39
|
+
topic,
|
|
40
|
+
replay,
|
|
41
|
+
withStore<Saved, Context>(store: SharedTextStore<Saved, Context>) {
|
|
42
|
+
return bindDocuments({ topic, replay }, store);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Server-only document logs. Applications authorize callers before read/edit. */
|
|
48
|
+
export const sharedText = { define };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const splitsSurrogatePair = (text: string, index: number): boolean => {
|
|
2
|
+
const before = text.charCodeAt(index - 1);
|
|
3
|
+
const after = text.charCodeAt(index);
|
|
4
|
+
return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** One replacement retaining the shared prefix and suffix at Unicode boundaries. */
|
|
8
|
+
export const minimalTextDiff = (before: string, after: string): { index: number; deleteCount: number; insert: string } => {
|
|
9
|
+
let index = 0;
|
|
10
|
+
while (index < before.length && index < after.length && before[index] === after[index]) index++;
|
|
11
|
+
if (splitsSurrogatePair(before, index) || splitsSurrogatePair(after, index)) index--;
|
|
12
|
+
|
|
13
|
+
let suffix = 0;
|
|
14
|
+
while (
|
|
15
|
+
suffix < before.length - index &&
|
|
16
|
+
suffix < after.length - index &&
|
|
17
|
+
before[before.length - suffix - 1] === after[after.length - suffix - 1]
|
|
18
|
+
)
|
|
19
|
+
suffix++;
|
|
20
|
+
if (splitsSurrogatePair(before, before.length - suffix) || splitsSurrogatePair(after, after.length - suffix)) suffix--;
|
|
21
|
+
|
|
22
|
+
return { index, deleteCount: before.length - index - suffix, insert: after.slice(index, after.length - suffix) };
|
|
23
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type SharedTextActor = { kind: "user" | "service_account"; id: string };
|
|
2
|
+
|
|
3
|
+
/** Actor is optional so events retained from older deployments remain readable. */
|
|
4
|
+
export type SharedTextEvent = {
|
|
5
|
+
kind: "sync" | "awareness";
|
|
6
|
+
payload: string;
|
|
7
|
+
originNodeId: string;
|
|
8
|
+
originPeerId: string | null;
|
|
9
|
+
actor?: SharedTextActor;
|
|
10
|
+
};
|
|
11
|
+
export type SharedTextSyncEvent = SharedTextEvent & { kind: "sync" };
|
|
12
|
+
export type SharedTextContributor = SharedTextActor & { lastContributedAt: Date };
|
|
13
|
+
|
|
14
|
+
/** The store anchors legacy cursors to this log before returning a snapshot. */
|
|
15
|
+
export type SharedTextStoredState = {
|
|
16
|
+
state: Uint8Array | null;
|
|
17
|
+
cursor: string | null;
|
|
18
|
+
restoreRevision: string;
|
|
19
|
+
text: string | null;
|
|
20
|
+
};
|
|
21
|
+
export type SharedTextSaveInput = {
|
|
22
|
+
state: Uint8Array;
|
|
23
|
+
text: string;
|
|
24
|
+
cursor: string;
|
|
25
|
+
restoreRevision: string;
|
|
26
|
+
requestedAt: number;
|
|
27
|
+
actor: SharedTextActor | null;
|
|
28
|
+
contributors: SharedTextContributor[];
|
|
29
|
+
};
|
|
30
|
+
export type SharedTextSnapshot = { state: Uint8Array | null; text: string | null; cursor: string | null };
|
|
31
|
+
export type SharedTextEditResult<Saved> =
|
|
32
|
+
| { status: "notFound" }
|
|
33
|
+
| { status: "unchanged"; text: string }
|
|
34
|
+
| { status: "syncFailed"; error: unknown }
|
|
35
|
+
| { status: "persistFailed"; cursor: string; error: unknown }
|
|
36
|
+
| { status: "edited"; text: string; cursor: string; saved: Saved };
|
|
37
|
+
|
|
38
|
+
export type SharedTextStore<Saved, Context> = {
|
|
39
|
+
load(docId: string, options: { signal?: AbortSignal }): Promise<SharedTextStoredState | null>;
|
|
40
|
+
save(docId: string, input: SharedTextSaveInput, context: Context | undefined): Promise<Saved>;
|
|
41
|
+
};
|
package/src/ssr/AdminLayout.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AppWorkspace } from "@k2b/ui";
|
|
2
2
|
import type { JSX } from "solid-js/jsx-runtime";
|
|
3
|
+
import type { PanelConsentSnapshot } from "../contracts/panel-context";
|
|
3
4
|
import type { RailSnapshot } from "../contracts/rail-preferences";
|
|
4
5
|
import { getLocale } from "../server/locale";
|
|
5
6
|
import type { LayoutAnnouncementsState } from "../server/middleware/settings";
|
|
@@ -10,6 +11,7 @@ import { getLocalizedRuntimeContext, type RuntimeContext } from "./runtime";
|
|
|
10
11
|
|
|
11
12
|
type Breadcrumb = { title: string; href?: string };
|
|
12
13
|
type AdminLayoutContext = {
|
|
14
|
+
get(key: "panelConsent"): PanelConsentSnapshot | undefined;
|
|
13
15
|
get(key: "railPreferences"): RailSnapshot | undefined;
|
|
14
16
|
get(key: "user"): any;
|
|
15
17
|
get(key: "page"): any;
|
|
@@ -143,7 +143,7 @@ export const AppLaunchpadPanel = (
|
|
|
143
143
|
<i class={shortcut.iconClass} aria-hidden="true" />
|
|
144
144
|
<AppBadge endpoint={shortcut.badge} appId={shortcut.appId} />
|
|
145
145
|
</span>
|
|
146
|
-
<span class="max-w-full truncate text-[11px] font-medium text-primary dark:text-white">
|
|
146
|
+
<span class="max-w-full k2b-truncate-fade text-[11px] font-medium text-primary dark:text-white">
|
|
147
147
|
{shortcut.label}
|
|
148
148
|
<Show when={badge()}>
|
|
149
149
|
<span class="sr-only">, {badge()}</span>
|
|
@@ -173,7 +173,7 @@ export const AppLaunchpadPanel = (
|
|
|
173
173
|
<i class={app.iconClass} />
|
|
174
174
|
<AppBadge endpoint={app.badge} appId={app.id} />
|
|
175
175
|
</span>
|
|
176
|
-
<span class="max-w-full truncate text-[11px] font-medium text-primary sm:text-xs dark:text-white">
|
|
176
|
+
<span class="max-w-full k2b-truncate-fade text-[11px] font-medium text-primary sm:text-xs dark:text-white">
|
|
177
177
|
{app.label}
|
|
178
178
|
<Show when={badge()}>
|
|
179
179
|
<span class="sr-only">, {badge()}</span>
|
|
@@ -1,30 +1,85 @@
|
|
|
1
|
-
import { useLocale } from "@k2b/ui";
|
|
2
|
-
import { createEffect } from "solid-js";
|
|
1
|
+
import { TabBar, useLocale } from "@k2b/ui";
|
|
2
|
+
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js";
|
|
3
|
+
import { isServer, Portal } from "solid-js/web";
|
|
3
4
|
import { AppLaunchpadButton, setAppLaunchpadContext } from "./app-launchpad";
|
|
4
5
|
import GlobalSearchTrigger from "./GlobalSearchTrigger";
|
|
5
6
|
import LayoutBreadcrumbs from "./LayoutBreadcrumbs";
|
|
6
7
|
import LayoutHelpTrigger from "./LayoutHelpTrigger";
|
|
7
8
|
import LayoutPreferences from "./LayoutPreferences";
|
|
8
9
|
import type { LayoutBreadcrumb } from "./layout-runtime";
|
|
10
|
+
import PanelTrigger from "./PanelTrigger";
|
|
9
11
|
import ProfilePreferences from "./ProfilePreferences";
|
|
10
12
|
import { platformMessages } from "./platform-messages";
|
|
11
13
|
import RailApps from "./RailApps";
|
|
14
|
+
import { RAIL_PREFERENCES_EVENT, readRailContext } from "./rail-context";
|
|
15
|
+
import { phoneBarItems, projectRailNavigation } from "./rail-navigation";
|
|
12
16
|
import ShellFocusToggle from "./ShellFocusToggle";
|
|
13
17
|
import { useShellData } from "./shell-data";
|
|
14
18
|
|
|
15
19
|
type CloudShellProps =
|
|
16
|
-
| { slot: "rail"; currentPath: string }
|
|
17
|
-
| { slot: "header"; authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[] };
|
|
20
|
+
| { slot: "rail"; currentPath: string; panelOpen?: boolean }
|
|
21
|
+
| { slot: "header"; authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[]; panelOpen?: boolean };
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* The interactive part of the shell chrome. `LayoutRail` and `LayoutHeader`
|
|
21
25
|
* own the frame and the plain links around it as server markup.
|
|
22
26
|
*/
|
|
23
27
|
export default function CloudShell(props: CloudShellProps) {
|
|
24
|
-
return props.slot === "rail" ?
|
|
28
|
+
return props.slot === "rail" ? (
|
|
29
|
+
<RailNavigation currentPath={props.currentPath} panelOpen={props.panelOpen} />
|
|
30
|
+
) : (
|
|
31
|
+
<HeaderControls {...props} />
|
|
32
|
+
);
|
|
25
33
|
}
|
|
26
34
|
|
|
27
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The phone bar's live copy, mounted with the rail's island so the shell adds no island instance. It renders nothing
|
|
37
|
+
* while the server's bar is current, so mounting moves and refocuses nothing. When the saved rail preferences or the
|
|
38
|
+
* URL (a fragment, Back and Forward, an in-page navigation) change what the bar shows, it renders the new bar into the
|
|
39
|
+
* bar's row, and CSS hides the server's.
|
|
40
|
+
*/
|
|
41
|
+
function BarNavigation(props: { currentPath: string }) {
|
|
42
|
+
if (isServer) return null;
|
|
43
|
+
const data = useShellData();
|
|
44
|
+
const locale = useLocale();
|
|
45
|
+
const t = () => platformMessages.resolve([locale()]).t;
|
|
46
|
+
const initial = data?.rail;
|
|
47
|
+
if (!data || !initial) return null;
|
|
48
|
+
const [settings, setSettings] = createSignal(initial);
|
|
49
|
+
const [currentPath, setCurrentPath] = createSignal(props.currentPath);
|
|
50
|
+
const itemsFor = (rail: typeof initial, path: string) =>
|
|
51
|
+
phoneBarItems(projectRailNavigation(data.apps, rail, locale()), path, { label: t().home, path: data.home ?? "/" });
|
|
52
|
+
const served = JSON.stringify(itemsFor(initial, props.currentPath));
|
|
53
|
+
const items = createMemo(() => itemsFor(settings(), currentPath()));
|
|
54
|
+
onMount(() => {
|
|
55
|
+
const sync = () => setSettings(readRailContext()?.settings ?? initial);
|
|
56
|
+
const syncLocation = () => setCurrentPath(`${window.location.pathname}${window.location.search}${window.location.hash}`);
|
|
57
|
+
syncLocation();
|
|
58
|
+
window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
|
|
59
|
+
window.addEventListener("popstate", syncLocation);
|
|
60
|
+
window.addEventListener("hashchange", syncLocation);
|
|
61
|
+
// In-page navigation (`@k2b/ssr/nav`) commits with pushState or replaceState, which only the Navigation API
|
|
62
|
+
// reports; where a browser lacks it, the bar follows at the next page load.
|
|
63
|
+
window.navigation?.addEventListener("currententrychange", syncLocation);
|
|
64
|
+
onCleanup(() => {
|
|
65
|
+
window.removeEventListener(RAIL_PREFERENCES_EVENT, sync);
|
|
66
|
+
window.removeEventListener("popstate", syncLocation);
|
|
67
|
+
window.removeEventListener("hashchange", syncLocation);
|
|
68
|
+
window.navigation?.removeEventListener("currententrychange", syncLocation);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
const row = document.querySelector<HTMLElement>(".cloud-tab-bar__row");
|
|
72
|
+
if (!row) return null;
|
|
73
|
+
return (
|
|
74
|
+
<Show when={JSON.stringify(items()) !== served}>
|
|
75
|
+
<Portal mount={row}>
|
|
76
|
+
<TabBar variant="floating" class="cloud-tab-bar__live" label={t().apps} items={items()} />
|
|
77
|
+
</Portal>
|
|
78
|
+
</Show>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function RailNavigation(props: { currentPath: string; panelOpen?: boolean }) {
|
|
28
83
|
const data = useShellData();
|
|
29
84
|
const locale = useLocale();
|
|
30
85
|
const t = () => platformMessages.resolve([locale()]).t;
|
|
@@ -36,9 +91,15 @@ function RailNavigation(props: { currentPath: string }) {
|
|
|
36
91
|
<ShellFocusToggle variant="rail" />
|
|
37
92
|
<RailApps apps={data.apps} settings={data.rail} currentPath={props.currentPath} />
|
|
38
93
|
<AppLaunchpadButton profile={profile} apps={data.apps} legalLinks={data.legalLinks} variant="rail" label={t().openApps} />
|
|
94
|
+
<BarNavigation currentPath={props.currentPath} />
|
|
39
95
|
<div class="mt-auto shrink-0 flex flex-col items-center gap-1">
|
|
96
|
+
{(data.panel?.providers.length ?? 0) > 1 && <PanelTrigger variant="rail" open={props.panelOpen} />}
|
|
40
97
|
<GlobalSearchTrigger variant="rail" />
|
|
41
|
-
<LayoutHelpTrigger
|
|
98
|
+
<LayoutHelpTrigger
|
|
99
|
+
variant="rail"
|
|
100
|
+
panel={data.panel?.providers.some((provider) => provider.id === "core")}
|
|
101
|
+
helpOpen={!!props.panelOpen && data.panel?.provider === "core"}
|
|
102
|
+
/>
|
|
42
103
|
<ProfilePreferences
|
|
43
104
|
appHref={data.profile.appHref}
|
|
44
105
|
avatarSrc={data.profile.avatarSrc}
|
|
@@ -51,7 +112,13 @@ function RailNavigation(props: { currentPath: string }) {
|
|
|
51
112
|
}
|
|
52
113
|
|
|
53
114
|
/** The header's title and controls, between the server-rendered Home and Sign in. */
|
|
54
|
-
function HeaderControls(props: {
|
|
115
|
+
function HeaderControls(props: {
|
|
116
|
+
authenticated: boolean;
|
|
117
|
+
title?: string;
|
|
118
|
+
launchpad: boolean;
|
|
119
|
+
breadcrumbs?: LayoutBreadcrumb[];
|
|
120
|
+
panelOpen?: boolean;
|
|
121
|
+
}) {
|
|
55
122
|
const data = useShellData();
|
|
56
123
|
const locale = useLocale();
|
|
57
124
|
const t = () => platformMessages.resolve([locale()]).t;
|
|
@@ -65,13 +132,31 @@ function HeaderControls(props: { authenticated: boolean; title?: string; launchp
|
|
|
65
132
|
</div>
|
|
66
133
|
)}
|
|
67
134
|
<div class="flex min-w-0 items-center lg:hidden">
|
|
68
|
-
<span class="truncate font-semibold">{props.title}</span>
|
|
135
|
+
<span class="layout-header-title truncate font-semibold">{props.title}</span>
|
|
136
|
+
{props.launchpad && (
|
|
137
|
+
<AppLaunchpadButton
|
|
138
|
+
profile={
|
|
139
|
+
data.profile
|
|
140
|
+
? { name: data.profile.name, theme: data.theme, ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}) }
|
|
141
|
+
: undefined
|
|
142
|
+
}
|
|
143
|
+
apps={data.apps}
|
|
144
|
+
legalLinks={data.legalLinks}
|
|
145
|
+
variant="title"
|
|
146
|
+
label={props.title}
|
|
147
|
+
/>
|
|
148
|
+
)}
|
|
69
149
|
</div>
|
|
70
150
|
</div>
|
|
71
151
|
<div class="flex shrink-0 items-center gap-1">
|
|
72
152
|
<div class="flex items-center gap-1 lg:hidden">
|
|
73
|
-
<
|
|
153
|
+
{(data.panel?.providers.length ?? 0) > 1 && <PanelTrigger variant="header" open={props.panelOpen} />}
|
|
74
154
|
{props.authenticated && <GlobalSearchTrigger variant="header" />}
|
|
155
|
+
<LayoutHelpTrigger
|
|
156
|
+
variant="header"
|
|
157
|
+
panel={data.panel?.providers.some((provider) => provider.id === "core")}
|
|
158
|
+
helpOpen={!!props.panelOpen && data.panel?.provider === "core"}
|
|
159
|
+
/>
|
|
75
160
|
{props.launchpad && (
|
|
76
161
|
<AppLaunchpadButton
|
|
77
162
|
profile={
|