Replace the composer textarea with a Tiptap markdown editor

The composer now writes markdown with bold, italic, strike, code,
links, quotes and lists, from shortcuts, markdown typing or a bubble
over the selection. @ mentions a bot and # a workspace, shown as chips
and saved as [bot:ID] / [workspace:ID]. Text selected in a message can
be quoted into the composer. User messages render as markdown, and
replies tell the bot who and what the user mentioned.

Adds an ESM stand-in for use-sync-external-store, since bunext keeps
React external in page bundles and the CommonJS original requires it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-20 05:48:23 +01:00
co-authored by Claude Opus 5.5
parent e2093b0df6
commit 1208eac6b7
51 changed files with 1618 additions and 126 deletions
+2 -1
View File
@@ -91,7 +91,8 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one.
- **Routes:** `/admin/bots` ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; `session-messages.ts` returns a session's latest messages, which the open conversation polls every `AppData.BotReplyCatchUpMs` while a reply is due (`use-reply-catch-up`), so a reply whose live push was missed (socket down) still lands without a reload (replayed as a `server:bot-message`, so the rail and unread take it too); `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models.
- **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). A bot names a crew member as `[bot:ID]` (`src/utils/bot-ref.ts`, linked to `#bot-ID` by `link-computer-refs.ts`): `(partials)/bot-ref-chip.tsx` shows its avatar and name, linked to its conversation (in the open workspace when it's in the crew there), from the live crew (`CrewBotsContext`), so a bot created in that reply links too; the coordinator is told to write them (`CoordinatorReplyNote`), and `find_bot` and `create_bot` return them. User messages stay plain text.
- **Composer:** a Tiptap editor (`(hooks)/use-composer-editor.ts`, `(partials)/composer-editor.tsx`), not a textarea, that reads and writes markdown (`@tiptap/markdown`), so drafts (`useComposerDraft`) and sent messages are markdown. Bold, italic, strike, code, lists, quotes and links (no headings), from shortcuts, markdown typing or the selection bubble (`composer-format-bubble.tsx`, items in `(data)/composer-formats.ts`). `@` mentions a bot and `#` a workspace (`(data)/composer-mention-kinds.ts`, menu `composer-mention-list.tsx` above the input, items from `CrewBotsContext` and `CrewWorkspacesContext` via `searchMentionItems`), shown as chips (`composer-mention-chip.tsx`) and written as `[bot:ID]` / `[workspace:ID]` (`(utils)/mention-markdown.ts`, `src/utils/workspace-ref.ts`). Enter sends, Shift+Enter breaks the line; in a list or code block Enter is the editor's and Mod+Enter sends. Selecting text in a message (`data-quotable`, `use-message-selection`) shows a Quote button (`quote-selection-button.tsx`) that inserts it as a blockquote at the cursor (`ComposerHandle.insertQuote`). Before a reply, `grabMessageRefNames` names the refs in the user's messages that the user reaches, and `botMessagesToHarness` adds a `[Mentions: ...]` line after each one (`describeMessageRefs`). `@tiptap/react` needs `use-sync-external-store`, which is CommonJS and calls `require("react")`; bunext keeps React external in page bundles, so the app installs an ESM stand-in from `packages/use-sync-external-store-esm` (a `file:` dependency plus an override; the Dockerfile copies `packages/` before `bun install`).
- **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). A bot names a crew member as `[bot:ID]` (`src/utils/bot-ref.ts`, linked to `#bot-ID` by `link-computer-refs.ts`): `(partials)/bot-ref-chip.tsx` shows its avatar and name, linked to its conversation (in the open workspace when it's in the crew there), from the live crew (`CrewBotsContext`), so a bot created in that reply links too; the coordinator is told to write them (`CoordinatorReplyNote`), and `find_bot` and `create_bot` return them. A workspace is named as `[workspace:ID]` (`#workspace-ID`, `(partials)/workspace-ref-chip.tsx`, from `CrewWorkspacesContext`). User messages are markdown too, rendered in the ink bubble by `user-bubble.tsx` with `UserMarkdownComponents` and `remark-breaks` (a single line break stays one, so older plain messages read as typed).
- **Replies and reactions:** every bot message has a row under it (`(partials)/message-actions.tsx`, handlers from `BotMessageActionsContext`) to reply or react; the user's own messages only show the bot's reactions, read-only. Replying quotes the message above the composer (`composer-reply-preview.tsx`) and the sent one carries `bot_messages.reply_to_message_id` (not a foreign key: a deleted quote only drops it); `send-message` takes any message of the same conversation (the user's, same bot and workspace, any session, so a rollover or a moved-on thread still sends), and the conversation drops a picked quote when its session changes. Messages carry `reply_to` (`grabMessagesReplies`, also on relayed messages), shown by `message-reply-quote.tsx`, and the bot reads it as `[Replying to ...]` before the user's turn (`botMessagesToHarness`). Reactions are a fixed set (`BotMessageReactions` in `src/dict/bot-dict.ts`) in `bot_message_reactions` (one row per message, emoji and user, or bot); `api/admin/bots/react-message.ts` toggles the user's own on a bot message in their sessions (`grabUserBotMessage`, `toggleMessageReaction`), and every bot gets `react_to_message` (`src/harness/tools/bot/react-to-message-tool.ts`, chat replies and tasks: a message in the session it answers, the user's latest by default). Both push the message's fresh reactions (`grabMessagesReactions`, `mine` for the session user) to the session's user as `server:bot-reaction` (`BotMessageReactionData`), which replaces the message's own; `grabBotMessages` adds them when given `user_id`. Reactions aren't relayed between processes (production runs one).
- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; while a task runs it reads "<name> is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay.
- **Voice:** the composer's mic (`(partials)/composer-voice-button.tsx`, `use-voice-recorder`, shown once the browser can record, disabled with a note while transcription has no model to run on for the bot there, `bot_voice` page prop, from `resolveHarnessCandidates`) dictates: the recording goes to `api/admin/bots/transcribe.ts` (`transcribeAudio` with the bot's models, after `grabUserConversation`) and the text lands in the input at the cursor, to edit and send; Escape cancels, `AppData.ComposerVoiceMaxMs` stops it. Each bot message has a play button (disabled with a note while speech has no model) (`speak-message-button.tsx`, one player per conversation via `SpeakMessageContext` / `use-speak-message`) that plays `GET api/admin/bots/speak-message.ts?message_id=` straight in an `<audio>` (`speakText` streamed through as the provider makes it, so it starts in a couple of seconds; a failure answers in JSON, which the hook fetches again to show; only the user's own sessions, `grabSpeakableMessage`; text from `speechText`, cut at `AppData.SpeechMaxChars`). Under Speech, every model settings form has "Hear a sample" (`settings/models/(partials)/harness-voice-preview.tsx`), which plays `AppData.VoicePreviewText` in the form's model and voice before they're saved (`GET api/admin/harness/preview-voice`, super admins and admins, that model only via `grabModelCandidate`, streamed). Neither recordings nor audio are stored. Harness errors (`HarnessError`, safe to show) come back as the buttons' titles.
+2
View File
@@ -16,6 +16,8 @@ WORKDIR /app
RUN echo 'alias ll="ls -laF"' >/root/.bashrc
COPY package.json bun.lock ./
# Local packages the lockfile installs from (`file:` dependencies)
COPY packages ./packages
RUN bun install --frozen-lockfile
COPY . /app/.
+131
View File
@@ -5,10 +5,18 @@
"": {
"name": "horandez-and-detroit",
"dependencies": {
"@floating-ui/dom": "1.8.0",
"@modelcontextprotocol/sdk": "^1.32.1",
"@moduletrace/bun-sqlite": "^1.3.0",
"@moduletrace/bunext": "^1.3.3",
"@moduletrace/twui": "^0.1.2",
"@tiptap/extension-bubble-menu": "3.32.0",
"@tiptap/extension-mention": "3.32.0",
"@tiptap/extension-placeholder": "3.32.0",
"@tiptap/markdown": "3.32.0",
"@tiptap/pm": "3.32.0",
"@tiptap/react": "3.32.0",
"@tiptap/starter-kit": "3.32.0",
"ace-builds": "^1.44.0",
"dayjs": "^1.11.23",
"gray-matter": "^4.0.3",
@@ -25,12 +33,14 @@
"react-markdown": "^10.1.0",
"react-responsive-modal": "^7.2.0",
"rehype-prism-plus": "^2.0.2",
"remark-breaks": "4.0.0",
"remark-gfm": "^4.0.1",
"sanitize-html": "^2.18.1",
"sharp": "^0.35.3",
"tailwind-merge": "^3.6.0",
"turndown": "^7.2.4",
"unpdf": "^1.8.0",
"use-sync-external-store": "file:./packages/use-sync-external-store-esm",
"web-push": "^3.6.7",
"xlsx": "^0.18.5",
"zod": "4.1.12",
@@ -55,6 +65,9 @@
},
},
},
"overrides": {
"use-sync-external-store": "file:./packages/use-sync-external-store-esm",
},
"packages": {
"@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -126,6 +139,12 @@
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
"@floating-ui/core": ["@floating-ui/[email protected]", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
"@floating-ui/dom": ["@floating-ui/[email protected]", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
"@floating-ui/utils": ["@floating-ui/[email protected]", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@hono/node-server": ["@hono/[email protected]", "", { "peerDependencies": { "hono": "^4 || ^5.0.0-0" } }, "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q=="],
"@img/colour": ["@img/[email protected]", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
@@ -306,6 +325,70 @@
"@tailwindcss/postcss": ["@tailwindcss/[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="],
"@tiptap/core": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/pm": "3.32.0" } }, "sha512-Ghh2h02GKSqzytQGcLV4espYOG4EHxjj0MsrUB2Vnr47xmlZYRf6r7k2J09H+JpBdaGrWwrTkyBEi5i3c3yy2Q=="],
"@tiptap/extension-blockquote": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0", "@tiptap/pm": "3.32.0" } }, "sha512-wmuthEdlYVArNoUbqFPH35giutBABFu7fqEB0lTD1k0UXJUs4dFvcbRyGWWgnHFZgP3TT4u8e+xiayYw0eQ0NQ=="],
"@tiptap/extension-bold": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0" } }, "sha512-SQYewnYDoIBN+8oH53IDcD8p+Pz+Jnyq3hrxjy/FAo5ctc+96d3ZLdqc9gzLVSqFFo/SUINv2FVGoq9PvU7yqw=="],
"@tiptap/extension-bubble-menu": ["@tiptap/[email protected]", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "3.32.0", "@tiptap/pm": "3.32.0" } }, "sha512-X3ll8i9Z8QNvt7+tcqlwopJG+68ugO13Av0Ox4Ico+jXcTnFdDuKQJBSI9sLuMR5vTQOeTHhKYXhjF88aahMSA=="],
"@tiptap/extension-bullet-list": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extension-list": "3.32.0" } }, "sha512-Y5P/l06pVCUG46vXpBl3z0fW2b6JpSMX4Cvr+5sfQh8zB3TxwSiLQaaPwI3R5MFD1KfRk3AjiPRieEyXdWcNHw=="],
"@tiptap/extension-code": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0" } }, "sha512-I+aEVl0dTmkfnU6HzBGEefb9U7ja2MkucwBBwrmoMwU+uIKpC4xw8NcHC9X6sjAcom3Pxs2HX+8WEo4Vp13R/Q=="],
"@tiptap/extension-code-block": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0", "@tiptap/pm": "3.32.0" } }, "sha512-9NZW/yXP9j9XKr+hRS5m58xiyZI+tQo1PJ7eLHXjGNFWaF601NG/h6741mbKh5uCY51XsbyqG+7que+RavngPA=="],
"@tiptap/extension-document": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0" } }, "sha512-GugeKE6AnZDluRXJUNBuX43TPcaJxy5wck6vT2J4a0/EnKStJ43suSZZf1IPfb1ij9EL9bYvAwg9DOcrvdEzTQ=="],
"@tiptap/extension-dropcursor": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extensions": "3.32.0" } }, "sha512-t4Ug8hFLt30dwwYvrh63BVUt6N1Yg5PVI3tMllhCmN3Q68oD47v4MB/i5nGfzIsQqMyXUOetwHJWcZ4MoLTx4Q=="],
"@tiptap/extension-floating-menu": ["@tiptap/[email protected]", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.32.0", "@tiptap/pm": "3.32.0" } }, "sha512-AzKCWceL+4Q1pTc8Whw1kPqN/aYGPQ70/jXPsblWl501t/pXi6Kz0Pi/NggKkuu73VGjykh68YYf/dcOJ/mPZQ=="],
"@tiptap/extension-gapcursor": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extensions": "3.32.0" } }, "sha512-HUpKKWJLtthnpQY5LIjHlJJOmh562RusmOMIe/4iP/H7y+WUR9ZFiZjEs1NBkRjig9fwj8F3BfsrVPYBnM4nLA=="],
"@tiptap/extension-hard-break": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0" } }, "sha512-IKUYvK+bO2hGrDWhhTVg0CSh9C2belWg9GyLzvpyd3SZyQ9EgdccLfoytNjOBKbAE/TQ4Oo3kSP+XF6RsUIKPA=="],
"@tiptap/extension-heading": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0" } }, "sha512-XRiZxZuhfjvbgwCvoBHj04gOsPANEXxxRwrFCQVuFi5yeTHYIzMEioc81j6iAH9pV7aQeJCwLxuh6VHNz3dXEQ=="],
"@tiptap/extension-horizontal-rule": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0", "@tiptap/pm": "3.32.0" } }, "sha512-Iry7ynsG4xgcxnH1cm+uXJnEtUzoop4sGA10NUN9I+08TOjhj/GrDiRKMQHKAiVI/R7t2MVav1MJlxFf2FdzGw=="],
"@tiptap/extension-italic": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0" } }, "sha512-KB/i1xoHilbBGcQ+UotDWhi60KpdCde3RzRIpl2na0Yol4z4Fe2IRWg8ljIrlsssDRwel/fsHJbG7a13EP8oKA=="],
"@tiptap/extension-link": ["@tiptap/[email protected]", "", { "dependencies": { "linkifyjs": "^4.3.3" }, "peerDependencies": { "@tiptap/core": "3.32.0", "@tiptap/pm": "3.32.0" } }, "sha512-DRdQSgWPSEjrDg5EDBB5yxRygHtUVLhceVGzAsTz8RziiFyvcwAvzBHCO9hd9jutjCXQ2DLIuZB8P+aHhFWnpg=="],
"@tiptap/extension-list": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0", "@tiptap/pm": "3.32.0" } }, "sha512-QdzS5nFz9agAQhBgIuQluobWib39W2cMKx1XB6tYSue7bHMJdgPFP0EnGfFajaeVX42CG6RXtQWrNfyQGKhBvg=="],
"@tiptap/extension-list-item": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extension-list": "3.32.0" } }, "sha512-FdJTCAeALixzyyGSSEmroXmkIEclbo/4t2Pl03aRERqpPOSPJcC3evDtIhLazTuP6v+FjCmJ+pXNql6ov7qitA=="],
"@tiptap/extension-list-keymap": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extension-list": "3.32.0" } }, "sha512-vXFre18Dk2m5N/gjo408C02GSrolZ8ZIf0wz6/U8lNC4FcYOISgZFj3BND6uabVntGh2GCSPrVFuNpVPhFnn/A=="],
"@tiptap/extension-mention": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0", "@tiptap/pm": "3.32.0", "@tiptap/suggestion": "3.32.0" } }, "sha512-TMT4exls1VH0QXsLd676s5oGn9Zlc6QHgS/jKRTe+XJcbRTUzc9n70n9uUG2wDwEHGB0128Ojy6663gc05e1Bw=="],
"@tiptap/extension-ordered-list": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extension-list": "3.32.0" } }, "sha512-htZ5hoMLBkGiDP6M5mGemjOoaBmkrj911wiGoTmMMKzO7qJq9QxUBkLY7ILPaKlUWL8U3ccLdDV2TB/nysp4Jg=="],
"@tiptap/extension-paragraph": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0" } }, "sha512-afKLMc7fWMkkar9H5FilizStSbhFzUoCzdwwdZf6DDsn3LUt8tJlFHACK78cAyx/TzMJBo0N8/8T1u9bL+N67Q=="],
"@tiptap/extension-placeholder": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extensions": "3.32.0" } }, "sha512-TSjyQmVFq56gfURPotxbMsXU3seNTy9VzMACdb6b0bQ66yGWBcjBfWmhpiSRVLdBjQrRvl1+jQbWx0YPSCSpsw=="],
"@tiptap/extension-strike": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0" } }, "sha512-J9Vw6wmh/wV6SxSsN9RhurvAGcXSY5J3p4EQrROD5PARBl0VOnDFi7/UVoz1RkSdqOKPgAiqDZpaLRgtdBO9Rg=="],
"@tiptap/extension-text": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0" } }, "sha512-sF0jIK67IHdZMnzv2mz5vPTyK7R0ui6/YZgFg+/bLnFCNmweVAoYcHZLlC7ISRnwu0mqJr+epNtzJepY1ZDCjg=="],
"@tiptap/extension-underline": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0" } }, "sha512-rMLdd4vg6sed/JnaXnaupLbCtYiWikTnQ8qir9ItKOXwf1Ui9P1Y4G0bYU4+gcnMsFzaSGtrOhMqG4/c66AogQ=="],
"@tiptap/extensions": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.32.0", "@tiptap/pm": "3.32.0" } }, "sha512-qO+ndNvlOjzU2MWCtQHoRg1tIortCXLvqPjDx58F1pUYFNCOVZegXi+jqLGDODUA31er+TVz66umzRbbN1bO1Q=="],
"@tiptap/markdown": ["@tiptap/[email protected]", "", { "dependencies": { "marked": "^17.0.1" }, "peerDependencies": { "@tiptap/core": "3.32.0", "@tiptap/pm": "3.32.0" } }, "sha512-y+pGAu4QMkXBMxhBoLvc6FWucQ1EWIoPi+Ajf4rAh5OlZtSV3CjBwyK6/xTwO7mvQxtoUf7DD23CzSaj5nCTBA=="],
"@tiptap/pm": ["@tiptap/[email protected]", "", { "dependencies": { "prosemirror-changeset": "^2.4.4", "prosemirror-commands": "^1.7.2", "prosemirror-dropcursor": "^1.8.4", "prosemirror-gapcursor": "^1.4.1", "prosemirror-history": "^1.5.1", "prosemirror-inputrules": "^1.5.1", "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.12", "prosemirror-schema-list": "^1.5.1", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.5", "prosemirror-transform": "^1.12.2", "prosemirror-view": "^1.42.6" } }, "sha512-H9eN+xHFboJybE7KPGVsu24S8aBlCIgYz38vCHRSnNv17nwmqm06owq/IQ5scza0n3Y7IfUfpgPOtm9SgeHmNQ=="],
"@tiptap/react": ["@tiptap/[email protected]", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "3.32.0", "@tiptap/extension-floating-menu": "3.32.0" }, "peerDependencies": { "@tiptap/core": "3.32.0", "@tiptap/pm": "3.32.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+iS9RKj6Ve8SXqJ49xptDiifF7PQXmUOh4cjMu+BCXXqKphQkiUccvbWYAeOJCTceM01wWgFhrpfy8qG+CHJYw=="],
"@tiptap/starter-kit": ["@tiptap/[email protected]", "", { "dependencies": { "@tiptap/core": "3.32.0", "@tiptap/extension-blockquote": "3.32.0", "@tiptap/extension-bold": "3.32.0", "@tiptap/extension-bullet-list": "3.32.0", "@tiptap/extension-code": "3.32.0", "@tiptap/extension-code-block": "3.32.0", "@tiptap/extension-document": "3.32.0", "@tiptap/extension-dropcursor": "3.32.0", "@tiptap/extension-gapcursor": "3.32.0", "@tiptap/extension-hard-break": "3.32.0", "@tiptap/extension-heading": "3.32.0", "@tiptap/extension-horizontal-rule": "3.32.0", "@tiptap/extension-italic": "3.32.0", "@tiptap/extension-link": "3.32.0", "@tiptap/extension-list": "3.32.0", "@tiptap/extension-list-item": "3.32.0", "@tiptap/extension-list-keymap": "3.32.0", "@tiptap/extension-ordered-list": "3.32.0", "@tiptap/extension-paragraph": "3.32.0", "@tiptap/extension-strike": "3.32.0", "@tiptap/extension-text": "3.32.0", "@tiptap/extension-underline": "3.32.0", "@tiptap/extensions": "3.32.0", "@tiptap/pm": "3.32.0" } }, "sha512-aPYxaevci4PgMrTLsg+baiqI7M1ICd4V9H+zTTdrqs8OUZIbAvj/pNDuHU8uWZyKMu3Dh72t2D6ZdqRu/6aSWw=="],
"@tiptap/suggestion": ["@tiptap/[email protected]", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.32.0", "@tiptap/pm": "3.32.0" } }, "sha512-5PbouU06zaZ7n4uyYzeNN/jBFVmGsHZ6c56MvN7M33echERAhu1L1X71Up0U6GK/pJfyugX7WbluBehJDPQeSQ=="],
"@types/ace": ["@types/[email protected]", "", {}, "sha512-YPF9S7fzpuyrxru+sG/rrTpZkC6gpHBPF14W3x70kqVOD+ks6jkYLapk4yceh36xej7K4HYxcyz9ZDQ2lTvwgQ=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
@@ -342,6 +425,8 @@
"@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/use-sync-external-store": ["@types/[email protected]", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/web-push": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
"@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
@@ -592,6 +677,8 @@
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-equals": ["[email protected]", "", {}, "sha512-yKEpcFuRPu/pTQL2VB8YKE2yFldrLlIsoWtJeBnAdqEhvu21OgkFT0C8njf/xYc2LSZGEr3K9JGjthHJxnVLvQ=="],
"fast-string-truncated-width": ["[email protected]", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["[email protected]", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
@@ -768,6 +855,8 @@
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"linkifyjs": ["[email protected]", "", {}, "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="],
"load-json-file": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^2.2.0", "pify": "^2.0.0", "pinkie-promise": "^2.0.0", "strip-bom": "^2.0.0" } }, "sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A=="],
"lodash": ["[email protected]", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
@@ -796,6 +885,8 @@
"markdown-table": ["[email protected]", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"math-intrinsics": ["[email protected]", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-find-and-replace": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
@@ -822,6 +913,8 @@
"mdast-util-mdxjs-esm": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-newline-to-break": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0" } }, "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog=="],
"mdast-util-phrasing": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
@@ -950,6 +1043,8 @@
"ora": ["[email protected]", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="],
"orderedmap": ["[email protected]", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="],
"pako": ["[email protected]", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse-entities": ["[email protected]", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
@@ -998,6 +1093,32 @@
"property-information": ["[email protected]", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"prosemirror-changeset": ["[email protected]", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA=="],
"prosemirror-commands": ["[email protected]", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw=="],
"prosemirror-dropcursor": ["[email protected]", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg=="],
"prosemirror-gapcursor": ["[email protected]", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw=="],
"prosemirror-history": ["[email protected]", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA=="],
"prosemirror-inputrules": ["[email protected]", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="],
"prosemirror-keymap": ["[email protected]", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="],
"prosemirror-model": ["[email protected]", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg=="],
"prosemirror-schema-list": ["[email protected]", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="],
"prosemirror-state": ["[email protected]", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="],
"prosemirror-tables": ["[email protected]", "", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="],
"prosemirror-transform": ["[email protected]", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g=="],
"prosemirror-view": ["[email protected]", "", { "dependencies": { "prosemirror-model": "^1.25.12", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ=="],
"proxy-addr": ["[email protected]", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="],
"qs": ["[email protected]", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg=="],
@@ -1046,6 +1167,8 @@
"rehype-recma": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="],
"remark-breaks": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="],
"remark-gfm": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-mdx": ["[email protected]", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="],
@@ -1064,6 +1187,8 @@
"restore-cursor": ["[email protected]", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"rope-sequence": ["[email protected]", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="],
"router": ["[email protected]", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-async": ["[email protected]", "", {}, "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ=="],
@@ -1212,6 +1337,8 @@
"unpipe": ["[email protected]", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"use-sync-external-store": ["use-sync-external-store@file:packages/use-sync-external-store-esm", { "peerDependencies": { "react": ">=18" } }],
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["[email protected]", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw=="],
@@ -1228,6 +1355,8 @@
"vfile-message": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"w3c-keyname": ["[email protected]", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"web-namespaces": ["[email protected]", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-push": ["[email protected]", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
@@ -1284,6 +1413,8 @@
"@tailwindcss/postcss/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"@tiptap/react/use-sync-external-store": ["use-sync-external-store@file:./packages/use-sync-external-store-esm", {}],
"body-parser/content-type": ["[email protected]", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"decode-named-character-reference/character-entities": ["[email protected]", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
+13
View File
@@ -36,10 +36,18 @@
"typescript": "^7.0.2"
},
"dependencies": {
"@floating-ui/dom": "1.8.0",
"@modelcontextprotocol/sdk": "^1.32.1",
"@moduletrace/bun-sqlite": "^1.3.0",
"@moduletrace/bunext": "^1.3.3",
"@moduletrace/twui": "^0.1.2",
"@tiptap/extension-bubble-menu": "3.32.0",
"@tiptap/extension-mention": "3.32.0",
"@tiptap/extension-placeholder": "3.32.0",
"@tiptap/markdown": "3.32.0",
"@tiptap/pm": "3.32.0",
"@tiptap/react": "3.32.0",
"@tiptap/starter-kit": "3.32.0",
"ace-builds": "^1.44.0",
"dayjs": "^1.11.23",
"gray-matter": "^4.0.3",
@@ -56,14 +64,19 @@
"react-markdown": "^10.1.0",
"react-responsive-modal": "^7.2.0",
"rehype-prism-plus": "^2.0.2",
"remark-breaks": "4.0.0",
"remark-gfm": "^4.0.1",
"sanitize-html": "^2.18.1",
"sharp": "^0.35.3",
"tailwind-merge": "^3.6.0",
"turndown": "^7.2.4",
"unpdf": "^1.8.0",
"use-sync-external-store": "file:./packages/use-sync-external-store-esm",
"web-push": "^3.6.7",
"xlsx": "^0.18.5",
"zod": "4.1.12"
},
"overrides": {
"use-sync-external-store": "file:./packages/use-sync-external-store-esm"
}
}
@@ -0,0 +1,4 @@
/**
* React 18+ has `useSyncExternalStore`; the shim only re-exports it.
*/
export { useSyncExternalStore } from "react";
@@ -0,0 +1,21 @@
{
"name": "use-sync-external-store",
"version": "1.7.0-esm",
"description": "ESM stand-in for use-sync-external-store on React 19, which has useSyncExternalStore built in. The page bundler keeps react external, so the upstream CommonJS require(\"react\") can't load in the browser.",
"license": "MIT",
"type": "module",
"sideEffects": false,
"exports": {
".": "./index.js",
"./with-selector": "./with-selector.js",
"./with-selector.js": "./with-selector.js",
"./shim": "./index.js",
"./shim/index.js": "./index.js",
"./shim/with-selector": "./with-selector.js",
"./shim/with-selector.js": "./with-selector.js",
"./package.json": "./package.json"
},
"peerDependencies": {
"react": ">=18"
}
}
@@ -0,0 +1 @@
export { useSyncExternalStore } from "../index.js";
@@ -0,0 +1 @@
export { useSyncExternalStoreWithSelector } from "../with-selector.js";
@@ -0,0 +1,69 @@
/**
* `useSyncExternalStoreWithSelector` from React's use-sync-external-store
* (MIT, Meta Platforms, Inc. and affiliates), as an ES module: it reads a
* store through a selector and only re-renders when the selection
* changes (`isEqual`).
*/
import { useDebugValue, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
export function useSyncExternalStoreWithSelector(subscribe, getSnapshot, getServerSnapshot, selector, isEqual) {
const instRef = useRef(null);
if (instRef.current === null) instRef.current = { hasValue: false, value: null };
const inst = instRef.current;
const [getSelection, getServerSelection] = useMemo(() => {
let hasMemo = false;
let memoizedSnapshot;
let memoizedSelection;
const memoizedSelector = (nextSnapshot) => {
if (!hasMemo) {
hasMemo = true;
memoizedSnapshot = nextSnapshot;
const nextSelection = selector(nextSnapshot);
if (isEqual !== undefined && inst.hasValue && isEqual(inst.value, nextSelection)) {
memoizedSelection = inst.value;
return memoizedSelection;
}
memoizedSelection = nextSelection;
return nextSelection;
}
const currentSelection = memoizedSelection;
if (Object.is(memoizedSnapshot, nextSnapshot)) return currentSelection;
const nextSelection = selector(nextSnapshot);
if (isEqual !== undefined && isEqual(currentSelection, nextSelection)) {
memoizedSnapshot = nextSnapshot;
return currentSelection;
}
memoizedSnapshot = nextSnapshot;
memoizedSelection = nextSelection;
return nextSelection;
};
return [
() => memoizedSelector(getSnapshot()),
getServerSnapshot === undefined ? undefined : () => memoizedSelector(getServerSnapshot()),
];
}, [getSnapshot, getServerSnapshot, selector, isEqual]);
const value = useSyncExternalStore(subscribe, getSelection, getServerSelection);
useEffect(() => {
inst.hasValue = true;
inst.value = value;
}, [value]);
useDebugValue(value);
return value;
}
@@ -133,3 +133,17 @@ describe("botMessagesToHarness with files", () => {
).toEqual([{ role: "user", content: `[Attached earlier: "shot.png"]` }]);
});
});
describe("botMessagesToHarness with mentions", () => {
test("names the bots and workspaces a user message mentions", () => {
expect(
botMessagesToHarness({
messages: [message("user", "ask [bot:3]"), message("bot", "[bot:3] is on it")],
refs: { bots: new Map([[3, { name: "Ada" }]]), workspaces: new Map() },
}),
).toEqual([
{ role: "user", content: "ask [bot:3]\n\n[Mentions: [bot:3] is Ada]" },
{ role: "assistant", content: "[bot:3] is on it" },
]);
});
});
+13 -3
View File
@@ -1,5 +1,6 @@
import type { HarnessContentPart, HarnessMessage } from "@/src/harness/types";
import type { BotMessageWithAttachments } from "@/src/types";
import describeMessageRefs, { type MessageRefNames } from "@/src/utils/describe-message-refs";
type Params = {
messages: Pick<
@@ -11,6 +12,11 @@ type Params = {
* messages are only named.
*/
parts?: Map<number, HarnessContentPart[]>;
/**
* Names of the bots and workspaces the user's messages mention
* (`grabMessageRefNames`), told to the bot after each one.
*/
refs?: MessageRefNames;
};
type Turn = { role: "user" | "assistant"; parts: HarnessContentPart[] };
@@ -22,12 +28,12 @@ type Turn = { role: "user" | "assistant"; parts: HarnessContentPart[] };
* repeated roles. Leading bot messages are dropped so the conversation
* starts with the user. A turn that is only text stays a string.
*/
export default function botMessagesToHarness({ messages, parts }: Params): HarnessMessage[] {
export default function botMessagesToHarness({ messages, parts, refs }: Params): HarnessMessage[] {
const turns: Turn[] = [];
for (const message of messages) {
const role = message.author == "user" ? "user" : "assistant";
const content = messageParts({ message, role, parts });
const content = messageParts({ message, role, parts, refs });
if (!content.length) continue;
if (!turns.length && role == "assistant") continue;
@@ -59,12 +65,16 @@ function messageParts({
message,
role,
parts,
refs,
}: {
message: Params["messages"][number];
role: Turn["role"];
parts?: Params["parts"];
refs?: Params["refs"];
}): HarnessContentPart[] {
const text = replyContext({ message, text: message.text?.trim() || "" });
const own = message.text?.trim() || "";
const mentions = role == "user" && refs && own ? describeMessageRefs({ text: own, names: refs }) : "";
const text = replyContext({ message, text: mentions ? `${own}\n\n${mentions}` : own });
const content: HarnessContentPart[] = text ? [{ type: "text", text }] : [];
if (role == "assistant" || !message.attachments?.length) return content;
+5
View File
@@ -17,6 +17,7 @@ import composeComputersNote from "./compose-computers-note";
import grabSessionCarryOver from "./grab-session-carry-over";
import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
import loadAttachmentParts from "./load-attachment-parts";
import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names";
import withTakeOverRefs from "./with-take-over-refs";
import type { BotJobContext, BotJobOutcome } from "./types";
@@ -124,6 +125,10 @@ export default async function runBotReply({
messages: botMessagesToHarness({
messages: history,
parts: await loadAttachmentParts({ messages: history }),
refs: await grabMessageRefNames({
texts: history.filter((m) => m.author == "user").map((m) => m.text || ""),
user_id,
}),
}),
tools: [...tools, ...extensions.tools],
max_steps: AppData.BotReplyMaxToolSteps,
+4
View File
@@ -291,6 +291,10 @@ export const AppData = {
* Longest dictation the composer records before it stops on its own.
*/
ComposerVoiceMaxMs: 5 * 60 * 1000,
/**
* Most bots or workspaces the composer's mention menu lists at once.
*/
ComposerMentionMaxItems: 6,
/**
* Most of a bot message read aloud; the rest is cut.
*/
+1
View File
@@ -30,6 +30,7 @@ You are chatting with the user. Answer their latest messages directly and briefl
- When the user tells you something worth keeping, save it with update_memory: about them in your memory of this user, never in your core memory, which every user you work for shares. When they correct how you work for everyone, update your instructions with update_instructions; a correction that's only their preference goes in your memory of them.
- To add a worker to the crew, use create_bot once the user has asked for it or agreed, and add_bot_to_workspace to place it. A bot exists only when create_bot made it: never describe one as if it did, and never write it into a workspace's context.
- Never claim you did something you didn't do.
- The user can mention a bot as [bot:ID] and a workspace as [workspace:ID]; a [Mentions: ...] line after their message says who and what those are. Write the same refs when you name them back.
## Keep the work moving
@@ -0,0 +1,33 @@
import type { MessageRefNames } from "@/src/utils/describe-message-refs";
import messageRefIds from "@/src/utils/message-ref-ids";
import grabUserWorkspace from "../workspaces/grab-user-workspace";
import grabUserBot from "./grab-user-bot";
type Params = {
texts: string[];
user_id: number | string;
};
/**
* The names of the bots and workspaces some messages mention, only those
* the user reaches (`grabUserBot`, `grabUserWorkspace`), so a ref to
* anything else stays unnamed.
*/
export default async function grabMessageRefNames({ texts, user_id }: Params): Promise<MessageRefNames> {
const { bot_ids, workspace_ids } = messageRefIds({ texts });
const names: MessageRefNames = { bots: new Map(), workspaces: new Map() };
for (const id of bot_ids) {
const bot = await grabUserBot({ bot_id: id, user_id });
if (bot?.name) names.bots.set(id, { name: bot.name, role: bot.role });
}
for (const id of workspace_ids) {
const workspace = await grabUserWorkspace({ workspace_id: id, user_id });
if (workspace?.name) names.workspaces.set(id, { name: workspace.name });
}
return names;
}
@@ -0,0 +1,72 @@
import type { Editor } from "@tiptap/react";
import { Bold, Code, Italic, Link2, List, ListOrdered, Quote, Strikethrough, type LucideIcon } from "lucide-react";
type ComposerFormat = {
/**
* The mark or node it toggles, for `editor.isActive`.
*/
name: string;
title: string;
Icon: LucideIcon;
toggle: (params: { editor: Editor }) => void;
};
/**
* The formatting the composer's selection bubble offers, in order.
*/
export const ComposerFormats: ComposerFormat[] = [
{ name: "bold", title: "Bold", Icon: Bold, toggle: ({ editor }) => editor.chain().focus().toggleBold().run() },
{ name: "italic", title: "Italic", Icon: Italic, toggle: ({ editor }) => editor.chain().focus().toggleItalic().run() },
{
name: "strike",
title: "Strikethrough",
Icon: Strikethrough,
toggle: ({ editor }) => editor.chain().focus().toggleStrike().run(),
},
{ name: "code", title: "Code", Icon: Code, toggle: ({ editor }) => editor.chain().focus().toggleCode().run() },
{ name: "link", title: "Link", Icon: Link2, toggle: toggleLink },
{
name: "blockquote",
title: "Quote",
Icon: Quote,
toggle: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
},
{
name: "bulletList",
title: "Bulleted list",
Icon: List,
toggle: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
},
{
name: "orderedList",
title: "Numbered list",
Icon: ListOrdered,
toggle: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
},
];
/**
* Removes the selection's link, or asks for one to set: web and mail
* links only, `https://` added when there's no scheme.
*/
function toggleLink({ editor }: { editor: Editor }) {
if (editor.isActive("link")) {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
const href = window.prompt("Link address")?.trim();
if (!href) return;
const has_scheme = /^[a-z][a-z0-9+.-]*:/i.test(href);
if (has_scheme && !/^(https?|mailto):/i.test(href)) return;
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: has_scheme ? href : `https://${href}` })
.run();
}
@@ -0,0 +1,18 @@
/**
* What the composer can mention, by the character that starts it: `@` a
* bot (`[bot:ID]`), `#` a workspace (`[workspace:ID]`).
*/
export const ComposerMentionKinds = {
"@": "bot",
"#": "workspace",
} as const;
export type ComposerMentionChar = keyof typeof ComposerMentionKinds;
export type ComposerMentionKind = (typeof ComposerMentionKinds)[ComposerMentionChar];
/**
* The trigger character of a kind.
*/
export function composerMentionChar({ kind }: { kind: ComposerMentionKind }): ComposerMentionChar {
return kind == "workspace" ? "#" : "@";
}
@@ -0,0 +1,8 @@
import React from "react";
import type { WorkspaceWithActivity } from "@/src/types";
/**
* The live workspaces the user is in, so a workspace named in a message
* links to it and the composer can mention it.
*/
export const CrewWorkspacesContext = React.createContext<WorkspaceWithActivity[]>([]);
@@ -0,0 +1,51 @@
import type { Components } from "react-markdown";
import P from "@moduletrace/twui/layout/P";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import MarkdownList from "../(partials)/markdown-list";
import UserMarkdownLink from "../(partials)/user-markdown-link";
import { MessageMarkdownComponents } from "./message-markdown-components";
const Heading: Components["h1"] = ({ children }) => (
<P noMargin className="text-sm leading-5 font-bold text-on-ink">
{children}
</P>
);
/**
* The user's own message markdown, on the ink bubble: the chat's
* components (`MessageMarkdownComponents`) in the bubble's colours, and
* headings no bigger than the text. Code blocks and ref chips keep their
* own surface.
*/
export const UserMarkdownComponents: Components = {
...MessageMarkdownComponents,
p: ({ children }) => (
<P noMargin className="text-sm leading-5 text-on-ink">
{children}
</P>
),
h1: Heading,
h2: Heading,
h3: Heading,
h4: Heading,
h5: Heading,
h6: Heading,
a: UserMarkdownLink,
ul: ({ children }) => <MarkdownList className="text-on-ink">{children}</MarkdownList>,
ol: ({ children, start }) => (
<MarkdownList ordered start={start} className="text-on-ink">
{children}
</MarkdownList>
),
code: ({ children }) => (
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-on-ink/15 text-on-ink break-words">
{children}
</Span>
),
blockquote: ({ children }) => (
<Stack className="gap-2! items-stretch pl-3 border-0 border-l-2 border-solid border-on-ink/40 opacity-80">
{children}
</Stack>
),
};
@@ -1,22 +0,0 @@
import { useLayoutEffect, type RefObject } from "react";
type Params = {
ref: RefObject<HTMLTextAreaElement | null>;
value: string;
};
/**
* Grows a textarea with its text where `field-sizing: content` isn't
* supported (Firefox). Its CSS `max-height` still caps it, then it scrolls.
* Does nothing where the CSS handles it.
*/
export default function useAutoGrowTextarea({ ref, value }: Params) {
useLayoutEffect(() => {
const el = ref.current;
if (!el || CSS.supports("field-sizing", "content")) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [ref, value]);
}
@@ -7,13 +7,14 @@ type Params = {
};
/**
* The composer's text, kept in `localStorage` per bot and workspace until
* it's sent, so a reload or leaving the conversation doesn't lose it.
* Read after mount, since the page renders on the server first.
* The composer's text (markdown), kept in `localStorage` per bot and
* workspace until it's sent, so a reload or leaving the conversation
* doesn't lose it. Read after mount, since the page renders on the server
* first; `loaded` turns true once it has been, for that `key`.
*/
export default function useComposerDraft({ bot_id, workspace_id }: Params) {
const key = composerDraftKey({ bot_id, workspace_id, part: "text" });
const [draft, setDraft] = useState({ key, text: "" });
const [draft, setDraft] = useState({ key, text: "", loaded: false });
useEffect(() => {
let text = "";
@@ -22,11 +23,11 @@ export default function useComposerDraft({ bot_id, workspace_id }: Params) {
text = localStorage.getItem(key) || "";
} catch {}
setDraft({ key, text });
setDraft({ key, text, loaded: true });
}, [key]);
const setText = (text: string) => {
setDraft({ key, text });
setDraft({ key, text, loaded: true });
try {
if (text) localStorage.setItem(key, text);
@@ -34,8 +35,12 @@ export default function useComposerDraft({ bot_id, workspace_id }: Params) {
} catch {}
};
const current = draft.key == key;
return {
text: draft.key == key ? draft.text : "",
key,
text: current ? draft.text : "",
loaded: current && draft.loaded,
setText,
};
}
@@ -0,0 +1,300 @@
import { useEffect, useRef, useState } from "react";
import { Extension, ReactNodeViewRenderer, useEditor, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "@tiptap/markdown";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
import {
ComposerMentionKinds,
type ComposerMentionChar,
} from "../(data)/composer-mention-kinds";
import ComposerMentionChip from "../(partials)/composer-mention-chip";
import { MentionMarkdown, mentionRef } from "../(utils)/mention-markdown";
import searchMentionItems, { type ComposerMentionItem } from "../(utils)/search-mention-items";
/**
* The open mention menu: what matches what's typed after `@` or `#`, the
* highlighted one, and how to pick one.
*/
export type ComposerMentionMenu = {
char: ComposerMentionChar;
items: ComposerMentionItem[];
index: number;
select: (item: ComposerMentionItem) => void;
};
type Params = {
placeholder: string;
bots: BotWithLastMessage[];
workspaces: WorkspaceWithActivity[];
/**
* The message as markdown, on every change.
*/
onChange: (params: { markdown: string }) => void;
onSubmit: () => void;
/**
* Files pasted or dropped in.
*/
onFiles: (params: { files: File[] }) => void;
};
/**
* The composer's editor (Tiptap): markdown in and out, with bold, italic,
* strike, code, lists, quotes and links (no headings), and mentions of
* bots (`@`) and workspaces (`#`) written as `[bot:ID]` and
* `[workspace:ID]`. Enter sends and Shift+Enter breaks the line, except
* in a code block or list, where Enter is the editor's and Mod+Enter
* sends. Enter picks from an open mention menu. Pasted text comes in
* plain, without another site's styling; pasted or dropped files go to
* `onFiles`.
*/
export default function useComposerEditor({
placeholder,
bots,
workspaces,
onChange,
onSubmit,
onFiles,
}: Params) {
const latest = useRef({ bots, workspaces, onChange, onSubmit, onFiles });
latest.current = { bots, workspaces, onChange, onSubmit, onFiles };
const [menu, setMenu] = useState<ComposerMentionMenu | null>(null);
const menu_ref = useRef<ComposerMentionMenu | null>(null);
const showMenu = (next: ComposerMentionMenu | null) => {
menu_ref.current = next;
setMenu(next);
};
const suggestion = (char: ComposerMentionChar): Omit<SuggestionOptions, "editor"> => ({
char,
allowSpaces: false,
items: ({ query }) =>
searchMentionItems({
kind: ComposerMentionKinds[char],
query,
bots: latest.current.bots,
workspaces: latest.current.workspaces,
}),
render: () => {
const open = (props: SuggestionProps<ComposerMentionItem>) => {
const index = menu_ref.current?.char == char ? menu_ref.current.index : 0;
showMenu({
char,
items: props.items,
index: Math.min(index, Math.max(props.items.length - 1, 0)),
select: (item) => props.command({ id: String(item.id), label: item.label }),
});
};
return {
onStart: open,
onUpdate: open,
onExit: () => showMenu(null),
onKeyDown: ({ event }) => moveMenu({ event }),
};
},
});
const moveMenu = ({ event }: { event: KeyboardEvent }) => {
const current = menu_ref.current;
if (!current?.items.length) return false;
const count = current.items.length;
if (event.key == "ArrowDown" || event.key == "ArrowUp") {
const step = event.key == "ArrowDown" ? 1 : -1;
showMenu({ ...current, index: (current.index + step + count) % count });
return true;
}
const picked = current.items[current.index];
if ((event.key == "Enter" || event.key == "Tab") && picked) {
current.select(picked);
return true;
}
return false;
};
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: false,
horizontalRule: false,
underline: false,
link: { openOnClick: false, autolink: true },
}),
Placeholder.configure({ placeholder }),
Markdown,
Mention.extend({
...MentionMarkdown,
addNodeView() {
return ReactNodeViewRenderer(ComposerMentionChip, { as: "span" });
},
}).configure({
renderText: ({ node }) =>
node.attrs.label
? `${node.attrs.mentionSuggestionChar || "@"}${node.attrs.label}`
: mentionRef({ id: node.attrs.id, char: node.attrs.mentionSuggestionChar }),
suggestions: [suggestion("@"), suggestion("#")],
}),
Extension.create({
name: "composerKeys",
addKeyboardShortcuts() {
const editorOwnsEnter = () =>
this.editor.isActive("codeBlock") || this.editor.isActive("listItem");
return {
Enter: () => {
if (menu_ref.current?.items.length || this.editor.view.composing) return false;
if (editorOwnsEnter()) return false;
latest.current.onSubmit();
return true;
},
"Mod-Enter": () => {
latest.current.onSubmit();
return true;
},
};
},
}),
],
editorProps: {
attributes: {
role: "textbox",
"aria-multiline": "true",
"aria-label": placeholder,
class: ComposerEditorClassName,
},
handlePaste: (view, event) => {
const files = [...(event.clipboardData?.files || [])];
if (files.length) {
latest.current.onFiles({ files });
return true;
}
const html = event.clipboardData?.getData("text/html") || "";
const text = event.clipboardData?.getData("text/plain") || "";
/** Our own copies keep their formatting; other sites' don't. */
if (!html || html.includes("data-pm-slice") || !text) return false;
return view.pasteText(text, event);
},
handleDrop: (_view, event) => {
const files = [...(event.dataTransfer?.files || [])];
if (!files.length) return false;
event.preventDefault();
latest.current.onFiles({ files });
return true;
},
},
onUpdate: ({ editor }) => {
latest.current.onChange({ markdown: editorMarkdown({ editor }) });
},
});
/**
* Picking a mention with the pointer leaves the menu's state behind if
* the editor goes before the suggestion exits.
*/
useEffect(() => () => showMenu(null), [editor]);
/**
* Replaces the content with a draft, without reporting it as a change.
*/
const setMarkdown = ({ markdown }: { markdown: string }) => {
editor?.commands.setContent(markdown, { contentType: "markdown", emitUpdate: false });
};
const clear = () => {
editor?.commands.clearContent(true);
};
/**
* Puts dictated text at the cursor, spaced from what's around it.
*/
const insertText = ({ text }: { text: string }) => {
if (!editor) return;
const { from, to } = editor.state.selection;
const before = editor.state.doc.textBetween(Math.max(0, from - 1), from, "\n", " ");
const after = editor.state.doc.textBetween(to, Math.min(editor.state.doc.content.size, to + 1), "\n", " ");
const piece =
(before && !/\s$/.test(before) ? " " : "") + text + (after && !/^\s/.test(after) ? " " : "");
editor.chain().focus().insertContent(piece).run();
};
/**
* Quotes text (one blockquote, a paragraph per line) at the cursor,
* after any text selected in the editor (never over it), and leaves the
* cursor in a fresh line after it.
*/
const insertQuote = ({ text }: { text: string }) => {
if (!editor) return;
const lines = text.replace(/\r\n?/g, "\n").trim().split("\n");
if (!lines.join("").trim()) return;
editor
.chain()
.focus()
.setTextSelection(editor.state.selection.to)
.insertContent([
{
type: "blockquote",
content: lines.map((line) => ({
type: "paragraph",
content: line.trim() ? [{ type: "text", text: line }] : [],
})),
},
{ type: "paragraph" },
])
.run();
};
return { editor, menu, setMarkdown, clear, insertText, insertQuote };
}
/**
* The editor's markdown with Tiptap's empty-paragraph `&nbsp;` dropped and
* blank lines at the ends trimmed.
*/
function editorMarkdown({ editor }: { editor: Editor }) {
if (editor.isEmpty) return "";
return editor
.getMarkdown()
.replace(/^&nbsp;$/gm, "")
.replace(/^\s*\n|\n\s*$/g, "")
.trimEnd();
}
/**
* The editable area: the textarea's old type and growth (to 160px, then
* it scrolls), with the chat's markdown look for what's formatted.
*/
const ComposerEditorClassName = [
"min-h-5 max-h-[160px] overflow-y-auto outline-none text-sm leading-5 text-ink break-words",
"[&_p]:m-0 [&_strong]:font-bold [&_em]:italic [&_s]:line-through [&_a]:underline [&_a]:underline-offset-2",
"[&_code]:font-mono [&_code]:text-[13px] [&_code]:px-1 [&_code]:rounded-default-xs [&_code]:bg-sunken",
"[&_pre]:my-1 [&_pre]:p-3 [&_pre]:rounded-default-sm [&_pre]:bg-sunken [&_pre]:whitespace-pre-wrap [&_pre_code]:p-0 [&_pre_code]:bg-transparent",
"[&_ul]:my-0 [&_ul]:pl-5 [&_ul]:list-disc [&_ol]:my-0 [&_ol]:pl-5 [&_ol]:list-decimal",
"[&_blockquote]:my-1 [&_blockquote]:pl-3 [&_blockquote]:border-0 [&_blockquote]:border-l-2 [&_blockquote]:border-solid [&_blockquote]:border-hairline-strong [&_blockquote]:text-ink-secondary",
"[&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&_.is-editor-empty:first-child]:before:float-left [&_.is-editor-empty:first-child]:before:h-0 [&_.is-editor-empty:first-child]:before:pointer-events-none [&_.is-editor-empty:first-child]:before:text-ink-secondary",
].join(" ");
@@ -0,0 +1,69 @@
import { useEffect, useState, type RefObject } from "react";
/**
* Text selected inside one message, and where it sits on screen.
*/
export type MessageSelection = {
text: string;
rect: DOMRect;
};
/**
* Attribute on a message's text, marking what can be quoted.
*/
export const QuotableAttribute = "data-quotable";
type Params = {
ref: RefObject<HTMLElement | null>;
};
/**
* The text the user has selected in a message of the thread under `ref`,
* or null. A selection that runs across messages, or out of the thread,
* doesn't count.
*/
export default function useMessageSelection({ ref }: Params) {
const [selection, setSelection] = useState<MessageSelection | null>(null);
useEffect(() => {
const read = () => {
const picked = window.getSelection();
const text = picked?.toString().trim() || "";
if (!picked || picked.isCollapsed || !picked.rangeCount || !text) {
setSelection(null);
return;
}
const range = picked.getRangeAt(0);
const quotable = (node: Node | null) =>
(node instanceof Element ? node : node?.parentElement)?.closest(`[${QuotableAttribute}]`);
const start = quotable(range.startContainer);
if (!start || start != quotable(range.endContainer) || !ref.current?.contains(start)) {
setSelection(null);
return;
}
setSelection({ text, rect: range.getBoundingClientRect() });
};
document.addEventListener("selectionchange", read);
ref.current?.addEventListener("scroll", read, { passive: true });
const scroller = ref.current;
return () => {
document.removeEventListener("selectionchange", read);
scroller?.removeEventListener("scroll", read);
};
}, [ref]);
return {
selection,
clear: () => {
window.getSelection()?.removeAllRanges();
setSelection(null);
},
};
}
@@ -7,6 +7,7 @@ import type { BotMessageWithAttachments } from "@/src/types";
import linkComputerRefs from "@/src/utils/link-computer-refs";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
import { QuotableAttribute } from "../(hooks)/use-message-selection";
import GroupTimestamp from "./group-timestamp";
import MessageActions from "./message-actions";
import MessageReactions from "./message-reactions";
@@ -29,7 +30,7 @@ type Props = {
* rendered). Computers it names (`computerRef`) show as buttons that
* switch the bot to them. A quoted reply sits above it, its reactions
* below, and a row under it replies, reacts, reads it aloud and shows
* why it needs you.
* why it needs you. Its text can be selected and quoted.
*/
export default function BotMessageLine({ message, bot_name, at }: Props) {
const { onReact } = useContext(BotMessageActionsContext);
@@ -43,7 +44,10 @@ export default function BotMessageLine({ message, bot_name, at }: Props) {
bot_name={bot_name}
/>
) : null}
<Stack className="w-full gap-2.5! items-start break-words">
<Stack
{...{ [QuotableAttribute]: "" }}
className="w-full gap-2.5! items-start break-words"
>
<Markdown
remarkPlugins={[remarkGfm]}
components={MessageMarkdownComponents}
@@ -0,0 +1,27 @@
import { EditorContent, type Editor } from "@tiptap/react";
import ComposerFormatBubble from "./composer-format-bubble";
type Props = {
editor: Editor | null;
invalid?: boolean;
title?: string;
};
/**
* The composer's input (`useComposerEditor`), with the formatting bubble
* over a selection. It fills the row between the attach and voice
* buttons.
*/
export default function ComposerEditor({ editor, invalid, title }: Props) {
return (
<>
<EditorContent
editor={editor}
aria-invalid={invalid ? true : undefined}
title={title}
className="grow min-w-0 self-center"
/>
{editor ? <ComposerFormatBubble editor={editor} /> : null}
</>
);
}
@@ -0,0 +1,38 @@
import type { Editor } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import Row from "@moduletrace/twui/layout/Row";
import ComposerFormatButton from "./composer-format-button";
import { ComposerFormats } from "../(data)/composer-formats";
type Props = {
editor: Editor;
};
/**
* Formatting for the selected text, floating over it: bold, italic,
* strike, code, link, quote and lists. The same marks have their usual
* shortcuts (Mod+B, Mod+I, ...) and markdown typing (`**`, `- `, `> `).
*/
export default function ComposerFormatBubble({ editor }: Props) {
return (
<BubbleMenu
editor={editor}
shouldShow={({ editor, from, to }) =>
from != to && !editor.isActive("codeBlock") && !editor.isActive("mention")
}
>
<Row className="gap-0.5! flex-nowrap items-center p-1 rounded-default border border-solid border-hairline bg-surface shadow-popover">
{ComposerFormats.map((format) => (
<ComposerFormatButton
key={format.name}
title={format.title}
active={editor.isActive(format.name)}
onClick={() => format.toggle({ editor })}
>
<format.Icon size={15} aria-hidden />
</ComposerFormatButton>
))}
</Row>
</BubbleMenu>
);
}
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import Button from "@moduletrace/twui/layout/Button";
type Props = {
title: string;
active: boolean;
onClick: () => void;
children: ReactNode;
};
/**
* One formatting toggle in the composer's selection bubble.
*/
export default function ComposerFormatButton({ title, active, onClick, children }: Props) {
return (
<Button
type="button"
variant="ghost"
color="text"
title={title}
aria-label={title}
aria-pressed={active}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className={`shrink-0 p-1.5! rounded-default-xs ${active ? "bg-sunken text-ink" : "text-ink-secondary hover:text-ink"}`}
>
{children}
</Button>
);
}
@@ -0,0 +1,43 @@
import { useContext } from "react";
import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import { CrewBotsContext } from "../(data)/crew-bots-context";
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
/**
* A bot or workspace mentioned in the composer, shown like the chips in
* the thread (`BotRefChip`, `WorkspaceRefChip`) but not a link. One the
* user can't see any more keeps its ref's words.
*/
export default function ComposerMentionChip({ node, selected }: ReactNodeViewProps) {
const bots = useContext(CrewBotsContext);
const workspaces = useContext(CrewWorkspacesContext);
const id = Number(node.attrs.id);
const is_workspace = node.attrs.mentionSuggestionChar == "#";
const bot = is_workspace ? undefined : bots.find((b) => b.id == id);
const workspace = is_workspace ? workspaces.find((w) => w.id == id) : undefined;
const name = bot?.name || workspace?.name || node.attrs.label || `${is_workspace ? "workspace" : "bot"} ${id}`;
return (
<NodeViewWrapper
as="span"
data-mention=""
className={`inline-flex h-6 gap-1.5 items-center px-1.5 mx-px align-middle rounded-default-xs bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap ${selected ? "ring-2 ring-ink" : ""}`}
>
{bot ? (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
label={bot.name || ""}
size={18}
/>
) : null}
{workspace ? <WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" /> : null}
<Span>{name}</Span>
</NodeViewWrapper>
);
}
@@ -0,0 +1,40 @@
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import type { ComposerMentionMenu } from "../(hooks)/use-composer-editor";
import ComposerMentionOption from "./composer-mention-option";
type Props = {
menu: ComposerMentionMenu;
};
/**
* The bots (`@`) or workspaces (`#`) matching what's typed, just above
* the composer's input. Up and Down move, Enter or Tab picks, Escape
* closes; a click picks too.
*/
export default function ComposerMentionList({ menu }: Props) {
return (
<Stack
role="listbox"
aria-label={menu.char == "#" ? "Workspaces" : "Bots"}
className="w-full max-w-container mx-auto gap-0.5! items-stretch px-3 md:px-6 pt-3"
>
<Stack className="gap-0.5! items-stretch p-1 rounded-default border border-solid border-hairline bg-surface shadow-popover">
{menu.items.length ? (
menu.items.map((item, index) => (
<ComposerMentionOption
key={item.id}
item={item}
active={index == menu.index}
onPick={menu.select}
/>
))
) : (
<Span className="px-2 py-1.5 text-xs leading-4 text-ink-secondary">
{menu.char == "#" ? "No workspace by that name" : "No bot by that name"}
</Span>
)}
</Stack>
</Stack>
);
}
@@ -0,0 +1,51 @@
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { ComposerMentionItem } from "../(utils)/search-mention-items";
type Props = {
item: ComposerMentionItem;
active: boolean;
onPick: (item: ComposerMentionItem) => void;
};
/**
* One bot or workspace in the mention menu, with its role or description.
*/
export default function ComposerMentionOption({ item, active, onPick }: Props) {
const { bot, workspace } = item;
const detail = bot?.role || workspace?.description || "";
return (
<Button
type="button"
variant="ghost"
color="text"
role="option"
aria-selected={active}
title={item.label}
onMouseDown={(e) => {
e.preventDefault();
onPick(item);
}}
className={`w-full justify-start! gap-2.5! px-2! py-1.5! rounded-default-sm text-left text-ink ${active ? "bg-sunken" : "hover:bg-sunken"}`}
>
{bot ? (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
label={bot.name || ""}
size={22}
/>
) : null}
{workspace ? <WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" /> : null}
<Span className="shrink-0 text-sm font-bold leading-5">{item.label}</Span>
{detail ? (
<Span className="min-w-0 truncate text-xs leading-4 text-ink-secondary">{detail}</Span>
) : null}
</Button>
);
}
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Textarea from "@moduletrace/twui/form/Textarea";
import P from "@moduletrace/twui/layout/P";
import type {
BotExtensionsPayload,
@@ -9,20 +8,34 @@ import type {
BotVoicePayload,
BotWithLastMessage,
} from "@/src/types";
import { CrewBotsContext } from "../(data)/crew-bots-context";
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
import sendBotMessage from "../(functions)/send-bot-message";
import transcribeVoice from "../(functions)/transcribe-voice";
import useComposerAttachments from "../(hooks)/use-composer-attachments";
import useComposerDraft from "../(hooks)/use-composer-draft";
import useAutoGrowTextarea from "../(hooks)/use-auto-grow-textarea";
import useComposerEditor from "../(hooks)/use-composer-editor";
import useVoiceRecorder from "../(hooks)/use-voice-recorder";
import useOnlineStatus from "@/src/hooks/use-online-status";
import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments";
import ComposerEditor from "./composer-editor";
import ComposerMentionList from "./composer-mention-list";
import ComposerReplyPreview from "./composer-reply-preview";
import ComposerSendButton from "./composer-send-button";
import ComposerTools from "./composer-tools";
import ComposerVoiceButton from "./composer-voice-button";
/**
* What the conversation can do to the composer from outside it.
*/
export type ComposerHandle = {
/**
* Quotes text from the thread at the cursor.
*/
insertQuote: (params: { text: string }) => void;
};
type Props = {
bot: BotWithLastMessage;
/**
@@ -43,10 +56,13 @@ type Props = {
reply_to?: BotMessageWithAttachments | null;
onCancelReply?: () => void;
onSent: (params: { message: BotMessageWithAttachments }) => void;
handle_ref?: Ref<ComposerHandle>;
};
/**
* Enter sends, Shift+Enter adds a line. Grows to eight lines, then scrolls.
* A Tiptap editor (`useComposerEditor`) writing markdown: Enter sends,
* Shift+Enter adds a line, a selection can be formatted, `@` mentions a
* bot and `#` a workspace. Grows to eight lines, then scrolls.
* Its top rule lines up with the rail's user-row rule; picked files sit
* just above it. A click anywhere on the row focuses the input. Files can
* also be pasted or dropped in. Unsent text is kept per conversation
@@ -62,21 +78,53 @@ export default function Composer({
reply_to,
onCancelReply,
onSent,
handle_ref,
}: Props) {
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
const draft = useComposerDraft({ bot_id: bot.id, workspace_id });
const { text, setText } = draft;
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
const online = useOnlineStatus();
const input_ref = useRef<HTMLTextAreaElement>(null);
const bots = useContext(CrewBotsContext);
const workspaces = useContext(CrewWorkspacesContext);
const send_ref = useRef<() => void>(() => {});
useAutoGrowTextarea({ ref: input_ref, value: text });
const input = useComposerEditor({
placeholder: `Message ${bot.name}`,
bots,
workspaces,
onChange: ({ markdown }) => setText(markdown),
onSubmit: () => send_ref.current(),
onFiles: files.add,
});
const editor = input.editor;
useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]);
/**
* The saved draft goes into the editor once per conversation, after
* it's read; typing saves it back from then on.
*/
const applied_key = useRef<string | null>(null);
useEffect(() => {
if (!editor || !draft.loaded || applied_key.current == draft.key) return;
applied_key.current = draft.key;
/** Out of React's render, since mention chips mount as it loads. */
queueMicrotask(() => {
input.setMarkdown({ markdown: text });
editor.commands.focus("end");
});
}, [editor, draft.loaded, draft.key]);
/**
* Picking a message to reply to puts the cursor back in the input.
*/
useEffect(() => {
if (reply_to) input_ref.current?.focus();
if (reply_to) editor?.commands.focus();
}, [reply_to]);
const recorder = useVoiceRecorder({
@@ -90,7 +138,7 @@ export default function Composer({
return;
}
insertText({ insert: res.singleRes.text });
input.insertText({ text: res.singleRes.text });
},
});
@@ -101,30 +149,6 @@ export default function Composer({
recorder.state == "idle" &&
online;
/**
* Puts dictated text at the cursor (or the end), spaced from what's
* around it, and leaves the cursor after it.
*/
function insertText({ insert }: { insert: string }) {
const el = input_ref.current;
const start = el ? el.selectionStart : text.length;
const end = el ? el.selectionEnd : text.length;
const before = text.slice(0, start);
const after = text.slice(end);
const piece =
(before && !/\s$/.test(before) ? " " : "") +
insert +
(after && !/^\s/.test(after) ? " " : "");
const cursor = before.length + piece.length;
setText(before + piece + after);
requestAnimationFrame(() => {
input_ref.current?.focus();
input_ref.current?.setSelectionRange(cursor, cursor);
});
}
async function send() {
if (!can_send || !bot.id) return;
@@ -147,18 +171,12 @@ export default function Composer({
}
setText("");
input.clear();
files.clear();
onSent({ message: res.singleRes });
}
function handleKeyDown(
e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>,
) {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
send();
}
}
send_ref.current = send;
return (
<Stack
@@ -167,7 +185,8 @@ export default function Composer({
if (e.dataTransfer.types.includes("Files")) e.preventDefault();
}}
onDrop={(e) => {
if (!e.dataTransfer.files.length) return;
/** Dropped on the editor, which took the files already. */
if (e.defaultPrevented || !e.dataTransfer.files.length) return;
e.preventDefault();
files.add({ files: [...e.dataTransfer.files] });
@@ -185,49 +204,22 @@ export default function Composer({
onCancel={onCancelReply}
/>
) : null}
{input.menu ? <ComposerMentionList menu={input.menu} /> : null}
<Row className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
onMouseDown={(e) => {
const target = e.target as HTMLElement;
if (target.closest("button, textarea")) return;
if (target.closest("button, [contenteditable]")) return;
e.preventDefault();
input_ref.current?.focus();
editor?.commands.focus();
}}
>
<ComposerAttachButton
disabled={files.full}
onFiles={files.add}
/>
<Textarea
name="message"
componentRef={input_ref}
aria-label={`Message ${bot.name}`}
placeholder={`Message ${bot.name}`}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
const pasted = [...e.clipboardData.files];
if (!pasted.length) return;
e.preventDefault();
files.add({ files: pasted });
}}
aria-invalid={error ? true : undefined}
title={error}
className="field-sizing-content min-h-5 max-h-[160px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
wrapperWrapperProps={{
className: "grow min-w-0 self-center z-auto! hover:z-auto!",
}}
wrapperProps={{
className:
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
}}
autoFocus
/>
<ComposerEditor editor={editor} invalid={Boolean(error)} title={error} />
{recorder.supported ? (
<ComposerVoiceButton
state={recorder.state}
@@ -1,15 +1,22 @@
import type { ComponentProps } from "react";
import Link from "@moduletrace/twui/layout/Link";
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
import { BotRefHrefPrefix, ComputerRefHrefPrefix, TakeOverRefHrefPrefix } from "@/src/utils/link-computer-refs";
import {
BotRefHrefPrefix,
ComputerRefHrefPrefix,
TakeOverRefHrefPrefix,
WorkspaceRefHrefPrefix,
} from "@/src/utils/link-computer-refs";
import BotRefChip from "./bot-ref-chip";
import ComputerRefChip from "./computer-ref-chip";
import TakeOverChip from "./take-over-chip";
import WorkspaceRefChip from "./workspace-ref-chip";
/**
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
* becomes its switch button, a take-over request a button that opens the
* computer, a bot it named a link to that bot's conversation; anything else opens in a new tab.
* computer, a bot it named a link to that bot's conversation, a workspace
* a link to that workspace; anything else opens in a new tab.
*/
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (href?.startsWith(ComputerRefHrefPrefix)) {
@@ -39,6 +46,12 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (bot_id) return <BotRefChip bot_id={bot_id} />;
}
if (href?.startsWith(WorkspaceRefHrefPrefix)) {
const workspace_id = Number(href.slice(WorkspaceRefHrefPrefix.length));
if (workspace_id) return <WorkspaceRefChip workspace_id={workspace_id} />;
}
return (
<Link
href={href}
@@ -1,17 +1,19 @@
import { Children, isValidElement, type ReactNode } from "react";
import List from "@moduletrace/twui/layout/List";
import { twMerge } from "tailwind-merge";
type Props = {
children?: ReactNode;
ordered?: boolean;
start?: number;
className?: string;
};
/**
* A list in a bot message, through twui's `List` (which takes items, not
* `li` children): each item's content is lifted out of its `li`.
*/
export default function MarkdownList({ children, ordered, start }: Props) {
export default function MarkdownList({ children, ordered, start, className }: Props) {
const items = Children.toArray(children)
.filter(isValidElement)
.map((item) => ({
@@ -23,7 +25,10 @@ export default function MarkdownList({ children, ordered, start }: Props) {
ordered={ordered}
start={start}
items={items}
className={`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`}
className={twMerge(
`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`,
className,
)}
/>
);
}
@@ -1,11 +1,14 @@
import { useRef, type RefObject } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type {
BotMessageWithAttachments,
BotWithLastMessage,
} from "@/src/types";
import useMessageSelection from "../(hooks)/use-message-selection";
import groupMessages from "../(utils)/group-messages";
import BotThinkingRow from "./bot-thinking-row";
import MessageGroupBlock from "./message-group-block";
import QuoteSelectionButton from "./quote-selection-button";
type Props = {
bot: BotWithLastMessage;
@@ -22,19 +25,27 @@ type Props = {
* This session has an active task.
*/
has_task?: boolean;
/**
* Quotes text selected in a message in the composer.
*/
onQuote?: (params: { text: string }) => void;
};
/**
* Bottom-anchored: the stack sits 24px above the composer rule and grows
* upward. Empty space belongs at the top. New messages announce politely.
* While the bot works on a reply or a task, a status row sits under the
* last one.
* last one. Text selected in a message can be quoted in the composer.
*/
export default function MessageThread({ bot, messages, progress, reply_progress, has_task }: Props) {
export default function MessageThread({ bot, messages, progress, reply_progress, has_task, onQuote }: Props) {
const groups = groupMessages({ messages });
const ref = useRef<HTMLDivElement>(null);
const { selection, clear } = useMessageSelection({ ref });
return (
<Stack className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
<Stack
componentRef={ref as RefObject<HTMLDivElement>}
className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
<Stack
role="log"
aria-live="polite"
@@ -56,6 +67,15 @@ export default function MessageThread({ bot, messages, progress, reply_progress,
has_task={has_task}
/>
</Stack>
{selection && onQuote ? (
<QuoteSelectionButton
selection={selection}
onQuote={() => {
onQuote({ text: selection.text });
clear();
}}
/>
) : null}
</Stack>
);
}
@@ -0,0 +1,50 @@
import { useLayoutEffect, useRef } from "react";
import { computePosition, flip, offset, shift } from "@floating-ui/dom";
import Button from "@moduletrace/twui/layout/Button";
import { Quote } from "lucide-react";
import type { MessageSelection } from "../(hooks)/use-message-selection";
type Props = {
selection: MessageSelection;
onQuote: () => void;
};
/**
* "Quote", floating over text selected in a message: it puts the text in
* the composer as a quote. Pressing it keeps the selection, so the text
* it quotes is the one shown.
*/
export default function QuoteSelectionButton({ selection, onQuote }: Props) {
const ref = useRef<HTMLButtonElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
computePosition({ getBoundingClientRect: () => selection.rect }, el, {
strategy: "fixed",
placement: "top",
middleware: [offset(8), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
el.style.left = `${x}px`;
el.style.top = `${y}px`;
});
}, [selection.rect]);
return (
<Button
ref={ref}
type="button"
color="secondary"
title="Quote in your message"
onPointerDown={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
onClick={onQuote}
className="fixed left-0 top-0 z-50 gap-1.5! px-2.5! py-1.5! rounded-default-sm border border-solid border-hairline bg-surface shadow-popover text-xs font-bold text-ink hover:bg-sunken"
>
<Quote size={13} aria-hidden />
Quote
</Button>
);
}
@@ -1,12 +1,26 @@
import P from "@moduletrace/twui/layout/P";
import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import Stack from "@moduletrace/twui/layout/Stack";
import linkComputerRefs from "@/src/utils/link-computer-refs";
import { UserMarkdownComponents } from "../(data)/user-markdown-components";
import { QuotableAttribute } from "../(hooks)/use-message-selection";
/**
* Ink bubble with paper text; inverts in dark mode.
* Ink bubble with paper text; inverts in dark mode. The text is markdown
* (GFM; raw HTML is never rendered) as the composer writes it, a single
* line break kept as one, so older plain messages read as typed. Bots
* and workspaces it mentions show as chips.
*/
export default function UserBubble({ text }: { text: string }) {
return (
<P noMargin className="max-w-[80%] rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 whitespace-pre-wrap break-words">
{text}
</P>
<Stack
{...{ [QuotableAttribute]: "" }}
className="max-w-[80%] min-w-0 gap-2.5! items-stretch rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 break-words"
>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={UserMarkdownComponents}>
{linkComputerRefs({ text })}
</Markdown>
</Stack>
);
}
@@ -0,0 +1,22 @@
import type { ComponentProps } from "react";
import Link from "@moduletrace/twui/layout/Link";
import MarkdownLink from "./markdown-link";
/**
* A link in the user's own message: refs (bots, workspaces) as their
* chips (`MarkdownLink`), anything else underlined in the bubble's ink.
*/
export default function UserMarkdownLink(props: ComponentProps<"a">) {
if (props.href?.startsWith("#")) return <MarkdownLink {...props} />;
return (
<Link
href={props.href}
target="_blank"
rel="noopener noreferrer"
className="text-on-ink underline underline-offset-2 hover:opacity-80 break-words"
>
{props.children}
</Link>
);
}
@@ -0,0 +1,33 @@
import { useContext } from "react";
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
type Props = {
workspace_id: number;
};
/**
* A workspace named in a message (`workspaceRef`): a link to its
* dashboard. One the user isn't in is shown by name only.
*/
export default function WorkspaceRefChip({ workspace_id }: Props) {
const workspaces = useContext(CrewWorkspacesContext);
const workspace = workspaces.find((w) => w.id == workspace_id);
if (!workspace) {
return <Span className="font-bold">{`workspace ${workspace_id}`}</Span>;
}
return (
<Link
href={`/admin/workspaces/${workspace.id}`}
title={`Open ${workspace.name}`}
className="inline-flex h-6 gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
>
<WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" />
{workspace.name}
</Link>
);
}
@@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import type {
@@ -9,8 +9,10 @@ import { AppContext } from "@/src/pages/__root";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import useLiveBots from "@/src/hooks/use-live-bots";
import useServerEvent from "@/src/hooks/use-server-event";
import useLiveWorkspaces from "@/src/layouts/bots/(hooks)/use-live-workspaces";
import { BotComputersContext } from "../(data)/bot-computers-context";
import { CrewBotsContext } from "../(data)/crew-bots-context";
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import { SpeakMessageContext } from "../(data)/speak-message-context";
import reactToBotMessage from "../(functions)/react-to-bot-message";
@@ -21,7 +23,7 @@ import useReplyCatchUp from "../(hooks)/use-reply-catch-up";
import useSpeakMessage from "../(hooks)/use-speak-message";
import ConversationHeader from "../(partials)/conversation-header";
import MessageThread from "../(partials)/message-thread";
import Composer from "../(partials)/composer";
import Composer, { type ComposerHandle } from "../(partials)/composer";
import ComputerViewerModal from "../(partials)/computer-viewer-modal";
/**
@@ -31,7 +33,8 @@ import ComputerViewerModal from "../(partials)/computer-viewer-modal";
* The bot's computers are shared with the header's modal and the computer
* buttons in the thread, so a switch from either shows in both; either
* can open one in the viewer. Bot messages can be read aloud, one at a
* time.
* time. Text selected in the thread can be quoted in the composer, which
* mentions the crew and workspaces the user sees.
*/
export default function Conversation() {
const { pageProps } = useContext(AppContext);
@@ -59,6 +62,11 @@ export default function Conversation() {
workspace_id,
});
const live_bot = bots.find((b) => b.id == bot?.id) || bot;
const workspaces = useLiveWorkspaces({
workspaces: pageProps?.workspaces || [],
open_workspace_id: workspace_id || undefined,
});
const composer = useRef<ComposerHandle>(null);
const bot_computers = useBotComputers({
bot_id: bot?.id || 0,
workspace_id,
@@ -158,6 +166,7 @@ export default function Conversation() {
return (
<CrewBotsContext.Provider value={bots}>
<CrewWorkspacesContext.Provider value={workspaces}>
<BotComputersContext.Provider value={bot_computers}>
<SpeakMessageContext.Provider value={speak}>
<BotMessageActionsContext.Provider
@@ -179,8 +188,10 @@ export default function Conversation() {
progress={progress.text}
reply_progress={reply_progress}
has_task={Boolean(progress.task_id)}
onQuote={({ text }) => composer.current?.insertQuote({ text })}
/>
<Composer
handle_ref={composer}
bot={bot}
workspace_id={workspace_id}
extensions={pageProps?.bot_extensions}
@@ -208,6 +219,7 @@ export default function Conversation() {
<ComputerViewerModal bot={live_bot} />
</SpeakMessageContext.Provider>
</BotComputersContext.Provider>
</CrewWorkspacesContext.Provider>
</CrewBotsContext.Provider>
);
}
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import Mention from "@tiptap/extension-mention";
import { MarkdownManager } from "@tiptap/markdown";
import StarterKit from "@tiptap/starter-kit";
import { MentionMarkdown, mentionRef } from "./mention-markdown";
const markdown = new MarkdownManager({
extensions: [StarterKit.configure({ heading: false }), Mention.extend(MentionMarkdown)],
});
const roundTrip = (text: string) => markdown.serialize(markdown.parse(text)).trim();
describe("mention markdown", () => {
test("reads bot and workspace refs as mentions", () => {
const doc = markdown.parse("Ask [bot:3] about [workspace:2]");
const nodes = doc.content?.[0]?.content || [];
const mentions = nodes.filter((node) => node.type == "mention");
expect(mentions.map((node) => [node.attrs?.id, node.attrs?.mentionSuggestionChar])).toEqual([
["3", "@"],
["2", "#"],
]);
});
test("writes them back as refs, marks staying on the text around them", () => {
expect(roundTrip("Ask [bot:3] about **[workspace:2] now**")).toBe("Ask [bot:3] about [workspace:2] **now**");
expect(roundTrip("_Ask_ [bot:3] `soon`")).toBe("*Ask* [bot:3] `soon`");
expect(roundTrip("> quoted\n\nreply to [bot:4]")).toBe("> quoted\n\nreply to [bot:4]");
});
test("leaves markdown links alone", () => {
const doc = markdown.parse("[bot:3](https://example.com)");
const nodes = doc.content?.[0]?.content || [];
expect(nodes.some((node) => node.type == "mention")).toBe(false);
});
test("mentionRef defaults to a bot", () => {
expect(mentionRef({ id: 5, char: "#" })).toBe("[workspace:5]");
expect(mentionRef({ id: 5 })).toBe("[bot:5]");
});
});
@@ -0,0 +1,59 @@
import type { JSONContent, MarkdownParseHelpers, MarkdownToken } from "@tiptap/core";
import {
composerMentionChar,
ComposerMentionKinds,
type ComposerMentionChar,
} from "../(data)/composer-mention-kinds";
const RefPattern = /^\[(bot|workspace):(\d+)\](?!\()/;
/**
* How the composer's mention node reads and writes markdown: a bot as
* `[bot:3]` (`botRef`), a workspace as `[workspace:2]` (`workspaceRef`),
* so a draft or a sent message holds the same refs bots write. A ref
* followed by `(` is a markdown link, not a mention.
*/
export const MentionMarkdown = {
markdownTokenizer: {
name: "mention",
level: "inline" as const,
start(src: string) {
const match = src.match(/\[(bot|workspace):\d+\](?!\()/);
return match?.index ?? -1;
},
tokenize(src: string) {
const match = src.match(RefPattern);
if (!match) return undefined;
const kind = match[1] as "bot" | "workspace";
return {
type: "mention",
raw: match[0],
attributes: { id: match[2], mentionSuggestionChar: composerMentionChar({ kind }) },
};
},
},
parseMarkdown(token: MarkdownToken, h: MarkdownParseHelpers) {
return h.createNode("mention", { ...token.attributes, label: null });
},
renderMarkdown(node: JSONContent) {
return mentionRef({
id: node.attrs?.id,
char: node.attrs?.mentionSuggestionChar,
});
},
};
/**
* The ref a mention writes, `[bot:3]` unless it's a workspace's.
*/
export function mentionRef({ id, char }: { id?: string | number | null; char?: string | null }) {
const kind = ComposerMentionKinds[(char || "@") as ComposerMentionChar] || "bot";
return `[${kind}:${id}]`;
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
import searchMentionItems from "./search-mention-items";
const bots = [
{ id: 1, name: "Cobble" },
{ id: 2, name: "Researcher" },
{ id: 3, name: "Archive reader" },
] as BotWithLastMessage[];
const workspaces = [
{ id: 1, name: "Launch", unread: 0 },
{ id: 2, name: "Old launch", archived: 1, unread: 0 },
] as WorkspaceWithActivity[];
describe("searchMentionItems", () => {
test("puts names starting with the query first", () => {
const items = searchMentionItems({ kind: "bot", query: "r", bots, workspaces });
expect(items.map((item) => item.id)).toEqual([2, 3]);
});
test("offers every bot for an empty query", () => {
expect(searchMentionItems({ kind: "bot", query: "", bots, workspaces })).toHaveLength(3);
});
test("leaves archived workspaces out", () => {
const items = searchMentionItems({ kind: "workspace", query: "launch", bots, workspaces });
expect(items.map((item) => item.label)).toEqual(["Launch"]);
});
});
@@ -0,0 +1,44 @@
import { AppData } from "@/src/data/app-data";
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
import type { ComposerMentionKind } from "../(data)/composer-mention-kinds";
/**
* One thing the mention menu offers.
*/
export type ComposerMentionItem = {
id: number;
label: string;
bot?: BotWithLastMessage;
workspace?: WorkspaceWithActivity;
};
type Params = {
kind: ComposerMentionKind;
query: string;
bots: BotWithLastMessage[];
workspaces: WorkspaceWithActivity[];
};
/**
* The bots or workspaces whose name has what was typed, names starting
* with it first, at most `AppData.ComposerMentionMaxItems`.
*/
export default function searchMentionItems({ kind, query, bots, workspaces }: Params): ComposerMentionItem[] {
const items: ComposerMentionItem[] =
kind == "bot"
? bots
.filter((bot) => bot.id && bot.name)
.map((bot) => ({ id: Number(bot.id), label: String(bot.name), bot }))
: workspaces
.filter((workspace) => workspace.id && workspace.name && !workspace.archived)
.map((workspace) => ({ id: Number(workspace.id), label: String(workspace.name), workspace }));
const q = query.trim().toLowerCase();
return items
.map((item) => ({ item, at: item.label.toLowerCase().indexOf(q) }))
.filter(({ at }) => at >= 0)
.sort((a, b) => Number(a.at != 0) - Number(b.at != 0) || a.item.label.localeCompare(b.item.label))
.slice(0, AppData.ComposerMentionMaxItems)
.map(({ item }) => item);
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import describeMessageRefs, { type MessageRefNames } from "./describe-message-refs";
import messageRefIds from "./message-ref-ids";
const names: MessageRefNames = {
bots: new Map([[3, { name: "Ada", role: "Researcher" }], [4, { name: "Bo" }]]),
workspaces: new Map([[2, { name: "Launch" }]]),
};
describe("messageRefIds", () => {
test("collects each bot and workspace once, links aside", () => {
expect(
messageRefIds({ texts: ["[bot:3] and [bot:3]", "[workspace:2] [bot:9](https://x.y)"] }),
).toEqual({ bot_ids: [3], workspace_ids: [2] });
});
});
describe("describeMessageRefs", () => {
test("names what a message mentions", () => {
expect(describeMessageRefs({ text: "Ask [bot:3] and [bot:4] in [workspace:2]", names })).toBe(
'[Mentions: [bot:3] is Ada (Researcher); [bot:4] is Bo; [workspace:2] is the workspace "Launch"]',
);
});
test("leaves out refs the user can't reach", () => {
expect(describeMessageRefs({ text: "[bot:8] and [workspace:7]", names })).toBe("");
expect(describeMessageRefs({ text: "no refs", names })).toBe("");
});
});
+34
View File
@@ -0,0 +1,34 @@
import messageRefIds from "./message-ref-ids";
/**
* What a ref stands for: a bot's name and role, or a workspace's name.
*/
export type MessageRefNames = {
bots: Map<number, { name: string; role?: string | null }>;
workspaces: Map<number, { name: string }>;
};
/**
* A line telling the bot who and what a user message mentions, e.g.
* `[Mentions: [bot:3] is Ada (Researcher); [workspace:2] is the workspace "Launch"]`.
* Refs missing from `names` (the user can't reach them) are left out;
* nothing is returned when none are known.
*/
export default function describeMessageRefs({ text, names }: { text: string; names: MessageRefNames }) {
const { bot_ids, workspace_ids } = messageRefIds({ texts: [text] });
const parts: string[] = [];
for (const id of bot_ids) {
const bot = names.bots.get(id);
if (bot) parts.push(`[bot:${id}] is ${bot.name}${bot.role ? ` (${bot.role})` : ""}`);
}
for (const id of workspace_ids) {
const workspace = names.workspaces.get(id);
if (workspace) parts.push(`[workspace:${id}] is the workspace "${workspace.name}"`);
}
return parts.length ? `[Mentions: ${parts.join("; ")}]` : "";
}
+9
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import botRef from "./bot-ref";
import workspaceRef from "./workspace-ref";
import computerRef from "./computer-ref";
import linkComputerRefs from "./link-computer-refs";
import takeOverRef from "./take-over-ref";
@@ -34,4 +35,12 @@ describe("linkComputerRefs", () => {
}),
).toBe("[bot 4](#bot-4) is on it. See [bot:x] and [bot:5](https://example.com).");
});
test("links every workspace a message names", () => {
expect(
linkComputerRefs({
text: `Added to ${workspaceRef({ id: 2 })}. Not [workspace:x] or [workspace:3](https://example.com).`,
}),
).toBe("Added to [workspace 2](#workspace-2). Not [workspace:x] or [workspace:3](https://example.com).");
});
});
+13 -2
View File
@@ -18,9 +18,16 @@ export const TakeOverRefHrefPrefix = "#take-over-";
*/
export const BotRefHrefPrefix = "#bot-";
/**
* The link target a workspace ref (`workspaceRef`) becomes, e.g.
* `#workspace-2`. The chat renders these as links to that workspace.
*/
export const WorkspaceRefHrefPrefix = "#workspace-";
/**
* Turns every computer ref (`[computer:3]`) and take-over ref
* (`[take-over:3:browser]`) and bot ref (`[bot:3]`) in a message into a markdown link, so it
* (`[take-over:3:browser]`), bot ref (`[bot:3]`) and workspace ref
* (`[workspace:2]`) in a message into a markdown link, so it
* survives markdown rendering as a link the chat can swap for a button.
*/
export default function linkComputerRefs({ text }: { text: string }) {
@@ -33,5 +40,9 @@ export default function linkComputerRefs({ text }: { text: string }) {
/\[take-over:(\d+):([a-z]+)\](?!\()/g,
(_, id: string, mode: string) => `[take over](${TakeOverRefHrefPrefix}${id}-${mode})`,
)
.replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => `[bot ${id}](${BotRefHrefPrefix}${id})`);
.replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => `[bot ${id}](${BotRefHrefPrefix}${id})`)
.replace(
/\[workspace:(\d+)\](?!\()/g,
(_, id: string) => `[workspace ${id}](${WorkspaceRefHrefPrefix}${id})`,
);
}
+1
View File
@@ -12,6 +12,7 @@ describe("messagePreview", () => {
test("names bots plainly", () => {
expect(messagePreview({ text: "[bot:4] is on it." })).toBe("a bot is on it.");
expect(messagePreview({ text: "Moved to [workspace:2]." })).toBe("Moved to a workspace.");
});
test("keeps code text without its fences", () => {
+3 -1
View File
@@ -1,13 +1,15 @@
/**
* A bot message as one line of plain text, for the rail's preview: no
* markdown syntax, links as their text, computer refs (`computerRef`) as
* "a computer", bot refs (`botRef`) as "a bot", whitespace collapsed.
* "a computer", bot refs (`botRef`) as "a bot", workspace refs
* (`workspaceRef`) as "a workspace", whitespace collapsed.
*/
export default function messagePreview({ text }: { text: string }) {
return text
.replace(/```[^\n]*\n?/g, "")
.replace(/\[computer:\d+\](?!\()/g, "a computer")
.replace(/\[bot:\d+\](?!\()/g, "a bot")
.replace(/\[workspace:\d+\](?!\()/g, "a workspace")
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/^\s{0,3}(#{1,6}\s+|>\s?|[-*+]\s+|\d+\.\s+)/gm, "")
.replace(/(\*\*|__|~~|`)/g, "")
+17
View File
@@ -0,0 +1,17 @@
/**
* The bots (`[bot:ID]`) and workspaces (`[workspace:ID]`) some messages
* name, each id once. Refs that are markdown links (`[bot:3](...)`) don't
* count.
*/
export default function messageRefIds({ texts }: { texts: string[] }) {
const bot_ids = new Set<number>();
const workspace_ids = new Set<number>();
for (const text of texts) {
for (const [, kind, id] of text.matchAll(/\[(bot|workspace):(\d+)\](?!\()/g)) {
(kind == "bot" ? bot_ids : workspace_ids).add(Number(id));
}
}
return { bot_ids: [...bot_ids], workspace_ids: [...workspace_ids] };
}
+7
View File
@@ -0,0 +1,7 @@
/**
* How a message names a workspace: `[workspace:2]`. The chat shows it as a
* link to that workspace.
*/
export default function workspaceRef({ id }: { id: number | string }) {
return `[workspace:${id}]`;
}