@athenaintel/react 0.59.0 → 0.60.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 (118) hide show
  1. package/README.md +52 -19
  2. package/dist/agent-runtime/src/composer-markdown.cjs +0 -16
  3. package/dist/agent-runtime/src/composer-markdown.cjs.map +1 -1
  4. package/dist/agent-runtime/src/composer-markdown.js +1 -17
  5. package/dist/agent-runtime/src/composer-markdown.js.map +1 -1
  6. package/dist/agent-runtime/src/named-agents.js +1 -1
  7. package/dist/agent-runtime/src/offline-status.cjs.map +1 -1
  8. package/dist/agent-runtime/src/offline-status.d.ts +27 -0
  9. package/dist/agent-runtime/src/offline-status.js.map +1 -1
  10. package/dist/agent-runtime/src/tool-metadata.cjs +7 -0
  11. package/dist/agent-runtime/src/tool-metadata.cjs.map +1 -1
  12. package/dist/agent-runtime/src/tool-metadata.js +7 -0
  13. package/dist/agent-runtime/src/tool-metadata.js.map +1 -1
  14. package/dist/chat/AthenaChat.cjs +15 -5
  15. package/dist/chat/AthenaChat.cjs.map +1 -1
  16. package/dist/chat/AthenaChat.js +16 -6
  17. package/dist/chat/AthenaChat.js.map +1 -1
  18. package/dist/chat-ui/index.cjs +2 -0
  19. package/dist/chat-ui/index.cjs.map +1 -1
  20. package/dist/chat-ui/index.d.ts +1 -0
  21. package/dist/chat-ui/index.js +2 -0
  22. package/dist/chat-ui/index.js.map +1 -1
  23. package/dist/chat-ui/mentions/mention-chip.cjs +9 -3
  24. package/dist/chat-ui/mentions/mention-chip.cjs.map +1 -1
  25. package/dist/chat-ui/mentions/mention-chip.js +9 -3
  26. package/dist/chat-ui/mentions/mention-chip.js.map +1 -1
  27. package/dist/chat-ui/mentions/suggestions/components/mention-icon.cjs +18 -2
  28. package/dist/chat-ui/mentions/suggestions/components/mention-icon.cjs.map +1 -1
  29. package/dist/chat-ui/mentions/suggestions/components/mention-icon.d.ts +10 -1
  30. package/dist/chat-ui/mentions/suggestions/components/mention-icon.js +19 -3
  31. package/dist/chat-ui/mentions/suggestions/components/mention-icon.js.map +1 -1
  32. package/dist/chat-ui/mentions/suggestions/data/api-client.cjs +9 -0
  33. package/dist/chat-ui/mentions/suggestions/data/api-client.cjs.map +1 -1
  34. package/dist/chat-ui/mentions/suggestions/data/api-client.d.ts +6 -0
  35. package/dist/chat-ui/mentions/suggestions/data/api-client.js +9 -0
  36. package/dist/chat-ui/mentions/suggestions/data/api-client.js.map +1 -1
  37. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icon-names.generated.cjs +89 -0
  38. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icon-names.generated.cjs.map +1 -0
  39. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icon-names.generated.d.ts +8 -0
  40. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icon-names.generated.js +89 -0
  41. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icon-names.generated.js.map +1 -0
  42. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.cjs +140 -116
  43. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.cjs.map +1 -1
  44. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.d.ts +46 -12
  45. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.js +141 -117
  46. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.js.map +1 -1
  47. package/dist/chat-ui/mentions/suggestions/data/sources/index.cjs +37 -6
  48. package/dist/chat-ui/mentions/suggestions/data/sources/index.cjs.map +1 -1
  49. package/dist/chat-ui/mentions/suggestions/data/sources/index.js +39 -8
  50. package/dist/chat-ui/mentions/suggestions/data/sources/index.js.map +1 -1
  51. package/dist/chat-ui/mentions/use-asset-icon-app-url.cjs +12 -0
  52. package/dist/chat-ui/mentions/use-asset-icon-app-url.cjs.map +1 -0
  53. package/dist/chat-ui/mentions/use-asset-icon-app-url.d.ts +8 -0
  54. package/dist/chat-ui/mentions/use-asset-icon-app-url.js +12 -0
  55. package/dist/chat-ui/mentions/use-asset-icon-app-url.js.map +1 -0
  56. package/dist/collab/client.cjs +24 -1
  57. package/dist/collab/client.cjs.map +1 -1
  58. package/dist/collab/client.d.ts +16 -0
  59. package/dist/collab/client.js +24 -1
  60. package/dist/collab/client.js.map +1 -1
  61. package/dist/composer/attached-references.js +1 -1
  62. package/dist/composer/staged-message-metadata.cjs +12 -0
  63. package/dist/composer/staged-message-metadata.cjs.map +1 -0
  64. package/dist/composer/staged-message-metadata.d.ts +15 -0
  65. package/dist/composer/staged-message-metadata.js +12 -0
  66. package/dist/composer/staged-message-metadata.js.map +1 -0
  67. package/dist/data/athena-request.cjs +2 -0
  68. package/dist/data/athena-request.cjs.map +1 -1
  69. package/dist/data/athena-request.js +2 -0
  70. package/dist/data/athena-request.js.map +1 -1
  71. package/dist/provider/AthenaProvider.cjs +4 -3
  72. package/dist/provider/AthenaProvider.cjs.map +1 -1
  73. package/dist/provider/AthenaProvider.js +4 -3
  74. package/dist/provider/AthenaProvider.js.map +1 -1
  75. package/dist/react/package.json.cjs +1 -1
  76. package/dist/react/package.json.js +1 -1
  77. package/dist/references/src/anchors/parse.cjs +11 -1
  78. package/dist/references/src/anchors/parse.cjs.map +1 -1
  79. package/dist/references/src/anchors/parse.js +11 -1
  80. package/dist/references/src/anchors/parse.js.map +1 -1
  81. package/dist/references/src/anchors/schemas.d.ts +1 -0
  82. package/dist/references/src/anchors/serialize.cjs +1 -0
  83. package/dist/references/src/anchors/serialize.cjs.map +1 -1
  84. package/dist/references/src/anchors/serialize.js +1 -0
  85. package/dist/references/src/anchors/serialize.js.map +1 -1
  86. package/dist/references/src/anchors/types.cjs.map +1 -1
  87. package/dist/references/src/anchors/types.d.ts +9 -0
  88. package/dist/references/src/anchors/types.js.map +1 -1
  89. package/dist/references/src/schemas.d.ts +2 -0
  90. package/dist/runtime/useAthenaStatewireRuntime.cjs +6 -8
  91. package/dist/runtime/useAthenaStatewireRuntime.cjs.map +1 -1
  92. package/dist/runtime/useAthenaStatewireRuntime.js +6 -8
  93. package/dist/runtime/useAthenaStatewireRuntime.js.map +1 -1
  94. package/dist/threads/statewire-thread-list.cjs +14 -2
  95. package/dist/threads/statewire-thread-list.cjs.map +1 -1
  96. package/dist/threads/statewire-thread-list.d.ts +11 -2
  97. package/dist/threads/statewire-thread-list.js +14 -2
  98. package/dist/threads/statewire-thread-list.js.map +1 -1
  99. package/dist/tools/drive-tool-uis.cjs +8 -4
  100. package/dist/tools/drive-tool-uis.cjs.map +1 -1
  101. package/dist/tools/drive-tool-uis.d.ts +5 -4
  102. package/dist/tools/drive-tool-uis.js +9 -5
  103. package/dist/tools/drive-tool-uis.js.map +1 -1
  104. package/dist/version.cjs +4 -0
  105. package/dist/version.cjs.map +1 -1
  106. package/dist/version.d.ts +9 -0
  107. package/dist/version.js +4 -0
  108. package/dist/version.js.map +1 -1
  109. package/dist/workbench/live-desktop-api.cjs +2 -1
  110. package/dist/workbench/live-desktop-api.cjs.map +1 -1
  111. package/dist/workbench/live-desktop-api.js +2 -1
  112. package/dist/workbench/live-desktop-api.js.map +1 -1
  113. package/dist/workbench/session-workspace-api.cjs +7 -2
  114. package/dist/workbench/session-workspace-api.cjs.map +1 -1
  115. package/dist/workbench/session-workspace-api.d.ts +10 -0
  116. package/dist/workbench/session-workspace-api.js +7 -2
  117. package/dist/workbench/session-workspace-api.js.map +1 -1
  118. package/package.json +1 -1
package/README.md CHANGED
@@ -1105,7 +1105,12 @@ reference standard's kinds (`@athenaintel/references`):
1105
1105
  (`MessageAttachedReferences`). A host that mounts `TiptapComposer` itself
1106
1106
  can mount both, and spreads `useAttachedReferencesComposerProps(threadId)`
1107
1107
  on the composer so its sends carry the references. A composer without them
1108
- never sends attachments.
1108
+ never sends attachments. `AthenaChat` puts the references on the message it
1109
+ sends itself, so they reach Athena on the stock `@assistant-ui/react-statewire`
1110
+ an app installs from npm; a host's own `TiptapComposer` hands them to the
1111
+ runtime's `getMessageMetadata`, which only Athena's patched build of that
1112
+ package reads (SDK 0.59.0's `AthenaChat` did the same, so an npm app's
1113
+ attachments never left the browser, #55425).
1109
1114
  - **The send.** The references ride the message's own metadata
1110
1115
  (`message.metadata.attachedReferences`), so a message that waits in the chat
1111
1116
  outbox, is retried, or is posted again after a reload keeps its own. A
@@ -1290,21 +1295,27 @@ workspace member who did not create the computer gets an asset-bound
1290
1295
  `member_viewer` credential (`athview_`, 30 minutes, bound to the computer, its
1291
1296
  workspace and the app port; the bridge renews it 5 minutes before expiry),
1292
1297
  never their full sign-in, because the app runs the computer owner's code. That
1293
- credential reaches only what this SDK's identity read and voice client call:
1294
- `GET /api/v0/me` (listing that workspace alone), voice session creation, the
1295
- transcript download of a voice session this app started, and
1296
- `agents/general/invoke` without tools, a knowledge base or an earlier thread
1297
- (transcript grading). The voice agent gets a credential of its own for each
1298
- call (it saves the call's turns and runs `ask_agent` without tools), so a
1299
- member's call cannot enable `read_full_asset`, `search_assets` or
1300
- `ask_agent` tools (`403`). Asset reads (`/api/v0/assets`, `/api/v0/tools/*`),
1301
- any other session download, sharing and the chat routes (the thread list and
1302
- the statewire chat transport) answer `403` for it, in the frame and on the
1303
- app's own URL alike, so a chat app, or an app that needs those reads, stays
1304
- signed out for a member who did not create the computer. See
1305
- `docs/COMPUTER_ASSET_STANDARDS.md` ("App frame sign-in"). Nothing to change
1306
- in your app: the credential arrives through the same `athena-auth` message and
1307
- the same `/_athena/auth` body.
1298
+ credential reaches `GET /api/v0/me` (listing that workspace alone), voice
1299
+ session creation, `agents/general/invoke` without tools, a knowledge base or an
1300
+ earlier thread (transcript grading), and these reads, each narrowed to what the
1301
+ app's code may learn:
1302
+ - `GET /api/v0/assets` and `GET /api/v0/assets/{id}`: only an asset both the member and the computer's creator can read, in that workspace, and never a conversation;
1303
+ - the thread list (`POST /api/conversations/threads/list`), `GET /api/v0/sessions`, `GET /api/v0/sessions/{id}` and the transcript download: only the sessions this app started for the member.
1304
+
1305
+ Every other id answers `404`, as a missing one does. The voice agent gets a
1306
+ credential of its own for each call (it saves the call's turns and runs
1307
+ `ask_agent` without tools), so a member's call cannot enable
1308
+ `read_full_asset`, `search_assets` or `ask_agent` tools (`403`). Asset writes,
1309
+ `POST /api/asset-sessions/{id}`, `/api/v0/tools/*`, `/api/v0/me/*`, sharing,
1310
+ GraphQL and the chat transport (`/v2/threads`, `POST /api/unstable/threads/`)
1311
+ answer `403` with `X-Athena-Credential-Scope: refused`, in the frame and on the
1312
+ app's own URL alike: the credential never reaches those routes, so renewing it
1313
+ cannot help (the SDK renews only on a `401`). A chat app opened by a member who
1314
+ did not create the computer lists its assets and sessions but cannot start a
1315
+ chat yet. See `marathon/MARATHON_SYSTEM.md` and
1316
+ `docs/COMPUTER_ASSET_STANDARDS.md` ("App frame sign-in"). Nothing to change in
1317
+ your app: the credential arrives through the same `athena-auth` message and the
1318
+ same `/_athena/auth` body.
1308
1319
 
1309
1320
  Anywhere else — standalone hosting, custom auth — pass
1310
1321
  `createSession` and create the session from your own server (API key stays server-side):
@@ -1332,6 +1343,18 @@ Both use the same bearer / API key as session creation. The full recipe
1332
1343
  template skill `voice-session-guide.md`. `createAthenaVoiceSession` is also
1333
1344
  exported if you hold the bridge state yourself.
1334
1345
 
1346
+ ## What's new in 0.60.0
1347
+
1348
+ **The `@` menu draws each asset's type icon, as Olympus does.** Asset rows used to show the same generic file icon in every app that didn't serve Athena's icon files itself. Now each row shows the icon Olympus draws for its type, from Olympus's own type → icon table. An email whose mailbox is recorded on it (a Gmail or Outlook draft or message) shows that mailbox's logo. The SVGs load from the Athena web app the chat talks to (`<appUrl>/assets/icons/files/`), so there's nothing to configure. Each chat uses its own provider's app, and a host that ships its own copy still points at it with `configureAssetIconBasePath`. Where an SVG can't load (a strict `img-src` CSP, offline), the row shows the type's Lucide icon. The mention chips and the drive cards draw the same icons. A renderer of your own draws them with `assetTypeToThumb(type, { appUrl: useAssetIconAppUrl() })`. The search reads no metadata; a page with email rows reads theirs alone (`getAssetsByIds`).
1349
+
1350
+ **What an app attaches reaches Athena on the stock `@assistant-ui/react-statewire`.** `AthenaChat` now puts a message's attachments (`useAttachToChat`) and the agents it names on the message itself, so an app that installs the SDK from npm sends them. Before, they reached Athena only on the monorepo's patched package. A send `AthenaChat` makes moves its conversation to the top of the list, as a composer send does.
1351
+
1352
+ Also in this release:
1353
+ - Requests from the data hooks (`useAthenaFetch`, `useAthenaGraphQL`, `useAthenaRest`, `useSemanticQuery`) name the SDK and its version in `X-Athena-Client`. This is telemetry only: Athena never admits or refuses a request by it.
1354
+ - `connectGenericDoc` takes `keepHttpFirstRead` with `httpFirstOpen`, and its handle's `takeHttpFirstRead()` hands over the bytes the room's HTTP-first read applied, once.
1355
+ - A session workspace or live desktop read that agora answers "Asset … not found" now says the asset does not exist or the signed-in user may not read it.
1356
+ - Tool labels for the Chrome extension's page subscriptions, and the "Always keep on this device" wording for offline availability.
1357
+
1335
1358
  ## What's new in 0.59.0
1336
1359
 
1337
1360
  **Every message is one `run/send`, and the chat needs an Athena that serves it.** The SDK moves to `statewire` 0.27.1 and `@assistant-ui/react-statewire` 0.35.1 (from 0.25.0 and 0.34.0) and speaks Athena's 2026-11-04 command contract. A new message goes as `run/send` with a behavior: `auto` from the composer (while a run is live, Athena decides whether the message joins the running turn, waits for a turn of its own, or stops the run for it), and `queue`, `steer` or `preempt` (Send now) from the queue tray. 0.58.0 sent `run/enqueue`, `run/steer`, `run/steer-live` and `run/prioritize`. The transport offers the 2026-11-04 contract only, on wire 2026-11-01. Athena's hosts serve it from the release that added `run/send` (agora on Python statewire 0.23.1, Athena's managed cloud since November 8, 2026); an older Athena cannot serve a 0.59.0 chat, so a customer deployment moves its own Athena first, then its apps. Messages an earlier SDK kept on the device are read back as the `run/send` each one meant, and session records stay inside `localStorage`'s quota.
@@ -2523,9 +2546,14 @@ app. `AthenaChat` renders them as chips with nothing to configure, and so do `Ch
2523
2546
  `TiptapText`, `MentionLink` and `parseMentionsInText` in a renderer of your own
2524
2547
  (`@athenaintel/react/chat-ui`). A chip shows:
2525
2548
 
2526
- - **the asset type's icon.** Hosts that serve Athena's asset-type SVGs point the chips at them with
2527
- `configureAssetIconBasePath({ basePath })`. Every other host gets a Lucide icon for the type,
2528
- and a repository file's lines read as a code file.
2549
+ - **the asset type's icon**, the one Olympus draws for that type. The SVGs load from the Athena web
2550
+ app the chat talks to (`<appUrl>/assets/icons/files/`, public files, no credential), each chat
2551
+ from its own provider's. A host that ships its own copy of the type glyphs points the chips at it
2552
+ with `configureAssetIconBasePath({ basePath })`; brand logos (Gmail, Outlook, Power BI, Slack)
2553
+ still come from the app. Outside a provider, or where the SVG cannot load (a strict `img-src`
2554
+ CSP, offline), the chip draws a Lucide icon for the type. A renderer of your own draws the same
2555
+ icon with `assetTypeToThumb(type, { appUrl: useAssetIconAppUrl() })`. A repository file's lines
2556
+ read as a code file.
2529
2557
  - **the label the link was written with**, then **the cited place** in grey (`p.5`, `Slide 3`,
2530
2558
  `'Buy List'!A1:B2`, `jwt.py:12-25`). The SDK reads and labels the place with the same parser
2531
2559
  Olympus's chips use. A citation written without a label is named by its place (`Page 5`).
@@ -2547,6 +2575,11 @@ Folders), plus Tools and Toolkits when `mentionTools` (or the provider's toolkit
2547
2575
  <AthenaChat mentionCategories={['favorites', 'assets', 'projects', 'folders', 'team', 'agents']} />
2548
2576
  ```
2549
2577
 
2578
+ Each asset row draws the icon Olympus's menu draws for its type (from the same SVGs as the chips,
2579
+ see **Mentions and citations**), and an email whose mailbox is recorded on it draws Gmail's or
2580
+ Outlook's logo. The search reads no metadata; a page with email rows reads theirs alone
2581
+ (`getAssetsByIds`), and lists them with the mail icon when that read fails.
2582
+
2550
2583
  - **`team`** — people, as Olympus's chat composer offers them: the workspace's members, then its
2551
2584
  groups. An Athena admin's people come ranked, the people they talked to most recently first,
2552
2585
  with the people they saved who are not members. They are read with the chat's own credentials
@@ -98,21 +98,6 @@ function takeStagedComposerMarkdownMetadata(threadId, now = Date.now()) {
98
98
  }
99
99
  return { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: entry.markdown };
100
100
  }
101
- function withStagedComposerMarkdown({
102
- threadId,
103
- getMessageMetadata
104
- }) {
105
- return () => {
106
- const staged = takeStagedComposerMarkdownMetadata(threadId);
107
- const captured = getMessageMetadata == null ? void 0 : getMessageMetadata();
108
- if (staged === void 0) return captured;
109
- const merge = (host) => ({
110
- ...host,
111
- ...staged
112
- });
113
- return captured instanceof Promise ? captured.then(merge) : merge(captured);
114
- };
115
- }
116
101
  function withDeepAgentComposerMarkdown({
117
102
  metadata,
118
103
  text,
@@ -130,5 +115,4 @@ exports.stageComposerMarkdown = stageComposerMarkdown;
130
115
  exports.takeStagedComposerMarkdownMetadata = takeStagedComposerMarkdownMetadata;
131
116
  exports.trimComposerDraft = trimComposerDraft;
132
117
  exports.withDeepAgentComposerMarkdown = withDeepAgentComposerMarkdown;
133
- exports.withStagedComposerMarkdown = withStagedComposerMarkdown;
134
118
  //# sourceMappingURL=composer-markdown.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"composer-markdown.cjs","sources":["../../../../agent-runtime/src/composer-markdown.ts"],"sourcesContent":["/**\n * The composer markdown a chat message was sent from.\n *\n * The chat composer sends a message's plain text exactly as typed, because the\n * model reads the text literally, and writes real formatting (bold, lists,\n * code, mention chips) as markdown. The sent text alone no longer says which\n * characters are formatting and which were typed: literal `**strong**` and\n * bold `strong` both send `**strong**`, and `List<T>`, `a\\\\b` or `2*3*4` read\n * as markup when the text is rendered as markdown. So the composer also\n * attaches the draft the text was serialized from (the same document with its\n * plain text escaped for markdown) under {@link DEEP_AGENT_COMPOSER_MARKDOWN_KEY}\n * in the message's metadata, and only when the two differ.\n *\n * It rides the statewire message, the queue and steer lanes and the persisted\n * HumanMessage (`additional_kwargs.metadata`, which the converter surfaces as\n * `metadata.custom`). It is display-only: the bubble renders it, and Edit,\n * branch-and-edit and a withdrawn turn load it back into the composer. The\n * model reads the message text, never this. A message without it (older\n * messages, other clients) renders its text as before.\n *\n * agora validates it with the same rule\n * (`agora/graphs/athena_deep_agent/composer_markdown.py`).\n */\nexport const DEEP_AGENT_COMPOSER_MARKDOWN_KEY = 'composer_markdown';\n\nconst DRAFT_CHARACTER_REFERENCES: Readonly<Record<string, string>> = {\n lt: '<',\n gt: '>',\n amp: '&',\n '#32': ' ',\n '#9': '\\t',\n};\n\n/**\n * A sent text in comparable form: every backslash dropped (the draft's\n * escapes, and the `\\` before a hard break's newline, are backslashes).\n */\nconst comparableText = (text: string): string => text.replace(/\\\\/g, '');\n\n/**\n * A draft in comparable form: every backslash dropped, then the character\n * references the draft serializer writes decoded in one pass (`&lt;` and\n * `&gt;` for typed `<` and `>`, `&amp;` for a typed `&` that starts a\n * reference, so typed `&lt;` is `&amp;lt;`, and `&#32;` / `&#9;` for the space\n * or tab a line starts with, which markdown would otherwise drop).\n */\nconst comparableDraft = (markdown: string): string =>\n comparableText(markdown).replace(\n /&(lt|gt|amp|#32|#9);/g,\n (reference, name: string) => DRAFT_CHARACTER_REFERENCES[name] ?? reference\n );\n\n/**\n * Escaping at most turns one character into four (`<` → `&lt;`). A line's\n * first space becomes five (`&#32;`), but only once a line, so a whole line\n * with its line break stays under four a character; the slack covers short\n * texts. agora caps a stored draft the same way.\n */\nconst MAX_EXPANSION = 4;\nconst EXPANSION_SLACK_CHARS = 1024;\n\n/**\n * Whether `markdown` is the draft of `text`: no longer than escaping could make\n * it, and equal to it once backslashes are dropped and the draft's character\n * references decoded. A draft that would show other\n * words than the text the model read (another message's, a client's mistake) is\n * not taken; such a message renders its text as before.\n */\nexport function isComposerMarkdownOfText({\n markdown,\n text,\n}: {\n markdown: string;\n text: string;\n}): boolean {\n return (\n markdown.length <= text.length * MAX_EXPANSION + EXPANSION_SLACK_CHARS &&\n comparableDraft(markdown) === comparableText(text)\n );\n}\n\n/** The references a draft writes a line's first space or tab as. */\nconst DRAFT_WHITESPACE_REFERENCES: readonly string[] = ['&#32;', '&#9;'];\n\nconst isWhitespace = (character: string): boolean => /\\s/.test(character);\n\n/** A hard break in a draft: `\\` before the newline (the text holds the newline alone). */\nconst DRAFT_HARD_BREAK = '\\\\\\n';\n\n/** Whether an odd run of backslashes ends at `end`: its last one escapes what follows, not a typed `\\` (`\\\\`). */\nfunction endsInEscapingBackslash({\n markdown,\n start,\n end,\n}: {\n markdown: string;\n start: number;\n end: number;\n}): boolean {\n let backslashes = 0;\n while (end - backslashes > start && markdown.charAt(end - backslashes - 1) === '\\\\') {\n backslashes += 1;\n }\n return backslashes % 2 === 1;\n}\n\n/**\n * `markdown`, a draft, trimmed as its text is trimmed (`text.trim()`): a\n * send that trims its text stays a draft of it. `markdown.trim()` would keep\n * the `&#32;` a draft writes for the space its first line starts with, and the\n * `\\` of a line break whose newline it trims.\n */\nexport function trimComposerDraft(markdown: string): string {\n let start = 0;\n let end = markdown.length;\n for (;;) {\n while (start < end && isWhitespace(markdown.charAt(start))) start += 1;\n const reference = [...DRAFT_WHITESPACE_REFERENCES, DRAFT_HARD_BREAK].find(\n (candidate) => start + candidate.length <= end && markdown.startsWith(candidate, start)\n );\n if (reference === undefined) break;\n start += reference.length;\n }\n for (;;) {\n while (end > start && isWhitespace(markdown.charAt(end - 1))) end -= 1;\n const reference = DRAFT_WHITESPACE_REFERENCES.find(\n (candidate) => end - candidate.length >= start && markdown.endsWith(candidate, end)\n );\n if (reference !== undefined) {\n end -= reference.length;\n } else if (\n markdown.charAt(end) === '\\n' &&\n endsInEscapingBackslash({ markdown, start, end })\n ) {\n // The newline just trimmed was a line break's: its `\\` goes with it.\n end -= 1;\n } else {\n break;\n }\n }\n return markdown.slice(start, end);\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/**\n * The composer markdown to render or load for a message whose metadata is\n * `metadata` (a message's `metadata.custom`, or a lane entry's `metadata`) and\n * whose text is `text`; `null` when it carries none, or one that is not a\n * draft of `text`.\n */\nexport function readDeepAgentComposerMarkdown({\n metadata,\n text,\n}: {\n metadata: unknown;\n text: string;\n}): string | null {\n if (!isRecord(metadata)) return null;\n const markdown: unknown = metadata[DEEP_AGENT_COMPOSER_MARKDOWN_KEY];\n return typeof markdown === 'string' && isComposerMarkdownOfText({ markdown, text })\n ? markdown\n : null;\n}\n\n/**\n * How long a staged composer markdown waits for its submission\n * ({@link stageComposerMarkdown}).\n */\nexport const STAGED_COMPOSER_MARKDOWN_TTL_MS = 10_000;\n\ninterface StagedComposerMarkdown {\n readonly markdown: string;\n readonly stagedAt: number;\n}\n\n/**\n * The staged drafts live on `globalThis`, not in module scope, because two\n * copies of this module run in one page: the SDK bundles agent-runtime into its\n * dist, so its composer stages through the SDK's copy, while the Chrome\n * extension and the Office add-in build their runtime on agent-runtime directly\n * and take the draft through their own (see `asset-attachment.ts`).\n */\nconst STAGED_COMPOSER_MARKDOWN = Symbol.for('@athenaintel/agent-runtime/staged-composer-markdown');\n\ntype StagedComposerMarkdownHost = typeof globalThis & {\n [STAGED_COMPOSER_MARKDOWN]?: Map<string, StagedComposerMarkdown>;\n};\n\nfunction stagedComposerMarkdownByThread(): Map<string, StagedComposerMarkdown> {\n const host = globalThis as StagedComposerMarkdownHost;\n return (host[STAGED_COMPOSER_MARKDOWN] ??= new Map());\n}\n\n/**\n * Stage the draft `markdown` of `text` for `threadId`'s next submission.\n *\n * statewire captures message metadata once per new user message\n * (`getMessageMetadata`, patched into `@assistant-ui/react-statewire@0.33.0`)\n * without saying which message it is for, so a composer stages its draft right\n * before it sends and the runtime's getter takes it for the submission that\n * follows ({@link withStagedComposerMarkdown}). A draft equal to its text adds\n * nothing and clears the slot. A slot nobody takes within\n * {@link STAGED_COMPOSER_MARKDOWN_TTL_MS} expires; one a submission the\n * composer did not make takes is harmless, since a composer markdown that is\n * not a draft of the message's own text is neither kept by agora nor rendered.\n */\nexport function stageComposerMarkdown({\n threadId,\n text,\n markdown,\n now = Date.now(),\n}: {\n threadId: string;\n text: string;\n markdown: string;\n /** Test seam: the time of the send. */\n now?: number;\n}): void {\n const staged = stagedComposerMarkdownByThread();\n if (markdown === text) {\n staged.delete(threadId);\n return;\n }\n staged.set(threadId, { markdown, stagedAt: now });\n}\n\n/**\n * The message metadata for `threadId`'s submission now being journaled: the\n * staged composer markdown, if one is waiting and fresh. Takes the slot.\n */\nexport function takeStagedComposerMarkdownMetadata(\n threadId: string,\n now: number = Date.now()\n): Record<string, unknown> | undefined {\n const staged = stagedComposerMarkdownByThread();\n const entry = staged.get(threadId);\n staged.delete(threadId);\n if (entry === undefined || now - entry.stagedAt > STAGED_COMPOSER_MARKDOWN_TTL_MS) {\n return undefined;\n }\n return { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: entry.markdown };\n}\n\ntype MessageMetadataGetter = () =>\n | Record<string, unknown>\n | undefined\n | Promise<Record<string, unknown> | undefined>;\n\n/**\n * A runtime's `getMessageMetadata` that also carries the composer markdown a\n * composer staged for `threadId` ({@link stageComposerMarkdown}), on top of the\n * host's own metadata (`getMessageMetadata`, when it has one). The slot is\n * taken synchronously at submission, before the host's capture resolves, so a\n * draft never rides a later message. With nothing staged the host's own value\n * is returned as is.\n */\nexport function withStagedComposerMarkdown({\n threadId,\n getMessageMetadata,\n}: {\n threadId: string;\n getMessageMetadata?: MessageMetadataGetter | undefined;\n}): MessageMetadataGetter {\n return () => {\n const staged = takeStagedComposerMarkdownMetadata(threadId);\n const captured = getMessageMetadata?.();\n if (staged === undefined) return captured;\n const merge = (host: Record<string, unknown> | undefined): Record<string, unknown> => ({\n ...host,\n ...staged,\n });\n return captured instanceof Promise ? captured.then(merge) : merge(captured);\n };\n}\n\n/** Test seam: drop every staged composer markdown. */\nexport function resetStagedComposerMarkdown(): void {\n stagedComposerMarkdownByThread().clear();\n}\n\n/**\n * `metadata` with the composer markdown set to the draft `markdown` of `text`,\n * or without it when the draft equals the text (it adds nothing then).\n * Returns `undefined` when nothing is left.\n */\nexport function withDeepAgentComposerMarkdown({\n metadata,\n text,\n markdown,\n}: {\n metadata: Readonly<Record<string, unknown>> | undefined;\n text: string;\n markdown: string;\n}): Record<string, unknown> | undefined {\n const { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: _previous, ...rest } = metadata ?? {};\n const next: Record<string, unknown> =\n markdown === text ? rest : { ...rest, [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: markdown };\n return Object.keys(next).length > 0 ? next : undefined;\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"composer-markdown.cjs","sources":["../../../../agent-runtime/src/composer-markdown.ts"],"sourcesContent":["/**\n * The composer markdown a chat message was sent from.\n *\n * The chat composer sends a message's plain text exactly as typed, because the\n * model reads the text literally, and writes real formatting (bold, lists,\n * code, mention chips) as markdown. The sent text alone no longer says which\n * characters are formatting and which were typed: literal `**strong**` and\n * bold `strong` both send `**strong**`, and `List<T>`, `a\\\\b` or `2*3*4` read\n * as markup when the text is rendered as markdown. So the composer also\n * attaches the draft the text was serialized from (the same document with its\n * plain text escaped for markdown) under {@link DEEP_AGENT_COMPOSER_MARKDOWN_KEY}\n * in the message's metadata, and only when the two differ.\n *\n * It rides the statewire message, the queue and steer lanes and the persisted\n * HumanMessage (`additional_kwargs.metadata`, which the converter surfaces as\n * `metadata.custom`). It is display-only: the bubble renders it, and Edit,\n * branch-and-edit and a withdrawn turn load it back into the composer. The\n * model reads the message text, never this. A message without it (older\n * messages, other clients) renders its text as before.\n *\n * agora validates it with the same rule\n * (`agora/graphs/athena_deep_agent/composer_markdown.py`).\n */\nexport const DEEP_AGENT_COMPOSER_MARKDOWN_KEY = 'composer_markdown';\n\nconst DRAFT_CHARACTER_REFERENCES: Readonly<Record<string, string>> = {\n lt: '<',\n gt: '>',\n amp: '&',\n '#32': ' ',\n '#9': '\\t',\n};\n\n/**\n * A sent text in comparable form: every backslash dropped (the draft's\n * escapes, and the `\\` before a hard break's newline, are backslashes).\n */\nconst comparableText = (text: string): string => text.replace(/\\\\/g, '');\n\n/**\n * A draft in comparable form: every backslash dropped, then the character\n * references the draft serializer writes decoded in one pass (`&lt;` and\n * `&gt;` for typed `<` and `>`, `&amp;` for a typed `&` that starts a\n * reference, so typed `&lt;` is `&amp;lt;`, and `&#32;` / `&#9;` for the space\n * or tab a line starts with, which markdown would otherwise drop).\n */\nconst comparableDraft = (markdown: string): string =>\n comparableText(markdown).replace(\n /&(lt|gt|amp|#32|#9);/g,\n (reference, name: string) => DRAFT_CHARACTER_REFERENCES[name] ?? reference\n );\n\n/**\n * Escaping at most turns one character into four (`<` → `&lt;`). A line's\n * first space becomes five (`&#32;`), but only once a line, so a whole line\n * with its line break stays under four a character; the slack covers short\n * texts. agora caps a stored draft the same way.\n */\nconst MAX_EXPANSION = 4;\nconst EXPANSION_SLACK_CHARS = 1024;\n\n/**\n * Whether `markdown` is the draft of `text`: no longer than escaping could make\n * it, and equal to it once backslashes are dropped and the draft's character\n * references decoded. A draft that would show other\n * words than the text the model read (another message's, a client's mistake) is\n * not taken; such a message renders its text as before.\n */\nexport function isComposerMarkdownOfText({\n markdown,\n text,\n}: {\n markdown: string;\n text: string;\n}): boolean {\n return (\n markdown.length <= text.length * MAX_EXPANSION + EXPANSION_SLACK_CHARS &&\n comparableDraft(markdown) === comparableText(text)\n );\n}\n\n/** The references a draft writes a line's first space or tab as. */\nconst DRAFT_WHITESPACE_REFERENCES: readonly string[] = ['&#32;', '&#9;'];\n\nconst isWhitespace = (character: string): boolean => /\\s/.test(character);\n\n/** A hard break in a draft: `\\` before the newline (the text holds the newline alone). */\nconst DRAFT_HARD_BREAK = '\\\\\\n';\n\n/** Whether an odd run of backslashes ends at `end`: its last one escapes what follows, not a typed `\\` (`\\\\`). */\nfunction endsInEscapingBackslash({\n markdown,\n start,\n end,\n}: {\n markdown: string;\n start: number;\n end: number;\n}): boolean {\n let backslashes = 0;\n while (end - backslashes > start && markdown.charAt(end - backslashes - 1) === '\\\\') {\n backslashes += 1;\n }\n return backslashes % 2 === 1;\n}\n\n/**\n * `markdown`, a draft, trimmed as its text is trimmed (`text.trim()`): a\n * send that trims its text stays a draft of it. `markdown.trim()` would keep\n * the `&#32;` a draft writes for the space its first line starts with, and the\n * `\\` of a line break whose newline it trims.\n */\nexport function trimComposerDraft(markdown: string): string {\n let start = 0;\n let end = markdown.length;\n for (;;) {\n while (start < end && isWhitespace(markdown.charAt(start))) start += 1;\n const reference = [...DRAFT_WHITESPACE_REFERENCES, DRAFT_HARD_BREAK].find(\n (candidate) => start + candidate.length <= end && markdown.startsWith(candidate, start)\n );\n if (reference === undefined) break;\n start += reference.length;\n }\n for (;;) {\n while (end > start && isWhitespace(markdown.charAt(end - 1))) end -= 1;\n const reference = DRAFT_WHITESPACE_REFERENCES.find(\n (candidate) => end - candidate.length >= start && markdown.endsWith(candidate, end)\n );\n if (reference !== undefined) {\n end -= reference.length;\n } else if (\n markdown.charAt(end) === '\\n' &&\n endsInEscapingBackslash({ markdown, start, end })\n ) {\n // The newline just trimmed was a line break's: its `\\` goes with it.\n end -= 1;\n } else {\n break;\n }\n }\n return markdown.slice(start, end);\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/**\n * The composer markdown to render or load for a message whose metadata is\n * `metadata` (a message's `metadata.custom`, or a lane entry's `metadata`) and\n * whose text is `text`; `null` when it carries none, or one that is not a\n * draft of `text`.\n */\nexport function readDeepAgentComposerMarkdown({\n metadata,\n text,\n}: {\n metadata: unknown;\n text: string;\n}): string | null {\n if (!isRecord(metadata)) return null;\n const markdown: unknown = metadata[DEEP_AGENT_COMPOSER_MARKDOWN_KEY];\n return typeof markdown === 'string' && isComposerMarkdownOfText({ markdown, text })\n ? markdown\n : null;\n}\n\n/**\n * How long a staged composer markdown waits for its submission\n * ({@link stageComposerMarkdown}).\n */\nexport const STAGED_COMPOSER_MARKDOWN_TTL_MS = 10_000;\n\ninterface StagedComposerMarkdown {\n readonly markdown: string;\n readonly stagedAt: number;\n}\n\n/**\n * The staged drafts live on `globalThis`, not in module scope, because two\n * copies of this module run in one page: the SDK bundles agent-runtime into its\n * dist, so its composer stages through the SDK's copy, while the Chrome\n * extension and the Office add-in build their runtime on agent-runtime directly\n * and take the draft through their own (see `asset-attachment.ts`).\n */\nconst STAGED_COMPOSER_MARKDOWN = Symbol.for('@athenaintel/agent-runtime/staged-composer-markdown');\n\ntype StagedComposerMarkdownHost = typeof globalThis & {\n [STAGED_COMPOSER_MARKDOWN]?: Map<string, StagedComposerMarkdown>;\n};\n\nfunction stagedComposerMarkdownByThread(): Map<string, StagedComposerMarkdown> {\n const host = globalThis as StagedComposerMarkdownHost;\n return (host[STAGED_COMPOSER_MARKDOWN] ??= new Map());\n}\n\n/**\n * Stage the draft `markdown` of `text` for `threadId`'s next submission.\n *\n * statewire captures message metadata once per new user message\n * (`getMessageMetadata`, patched into `@assistant-ui/react-statewire@0.33.0`)\n * without saying which message it is for, so a composer stages its draft right\n * before it sends and the runtime's getter takes it for the submission that\n * follows ({@link withStagedComposerMarkdown}). A draft equal to its text adds\n * nothing and clears the slot. A slot nobody takes within\n * {@link STAGED_COMPOSER_MARKDOWN_TTL_MS} expires; one a submission the\n * composer did not make takes is harmless, since a composer markdown that is\n * not a draft of the message's own text is neither kept by agora nor rendered.\n */\nexport function stageComposerMarkdown({\n threadId,\n text,\n markdown,\n now = Date.now(),\n}: {\n threadId: string;\n text: string;\n markdown: string;\n /** Test seam: the time of the send. */\n now?: number;\n}): void {\n const staged = stagedComposerMarkdownByThread();\n if (markdown === text) {\n staged.delete(threadId);\n return;\n }\n staged.set(threadId, { markdown, stagedAt: now });\n}\n\n/**\n * The message metadata for `threadId`'s submission now being journaled: the\n * staged composer markdown, if one is waiting and fresh. Takes the slot.\n */\nexport function takeStagedComposerMarkdownMetadata(\n threadId: string,\n now: number = Date.now()\n): Record<string, unknown> | undefined {\n const staged = stagedComposerMarkdownByThread();\n const entry = staged.get(threadId);\n staged.delete(threadId);\n if (entry === undefined || now - entry.stagedAt > STAGED_COMPOSER_MARKDOWN_TTL_MS) {\n return undefined;\n }\n return { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: entry.markdown };\n}\n\ntype MessageMetadataGetter = () =>\n | Record<string, unknown>\n | undefined\n | Promise<Record<string, unknown> | undefined>;\n\n/**\n * A runtime's `getMessageMetadata` that also carries the composer markdown a\n * composer staged for `threadId` ({@link stageComposerMarkdown}), on top of the\n * host's own metadata (`getMessageMetadata`, when it has one). The slot is\n * taken synchronously at submission, before the host's capture resolves, so a\n * draft never rides a later message. With nothing staged the host's own value\n * is returned as is.\n */\nexport function withStagedComposerMarkdown({\n threadId,\n getMessageMetadata,\n}: {\n threadId: string;\n getMessageMetadata?: MessageMetadataGetter | undefined;\n}): MessageMetadataGetter {\n return () => {\n const staged = takeStagedComposerMarkdownMetadata(threadId);\n const captured = getMessageMetadata?.();\n if (staged === undefined) return captured;\n const merge = (host: Record<string, unknown> | undefined): Record<string, unknown> => ({\n ...host,\n ...staged,\n });\n return captured instanceof Promise ? captured.then(merge) : merge(captured);\n };\n}\n\n/** Test seam: drop every staged composer markdown. */\nexport function resetStagedComposerMarkdown(): void {\n stagedComposerMarkdownByThread().clear();\n}\n\n/**\n * `metadata` with the composer markdown set to the draft `markdown` of `text`,\n * or without it when the draft equals the text (it adds nothing then).\n * Returns `undefined` when nothing is left.\n */\nexport function withDeepAgentComposerMarkdown({\n metadata,\n text,\n markdown,\n}: {\n metadata: Readonly<Record<string, unknown>> | undefined;\n text: string;\n markdown: string;\n}): Record<string, unknown> | undefined {\n const { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: _previous, ...rest } = metadata ?? {};\n const next: Record<string, unknown> =\n markdown === text ? rest : { ...rest, [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: markdown };\n return Object.keys(next).length > 0 ? next : undefined;\n}\n"],"names":[],"mappings":"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"}
@@ -96,21 +96,6 @@ function takeStagedComposerMarkdownMetadata(threadId, now = Date.now()) {
96
96
  }
97
97
  return { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: entry.markdown };
98
98
  }
99
- function withStagedComposerMarkdown({
100
- threadId,
101
- getMessageMetadata
102
- }) {
103
- return () => {
104
- const staged = takeStagedComposerMarkdownMetadata(threadId);
105
- const captured = getMessageMetadata == null ? void 0 : getMessageMetadata();
106
- if (staged === void 0) return captured;
107
- const merge = (host) => ({
108
- ...host,
109
- ...staged
110
- });
111
- return captured instanceof Promise ? captured.then(merge) : merge(captured);
112
- };
113
- }
114
99
  function withDeepAgentComposerMarkdown({
115
100
  metadata,
116
101
  text,
@@ -128,7 +113,6 @@ export {
128
113
  stageComposerMarkdown,
129
114
  takeStagedComposerMarkdownMetadata,
130
115
  trimComposerDraft,
131
- withDeepAgentComposerMarkdown,
132
- withStagedComposerMarkdown
116
+ withDeepAgentComposerMarkdown
133
117
  };
134
118
  //# sourceMappingURL=composer-markdown.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"composer-markdown.js","sources":["../../../../agent-runtime/src/composer-markdown.ts"],"sourcesContent":["/**\n * The composer markdown a chat message was sent from.\n *\n * The chat composer sends a message's plain text exactly as typed, because the\n * model reads the text literally, and writes real formatting (bold, lists,\n * code, mention chips) as markdown. The sent text alone no longer says which\n * characters are formatting and which were typed: literal `**strong**` and\n * bold `strong` both send `**strong**`, and `List<T>`, `a\\\\b` or `2*3*4` read\n * as markup when the text is rendered as markdown. So the composer also\n * attaches the draft the text was serialized from (the same document with its\n * plain text escaped for markdown) under {@link DEEP_AGENT_COMPOSER_MARKDOWN_KEY}\n * in the message's metadata, and only when the two differ.\n *\n * It rides the statewire message, the queue and steer lanes and the persisted\n * HumanMessage (`additional_kwargs.metadata`, which the converter surfaces as\n * `metadata.custom`). It is display-only: the bubble renders it, and Edit,\n * branch-and-edit and a withdrawn turn load it back into the composer. The\n * model reads the message text, never this. A message without it (older\n * messages, other clients) renders its text as before.\n *\n * agora validates it with the same rule\n * (`agora/graphs/athena_deep_agent/composer_markdown.py`).\n */\nexport const DEEP_AGENT_COMPOSER_MARKDOWN_KEY = 'composer_markdown';\n\nconst DRAFT_CHARACTER_REFERENCES: Readonly<Record<string, string>> = {\n lt: '<',\n gt: '>',\n amp: '&',\n '#32': ' ',\n '#9': '\\t',\n};\n\n/**\n * A sent text in comparable form: every backslash dropped (the draft's\n * escapes, and the `\\` before a hard break's newline, are backslashes).\n */\nconst comparableText = (text: string): string => text.replace(/\\\\/g, '');\n\n/**\n * A draft in comparable form: every backslash dropped, then the character\n * references the draft serializer writes decoded in one pass (`&lt;` and\n * `&gt;` for typed `<` and `>`, `&amp;` for a typed `&` that starts a\n * reference, so typed `&lt;` is `&amp;lt;`, and `&#32;` / `&#9;` for the space\n * or tab a line starts with, which markdown would otherwise drop).\n */\nconst comparableDraft = (markdown: string): string =>\n comparableText(markdown).replace(\n /&(lt|gt|amp|#32|#9);/g,\n (reference, name: string) => DRAFT_CHARACTER_REFERENCES[name] ?? reference\n );\n\n/**\n * Escaping at most turns one character into four (`<` → `&lt;`). A line's\n * first space becomes five (`&#32;`), but only once a line, so a whole line\n * with its line break stays under four a character; the slack covers short\n * texts. agora caps a stored draft the same way.\n */\nconst MAX_EXPANSION = 4;\nconst EXPANSION_SLACK_CHARS = 1024;\n\n/**\n * Whether `markdown` is the draft of `text`: no longer than escaping could make\n * it, and equal to it once backslashes are dropped and the draft's character\n * references decoded. A draft that would show other\n * words than the text the model read (another message's, a client's mistake) is\n * not taken; such a message renders its text as before.\n */\nexport function isComposerMarkdownOfText({\n markdown,\n text,\n}: {\n markdown: string;\n text: string;\n}): boolean {\n return (\n markdown.length <= text.length * MAX_EXPANSION + EXPANSION_SLACK_CHARS &&\n comparableDraft(markdown) === comparableText(text)\n );\n}\n\n/** The references a draft writes a line's first space or tab as. */\nconst DRAFT_WHITESPACE_REFERENCES: readonly string[] = ['&#32;', '&#9;'];\n\nconst isWhitespace = (character: string): boolean => /\\s/.test(character);\n\n/** A hard break in a draft: `\\` before the newline (the text holds the newline alone). */\nconst DRAFT_HARD_BREAK = '\\\\\\n';\n\n/** Whether an odd run of backslashes ends at `end`: its last one escapes what follows, not a typed `\\` (`\\\\`). */\nfunction endsInEscapingBackslash({\n markdown,\n start,\n end,\n}: {\n markdown: string;\n start: number;\n end: number;\n}): boolean {\n let backslashes = 0;\n while (end - backslashes > start && markdown.charAt(end - backslashes - 1) === '\\\\') {\n backslashes += 1;\n }\n return backslashes % 2 === 1;\n}\n\n/**\n * `markdown`, a draft, trimmed as its text is trimmed (`text.trim()`): a\n * send that trims its text stays a draft of it. `markdown.trim()` would keep\n * the `&#32;` a draft writes for the space its first line starts with, and the\n * `\\` of a line break whose newline it trims.\n */\nexport function trimComposerDraft(markdown: string): string {\n let start = 0;\n let end = markdown.length;\n for (;;) {\n while (start < end && isWhitespace(markdown.charAt(start))) start += 1;\n const reference = [...DRAFT_WHITESPACE_REFERENCES, DRAFT_HARD_BREAK].find(\n (candidate) => start + candidate.length <= end && markdown.startsWith(candidate, start)\n );\n if (reference === undefined) break;\n start += reference.length;\n }\n for (;;) {\n while (end > start && isWhitespace(markdown.charAt(end - 1))) end -= 1;\n const reference = DRAFT_WHITESPACE_REFERENCES.find(\n (candidate) => end - candidate.length >= start && markdown.endsWith(candidate, end)\n );\n if (reference !== undefined) {\n end -= reference.length;\n } else if (\n markdown.charAt(end) === '\\n' &&\n endsInEscapingBackslash({ markdown, start, end })\n ) {\n // The newline just trimmed was a line break's: its `\\` goes with it.\n end -= 1;\n } else {\n break;\n }\n }\n return markdown.slice(start, end);\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/**\n * The composer markdown to render or load for a message whose metadata is\n * `metadata` (a message's `metadata.custom`, or a lane entry's `metadata`) and\n * whose text is `text`; `null` when it carries none, or one that is not a\n * draft of `text`.\n */\nexport function readDeepAgentComposerMarkdown({\n metadata,\n text,\n}: {\n metadata: unknown;\n text: string;\n}): string | null {\n if (!isRecord(metadata)) return null;\n const markdown: unknown = metadata[DEEP_AGENT_COMPOSER_MARKDOWN_KEY];\n return typeof markdown === 'string' && isComposerMarkdownOfText({ markdown, text })\n ? markdown\n : null;\n}\n\n/**\n * How long a staged composer markdown waits for its submission\n * ({@link stageComposerMarkdown}).\n */\nexport const STAGED_COMPOSER_MARKDOWN_TTL_MS = 10_000;\n\ninterface StagedComposerMarkdown {\n readonly markdown: string;\n readonly stagedAt: number;\n}\n\n/**\n * The staged drafts live on `globalThis`, not in module scope, because two\n * copies of this module run in one page: the SDK bundles agent-runtime into its\n * dist, so its composer stages through the SDK's copy, while the Chrome\n * extension and the Office add-in build their runtime on agent-runtime directly\n * and take the draft through their own (see `asset-attachment.ts`).\n */\nconst STAGED_COMPOSER_MARKDOWN = Symbol.for('@athenaintel/agent-runtime/staged-composer-markdown');\n\ntype StagedComposerMarkdownHost = typeof globalThis & {\n [STAGED_COMPOSER_MARKDOWN]?: Map<string, StagedComposerMarkdown>;\n};\n\nfunction stagedComposerMarkdownByThread(): Map<string, StagedComposerMarkdown> {\n const host = globalThis as StagedComposerMarkdownHost;\n return (host[STAGED_COMPOSER_MARKDOWN] ??= new Map());\n}\n\n/**\n * Stage the draft `markdown` of `text` for `threadId`'s next submission.\n *\n * statewire captures message metadata once per new user message\n * (`getMessageMetadata`, patched into `@assistant-ui/react-statewire@0.33.0`)\n * without saying which message it is for, so a composer stages its draft right\n * before it sends and the runtime's getter takes it for the submission that\n * follows ({@link withStagedComposerMarkdown}). A draft equal to its text adds\n * nothing and clears the slot. A slot nobody takes within\n * {@link STAGED_COMPOSER_MARKDOWN_TTL_MS} expires; one a submission the\n * composer did not make takes is harmless, since a composer markdown that is\n * not a draft of the message's own text is neither kept by agora nor rendered.\n */\nexport function stageComposerMarkdown({\n threadId,\n text,\n markdown,\n now = Date.now(),\n}: {\n threadId: string;\n text: string;\n markdown: string;\n /** Test seam: the time of the send. */\n now?: number;\n}): void {\n const staged = stagedComposerMarkdownByThread();\n if (markdown === text) {\n staged.delete(threadId);\n return;\n }\n staged.set(threadId, { markdown, stagedAt: now });\n}\n\n/**\n * The message metadata for `threadId`'s submission now being journaled: the\n * staged composer markdown, if one is waiting and fresh. Takes the slot.\n */\nexport function takeStagedComposerMarkdownMetadata(\n threadId: string,\n now: number = Date.now()\n): Record<string, unknown> | undefined {\n const staged = stagedComposerMarkdownByThread();\n const entry = staged.get(threadId);\n staged.delete(threadId);\n if (entry === undefined || now - entry.stagedAt > STAGED_COMPOSER_MARKDOWN_TTL_MS) {\n return undefined;\n }\n return { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: entry.markdown };\n}\n\ntype MessageMetadataGetter = () =>\n | Record<string, unknown>\n | undefined\n | Promise<Record<string, unknown> | undefined>;\n\n/**\n * A runtime's `getMessageMetadata` that also carries the composer markdown a\n * composer staged for `threadId` ({@link stageComposerMarkdown}), on top of the\n * host's own metadata (`getMessageMetadata`, when it has one). The slot is\n * taken synchronously at submission, before the host's capture resolves, so a\n * draft never rides a later message. With nothing staged the host's own value\n * is returned as is.\n */\nexport function withStagedComposerMarkdown({\n threadId,\n getMessageMetadata,\n}: {\n threadId: string;\n getMessageMetadata?: MessageMetadataGetter | undefined;\n}): MessageMetadataGetter {\n return () => {\n const staged = takeStagedComposerMarkdownMetadata(threadId);\n const captured = getMessageMetadata?.();\n if (staged === undefined) return captured;\n const merge = (host: Record<string, unknown> | undefined): Record<string, unknown> => ({\n ...host,\n ...staged,\n });\n return captured instanceof Promise ? captured.then(merge) : merge(captured);\n };\n}\n\n/** Test seam: drop every staged composer markdown. */\nexport function resetStagedComposerMarkdown(): void {\n stagedComposerMarkdownByThread().clear();\n}\n\n/**\n * `metadata` with the composer markdown set to the draft `markdown` of `text`,\n * or without it when the draft equals the text (it adds nothing then).\n * Returns `undefined` when nothing is left.\n */\nexport function withDeepAgentComposerMarkdown({\n metadata,\n text,\n markdown,\n}: {\n metadata: Readonly<Record<string, unknown>> | undefined;\n text: string;\n markdown: string;\n}): Record<string, unknown> | undefined {\n const { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: _previous, ...rest } = metadata ?? {};\n const next: Record<string, unknown> =\n markdown === text ? rest : { ...rest, [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: markdown };\n return Object.keys(next).length > 0 ? next : undefined;\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"composer-markdown.js","sources":["../../../../agent-runtime/src/composer-markdown.ts"],"sourcesContent":["/**\n * The composer markdown a chat message was sent from.\n *\n * The chat composer sends a message's plain text exactly as typed, because the\n * model reads the text literally, and writes real formatting (bold, lists,\n * code, mention chips) as markdown. The sent text alone no longer says which\n * characters are formatting and which were typed: literal `**strong**` and\n * bold `strong` both send `**strong**`, and `List<T>`, `a\\\\b` or `2*3*4` read\n * as markup when the text is rendered as markdown. So the composer also\n * attaches the draft the text was serialized from (the same document with its\n * plain text escaped for markdown) under {@link DEEP_AGENT_COMPOSER_MARKDOWN_KEY}\n * in the message's metadata, and only when the two differ.\n *\n * It rides the statewire message, the queue and steer lanes and the persisted\n * HumanMessage (`additional_kwargs.metadata`, which the converter surfaces as\n * `metadata.custom`). It is display-only: the bubble renders it, and Edit,\n * branch-and-edit and a withdrawn turn load it back into the composer. The\n * model reads the message text, never this. A message without it (older\n * messages, other clients) renders its text as before.\n *\n * agora validates it with the same rule\n * (`agora/graphs/athena_deep_agent/composer_markdown.py`).\n */\nexport const DEEP_AGENT_COMPOSER_MARKDOWN_KEY = 'composer_markdown';\n\nconst DRAFT_CHARACTER_REFERENCES: Readonly<Record<string, string>> = {\n lt: '<',\n gt: '>',\n amp: '&',\n '#32': ' ',\n '#9': '\\t',\n};\n\n/**\n * A sent text in comparable form: every backslash dropped (the draft's\n * escapes, and the `\\` before a hard break's newline, are backslashes).\n */\nconst comparableText = (text: string): string => text.replace(/\\\\/g, '');\n\n/**\n * A draft in comparable form: every backslash dropped, then the character\n * references the draft serializer writes decoded in one pass (`&lt;` and\n * `&gt;` for typed `<` and `>`, `&amp;` for a typed `&` that starts a\n * reference, so typed `&lt;` is `&amp;lt;`, and `&#32;` / `&#9;` for the space\n * or tab a line starts with, which markdown would otherwise drop).\n */\nconst comparableDraft = (markdown: string): string =>\n comparableText(markdown).replace(\n /&(lt|gt|amp|#32|#9);/g,\n (reference, name: string) => DRAFT_CHARACTER_REFERENCES[name] ?? reference\n );\n\n/**\n * Escaping at most turns one character into four (`<` → `&lt;`). A line's\n * first space becomes five (`&#32;`), but only once a line, so a whole line\n * with its line break stays under four a character; the slack covers short\n * texts. agora caps a stored draft the same way.\n */\nconst MAX_EXPANSION = 4;\nconst EXPANSION_SLACK_CHARS = 1024;\n\n/**\n * Whether `markdown` is the draft of `text`: no longer than escaping could make\n * it, and equal to it once backslashes are dropped and the draft's character\n * references decoded. A draft that would show other\n * words than the text the model read (another message's, a client's mistake) is\n * not taken; such a message renders its text as before.\n */\nexport function isComposerMarkdownOfText({\n markdown,\n text,\n}: {\n markdown: string;\n text: string;\n}): boolean {\n return (\n markdown.length <= text.length * MAX_EXPANSION + EXPANSION_SLACK_CHARS &&\n comparableDraft(markdown) === comparableText(text)\n );\n}\n\n/** The references a draft writes a line's first space or tab as. */\nconst DRAFT_WHITESPACE_REFERENCES: readonly string[] = ['&#32;', '&#9;'];\n\nconst isWhitespace = (character: string): boolean => /\\s/.test(character);\n\n/** A hard break in a draft: `\\` before the newline (the text holds the newline alone). */\nconst DRAFT_HARD_BREAK = '\\\\\\n';\n\n/** Whether an odd run of backslashes ends at `end`: its last one escapes what follows, not a typed `\\` (`\\\\`). */\nfunction endsInEscapingBackslash({\n markdown,\n start,\n end,\n}: {\n markdown: string;\n start: number;\n end: number;\n}): boolean {\n let backslashes = 0;\n while (end - backslashes > start && markdown.charAt(end - backslashes - 1) === '\\\\') {\n backslashes += 1;\n }\n return backslashes % 2 === 1;\n}\n\n/**\n * `markdown`, a draft, trimmed as its text is trimmed (`text.trim()`): a\n * send that trims its text stays a draft of it. `markdown.trim()` would keep\n * the `&#32;` a draft writes for the space its first line starts with, and the\n * `\\` of a line break whose newline it trims.\n */\nexport function trimComposerDraft(markdown: string): string {\n let start = 0;\n let end = markdown.length;\n for (;;) {\n while (start < end && isWhitespace(markdown.charAt(start))) start += 1;\n const reference = [...DRAFT_WHITESPACE_REFERENCES, DRAFT_HARD_BREAK].find(\n (candidate) => start + candidate.length <= end && markdown.startsWith(candidate, start)\n );\n if (reference === undefined) break;\n start += reference.length;\n }\n for (;;) {\n while (end > start && isWhitespace(markdown.charAt(end - 1))) end -= 1;\n const reference = DRAFT_WHITESPACE_REFERENCES.find(\n (candidate) => end - candidate.length >= start && markdown.endsWith(candidate, end)\n );\n if (reference !== undefined) {\n end -= reference.length;\n } else if (\n markdown.charAt(end) === '\\n' &&\n endsInEscapingBackslash({ markdown, start, end })\n ) {\n // The newline just trimmed was a line break's: its `\\` goes with it.\n end -= 1;\n } else {\n break;\n }\n }\n return markdown.slice(start, end);\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/**\n * The composer markdown to render or load for a message whose metadata is\n * `metadata` (a message's `metadata.custom`, or a lane entry's `metadata`) and\n * whose text is `text`; `null` when it carries none, or one that is not a\n * draft of `text`.\n */\nexport function readDeepAgentComposerMarkdown({\n metadata,\n text,\n}: {\n metadata: unknown;\n text: string;\n}): string | null {\n if (!isRecord(metadata)) return null;\n const markdown: unknown = metadata[DEEP_AGENT_COMPOSER_MARKDOWN_KEY];\n return typeof markdown === 'string' && isComposerMarkdownOfText({ markdown, text })\n ? markdown\n : null;\n}\n\n/**\n * How long a staged composer markdown waits for its submission\n * ({@link stageComposerMarkdown}).\n */\nexport const STAGED_COMPOSER_MARKDOWN_TTL_MS = 10_000;\n\ninterface StagedComposerMarkdown {\n readonly markdown: string;\n readonly stagedAt: number;\n}\n\n/**\n * The staged drafts live on `globalThis`, not in module scope, because two\n * copies of this module run in one page: the SDK bundles agent-runtime into its\n * dist, so its composer stages through the SDK's copy, while the Chrome\n * extension and the Office add-in build their runtime on agent-runtime directly\n * and take the draft through their own (see `asset-attachment.ts`).\n */\nconst STAGED_COMPOSER_MARKDOWN = Symbol.for('@athenaintel/agent-runtime/staged-composer-markdown');\n\ntype StagedComposerMarkdownHost = typeof globalThis & {\n [STAGED_COMPOSER_MARKDOWN]?: Map<string, StagedComposerMarkdown>;\n};\n\nfunction stagedComposerMarkdownByThread(): Map<string, StagedComposerMarkdown> {\n const host = globalThis as StagedComposerMarkdownHost;\n return (host[STAGED_COMPOSER_MARKDOWN] ??= new Map());\n}\n\n/**\n * Stage the draft `markdown` of `text` for `threadId`'s next submission.\n *\n * statewire captures message metadata once per new user message\n * (`getMessageMetadata`, patched into `@assistant-ui/react-statewire@0.33.0`)\n * without saying which message it is for, so a composer stages its draft right\n * before it sends and the runtime's getter takes it for the submission that\n * follows ({@link withStagedComposerMarkdown}). A draft equal to its text adds\n * nothing and clears the slot. A slot nobody takes within\n * {@link STAGED_COMPOSER_MARKDOWN_TTL_MS} expires; one a submission the\n * composer did not make takes is harmless, since a composer markdown that is\n * not a draft of the message's own text is neither kept by agora nor rendered.\n */\nexport function stageComposerMarkdown({\n threadId,\n text,\n markdown,\n now = Date.now(),\n}: {\n threadId: string;\n text: string;\n markdown: string;\n /** Test seam: the time of the send. */\n now?: number;\n}): void {\n const staged = stagedComposerMarkdownByThread();\n if (markdown === text) {\n staged.delete(threadId);\n return;\n }\n staged.set(threadId, { markdown, stagedAt: now });\n}\n\n/**\n * The message metadata for `threadId`'s submission now being journaled: the\n * staged composer markdown, if one is waiting and fresh. Takes the slot.\n */\nexport function takeStagedComposerMarkdownMetadata(\n threadId: string,\n now: number = Date.now()\n): Record<string, unknown> | undefined {\n const staged = stagedComposerMarkdownByThread();\n const entry = staged.get(threadId);\n staged.delete(threadId);\n if (entry === undefined || now - entry.stagedAt > STAGED_COMPOSER_MARKDOWN_TTL_MS) {\n return undefined;\n }\n return { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: entry.markdown };\n}\n\ntype MessageMetadataGetter = () =>\n | Record<string, unknown>\n | undefined\n | Promise<Record<string, unknown> | undefined>;\n\n/**\n * A runtime's `getMessageMetadata` that also carries the composer markdown a\n * composer staged for `threadId` ({@link stageComposerMarkdown}), on top of the\n * host's own metadata (`getMessageMetadata`, when it has one). The slot is\n * taken synchronously at submission, before the host's capture resolves, so a\n * draft never rides a later message. With nothing staged the host's own value\n * is returned as is.\n */\nexport function withStagedComposerMarkdown({\n threadId,\n getMessageMetadata,\n}: {\n threadId: string;\n getMessageMetadata?: MessageMetadataGetter | undefined;\n}): MessageMetadataGetter {\n return () => {\n const staged = takeStagedComposerMarkdownMetadata(threadId);\n const captured = getMessageMetadata?.();\n if (staged === undefined) return captured;\n const merge = (host: Record<string, unknown> | undefined): Record<string, unknown> => ({\n ...host,\n ...staged,\n });\n return captured instanceof Promise ? captured.then(merge) : merge(captured);\n };\n}\n\n/** Test seam: drop every staged composer markdown. */\nexport function resetStagedComposerMarkdown(): void {\n stagedComposerMarkdownByThread().clear();\n}\n\n/**\n * `metadata` with the composer markdown set to the draft `markdown` of `text`,\n * or without it when the draft equals the text (it adds nothing then).\n * Returns `undefined` when nothing is left.\n */\nexport function withDeepAgentComposerMarkdown({\n metadata,\n text,\n markdown,\n}: {\n metadata: Readonly<Record<string, unknown>> | undefined;\n text: string;\n markdown: string;\n}): Record<string, unknown> | undefined {\n const { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: _previous, ...rest } = metadata ?? {};\n const next: Record<string, unknown> =\n markdown === text ? rest : { ...rest, [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: markdown };\n return Object.keys(next).length > 0 ? next : undefined;\n}\n"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { useSyncExternalStore } from "react";
2
- import { readAddressedAgents, withAddressedAgents } from "./addressed-agents.js";
2
+ import { withAddressedAgents, readAddressedAgents } from "./addressed-agents.js";
3
3
  const MAX_DEEP_AGENT_NAMED_AGENTS = 8;
4
4
  const OWN_ASSISTANT_AGENT_REF = "assistant";
5
5
  const COLLAB_AGENT_REF_PREFIX = "collab_agent:";
@@ -1 +1 @@
1
- {"version":3,"file":"offline-status.cjs","sources":["../../../../agent-runtime/src/offline-status.ts"],"sourcesContent":["/**\n * Athena's offline language: the one set of states and words every app uses to\n * say where the person stands when the network, or Athena, is out of reach —\n * Olympus, the desktop app, the phone app, the Chrome extension and the Office\n * add-in.\n *\n * The rule the words follow: say what still works before what doesn't. Work\n * done while Athena can't be reached is kept on the device and syncs when\n * Athena is back, so every message leads with that promise. Nothing here is\n * shown while Athena answers and nothing waits; the \"back\" messages are brief.\n *\n * Pure strings and small functions, no UI library: each app draws them its own\n * way (a banner, a header chip, a tooltip, a sheet). Reachability itself is\n * each app's own (the browser's or OS's online flag AND agora answering its\n * health read); this module only names the result.\n */\n\n/** Where the app stands with Athena. */\nexport type ConnectionState =\n /** Athena answers. Nothing is shown. */\n | 'online'\n /** The device has no network. */\n | 'offline'\n /** The network is up but Athena doesn't answer. */\n | 'unreachable'\n /** Athena answers again and what waited is being sent. */\n | 'reconnecting'\n /** Just back, and everything that waited was sent. Shown briefly. */\n | 'synced';\n\n/** Where one document's edits stand, for the chip in its header. */\nexport type DocumentSyncState =\n /** In Athena. */\n | 'saved'\n /** Being sent to Athena. */\n | 'saving'\n /** Kept on this device while Athena can't be reached; syncs when it is back. */\n | 'on-device'\n /** Kept only while this tab or app stays open (no device storage); syncs when Athena is back. */\n | 'in-memory'\n /** Sending what was kept on this device. */\n | 'syncing'\n /** Some changes overlap with Athena's version: the person chooses. */\n | 'needs-review'\n /** The document can no longer take these edits (access or state changed): kept, not sent. */\n | 'view-only'\n /**\n * Athena answers but isn't taking this document's changes for now: it holds the document while it\n * repairs it, or hasn't confirmed the latest changes for longer than usual (`SavingDelayCause`\n * says which). Kept, and saved once Athena takes them. Never said while Athena can't be reached:\n * that is `on-device` or `in-memory`.\n */\n | 'delayed';\n\n/** Why a document's saving is delayed while Athena answers (`DocumentSyncState` `delayed`). */\nexport type SavingDelayCause =\n /** Athena holds the document while it repairs it, and takes no changes to it until then. */\n | 'repairing'\n /** Athena hasn't confirmed the latest changes for longer than usual. */\n | 'unconfirmed';\n\n/** How a sentence names the device the person is on. */\nexport type DeviceNoun =\n | 'this browser'\n | 'this Mac'\n | 'this computer'\n | 'this phone'\n | 'this tablet'\n | 'this device';\n\n/**\n * What the app really keeps of work done while Athena can't be reached, so the\n * words never promise more: `device` (stored on the device, survives closing),\n * `memory` (kept only while this tab or app stays open: a tenant that turns\n * device copies off, or storage the app can't open), `none` (a surface that\n * keeps no work offline at all, like the Chrome side panel).\n */\nexport type OfflineKeeping = 'device' | 'memory' | 'none';\n\n/** A title and the one line under it (a banner, a sheet, a tooltip's heading). */\nexport interface StatusCopy {\n title: string;\n detail: string;\n}\n\n/** A short label for a chip or a disabled control, and its one-line explanation. */\nexport interface LabelCopy {\n label: string;\n detail: string;\n}\n\nfunction plural(count: number, one: string, many: string): string {\n return `${count} ${count === 1 ? one : many}`;\n}\n\n/** What a tab or an app is called when work lasts only while it stays open. */\nfunction openContainer(device: DeviceNoun): string {\n return device === 'this browser' ? 'this tab' : 'the app';\n}\n\n/** The device noun at the start of a sentence (\"This browser can…\"). */\nfunction deviceAtStart(device: DeviceNoun): string {\n return `${device.charAt(0).toUpperCase()}${device.slice(1)}`;\n}\n\n/**\n * The sentence a promise ends with when the device can remove what it keeps before it syncs\n * (`evictable`: a browser that hasn't granted persistent storage, a private window): \"before then\"\n * after a sentence that says when it syncs, \"before it syncs\" after one that doesn't.\n */\nfunction removableBeforeSync({\n device,\n count,\n before = 'then',\n}: {\n device: DeviceNoun;\n count: number;\n before?: 'then' | 'syncing';\n}): string {\n const what = count === 1 ? 'it' : 'them';\n const when = before === 'then' ? 'then' : count === 1 ? 'it syncs' : 'they sync';\n return `${deviceAtStart(device)} can remove ${what} before ${when}.`;\n}\n\n/** The promise after \"You're offline\" / \"Can't reach Athena\", honest to what is kept. */\nfunction keepingPromise({\n keeping,\n device,\n when,\n evictable,\n}: {\n keeping: OfflineKeeping;\n device: DeviceNoun;\n when: string;\n evictable: boolean;\n}): string {\n switch (keeping) {\n case 'device':\n return evictable\n ? `You can keep working. Changes are kept on ${device} and sync ${when}. ${removableBeforeSync({ device, count: 2 })}`\n : `You can keep working. Changes are saved on ${device} and sync ${when}.`;\n case 'memory':\n return `You can keep working, but changes are kept only until you close ${openContainer(device)}. They sync ${when}.`;\n case 'none':\n return `You’re still signed in. This reconnects ${when}.`;\n }\n}\n\n/**\n * The app-wide status line, or `null` while Athena answers and nothing waits.\n * `waiting` counts changes not in Athena yet: while `reconnecting` they are\n * being sent; while `online` they wait for something else (a copy that sends\n * when it is next opened), and the line stays until they are gone.\n * `keeping` is what the app really keeps offline (default `device`). A surface\n * that keeps nothing (`none`) has nothing to sync, so its way back is only\n * \"Back online\", never \"Syncing…\" or \"All changes synced\".\n * `evictable`: the device may remove what it keeps before it syncs (a browser that hasn't granted\n * persistent storage, a private window); only `keeping` `device` says so (default `false`).\n */\nexport function connectionCopy({\n state,\n device,\n waiting = 0,\n keeping = 'device',\n evictable = false,\n}: {\n state: ConnectionState;\n device: DeviceNoun;\n waiting?: number;\n keeping?: OfflineKeeping;\n evictable?: boolean;\n}): StatusCopy | null {\n const atRisk = evictable && keeping === 'device';\n switch (state) {\n case 'online':\n return waiting > 0\n ? {\n title: 'Not synced yet',\n detail: `${plural(waiting, 'change', 'changes')} on ${device} ${waiting === 1 ? 'hasn’t' : 'haven’t'} synced yet.${\n atRisk ? ` ${removableBeforeSync({ device, count: waiting, before: 'syncing' })}` : ''\n }`,\n }\n : null;\n case 'offline':\n return {\n title: 'You’re offline',\n detail: keepingPromise({\n keeping,\n device,\n when: 'when you’re back online',\n evictable: atRisk,\n }),\n };\n case 'unreachable':\n return {\n title: 'Can’t reach Athena',\n detail: keepingPromise({ keeping, device, when: 'when Athena is back', evictable: atRisk }),\n };\n case 'reconnecting':\n if (keeping === 'none') return { title: 'Back online', detail: '' };\n return {\n title: 'Back online',\n detail:\n waiting > 0 ? `Syncing ${plural(waiting, 'change', 'changes')}…` : 'Syncing…',\n };\n case 'synced':\n return keeping === 'none'\n ? { title: 'Back online', detail: '' }\n : { title: 'All changes synced', detail: '' };\n }\n}\n\n/**\n * The line under \"Saving delayed\": why saving waits, and what is kept meanwhile, never promising\n * more than the app keeps.\n */\nfunction savingDelayedDetail({\n delay,\n keeping,\n device,\n}: {\n delay: SavingDelayCause;\n keeping: Exclude<OfflineKeeping, 'none'>;\n device: DeviceNoun;\n}): string {\n if (delay === 'repairing') {\n return keeping === 'memory'\n ? `You can keep working, but your changes are kept only until you close ${openContainer(device)}. They save once Athena finishes repairing this document.`\n : `You can keep working. Your changes are kept on ${device} and save once Athena finishes repairing this document.`;\n }\n return keeping === 'memory'\n ? `Athena is taking longer than usual to save your latest changes. They’re kept only until you close ${openContainer(device)}, so keep it open.`\n : `Athena is taking longer than usual to save your latest changes. They’re kept on ${device} in the meantime.`;\n}\n\n/**\n * What `on-device` says. The desktop app on a Mac speaks as Word, Excel and PowerPoint for Mac do\n * of a cloud document whose changes wait on the Mac (Office's own title-bar words): \"Upload\n * Pending\", and \"Upload Pending (Offline)\" while Athena can't be reached. Every other app says the\n * changes are saved on its device: a browser has no such words (Word for the web can't edit\n * offline), nor does the phone.\n */\nfunction onDeviceCopy({ device, reachable }: { device: DeviceNoun; reachable: boolean }): LabelCopy {\n if (device === 'this Mac') {\n return {\n label: reachable ? 'Upload Pending' : 'Upload Pending (Offline)',\n detail: 'Upload pending. Your changes sync when Athena is back.',\n };\n }\n return {\n label: `Saved on ${device}`,\n detail: `Saved on ${device}. Your changes sync when Athena is back.`,\n };\n}\n\n/**\n * The chip in a document's header, and the line it explains itself with. For `delayed`, `delay`\n * says why (default `unconfirmed`) and `keeping` whether the changes are kept on the device or only\n * while the tab or app stays open (default `device`); every other state ignores both. `reachable`\n * (default `true`) is whether Athena can be reached, which only the Mac's `on-device` words read.\n * For `on-device`, `evictable` says the device may remove the changes before they sync (default\n * `false`).\n */\nexport function documentSyncCopy({\n state,\n device,\n delay = 'unconfirmed',\n keeping = 'device',\n reachable = true,\n evictable = false,\n}: {\n state: DocumentSyncState;\n device: DeviceNoun;\n delay?: SavingDelayCause;\n keeping?: Exclude<OfflineKeeping, 'none'>;\n reachable?: boolean;\n evictable?: boolean;\n}): LabelCopy {\n switch (state) {\n case 'saved':\n return { label: 'Saved', detail: 'Saved to Athena.' };\n case 'saving':\n return { label: 'Saving…', detail: 'Saving to Athena.' };\n case 'on-device': {\n const copy = onDeviceCopy({ device, reachable });\n return evictable\n ? { ...copy, detail: `${copy.detail} ${removableBeforeSync({ device, count: 2 })}` }\n : copy;\n }\n case 'in-memory':\n return {\n label: 'Not saved yet',\n detail: `Your changes are kept only until you close ${openContainer(device)}, and save when Athena is back.`,\n };\n case 'syncing':\n return { label: 'Syncing…', detail: `Sending the changes saved on ${device}.` };\n case 'needs-review':\n return {\n label: 'Review changes',\n detail: 'Some of your changes overlap with changes made in Athena. Choose what to keep.',\n };\n case 'view-only':\n return {\n label: 'View only',\n detail: `You can’t edit this anymore. Your changes stay on ${device}.`,\n };\n case 'delayed':\n return { label: 'Saving delayed', detail: savingDelayedDetail({ delay, keeping, device }) };\n }\n}\n\n/**\n * How long a document's edits may wait unconfirmed on a connection that is up before its saving\n * reads as delayed (`delayed`, `unconfirmed`), and before its room asks Keryx why: the stall window\n * every editor shares (`ACK_STALL_MS` in `@athenaintel/asset-host/local-updates`, which Olympus's\n * `room-sync-store.test.ts` holds equal).\n */\nexport const UNCONFIRMED_EDITS_STALL_MS = 15_000;\n\n/**\n * Whether a document's saving is delayed, and why (`DocumentSyncState` `delayed`): only while Athena\n * answers and the document's room is connected; `repairing` when Keryx refuses the room's writes\n * (quarantined while it is repaired: said at once, before anything waits), `unconfirmed` once its\n * edits have stayed unconfirmed past {@link UNCONFIRMED_EDITS_STALL_MS}; `null` otherwise. Every\n * app's chip asks it after a hold and after edits kept without a connection, and ahead of syncing\n * and saving, so a room that refuses writes never spins and being offline is never a delay.\n */\nexport function savingDelayCause({\n reachable,\n connected,\n writesRefused,\n unconfirmedStalled,\n}: {\n /** Athena answers (the app's reachability, never the socket alone). */\n reachable: boolean;\n /** The document's room is connected. */\n connected: boolean;\n /** Keryx's document status says the room takes no writes for now. */\n writesRefused: boolean;\n /** Local edits have waited unconfirmed past the stall window on this connection. */\n unconfirmedStalled: boolean;\n}): SavingDelayCause | null {\n if (!reachable || !connected) return null;\n if (writesRefused) return 'repairing';\n return unconfirmedStalled ? 'unconfirmed' : null;\n}\n\n/**\n * How long a chat message sent while Athena answers may go unaccepted before it counts as waiting\n * to sync. Athena accepts a send when it takes it, not when its turn ends: 99% of follow-up messages\n * within 3.4 s of the send, and 99% of a chat's first, which also dials, within 5.7 s (production\n * `chat_ttft`, 30 days to 2026-11-07). A send still unaccepted after this is stuck, not on its way.\n */\nexport const UNACCEPTED_SEND_STALL_MS = 8_000;\n\n/** Where a chat message a chat outbox lists stands, for {@link messageWaitsToSync}. */\nexport interface OutboxMessageStanding {\n /** Athena answers (the app's reachability, never the socket alone). */\n reachable: boolean;\n /**\n * On its way to Athena: sent, or queued to go. `false` while the outbox holds it: typed while\n * Athena couldn't be reached, behind a message that waits, asking after a day, or failed.\n */\n sending: boolean;\n /** Athena received it, before its answer reached the outbox. */\n received?: boolean;\n /** When it was sent (epoch ms); for one typed while Athena couldn't be reached, when it was typed. */\n sentAt: number;\n}\n\n/**\n * When a message on its way starts counting as waiting to sync, should Athena not have accepted it\n * by then ({@link UNACCEPTED_SEND_STALL_MS} after it was sent, while Athena answers); `null` when no\n * time passing changes whether it counts.\n */\nexport function messageStartsWaitingAt({\n reachable,\n sending,\n received = false,\n sentAt,\n}: OutboxMessageStanding): number | null {\n return reachable && sending && !received ? sentAt + UNACCEPTED_SEND_STALL_MS : null;\n}\n\n/**\n * Whether a chat message a chat outbox lists counts as waiting to sync, for the status line's\n * count and the \"Waiting to sync\" list: one the outbox holds, always; one on its way, at once while\n * Athena can't be reached, and while it answers only once it went unaccepted past\n * {@link UNACCEPTED_SEND_STALL_MS}; one Athena received, never. A send that goes as usual, however\n * long its turn runs, shows no status line.\n */\nexport function messageWaitsToSync(standing: OutboxMessageStanding & { now: number }): boolean {\n const startsAt = messageStartsWaitingAt(standing);\n if (startsAt !== null) return standing.now >= startsAt;\n return !(standing.sending && standing.received === true);\n}\n\n/**\n * What the person lost of a document while it was open, when the server checked its room's grant\n * again (Keryx's 4401, after a share changed): `edit`, they may still view it but no longer edit it;\n * `view`, they may no longer open it at all.\n */\nexport type AccessLoss = 'edit' | 'view';\n\n/** What a document says once the person's access changed while it was open. */\nexport interface AccessChangedCopy extends StatusCopy {\n /**\n * The label of the button that saves the person's version as a file of their own, with the\n * changes Athena never took, or `null` when nothing is offered: nothing of theirs waits, or the\n * device no longer serves the document (`view`).\n */\n saveCopy: string | null;\n}\n\n/**\n * What a document says once the person's access to it changed while it was open (its editor turns\n * read-only at once), in Word's own words (Word for Mac's message bar, `strman_a6d4`, and its\n * \"Permissions Have Changed\" notice, `strman_0a3e`). `keptChanges`: changes Athena never took wait\n * on the device, never sent; Word offers to save a copy of the file so nothing typed is lost. A\n * `view` loss offers nothing to save: the device stops serving a document the person may no longer\n * read, and their changes stay where they are kept.\n */\nexport function accessChangedCopy({\n loss,\n keptChanges,\n}: {\n loss: AccessLoss;\n keptChanges: boolean;\n}): AccessChangedCopy {\n if (loss === 'view') {\n return {\n title: 'Permissions Have Changed',\n detail:\n 'The permissions on this file were updated and you no longer have rights to view this file. If you previously made changes to this file, they may not be saved.',\n saveCopy: null,\n };\n }\n if (keptChanges) {\n return {\n title: 'Can’t save',\n detail:\n 'You no longer have the permission to edit this file. To keep your changes, save a copy of the file.',\n saveCopy: 'Save a Copy',\n };\n }\n return {\n title: 'Read Only',\n detail: 'You no longer have the permission to edit this file.',\n saveCopy: null,\n };\n}\n\n/** What a document says while it holds changes an older version of Athena made on the device. */\nexport interface OlderVersionChangesCopy extends StatusCopy {\n /** Saves this device's version as a file of the person's own, those changes included (Word's). */\n saveCopy: string;\n /** Sets this device's version aside, kept on the device to compare, and opens Athena's. */\n useAthenaVersion: string;\n /** Athena's version couldn't be opened: another window of the device has the document open. */\n openElsewhere: string;\n}\n\n/**\n * What a document says while its local copy holds changes an older version of Athena made on this\n * device, below its room's write floor (`older-version`, `@athenaintel/local-copy/room-copy`): such\n * a version deletes what newer ones store, so those changes are never uploaded. Word's way out,\n * as when it can't save a cloud document (\"You can save a copy to prevent losing your work\",\n * `strman`): Save a Copy, and Athena's version, which keeps this device's version on it to compare\n * and copy from until the person discards it. `saveCopy: false` for an editor that offers no Save\n * a Copy of the live document. `keepsAside: false` where the device keeps no version of it aside\n * (copies off, an embed): Athena's version then replaces the changes, which the person confirms\n * first (`olderVersionDiscardCopy`). `atRisk`: the device can lose them before the person chooses\n * (`ChangesAtRisk`), said here so the document shows one strip, never a second \"Changes not saved\".\n */\nexport function olderVersionChangesCopy({\n device,\n noun,\n saveCopy = true,\n keepsAside = true,\n atRisk = null,\n}: {\n device: DeviceNoun;\n /** What the person calls the document (\"document\", \"spreadsheet\"). */\n noun: string;\n /** Whether the editor offers Save a Copy beside Athena's version. */\n saveCopy?: boolean;\n /** Whether Athena's version keeps the device's aside, as a version to compare and copy from. */\n keepsAside?: boolean;\n /** How the device could lose the changes before the person chooses, or `null`. */\n atRisk?: ChangesAtRisk | null;\n}): OlderVersionChangesCopy {\n const made = `You changed this ${noun} on ${device} in an older version of Athena, so those changes can’t be uploaded.`;\n const keep = !keepsAside\n ? `${made} To keep them, save a copy: Athena’s version replaces them on ${device}.`\n : saveCopy\n ? `${made} To keep them, save a copy, or use Athena’s version: yours stays on ${device} to compare and copy from.`\n : `${made} Use Athena’s version to keep yours on ${device}, where you can compare it, copy from it or download it.`;\n const risk =\n atRisk === null\n ? ''\n : atRisk === 'memory'\n ? ` Until you choose, they’re kept only until you close ${openContainer(device)}.`\n : ` Until you choose, they’re saved only on ${device}, which can remove them.`;\n return {\n title: 'Changes made in an older version of Athena',\n detail: `${keep}${risk}`,\n saveCopy: 'Save a Copy',\n useAthenaVersion: 'Use Athena’s version',\n openElsewhere: `This ${noun} is open in another window. Close it there, then try again.`,\n };\n}\n\n/**\n * What the device asks before Athena's version replaces changes an older version of Athena made,\n * where it keeps no version of them aside (`olderVersionChangesCopy`'s `keepsAside: false`): they\n * are deleted then, so a copy saved first is the only way to keep them.\n */\nexport function olderVersionDiscardCopy({ device }: { device: DeviceNoun }): StatusCopy & {\n confirm: string;\n cancel: string;\n} {\n return {\n title: 'Use Athena’s version?',\n detail: `The changes made in an older version of Athena are deleted from ${device} and can’t be recovered. To keep them, save a copy first.`,\n confirm: 'Use Athena’s version',\n cancel: 'Cancel',\n };\n}\n\n/** When something that needs Athena works again (the second half of `NEEDS_CONNECTION`'s line). */\nconst AVAILABLE_AGAIN = 'It’s available again when you’re back online.';\n\n/**\n * The reason on a control that needs Athena (share, run, upload, invite…),\n * shown disabled while Athena can't be reached rather than failing when used.\n */\nexport const NEEDS_CONNECTION: LabelCopy = {\n label: 'Needs a connection',\n detail: `This needs Athena. ${AVAILABLE_AGAIN}`,\n};\n\n/** What a composer was given to attach: a photo (from the phone's library or camera) or any file. */\nexport type AttachmentKind = 'photo' | 'file';\n\n/**\n * What a composer says when a photo or file is picked, pasted or dropped while\n * Athena can't be reached. It isn't attached: only Athena can take the upload.\n * In a chat whose messages wait to be sent meanwhile (`messagesWait`: the chat\n * keeps an outbox), the message itself still goes, so that comes first. Where\n * nothing waits (the Chrome side panel, a launcher that keeps its draft), it\n * says only when attaching works again, as `NEEDS_CONNECTION` does.\n */\nexport function attachmentNeedsConnection({\n kind,\n messagesWait,\n}: {\n kind: AttachmentKind;\n messagesWait: boolean;\n}): string {\n const attaching = `Attaching ${kind === 'photo' ? 'a photo' : 'a file'} needs Athena. ${AVAILABLE_AGAIN}`;\n return messagesWait\n ? `You can still send your message: it goes when Athena is back. ${attaching}`\n : attaching;\n}\n\n/** What a sent message carries besides its text that can't wait for Athena. */\nfunction heldExtras(withQuote: boolean): string {\n return withQuote ? 'an attachment or a quote' : 'an attachment';\n}\n\n/**\n * What a composer says when a message is sent while Athena can't be reached\n * with an attachment that never reached Athena (or, `withQuote`, a quote the\n * chat can't keep waiting). Its text could wait in the chat outbox; the\n * attachment can't, so the draft stays as it is.\n */\nexport function attachedMessageNeedsConnection({\n withQuote = false,\n}: {\n withQuote?: boolean;\n} = {}): string {\n return `Your text can wait and go when Athena is back, but ${heldExtras(withQuote)} can’t. Remove ${\n withQuote ? 'it' : 'the attachment'\n } to send the text.`;\n}\n\n/**\n * What a composer says when a message with an attachment (or, `withQuote`, a\n * quote) is sent while messages written when Athena couldn't be reached still\n * wait to go: those go first, and this one can't wait behind them.\n */\nexport function attachedMessageBehindWaiting({\n withQuote = false,\n}: {\n withQuote?: boolean;\n} = {}): string {\n return `Messages you wrote while Athena couldn’t be reached go first. A message with ${heldExtras(\n withQuote\n )} can’t wait behind them, so send it once they’ve gone.`;\n}\n\n/**\n * The reason on New Presentation while Athena can't be reached, in a workspace\n * whose new presentations start from its design system's template: a\n * presentation made offline starts blank, and only Athena can copy the\n * template, so none is made until it can.\n */\nexport const NEW_PRESENTATION_NEEDS_TEMPLATE: LabelCopy = {\n label: 'Needs a connection',\n detail:\n 'New presentations in this workspace start from its design template, which needs Athena. You can make one when you’re back online.',\n};\n\n/**\n * What a download of a spreadsheet made from an Excel Macro-Enabled Workbook\n * (`.xlsm`) says when it went without the macros because they come from Athena,\n * which can't be reached, and the device hasn't kept them (they travel with the\n * workbook and never run). The file is the workbook without them; downloading\n * it again with a connection keeps them.\n */\nexport function downloadedWithoutMacros({ device }: { device: DeviceNoun }): string {\n return `Downloaded without the spreadsheet’s macros: they come from Athena, which can’t be reached, and ${device} hasn’t kept them. Download it again when you’re back online to keep them.`;\n}\n\n/**\n * Save's question for a spreadsheet whose file is an Excel Macro-Enabled\n * Workbook (`.xlsm`) while its macros come from Athena, which can't be reached,\n * and the device hasn't kept them: the `.xlsm` is never written without them,\n * so the person may save an Excel Workbook (`.xlsx`) beside it, and Save writes\n * the `.xlsm` again once Athena answers.\n */\nexport function macrosNeedConnectionToSave({\n name,\n device,\n}: {\n name: string;\n device: DeviceNoun;\n}): StatusCopy {\n return {\n title: `Athena can’t save the macros in “${name}” right now.`,\n detail: `They come from Athena, which can’t be reached, and ${device} hasn’t kept them. Save it as an Excel Workbook (.xlsx) instead. “${name}” stays as it is, macros included, and Save writes it again when you’re back online.`,\n };\n}\n\n/**\n * What saving a version kept on the device as a new spreadsheet says when the\n * spreadsheet was made from an Excel Macro-Enabled Workbook (`.xlsm`) and\n * Athena can't be reached: the new spreadsheet is made all the same (on the\n * device, and it syncs when Athena is back), but without the macros, which only\n * Athena can keep with a new spreadsheet (they travel with the workbook and\n * never run). The kept version stays on the device, so saving it again with a\n * connection keeps them.\n */\nexport function savedAsNewWithoutMacros({ device }: { device: DeviceNoun }): string {\n return `The new spreadsheet was made without the macros: only Athena can keep them in a new spreadsheet, and it can’t be reached. The kept version stays on ${device}: save it as a new spreadsheet again when you’re back online to keep them.`;\n}\n\n/**\n * What saving a copy of a Word document says when the copy was made on the\n * device while Athena can't be reached, for a document whose original Word file\n * Athena keeps (one imported from Word): the copy is made all the same, from the\n * document as the device holds it, over the original file kept with it, so it\n * keeps that file's parts but writes its body from what the editor shows.\n * Athena's own copy, made from the original file, keeps everything, so saving\n * a copy again with a connection makes that one.\n */\nexport function wordCopyMadeOnDevice({ device }: { device: DeviceNoun }): StatusCopy {\n return {\n title: `Copy made on ${device}`,\n detail: `Athena can’t be reached, so the copy holds the document as it is on ${device}. Anything in the original Word file that Athena doesn’t show may be missing from it: save a copy again when you’re back online for the complete file.`,\n };\n}\n\n/**\n * Whether an item opens without a connection on this device, for lists.\n * `canKeep` says whether opening it online would keep a copy here (the\n * deployment allows device copies and the item's type is one that is kept):\n * only then is the person told to open it once.\n */\nexport function offlineAvailabilityCopy({\n available,\n canKeep,\n device,\n}: {\n available: boolean;\n canKeep: boolean;\n device: DeviceNoun;\n}): LabelCopy {\n if (available) {\n return { label: 'Available offline', detail: `Opens without a connection on ${device}.` };\n }\n return canKeep\n ? {\n label: 'Not available offline',\n detail: `Open it once while online to keep it on ${device}.`,\n }\n : { label: 'Not available offline', detail: 'This opens only with a connection.' };\n}\n\n/**\n * A history Athena keeps in full (a person's dictations), shown while Athena\n * can't be reached: the list holds only what is kept on the device, and the\n * rest comes back once online. `items` names what the list holds, plural.\n */\nexport function historyOnDeviceCopy({\n items,\n device,\n}: {\n items: string;\n device: DeviceNoun;\n}): string {\n return `Showing the ${items} kept on ${device}. Your full history shows when you’re back online.`;\n}\n\n/**\n * The one-time explainer shown the first time the person goes offline, on a\n * surface that keeps work there (`keeping` `device`). A surface that keeps\n * nothing (`none`: the Chrome side panel, the Office task pane) shows no\n * explainer; its status line already says all that holds.\n */\nexport function firstOfflineExplainer({ device }: { device: DeviceNoun }): StatusCopy & {\n action: string;\n} {\n return {\n title: 'You can keep working offline',\n detail: `Documents you’ve opened recently keep working on ${device}. Your changes sync when you’re back online.`,\n action: 'Got it',\n };\n}\n\n/**\n * How a document's changes could be lost before they reach Athena:\n *\n * - `device`: saved on the device until they reach Athena (no connection, or a review the person\n * has not finished), but the device can remove them before then (a browser that hasn't granted\n * persistent storage, a private window);\n * - `held`: saved on the device and can't be sent (the person can only view the document now, or\n * Athena refused it), and the device can remove them;\n * - `memory`: kept only until the tab or app closes (the device keeps no copy).\n */\nexport type ChangesAtRisk = 'device' | 'held' | 'memory';\n\n/**\n * What an editor says above a document whose changes could be lost before they reach Athena, with\n * the copy it offers of them (Word's \"Upload pending\" and \"Changes not saved\"), by\n * {@link ChangesAtRisk}.\n */\nexport function changesAtRiskCopy({\n risk,\n device,\n}: {\n risk: ChangesAtRisk;\n device: DeviceNoun;\n}): StatusCopy & { download: string; downloadFailed: string } {\n const actions = {\n download: 'Download a copy',\n downloadFailed: 'Couldn’t download a copy. Try again.',\n };\n switch (risk) {\n case 'memory':\n return {\n title: 'Changes not saved',\n detail: `Your changes are kept only until you close ${openContainer(device)}. Download a copy to keep them.`,\n ...actions,\n };\n case 'held':\n return {\n title: 'Changes not saved',\n detail: `Your changes can’t be sent, and they’re saved only on ${device}, which can remove them. Download a copy to keep them.`,\n ...actions,\n };\n case 'device':\n return {\n title: 'Upload pending',\n detail: `Your changes are saved only on ${device} until they reach Athena, and ${device} can remove them before then. Download a copy to keep them.`,\n ...actions,\n };\n }\n}\n\n/**\n * The action that asks the device to keep what it holds until it syncs, where asking shows the\n * person a question of the device's own (Firefox asks whether the site may store data in\n * persistent storage): the app says why first (`changesAtRiskCopy`) and asks only on this press.\n */\nexport function keepOnDeviceAction({ device }: { device: DeviceNoun }): string {\n return `Keep them on ${device}`;\n}\n\n/** A browser whose own way of keeping a site's data longer the words point to. */\nexport type SaferStorageBrowser = 'safari-mac' | 'safari-ios';\n\n/**\n * Where changes made offline in Safari are kept longer: Safari keeps a site added to the Dock or\n * the Home Screen as an app of its own, which it is likelier to let keep its data, and the desktop\n * app keeps them in storage of its own.\n */\nexport function saferStorageHint({ browser }: { browser: SaferStorageBrowser }): string {\n return browser === 'safari-mac'\n ? 'To keep changes made offline safer in Safari, add Athena to the Dock (File ▸ Add to Dock), or use Athena Desktop.'\n : 'To keep changes made offline safer, add Athena to your Home Screen (Share ▸ Add to Home Screen).';\n}\n\n/**\n * What the app says when it finds that changes it kept on the device never reached Athena and the\n * device no longer has them (the browser removed them before they synced), naming how many\n * documents; each document's row says when its changes were made (`lostChangesItemCopy`).\n */\nexport function lostChangesCopy({\n device,\n documents,\n}: {\n device: DeviceNoun;\n documents: number;\n}): StatusCopy & { why: string; open: string; dismiss: string } {\n const which = documents === 1 ? 'a document' : plural(documents, 'document', 'documents');\n return {\n title: 'Changes not saved',\n detail: `Changes you made to ${which} on ${device} never reached Athena, and ${device} no longer has them.`,\n why: 'Browsers can remove what a site keeps when they run short of space, or when the site hasn’t been opened for a while.',\n open: 'Open',\n dismiss: 'Dismiss',\n };\n}\n\n/** One document's row under `lostChangesCopy`: the words before when its changes were made. */\nexport function lostChangesItemCopy({ device }: { device: DeviceNoun }): string {\n return `Changed on ${device}`;\n}\n\n/**\n * The summary heading of the one \"Waiting to sync\" list, from what waits on\n * the device; `null` when nothing does.\n */\nexport function waitingSummary({\n changes = 0,\n messages = 0,\n recordings = 0,\n}: {\n changes?: number;\n messages?: number;\n recordings?: number;\n}): string | null {\n const parts = [\n changes > 0 ? plural(changes, 'change', 'changes') : null,\n messages > 0 ? plural(messages, 'message', 'messages') : null,\n recordings > 0 ? plural(recordings, 'recording', 'recordings') : null,\n ].filter((part): part is string => part !== null);\n return parts.length === 0 ? null : `Waiting to sync: ${parts.join(', ')}`;\n}\n\n/**\n * Where one item in the \"Waiting to sync\" list stands: a change, a message or a\n * recording made while Athena couldn't be reached.\n */\nexport type WaitingItemState =\n /** Saved on the device; it syncs when Athena is back. */\n | 'waiting'\n /** Being sent to Athena now. */\n | 'syncing'\n /**\n * Kept only while this tab or app stays open: it couldn't be saved on the\n * device (the organization keeps nothing on devices, or storage failed).\n */\n | 'in-memory'\n /**\n * Saved on the device in something that isn't open (a document closed before\n * its edits synced, one made offline): it syncs when it is next opened with a\n * connection (opening it offline sends nothing).\n */\n | 'when-opened'\n /** Made in another workspace: it syncs once that workspace is open again, with a connection. */\n | 'other-workspace'\n /** It doesn't go on its own: the person chooses (a message that waited more than a day). */\n | 'needs-you'\n /** Athena didn't take it: the app adds why, and what the person can do about it. */\n | 'not-synced';\n\n/**\n * The state of one item in the \"Waiting to sync\" list, and the line that explains it. For an item\n * saved on the device (`waiting`, `when-opened`), `evictable` says the device may remove it before\n * it syncs (default `false`).\n */\nexport function waitingItemCopy({\n state,\n device,\n evictable = false,\n}: {\n state: WaitingItemState;\n device: DeviceNoun;\n evictable?: boolean;\n}): LabelCopy {\n const removable = evictable ? ` ${removableBeforeSync({ device, count: 1 })}` : '';\n switch (state) {\n case 'waiting':\n return {\n label: 'Waiting to sync',\n detail: `Saved on ${device}. It syncs when Athena is back.${removable}`,\n };\n case 'syncing':\n return { label: 'Syncing…', detail: `Sending what was saved on ${device}.` };\n case 'in-memory':\n return {\n label: 'Not saved yet',\n detail: `Kept only until you close ${openContainer(device)}. It syncs when Athena is back.`,\n };\n case 'when-opened':\n return {\n label: 'Not synced yet',\n detail: `Saved on ${device}. It syncs when you next open it with a connection.${removable}`,\n };\n case 'other-workspace':\n return {\n label: 'Waiting for its workspace',\n detail: 'It syncs once you’re in the workspace it was made in, with a connection.',\n };\n case 'needs-you':\n return { label: 'Waiting for you', detail: 'Open it to choose what happens to it.' };\n case 'not-synced':\n return { label: 'Couldn’t sync', detail: 'Athena didn’t take it.' };\n }\n}\n\n/** The headings of the \"Waiting to sync\" list, one per kind of item. */\nexport const WAITING_SECTION_TITLES = {\n changes: 'Changes',\n messages: 'Messages',\n recordings: 'Recordings',\n} as const;\n\n/**\n * What the person can do with an item Athena didn't take (`not-synced`) in the\n * \"Waiting to sync\" list: send it again, or delete the copy on the device. That\n * copy is the item's only one, so the app asks first (`deleteFromDeviceConfirm`)\n * and never deletes it as a side effect of anything else.\n */\nexport function notSyncedActions({ device }: { device: DeviceNoun }): {\n retry: string;\n remove: string;\n} {\n return { retry: 'Try again', remove: `Delete from ${device}` };\n}\n\n/** One of `notSyncedActions`, or showing where the item is kept on the device. */\nexport type NotSyncedAction = 'retry' | 'reveal' | 'remove';\n\n/**\n * What the person hears when the device couldn't do what they asked for an\n * item Athena didn't take (its files, not Athena, failed).\n */\nexport function notSyncedActionFailed({\n action,\n device,\n}: {\n action: NotSyncedAction;\n device: DeviceNoun;\n}): string {\n switch (action) {\n case 'retry':\n return 'Couldn’t send it again. Try again in a moment.';\n case 'reveal':\n return `Couldn’t show where it’s kept on ${device}.`;\n case 'remove':\n return `Couldn’t delete it from ${device}.`;\n }\n}\n\n/**\n * The question before deleting such an item's copy on the device, naming the\n * item: the destructive choice is never the default.\n */\nexport function deleteFromDeviceConfirm({\n name,\n device,\n}: {\n name: string;\n device: DeviceNoun;\n}): StatusCopy & { confirm: string; cancel: string } {\n return {\n title: `Delete “${name}” from ${device}?`,\n detail: 'Athena didn’t take it, so this is its only copy. Deleting it can’t be undone.',\n confirm: 'Delete',\n cancel: 'Cancel',\n };\n}\n"],"names":[],"mappings":";;AA6TO,MAAM,6BAA6B;AAqN1C,MAAM,kBAAkB;AAMjB,MAAM,mBAA8B;AAAA,EACzC,OAAO;AAAA,EACP,QAAQ,sBAAsB,eAAe;AAC/C;AAaO,SAAS,0BAA0B;AAAA,EACxC;AAAA,EACA;AACF,GAGW;AACT,QAAM,YAAY,aAA4C,QAAQ,kBAAkB,eAAe;AACvG,SAAO,eACH,iEAAiE,SAAS,KAC1E;AACN;AAGA,SAAS,WAAW,WAA4B;AAC9C,SAAO,YAAY,6BAA6B;AAClD;AAQO,SAAS,+BAA+B;AAAA,EAC7C,YAAY;AACd,IAEI,IAAY;AACd,SAAO,sDAAsD,WAAW,SAAS,CAAC,kBAChF,YAAY,OAAO,gBACrB;AACF;AAOO,SAAS,6BAA6B;AAAA,EAC3C,YAAY;AACd,IAEI,IAAY;AACd,SAAO,gFAAgF;AAAA,IACrF;AAAA,EAAA,CACD;AACH;;;;;;"}
1
+ {"version":3,"file":"offline-status.cjs","sources":["../../../../agent-runtime/src/offline-status.ts"],"sourcesContent":["/**\n * Athena's offline language: the one set of states and words every app uses to\n * say where the person stands when the network, or Athena, is out of reach —\n * Olympus, the desktop app, the phone app, the Chrome extension and the Office\n * add-in.\n *\n * The rule the words follow: say what still works before what doesn't. Work\n * done while Athena can't be reached is kept on the device and syncs when\n * Athena is back, so every message leads with that promise. Nothing here is\n * shown while Athena answers and nothing waits; the \"back\" messages are brief.\n *\n * Pure strings and small functions, no UI library: each app draws them its own\n * way (a banner, a header chip, a tooltip, a sheet). Reachability itself is\n * each app's own (the browser's or OS's online flag AND agora answering its\n * health read); this module only names the result.\n */\n\n/** Where the app stands with Athena. */\nexport type ConnectionState =\n /** Athena answers. Nothing is shown. */\n | 'online'\n /** The device has no network. */\n | 'offline'\n /** The network is up but Athena doesn't answer. */\n | 'unreachable'\n /** Athena answers again and what waited is being sent. */\n | 'reconnecting'\n /** Just back, and everything that waited was sent. Shown briefly. */\n | 'synced';\n\n/** Where one document's edits stand, for the chip in its header. */\nexport type DocumentSyncState =\n /** In Athena. */\n | 'saved'\n /** Being sent to Athena. */\n | 'saving'\n /** Kept on this device while Athena can't be reached; syncs when it is back. */\n | 'on-device'\n /** Kept only while this tab or app stays open (no device storage); syncs when Athena is back. */\n | 'in-memory'\n /** Sending what was kept on this device. */\n | 'syncing'\n /** Some changes overlap with Athena's version: the person chooses. */\n | 'needs-review'\n /** The document can no longer take these edits (access or state changed): kept, not sent. */\n | 'view-only'\n /**\n * Athena answers but isn't taking this document's changes for now: it holds the document while it\n * repairs it, or hasn't confirmed the latest changes for longer than usual (`SavingDelayCause`\n * says which). Kept, and saved once Athena takes them. Never said while Athena can't be reached:\n * that is `on-device` or `in-memory`.\n */\n | 'delayed';\n\n/** Why a document's saving is delayed while Athena answers (`DocumentSyncState` `delayed`). */\nexport type SavingDelayCause =\n /** Athena holds the document while it repairs it, and takes no changes to it until then. */\n | 'repairing'\n /** Athena hasn't confirmed the latest changes for longer than usual. */\n | 'unconfirmed';\n\n/** How a sentence names the device the person is on. */\nexport type DeviceNoun =\n | 'this browser'\n | 'this Mac'\n | 'this computer'\n | 'this phone'\n | 'this tablet'\n | 'this device';\n\n/**\n * What the app really keeps of work done while Athena can't be reached, so the\n * words never promise more: `device` (stored on the device, survives closing),\n * `memory` (kept only while this tab or app stays open: a tenant that turns\n * device copies off, or storage the app can't open), `none` (a surface that\n * keeps no work offline at all, like the Chrome side panel).\n */\nexport type OfflineKeeping = 'device' | 'memory' | 'none';\n\n/** A title and the one line under it (a banner, a sheet, a tooltip's heading). */\nexport interface StatusCopy {\n title: string;\n detail: string;\n}\n\n/** A short label for a chip or a disabled control, and its one-line explanation. */\nexport interface LabelCopy {\n label: string;\n detail: string;\n}\n\nfunction plural(count: number, one: string, many: string): string {\n return `${count} ${count === 1 ? one : many}`;\n}\n\n/** What a tab or an app is called when work lasts only while it stays open. */\nfunction openContainer(device: DeviceNoun): string {\n return device === 'this browser' ? 'this tab' : 'the app';\n}\n\n/** The device noun at the start of a sentence (\"This browser can…\"). */\nfunction deviceAtStart(device: DeviceNoun): string {\n return `${device.charAt(0).toUpperCase()}${device.slice(1)}`;\n}\n\n/**\n * The sentence a promise ends with when the device can remove what it keeps before it syncs\n * (`evictable`: a browser that hasn't granted persistent storage, a private window): \"before then\"\n * after a sentence that says when it syncs, \"before it syncs\" after one that doesn't.\n */\nfunction removableBeforeSync({\n device,\n count,\n before = 'then',\n}: {\n device: DeviceNoun;\n count: number;\n before?: 'then' | 'syncing';\n}): string {\n const what = count === 1 ? 'it' : 'them';\n const when = before === 'then' ? 'then' : count === 1 ? 'it syncs' : 'they sync';\n return `${deviceAtStart(device)} can remove ${what} before ${when}.`;\n}\n\n/** The promise after \"You're offline\" / \"Can't reach Athena\", honest to what is kept. */\nfunction keepingPromise({\n keeping,\n device,\n when,\n evictable,\n}: {\n keeping: OfflineKeeping;\n device: DeviceNoun;\n when: string;\n evictable: boolean;\n}): string {\n switch (keeping) {\n case 'device':\n return evictable\n ? `You can keep working. Changes are kept on ${device} and sync ${when}. ${removableBeforeSync({ device, count: 2 })}`\n : `You can keep working. Changes are saved on ${device} and sync ${when}.`;\n case 'memory':\n return `You can keep working, but changes are kept only until you close ${openContainer(device)}. They sync ${when}.`;\n case 'none':\n return `You’re still signed in. This reconnects ${when}.`;\n }\n}\n\n/**\n * The app-wide status line, or `null` while Athena answers and nothing waits.\n * `waiting` counts changes not in Athena yet: while `reconnecting` they are\n * being sent; while `online` they wait for something else (a copy that sends\n * when it is next opened), and the line stays until they are gone.\n * `keeping` is what the app really keeps offline (default `device`). A surface\n * that keeps nothing (`none`) has nothing to sync, so its way back is only\n * \"Back online\", never \"Syncing…\" or \"All changes synced\".\n * `evictable`: the device may remove what it keeps before it syncs (a browser that hasn't granted\n * persistent storage, a private window); only `keeping` `device` says so (default `false`).\n */\nexport function connectionCopy({\n state,\n device,\n waiting = 0,\n keeping = 'device',\n evictable = false,\n}: {\n state: ConnectionState;\n device: DeviceNoun;\n waiting?: number;\n keeping?: OfflineKeeping;\n evictable?: boolean;\n}): StatusCopy | null {\n const atRisk = evictable && keeping === 'device';\n switch (state) {\n case 'online':\n return waiting > 0\n ? {\n title: 'Not synced yet',\n detail: `${plural(waiting, 'change', 'changes')} on ${device} ${waiting === 1 ? 'hasn’t' : 'haven’t'} synced yet.${\n atRisk ? ` ${removableBeforeSync({ device, count: waiting, before: 'syncing' })}` : ''\n }`,\n }\n : null;\n case 'offline':\n return {\n title: 'You’re offline',\n detail: keepingPromise({\n keeping,\n device,\n when: 'when you’re back online',\n evictable: atRisk,\n }),\n };\n case 'unreachable':\n return {\n title: 'Can’t reach Athena',\n detail: keepingPromise({ keeping, device, when: 'when Athena is back', evictable: atRisk }),\n };\n case 'reconnecting':\n if (keeping === 'none') return { title: 'Back online', detail: '' };\n return {\n title: 'Back online',\n detail:\n waiting > 0 ? `Syncing ${plural(waiting, 'change', 'changes')}…` : 'Syncing…',\n };\n case 'synced':\n return keeping === 'none'\n ? { title: 'Back online', detail: '' }\n : { title: 'All changes synced', detail: '' };\n }\n}\n\n/**\n * The line under \"Saving delayed\": why saving waits, and what is kept meanwhile, never promising\n * more than the app keeps.\n */\nfunction savingDelayedDetail({\n delay,\n keeping,\n device,\n}: {\n delay: SavingDelayCause;\n keeping: Exclude<OfflineKeeping, 'none'>;\n device: DeviceNoun;\n}): string {\n if (delay === 'repairing') {\n return keeping === 'memory'\n ? `You can keep working, but your changes are kept only until you close ${openContainer(device)}. They save once Athena finishes repairing this document.`\n : `You can keep working. Your changes are kept on ${device} and save once Athena finishes repairing this document.`;\n }\n return keeping === 'memory'\n ? `Athena is taking longer than usual to save your latest changes. They’re kept only until you close ${openContainer(device)}, so keep it open.`\n : `Athena is taking longer than usual to save your latest changes. They’re kept on ${device} in the meantime.`;\n}\n\n/**\n * What `on-device` says. The desktop app on a Mac speaks as Word, Excel and PowerPoint for Mac do\n * of a cloud document whose changes wait on the Mac (Office's own title-bar words): \"Upload\n * Pending\", and \"Upload Pending (Offline)\" while Athena can't be reached. Every other app says the\n * changes are saved on its device: a browser has no such words (Word for the web can't edit\n * offline), nor does the phone.\n */\nfunction onDeviceCopy({ device, reachable }: { device: DeviceNoun; reachable: boolean }): LabelCopy {\n if (device === 'this Mac') {\n return {\n label: reachable ? 'Upload Pending' : 'Upload Pending (Offline)',\n detail: 'Upload pending. Your changes sync when Athena is back.',\n };\n }\n return {\n label: `Saved on ${device}`,\n detail: `Saved on ${device}. Your changes sync when Athena is back.`,\n };\n}\n\n/**\n * The chip in a document's header, and the line it explains itself with. For `delayed`, `delay`\n * says why (default `unconfirmed`) and `keeping` whether the changes are kept on the device or only\n * while the tab or app stays open (default `device`); every other state ignores both. `reachable`\n * (default `true`) is whether Athena can be reached, which only the Mac's `on-device` words read.\n * For `on-device`, `evictable` says the device may remove the changes before they sync (default\n * `false`).\n */\nexport function documentSyncCopy({\n state,\n device,\n delay = 'unconfirmed',\n keeping = 'device',\n reachable = true,\n evictable = false,\n}: {\n state: DocumentSyncState;\n device: DeviceNoun;\n delay?: SavingDelayCause;\n keeping?: Exclude<OfflineKeeping, 'none'>;\n reachable?: boolean;\n evictable?: boolean;\n}): LabelCopy {\n switch (state) {\n case 'saved':\n return { label: 'Saved', detail: 'Saved to Athena.' };\n case 'saving':\n return { label: 'Saving…', detail: 'Saving to Athena.' };\n case 'on-device': {\n const copy = onDeviceCopy({ device, reachable });\n return evictable\n ? { ...copy, detail: `${copy.detail} ${removableBeforeSync({ device, count: 2 })}` }\n : copy;\n }\n case 'in-memory':\n return {\n label: 'Not saved yet',\n detail: `Your changes are kept only until you close ${openContainer(device)}, and save when Athena is back.`,\n };\n case 'syncing':\n return { label: 'Syncing…', detail: `Sending the changes saved on ${device}.` };\n case 'needs-review':\n return {\n label: 'Review changes',\n detail: 'Some of your changes overlap with changes made in Athena. Choose what to keep.',\n };\n case 'view-only':\n return {\n label: 'View only',\n detail: `You can’t edit this anymore. Your changes stay on ${device}.`,\n };\n case 'delayed':\n return { label: 'Saving delayed', detail: savingDelayedDetail({ delay, keeping, device }) };\n }\n}\n\n/**\n * How long a document's edits may wait unconfirmed on a connection that is up before its saving\n * reads as delayed (`delayed`, `unconfirmed`), and before its room asks Keryx why: the stall window\n * every editor shares (`ACK_STALL_MS` in `@athenaintel/asset-host/local-updates`, which Olympus's\n * `room-sync-store.test.ts` holds equal).\n */\nexport const UNCONFIRMED_EDITS_STALL_MS = 15_000;\n\n/**\n * Whether a document's saving is delayed, and why (`DocumentSyncState` `delayed`): only while Athena\n * answers and the document's room is connected; `repairing` when Keryx refuses the room's writes\n * (quarantined while it is repaired: said at once, before anything waits), `unconfirmed` once its\n * edits have stayed unconfirmed past {@link UNCONFIRMED_EDITS_STALL_MS}; `null` otherwise. Every\n * app's chip asks it after a hold and after edits kept without a connection, and ahead of syncing\n * and saving, so a room that refuses writes never spins and being offline is never a delay.\n */\nexport function savingDelayCause({\n reachable,\n connected,\n writesRefused,\n unconfirmedStalled,\n}: {\n /** Athena answers (the app's reachability, never the socket alone). */\n reachable: boolean;\n /** The document's room is connected. */\n connected: boolean;\n /** Keryx's document status says the room takes no writes for now. */\n writesRefused: boolean;\n /** Local edits have waited unconfirmed past the stall window on this connection. */\n unconfirmedStalled: boolean;\n}): SavingDelayCause | null {\n if (!reachable || !connected) return null;\n if (writesRefused) return 'repairing';\n return unconfirmedStalled ? 'unconfirmed' : null;\n}\n\n/**\n * How long a chat message sent while Athena answers may go unaccepted before it counts as waiting\n * to sync. Athena accepts a send when it takes it, not when its turn ends: 99% of follow-up messages\n * within 3.4 s of the send, and 99% of a chat's first, which also dials, within 5.7 s (production\n * `chat_ttft`, 30 days to 2026-11-07). A send still unaccepted after this is stuck, not on its way.\n */\nexport const UNACCEPTED_SEND_STALL_MS = 8_000;\n\n/** Where a chat message a chat outbox lists stands, for {@link messageWaitsToSync}. */\nexport interface OutboxMessageStanding {\n /** Athena answers (the app's reachability, never the socket alone). */\n reachable: boolean;\n /**\n * On its way to Athena: sent, or queued to go. `false` while the outbox holds it: typed while\n * Athena couldn't be reached, behind a message that waits, asking after a day, or failed.\n */\n sending: boolean;\n /** Athena received it, before its answer reached the outbox. */\n received?: boolean;\n /** When it was sent (epoch ms); for one typed while Athena couldn't be reached, when it was typed. */\n sentAt: number;\n}\n\n/**\n * When a message on its way starts counting as waiting to sync, should Athena not have accepted it\n * by then ({@link UNACCEPTED_SEND_STALL_MS} after it was sent, while Athena answers); `null` when no\n * time passing changes whether it counts.\n */\nexport function messageStartsWaitingAt({\n reachable,\n sending,\n received = false,\n sentAt,\n}: OutboxMessageStanding): number | null {\n return reachable && sending && !received ? sentAt + UNACCEPTED_SEND_STALL_MS : null;\n}\n\n/**\n * Whether a chat message a chat outbox lists counts as waiting to sync, for the status line's\n * count and the \"Waiting to sync\" list: one the outbox holds, always; one on its way, at once while\n * Athena can't be reached, and while it answers only once it went unaccepted past\n * {@link UNACCEPTED_SEND_STALL_MS}; one Athena received, never. A send that goes as usual, however\n * long its turn runs, shows no status line.\n */\nexport function messageWaitsToSync(standing: OutboxMessageStanding & { now: number }): boolean {\n const startsAt = messageStartsWaitingAt(standing);\n if (startsAt !== null) return standing.now >= startsAt;\n return !(standing.sending && standing.received === true);\n}\n\n/**\n * What the person lost of a document while it was open, when the server checked its room's grant\n * again (Keryx's 4401, after a share changed): `edit`, they may still view it but no longer edit it;\n * `view`, they may no longer open it at all.\n */\nexport type AccessLoss = 'edit' | 'view';\n\n/** What a document says once the person's access changed while it was open. */\nexport interface AccessChangedCopy extends StatusCopy {\n /**\n * The label of the button that saves the person's version as a file of their own, with the\n * changes Athena never took, or `null` when nothing is offered: nothing of theirs waits, or the\n * device no longer serves the document (`view`).\n */\n saveCopy: string | null;\n}\n\n/**\n * What a document says once the person's access to it changed while it was open (its editor turns\n * read-only at once), in Word's own words (Word for Mac's message bar, `strman_a6d4`, and its\n * \"Permissions Have Changed\" notice, `strman_0a3e`). `keptChanges`: changes Athena never took wait\n * on the device, never sent; Word offers to save a copy of the file so nothing typed is lost. A\n * `view` loss offers nothing to save: the device stops serving a document the person may no longer\n * read, and their changes stay where they are kept.\n */\nexport function accessChangedCopy({\n loss,\n keptChanges,\n}: {\n loss: AccessLoss;\n keptChanges: boolean;\n}): AccessChangedCopy {\n if (loss === 'view') {\n return {\n title: 'Permissions Have Changed',\n detail:\n 'The permissions on this file were updated and you no longer have rights to view this file. If you previously made changes to this file, they may not be saved.',\n saveCopy: null,\n };\n }\n if (keptChanges) {\n return {\n title: 'Can’t save',\n detail:\n 'You no longer have the permission to edit this file. To keep your changes, save a copy of the file.',\n saveCopy: 'Save a Copy',\n };\n }\n return {\n title: 'Read Only',\n detail: 'You no longer have the permission to edit this file.',\n saveCopy: null,\n };\n}\n\n/** What a document says while it holds changes an older version of Athena made on the device. */\nexport interface OlderVersionChangesCopy extends StatusCopy {\n /** Saves this device's version as a file of the person's own, those changes included (Word's). */\n saveCopy: string;\n /** Sets this device's version aside, kept on the device to compare, and opens Athena's. */\n useAthenaVersion: string;\n /** Athena's version couldn't be opened: another window of the device has the document open. */\n openElsewhere: string;\n}\n\n/**\n * What a document says while its local copy holds changes an older version of Athena made on this\n * device, below its room's write floor (`older-version`, `@athenaintel/local-copy/room-copy`): such\n * a version deletes what newer ones store, so those changes are never uploaded. Word's way out,\n * as when it can't save a cloud document (\"You can save a copy to prevent losing your work\",\n * `strman`): Save a Copy, and Athena's version, which keeps this device's version on it to compare\n * and copy from until the person discards it. `saveCopy: false` for an editor that offers no Save\n * a Copy of the live document. `keepsAside: false` where the device keeps no version of it aside\n * (copies off, an embed): Athena's version then replaces the changes, which the person confirms\n * first (`olderVersionDiscardCopy`). `atRisk`: the device can lose them before the person chooses\n * (`ChangesAtRisk`), said here so the document shows one strip, never a second \"Changes not saved\".\n */\nexport function olderVersionChangesCopy({\n device,\n noun,\n saveCopy = true,\n keepsAside = true,\n atRisk = null,\n}: {\n device: DeviceNoun;\n /** What the person calls the document (\"document\", \"spreadsheet\"). */\n noun: string;\n /** Whether the editor offers Save a Copy beside Athena's version. */\n saveCopy?: boolean;\n /** Whether Athena's version keeps the device's aside, as a version to compare and copy from. */\n keepsAside?: boolean;\n /** How the device could lose the changes before the person chooses, or `null`. */\n atRisk?: ChangesAtRisk | null;\n}): OlderVersionChangesCopy {\n const made = `You changed this ${noun} on ${device} in an older version of Athena, so those changes can’t be uploaded.`;\n const keep = !keepsAside\n ? `${made} To keep them, save a copy: Athena’s version replaces them on ${device}.`\n : saveCopy\n ? `${made} To keep them, save a copy, or use Athena’s version: yours stays on ${device} to compare and copy from.`\n : `${made} Use Athena’s version to keep yours on ${device}, where you can compare it, copy from it or download it.`;\n const risk =\n atRisk === null\n ? ''\n : atRisk === 'memory'\n ? ` Until you choose, they’re kept only until you close ${openContainer(device)}.`\n : ` Until you choose, they’re saved only on ${device}, which can remove them.`;\n return {\n title: 'Changes made in an older version of Athena',\n detail: `${keep}${risk}`,\n saveCopy: 'Save a Copy',\n useAthenaVersion: 'Use Athena’s version',\n openElsewhere: `This ${noun} is open in another window. Close it there, then try again.`,\n };\n}\n\n/**\n * What the device asks before Athena's version replaces changes an older version of Athena made,\n * where it keeps no version of them aside (`olderVersionChangesCopy`'s `keepsAside: false`): they\n * are deleted then, so a copy saved first is the only way to keep them.\n */\nexport function olderVersionDiscardCopy({ device }: { device: DeviceNoun }): StatusCopy & {\n confirm: string;\n cancel: string;\n} {\n return {\n title: 'Use Athena’s version?',\n detail: `The changes made in an older version of Athena are deleted from ${device} and can’t be recovered. To keep them, save a copy first.`,\n confirm: 'Use Athena’s version',\n cancel: 'Cancel',\n };\n}\n\n/** When something that needs Athena works again (the second half of `NEEDS_CONNECTION`'s line). */\nconst AVAILABLE_AGAIN = 'It’s available again when you’re back online.';\n\n/**\n * The reason on a control that needs Athena (share, run, upload, invite…),\n * shown disabled while Athena can't be reached rather than failing when used.\n */\nexport const NEEDS_CONNECTION: LabelCopy = {\n label: 'Needs a connection',\n detail: `This needs Athena. ${AVAILABLE_AGAIN}`,\n};\n\n/** What a composer was given to attach: a photo (from the phone's library or camera) or any file. */\nexport type AttachmentKind = 'photo' | 'file';\n\n/**\n * What a composer says when a photo or file is picked, pasted or dropped while\n * Athena can't be reached. It isn't attached: only Athena can take the upload.\n * In a chat whose messages wait to be sent meanwhile (`messagesWait`: the chat\n * keeps an outbox), the message itself still goes, so that comes first. Where\n * nothing waits (the Chrome side panel, a launcher that keeps its draft), it\n * says only when attaching works again, as `NEEDS_CONNECTION` does.\n */\nexport function attachmentNeedsConnection({\n kind,\n messagesWait,\n}: {\n kind: AttachmentKind;\n messagesWait: boolean;\n}): string {\n const attaching = `Attaching ${kind === 'photo' ? 'a photo' : 'a file'} needs Athena. ${AVAILABLE_AGAIN}`;\n return messagesWait\n ? `You can still send your message: it goes when Athena is back. ${attaching}`\n : attaching;\n}\n\n/** What a sent message carries besides its text that can't wait for Athena. */\nfunction heldExtras(withQuote: boolean): string {\n return withQuote ? 'an attachment or a quote' : 'an attachment';\n}\n\n/**\n * What a composer says when a message is sent while Athena can't be reached\n * with an attachment that never reached Athena (or, `withQuote`, a quote the\n * chat can't keep waiting). Its text could wait in the chat outbox; the\n * attachment can't, so the draft stays as it is.\n */\nexport function attachedMessageNeedsConnection({\n withQuote = false,\n}: {\n withQuote?: boolean;\n} = {}): string {\n return `Your text can wait and go when Athena is back, but ${heldExtras(withQuote)} can’t. Remove ${\n withQuote ? 'it' : 'the attachment'\n } to send the text.`;\n}\n\n/**\n * What a composer says when a message with an attachment (or, `withQuote`, a\n * quote) is sent while messages written when Athena couldn't be reached still\n * wait to go: those go first, and this one can't wait behind them.\n */\nexport function attachedMessageBehindWaiting({\n withQuote = false,\n}: {\n withQuote?: boolean;\n} = {}): string {\n return `Messages you wrote while Athena couldn’t be reached go first. A message with ${heldExtras(\n withQuote\n )} can’t wait behind them, so send it once they’ve gone.`;\n}\n\n/**\n * The reason on New Presentation while Athena can't be reached, in a workspace\n * whose new presentations start from its design system's template: a\n * presentation made offline starts blank, and only Athena can copy the\n * template, so none is made until it can.\n */\nexport const NEW_PRESENTATION_NEEDS_TEMPLATE: LabelCopy = {\n label: 'Needs a connection',\n detail:\n 'New presentations in this workspace start from its design template, which needs Athena. You can make one when you’re back online.',\n};\n\n/**\n * What a download of a spreadsheet made from an Excel Macro-Enabled Workbook\n * (`.xlsm`) says when it went without the macros because they come from Athena,\n * which can't be reached, and the device hasn't kept them (they travel with the\n * workbook and never run). The file is the workbook without them; downloading\n * it again with a connection keeps them.\n */\nexport function downloadedWithoutMacros({ device }: { device: DeviceNoun }): string {\n return `Downloaded without the spreadsheet’s macros: they come from Athena, which can’t be reached, and ${device} hasn’t kept them. Download it again when you’re back online to keep them.`;\n}\n\n/**\n * Save's question for a spreadsheet whose file is an Excel Macro-Enabled\n * Workbook (`.xlsm`) while its macros come from Athena, which can't be reached,\n * and the device hasn't kept them: the `.xlsm` is never written without them,\n * so the person may save an Excel Workbook (`.xlsx`) beside it, and Save writes\n * the `.xlsm` again once Athena answers.\n */\nexport function macrosNeedConnectionToSave({\n name,\n device,\n}: {\n name: string;\n device: DeviceNoun;\n}): StatusCopy {\n return {\n title: `Athena can’t save the macros in “${name}” right now.`,\n detail: `They come from Athena, which can’t be reached, and ${device} hasn’t kept them. Save it as an Excel Workbook (.xlsx) instead. “${name}” stays as it is, macros included, and Save writes it again when you’re back online.`,\n };\n}\n\n/**\n * What saving a version kept on the device as a new spreadsheet says when the\n * spreadsheet was made from an Excel Macro-Enabled Workbook (`.xlsm`) and\n * Athena can't be reached: the new spreadsheet is made all the same (on the\n * device, and it syncs when Athena is back), but without the macros, which only\n * Athena can keep with a new spreadsheet (they travel with the workbook and\n * never run). The kept version stays on the device, so saving it again with a\n * connection keeps them.\n */\nexport function savedAsNewWithoutMacros({ device }: { device: DeviceNoun }): string {\n return `The new spreadsheet was made without the macros: only Athena can keep them in a new spreadsheet, and it can’t be reached. The kept version stays on ${device}: save it as a new spreadsheet again when you’re back online to keep them.`;\n}\n\n/**\n * What saving a copy of a Word document says when the copy was made on the\n * device while Athena can't be reached, for a document whose original Word file\n * Athena keeps (one imported from Word): the copy is made all the same, from the\n * document as the device holds it, over the original file kept with it, so it\n * keeps that file's parts but writes its body from what the editor shows.\n * Athena's own copy, made from the original file, keeps everything, so saving\n * a copy again with a connection makes that one.\n */\nexport function wordCopyMadeOnDevice({ device }: { device: DeviceNoun }): StatusCopy {\n return {\n title: `Copy made on ${device}`,\n detail: `Athena can’t be reached, so the copy holds the document as it is on ${device}. Anything in the original Word file that Athena doesn’t show may be missing from it: save a copy again when you’re back online for the complete file.`,\n };\n}\n\n/**\n * Whether an item opens without a connection on this device, for lists.\n * `canKeep` says whether opening it online would keep a copy here (the\n * deployment allows device copies and the item's type is one that is kept):\n * only then is the person told to open it once.\n */\nexport function offlineAvailabilityCopy({\n available,\n canKeep,\n device,\n}: {\n available: boolean;\n canKeep: boolean;\n device: DeviceNoun;\n}): LabelCopy {\n if (available) {\n return { label: 'Available offline', detail: `Opens without a connection on ${device}.` };\n }\n return canKeep\n ? {\n label: 'Not available offline',\n detail: `Open it once while online to keep it on ${device}.`,\n }\n : { label: 'Not available offline', detail: 'This opens only with a connection.' };\n}\n\n/**\n * The choice to always keep an item on the device, as OneDrive's \"Always Keep\n * on This Device\" (for the item's menu and its toggle in a list). Kept, it\n * stays however long it goes unopened; otherwise the device keeps what was\n * opened in the last `keptDays` days while there is room, and an item turned\n * off counts its days from then. `menuLabel` is the label in a desktop menu's\n * title case (\"Always Keep on This Mac\").\n */\nexport function alwaysKeepCopy({\n kept,\n device,\n keptDays,\n}: {\n kept: boolean;\n device: DeviceNoun;\n keptDays: number;\n}): LabelCopy & { menuLabel: string } {\n const titled = device.replace(/(^|\\s)\\S/g, (letter) => letter.toUpperCase());\n return {\n label: `Always keep on ${device}`,\n menuLabel: `Always Keep on ${titled}`,\n detail: kept\n ? `Kept on ${device} however long it goes unopened, so it always opens without a connection. Turned off, it stays while you open it at least every ${keptDays} days and there’s room, counting from then.`\n : `Kept on ${device} while you open it at least every ${keptDays} days and there’s room. Turn this on to keep it however long it goes unopened.`,\n };\n}\n\n/**\n * What a toast says when always keeping an item on the device could not be\n * turned on or off (`alwaysKeepCopy`): the item was no longer kept there\n * (`gone`: nothing to keep until it is opened once online), or the change\n * failed and can be tried again.\n */\nexport function alwaysKeepFailedCopy({\n kept,\n gone,\n device,\n}: {\n /** What the person asked for: on (`true`) or off. */\n kept: boolean;\n gone: boolean;\n device: DeviceNoun;\n}): string {\n if (gone) {\n return `This isn’t kept on ${device} any more. Open it once while online to keep it here.`;\n }\n return `Always keep on ${device} couldn’t be turned ${kept ? 'on' : 'off'}. Try again.`;\n}\n\n/**\n * A history Athena keeps in full (a person's dictations), shown while Athena\n * can't be reached: the list holds only what is kept on the device, and the\n * rest comes back once online. `items` names what the list holds, plural.\n */\nexport function historyOnDeviceCopy({\n items,\n device,\n}: {\n items: string;\n device: DeviceNoun;\n}): string {\n return `Showing the ${items} kept on ${device}. Your full history shows when you’re back online.`;\n}\n\n/**\n * The one-time explainer shown the first time the person goes offline, on a\n * surface that keeps work there (`keeping` `device`). A surface that keeps\n * nothing (`none`: the Chrome side panel, the Office task pane) shows no\n * explainer; its status line already says all that holds.\n */\nexport function firstOfflineExplainer({ device }: { device: DeviceNoun }): StatusCopy & {\n action: string;\n} {\n return {\n title: 'You can keep working offline',\n detail: `Documents you’ve opened recently keep working on ${device}. Your changes sync when you’re back online.`,\n action: 'Got it',\n };\n}\n\n/**\n * How a document's changes could be lost before they reach Athena:\n *\n * - `device`: saved on the device until they reach Athena (no connection, or a review the person\n * has not finished), but the device can remove them before then (a browser that hasn't granted\n * persistent storage, a private window);\n * - `held`: saved on the device and can't be sent (the person can only view the document now, or\n * Athena refused it), and the device can remove them;\n * - `memory`: kept only until the tab or app closes (the device keeps no copy).\n */\nexport type ChangesAtRisk = 'device' | 'held' | 'memory';\n\n/**\n * What an editor says above a document whose changes could be lost before they reach Athena, with\n * the copy it offers of them (Word's \"Upload pending\" and \"Changes not saved\"), by\n * {@link ChangesAtRisk}.\n */\nexport function changesAtRiskCopy({\n risk,\n device,\n}: {\n risk: ChangesAtRisk;\n device: DeviceNoun;\n}): StatusCopy & { download: string; downloadFailed: string } {\n const actions = {\n download: 'Download a copy',\n downloadFailed: 'Couldn’t download a copy. Try again.',\n };\n switch (risk) {\n case 'memory':\n return {\n title: 'Changes not saved',\n detail: `Your changes are kept only until you close ${openContainer(device)}. Download a copy to keep them.`,\n ...actions,\n };\n case 'held':\n return {\n title: 'Changes not saved',\n detail: `Your changes can’t be sent, and they’re saved only on ${device}, which can remove them. Download a copy to keep them.`,\n ...actions,\n };\n case 'device':\n return {\n title: 'Upload pending',\n detail: `Your changes are saved only on ${device} until they reach Athena, and ${device} can remove them before then. Download a copy to keep them.`,\n ...actions,\n };\n }\n}\n\n/**\n * The action that asks the device to keep what it holds until it syncs, where asking shows the\n * person a question of the device's own (Firefox asks whether the site may store data in\n * persistent storage): the app says why first (`changesAtRiskCopy`) and asks only on this press.\n */\nexport function keepOnDeviceAction({ device }: { device: DeviceNoun }): string {\n return `Keep them on ${device}`;\n}\n\n/** A browser whose own way of keeping a site's data longer the words point to. */\nexport type SaferStorageBrowser = 'safari-mac' | 'safari-ios';\n\n/**\n * Where changes made offline in Safari are kept longer: Safari keeps a site added to the Dock or\n * the Home Screen as an app of its own, which it is likelier to let keep its data, and the desktop\n * app keeps them in storage of its own.\n */\nexport function saferStorageHint({ browser }: { browser: SaferStorageBrowser }): string {\n return browser === 'safari-mac'\n ? 'To keep changes made offline safer in Safari, add Athena to the Dock (File ▸ Add to Dock), or use Athena Desktop.'\n : 'To keep changes made offline safer, add Athena to your Home Screen (Share ▸ Add to Home Screen).';\n}\n\n/**\n * What the app says when it finds that changes it kept on the device never reached Athena and the\n * device no longer has them (the browser removed them before they synced), naming how many\n * documents; each document's row says when its changes were made (`lostChangesItemCopy`).\n */\nexport function lostChangesCopy({\n device,\n documents,\n}: {\n device: DeviceNoun;\n documents: number;\n}): StatusCopy & { why: string; open: string; dismiss: string } {\n const which = documents === 1 ? 'a document' : plural(documents, 'document', 'documents');\n return {\n title: 'Changes not saved',\n detail: `Changes you made to ${which} on ${device} never reached Athena, and ${device} no longer has them.`,\n why: 'Browsers can remove what a site keeps when they run short of space, or when the site hasn’t been opened for a while.',\n open: 'Open',\n dismiss: 'Dismiss',\n };\n}\n\n/** One document's row under `lostChangesCopy`: the words before when its changes were made. */\nexport function lostChangesItemCopy({ device }: { device: DeviceNoun }): string {\n return `Changed on ${device}`;\n}\n\n/**\n * The summary heading of the one \"Waiting to sync\" list, from what waits on\n * the device; `null` when nothing does.\n */\nexport function waitingSummary({\n changes = 0,\n messages = 0,\n recordings = 0,\n}: {\n changes?: number;\n messages?: number;\n recordings?: number;\n}): string | null {\n const parts = [\n changes > 0 ? plural(changes, 'change', 'changes') : null,\n messages > 0 ? plural(messages, 'message', 'messages') : null,\n recordings > 0 ? plural(recordings, 'recording', 'recordings') : null,\n ].filter((part): part is string => part !== null);\n return parts.length === 0 ? null : `Waiting to sync: ${parts.join(', ')}`;\n}\n\n/**\n * Where one item in the \"Waiting to sync\" list stands: a change, a message or a\n * recording made while Athena couldn't be reached.\n */\nexport type WaitingItemState =\n /** Saved on the device; it syncs when Athena is back. */\n | 'waiting'\n /** Being sent to Athena now. */\n | 'syncing'\n /**\n * Kept only while this tab or app stays open: it couldn't be saved on the\n * device (the organization keeps nothing on devices, or storage failed).\n */\n | 'in-memory'\n /**\n * Saved on the device in something that isn't open (a document closed before\n * its edits synced, one made offline): it syncs when it is next opened with a\n * connection (opening it offline sends nothing).\n */\n | 'when-opened'\n /** Made in another workspace: it syncs once that workspace is open again, with a connection. */\n | 'other-workspace'\n /** It doesn't go on its own: the person chooses (a message that waited more than a day). */\n | 'needs-you'\n /** Athena didn't take it: the app adds why, and what the person can do about it. */\n | 'not-synced';\n\n/**\n * The state of one item in the \"Waiting to sync\" list, and the line that explains it. For an item\n * saved on the device (`waiting`, `when-opened`), `evictable` says the device may remove it before\n * it syncs (default `false`).\n */\nexport function waitingItemCopy({\n state,\n device,\n evictable = false,\n}: {\n state: WaitingItemState;\n device: DeviceNoun;\n evictable?: boolean;\n}): LabelCopy {\n const removable = evictable ? ` ${removableBeforeSync({ device, count: 1 })}` : '';\n switch (state) {\n case 'waiting':\n return {\n label: 'Waiting to sync',\n detail: `Saved on ${device}. It syncs when Athena is back.${removable}`,\n };\n case 'syncing':\n return { label: 'Syncing…', detail: `Sending what was saved on ${device}.` };\n case 'in-memory':\n return {\n label: 'Not saved yet',\n detail: `Kept only until you close ${openContainer(device)}. It syncs when Athena is back.`,\n };\n case 'when-opened':\n return {\n label: 'Not synced yet',\n detail: `Saved on ${device}. It syncs when you next open it with a connection.${removable}`,\n };\n case 'other-workspace':\n return {\n label: 'Waiting for its workspace',\n detail: 'It syncs once you’re in the workspace it was made in, with a connection.',\n };\n case 'needs-you':\n return { label: 'Waiting for you', detail: 'Open it to choose what happens to it.' };\n case 'not-synced':\n return { label: 'Couldn’t sync', detail: 'Athena didn’t take it.' };\n }\n}\n\n/** The headings of the \"Waiting to sync\" list, one per kind of item. */\nexport const WAITING_SECTION_TITLES = {\n changes: 'Changes',\n messages: 'Messages',\n recordings: 'Recordings',\n} as const;\n\n/**\n * What the person can do with an item Athena didn't take (`not-synced`) in the\n * \"Waiting to sync\" list: send it again, or delete the copy on the device. That\n * copy is the item's only one, so the app asks first (`deleteFromDeviceConfirm`)\n * and never deletes it as a side effect of anything else.\n */\nexport function notSyncedActions({ device }: { device: DeviceNoun }): {\n retry: string;\n remove: string;\n} {\n return { retry: 'Try again', remove: `Delete from ${device}` };\n}\n\n/** One of `notSyncedActions`, or showing where the item is kept on the device. */\nexport type NotSyncedAction = 'retry' | 'reveal' | 'remove';\n\n/**\n * What the person hears when the device couldn't do what they asked for an\n * item Athena didn't take (its files, not Athena, failed).\n */\nexport function notSyncedActionFailed({\n action,\n device,\n}: {\n action: NotSyncedAction;\n device: DeviceNoun;\n}): string {\n switch (action) {\n case 'retry':\n return 'Couldn’t send it again. Try again in a moment.';\n case 'reveal':\n return `Couldn’t show where it’s kept on ${device}.`;\n case 'remove':\n return `Couldn’t delete it from ${device}.`;\n }\n}\n\n/**\n * The question before deleting such an item's copy on the device, naming the\n * item: the destructive choice is never the default.\n */\nexport function deleteFromDeviceConfirm({\n name,\n device,\n}: {\n name: string;\n device: DeviceNoun;\n}): StatusCopy & { confirm: string; cancel: string } {\n return {\n title: `Delete “${name}” from ${device}?`,\n detail: 'Athena didn’t take it, so this is its only copy. Deleting it can’t be undone.',\n confirm: 'Delete',\n cancel: 'Cancel',\n };\n}\n"],"names":[],"mappings":";;AA6TO,MAAM,6BAA6B;AAqN1C,MAAM,kBAAkB;AAMjB,MAAM,mBAA8B;AAAA,EACzC,OAAO;AAAA,EACP,QAAQ,sBAAsB,eAAe;AAC/C;AAaO,SAAS,0BAA0B;AAAA,EACxC;AAAA,EACA;AACF,GAGW;AACT,QAAM,YAAY,aAA4C,QAAQ,kBAAkB,eAAe;AACvG,SAAO,eACH,iEAAiE,SAAS,KAC1E;AACN;AAGA,SAAS,WAAW,WAA4B;AAC9C,SAAO,YAAY,6BAA6B;AAClD;AAQO,SAAS,+BAA+B;AAAA,EAC7C,YAAY;AACd,IAEI,IAAY;AACd,SAAO,sDAAsD,WAAW,SAAS,CAAC,kBAChF,YAAY,OAAO,gBACrB;AACF;AAOO,SAAS,6BAA6B;AAAA,EAC3C,YAAY;AACd,IAEI,IAAY;AACd,SAAO,gFAAgF;AAAA,IACrF;AAAA,EAAA,CACD;AACH;;;;;;"}
@@ -340,6 +340,33 @@ export declare function offlineAvailabilityCopy({ available, canKeep, device, }:
340
340
  canKeep: boolean;
341
341
  device: DeviceNoun;
342
342
  }): LabelCopy;
343
+ /**
344
+ * The choice to always keep an item on the device, as OneDrive's "Always Keep
345
+ * on This Device" (for the item's menu and its toggle in a list). Kept, it
346
+ * stays however long it goes unopened; otherwise the device keeps what was
347
+ * opened in the last `keptDays` days while there is room, and an item turned
348
+ * off counts its days from then. `menuLabel` is the label in a desktop menu's
349
+ * title case ("Always Keep on This Mac").
350
+ */
351
+ export declare function alwaysKeepCopy({ kept, device, keptDays, }: {
352
+ kept: boolean;
353
+ device: DeviceNoun;
354
+ keptDays: number;
355
+ }): LabelCopy & {
356
+ menuLabel: string;
357
+ };
358
+ /**
359
+ * What a toast says when always keeping an item on the device could not be
360
+ * turned on or off (`alwaysKeepCopy`): the item was no longer kept there
361
+ * (`gone`: nothing to keep until it is opened once online), or the change
362
+ * failed and can be tried again.
363
+ */
364
+ export declare function alwaysKeepFailedCopy({ kept, gone, device, }: {
365
+ /** What the person asked for: on (`true`) or off. */
366
+ kept: boolean;
367
+ gone: boolean;
368
+ device: DeviceNoun;
369
+ }): string;
343
370
  /**
344
371
  * A history Athena keeps in full (a person's dictations), shown while Athena
345
372
  * can't be reached: the list holds only what is kept on the device, and the