@k2b/cloud 0.31.0 → 0.32.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.
Files changed (119) hide show
  1. package/package.json +6 -4
  2. package/src/_internal/define-app.ts +3 -1
  3. package/src/ai/app-skills.ts +1 -7
  4. package/src/ai/audio-tool.ts +2 -1
  5. package/src/ai/browser.ts +109 -0
  6. package/src/ai/capabilities.ts +6 -2
  7. package/src/ai/chart-block.ts +431 -81
  8. package/src/ai/chat/action-sentences.ts +13 -7
  9. package/src/ai/chat/blocks.tsx +11 -7
  10. package/src/ai/chat/builtin-tools.tsx +9 -7
  11. package/src/ai/chat/chart-block.tsx +218 -44
  12. package/src/ai/chat/explorer-block.tsx +276 -0
  13. package/src/ai/chat/file-tools.tsx +40 -1
  14. package/src/ai/chat/message-actions.tsx +6 -0
  15. package/src/ai/chat/message-utils.ts +7 -0
  16. package/src/ai/chat/messages.ts +16 -0
  17. package/src/ai/chat/presentation.tsx +13 -3
  18. package/src/ai/chat/reference-text.tsx +34 -0
  19. package/src/ai/chat/tool-groups.ts +1 -0
  20. package/src/ai/chat/tool-names.ts +14 -0
  21. package/src/ai/chat/turn-view.tsx +17 -7
  22. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  23. package/src/ai/chat/view-block.tsx +417 -0
  24. package/src/ai/chat/visual-tools.tsx +32 -53
  25. package/src/ai/client-tool-results.ts +9 -0
  26. package/src/ai/default-tool-contracts.ts +334 -91
  27. package/src/ai/default-tools.ts +87 -11
  28. package/src/ai/executor.ts +173 -31
  29. package/src/ai/file-error.ts +2 -0
  30. package/src/ai/file-tools.ts +27 -7
  31. package/src/ai/files-store.ts +60 -0
  32. package/src/ai/html-pdf-tool.ts +3 -2
  33. package/src/ai/http.ts +37 -12
  34. package/src/ai/index.ts +90 -0
  35. package/src/ai/local-tool-contracts.ts +244 -0
  36. package/src/ai/malformed-tool-call-repair.ts +144 -0
  37. package/src/ai/markdown-pdf-tool.ts +2 -1
  38. package/src/ai/migrate.ts +7 -0
  39. package/src/ai/openai-api-messages.ts +53 -0
  40. package/src/ai/openai-api-slots.ts +40 -0
  41. package/src/ai/openai-api.ts +383 -0
  42. package/src/ai/protocol.ts +6 -2
  43. package/src/ai/provider.ts +16 -4
  44. package/src/ai/request-context.ts +33 -0
  45. package/src/ai/routes.ts +96 -57
  46. package/src/ai/runtime.ts +48 -12
  47. package/src/ai/settings.ts +5 -0
  48. package/src/ai/skill-catalog.ts +20 -5
  49. package/src/ai/skill-format.ts +17 -7
  50. package/src/ai/skill-tool.ts +15 -5
  51. package/src/ai/skills-routes.ts +20 -6
  52. package/src/ai/skills.ts +26 -7
  53. package/src/ai/store.ts +25 -0
  54. package/src/ai/system-prompt.ts +69 -10
  55. package/src/ai/timeline.ts +3 -1
  56. package/src/ai/tools.ts +3 -0
  57. package/src/ai/types.ts +9 -0
  58. package/src/ai/view-block.ts +941 -0
  59. package/src/ai/vision-tool.ts +2 -1
  60. package/src/api/admin-identity.ts +6 -1
  61. package/src/api/app-approval-actions.ts +36 -0
  62. package/src/api/app-approval.ts +10 -2
  63. package/src/api/approvals-loader.ts +15 -0
  64. package/src/api/approvals.ts +120 -0
  65. package/src/api/index.ts +3 -0
  66. package/src/api/me.ts +12 -3
  67. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  68. package/src/approvals/Approvals.tsx +621 -0
  69. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  70. package/src/approvals/messages.ts +95 -0
  71. package/src/approvals/ui.ts +8 -0
  72. package/src/browser/app-approval.ts +32 -0
  73. package/src/browser/choose-files.tsx +26 -13
  74. package/src/browser/commands.ts +13 -4
  75. package/src/browser/file-provider-error.ts +11 -0
  76. package/src/browser/file-providers.ts +3 -12
  77. package/src/browser/files.tsx +1 -2
  78. package/src/browser/live-connection.ts +2 -3
  79. package/src/browser/provider-list.ts +2 -1
  80. package/src/browser/save-files.tsx +13 -2
  81. package/src/cli/account.ts +21 -2
  82. package/src/cli/commands.ts +4 -2
  83. package/src/contracts/app-approval.ts +120 -0
  84. package/src/contracts/approval-paths.ts +4 -0
  85. package/src/contracts/approvals.ts +81 -0
  86. package/src/contracts/command-path.ts +17 -0
  87. package/src/contracts/commands.ts +5 -15
  88. package/src/contracts/shared.ts +5 -0
  89. package/src/events/live-protocol.ts +2 -1
  90. package/src/events/live-server-message.ts +40 -0
  91. package/src/events/testing.ts +88 -0
  92. package/src/server/index.ts +2 -0
  93. package/src/server/middleware/auth.ts +11 -1
  94. package/src/server/request-timeout.ts +22 -0
  95. package/src/server/services/access.ts +4 -3
  96. package/src/services/app-approval.ts +103 -15
  97. package/src/services/approvals/cursor.ts +15 -0
  98. package/src/services/approvals/device.ts +112 -0
  99. package/src/services/approvals/index.ts +27 -11
  100. package/src/services/approvals/list.ts +120 -0
  101. package/src/services/approvals/migrate.ts +9 -1
  102. package/src/services/identity/models-only-auth.ts +25 -0
  103. package/src/services/notifications/email-frame.ts +16 -3
  104. package/src/services/service-account-credentials.ts +22 -10
  105. package/src/services/settings/core-settings.ts +27 -0
  106. package/src/shared/ai-platform-prompt.ts +26 -1
  107. package/src/shared/email-html.ts +4 -0
  108. package/src/shared/index.ts +6 -1
  109. package/src/shared/markdown/extensions/code.ts +14 -0
  110. package/src/shared/markdown/index.ts +4 -4
  111. package/src/ssr/AppBadge.tsx +4 -4
  112. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  113. package/src/ssr/RailApps.island.tsx +2 -2
  114. package/src/ssr/app-badges.ts +50 -6
  115. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  116. package/src/ssr/rail-navigation.ts +5 -3
  117. package/src/ssr/runtime.ts +6 -1
  118. package/src/styles/approvals.css +390 -0
  119. package/src/styles/global.css +5 -2
@@ -2,6 +2,7 @@ import sanitizeHtml from "sanitize-html";
2
2
 
3
3
  export const EMAIL_HTML_TAGS = [
4
4
  "a",
5
+ "blockquote",
5
6
  "br",
6
7
  "code",
7
8
  "div",
@@ -12,6 +13,7 @@ export const EMAIL_HTML_TAGS = [
12
13
  "li",
13
14
  "ol",
14
15
  "p",
16
+ "pre",
15
17
  "span",
16
18
  "strong",
17
19
  "table",
@@ -26,6 +28,7 @@ export const EMAIL_HTML_TAGS = [
26
28
 
27
29
  export const EMAIL_HTML_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
28
30
  a: ["href", "target", "style"],
31
+ blockquote: ["style"],
29
32
  div: ["style"],
30
33
  h1: ["style"],
31
34
  h2: ["style"],
@@ -33,6 +36,7 @@ export const EMAIL_HTML_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
33
36
  li: ["style"],
34
37
  ol: ["style"],
35
38
  p: ["style"],
39
+ pre: ["style"],
36
40
  span: ["style"],
37
41
  table: ["style", "cellpadding", "cellspacing", "width", "align", "border"],
38
42
  tbody: ["style"],
@@ -13,7 +13,12 @@ export * from "./account-display";
13
13
  export * from "./account-session";
14
14
  export * from "./ai-costs";
15
15
  export type { AiPromptContextInput, AiToolPromptHint } from "./ai-platform-prompt";
16
- export { AI_PLATFORM_PROMPT_TEMPLATE, aiPromptContext, renderAiPlatformPrompt } from "./ai-platform-prompt";
16
+ export {
17
+ AI_LOCAL_CLIENT_DEFAULT_INSTRUCTIONS,
18
+ AI_PLATFORM_PROMPT_TEMPLATE,
19
+ aiPromptContext,
20
+ renderAiPlatformPrompt,
21
+ } from "./ai-platform-prompt";
17
22
  export type { AiChatQuotaSnapshot } from "./ai-quotas";
18
23
  export * from "./ai-quotas";
19
24
  export {
@@ -55,6 +55,20 @@ const renderCode = (source: string, language?: string): string => {
55
55
  return highlighter ? highlighter(source) : escapeHtml(source);
56
56
  };
57
57
 
58
+ /**
59
+ * The label and diagram container need Cloud's styles and scripts; highlight spans only add to the email
60
+ * element budget. A bare `pre` lets the email design style blocks with `pre` and reserve `code` for inline code.
61
+ */
62
+ export function plainCodeExtension(): MarkedExtension {
63
+ return {
64
+ renderer: {
65
+ code(token: Tokens.Code): string {
66
+ return `<pre>${escapeHtml(token.text)}</pre>\n`;
67
+ },
68
+ },
69
+ };
70
+ }
71
+
58
72
  export function codeExtension(): MarkedExtension {
59
73
  return {
60
74
  renderer: {
@@ -9,7 +9,7 @@ import { markdownInfoBlocks } from "@k2b/ui";
9
9
  import { Marked, type MarkedExtension } from "marked";
10
10
  import sanitizeHtml from "sanitize-html";
11
11
  import { markdownClient } from "./client";
12
- import { codeExtension } from "./extensions/code";
12
+ import { codeExtension, plainCodeExtension } from "./extensions/code";
13
13
  import { guidedHelpExtension } from "./extensions/guided-help";
14
14
  import { imagesExtension } from "./extensions/images";
15
15
  import { katexExtension } from "./extensions/katex";
@@ -36,12 +36,12 @@ const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: Ma
36
36
  marked.use(infoBlocks);
37
37
  marked.use(taskListExtension());
38
38
  marked.use(tablesExtension());
39
- // Plain links keep marked's own renderer: an anchor around the link text.
39
+ // Plain output keeps marked's own link renderer and renders code blocks as a bare `pre`.
40
40
  // Cloud links are calm reference pills and quiet web links from `@k2b/ui`.
41
41
  if (links === "widget") marked.use(linksExtension({ internalTarget: profile === "help" ? "_self" : "_blank", locale }));
42
42
  marked.use(imagesExtension());
43
43
  marked.use(katexExtension());
44
- marked.use(codeExtension());
44
+ marked.use(links === "widget" ? codeExtension() : plainCodeExtension());
45
45
  // Inline-style decorators come last so they run after structural tokenizers.
46
46
  marked.use(markExtension());
47
47
  marked.use(subSupExtension());
@@ -67,7 +67,7 @@ const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale
67
67
  const marked = markedFor("content");
68
68
 
69
69
  export type MarkdownRenderOptions = {
70
- /** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. The default renders references to Cloud content as calm pills and web links as quiet text links. */
70
+ /** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. It also renders a code block, fenced or indented and including a Mermaid fence, as its source in a `pre` without the language label. The default renders references to Cloud content as calm pills and web links as quiet text links. */
71
71
  links?: LinkStyle;
72
72
  /** Request locale for the screen-reader names of untitled info blocks. Defaults to English on the server and the page language in the browser. */
73
73
  locale?: string;
@@ -4,10 +4,10 @@ import { appBadgeCount } from "./app-badges";
4
4
  import { railMessages } from "./rail-messages";
5
5
 
6
6
  /** What a screen reader hears after the app name; empty without a count. Says no more than the "99+" shown. */
7
- export const useAppBadgeDescription = (endpoint: () => string | undefined) => {
7
+ export const useAppBadgeDescription = (input: () => { endpoint?: string; appId?: string }) => {
8
8
  const locale = useLocale();
9
9
  return () => {
10
- const count = appBadgeCount(endpoint());
10
+ const count = appBadgeCount(input());
11
11
  const t = railMessages.resolve([locale()]).t;
12
12
  return count > 99 ? t.badgeMany : count > 0 ? t.badge({ count }) : "";
13
13
  };
@@ -18,8 +18,8 @@ export const useAppBadgeDescription = (endpoint: () => string | undefined) => {
18
18
  * takes layout space, so appearing, growing to "99+", or clearing moves
19
19
  * nothing. Decorative: the link carries the description.
20
20
  */
21
- export function AppBadge(props: { endpoint?: string }) {
22
- const count = () => appBadgeCount(props.endpoint);
21
+ export function AppBadge(props: { endpoint?: string; appId?: string }) {
22
+ const count = () => appBadgeCount(props);
23
23
  return (
24
24
  <Show when={count() > 0}>
25
25
  <span class="cloud-app-badge" aria-hidden="true">
@@ -132,7 +132,7 @@ export const AppLaunchpadPanel = (
132
132
  <div class="flex flex-wrap justify-center gap-3">
133
133
  <For each={shortcuts()}>
134
134
  {(shortcut) => {
135
- const badge = useAppBadgeDescription(() => shortcut.badge);
135
+ const badge = useAppBadgeDescription(() => ({ endpoint: shortcut.badge, appId: shortcut.appId }));
136
136
  return (
137
137
  <a
138
138
  onClick={follow}
@@ -141,7 +141,7 @@ export const AppLaunchpadPanel = (
141
141
  >
142
142
  <span class="relative grid h-9 w-9 place-items-center rounded-xl bg-[var(--ui-hover)] text-lg transition-colors group-hover:bg-[var(--ui-active)]">
143
143
  <i class={shortcut.iconClass} aria-hidden="true" />
144
- <AppBadge endpoint={shortcut.badge} />
144
+ <AppBadge endpoint={shortcut.badge} appId={shortcut.appId} />
145
145
  </span>
146
146
  <span class="max-w-full truncate text-[11px] font-medium text-primary dark:text-white">
147
147
  {shortcut.label}
@@ -159,7 +159,7 @@ export const AppLaunchpadPanel = (
159
159
  <div class="launchpad-apps-grid flex flex-wrap justify-center gap-x-4 gap-y-4 sm:gap-x-7 sm:gap-y-6">
160
160
  <For each={apps()}>
161
161
  {(app) => {
162
- const badge = useAppBadgeDescription(() => app.badge);
162
+ const badge = useAppBadgeDescription(() => ({ endpoint: app.badge, appId: app.id }));
163
163
  return (
164
164
  <a
165
165
  onClick={follow}
@@ -171,7 +171,7 @@ export const AppLaunchpadPanel = (
171
171
  style={appIconStyle(app)}
172
172
  >
173
173
  <i class={app.iconClass} />
174
- <AppBadge endpoint={app.badge} />
174
+ <AppBadge endpoint={app.badge} appId={app.id} />
175
175
  </span>
176
176
  <span class="max-w-full truncate text-[11px] font-medium text-primary sm:text-xs dark:text-white">
177
177
  {app.label}
@@ -10,7 +10,7 @@ import { projectRailNavigation, type RailApp, type RailLink, railLinkActive } fr
10
10
  function RailIcon(props: { link: RailLink; currentPath: string }) {
11
11
  let target: HTMLAnchorElement | undefined;
12
12
  const active = () => railLinkActive(props.link, props.currentPath);
13
- const badge = useAppBadgeDescription(() => props.link.badge);
13
+ const badge = useAppBadgeDescription(() => ({ endpoint: props.link.badge, appId: props.link.appId }));
14
14
  return (
15
15
  <>
16
16
  <a
@@ -23,7 +23,7 @@ function RailIcon(props: { link: RailLink; currentPath: string }) {
23
23
  >
24
24
  <i class={`${props.link.iconClass} text-base`} aria-hidden="true" />
25
25
  <span class="sr-only">{props.link.label}</span>
26
- <AppBadge endpoint={props.link.badge} />
26
+ <AppBadge endpoint={props.link.badge} appId={props.link.appId} />
27
27
  </a>
28
28
  <Tooltip target={() => target} content={props.link.label} placement="right" delay={0} />
29
29
  </>
@@ -1,5 +1,7 @@
1
1
  import { createSignal } from "solid-js";
2
2
  import { APP_BADGES_REFRESH_EVENT } from "../browser/app-badges";
3
+ import { APPROVAL_COUNTS_PATH } from "../contracts/approval-paths";
4
+ import type { ApprovalCounts } from "../contracts/approvals";
3
5
 
4
6
  /**
5
7
  * Shortest time between two scheduled reads in one tab, and the longest one
@@ -13,8 +15,20 @@ export const APP_BADGE_INTERVAL_MS = 60_000;
13
15
  // count. Only the browser ever writes it; server renders always see no badge.
14
16
  const [counts, setCounts] = createSignal<ReadonlyMap<string, number>>(new Map());
15
17
 
16
- /** The count for a declared `nav.badge` endpoint; 0 until the browser has read it. */
17
- export const appBadgeCount = (endpoint: string | undefined): number => (endpoint ? (counts().get(endpoint) ?? 0) : 0);
18
+ const [approvalCounts, setApprovalCounts] = createSignal<ApprovalCounts | null>(null);
19
+
20
+ /** Retain an SSR count until the first browser read; a failed read still resolves to zero. */
21
+ export const liveApprovalCount = (appId?: string): number | null => {
22
+ const current = approvalCounts();
23
+ if (!current) return null;
24
+ if (appId === undefined) return current.total;
25
+ return Object.hasOwn(current.apps, appId) ? (current.apps[appId] ?? 0) : 0;
26
+ };
27
+ export const approvalCount = (appId?: string): number => liveApprovalCount(appId) ?? 0;
28
+
29
+ /** Endpoint and approval counts for one app; 0 until the browser has read them. */
30
+ export const appBadgeCount = (input: { endpoint?: string; appId?: string }): number =>
31
+ (input.endpoint ? (counts().get(input.endpoint) ?? 0) : 0) + (input.appId ? approvalCount(input.appId) : 0);
18
32
 
19
33
  /** A positive safe integer from an `AppNavBadge` body, else 0. */
20
34
  export const parseAppBadge = (body: unknown): number => {
@@ -33,6 +47,33 @@ const readBadge = async (endpoint: string, signal: AbortSignal): Promise<number>
33
47
  }
34
48
  };
35
49
 
50
+ const isCount = (value: unknown, minimum: number): value is number =>
51
+ typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
52
+
53
+ /** What `ApprovalCountsSchema` accepts from a JSON body, without loading validators into the shell; a test compares both. */
54
+ export const parseApprovalCounts = (body: unknown): ApprovalCounts | null => {
55
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
56
+ if (Object.keys(body).length !== 2 || !("total" in body) || !("apps" in body)) return null;
57
+ const { total, apps } = body;
58
+ if (!isCount(total, 0) || typeof apps !== "object" || apps === null || Array.isArray(apps)) return null;
59
+ // Like Zod's record, ignore a `__proto__` key that JSON.parse keeps as an own property.
60
+ const entries = Object.entries(apps).filter(([app]) => app !== "__proto__");
61
+ return entries.every(([, count]) => isCount(count, 1)) ? { total, apps: Object.fromEntries(entries) } : null;
62
+ };
63
+
64
+ const readApprovalCounts = async (signal: AbortSignal): Promise<ApprovalCounts> => {
65
+ try {
66
+ const response = await fetch(APPROVAL_COUNTS_PATH, { credentials: "same-origin", headers: { accept: "application/json" }, signal });
67
+ if (response.status === 200) {
68
+ const parsed = parseApprovalCounts(await response.json());
69
+ if (parsed) return parsed;
70
+ }
71
+ } catch {
72
+ // A failing read clears approval counts just like an app badge.
73
+ }
74
+ return { total: 0, apps: {} };
75
+ };
76
+
36
77
  let watching = false;
37
78
 
38
79
  /**
@@ -45,8 +86,8 @@ let watching = false;
45
86
  * read still running after the interval gives up. Returns a disposer.
46
87
  */
47
88
  export const watchAppBadges = (endpoints: readonly string[]): (() => void) => {
48
- const unique = [...new Set(endpoints)];
49
- if (typeof window === "undefined" || unique.length === 0 || watching) return () => {};
89
+ const unique = [...new Set([...endpoints, APPROVAL_COUNTS_PATH])];
90
+ if (typeof window === "undefined" || watching) return () => {};
50
91
  watching = true;
51
92
  const stopped = new AbortController();
52
93
  // Endpoints with a read in flight, mapped to whether one more read waits for it.
@@ -60,9 +101,12 @@ export const watchAppBadges = (endpoints: readonly string[]): (() => void) => {
60
101
  return;
61
102
  }
62
103
  running.set(endpoint, false);
63
- void readBadge(endpoint, AbortSignal.any([stopped.signal, AbortSignal.timeout(APP_BADGE_INTERVAL_MS)])).then((count) => {
104
+ const signal = AbortSignal.any([stopped.signal, AbortSignal.timeout(APP_BADGE_INTERVAL_MS)]);
105
+ const result = endpoint === APPROVAL_COUNTS_PATH ? readApprovalCounts(signal) : readBadge(endpoint, signal);
106
+ void result.then((count) => {
64
107
  if (stopped.signal.aborted) return;
65
- setCounts((previous) => new Map(previous).set(endpoint, count));
108
+ if (typeof count === "number") setCounts((previous) => new Map(previous).set(endpoint, count));
109
+ else setApprovalCounts(count);
66
110
  const queued = running.get(endpoint);
67
111
  running.delete(endpoint);
68
112
  if (queued && document.visibilityState !== "hidden") read(endpoint);
@@ -66,11 +66,11 @@ const Page = (props: { c: unknown; title: Title; children: JSX.Element }) => (
66
66
  {props.children}
67
67
  </Layout>
68
68
  );
69
- const AccountsWorkspace = (props: { children: JSX.Element }) => (
69
+ const AccountsWorkspace = (props: { children: JSX.Element; active?: boolean }) => (
70
70
  <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
71
71
  <AppWorkspace.Sidebar id="accounts-navigation" label="Accounts">
72
72
  <AppWorkspace.SidebarDesktop>
73
- <AppWorkspace.SidebarItem href="/app/accounts/users" icon="ti ti-user" active>
73
+ <AppWorkspace.SidebarItem href="/app/accounts/users" icon="ti ti-user" active={props.active ?? true}>
74
74
  Users
75
75
  </AppWorkspace.SidebarItem>
76
76
  </AppWorkspace.SidebarDesktop>
@@ -134,7 +134,8 @@ export const server = new Hono()
134
134
  const uid = c.req.param("uid") ?? "";
135
135
  return () => (
136
136
  <Page c={c} title={[start, accountsRoot, { title: "Users", href: "/app/accounts/users" }, { title: uid }]}>
137
- <AccountsWorkspace>
137
+ <AccountsWorkspace active={false}>
138
+ <h1 class="p-3">User Seven</h1>
138
139
  <p class="p-3">Profile of {uid}</p>
139
140
  <input aria-label="Note" class="m-3" />
140
141
  </AccountsWorkspace>
@@ -142,6 +143,54 @@ export const server = new Hono()
142
143
  );
143
144
  }),
144
145
  )
146
+ .get(
147
+ "/app/accounts/teams",
148
+ ...probe.ssr((c) => () => (
149
+ <Page c={c} title={[start, accountsRoot, { title: "Directory", href: "/app/accounts/directory" }, { title: "Teams" }]}>
150
+ <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
151
+ <AppWorkspace.Sidebar label="Directory">
152
+ <AppWorkspace.SidebarDesktop>
153
+ <AppWorkspace.SidebarItem href="/app/accounts/teams" active>
154
+ Teams
155
+ </AppWorkspace.SidebarItem>
156
+ </AppWorkspace.SidebarDesktop>
157
+ </AppWorkspace.Sidebar>
158
+ <AppWorkspace.Content>
159
+ <AppWorkspace.Main>
160
+ <input aria-label="Search teams" placeholder="Search Teams…" />
161
+ </AppWorkspace.Main>
162
+ </AppWorkspace.Content>
163
+ </AppWorkspace>
164
+ </Page>
165
+ )),
166
+ )
167
+ .get(
168
+ "/app/accounts/teams/engineering",
169
+ ...probe.ssr((c) => () => (
170
+ <Page c={c} title={[start, accountsRoot, { title: "Teams", href: "/app/accounts/teams" }, { title: "Engineering" }]}>
171
+ <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
172
+ <AppWorkspace.Sidebar label="Directory">
173
+ <AppWorkspace.SidebarDesktop>
174
+ <AppWorkspace.SidebarItem href="/app/accounts/teams" active="ancestor">
175
+ Teams
176
+ </AppWorkspace.SidebarItem>
177
+ </AppWorkspace.SidebarDesktop>
178
+ </AppWorkspace.Sidebar>
179
+ <AppWorkspace.Content>
180
+ <AppWorkspace.Main>Team members</AppWorkspace.Main>
181
+ </AppWorkspace.Content>
182
+ </AppWorkspace>
183
+ </Page>
184
+ )),
185
+ )
186
+ .get(
187
+ "/app/accounts/directory",
188
+ ...probe.ssr((c) => () => (
189
+ <Page c={c} title={[{ title: "Directory" }]}>
190
+ <h1>Directory</h1>
191
+ </Page>
192
+ )),
193
+ )
145
194
  .get(
146
195
  "/app/files",
147
196
  ...probe.ssr((c) => {
@@ -12,7 +12,7 @@ export type RailApp = {
12
12
  /** The app's `nav.badge` endpoint. */
13
13
  badge?: string;
14
14
  };
15
- export type RailLink = Omit<RailApp, "defaultVisible"> & { exact?: boolean };
15
+ export type RailLink = Omit<RailApp, "defaultVisible"> & { appId?: string; exact?: boolean };
16
16
 
17
17
  export const isRailAppVisible = (app: RailApp, settings: RailPreferences): boolean =>
18
18
  Object.hasOwn(settings.visibility, app.id) ? (settings.visibility[app.id] ?? app.defaultVisible) : app.defaultVisible;
@@ -32,7 +32,7 @@ export const projectRailNavigation = (apps: readonly RailApp[], settings: RailSn
32
32
  const app = catalog.get(shortcut.appId);
33
33
  if (!app || pinned.has(app.id)) continue;
34
34
  pinned.add(app.id);
35
- shortcuts.push({ ...app, id: shortcut.id });
35
+ shortcuts.push({ ...app, id: shortcut.id, appId: app.id });
36
36
  } else {
37
37
  shortcuts.push({
38
38
  id: shortcut.id,
@@ -46,7 +46,9 @@ export const projectRailNavigation = (apps: readonly RailApp[], settings: RailSn
46
46
  }
47
47
  return {
48
48
  shortcuts,
49
- apps: sortRailApps(apps, locale).filter((app) => !pinned.has(app.id) && isRailAppVisible(app, settings)),
49
+ apps: sortRailApps(apps, locale)
50
+ .filter((app) => !pinned.has(app.id) && isRailAppVisible(app, settings))
51
+ .map((app) => ({ ...app, appId: app.id })),
50
52
  };
51
53
  };
52
54
 
@@ -36,9 +36,14 @@ export const localizedAppName = (
36
36
  appId: string,
37
37
  fallback: string,
38
38
  ): string => {
39
+ return appNameIn(carrier, appId, getLocale(carrier as Parameters<typeof getLocale>[0]), fallback);
40
+ };
41
+
42
+ /** An app's registered name in the locale of a recorded request. */
43
+ export const appNameIn = (carrier: RuntimeCarrier, appId: string, locale: string, fallback: string): string => {
39
44
  const runtime = carrier.get("runtime");
40
45
  const apps =
41
46
  runtime && typeof runtime === "object" && Array.isArray((runtime as RuntimeContext).apps) ? (runtime as RuntimeContext).apps : [];
42
47
  const app = apps.find((entry) => entry.id === appId);
43
- return app ? resolveAppPresentation(app, getLocale(carrier as Parameters<typeof getLocale>[0])).name : fallback;
48
+ return app ? resolveAppPresentation(app, locale).name : fallback;
44
49
  };