@k2b/cloud 0.22.0 → 0.24.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 (117) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/capabilities.ts +230 -75
  7. package/src/_internal/define-app.ts +90 -7
  8. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  9. package/src/_internal/page-responses.ts +20 -4
  10. package/src/_internal/registry-validation.ts +6 -0
  11. package/src/_internal/registry.ts +78 -3
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/static-assets.ts +18 -3
  14. package/src/access/ResourceApiKeys.tsx +30 -30
  15. package/src/ai/capability-execution.ts +5 -0
  16. package/src/ai/chat/blocks.tsx +2 -1
  17. package/src/ai/chat/messages.ts +6 -0
  18. package/src/ai/client/controller.ts +80 -21
  19. package/src/ai/client/transport.ts +47 -3
  20. package/src/ai/code-mode-skill.ts +2 -2
  21. package/src/ai/index.ts +0 -7
  22. package/src/ai/live-events.ts +1 -262
  23. package/src/ai/live.ts +31 -2
  24. package/src/ai/migrate.ts +35 -16
  25. package/src/ai/pdf-render.ts +2 -22
  26. package/src/ai/routes.ts +5 -4
  27. package/src/ai/runtime.ts +1 -6
  28. package/src/ai/solid.ts +0 -5
  29. package/src/ai/stream.ts +89 -65
  30. package/src/api/app-approval.ts +5 -1
  31. package/src/api/help.ts +2 -2
  32. package/src/api/index.ts +3 -0
  33. package/src/api/me.ts +19 -5
  34. package/src/api/pwa-phone.ts +168 -0
  35. package/src/api/pwa.ts +179 -0
  36. package/src/api/search/schemas.ts +34 -0
  37. package/src/api/search.ts +118 -50
  38. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  39. package/src/browser/CloudResourceSearch.tsx +208 -80
  40. package/src/browser/app-session.ts +69 -0
  41. package/src/browser/live-connection.ts +211 -0
  42. package/src/browser/live-websocket.ts +31 -11
  43. package/src/browser/live.ts +2 -0
  44. package/src/browser/resource-search-messages.ts +15 -2
  45. package/src/browser/search-stream.ts +151 -0
  46. package/src/contracts/app.ts +10 -0
  47. package/src/contracts/capabilities.ts +89 -24
  48. package/src/contracts/capability-compatibility.ts +28 -14
  49. package/src/contracts/file-provider.ts +158 -0
  50. package/src/contracts/index.ts +2 -0
  51. package/src/contracts/pwa-paths.ts +6 -0
  52. package/src/contracts/pwa.ts +156 -0
  53. package/src/contracts/registry.ts +7 -0
  54. package/src/contracts/shared.ts +7 -1
  55. package/src/events/index.ts +2 -0
  56. package/src/events/live-engine.ts +830 -0
  57. package/src/events/live-protocol.ts +31 -0
  58. package/src/events/live.ts +298 -0
  59. package/src/server/actor.ts +7 -0
  60. package/src/server/api-client.ts +23 -2
  61. package/src/server/help.ts +3 -3
  62. package/src/server/index.ts +3 -1
  63. package/src/server/middleware/auth.ts +80 -8
  64. package/src/server/middleware/openapi.ts +2 -1
  65. package/src/server/services/access.ts +49 -0
  66. package/src/server/services/index.ts +1 -0
  67. package/src/services/app-approval.ts +6 -25
  68. package/src/services/audit/index.ts +2 -0
  69. package/src/services/branding/app-icon-source.ts +40 -0
  70. package/src/services/branding/app-icons.ts +112 -0
  71. package/src/services/branding/icon-render-worker.ts +69 -0
  72. package/src/services/branding/icon-svg.ts +22 -0
  73. package/src/services/identity/invocation-actor.ts +8 -5
  74. package/src/services/identity/invocation-authority.ts +1 -0
  75. package/src/services/identity/invocation-token.ts +6 -0
  76. package/src/services/index.ts +1 -0
  77. package/src/services/outbox.ts +114 -80
  78. package/src/services/pairing-secret.ts +16 -0
  79. package/src/services/pdf/markdown.ts +22 -2
  80. package/src/services/pwa-devices.ts +640 -0
  81. package/src/services/session/index.ts +212 -57
  82. package/src/services/session/recent.ts +42 -0
  83. package/src/services/session/user.ts +15 -5
  84. package/src/shared/index.ts +1 -0
  85. package/src/{browser → shared}/locale-preference.ts +1 -1
  86. package/src/shared/markdown/formula.ts +122 -26
  87. package/src/shared/markdown/index.ts +27 -23
  88. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  89. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  90. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  91. package/src/ssr/Layout.tsx +10 -12
  92. package/src/ssr/LayoutHeader.tsx +14 -2
  93. package/src/ssr/LayoutHelp.tsx +2 -4
  94. package/src/ssr/LayoutRail.tsx +13 -4
  95. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  96. package/src/ssr/MobileProfileActions.tsx +19 -13
  97. package/src/ssr/PageError.tsx +23 -1
  98. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  99. package/src/ssr/PwaLayout.tsx +70 -0
  100. package/src/ssr/PwaRuntime.island.tsx +105 -0
  101. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  102. package/src/ssr/app-navigation.ts +25 -10
  103. package/src/ssr/index.ts +8 -1
  104. package/src/ssr/layout-context.ts +1 -1
  105. package/src/ssr/preference-controller.ts +2 -2
  106. package/src/ssr/profile-actions.ts +3 -1
  107. package/src/ssr/profile-preferences-messages.ts +2 -0
  108. package/src/ssr/pwa-messages.ts +24 -0
  109. package/src/styles/global.css +6 -0
  110. package/src/styles/resource-search.css +19 -0
  111. package/src/styles/utilities-feedback.css +2 -2
  112. package/scripts/build-pdf-renderer.ts +0 -39
  113. package/src/ai/client/live-connection.ts +0 -141
  114. package/src/ai/live-messages.ts +0 -45
  115. package/src/ai/live-outbox.ts +0 -102
  116. package/src/ai/live-routes.ts +0 -412
  117. package/src/shared/markdown/extensions/info-blocks.ts +0 -108
@@ -1,17 +1,27 @@
1
- import { query, timed } from "@k2b/stdlib/solid";
2
- import { Button, IconButton, ScrollArea, useLocale } from "@k2b/ui";
1
+ import { timed } from "@k2b/stdlib/solid";
2
+ import { announce, Button, IconButton, ScrollArea, useLocale } from "@k2b/ui";
3
3
  import { createEffect, createMemo, createSignal, createUniqueId, For, on, onCleanup, onMount, Show } from "solid-js";
4
- import type { SearchItem, SearchResponse } from "../api/search/schemas";
4
+ import type { SearchApp, SearchItem } from "../api/search/schemas";
5
5
  import type { CloudResourceRef } from "../contracts";
6
- import { LOCALE_HEADER } from "../shared/locale";
7
6
  import { shortcutLabel } from "./command-shortcuts";
8
7
  import { matchNavigationSearchItems, type NavigationSearchItem } from "./navigation-search";
9
8
  import { cloudResourceSearchUrl, filterCloudResourceSearchItems } from "./resource-search";
10
9
  import { commitTypedTags, matchingSearchTags, searchTags, tagAtCursor } from "./resource-search-input";
11
10
  import { resourceSearchMessages } from "./resource-search-messages";
12
11
  import type { GlobalSearchOptions, SearchScope } from "./search-bridge";
13
-
14
12
  import { commandSearchItem, matchingCommands, type PaletteCommand } from "./search-commands";
13
+ import {
14
+ applySearchLine,
15
+ breakSearchRun,
16
+ emptySearchRun,
17
+ failedSearchApps,
18
+ retrySearchApp,
19
+ type SearchBlock,
20
+ type SearchRun,
21
+ searchFinished,
22
+ searchingApps,
23
+ streamCloudResourceSearch,
24
+ } from "./search-stream";
15
25
 
16
26
  export type CloudResourceSearchProps = {
17
27
  commands?: readonly PaletteCommand[];
@@ -37,6 +47,7 @@ export type CloudResourceSearchProps = {
37
47
  const isLinkableCommand = (command: PaletteCommand) => typeof command.action !== "function" && "command" in command.action;
38
48
 
39
49
  const itemKey = (item: SearchItem) => `${item.ref.type}:${item.ref.id}`;
50
+ const URL_BASE = "https://cloud.invalid";
40
51
  const groupByApp = (items: SearchItem[]) => {
41
52
  const groups = new Map<string, SearchItem[]>();
42
53
  for (const item of items) {
@@ -59,7 +70,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
59
70
  const appId = () => scope()?.appId ?? scope()?.ref?.type.split(".")[0];
60
71
  const scopeLabel = () =>
61
72
  !props.request && props.initialAppId
62
- ? (response()?.apps.find((app) => app.id === props.initialAppId)?.name ?? scope()?.label)
73
+ ? (catalogApps()?.find((app) => app.id === props.initialAppId)?.name ?? scope()?.label)
63
74
  : scope()?.label;
64
75
  createEffect(() => {
65
76
  const request = props.request;
@@ -114,23 +125,80 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
114
125
  }),
115
126
  );
116
127
  const [searchUrl, setSearchUrl] = createSignal(desiredUrl());
117
- const searchQuery = query.create({
118
- source: searchUrl,
119
- load: async (url, { abortSignal }) => {
120
- if (props.searchResources === false) {
121
- const data: SearchResponse = { query: "", count: 0, apps: [], items: [] };
122
- return { url, data };
123
- }
124
- const response = await fetch(url, { signal: abortSignal, headers: { [LOCALE_HEADER]: locale() } });
125
- if (!response.ok) throw new Error(t().searchFailed);
126
- const data: SearchResponse = await response.json();
127
- return { url, data };
128
- },
129
- });
130
- const response = () => searchQuery.data()?.data;
131
- const fresh = () => searchQuery.data()?.url === desiredUrl() && !searchQuery.error();
132
- const pending = () => searchUrl() !== desiredUrl() || searchQuery.loading() || searchQuery.refreshing();
133
- const catalog = createMemo(() => searchTags(response()?.apps ?? [], appId()));
128
+ // One streamed run per search URL. Apps answer in any order; their rows are appended and never reordered.
129
+ const [run, setRun] = createSignal<SearchRun>(emptySearchRun());
130
+ const [runUrl, setRunUrl] = createSignal<string>();
131
+ const [catalogApps, setCatalogApps] = createSignal<SearchApp[]>();
132
+ // The last rows a run showed stay until the next run shows its first rows, so typing does not blank the list.
133
+ const [stale, setStale] = createSignal<{ url: string; blocks: SearchBlock[] }>();
134
+ /** The run shows its own rows, or that it found none, instead of the stale ones. */
135
+ const shownRun = () => run().blocks.length > 0 || searchFinished(run());
136
+ let controller: AbortController | undefined;
137
+ const startRun = (url: string) => {
138
+ controller?.abort();
139
+ const own = new AbortController();
140
+ controller = own;
141
+ const previous = runUrl();
142
+ // What is on screen stays: the last run's rows, or none after it found nothing; a run that showed nothing yet
143
+ // leaves the rows before it.
144
+ if (previous && shownRun()) setStale({ url: previous, blocks: run().blocks });
145
+ setRunUrl(url);
146
+ if (props.searchResources === false) {
147
+ setCatalogApps([]);
148
+ setRun({ ...emptySearchRun(), done: true });
149
+ return;
150
+ }
151
+ setRun(emptySearchRun());
152
+ void streamCloudResourceSearch(url, {
153
+ signal: own.signal,
154
+ locale: locale(),
155
+ onLine: (line) => {
156
+ if (controller !== own) return;
157
+ if (line.type === "start") setCatalogApps(line.apps);
158
+ setRun((current) => applySearchLine(current, line));
159
+ },
160
+ }).catch(() => {
161
+ if (controller === own && !own.signal.aborted) setRun(breakSearchRun);
162
+ });
163
+ };
164
+ /** Searches one app again within the current run; its rows, if any, are appended at the end. */
165
+ const retryApp = (appId: string) => {
166
+ const own = controller;
167
+ const url = runUrl();
168
+ if (!own || !url) return;
169
+ const target = new URL(url, URL_BASE);
170
+ target.searchParams.set("app", appId);
171
+ // Narrowed to one app, the server samples more; the retried section keeps the size the others have.
172
+ const limit = run().providers.length > 1 ? Number(target.searchParams.get("provider_limit")) : undefined;
173
+ setRun((current) => retrySearchApp(current, appId));
174
+ const giveUp = () => {
175
+ if (controller === own && run().status[appId] === "searching")
176
+ setRun((current) => applySearchLine(current, { type: "provider", provider: appId, status: "error", results: [], ms: 0 }));
177
+ };
178
+ void streamCloudResourceSearch(`${target.pathname}${target.search}`, {
179
+ signal: own.signal,
180
+ locale: locale(),
181
+ onLine: (line) => {
182
+ if (controller !== own || line.type !== "provider" || line.provider !== appId) return;
183
+ setRun((current) => applySearchLine(current, { ...line, results: line.results.slice(0, limit) }));
184
+ },
185
+ }).then(giveUp, giveUp);
186
+ };
187
+ createEffect(on(searchUrl, startRun));
188
+ onCleanup(() => controller?.abort());
189
+
190
+ const current = () => runUrl() === desiredUrl();
191
+ const started = () => run().providers.length > 0 || run().done || run().failed;
192
+ const finished = () => current() && searchFinished(run());
193
+ const waiting = () => searchUrl() !== desiredUrl() || !started();
194
+ const pendingApps = () => (current() ? searchingApps(run()) : []);
195
+ const failedApps = () => (current() ? failedSearchApps(run()) : []);
196
+ const appName = (appId: string) => run().apps.find((app) => app.id === appId)?.name ?? appId;
197
+ const failureText = (failure: ReturnType<typeof failedSearchApps>[number]) =>
198
+ failure.status === "timeout" ? t().appTimedOut({ app: appName(failure.appId) }) : t().appUnavailable({ app: appName(failure.appId) });
199
+ // Only a search the user narrowed with the app chip or a tag names its one app when nothing matched.
200
+ const narrowedApp = () => ((Boolean(appId()) || tags().length > 0) && run().providers.length === 1 ? run().providers[0] : undefined);
201
+ const catalog = createMemo(() => searchTags(catalogApps() ?? [], appId()));
134
202
  const quickTags = createMemo(() =>
135
203
  catalog()
136
204
  .filter((tag, index, all) => all.findIndex((other) => other.appName === tag.appName) === index)
@@ -147,31 +215,64 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
147
215
  })
148
216
  : [],
149
217
  );
218
+ // Keep last-good rows only within the same context, never across scope changes.
219
+ const sameScope = (url: string) => {
220
+ const loaded = new URL(url, URL_BASE).searchParams;
221
+ return (
222
+ (loaded.get("scope_type") ?? undefined) === scope()?.ref?.type &&
223
+ (loaded.get("scope_id") ?? undefined) === scope()?.ref?.id &&
224
+ (loaded.get("app") ?? undefined) === appId() &&
225
+ (loaded.get("scope_tag") ?? undefined) === scope()?.tag
226
+ );
227
+ };
228
+ const shownBlocks = () => {
229
+ const source = shownRun() ? { url: runUrl() ?? "", blocks: run().blocks } : stale();
230
+ return source && sameScope(source.url) ? source.blocks : [];
231
+ };
232
+ /** Rows of the run for the current input; anything older is visible but cannot be chosen. */
233
+ const freshResults = () => current() && shownRun();
234
+ const results = createMemo(() =>
235
+ canSearch()
236
+ ? [...groupByApp(navigation()), ...shownBlocks().flatMap((block) => filterCloudResourceSearchItems(block.items, props))]
237
+ : [],
238
+ );
239
+ const searchContext = () => JSON.stringify([desiredUrl(), commandMode(), commandQuery()]);
240
+ /**
241
+ * Actions come first. One that starts to match after rows below the actions are on screen, such as from a catalog
242
+ * that loaded late, waits for the next keystroke instead of pushing those rows down.
243
+ */
244
+ const shownCommands = createMemo<{ context: string; commands: PaletteCommand[] }>((previous) => {
245
+ const context = searchContext();
246
+ const matching = visibleCommands();
247
+ if (previous?.context !== context || results().length === 0) return { context, commands: matching };
248
+ return { context, commands: matching.filter((command) => previous.commands.some((shown) => shown.id === command.id)) };
249
+ });
150
250
  const items = createMemo(() => {
151
- const commands = visibleCommands().map((command) => commandSearchItem(command, command.context ? t().contextActions : t().actions));
152
- if (!canSearch()) return commands;
153
- // Keep last-good rows only within the same context, never across scope changes.
154
- const loaded = new URL(searchQuery.data()?.url ?? "/api/search", "https://cloud.invalid");
155
- const sameScope =
156
- (loaded.searchParams.get("scope_type") ?? undefined) === scope()?.ref?.type &&
157
- (loaded.searchParams.get("scope_id") ?? undefined) === scope()?.ref?.id &&
158
- (loaded.searchParams.get("app") ?? undefined) === appId() &&
159
- (loaded.searchParams.get("scope_tag") ?? undefined) === scope()?.tag;
160
- const resources = filterCloudResourceSearchItems(sameScope ? (response()?.items ?? []) : [], props).sort(
161
- (a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.title.localeCompare(b.title),
251
+ const commands = shownCommands().commands.map((command) =>
252
+ commandSearchItem(command, command.context ? t().contextActions : t().actions),
162
253
  );
163
- const results = [...groupByApp(resources), ...groupByApp(navigation())];
164
- // Context actions stay visible when opening an app-scoped search without text.
165
- return textQuery() ? [...results, ...commands] : [...commands, ...results];
254
+ // Instant rows come first: streamed app sections are appended below them and never push them down.
255
+ return [...commands, ...results()];
166
256
  });
167
257
  const selectable = (item: SearchItem) =>
168
- !props.disabled && !choosingTag() && (commandFor(item) || navigation().includes(item) || (fresh() && !pending()));
258
+ !props.disabled &&
259
+ !choosingTag() &&
260
+ (commandFor(item) || navigation().includes(item) || (freshResults() && searchUrl() === desiredUrl()));
169
261
  const activeItem = () => items()[activeIndex()];
170
262
  const selectedItem = () => items().find((item) => itemKey(item) === selectedKey());
171
263
  const previewItem = () => (props.selectionMode ? (selectedItem() ?? activeItem()) : activeItem());
172
264
  const showList = () => !choosingTag() && items().length > 0;
173
- const showResults = () => showList() && (canSearch() || commandMode());
174
- const unknownTags = () => (fresh() && !pending() ? (response()?.unsupportedTags ?? []) : []);
265
+ // An active search keeps the full height from its first keystroke, so arriving rows never resize the dialog.
266
+ const searching = () => !choosingTag() && (canSearch() || commandMode());
267
+ const showResults = () => showList() && searching();
268
+ const unknownTags = () => (current() && started() ? run().unsupportedTags : []);
269
+ const noMatches = () =>
270
+ !items().length && (commandMode() ? !props.commandsLoading : canSearch() && finished() && !run().failed && failedApps().length === 0);
271
+ const stillSearching = () => {
272
+ const names = pendingApps().map(appName);
273
+ const shown = names.length > 3 ? [...names.slice(0, 2), t().moreApps({ count: names.length - 2 })] : names;
274
+ return t().stillSearching({ apps: new Intl.ListFormat(locale(), { type: "conjunction" }).format(shown), count: names.length });
275
+ };
175
276
 
176
277
  const { debouncedFn: scheduleSearch, cancel } = timed.debounce((url: string) => setSearchUrl(url), 200);
177
278
  createEffect(() => {
@@ -197,7 +298,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
197
298
  });
198
299
  createEffect(
199
300
  on(
200
- () => ({ rows: items(), context: JSON.stringify([desiredUrl(), commandMode(), commandQuery()]) }),
301
+ () => ({ rows: items(), context: searchContext() }),
201
302
  (current, previous) => {
202
303
  if (userSelected && previous?.context === current.context) {
203
304
  const selected = previous.rows[activeIndex()];
@@ -207,11 +308,24 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
207
308
  } else {
208
309
  userSelected = false;
209
310
  const explicitCommand = commandMode() && Boolean(commandQuery());
210
- setActiveIndex(current.rows[0]?.ref.type === "cloud.command" && !explicitCommand ? -1 : 0);
311
+ const firstResult = current.rows.findIndex((row) => row.ref.type !== "cloud.command");
312
+ // Actions are never implicitly selected; with text, the first result is.
313
+ setActiveIndex(explicitCommand ? 0 : current.rows[0]?.ref.type === "cloud.command" && !textQuery() ? -1 : firstResult);
211
314
  }
212
315
  },
213
316
  ),
214
317
  );
318
+ createEffect(
319
+ on(
320
+ () => canSearch() && !commandMode() && finished(),
321
+ (done, wasDone) => {
322
+ if (!done || wasDone) return;
323
+ // Apps that did not answer are part of the outcome, so a missing answer never sounds like “not found”.
324
+ if (run().failed) announce(t().searchFailed);
325
+ else announce([t().searchDone({ count: results().length }), ...failedApps().map(failureText)].join(". "));
326
+ },
327
+ ),
328
+ );
215
329
  createEffect(() => {
216
330
  suggestions();
217
331
  setTagIndex(0);
@@ -349,7 +463,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
349
463
  role="group"
350
464
  aria-label={props.title ?? t().searchCloudResources}
351
465
  class="cloud-resource-search"
352
- classList={{ "has-results": showResults(), "is-picker": props.selectionMode, "shows-details": mobileDetails() && showResults() }}
466
+ classList={{ "has-results": searching(), "is-picker": props.selectionMode, "shows-details": mobileDetails() && showResults() }}
353
467
  onKeyDown={(event) => {
354
468
  if (event.target === inputRef) return;
355
469
  if (event.key === "Escape") handleKeyDown(event);
@@ -366,7 +480,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
366
480
  </IconButton>
367
481
  <label class="cloud-resource-search__input">
368
482
  <Show
369
- when={commandMode() ? props.commandsLoading : pending() && !choosingTag()}
483
+ when={commandMode() ? props.commandsLoading : canSearch() && waiting() && !choosingTag()}
370
484
  fallback={<i class="ti ti-search" aria-hidden="true" />}
371
485
  >
372
486
  <i class="ti ti-loader-2 animate-spin" role="status" aria-label={t().loading} />
@@ -446,7 +560,11 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
446
560
  />
447
561
  </div>
448
562
  </label>
449
- <ScrollArea ref={bodyRef} class="cloud-resource-search__body" aria-busy={pending() && !choosingTag()}>
563
+ <ScrollArea
564
+ ref={bodyRef}
565
+ class="cloud-resource-search__body"
566
+ aria-busy={canSearch() && !choosingTag() && (waiting() || pendingApps().length > 0)}
567
+ >
450
568
  <Show
451
569
  when={choosingTag()}
452
570
  fallback={
@@ -458,25 +576,12 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
458
576
  </p>
459
577
  )}
460
578
  </Show>
461
- <Show when={searchQuery.error()}>
462
- <div class="cloud-resource-search__hint" role="status">
463
- {t().searchFailed}{" "}
464
- <Button variant="text" size="xs" onClick={() => void searchQuery.refresh()}>
465
- {t().retry}
466
- </Button>
467
- </div>
468
- </Show>
469
- <Show when={fresh() && response()?.failedApps?.length}>
470
- <p class="cloud-resource-search__hint" role="status">
471
- {t().partialFailure}
472
- </p>
473
- </Show>
474
579
  <Show when={!canSearch() && !commandMode()}>
475
580
  <div class="cloud-resource-search__idle">
476
581
  <p>{props.selectionMode ? t().pickerHint : props.searchResources === false ? t().navigationHint : t().startHint}</p>
477
- <div class="cloud-resource-search__quick" aria-busy={!response() && !searchQuery.error()}>
582
+ <div class="cloud-resource-search__quick" aria-busy={!catalogApps() && !run().failed}>
478
583
  <Show
479
- when={response() || searchQuery.error()}
584
+ when={catalogApps() || run().failed}
480
585
  fallback={<For each={[0, 1, 2]}>{() => <span class="cloud-resource-search__tag-skeleton" aria-hidden="true" />}</For>}
481
586
  >
482
587
  <For each={quickTags()}>
@@ -487,7 +592,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
487
592
  )}
488
593
  </For>
489
594
  </Show>
490
- <Show when={!response() || catalog().length > 0}>
595
+ <Show when={!catalogApps() || catalog().length > 0}>
491
596
  <Button
492
597
  variant="ghost"
493
598
  size="sm"
@@ -523,10 +628,10 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
523
628
  {t().commandFailed}
524
629
  </p>
525
630
  </Show>
631
+ <Show when={searching()}>
632
+ <div class="cloud-resource-search__count">{items().length > 0 ? t().resultCount({ count: items().length }) : "\u00a0"}</div>
633
+ </Show>
526
634
  <Show when={showList()}>
527
- <div class="cloud-resource-search__count" aria-live="polite">
528
- {t().resultCount({ count: items().length })}
529
- </div>
530
635
  <section id={`${id}-options`} role="listbox" aria-label={t().searchCloudResources} class="cloud-resource-search__results">
531
636
  <For each={items()}>
532
637
  {(item, index) => (
@@ -575,18 +680,43 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
575
680
  </For>
576
681
  </section>
577
682
  </Show>
578
- <Show
579
- when={
580
- (canSearch() || commandMode()) &&
581
- !items().length &&
582
- !pending() &&
583
- fresh() &&
584
- !searchQuery.error() &&
585
- !response()?.failedApps?.length
586
- }
587
- >
683
+ <Show when={canSearch() && !commandMode() && (pendingApps().length > 0 || failedApps().length > 0)}>
684
+ <div class="cloud-resource-search__status">
685
+ <Show when={pendingApps().length > 0}>
686
+ <p>
687
+ <i class="ti ti-loader-2 animate-spin motion-reduce:animate-none" aria-hidden="true" />
688
+ {stillSearching()}
689
+ </p>
690
+ </Show>
691
+ <For each={failedApps()}>
692
+ {(failure) => (
693
+ <p>
694
+ {failureText(failure)}
695
+ <span aria-hidden="true">·</span>
696
+ <Button variant="text" size="xs" onClick={() => retryApp(failure.appId)}>
697
+ {t().retry}
698
+ </Button>
699
+ </p>
700
+ )}
701
+ </For>
702
+ </div>
703
+ </Show>
704
+ {/* Below the rows, so pages and actions already on screen stay where they are. */}
705
+ <Show when={current() && run().failed}>
706
+ <div class="cloud-resource-search__hint" role="status">
707
+ {t().searchFailed}{" "}
708
+ <Button variant="text" size="xs" onClick={() => startRun(searchUrl())}>
709
+ {t().retry}
710
+ </Button>
711
+ </div>
712
+ </Show>
713
+ <Show when={noMatches()}>
588
714
  <p class="cloud-resource-search__hint" role="status">
589
- {unknownTags().length ? t().unsupportedTags : t().noMatches}
715
+ {unknownTags().length
716
+ ? t().unsupportedTags
717
+ : !commandMode() && narrowedApp()
718
+ ? t().appNoMatches({ app: appName(narrowedApp()!), query: textQuery() })
719
+ : t().noMatches}
590
720
  </p>
591
721
  </Show>
592
722
  </>
@@ -601,9 +731,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
601
731
  <Show
602
732
  when={suggestions().length > 0}
603
733
  fallback={
604
- <p class="cloud-resource-search__hint">
605
- {searchQuery.loading() ? t().loadingTags : searchQuery.error() ? t().searchFailed : t().noTags}
606
- </p>
734
+ <p class="cloud-resource-search__hint">{run().failed ? t().searchFailed : catalogApps() ? t().noTags : t().loadingTags}</p>
607
735
  }
608
736
  >
609
737
  <div role="listbox" id={`${id}-options`} aria-label={t().tagSuggestions} class="cloud-resource-search__tags">
@@ -634,9 +762,9 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
634
762
  </Show>
635
763
  </Show>
636
764
  </ScrollArea>
637
- <Show when={showResults() || (choosingTag() && suggestions().length > 0)}>
765
+ <Show when={searching() || (choosingTag() && suggestions().length > 0)}>
638
766
  <footer class="cloud-resource-search__footer">
639
- <Show when={showResults()}>
767
+ <Show when={searching()}>
640
768
  <Button
641
769
  class="cloud-resource-search__details"
642
770
  disabled={!previewItem()}
@@ -0,0 +1,69 @@
1
+ import { PWA_AUTH_PATH, PWA_SCOPE } from "../contracts/pwa-paths";
2
+
3
+ export type AppSessionRenewal = {
4
+ /** The page has a valid app session now. */
5
+ ok: boolean;
6
+ /** Android shares Chrome's cookies with the app, so the web session there may belong to another account. */
7
+ otherAccount?: { name: string };
8
+ };
9
+
10
+ /** Whether this document is a page of the mobile app, whose requests carry the app session. */
11
+ export const insideMobileApp = (): boolean =>
12
+ typeof location !== "undefined" && location.pathname.startsWith(PWA_SCOPE) && !location.pathname.startsWith(`${PWA_AUTH_PATH}/`);
13
+
14
+ const call = async (): Promise<AppSessionRenewal & { renewed: boolean }> => {
15
+ let response: Response;
16
+ try {
17
+ response = await fetch(`${PWA_AUTH_PATH}/session/renew`, {
18
+ method: "POST",
19
+ credentials: "same-origin",
20
+ headers: { Accept: "application/json" },
21
+ });
22
+ } catch {
23
+ // Offline or unreachable: the next foreground or request tries again.
24
+ return { ok: false, renewed: false };
25
+ }
26
+ if (response.status === 401) {
27
+ // The phone was removed or its pairing ended. Core has already deleted the device key with this answer, so
28
+ // a reload could only show the generic pairing view; the pairing view for a signed-out phone says why.
29
+ location.replace(`${PWA_SCOPE}?pwa=ended`);
30
+ return { ok: false, renewed: false };
31
+ }
32
+ if (response.status === 403) {
33
+ const body: unknown = await response.json().catch(() => null);
34
+ if (typeof body === "object" && body !== null && "code" in body && body.code === "ACCOUNT_BLOCKED") {
35
+ location.replace(`${PWA_SCOPE}?pwa=blocked`);
36
+ }
37
+ return { ok: false, renewed: false };
38
+ }
39
+ if (!response.ok) return { ok: false, renewed: false };
40
+ // `PwaRenewResultSchema`, read by hand: the typed client on every page must not load the validators.
41
+ const body: unknown = await response.json().catch(() => null);
42
+ if (typeof body !== "object" || body === null || !("renewed" in body) || typeof body.renewed !== "boolean") {
43
+ return { ok: false, renewed: false };
44
+ }
45
+ const other = "otherAccount" in body ? body.otherAccount : undefined;
46
+ const otherAccount =
47
+ typeof other === "object" && other !== null && "name" in other && typeof other.name === "string" ? { name: other.name } : undefined;
48
+ return { ok: true, renewed: body.renewed, otherAccount };
49
+ };
50
+
51
+ let running: Promise<AppSessionRenewal> | undefined;
52
+
53
+ /**
54
+ * Renews the mobile app's session with the phone's device key through Core. One renewal runs at a time in a page,
55
+ * and every caller shares it. An ended pairing or a blocked account leaves for the app's matching Start view.
56
+ */
57
+ export const renewAppSession = (): Promise<AppSessionRenewal> => {
58
+ running ??= (async () => {
59
+ try {
60
+ const first = await call();
61
+ // After a rotation, a second call presents the new key and retires the old one.
62
+ if (first.renewed) await call();
63
+ return { ok: first.ok, otherAccount: first.otherAccount };
64
+ } finally {
65
+ running = undefined;
66
+ }
67
+ })();
68
+ return running;
69
+ };