From 5a0d581a22ef9f18c3d558067e2c94ddf6f8524a Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:08:08 +0800 Subject: [PATCH] client: a chat bubble keeps the host's image resolver and file opener The nested MarkdownDelegateProvider #2313 put in every bubble replaces the host's Markdown capabilities, so fileImages never reached the bubble and an image drew as its alt text. Render bare MarkdownText again and take the #2201 link in a capture-phase click handler on the bubble's containers instead. --- .../2319-bubble-image-delegate.fixed.md | 1 + core/client.js | 77 +- core/src/client/cells.js | 108 +- core/test/chat-link-open.test.js | 418 ++- docs/subsystems/core-client.md | 2 +- patches/ds-bot/0003-flybot-api-client.patch | 151 +- patches/ds-bot/0008-client-bundle.patch | 2382 ++++++++--------- .../ds-bot/files/test/chat-link-open.test.js | 418 ++- 8 files changed, 2025 insertions(+), 1532 deletions(-) create mode 100644 changelog.d/2319-bubble-image-delegate.fixed.md diff --git a/changelog.d/2319-bubble-image-delegate.fixed.md b/changelog.d/2319-bubble-image-delegate.fixed.md new file mode 100644 index 000000000..531e77f01 --- /dev/null +++ b/changelog.d/2319-bubble-image-delegate.fixed.md @@ -0,0 +1 @@ +An image in a chat bubble shows its picture again instead of only the alt text: the bubble's Markdown is no longer wrapped in a delegate of its own, so the host's image resolver and file opener reach it again and a local `[x](path#L12)` link keeps its chip. A link is still taken by FlyBot's own browser panel, now from a capture-phase click on the bubble or on the person's own line, while a mention, a file link and a modified or middle click are left to the host (#2319). diff --git a/core/client.js b/core/client.js index b90616269..564017a1f 100644 --- a/core/client.js +++ b/core/client.js @@ -6836,19 +6836,28 @@ var import_react10 = require("react"); var import_react_dom = require("react-dom"); var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); -function bubbleMarkdown({ actions, subject, openFile, fileImages, ...markdown }) { - const openExternalLink = (href) => { +function bubbleLinkClick({ actions, subject }) { + return (event) => { + if (!event || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false; + const anchor = event.target?.closest?.("a[href]"); + if (!anchor) return false; + const href = anchor.getAttribute?.("href"); + if (typeof href !== "string" || href.startsWith(MENTION_ORIGIN) || !/^https?:/i.test(href)) return false; + event.preventDefault(); + event.stopPropagation(); const tabId = typeof actions?.browserNewTab === "function" ? actions.browserNewTab(subject) : null; if (tabId !== void 0 && tabId !== null && typeof actions.browserNavigate === "function") { void actions.browserNavigate(tabId, href); - return; + return true; } - if (typeof window !== "undefined" && typeof window.open === "function") window.open(href, "_blank", "noopener"); + if (typeof window !== "undefined" && typeof window.open === "function") window.open(href, "_blank", "noopener,noreferrer"); + return true; }; - return (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownDelegateProvider, { openExternalLink, openFile, fileImages }, (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, markdown)); } -function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions, openFile, fileImages }) { +function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions }) { + const openLink = bubbleLinkClick({ actions, subject: selfId ?? sessionId }); const onClick = (event) => { + if (openLink(event)) return; const target = mentionTarget(event); if (target === null) return; event.preventDefault(); @@ -6878,7 +6887,7 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, (0, import_react10.createElement)( "div", { className: "bt-bubble" }, - bubbleMarkdown({ text: linkMentions(text2, roster.bots, selfId), actions, subject: selfId ?? sessionId, openFile, fileImages, streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) + (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text2, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) ), live ? null : (0, import_react10.createElement)(MessageTools, { text: text2, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(key, reactIndex(index), emoji) : void 0, actions }) ), @@ -7041,7 +7050,7 @@ function friendLead(bot, texts2) { if (texts2.every((text2) => /^Not sent:/.test(text2.trim()))) return void 0; return (0, import_react10.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react10.createElement)(BotMark, { bot, size: 22 })); } -function AssistantCell({ node, groupPart, sessionId, openFile, fileImages, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { +function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { const roster = useRoster((value) => value); const data = node.data; const steps = useTurnSteps(String(data.turn)); @@ -7059,7 +7068,7 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileImages, fileM const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; if (texts2.length === 0 && !reasoning && relays.length === 0) return null; const selfId = roster.byId[sessionId]?.id ?? sessionId; - const bubbles = texts2.length ? person ? (0, import_react10.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, openFile, fileImages }) : (0, import_react10.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts2), openFile, fileImages }) : null; + const bubbles = texts2.length ? person ? (0, import_react10.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi }) : (0, import_react10.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts2) }) : null; return (0, import_react10.createElement)( import_react10.Fragment, null, @@ -7091,14 +7100,14 @@ function HeldReply({ held, roster, sessionId, actions, closing, children }) { closing ? (0, import_react10.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null ); } -function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, openFile, fileImages }) { +function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { if (!(texts2 ?? []).some((text2) => String(text2 ?? "").trim() !== "")) return null; const lead = (0, import_react10.createElement)("span", { className: "bt-lead", title: name }, (0, import_react10.createElement)(PersonMark, { name })); return (0, import_react10.createElement)( "div", { className: "bt-group-msg bt-person-msg", "data-person": name }, (0, import_react10.createElement)("span", { className: "bt-group-name" }, name), - (0, import_react10.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead, openFile, fileImages }) + (0, import_react10.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) ); } function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { @@ -7152,7 +7161,7 @@ function heldLineState(room2, { seq, botId, text: text2 } = {}, now = Date.now() if (entry.status !== "pending" && entry.status !== "sending") return void 0; return room2.face?.status === "left" || room2.face?.status === "closed" ? "rejected" : "held"; } -function GroupMessage({ bot, text: text2, room: room2, roster, actions, msgKey, useUi, seq, openFile, fileImages }) { +function GroupMessage({ bot, text: text2, room: room2, roster, actions, msgKey, useUi, seq }) { if (String(text2 ?? "").trim() === "") return null; const held = heldLineState(room2, { seq, botId: bot.id, text: text2 }); if (held === "rejected") { @@ -7179,7 +7188,7 @@ function GroupMessage({ bot, text: text2, room: room2, roster, actions, msgKey, "div", { className: "bt-group-msg" }, name, - (0, import_react10.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead, openFile, fileImages }) + (0, import_react10.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ); } function remoteSpeaker(source, roster, sessionId) { @@ -7214,7 +7223,7 @@ function faceSeatLook(room2, handle) { return Object.keys(look).length ? look : void 0; } var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; -function RemoteMessage({ source, text: text2, roster, actions, sessionId, msgKey, useUi, openFile, fileImages }) { +function RemoteMessage({ source, text: text2, roster, actions, sessionId, msgKey, useUi }) { const room2 = roster.roomsById?.[sessionId]; const face = room2?.origin === "face-to-face"; const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId); @@ -7233,7 +7242,7 @@ function RemoteMessage({ source, text: text2, roster, actions, sessionId, msgKey person, carrier ? (0, import_react10.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null ), - String(words ?? "").trim() === "" ? null : (0, import_react10.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react10.createElement)("span", { className: "bt-lead", title: person }, (0, import_react10.createElement)(PersonMark, { name: person })), openFile, fileImages }), + String(words ?? "").trim() === "" ? null : (0, import_react10.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react10.createElement)("span", { className: "bt-lead", title: person }, (0, import_react10.createElement)(PersonMark, { name: person })) }), files.length ? (0, import_react10.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null ); } @@ -7249,7 +7258,7 @@ function RemoteMessage({ source, text: text2, roster, actions, sessionId, msgKey name, (0, import_react10.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react10.createElement)(GlobeMark), handle) ), - String(words ?? "").trim() === "" ? null : (0, import_react10.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead, openFile, fileImages }), + String(words ?? "").trim() === "" ? null : (0, import_react10.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), files.length ? (0, import_react10.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null ); } @@ -7329,7 +7338,7 @@ var scrollParent = (node) => { } return null; }; -function EarlierParts({ sessionId, roster, actions, useUi, openFile, fileImages }) { +function EarlierParts({ sessionId, roster, actions, useUi }) { const [view, setView] = (0, import_react10.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); const top = (0, import_react10.useRef)(null); const busy = (0, import_react10.useRef)(false); @@ -7400,16 +7409,16 @@ function EarlierParts({ sessionId, roster, actions, useUi, openFile, fileImages case "divider": return (0, import_react10.createElement)(PartLine, { key, time: row.time }); case "user": - return (0, import_react10.createElement)("div", { key, className: "bt-urow" }, (0, import_react10.createElement)( + return (0, import_react10.createElement)("div", { key, className: "bt-urow", onClickCapture: bubbleLinkClick({ actions, subject: selfId ?? sessionId }) }, (0, import_react10.createElement)( "div", { className: "bt-ububble" }, - row.text ? bubbleMarkdown({ text: row.text, actions, subject: selfId ?? sessionId, openFile, fileImages, labels: mdLabels(), variant: "compact" }) : null, + row.text ? (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, row.images ? (0, import_react10.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null )); case "answer": return (0, import_react10.createElement)(AnsweredCell, { key, node: { data: row } }); case "bot": - return (0, import_react10.createElement)(Bubbles, { key, texts: row.texts, msgKeys: row.msgKeys, roster, selfId, actions, sessionId, useUi, openFile, fileImages }); + return (0, import_react10.createElement)(Bubbles, { key, texts: row.texts, msgKeys: row.msgKeys, roster, selfId, actions, sessionId, useUi }); case "event": { if (row.role === "report") return (0, import_react10.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; @@ -7425,7 +7434,7 @@ function EarlierParts({ sessionId, roster, actions, useUi, openFile, fileImages (0, import_react10.createElement)(PartLine, { time: view.startedAt }) ); } -function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions, openFile, fileImages }) { +function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { const roster = useRoster((value) => value); const data = node.data; const source = data.source !== null && typeof data.source === "object" ? data.source : {}; @@ -7457,11 +7466,11 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us if (source.form === "remote-chat") return null; if (source.shown === true) return null; if (source.remote?.handle) { - const message = (0, import_react10.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi, openFile, fileImages }); + const message = (0, import_react10.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); return source.remote.firstContact === true ? (0, import_react10.createElement)(import_react10.Fragment, null, (0, import_react10.createElement)(FirstContact, { source, roster, sessionId }), message) : message; } if (source.kind === "bot") { - if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react10.createElement)(EarlierParts, { sessionId, roster, actions, useUi, openFile, fileImages }); + if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react10.createElement)(EarlierParts, { sessionId, roster, actions, useUi }); if (source.role === "room" || source.role === "kickoff") return null; if (source.role === "mention") return (0, import_react10.createElement)("div", { className: "bt-event" }, (0, import_react10.createElement)("span", null, contentText(data.content))); if (source.role === "secret" || deferred) return null; @@ -7470,7 +7479,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us if (source.notice === true || source.senderSessionId === "face-notice") return null; const room2 = roster.roomsById[sessionId]; const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; - return (0, import_react10.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi, openFile, fileImages }); + return (0, import_react10.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); } if (source.role === "report") { return (0, import_react10.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); @@ -7723,7 +7732,7 @@ function useLiveNote(actions, sessionId, id, entry) { return () => actions.noteLive(sessionId, id, null); }, [sessionId, id, key]); } -function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, openFile, fileImages }) { +function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { const [live, setLive] = (0, import_react10.useState)({ replies: [] }); (0, import_react10.useEffect)(() => { if (done) return void 0; @@ -7767,16 +7776,16 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi if (reply.kind === "user") { const who = reply.name && reply.name !== "User" ? reply.name : reply.authorId; if (reply.other) { - return (0, import_react10.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, openFile, fileImages }); + return (0, import_react10.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); } return (0, import_react10.createElement)( "div", - { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who }, + { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who, onClickCapture: bubbleLinkClick({ actions, subject: sessionId }) }, who ? (0, import_react10.createElement)("span", { className: "bt-uauthor" }, who) : null, (0, import_react10.createElement)( "div", { className: "bt-ububble" }, - bubbleMarkdown({ text: remoteMessageText(reply.text), actions, subject: reply.botId ?? sessionId, openFile, fileImages, labels: mdLabels(), variant: "compact" }) + (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) ), room2?.origin === "face-to-face" ? (0, import_react10.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react10.createElement)(PersonMark, { name: who || void 0 })) : null ); @@ -7827,8 +7836,6 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; if (remote) { return (0, import_react10.createElement)(RemoteMessage, { - openFile, - fileImages, key: `${reply.botId || reply.handle}-${index}`, source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, text: remoteMessageText(reply.text), @@ -7839,7 +7846,7 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi useUi }); } - return (0, import_react10.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq, openFile, fileImages }); + return (0, import_react10.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); }) ); } @@ -7867,7 +7874,7 @@ function useShownCards(actions, sessionId, callIds) { return () => actions.noteCards(sessionId, ids, -1); }, [sessionId, key]); } -function renderCall(call4, { sessionId, roster, actions, useUi, findBot, openFile, fileImages }) { +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { const tool = toolName(call4); const done = call4.kind === "tool-result"; const { text: arg, value: argValue } = toolArgs(call4); @@ -7893,7 +7900,7 @@ function renderCall(call4, { sessionId, roster, actions, useUi, findBot, openFil return done && target ? (0, import_react10.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; } case "group_relay": - return (0, import_react10.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi, openFile, fileImages }); + return (0, import_react10.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi }); case "create_group": case "update_group": case "delete_group": @@ -7944,7 +7951,7 @@ function renderCall(call4, { sessionId, roster, actions, useUi, findBot, openFil return null; } } -function ToolCell({ node, sessionId, useRoster, useUi, actions, openFile, fileImages }) { +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { const roster = useRoster((value) => value); const root = node.data.root; const turn = node.location?.turn?.turn ?? root.turn; @@ -7957,7 +7964,7 @@ function ToolCell({ node, sessionId, useRoster, useUi, actions, openFile, fileIm useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); if (drawn.length === 0) return null; - const env = { sessionId, roster, actions, useUi, findBot, openFile, fileImages }; + const env = { sessionId, roster, actions, useUi, findBot }; return (0, import_react10.createElement)(import_react10.Fragment, null, drawn.map((call4, index) => (0, import_react10.createElement)(import_react10.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); } diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 89903cd01..7ec3a41c6 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -1,8 +1,8 @@ import { createElement as h, Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { MarkdownText, MarkdownDelegateProvider } from '@deepseek-ai/dsh-client-ui-primitives' +import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import { inkText } from './inks.js' -import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, heldReplyOf, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts, isPluginTool } from './text.js' +import { currentPart, linkMentions, mentionTarget, MENTION_ORIGIN, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, heldReplyOf, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts, isPluginTool } from './text.js' import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js' import { colorOf, lookBotForRemote, toolState, BotMark, PersonMark, RoomAvatar } from './mark.js' import { botByName, rosterBot } from './sources.js' @@ -24,35 +24,43 @@ import { turnReasoning } from './turn-reasoning.js' const mdLabels = () => ({ code: { copyLabel: t('Copy'), copiedLabel: t('Copied') }, footnotes: t('Footnotes') }) -// #2201: a link a person picks inside a bubble belongs to FlyBot's own panel. Left alone the host opens -// it in its right sidebar — a browser tab this panel's fixed layer covers, so the content area looks -// blank — and nothing in FlyBot ever sees the click. The link therefore opens a tab in the panel's own -// browser view (the one the globe button opens) carrying the URL; without the Desktop bridge there is -// no panel at all, and the page opens in the window itself. An element builder, not a component: the -// provider sits directly above the text so the bubble keeps one Markdown node, as before. -function bubbleMarkdown({ actions, subject, openFile, fileImages, ...markdown }) { - const openExternalLink = (href) => { +// #2201: a link a person picks inside a bubble belongs to FlyBot's own panel. Left to the host it +// opens in the host's right sidebar — a browser tab this panel's fixed layer covers, so the content +// area looks blank — and FlyBot never sees the click. A capture handler on the bubble's container +// takes the anchor before the host's delegate does: an http(s) link opens a tab in the panel's own +// browser view (the one the globe button opens), and without the Desktop bridge the page opens in +// the window itself. Everything else — no anchor, a middle or modified click, a mention, a file +// link — falls through to the handler that already owns it, the host's included. +function bubbleLinkClick({ actions, subject }) { + return (event) => { + if (!event || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false + const anchor = event.target?.closest?.('a[href]') + if (!anchor) return false + const href = anchor.getAttribute?.('href') + // A mention is an https link to the fake origin the plugin rewrites names into; the click + // handler that owns mentions opens the Bot's part for it, so it must not be taken for a page. + if (typeof href !== 'string' || href.startsWith(MENTION_ORIGIN) || !/^https?:/i.test(href)) return false + event.preventDefault() + event.stopPropagation() const tabId = typeof actions?.browserNewTab === 'function' ? actions.browserNewTab(subject) : null if (tabId !== undefined && tabId !== null && typeof actions.browserNavigate === 'function') { void actions.browserNavigate(tabId, href) - return + return true } - // No tab for this link — a room id, a Bot outside the roster, no bridge, or no navigation — - // so the page opens in the window itself. Exactly one window here: opening in both places - // would show two tabs (#2201 review). - if (typeof window !== 'undefined' && typeof window.open === 'function') window.open(href, '_blank', 'noopener') + // No tab for this link — a Bot outside the roster, no bridge, or no navigation — so the page + // opens in the window itself. Exactly one window here: opening in both places would show two + // tabs (#2201 review). + if (typeof window !== 'undefined' && typeof window.open === 'function') window.open(href, '_blank', 'noopener,noreferrer') + return true } - // The nearest provider *replaces* the enclosing Markdown scope (primitives: "Nested providers - // replace the enclosing capabilities"), so the host's file opener and image resolver have to - // ride with this one: a local `[x](path#L12)` link keeps its chip and a local image keeps - // previewing, as they did before the bubble got a delegate of its own. - return h(MarkdownDelegateProvider, { openExternalLink, openFile, fileImages }, h(MarkdownText, markdown)) } // `lead` (a sender avatar in group chats) sits beside the last bubble, in the same // row, so it lines up with the bubble whether or not reactions hang below it. -export function Bubbles({ texts, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions, openFile, fileImages }) { +export function Bubbles({ texts, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions }) { + const openLink = bubbleLinkClick({ actions, subject: selfId ?? sessionId }) const onClick = (event) => { + if (openLink(event)) return const target = mentionTarget(event) if (target === null) return event.preventDefault() @@ -82,7 +90,7 @@ export function Bubbles({ texts, roster, selfId, actions, streaming, sessionId, h('div', { className: 'bt-msg-line' }, lead && last ? lead : null, h('div', { className: 'bt-bubble' }, - bubbleMarkdown({ text: linkMentions(text, roster.bots, selfId), actions, subject: selfId ?? sessionId, openFile, fileImages, streaming: live, labels: mdLabels(), variant: 'compact', fileMentions: mentions })), + h(MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: 'compact', fileMentions: mentions })), live ? null : h(MessageTools, { text, name: roster.byId[selfId]?.name ?? t('Bot'), sessionId, react: key ? emoji => actions.toggleReaction(key, reactIndex(index), emoji) : undefined, actions })), picked.length ? h('div', { className: 'bt-reacts' }, picked.map(emoji => h('button', { key: emoji, type: 'button', className: 'bt-react', 'aria-label': t('Remove reaction {emoji}', { emoji }), onClick: () => actions.toggleReaction(key, reactIndex(index), emoji), @@ -236,7 +244,7 @@ export function friendLead(bot, texts) { return h('span', { className: 'bt-lead', title: bot.name, 'data-flybot': 'friend-lead' }, h(BotMark, { bot, size: 22 })) } -export function AssistantCell({ node, groupPart, sessionId, openFile, fileImages, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { +export function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { const roster = useRoster(value => value) const data = node.data const steps = useTurnSteps(String(data.turn)) @@ -259,8 +267,8 @@ export function AssistantCell({ node, groupPart, sessionId, openFile, fileImages if (texts.length === 0 && !reasoning && relays.length === 0) return null const selfId = roster.byId[sessionId]?.id ?? sessionId const bubbles = texts.length ? (person - ? h(PersonMessage, { name: person, texts, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi, openFile, fileImages }) - : h(Bubbles, { texts, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts), openFile, fileImages })) : null + ? h(PersonMessage, { name: person, texts, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi }) + : h(Bubbles, { texts, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts) })) : null return h(Fragment, null, relays.map(relay => { const source = relay.source @@ -294,13 +302,13 @@ function HeldReply({ held, roster, sessionId, actions, closing, children }) { // A friend's line that Bot Social attests the friend typed themselves, or another person's own // line in a face-to-face room: the person's name and a person mark, not the Bot's (the host marks // the turn, remote-chat.js / turns.js, or posts it as a person line, hooks.js postHuman). -function PersonMessage({ name, texts, roster, selfId, actions, sessionId, msgKey, useUi, openFile, fileImages }) { +function PersonMessage({ name, texts, roster, selfId, actions, sessionId, msgKey, useUi }) { // #2123: the same rule as a Bot's line — text a person can read or no row (the name and mark go too). if (!(texts ?? []).some(text => String(text ?? '').trim() !== '')) return null const lead = h('span', { className: 'bt-lead', title: name }, h(PersonMark, { name })) return h('div', { className: 'bt-group-msg bt-person-msg', 'data-person': name }, h('span', { className: 'bt-group-name' }, name), - h(Bubbles, { texts, roster, selfId, actions, sessionId, msgKey, useUi, lead, openFile, fileImages })) + h(Bubbles, { texts, roster, selfId, actions, sessionId, msgKey, useUi, lead })) } function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { @@ -359,7 +367,7 @@ export function heldLineState(room, { seq, botId, text } = {}, now = Date.now()) } // One Bot message inside a group chat: avatar, name (with its group role), bubble. -function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi, seq, openFile, fileImages }) { +function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi, seq }) { // #2123: the Bot posted no words (a turn that only called tools, a reply carried by a card): there is // no line to draw, and drawing one leaves the avatar and an empty bubble under the trace row. if (String(text ?? '').trim() === '') return null @@ -377,7 +385,7 @@ function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi, seq, op const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, faceBotLabel(room, roster, bot) ?? bot.name, room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null) return h('div', { className: 'bt-group-msg' }, name, - h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead, openFile, fileImages })) + h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead })) } // A friend's remote Bot speaking in this chat, laid out like any chat partner: its own @@ -427,7 +435,7 @@ function faceSeatLook(room, handle) { const cleanCarrier = value => (typeof value === 'string' && value.trim() && !/[@()]/.test(value) ? value.trim() : '') -export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi, openFile, fileImages }) { +export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { const room = roster.roomsById?.[sessionId] const face = room?.origin === 'face-to-face' const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId) @@ -444,7 +452,7 @@ export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey // One short header: the person, and the Bot that carried the line only when it is known (#275). h('span', { className: 'bt-group-name', title: handle }, person, carrier ? h('span', { className: 'bt-remote-handle' }, t('via {name}', { name: carrier })) : null), - String(words ?? '').trim() === '' ? null : h(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark, { name: person })), openFile, fileImages }), + String(words ?? '').trim() === '' ? null : h(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark, { name: person })) }), files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null) } const mark = h(BotMark, { bot, size: 22 }) @@ -457,7 +465,7 @@ export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey ? h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) }, title: handle }, name) : h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, name, h('span', { className: 'bt-remote-handle', title: source.remote.home ?? handle, 'aria-label': t('Friend\'s Bot') }, h(GlobeMark), handle)), - String(words ?? '').trim() === '' ? null : h(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead, openFile, fileImages }), + String(words ?? '').trim() === '' ? null : h(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null) } @@ -539,7 +547,7 @@ const scrollParent = (node) => { // The earlier parts of a Bot's own chat, above its current part as one conversation. // Pages load from the host as the user scrolls up; a thin line marks where each // part begins, the last one where the current part begins. -function EarlierParts({ sessionId, roster, actions, useUi, openFile, fileImages }) { +function EarlierParts({ sessionId, roster, actions, useUi }) { const [view, setView] = useState({ items: [], cursor: undefined, loading: true, failed: false, startedAt: null }) const top = useRef(null) const busy = useRef(false) @@ -610,11 +618,11 @@ function EarlierParts({ sessionId, roster, actions, useUi, openFile, fileImages const key = rows.length - index switch (row.kind) { case 'divider': return h(PartLine, { key, time: row.time }) - case 'user': return h('div', { key, className: 'bt-urow' }, h('div', { className: 'bt-ububble' }, - row.text ? bubbleMarkdown({ text: row.text, actions, subject: selfId ?? sessionId, openFile, fileImages, labels: mdLabels(), variant: 'compact' }) : null, + case 'user': return h('div', { key, className: 'bt-urow', onClickCapture: bubbleLinkClick({ actions, subject: selfId ?? sessionId }) }, h('div', { className: 'bt-ububble' }, + row.text ? h(MarkdownText, { text: row.text, labels: mdLabels(), variant: 'compact' }) : null, row.images ? h('span', { className: 'bt-uimages' }, row.images === 1 ? t('1 image') : t('{count} images', { count: row.images })) : null)) case 'answer': return h(AnsweredCell, { key, node: { data: row } }) - case 'bot': return h(Bubbles, { key, texts: row.texts, msgKeys: row.msgKeys, roster, selfId, actions, sessionId, useUi, openFile, fileImages }) + case 'bot': return h(Bubbles, { key, texts: row.texts, msgKeys: row.msgKeys, roster, selfId, actions, sessionId, useUi }) case 'event': { if (row.role === 'report') return h(GroupEvent, { key, prefix: t('Replies from'), room: roster.roomsById[row.roomId], name: t('a group chat'), roster, actions }) const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t('a Bot'), color: 'gray' } @@ -627,7 +635,7 @@ function EarlierParts({ sessionId, roster, actions, useUi, openFile, fileImages h(PartLine, { time: view.startedAt })) } -export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions, openFile, fileImages }) { +export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { const roster = useRoster(value => value) const data = node.data const source = data.source !== null && typeof data.source === 'object' ? data.source : {} @@ -671,12 +679,12 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl // A line that arrived while a round was running is that round's bubble already (#455). if (source.shown === true) return null if (source.remote?.handle) { - const message = h(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi, openFile, fileImages }) + const message = h(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }) return source.remote.firstContact === true ? h(Fragment, null, h(FirstContact, { source, roster, sessionId }), message) : message } if (source.kind === 'bot') { // A part opens with its handoff package, so the earlier parts go right above it. - if (source.handoff === true) return roster.roomsById[sessionId] ? null : h(EarlierParts, { sessionId, roster, actions, useUi, openFile, fileImages }) + if (source.handoff === true) return roster.roomsById[sessionId] ? null : h(EarlierParts, { sessionId, roster, actions, useUi }) // Group turns belong to the group chat; the member's own chat stays one-to-one. A // kickoff line was a setup prompt, never a message the parent Bot sent (#698). if (source.role === 'room' || source.role === 'kickoff') return null @@ -693,7 +701,7 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl if (source.notice === true || source.senderSessionId === 'face-notice') return null const room = roster.roomsById[sessionId] const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t('a Bot'), color: 'gray' } - return h(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi, openFile, fileImages }) + return h(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }) } if (source.role === 'report') { return h(GroupEvent, { prefix: t('Replies from'), room: roster.roomsById[source.roomId], name: t('a group chat'), roster, actions }) @@ -957,7 +965,7 @@ function useLiveNote(actions, sessionId, id, entry) { }, [sessionId, id, key]) } -export function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, openFile, fileImages }) { +export function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { const [live, setLive] = useState({ replies: [] }) useEffect(() => { if (done) return undefined @@ -993,13 +1001,13 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions // Another person's own line: their name and person mark beside a received bubble, // never the sent-bubble style of my own lines (#180, #229). if (reply.other) { - return h(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi, openFile, fileImages }) + return h(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi }) } // My own line in a face room has my person mark too, as everyone else's (#457). - return h('div', { key: `user-${index}`, className: 'bt-urow', 'data-author': reply.authorId, 'data-author-name': who }, + return h('div', { key: `user-${index}`, className: 'bt-urow', 'data-author': reply.authorId, 'data-author-name': who, onClickCapture: bubbleLinkClick({ actions, subject: sessionId }) }, who ? h('span', { className: 'bt-uauthor' }, who) : null, h('div', { className: 'bt-ububble' }, - bubbleMarkdown({ text: remoteMessageText(reply.text), actions, subject: reply.botId ?? sessionId, openFile, fileImages, labels: mdLabels(), variant: 'compact' })), + h(MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: 'compact' })), room?.origin === 'face-to-face' ? h('span', { className: 'bt-lead', title: who || undefined }, h(PersonMark, { name: who || undefined })) : null) } if (reply.kind === 'failed') { @@ -1037,8 +1045,6 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions const remote = reply.remote || (reply.handle && !roster.byId[reply.botId]) if (remote) { return h(RemoteMessage, { - openFile, - fileImages, key: `${reply.botId || reply.handle}-${index}`, source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, text: remoteMessageText(reply.text), @@ -1049,7 +1055,7 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions useUi, }) } - return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi, seq: reply.seq, openFile, fileImages }) + return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi, seq: reply.seq }) })) } @@ -1086,7 +1092,7 @@ function useShownCards(actions, sessionId, callIds) { }, [sessionId, key]) } -function renderCall(call, { sessionId, roster, actions, useUi, findBot, openFile, fileImages }) { +function renderCall(call, { sessionId, roster, actions, useUi, findBot }) { const tool = toolName(call) const done = call.kind === 'tool-result' const { text: arg, value: argValue } = toolArgs(call) @@ -1112,7 +1118,7 @@ function renderCall(call, { sessionId, roster, actions, useUi, findBot, openFile return done && target ? h(EventLine, { prefix: t('Updated'), bots: [target], onOpen: bot => actions.openSession(bot.id) }) : null } case 'group_relay': - return h(GroupReplies, { sessionId, callId: call.callId, done, content: call.content, roster, actions, useUi, openFile, fileImages }) + return h(GroupReplies, { sessionId, callId: call.callId, done, content: call.content, roster, actions, useUi }) case 'create_group': case 'update_group': case 'delete_group': @@ -1162,7 +1168,7 @@ function renderCall(call, { sessionId, roster, actions, useUi, findBot, openFile } } -export function ToolCell({ node, sessionId, useRoster, useUi, actions, openFile, fileImages }) { +export function ToolCell({ node, sessionId, useRoster, useUi, actions }) { const roster = useRoster(value => value) const root = node.data.root const turn = node.location?.turn?.turn ?? root.turn @@ -1177,6 +1183,6 @@ export function ToolCell({ node, sessionId, useRoster, useUi, actions, openFile, useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note) useShownCards(actions, sessionId, drawn.filter(call => CARDS.has(toolName(call)) && call.callId).map(call => call.callId)) if (drawn.length === 0) return null - const env = { sessionId, roster, actions, useUi, findBot, openFile, fileImages } + const env = { sessionId, roster, actions, useUi, findBot } return h(Fragment, null, drawn.map((call, index) => h(Fragment, { key: call.callId ?? index }, renderCall(call, env)))) } diff --git a/core/test/chat-link-open.test.js b/core/test/chat-link-open.test.js index cf4f2de91..92c20ab53 100644 --- a/core/test/chat-link-open.test.js +++ b/core/test/chat-link-open.test.js @@ -1,8 +1,17 @@ -// #2201: a link in a chat message was left to the host, which opens it in its own right sidebar — a -// browser tab FlyBot's fixed panel covers, so the content area looked blank and no error appeared. -// The link now opens a tab in FlyBot's own panel, the browser view the globe button already uses, and -// carries the URL there. Each case clicks the anchor MarkdownText draws and reads the action the page -// really calls; without the Desktop bridge there is no panel, so the page opens in the window itself. +// #2201 / #2319: what a pick inside a chat bubble does, and what the bubble must leave alone. +// +// #2201: a link left to the host opens in the host's right sidebar — a browser tab FlyBot's fixed panel +// covers, so the content area looked blank. The panel's own browser view carries the URL now, and a +// window without the Desktop bridge opens the page itself. +// +// #2319: the first fix wrapped every bubble in a Markdown delegate of its own, and a nested +// MarkdownDelegateProvider REPLACES the enclosing capabilities: the host's file opener and image +// resolver stopped reaching the bubble, so `![shot](screenshots/a.png)` drew the alt text `shot`, and a +// local `[x](path#L12)` link lost its chip. The harness below models that replacement semantics and +// renders an image through the resolver, which the earlier stub could not see. +// +// The click cases drive the capture handler the containers install, the way a browser dispatches one: +// capture on the way down, then the anchor's own handler when nothing stopped the event. import { test } from 'node:test' import assert from 'node:assert/strict' import { fileURLToPath } from 'node:url' @@ -10,28 +19,68 @@ import { runInNewContext } from 'node:vm' import { build } from 'esbuild' const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) -const all = (node, out = []) => { - if (Array.isArray(node)) { for (const child of node) all(child, out); return out } - if (node && typeof node === 'object') { out.push(node); for (const child of [node.props?.children].flat(Infinity)) all(child, out) } - return out -} -// The platform's Markdown scope: the nearest provider hands the link handler down, and the anchor -// MarkdownText draws calls it exactly as the real one does on a plain HTTP(S) click. -const delegate = { openExternalLink: undefined, openFile: undefined, fileImages: undefined } -function MarkdownDelegateProvider({ openExternalLink, openFile, fileImages, children }) { - Object.assign(delegate, { openExternalLink, openFile, fileImages }) - return children +// --------------------------------------------------------------------------- +// The platform's Markdown scope +// +// `MarkdownDelegateProvider` starts a context from exactly its own props: "Nested providers replace +// the enclosing capabilities" (dsh-client-ui-primitives). `scope` is that context; a provider element +// swaps it wholesale — never merges — so an inner provider that lacks `fileImages` hides the outer one, +// which is what #2313 did to every bubble. +const scope = { current: null } +function MarkdownDelegateProvider({ children }) { return children } +MarkdownDelegateProvider.__replacesScope = true + +// A DOM node is only needed for `event.target.closest(...)`; `mentionTarget` checks `instanceof Element`. +class Element {} +const anchorTarget = (href) => Object.assign(new Element(), { + href, + getAttribute: (name) => (name === 'href' ? href : null), + closest(selector) { + if (selector === 'a[href]') return this + const prefix = /^a\[href\^="(.*)"\]$/.exec(selector) + if (prefix && href.startsWith(prefix[1])) return this + return null + }, +}) +const plainTarget = () => Object.assign(new Element(), { closest: () => null }) + +// MarkdownText as the host renders it: an image resolves through the nearest `fileImages` (otherwise it +// falls back to the alt span), an http(s) destination becomes an anchor whose own handler is the host's +// `openExternalLink`, and anything else is a file link that needs `openFile` to become a chip. +function MarkdownImage({ destination, alt }) { + const fileImages = scope.current?.fileImages + const resolved = fileImages?.resolve?.(destination) + if (typeof resolved !== 'string') return h('span', { className: 'imageAlt' }, alt) + return h('img', { className: 'bt-md-image', src: resolved, alt, preview: fileImages }) +} +function MarkdownFileLink({ destination, children }) { + // The opener is read while the bubble renders, the way React hands a context value to a handler. + const openFile = scope.current?.openFile + if (typeof openFile !== 'function') return h('span', { className: 'bt-md-file-plain' }, children) + return h('span', { className: 'bt-md-file', onClick: (event) => { event?.preventDefault?.(); openFile(destination) } }, children) } -const LINK = /\]\((https?:\/\/[^)\s]+)\)/ function MarkdownText({ text }) { - const href = String(text ?? '').match(LINK)?.[1] - if (!href) return h('span', { className: 'bt-md', text }) - return h('a', { - className: 'bt-md-link', - href, - onClick: (event) => { event?.preventDefault?.(); delegate.openExternalLink?.(href) }, - }) + const source = String(text ?? '') + const token = /!\[([^\]]*)\]\(([^)\s]+)\)|\[([^\]]+)\]\(([^)\s]+)\)/g + const parts = [] + let last = 0 + for (let match = token.exec(source); match; match = token.exec(source)) { + if (match.index > last) parts.push(source.slice(last, match.index)) + if (match[1] !== undefined) parts.push(h(MarkdownImage, { destination: match[2], alt: match[1] })) + else { + const destination = match[4] + const openExternalLink = scope.current?.openExternalLink + // MarkdownAnchor: a modified or middle click is the browser's (new tab, new window), so the + // host delegate is not called and nothing is prevented. + parts.push(/^https?:/i.test(destination) + ? h('a', { className: 'bt-md-link', href: destination, onClick: (event) => { if (event?.button !== 0 || event?.metaKey || event?.ctrlKey || event?.shiftKey || event?.altKey) return; event?.preventDefault?.(); openExternalLink?.(destination) } }) + : h(MarkdownFileLink, { destination }, match[3])) + } + last = token.lastIndex + } + if (last < source.length) parts.push(source.slice(last)) + return h('span', { className: 'bt-md' }, ...parts) } // One state slot set per component path, effects only when their deps change, setters mark the tree @@ -79,6 +128,7 @@ const bundle = await build({ const opened = [] const env = { module: { exports: {} }, + Element, document: { body: {}, hidden: false, addEventListener() {}, removeEventListener() {} }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, @@ -92,26 +142,102 @@ env.require = (name) => { throw new Error(`Unexpected platform module ${name}`) } runInNewContext(bundle.outputFiles[0].text, env) -const { Bubbles } = env.module.exports +const { Bubbles, GroupReplies, TriggerCell } = env.module.exports -// Function components run the way React runs them, so the provider and the bubble see their props. +// Function components run the way React runs them, so the provider and the bubble see their props. A +// provider element replaces the scope for the children it renders and restores it afterwards. const expand = (node, path = 'r') => { if (node === null || node === undefined || typeof node === 'boolean') return null if (Array.isArray(node)) return node.map((child, at) => expand(child, `${path}.${at}`)) if (typeof node !== 'object') return node - if (typeof node.type === 'function') return withInstance(path, () => expand(node.type(node.props), path)) + if (typeof node.type === 'function') { + if (node.type.__replacesScope) { + const outer = scope.current + scope.current = { openExternalLink: node.props.openExternalLink, openFile: node.props.openFile, fileImages: node.props.fileImages } + try { return expand(node.props.children, `${path}.scope`) } finally { scope.current = outer } + } + return withInstance(`${path}:${node.type.name || 'anon'}`, () => expand(node.type(node.props), path)) + } return { ...node, props: { ...node.props, children: expand(node.props.children, path) } } } -const clickLink = (tree) => { - const anchor = all(tree).find(node => node.type === 'a') - assert.ok(anchor, 'the bubble draws the link as an anchor') - anchor.props.onClick({ preventDefault() {} }) - return anchor.props.href + +const runQueuedEffects = () => { + for (const inst of runtime.instances.values()) { + for (const entry of inst.pending.splice(0)) { + const previous = inst.deps.get(entry.index) + const deps = entry.deps + const changed = !previous || !deps || previous.length !== deps.length || deps.some((value, at) => value !== previous[at]) + if (!changed) continue + inst.deps.set(entry.index, deps) + entry.run() + } + } +} + +// Render, let the effects of that render run, wait for the page they ask for, render again: what a +// mounted component does over two paints, without a DOM. +const settle = async (node, delegate, rounds = 6) => { + let tree = withHost(node, delegate) + for (let round = 0; round < rounds; round++) { + runQueuedEffects() + await new Promise((resolve) => setImmediate(resolve)) + tree = withHost(node, delegate) + } + return tree +} + +// The host wraps the node list in its own provider: that is where `openFile` and `fileImages` live, +// and the host never hands `fileImages` to the node itself (#2319). +const withHost = (node, delegate) => { + const outer = scope.current + scope.current = delegate + try { return expand(node) } finally { scope.current = outer } +} + +const find = (node, match, parents = []) => { + if (Array.isArray(node)) { + for (const child of node) { const found = find(child, match, parents); if (found) return found } + return null + } + if (!node || typeof node !== 'object') return null + if (match(node)) return { node, parents } + for (const child of [node.props?.children].flat(Infinity)) { + const found = find(child, match, [...parents, node]) + if (found) return found + } + return null +} +// A browser dispatches a click down the tree (capture) and then to the target, unless a capture +// handler stopped it. Only the left button without modifiers reaches the target's own handler. +const clickOn = (tree, targetNode, { button = 0, metaKey = false, ctrlKey = false, shiftKey = false, altKey = false } = {}) => { + const found = find(tree, (node) => node === targetNode) + assert.ok(found, 'the clicked node is in the rendered tree') + const event = { + target: targetNode.type === 'a' && typeof targetNode.props?.href === 'string' ? anchorTarget(targetNode.props.href) : plainTarget(), + button, metaKey, ctrlKey, shiftKey, altKey, + defaultPrevented: false, stopped: false, + preventDefault() { this.defaultPrevented = true }, + stopPropagation() { this.stopped = true }, + } + for (const parent of found.parents) { + const handler = parent.props?.onClickCapture + if (typeof handler !== 'function') continue + handler(event) + if (event.stopped) return event + } + const onClick = targetNode.props?.onClick + if (button === 0 && typeof onClick === 'function') onClick(event) + return event +} +const anchorIn = (tree) => { + const found = find(tree, (node) => node.type === 'a') + assert.ok(found, 'the bubble draws the link as an anchor') + return found.node } // The panel the way index.js drives it: browserNewTab puts a tab in the Bot's strip and moves the // panel to the browser view (null when the Bot is not in the roster), browserNavigate loads the tab. -const panelHarness = () => { +const panelHarness = (roster = ROSTER) => { const panel = { details: null, view: 'details', renaming: false, tab: null } const tabs = new Map() const calls = [] @@ -121,7 +247,7 @@ const panelHarness = () => { useUi: (read) => read(panel), browserNewTab: (id) => { calls.push(['tab', id]) - if (!ROSTER.byId[id]) return null + if (!roster.byId[id]) return null const tabId = `t${++next}` tabs.set(tabId, null) Object.assign(panel, { details: id, view: 'browser', renaming: false, tab: null }) @@ -135,18 +261,29 @@ const panelHarness = () => { }, } } - const ROSTER = { bots: [], byId: { b1: { id: 'b1', name: 'FlyBot', color: 'blue' } }, roomsById: {} } +// The host's own delegate: what the outer provider carried before #2313 and carries again after #2319. +const hostScope = ({ openFile, fileImages } = {}) => { + const external = [] + return { external, openFile, fileImages, delegate: { openExternalLink: (href) => external.push(href), openFile, fileImages } } +} + +// --------------------------------------------------------------------------- +// #2201: the page opens in FlyBot's own panel test('#2201: a link in an assistant bubble opens FlyBot\'s own panel with the URL', () => { const p = panelHarness() const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate, toggleReaction() {} } - const tree = expand(h(Bubbles, { texts: ['see [the issue](https://github.com/o/r/issues/2) for detail'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi })) - const href = clickLink(tree) + const host = hostScope({}) + const tree = withHost(h(Bubbles, { texts: ['see [the issue](https://github.com/o/r/issues/2) for detail'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi }), host.delegate) + const anchor = anchorIn(tree) + const href = anchor.props.href + clickOn(tree, anchor) assert.deepEqual(p.calls, [['tab', 'b1'], ['navigate', 't1', href]], 'the link opens a tab in FlyBot\'s panel and carries the URL there') assert.equal(p.tabs.get('t1'), href, 'the tab shows the URL the link pointed at') assert.equal(p.panel.view, 'browser', 'the panel is on the browser view, not the details panel that covered the page') assert.equal(p.panel.details, 'b1', 'the panel is showing the Bot whose chat the link was in') + assert.deepEqual(host.external, [], 'the host never sees a page link that FlyBot opened') }) test('#2201: the click does not depend on the details panel being open or closed', () => { @@ -155,8 +292,8 @@ test('#2201: the click does not depend on the details panel being open or closed const p = panelHarness() Object.assign(p.panel, { details, view: 'details' }) const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } - const tree = expand(h(Bubbles, { texts: [`[x](${url})`], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi })) - clickLink(tree) + const tree = withHost(h(Bubbles, { texts: [`[x](${url})`], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi }), hostScope({}).delegate) + clickOn(tree, anchorIn(tree)) assert.deepEqual(p.calls, [['tab', 'b1'], ['navigate', 't1', url]], `the panel opens the same way with details ${details ? 'open' : 'closed'}`) assert.equal(p.panel.view, 'browser', 'the view the person lands on is the browser view either way') assert.equal(p.tabs.get('t1'), url) @@ -167,13 +304,15 @@ test('#2201: a Bot outside the roster gets no tab and the page opens in the wind opened.length = 0 const p = panelHarness() const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } - const tree = expand(h(Bubbles, { texts: ['[x](https://example.com/b)'], roster: ROSTER, selfId: 'ghost', actions, sessionId: 'ghost', useUi: p.useUi })) + const host = hostScope({}) + const tree = withHost(h(Bubbles, { texts: ['[x](https://example.com/b)'], roster: ROSTER, selfId: 'ghost', actions, sessionId: 'ghost', useUi: p.useUi }), host.delegate) let href - assert.doesNotThrow(() => { href = clickLink(tree) }, 'a missing tab falls back instead of throwing') + assert.doesNotThrow(() => { href = anchorIn(tree).props.href; clickOn(tree, anchorIn(tree)) }, 'a missing tab falls back instead of throwing') assert.deepEqual(p.calls, [['tab', 'ghost']], 'no navigation is attempted when browserNewTab answers null') assert.equal(p.panel.view, 'details', 'the panel is left where it was') // Exactly one window: a fallback here and another in the host would open the page twice. - assert.deepEqual(opened, [[href, '_blank', 'noopener']], 'the page opens in the window exactly once') + assert.deepEqual(opened, [[href, '_blank', 'noopener,noreferrer']], 'the page opens in the window exactly once') + assert.deepEqual(host.external, [], 'the host does not open a second copy') }) test('#2201: a room bubble targets the Bot that spoke, not the room id', () => { @@ -182,8 +321,10 @@ test('#2201: a room bubble targets the Bot that spoke, not the room id', () => { opened.length = 0 const p = panelHarness() const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } - const tree = expand(h(Bubbles, { texts: ['[x](https://example.com/room)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'room-1', useUi: p.useUi })) - const href = clickLink(tree) + const tree = withHost(h(Bubbles, { texts: ['[x](https://example.com/room)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'room-1', useUi: p.useUi }), hostScope({}).delegate) + const anchor = anchorIn(tree) + const href = anchor.props.href + clickOn(tree, anchor) assert.deepEqual(p.calls, [['tab', 'b1'], ['navigate', 't1', href]], 'the tab opens for the Bot whose message carried the link') assert.equal(p.tabs.get('t1'), href, 'the tab carries the URL') assert.deepEqual(opened, [], 'nothing falls back to the window when the panel opened the link') @@ -191,32 +332,179 @@ test('#2201: a room bubble targets the Bot that spoke, not the room id', () => { test('#2201: without the Desktop bridge the page opens in the window itself', () => { opened.length = 0 - const actions = {} - const tree = expand(h(Bubbles, { texts: ['[x](https://example.com/a)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: (read) => read({ details: null, view: 'details' }) })) - const href = clickLink(tree) - assert.deepEqual(opened, [[href, '_blank', 'noopener']], 'the link opens in the window, exactly once') + const tree = withHost(h(Bubbles, { texts: ['[x](https://example.com/a)'], roster: ROSTER, selfId: 'b1', actions: {}, sessionId: 'b1', useUi: (read) => read({ details: null, view: 'details' }) }), hostScope({}).delegate) + const anchor = anchorIn(tree) + const href = anchor.props.href + clickOn(tree, anchor) + assert.deepEqual(opened, [[href, '_blank', 'noopener,noreferrer']], 'the link opens in the window, exactly once') +}) + +// --------------------------------------------------------------------------- +// #2319: the bubble keeps the host's file resolver and opener + +test('#2319: a bubble image resolves through the host and is not drawn as its alt text', () => { + // The host gives `fileImages` to its own provider, never to the node (#2319 root cause). A bubble + // that starts a delegate without it replaces the resolver with `undefined` and MarkdownImage falls + // back to . This case is red on e40cecf6 and green once the bubble is bare. + const fileImages = { resolve: (path) => `/media/${path}`, labels: { open: 'Open', close: 'Close' } } + const host = hostScope({ fileImages }) + const tree = withHost(h(Bubbles, { texts: ['![shot](screenshots/a.png)'], roster: ROSTER, selfId: 'b1', actions: {}, sessionId: 'b1', useUi: (read) => read({}) }), host.delegate) + const image = find(tree, (node) => node.type === 'img')?.node + assert.ok(image, 'the image renders as an image, not as the alt span') + assert.equal(image.props.src, '/media/screenshots/a.png', 'the source comes from the host resolver') + assert.equal(image.props.preview, fileImages, 'the lightbox opens through the same host resolver') + assert.equal(find(tree, (node) => node.type === 'span' && node.props?.className === 'imageAlt'), null, 'nothing was drawn as alt text') }) -test('#2201: the bubble keeps the host\'s file opener and image resolver', () => { - // The nearest MarkdownDelegateProvider replaces the enclosing capabilities, so a provider that - // carried only openExternalLink dropped openFile (a local `[x](path#L12)` link rendered as plain - // text instead of a chip) and fileImages (local images). Bubbles is where the host hands them - // over; the delegate it builds must carry both on to MarkdownText. - const openFile = (path) => { throw new Error(`not called here: ${path}`) } - const fileImages = { resolve: (path) => `/media/${path}`, labels: { open: 'Open', loading: 'Loading', failed: 'Failed', dialog: 'Image', close: 'Close' } } +test('#2319: a local file link keeps the host\'s chip and its opener', () => { + opened.length = 0 + const openedFiles = [] + const host = hostScope({ openFile: (path) => openedFiles.push(path) }) const p = panelHarness() const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } - expand(h(Bubbles, { texts: ['[x](https://example.com/a)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi, openFile, fileImages })) - assert.equal(delegate.openFile, openFile, 'the delegate carries the host\'s openFile, so local file links stay chips') - assert.equal(delegate.fileImages, fileImages, 'the delegate carries the host\'s fileImages, so local images keep previewing') + const tree = withHost(h(Bubbles, { texts: ['[cells](core/src/client/cells.js#L12)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi }), host.delegate) + const chip = find(tree, (node) => node.type === 'span' && node.props?.className === 'bt-md-file')?.node + assert.ok(chip, 'the host openFile draws the file chip; without it the link is plain text') + clickOn(tree, chip) + assert.deepEqual(openedFiles, ['core/src/client/cells.js#L12'], 'the click reaches the host file opener') + assert.deepEqual(p.calls, [], 'a file is not a page: FlyBot\'s panel is not touched') + assert.deepEqual(opened, [], 'and nothing opens in the window') +}) + +// --------------------------------------------------------------------------- +// #2319: clicks the bubble must leave alone + +test('#2319: a modifier or middle click is left to the host', () => { + const cases = [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }] + for (const options of cases) { + opened.length = 0 + const p = panelHarness() + const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } + const host = hostScope({}) + const tree = withHost(h(Bubbles, { texts: ['[x](https://example.com/a)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi }), host.delegate) + const event = clickOn(tree, anchorIn(tree), options) + const label = Object.keys(options).join('+') + assert.deepEqual(p.calls, [], `${label}: FlyBot's panel is not involved`) + assert.deepEqual(opened, [], `${label}: FlyBot opens no window`) + // The click is the browser's: the bubble neither prevents it nor hands it to the host delegate + // (MarkdownAnchor ignores a modified or middle click itself, like the platform round it models). + assert.equal(event.defaultPrevented, false, `${label}: the bubble does not claim the click`) + assert.deepEqual(host.external, [], `${label}: the host delegate is not asked to open it`) + } }) -test('#2201: a tab with no navigation falls back to the window instead of doing nothing', () => { +test('#2319: a mention still opens the Bot part and is not taken for a page', () => { + // A mention is an https link to the origin the plugin rewrites names into, so the page rule must not + // swallow it: the capture handler opens the part, as it did before the link fix. + const roster = { bots: [{ id: 'b2', name: 'Chief', color: 'blue' }], byId: { b1: { id: 'b1', name: 'FlyBot', color: 'blue' }, b2: { id: 'b2', name: 'Chief', color: 'blue' } }, roomsById: {} } opened.length = 0 - const p = panelHarness() - const actions = { browserNewTab: p.browserNewTab } - const tree = expand(h(Bubbles, { texts: ['[x](https://example.com/c)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi })) - let href - assert.doesNotThrow(() => { href = clickLink(tree) }, 'a tab without browserNavigate does not throw') - assert.deepEqual(opened, [[href, '_blank', 'noopener']], 'the page still opens, exactly once') + const sessions = [] + const actions = { openSession: (id) => sessions.push(id) } + const host = hostScope({}) + const tree = withHost(h(Bubbles, { texts: ['ask @Chief'], roster, selfId: 'b1', actions, sessionId: 'b1', useUi: (read) => read({}) }), host.delegate) + const mention = find(tree, (node) => node.type === 'a' && String(node.props?.href ?? '').startsWith('https://bots.local/'))?.node + assert.ok(mention, 'a mention is drawn as an anchor') + clickOn(tree, mention) + assert.deepEqual(sessions, ['b2'], 'the mention opens the Bot part') + assert.deepEqual(host.external, [], 'the host delegate never sees a mention') + assert.deepEqual(opened, [], 'a mention is not a page') +}) + +test('#2319: the group replies card keeps its own button clickable', () => { + // GroupReplies gains a capture handler for links; its own controls must still receive a click that + // has no anchor in it (the Lead's guard against swallowing button presses). + opened.length = 0 + const details = [] + const actions = { showDetails: (id) => details.push(id), roomProgress: async () => [] } + const content = [{ type: 'text', text: JSON.stringify({ replies: [{ kind: 'working', botId: 'b1', name: 'FlyBot', text: 'working' }] }) }] + const tree = withHost(h(GroupReplies, { sessionId: 'b1', callId: 'c1', done: true, content, roster: ROSTER, actions, useUi: (read) => read({}) }), hostScope({}).delegate) + const button = find(tree, (node) => node.type === 'button')?.node + assert.ok(button, 'the reply card draws its button') + clickOn(tree, button) + assert.deepEqual(details, ['b1'], 'the capture handler leaves a button press alone') + assert.deepEqual(opened, [], 'and opens nothing') +}) + +// --------------------------------------------------------------------------- +// #2319 review: the click belongs to the line the person clicked + +test('#2319: a link in another Bot\'s reply opens that Bot\'s tab, not the room', () => { + // GroupReplies puts one capture handler on the card in the first cut of the fix; it claimed every + // anchor with the room as the subject, so a reply's own link fell to the window instead of opening + // the tab of the Bot that spoke. The Bot's own bubble carries the handler now. + opened.length = 0 + const roster = { bots: [{ id: 'b2', name: 'Chief', color: 'blue' }], byId: { b1: { id: 'b1', name: 'FlyBot', color: 'blue' }, b2: { id: 'b2', name: 'Chief', color: 'blue' } }, roomsById: {} } + const p = panelHarness(roster) + const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } + const url = 'https://example.com/reply' + const content = [{ type: 'text', text: JSON.stringify({ replies: [{ kind: 'bot', botId: 'b2', name: 'Chief', text: `see [the page](${url})` }] }) }] + const host = hostScope({}) + const tree = withHost(h(GroupReplies, { sessionId: 'room-1', callId: 'c1', done: true, content, roster, actions, useUi: p.useUi }), host.delegate) + clickOn(tree, anchorIn(tree)) + assert.deepEqual(p.calls, [['tab', 'b2'], ['navigate', 't1', url]], 'the tab belongs to the Bot whose reply carried the link') + assert.equal(p.tabs.get('t1'), url, 'the tab carries the URL') + assert.deepEqual(opened, [], 'the page does not fall back to a bare window') + assert.deepEqual(host.external, [], 'the host delegate never sees it') +}) + +test('#2319: a link on the person\'s own line in a group card is still claimed', () => { + // The capture handler moved from the card onto this row; a row with no handler would leave the link + // to the host, which opens it in the sidebar the panel covers (#2201). + opened.length = 0 + const roster = { bots: [], byId: { b1: { id: 'b1', name: 'FlyBot', color: 'blue' } }, roomsById: {} } + const p = panelHarness(roster) + const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } + const url = 'https://example.com/mine' + const content = [{ type: 'text', text: JSON.stringify({ replies: [{ kind: 'user', authorId: 'u1', name: 'Ann', text: `[mine](${url})` }] }) }] + const host = hostScope({}) + const tree = withHost(h(GroupReplies, { sessionId: 'room-1', callId: 'c1', done: true, content, roster, actions, useUi: p.useUi }), host.delegate) + const row = find(tree, (node) => node.type === 'div' && node.props?.className === 'bt-urow')?.node + assert.ok(row, 'the card draws the person\'s own line') + const event = clickOn(tree, find(row, (node) => node.type === 'a')?.node) + assert.equal(event.defaultPrevented, true, 'the row claims the click, as the card did before') + assert.equal(event.stopped, true, 'and nothing else sees it') + assert.deepEqual(opened, [[url, '_blank', 'noopener,noreferrer']], 'the page opens once, in the window') + assert.deepEqual(host.external, [], 'the host never sees it') +}) + +test('#2319: an earlier part claims its own lines and leaves a Bot line to that Bot', async () => { + // EarlierParts is not exported; a handoff line mounts it through TriggerCell. Both kinds of row use + // the same subject here, so the point is that a link on either one is still claimed by this page and + // never falls to the host (the handler moved from the page container onto the person's row). + opened.length = 0 + const roster = { bots: [{ id: 'b1', name: 'FlyBot', color: 'blue' }], byId: { b1: { id: 'b1', name: 'FlyBot', color: 'blue' } }, roomsById: {}, exchangeTurns: {} } + const p = panelHarness(roster) + const mine = 'https://example.com/earlier-u' + const bot = 'https://example.com/earlier-b' + const page = { + items: [ + { kind: 'user', text: `[mine](${mine})`, sessionId: 'b1', turn: 1, seq: 1, time: 1 }, + { kind: 'bot', text: `[bot](${bot})`, sessionId: 'b1', turn: 1, seq: 2, time: 2 }, + ], + cursor: undefined, + startedAt: 1, + } + const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate, earlier: () => Promise.resolve(page), noteTurnPeer() {} } + const node = h(TriggerCell, { + node: { data: { source: { kind: 'bot', handoff: true }, turn: 4, content: [] } }, + sessionId: 'b1', + useRoster: (read) => read(roster), + useSessions: (read) => read({ projectionsBySession: {}, byId: {} }), + useTurnClock: (read) => read({}), + useUi: p.useUi, + actions, + }) + const host = hostScope({}) + const tree = await settle(node, host.delegate) + const userRow = find(tree, (candidate) => candidate.type === 'div' && candidate.props?.className === 'bt-urow')?.node + assert.ok(userRow, 'the earlier part draws the person\'s own line') + const mineEvent = clickOn(tree, find(userRow, (candidate) => candidate.type === 'a')?.node) + assert.equal(mineEvent.defaultPrevented, true, 'a link on the person\'s line is claimed') + const botAnchor = find(tree, (candidate) => candidate.type === 'a' && candidate.props?.href === bot)?.node + assert.ok(botAnchor, 'the earlier part draws the Bot\'s line as a link') + const botEvent = clickOn(tree, botAnchor) + assert.equal(botEvent.defaultPrevented, true, 'a link in the Bot\'s line is claimed by its bubble') + assert.deepEqual(p.calls, [['tab', 'b1'], ['navigate', 't1', mine], ['tab', 'b1'], ['navigate', 't2', bot]], 'each link opens the part\'s own tab') + assert.deepEqual(host.external, [], 'the host delegate never sees either link') + assert.deepEqual(opened, [], 'nothing falls back to a bare window') }) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index b833d7254..9e22b2759 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -18,7 +18,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl - Chat context rows: a tool the plugin registers at install draws one `tool-addition` row, and `cells.js` keeps every name in `TEAM_TOOLS` out of the chat (`SILENT_REGISTRATION_TOOLS`, #1101) — the Project Assistant's `project_digest` joins them (#1712), so its 09:00 Session shows the digest, not its registration. - A Settings dialog takes focus when it opens (`useDialogFocus` in `core/src/client/overlay-hooks.js`; the `.bt-settings` dialog carries `tabIndex -1` and a ref) and gives it back to the still-connected opener when it closes; it is not a focus trap, so Tab can still leave the dialog for the page behind the scrim (#2251). It is drawn for `DIALOG_EXIT_MS` (160 ms) with `pointer-events: none` on the leaving layer, so the unmount restores focus only while the dialog still holds it (or nothing does) — a click on the page during that window keeps its focus (#2270). - Settings → Feedback's error list (`feedback.js`) still titles the block with the whole log (`Recent errors · 60`) but lists only the newest eight, so when the report holds more rows one `Show more` control (`aria-expanded`) sits under them and draws every loaded error, folding back to `Show less`; a report of eight or fewer draws no control (#2266). -- A link picked in a chat message opens in FlyBot's own panel: `cells.js` scopes the bubble's Markdown in `MarkdownDelegateProvider`, whose `openExternalLink` puts a tab in the panel's browser view (the globe button's view) with `actions.browserNewTab` and carries the URL with `actions.browserNavigate`; `browserNewTab` answers `null` for a Bot outside the roster and the navigation is skipped; without the Desktop bridge there is no panel and the page opens in the window itself. Left to the host the click opened a tab in the host's right sidebar, which this panel's fixed layer covers, so the content area looked blank (#2201). Tests: `core/test/chat-link-open.test.js`. +- A link picked in a chat message opens in FlyBot's own panel: a capture-phase click handler on each bubble and on the person's own line (`bubbleLinkClick` in `cells.js`; a group card's container carries none, so a reply's link opens the tab of the Bot that spoke) takes an `http(s)` anchor before the host's delegate does, puts a tab in the panel's browser view (the globe button's view) with `actions.browserNewTab` and carries the URL with `actions.browserNavigate`; `browserNewTab` answers `null` for a Bot outside the roster, and with no tab — or no Desktop bridge — the page opens in the window itself. A mention, a file link and a modified or middle click are left alone, and the bubble renders bare `MarkdownText` so the host's `openFile`/`fileImages` reach it: a nested `MarkdownDelegateProvider` replaces the host's capabilities, and one that lacked `fileImages` drew every bubble image as its alt text (#2319). Left to the host a click opened a tab in the host's right sidebar, which this panel's fixed layer covers, so the content area looked blank (#2201). Tests: `core/test/chat-link-open.test.js`. ## Does not own diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index a428df6b4..e046de78c 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -478,14 +478,12 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js diff --git a/src/client/cells.js b/src/client/cells.js --- a/src/client/cells.js +++ b/src/client/cells.js -@@ -1,13 +1,21 @@ - import { createElement as h, Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +@@ -2,12 +2,20 @@ import { createElement as h, Fragment, useEffect, useLayoutEffect, useMemo, useR import { createPortal } from 'react-dom' --import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' -+import { MarkdownText, MarkdownDelegateProvider } from '@deepseek-ai/dsh-client-ui-primitives' + import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import { inkText } from './inks.js' -import { linkMentions, mentionTarget, dayLabel, contentText, stripHeader, isHiddenTurn, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts } from './text.js' -+import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, heldReplyOf, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts, isPluginTool } from './text.js' ++import { currentPart, linkMentions, mentionTarget, MENTION_ORIGIN, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, heldReplyOf, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts, isPluginTool } from './text.js' import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js' -import { colorOf, toolState, BotMark, RoomAvatar } from './mark.js' +import { colorOf, lookBotForRemote, toolState, BotMark, PersonMark, RoomAvatar } from './mark.js' @@ -503,43 +501,51 @@ diff --git a/src/client/cells.js b/src/client/cells.js import { failureLabel } from './diagnostics.js' import { turnReasoning } from './turn-reasoning.js' -@@ -16,9 +16,34 @@ import { turnReasoning } from './turn-reasoning.js' +@@ -16,37 +16,101 @@ import { turnReasoning } from './turn-reasoning.js' const mdLabels = () => ({ code: { copyLabel: t('Copy'), copiedLabel: t('Copied') }, footnotes: t('Footnotes') }) -+// #2201: a link a person picks inside a bubble belongs to FlyBot's own panel. Left alone the host opens -+// it in its right sidebar — a browser tab this panel's fixed layer covers, so the content area looks -+// blank — and nothing in FlyBot ever sees the click. The link therefore opens a tab in the panel's own -+// browser view (the one the globe button opens) carrying the URL; without the Desktop bridge there is -+// no panel at all, and the page opens in the window itself. An element builder, not a component: the -+// provider sits directly above the text so the bubble keeps one Markdown node, as before. -+function bubbleMarkdown({ actions, subject, openFile, fileImages, ...markdown }) { -+ const openExternalLink = (href) => { ++// #2201: a link a person picks inside a bubble belongs to FlyBot's own panel. Left to the host it ++// opens in the host's right sidebar — a browser tab this panel's fixed layer covers, so the content ++// area looks blank — and FlyBot never sees the click. A capture handler on the bubble's container ++// takes the anchor before the host's delegate does: an http(s) link opens a tab in the panel's own ++// browser view (the one the globe button opens), and without the Desktop bridge the page opens in ++// the window itself. Everything else — no anchor, a middle or modified click, a mention, a file ++// link — falls through to the handler that already owns it, the host's included. ++function bubbleLinkClick({ actions, subject }) { ++ return (event) => { ++ if (!event || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false ++ const anchor = event.target?.closest?.('a[href]') ++ if (!anchor) return false ++ const href = anchor.getAttribute?.('href') ++ // A mention is an https link to the fake origin the plugin rewrites names into; the click ++ // handler that owns mentions opens the Bot's part for it, so it must not be taken for a page. ++ if (typeof href !== 'string' || href.startsWith(MENTION_ORIGIN) || !/^https?:/i.test(href)) return false ++ event.preventDefault() ++ event.stopPropagation() + const tabId = typeof actions?.browserNewTab === 'function' ? actions.browserNewTab(subject) : null + if (tabId !== undefined && tabId !== null && typeof actions.browserNavigate === 'function') { + void actions.browserNavigate(tabId, href) -+ return ++ return true + } -+ // No tab for this link — a room id, a Bot outside the roster, no bridge, or no navigation — -+ // so the page opens in the window itself. Exactly one window here: opening in both places -+ // would show two tabs (#2201 review). -+ if (typeof window !== 'undefined' && typeof window.open === 'function') window.open(href, '_blank', 'noopener') ++ // No tab for this link — a Bot outside the roster, no bridge, or no navigation — so the page ++ // opens in the window itself. Exactly one window here: opening in both places would show two ++ // tabs (#2201 review). ++ if (typeof window !== 'undefined' && typeof window.open === 'function') window.open(href, '_blank', 'noopener,noreferrer') ++ return true + } -+ // The nearest provider *replaces* the enclosing Markdown scope (primitives: "Nested providers -+ // replace the enclosing capabilities"), so the host's file opener and image resolver have to -+ // ride with this one: a local `[x](path#L12)` link keeps its chip and a local image keeps -+ // previewing, as they did before the bubble got a delegate of its own. -+ return h(MarkdownDelegateProvider, { openExternalLink, openFile, fileImages }, h(MarkdownText, markdown)) +} + // `lead` (a sender avatar in group chats) sits beside the last bubble, in the same // row, so it lines up with the bubble whether or not reactions hang below it. -export function Bubbles({ texts, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { -+export function Bubbles({ texts, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions, openFile, fileImages }) { ++export function Bubbles({ texts, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions }) { ++ const openLink = bubbleLinkClick({ actions, subject: selfId ?? sessionId }) const onClick = (event) => { ++ if (openLink(event)) return const target = mentionTarget(event) if (target === null) return -@@ -26,27 +26,58 @@ export function Bubbles({ texts, roster, selfId, actions, streaming, sessionId, + event.preventDefault() event.stopPropagation() actions.openSession(target) } @@ -571,9 +577,8 @@ diff --git a/src/client/cells.js b/src/client/cells.js h('div', { className: 'bt-msg-line' }, lead && last ? lead : null, h('div', { className: 'bt-bubble' }, -- h(MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: 'compact', fileMentions: mentions })), + h(MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: 'compact', fileMentions: mentions })), - live ? null : h(MessageTools, { text, name: roster.byId[selfId]?.name ?? t('Bot'), sessionId, react: key ? emoji => actions.toggleReaction(msgKey, index, emoji) : undefined, actions })), -+ bubbleMarkdown({ text: linkMentions(text, roster.bots, selfId), actions, subject: selfId ?? sessionId, openFile, fileImages, streaming: live, labels: mdLabels(), variant: 'compact', fileMentions: mentions })), + live ? null : h(MessageTools, { text, name: roster.byId[selfId]?.name ?? t('Bot'), sessionId, react: key ? emoji => actions.toggleReaction(key, reactIndex(index), emoji) : undefined, actions })), picked.length ? h('div', { className: 'bt-reacts' }, picked.map(emoji => h('button', { - key: emoji, type: 'button', className: 'bt-react', 'aria-label': t('Remove reaction {emoji}', { emoji }), onClick: () => actions.toggleReaction(msgKey, index, emoji), @@ -657,7 +662,6 @@ diff --git a/src/client/cells.js b/src/client/cells.js }, [fileMentions, openFile, seq, tail, turn]) } --export function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { +// Whether this text is the one that closes its turn (no turn data: assume it is). +function useClosingText(node, useTurnData) { + const tail = useTurnData?.('turn-tail') @@ -675,7 +679,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js + return h('span', { className: 'bt-lead', title: bot.name, 'data-flybot': 'friend-lead' }, h(BotMark, { bot, size: 22 })) +} + -+export function AssistantCell({ node, groupPart, sessionId, openFile, fileImages, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { + export function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { const roster = useRoster(value => value) const data = node.data const steps = useTurnSteps(String(data.turn)) @@ -695,8 +699,8 @@ diff --git a/src/client/cells.js b/src/client/cells.js if (texts.length === 0 && !reasoning && relays.length === 0) return null const selfId = roster.byId[sessionId]?.id ?? sessionId + const bubbles = texts.length ? (person -+ ? h(PersonMessage, { name: person, texts, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi, openFile, fileImages }) -+ : h(Bubbles, { texts, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts), openFile, fileImages })) : null ++ ? h(PersonMessage, { name: person, texts, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi }) ++ : h(Bubbles, { texts, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts) })) : null return h(Fragment, null, relays.map(relay => { const source = relay.source @@ -725,17 +729,17 @@ diff --git a/src/client/cells.js b/src/client/cells.js +// A friend's line that Bot Social attests the friend typed themselves, or another person's own +// line in a face-to-face room: the person's name and a person mark, not the Bot's (the host marks +// the turn, remote-chat.js / turns.js, or posts it as a person line, hooks.js postHuman). -+function PersonMessage({ name, texts, roster, selfId, actions, sessionId, msgKey, useUi, openFile, fileImages }) { ++function PersonMessage({ name, texts, roster, selfId, actions, sessionId, msgKey, useUi }) { + // #2123: the same rule as a Bot's line — text a person can read or no row (the name and mark go too). + if (!(texts ?? []).some(text => String(text ?? '').trim() !== '')) return null + const lead = h('span', { className: 'bt-lead', title: name }, h(PersonMark, { name })) + return h('div', { className: 'bt-group-msg bt-person-msg', 'data-person': name }, + h('span', { className: 'bt-group-name' }, name), -+ h(Bubbles, { texts, roster, selfId, actions, sessionId, msgKey, useUi, lead, openFile, fileImages })) ++ h(Bubbles, { texts, roster, selfId, actions, sessionId, msgKey, useUi, lead })) } function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { -@@ -170,14 +170,188 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { +@@ -170,16 +170,190 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { return h('div', { className: 'bt-event' }, parts) } @@ -780,7 +784,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js + // One Bot message inside a group chat: avatar, name (with its group role), bubble. -function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) { -+function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi, seq, openFile, fileImages }) { ++function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi, seq }) { + // #2123: the Bot posted no words (a turn that only called tools, a reply carried by a card): there is + // no line to draw, and drawing one leaves the avatar and an empty bubble under the trace row. + if (String(text ?? '').trim() === '') return null @@ -797,14 +801,13 @@ diff --git a/src/client/cells.js b/src/client/cells.js - return h('div', { className: 'bt-group-msg' }, - h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name, - room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null), -- h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead })) + // In a face room my Bot reads as everyone's does, "Chief (Ann)" (#457). + const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, faceBotLabel(room, roster, bot) ?? bot.name, + room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null) + return h('div', { className: 'bt-group-msg' }, name, -+ h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead, openFile, fileImages })) -+} -+ + h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead })) + } + +// A friend's remote Bot speaking in this chat, laid out like any chat partner: its own +// avatar (the one its sidebar row shows), its name, a small globe and its @handle. The +// words are shown as data; the Bot reading them still gets them fenced as untrusted. @@ -852,7 +855,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js + +const cleanCarrier = value => (typeof value === 'string' && value.trim() && !/[@()]/.test(value) ? value.trim() : '') + -+export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi, openFile, fileImages }) { ++export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { + const room = roster.roomsById?.[sessionId] + const face = room?.origin === 'face-to-face' + const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId) @@ -869,7 +872,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js + // One short header: the person, and the Bot that carried the line only when it is known (#275). + h('span', { className: 'bt-group-name', title: handle }, person, + carrier ? h('span', { className: 'bt-remote-handle' }, t('via {name}', { name: carrier })) : null), -+ String(words ?? '').trim() === '' ? null : h(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark, { name: person })), openFile, fileImages }), ++ String(words ?? '').trim() === '' ? null : h(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark, { name: person })) }), + files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null) + } + const mark = h(BotMark, { bot, size: 22 }) @@ -882,7 +885,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js + ? h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) }, title: handle }, name) + : h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, name, + h('span', { className: 'bt-remote-handle', title: source.remote.home ?? handle, 'aria-label': t('Friend\'s Bot') }, h(GlobeMark), handle)), -+ String(words ?? '').trim() === '' ? null : h(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead, openFile, fileImages }), ++ String(words ?? '').trim() === '' ? null : h(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null) +} + @@ -926,15 +929,17 @@ diff --git a/src/client/cells.js b/src/client/cells.js + files.map(file => h('button', { key: file.id, type: 'button', className: 'bt-file-chip', disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download(file) }, + `📎 ${file.name} · ${fileSize(file.size)}`)), + error ? h('span', { className: 'bt-file-error', role: 'alert' }, error) : null) - } - ++} ++ // A centered event line that names a group chat and opens it. + function GroupEvent({ prefix, room, name, roster, actions }) { + return h('div', { className: 'bt-event' }, @@ -216,7 +216,7 @@ const scrollParent = (node) => { // The earlier parts of a Bot's own chat, above its current part as one conversation. // Pages load from the host as the user scrolls up; a thin line marks where each // part begins, the last one where the current part begins. -function EarlierParts({ sessionId, roster, actions }) { -+function EarlierParts({ sessionId, roster, actions, useUi, openFile, fileImages }) { ++function EarlierParts({ sessionId, roster, actions, useUi }) { const [view, setView] = useState({ items: [], cursor: undefined, loading: true, failed: false, startedAt: null }) const top = useRef(null) const busy = useRef(false) @@ -965,29 +970,21 @@ diff --git a/src/client/cells.js b/src/client/cells.js } return h('div', { className: 'bt-earlier', role: 'feed', 'aria-label': t('Earlier messages'), 'aria-busy': view.loading }, h('div', { ref: top, className: 'bt-earlier-top' }, -@@ -276,10 +276,10 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -275,11 +275,11 @@ function EarlierParts({ sessionId, roster, actions }) { + const key = rows.length - index switch (row.kind) { case 'divider': return h(PartLine, { key, time: row.time }) - case 'user': return h('div', { key, className: 'bt-urow' }, h('div', { className: 'bt-ububble' }, -- row.text ? h(MarkdownText, { text: row.text, labels: mdLabels(), variant: 'compact' }) : null, -+ row.text ? bubbleMarkdown({ text: row.text, actions, subject: selfId ?? sessionId, openFile, fileImages, labels: mdLabels(), variant: 'compact' }) : null, +- case 'user': return h('div', { key, className: 'bt-urow' }, h('div', { className: 'bt-ububble' }, ++ case 'user': return h('div', { key, className: 'bt-urow', onClickCapture: bubbleLinkClick({ actions, subject: selfId ?? sessionId }) }, h('div', { className: 'bt-ububble' }, + row.text ? h(MarkdownText, { text: row.text, labels: mdLabels(), variant: 'compact' }) : null, row.images ? h('span', { className: 'bt-uimages' }, row.images === 1 ? t('1 image') : t('{count} images', { count: row.images })) : null)) case 'answer': return h(AnsweredCell, { key, node: { data: row } }) - case 'bot': return h(Bubbles, { key, texts: row.texts, roster, selfId, actions }) -+ case 'bot': return h(Bubbles, { key, texts: row.texts, msgKeys: row.msgKeys, roster, selfId, actions, sessionId, useUi, openFile, fileImages }) ++ case 'bot': return h(Bubbles, { key, texts: row.texts, msgKeys: row.msgKeys, roster, selfId, actions, sessionId, useUi }) case 'event': { if (row.role === 'report') return h(GroupEvent, { key, prefix: t('Replies from'), room: roster.roomsById[row.roomId], name: t('a group chat'), roster, actions }) const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t('a Bot'), color: 'gray' } -@@ -292,7 +292,7 @@ function EarlierParts({ sessionId, roster, actions }) { - h(PartLine, { time: view.startedAt })) - } - --export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -+export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions, openFile, fileImages }) { - const roster = useRoster(value => value) - const data = node.data - const source = data.source !== null && typeof data.source === 'object' ? data.source : {} -@@ -331,20 +331,34 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl +@@ -331,17 +331,31 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl : body function triggerBody() { @@ -996,7 +993,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js + // A line that arrived while a round was running is that round's bubble already (#455). + if (source.shown === true) return null + if (source.remote?.handle) { -+ const message = h(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi, openFile, fileImages }) ++ const message = h(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }) + return source.remote.firstContact === true ? h(Fragment, null, h(FirstContact, { source, roster, sessionId }), message) : message + } if (source.kind === 'bot') { @@ -1004,7 +1001,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js - if (source.handoff === true) return roster.roomsById[sessionId] ? null : h(EarlierParts, { sessionId, roster, actions }) - // Group turns belong to the group chat; the member's own chat stays one-to-one. - if (source.role === 'kickoff' || source.role === 'room') return null -+ if (source.handoff === true) return roster.roomsById[sessionId] ? null : h(EarlierParts, { sessionId, roster, actions, useUi, openFile, fileImages }) ++ if (source.handoff === true) return roster.roomsById[sessionId] ? null : h(EarlierParts, { sessionId, roster, actions, useUi }) + // Group turns belong to the group chat; the member's own chat stays one-to-one. A + // kickoff line was a setup prompt, never a message the parent Bot sent (#698). + if (source.role === 'room' || source.role === 'kickoff') return null @@ -1022,11 +1019,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js + if (source.notice === true || source.senderSessionId === 'face-notice') return null const room = roster.roomsById[sessionId] const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t('a Bot'), color: 'gray' } -- return h(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }) -+ return h(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi, openFile, fileImages }) - } - if (source.role === 'report') { - return h(GroupEvent, { prefix: t('Replies from'), room: roster.roomsById[source.roomId], name: t('a group chat'), roster, actions }) + return h(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }) @@ -411,6 +411,55 @@ export function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi export function Nothing() { return null } @@ -1128,7 +1121,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js -function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { - const [live, setLive] = useState({ replies: [], speaking: null }) -+export function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, openFile, fileImages }) { ++export function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { + const [live, setLive] = useState({ replies: [] }) useEffect(() => { if (done) return undefined @@ -1169,13 +1162,13 @@ diff --git a/src/client/cells.js b/src/client/cells.js + // Another person's own line: their name and person mark beside a received bubble, + // never the sent-bubble style of my own lines (#180, #229). + if (reply.other) { -+ return h(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi, openFile, fileImages }) ++ return h(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi }) + } + // My own line in a face room has my person mark too, as everyone else's (#457). -+ return h('div', { key: `user-${index}`, className: 'bt-urow', 'data-author': reply.authorId, 'data-author-name': who }, ++ return h('div', { key: `user-${index}`, className: 'bt-urow', 'data-author': reply.authorId, 'data-author-name': who, onClickCapture: bubbleLinkClick({ actions, subject: sessionId }) }, + who ? h('span', { className: 'bt-uauthor' }, who) : null, + h('div', { className: 'bt-ububble' }, -+ bubbleMarkdown({ text: remoteMessageText(reply.text), actions, subject: reply.botId ?? sessionId, openFile, fileImages, labels: mdLabels(), variant: 'compact' })), ++ h(MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: 'compact' })), + room?.origin === 'face-to-face' ? h('span', { className: 'bt-lead', title: who || undefined }, h(PersonMark, { name: who || undefined })) : null) } if (reply.kind === 'failed') { @@ -1184,7 +1177,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js t('{name} could not answer: {reason}', { name: bot.name, reason: failureLabel(reply.code, reply.text) })) } if (reply.kind === 'lookup') { -@@ -599,14 +599,29 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -599,14 +599,27 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi if (reply.kind === 'stopped') { return h('div', { key: `${reply.botId}-${index}`, className: 'bt-event' }, t("You stopped {name}'s work", { name: bot.name })) } @@ -1206,8 +1199,6 @@ diff --git a/src/client/cells.js b/src/client/cells.js + const remote = reply.remote || (reply.handle && !roster.byId[reply.botId]) + if (remote) { + return h(RemoteMessage, { -+ openFile, -+ fileImages, + key: `${reply.botId || reply.handle}-${index}`, + source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, + text: remoteMessageText(reply.text), @@ -1218,7 +1209,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js + useUi, + }) + } -+ return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi, seq: reply.seq, openFile, fileImages }) ++ return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi, seq: reply.seq }) })) } @@ -1227,7 +1218,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js } -function renderCall(call, { sessionId, roster, actions, useUi, useWork, findBot }) { -+function renderCall(call, { sessionId, roster, actions, useUi, findBot, openFile, fileImages }) { ++function renderCall(call, { sessionId, roster, actions, useUi, findBot }) { const tool = toolName(call) const done = call.kind === 'tool-result' const { text: arg, value: argValue } = toolArgs(call) @@ -1236,7 +1227,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js } case 'group_relay': - return h(GroupReplies, { sessionId, callId: call.callId, done, content: call.content, roster, actions, useUi, useWork }) -+ return h(GroupReplies, { sessionId, callId: call.callId, done, content: call.content, roster, actions, useUi, openFile, fileImages }) ++ return h(GroupReplies, { sessionId, callId: call.callId, done, content: call.content, roster, actions, useUi }) case 'create_group': case 'update_group': case 'delete_group': @@ -1254,7 +1245,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js } -export function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { -+export function ToolCell({ node, sessionId, useRoster, useUi, actions, openFile, fileImages }) { ++export function ToolCell({ node, sessionId, useRoster, useUi, actions }) { const roster = useRoster(value => value) const root = node.data.root const turn = node.location?.turn?.turn ?? root.turn @@ -1269,7 +1260,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js useShownCards(actions, sessionId, drawn.filter(call => CARDS.has(toolName(call)) && call.callId).map(call => call.callId)) if (drawn.length === 0) return null - const env = { sessionId, roster, actions, useUi, useWork, findBot } -+ const env = { sessionId, roster, actions, useUi, findBot, openFile, fileImages } ++ const env = { sessionId, roster, actions, useUi, findBot } return h(Fragment, null, drawn.map((call, index) => h(Fragment, { key: call.callId ?? index }, renderCall(call, env)))) } diff --git a/src/client/context-menu.js b/src/client/context-menu.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 09e038347..e30494886 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -5164,94 +5164,14 @@ diff --git a/client.js b/client.js .bt-drawer-name-input{animation:bt-veil-in .16s ease-out} .bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} :is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}} -@@ -2595,2914 +2595,7482 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} - :is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} - @media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} +@@ -2597,9 +2597,20 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} `; --var CONVERSATION_CSS = ` --.bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} + var CONVERSATION_CSS = ` + .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} -.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} --.bt-think summary::-webkit-details-marker{display:none} --.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} --.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} --.bt-event>span{padding:0 2px} --.bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} --.bt-event button:hover{background:var(--bt-hover)} --.bt-event button:active{background:var(--bt-active)} --.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px} --.bt-event-whole>*{flex:none} --.bt-event-whole>span:first-child{margin-right:2px} --/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */ --.bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} --[class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} --.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)} --.bt-group-replies{display:contents} --/* A Bot chat is flat in every work-details mode, without changing the user's mode -- (it is a DSH-wide setting): step groups lose their header and stay open, and what a -- mode folds away in a finished turn stays shown. TurnProcessCell also opens that -- fold; these rules cover the first frame before it does. */ --[data-step-process]>div:first-child{display:none!important} --[data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} --:is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} --[class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} --[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important} --/* The transcript runs under a floating header pill. */ --header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center} --header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1} --header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto} --header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} --[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} --/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), -- without its copy and branch row; a tail with nothing in it takes no room. */ --[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]){display:none!important} --[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important} --/* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not -- the shell's new-session hero: no headline, no workspace or mode picker, and the -- composer stays docked at the bottom. */ --[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} --[data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} --[data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} --[data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} --/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */ --[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important} --/* The shell's running status only hosts the activity row (.bt-typing). */ --[data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} --[data-chat-running]>span:not([role=status]){display:none!important} --[data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} --.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} --.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} --.bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} --.bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} --.bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} --.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)} --.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18} --.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards} --.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards} --.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap} --@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} --@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} --@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} --/* Shimmer: a stepped sweep clipped to the text. */ --.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)} --@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} --/* New rows slide up; a reply that replaces the activity row grows out of its corner. */ --[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards} --@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}} --@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}} --@media (prefers-reduced-motion:reduce){ --.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none} --.bt-typing[data-exiting]{animation-name:bt-fade-out} --.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s} --.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)} --[data-bt-enter="new"]{animation:none} --[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} -+var CONVERSATION_CSS = ` -+.bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} +.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px;max-width:100%;min-width:0;white-space:nowrap} -+.bt-think summary::-webkit-details-marker{display:none} + .bt-think summary::-webkit-details-marker{display:none} +-.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} +.bt-think-caret{display:inline-block;transition:transform .15s;font-size:14px;line-height:1} +.bt-think[open] .bt-think-caret{transform:rotate(90deg)} +.bt-think-peek{min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-3)} @@ -5264,27 +5184,27 @@ diff --git a/client.js b/client.js +.bt-person-msg .bt-bubble :is(p,li){white-space:pre-line} +.bt-remote-handle{display:inline-flex;align-items:center;gap:3px;margin-left:6px;font-weight:400;color:var(--bt-ink-3,rgba(20,20,20,.36))} +.bt-think-text{white-space:pre-wrap;overflow-wrap:anywhere;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} -+.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} -+.bt-event>span{padding:0 2px} -+.bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} -+.bt-event button:hover{background:var(--bt-hover)} -+.bt-event button:active{background:var(--bt-active)} -+.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px} -+.bt-event-whole>*{flex:none} -+.bt-event-whole>span:first-child{margin-right:2px} -+/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */ -+.bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} -+[class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} -+.bt-group-replies{display:contents} -+/* A Bot chat is flat in every work-details mode, without changing the user's mode -+ (it is a DSH-wide setting): step groups lose their header and stay open, and what a -+ mode folds away in a finished turn stays shown. TurnProcessCell also opens that -+ fold; these rules cover the first frame before it does. */ -+[data-step-process]>div:first-child{display:none!important} -+[data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} -+:is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} -+[class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} + .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} + .bt-event>span{padding:0 2px} + .bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} +@@ -2612,7 +2612,6 @@ var CONVERSATION_CSS = ` + .bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} + [class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} +-.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)} + .bt-group-replies{display:contents} + /* A Bot chat is flat in every work-details mode, without changing the user's mode + (it is a DSH-wide setting): step groups lose their header and stay open, and what a +@@ -2622,21 +2622,48 @@ var CONVERSATION_CSS = ` + [data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} + :is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} + [class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} +-[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important} +-/* The transcript runs under a floating header pill. */ +-header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center} +-header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1} +-header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto} +-header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +/* FlyBot's pill rides the shell's own Session header utilities, beside the seats + the native header renders (actions: the subagent catalog; lineage; corner). + It is a compact control in that row now, not a floating replacement for the @@ -5304,9 +5224,11 @@ diff --git a/client.js b/client.js + line. No fill, no radius, muted ink, and the description stays on hover. */ +[data-slot="conversation.session.header.actions"] [class*="_label"]{background:none;border-radius:0;color:var(--bt-ink-3);cursor:help;pointer-events:auto} +[data-slot="conversation.session.header.actions"] [class*="_label"] svg{opacity:.7} -+[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} -+/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), -+ without its copy and branch row; a tail with nothing in it takes no room. */ + [class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} + /* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), + without its copy and branch row; a tail with nothing in it takes no room. */ +-[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]){display:none!important} +-[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important} +[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]):not(.bt-typing){display:none!important} +[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))):not(:has([data-turn-tail]>.bt-typing)){display:none!important} +/* #1347: the thread must never paint above a dialog, a sheet or a scrim. @@ -5322,20 +5244,18 @@ diff --git a/client.js b/client.js + whatever any single bubble does. contain:paint is deliberately NOT used: it would clip the + popovers the thread legitimately renders (reaction strip, message menu), which portal to body + for exactly that reason. */ -+/* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not -+ the shell's new-session hero: no headline, no workspace or mode picker, and the -+ composer stays docked at the bottom. */ -+[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} + /* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not + the shell's new-session hero: no headline, no workspace or mode picker, and the + composer stays docked at the bottom. */ + [data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} +body[data-bt-remote-chat=empty][data-content-phase=hero] [class*="_scrollBody"]::before{content:var(--bt-remote-empty);display:block;text-align:center;color:var(--bt-ink-3);font-size:13px;line-height:18px;padding:16px 12px 8px;max-width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto;white-space:pre-wrap} -+[data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} -+[data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} -+[data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} -+/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */ -+[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important} -+/* The shell's running status only hosts the activity row (.bt-typing). */ -+[data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} -+[data-chat-running]>span:not([role=status]){display:none!important} -+[data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} + [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} + [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} + [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} +@@ -2646,8 +2646,19 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + [data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} + [data-chat-running]>span:not([role=status]){display:none!important} + [data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} +.bt-busy-ack{display:flex;align-items:flex-end;gap:8px;width:100%;margin-top:var(--dsh-chat-flow-gap,12px);margin-bottom:4px} +.bt-busy-ack .bt-astack{align-items:flex-start} +.bt-busy-ack .bt-bubble{opacity:.92} @@ -5344,39 +5264,132 @@ diff --git a/client.js b/client.js +.bt-busy-dots>span:nth-child(2){animation-delay:.2s}.bt-busy-dots>span:nth-child(3){animation-delay:.4s} +@keyframes bt-busy-blink{0%,80%,100%{opacity:.25}40%{opacity:1}} +@media (prefers-reduced-motion:reduce){.bt-busy-dots>span{animation:none;opacity:.6}} -+.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} -+.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} + .bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} + .bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} +.bt-typing[data-carry],.bt-typing[data-carry]>.bt-typing-text{animation:none} +/* Between a friend's chat turns the row sits in the last turn's tail (#443), at the transcript's foot. */ +[data-turn-tail]>.bt-typing{margin-top:var(--dsh-chat-flow-gap,12px)} -+.bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} -+.bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} -+.bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} -+.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)} -+.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18} -+.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards} -+.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards} -+.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap} -+@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} -+@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} -+@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} -+/* Shimmer: a stepped sweep clipped to the text. */ -+.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)} -+@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} -+/* New rows slide up; a reply that replaces the activity row grows out of its corner. */ -+[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -+[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards} -+@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}} -+@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}} -+@media (prefers-reduced-motion:reduce){ -+.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none} -+.bt-typing[data-exiting]{animation-name:bt-fade-out} -+.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s} -+.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)} -+[data-bt-enter="new"]{animation:none} -+[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} -+} + .bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} + .bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} + .bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} +@@ -2676,2833 +2676,7360 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + [data-bt-enter="new"]{animation:none} + [data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} + } +-@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} +-@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} +-[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} +-[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} +-[data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} +-[class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} +-[class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} +-[data-composer-card]>[class*="_row"]{display:contents!important} +-[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0} +-[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important} +-[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0} +-[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%} +-[data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} +-[data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} +-[data-composer-placeholder]{font-size:0!important;line-height:0!important} +-[data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} +-/* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ +-[data-composer-card] button[class*="_add"]{display:none!important} +-.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} +-.bt-plus:active{scale:.9} +-.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}} +-.bt-plus[aria-expanded=true] svg{rotate:45deg} +-.bt-plus-menu{min-width:180px} +-[data-composer-card] [class*="_tools"]{gap:0!important} +-[data-composer-card] [class*="_modes"]:empty{display:none!important} +-[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important} +-[data-composer-card] [class*="_trailing"]{gap:8px} +-[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px} +-[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9} +-[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9} +-@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}} +-[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px} +-[data-composer-card] button[class*="_primary"]:disabled{display:none!important} +-[data-composer-dock]{display:none!important} +-.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)} +-.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} +-.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite} +-@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}} +-.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-.bt-voice:hover{opacity:.9} +-.bt-mic:active,.bt-voice:active{scale:.9} +-[data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} +-body[data-bt-room] .bt-voice{display:none!important} +-body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} +-.bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} +-[data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} +-.bt-q{order:1} +-div:has(> nav [class*="_marks"]){display:none!important} +-/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies) +- takes its surface and its text from the one --bt-user pair: a skin that repaints +- the host's bubble surface alone would otherwise leave the text unreadable. */ +-[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important} +-[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit} +-/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */ +-[class*="_userRow"] [class*="_actions"]{display:none!important} +-[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start} +-[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none} +-[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible} +-[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none} +-[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)} +-[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)} +-[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px} +-.bt-q-inline{display:flex;margin:4px 0 6px} +-[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important} +-[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important} +-.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0} +-.bt-q-dock{width:100%;display:flex;justify-content:center} +-.bt-q-dock .bt-q{margin:0 auto 8px} +-[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important} +-[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important} +-[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} +-[class*="_toBottomSlot"]>button:active{scale:.9} +-[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important} +-[class*="_toBottomSlot"]>button svg{display:none} +-[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat} +-@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}} +-[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important} +-.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} +-@keyframes bt-news-in{from{translate:0 8px;scale:.9}} +-.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in} +-@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}} +-.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none} +-.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px} +-.bt-news-ico svg{width:16px;height:16px} +-.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer} +-.bt-news-x svg{width:12px;height:12px} +-.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)} +-@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}} +-.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)} +-.bt-open-new{display:flex;flex-direction:column} +-.bt-open-new .bt-new-sep{margin:18px 0 0} +-.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px} +-.bt-earlier{display:flex;flex-direction:column;gap:6px} +-.bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} +-.bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} +-.bt-urow{display:flex;justify-content:flex-end} +-.bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} +-.bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} +-.bt-ububble p{margin:0;line-height:22px} +-.bt-uimages{display:block;font-size:12px;opacity:.7} +-.bt-earlier .bt-event{margin:4px 0} +-.bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} +-.bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} +-`; +-function installStyles(text, tag) { +- const style = document.createElement("style"); +- style.dataset.dshBot = tag; +- style.textContent = text; +- document.head.appendChild(style); +- return style; +@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} +@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} +[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} @@ -7251,19 +7264,28 @@ diff --git a/client.js b/client.js +var import_react_dom = require("react-dom"); +var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); +var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); -+function bubbleMarkdown({ actions, subject, openFile, fileImages, ...markdown }) { -+ const openExternalLink = (href) => { ++function bubbleLinkClick({ actions, subject }) { ++ return (event) => { ++ if (!event || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false; ++ const anchor = event.target?.closest?.("a[href]"); ++ if (!anchor) return false; ++ const href = anchor.getAttribute?.("href"); ++ if (typeof href !== "string" || href.startsWith(MENTION_ORIGIN) || !/^https?:/i.test(href)) return false; ++ event.preventDefault(); ++ event.stopPropagation(); + const tabId = typeof actions?.browserNewTab === "function" ? actions.browserNewTab(subject) : null; + if (tabId !== void 0 && tabId !== null && typeof actions.browserNavigate === "function") { + void actions.browserNavigate(tabId, href); -+ return; ++ return true; + } -+ if (typeof window !== "undefined" && typeof window.open === "function") window.open(href, "_blank", "noopener"); ++ if (typeof window !== "undefined" && typeof window.open === "function") window.open(href, "_blank", "noopener,noreferrer"); ++ return true; + }; -+ return (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownDelegateProvider, { openExternalLink, openFile, fileImages }, (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, markdown)); +} -+function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions, openFile, fileImages }) { ++function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions }) { ++ const openLink = bubbleLinkClick({ actions, subject: selfId ?? sessionId }); + const onClick = (event) => { ++ if (openLink(event)) return; + const target = mentionTarget(event); + if (target === null) return; + event.preventDefault(); @@ -7293,7 +7315,7 @@ diff --git a/client.js b/client.js + (0, import_react10.createElement)( + "div", + { className: "bt-bubble" }, -+ bubbleMarkdown({ text: linkMentions(text2, roster.bots, selfId), actions, subject: selfId ?? sessionId, openFile, fileImages, streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) ++ (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text2, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) + ), + live ? null : (0, import_react10.createElement)(MessageTools, { text: text2, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(key, reactIndex(index), emoji) : void 0, actions }) + ), @@ -7456,7 +7478,7 @@ diff --git a/client.js b/client.js + if (texts2.every((text2) => /^Not sent:/.test(text2.trim()))) return void 0; + return (0, import_react10.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react10.createElement)(BotMark, { bot, size: 22 })); +} -+function AssistantCell({ node, groupPart, sessionId, openFile, fileImages, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { ++function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { + const roster = useRoster((value) => value); + const data = node.data; + const steps = useTurnSteps(String(data.turn)); @@ -7474,7 +7496,7 @@ diff --git a/client.js b/client.js + const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; + if (texts2.length === 0 && !reasoning && relays.length === 0) return null; + const selfId = roster.byId[sessionId]?.id ?? sessionId; -+ const bubbles = texts2.length ? person ? (0, import_react10.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, openFile, fileImages }) : (0, import_react10.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts2), openFile, fileImages }) : null; ++ const bubbles = texts2.length ? person ? (0, import_react10.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi }) : (0, import_react10.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts2) }) : null; + return (0, import_react10.createElement)( + import_react10.Fragment, + null, @@ -7506,14 +7528,14 @@ diff --git a/client.js b/client.js + closing ? (0, import_react10.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null + ); +} -+function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, openFile, fileImages }) { ++function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { + if (!(texts2 ?? []).some((text2) => String(text2 ?? "").trim() !== "")) return null; + const lead = (0, import_react10.createElement)("span", { className: "bt-lead", title: name }, (0, import_react10.createElement)(PersonMark, { name })); + return (0, import_react10.createElement)( + "div", + { className: "bt-group-msg bt-person-msg", "data-person": name }, + (0, import_react10.createElement)("span", { className: "bt-group-name" }, name), -+ (0, import_react10.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead, openFile, fileImages }) ++ (0, import_react10.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) + ); +} +function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { @@ -7567,7 +7589,7 @@ diff --git a/client.js b/client.js + if (entry.status !== "pending" && entry.status !== "sending") return void 0; + return room2.face?.status === "left" || room2.face?.status === "closed" ? "rejected" : "held"; +} -+function GroupMessage({ bot, text: text2, room: room2, roster, actions, msgKey, useUi, seq, openFile, fileImages }) { ++function GroupMessage({ bot, text: text2, room: room2, roster, actions, msgKey, useUi, seq }) { + if (String(text2 ?? "").trim() === "") return null; + const held = heldLineState(room2, { seq, botId: bot.id, text: text2 }); + if (held === "rejected") { @@ -7594,7 +7616,7 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-group-msg" }, + name, -+ (0, import_react10.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead, openFile, fileImages }) ++ (0, import_react10.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); +} +function remoteSpeaker(source, roster, sessionId) { @@ -7629,7 +7651,7 @@ diff --git a/client.js b/client.js + return Object.keys(look).length ? look : void 0; +} +var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; -+function RemoteMessage({ source, text: text2, roster, actions, sessionId, msgKey, useUi, openFile, fileImages }) { ++function RemoteMessage({ source, text: text2, roster, actions, sessionId, msgKey, useUi }) { + const room2 = roster.roomsById?.[sessionId]; + const face = room2?.origin === "face-to-face"; + const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId); @@ -7648,7 +7670,7 @@ diff --git a/client.js b/client.js + person, + carrier ? (0, import_react10.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null + ), -+ String(words ?? "").trim() === "" ? null : (0, import_react10.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react10.createElement)("span", { className: "bt-lead", title: person }, (0, import_react10.createElement)(PersonMark, { name: person })), openFile, fileImages }), ++ String(words ?? "").trim() === "" ? null : (0, import_react10.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react10.createElement)("span", { className: "bt-lead", title: person }, (0, import_react10.createElement)(PersonMark, { name: person })) }), + files.length ? (0, import_react10.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null + ); + } @@ -7664,7 +7686,7 @@ diff --git a/client.js b/client.js + name, + (0, import_react10.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react10.createElement)(GlobeMark), handle) + ), -+ String(words ?? "").trim() === "" ? null : (0, import_react10.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead, openFile, fileImages }), ++ String(words ?? "").trim() === "" ? null : (0, import_react10.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react10.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null + ); +} @@ -7744,7 +7766,7 @@ diff --git a/client.js b/client.js + } + return null; +}; -+function EarlierParts({ sessionId, roster, actions, useUi, openFile, fileImages }) { ++function EarlierParts({ sessionId, roster, actions, useUi }) { + const [view, setView] = (0, import_react10.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); + const top = (0, import_react10.useRef)(null); + const busy = (0, import_react10.useRef)(false); @@ -7815,16 +7837,16 @@ diff --git a/client.js b/client.js + case "divider": + return (0, import_react10.createElement)(PartLine, { key, time: row.time }); + case "user": -+ return (0, import_react10.createElement)("div", { key, className: "bt-urow" }, (0, import_react10.createElement)( ++ return (0, import_react10.createElement)("div", { key, className: "bt-urow", onClickCapture: bubbleLinkClick({ actions, subject: selfId ?? sessionId }) }, (0, import_react10.createElement)( + "div", + { className: "bt-ububble" }, -+ row.text ? bubbleMarkdown({ text: row.text, actions, subject: selfId ?? sessionId, openFile, fileImages, labels: mdLabels(), variant: "compact" }) : null, ++ row.text ? (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, + row.images ? (0, import_react10.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null + )); + case "answer": + return (0, import_react10.createElement)(AnsweredCell, { key, node: { data: row } }); + case "bot": -+ return (0, import_react10.createElement)(Bubbles, { key, texts: row.texts, msgKeys: row.msgKeys, roster, selfId, actions, sessionId, useUi, openFile, fileImages }); ++ return (0, import_react10.createElement)(Bubbles, { key, texts: row.texts, msgKeys: row.msgKeys, roster, selfId, actions, sessionId, useUi }); + case "event": { + if (row.role === "report") return (0, import_react10.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); + const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; @@ -7839,8 +7861,16 @@ diff --git a/client.js b/client.js + }), + (0, import_react10.createElement)(PartLine, { time: view.startedAt }) + ); -+} -+function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions, openFile, fileImages }) { + } +-var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; +-var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; +-function sheetClassNames(sheet) { +- const names = []; +- const visit = (rules) => { +- for (const rule of rules) { +- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); +- if (rule.cssRules) visit(rule.cssRules); ++function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; + const source = data.source !== null && typeof data.source === "object" ? data.source : {}; @@ -7872,11 +7902,15 @@ diff --git a/client.js b/client.js + if (source.form === "remote-chat") return null; + if (source.shown === true) return null; + if (source.remote?.handle) { -+ const message = (0, import_react10.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi, openFile, fileImages }); ++ const message = (0, import_react10.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); + return source.remote.firstContact === true ? (0, import_react10.createElement)(import_react10.Fragment, null, (0, import_react10.createElement)(FirstContact, { source, roster, sessionId }), message) : message; -+ } + } +- }; +- try { +- visit(sheet.cssRules); +- } catch { + if (source.kind === "bot") { -+ if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react10.createElement)(EarlierParts, { sessionId, roster, actions, useUi, openFile, fileImages }); ++ if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react10.createElement)(EarlierParts, { sessionId, roster, actions, useUi }); + if (source.role === "room" || source.role === "kickoff") return null; + if (source.role === "mention") return (0, import_react10.createElement)("div", { className: "bt-event" }, (0, import_react10.createElement)("span", null, contentText(data.content))); + if (source.role === "secret" || deferred) return null; @@ -7885,7 +7919,7 @@ diff --git a/client.js b/client.js + if (source.notice === true || source.senderSessionId === "face-notice") return null; + const room2 = roster.roomsById[sessionId]; + const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -+ return (0, import_react10.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi, openFile, fileImages }); ++ return (0, import_react10.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); + } + if (source.role === "report") { + return (0, import_react10.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); @@ -7918,8 +7952,18 @@ diff --git a/client.js b/client.js + } + if (source.kind === "user-question-reply") return null; + return (0, import_react10.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); -+ } -+} + } +- return names; + } +-function compileFragmentSelectors(text, names) { +- const compiled = /* @__PURE__ */ new Map(); +- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { +- if (!compiled.has(fragment)) { +- const exact = []; +- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); +- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); +- } +- return compiled.get(fragment); +function NewDivider() { + return (0, import_react10.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react10.createElement)("span", null, t("New"))); +} @@ -7938,9 +7982,22 @@ diff --git a/client.js b/client.js + const fresh = useTurnClock((state) => { + const own = state[sessionId]?.[turn]; + return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; -+ }); + }); + return fresh ? (0, import_react10.createElement)(NewDivider) : null; -+} + } +-function installCompiledStyles(text, tag) { +- const style = installStyles("", tag); +- const cache = /* @__PURE__ */ new WeakMap(); +- const render = () => { +- const names = /* @__PURE__ */ new Set(); +- for (const sheet of document.styleSheets) { +- if (sheet.ownerNode?.dataset?.dshBot) continue; +- let list = cache.get(sheet); +- if (!list) { +- list = sheetClassNames(sheet); +- cache.set(sheet, list); +- } +- for (const name of list) names.add(name); +function Nothing() { + return null; +} @@ -7999,121 +8056,7 @@ diff --git a/client.js b/client.js + } + if (parts.length === 0) return null; + return (0, import_react10.createElement)("div", { className: "bt-event" }, parts.join(" · ")); - } --@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} --@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} --[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} --[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} --[data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} --[class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} --[class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} --[data-composer-card]>[class*="_row"]{display:contents!important} --[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0} --[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important} --[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0} --[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%} --[data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} --[data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} --[data-composer-placeholder]{font-size:0!important;line-height:0!important} --[data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} --/* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ --[data-composer-card] button[class*="_add"]{display:none!important} --.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} --.bt-plus:active{scale:.9} --.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}} --.bt-plus[aria-expanded=true] svg{rotate:45deg} --.bt-plus-menu{min-width:180px} --[data-composer-card] [class*="_tools"]{gap:0!important} --[data-composer-card] [class*="_modes"]:empty{display:none!important} --[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important} --[data-composer-card] [class*="_trailing"]{gap:8px} --[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px} --[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9} --[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9} --@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}} --[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px} --[data-composer-card] button[class*="_primary"]:disabled{display:none!important} --[data-composer-dock]{display:none!important} --.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)} --.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} --.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite} --@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}} --.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-voice:hover{opacity:.9} --.bt-mic:active,.bt-voice:active{scale:.9} --[data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} --body[data-bt-room] .bt-voice{display:none!important} --body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} --.bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} --[data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} --.bt-q{order:1} --div:has(> nav [class*="_marks"]){display:none!important} --/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies) -- takes its surface and its text from the one --bt-user pair: a skin that repaints -- the host's bubble surface alone would otherwise leave the text unreadable. */ --[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important} --[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit} --/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */ --[class*="_userRow"] [class*="_actions"]{display:none!important} --[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start} --[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none} --[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible} --[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none} --[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)} --[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)} --[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px} --.bt-q-inline{display:flex;margin:4px 0 6px} --[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important} --[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important} --.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0} --.bt-q-dock{width:100%;display:flex;justify-content:center} --.bt-q-dock .bt-q{margin:0 auto 8px} --[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important} --[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important} --[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} --[class*="_toBottomSlot"]>button:active{scale:.9} --[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important} --[class*="_toBottomSlot"]>button svg{display:none} --[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat} --@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}} --[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important} --.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} --@keyframes bt-news-in{from{translate:0 8px;scale:.9}} --.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in} --@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}} --.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none} --.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px} --.bt-news-ico svg{width:16px;height:16px} --.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer} --.bt-news-x svg{width:12px;height:12px} --.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)} --@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}} --.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)} --.bt-open-new{display:flex;flex-direction:column} --.bt-open-new .bt-new-sep{margin:18px 0 0} --.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px} --.bt-earlier{display:flex;flex-direction:column;gap:6px} --.bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} --.bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} --.bt-urow{display:flex;justify-content:flex-end} --.bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} --.bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} --.bt-ububble p{margin:0;line-height:22px} --.bt-uimages{display:block;font-size:12px;opacity:.7} --.bt-earlier .bt-event{margin:4px 0} --.bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} --.bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} --`; --function installStyles(text, tag) { -- const style = document.createElement("style"); -- style.dataset.dshBot = tag; -- style.textContent = text; -- document.head.appendChild(style); -- return style; ++} +var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +function clockLabel(time, now = Date.now()) { + const date = new Date(time); @@ -8124,15 +8067,7 @@ diff --git a/client.js b/client.js + if (days === 1) return t("Yesterday {time}", { time: clock4 }); + const sameYear = date.getFullYear() === new Date(now).getFullYear(); + return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; - } --var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; --var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; --function sheetClassNames(sheet) { -- const names = []; -- const visit = (rules) => { -- for (const rule of rules) { -- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); -- if (rule.cssRules) visit(rule.cssRules); ++} +function nextTurnStart(turns, turn) { + let next; + for (const key in turns) { @@ -8216,7 +8151,28 @@ diff --git a/client.js b/client.js + case "message_bot": { + const target = findBot(arg("to")); + return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -+ } + } +- const next = compileFragmentSelectors(text, names); +- if (style.textContent !== next) style.textContent = next; +- }; +- render(); +- let timer; +- const schedule = () => { +- clearTimeout(timer); +- timer = setTimeout(render, 120); +- }; +- const onLoad = (event) => { +- if (event.target instanceof HTMLLinkElement) schedule(); +- }; +- const observer = new MutationObserver(schedule); +- observer.observe(document.head, { childList: true }); +- document.addEventListener("load", onLoad, true); +- return () => { +- observer.disconnect(); +- document.removeEventListener("load", onLoad, true); +- clearTimeout(timer); +- style.remove(); +- }; + case "stop_bot": { + const target = findBot(arg("bot")); + return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; @@ -8244,7 +8200,35 @@ diff --git a/client.js b/client.js + const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; + return { label: phrase ? t(phrase) : null, state: toolState(name) }; + } -+} + } +- +-// src/client/mark.js +-var import_react2 = require("react"); +-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +-function useGaze(ref, enabled) { +- (0, import_react2.useEffect)(() => { +- if (!enabled) return void 0; +- let frame = 0; +- let point = null; +- const place = () => { +- frame = 0; +- const node = ref.current; +- if (!node || !point) return; +- const box = node.getBoundingClientRect(); +- const clamp = (value) => Math.max(-1, Math.min(1, value)); +- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); +- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); +- }; +- const onMove = (event) => { +- point = { x: event.clientX, y: event.clientY }; +- if (!frame) frame = requestAnimationFrame(place); +- }; +- const onLeave = () => { +- point = null; +- ref.current?.style.setProperty("--bt-gx", "0"); +- ref.current?.style.setProperty("--bt-gy", "0"); +function workPhrase(tool) { + if (!tool) return t("Thinking about it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); @@ -8260,7 +8244,7 @@ diff --git a/client.js b/client.js + return () => actions.noteLive(sessionId, id, null); + }, [sessionId, id, key]); +} -+function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, openFile, fileImages }) { ++function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { + const [live, setLive] = (0, import_react10.useState)({ replies: [] }); + (0, import_react10.useEffect)(() => { + if (done) return void 0; @@ -8277,12 +8261,54 @@ diff --git a/client.js b/client.js + setLive({ replies: entry.replies ?? [] }); + } + timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); -+ }; + }; +- window.addEventListener("pointermove", onMove, { passive: true }); +- document.documentElement.addEventListener("pointerleave", onLeave); + void tick(); -+ return () => { + return () => { +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); + stop = true; + clearTimeout(timer); -+ }; + }; +- }, [enabled]); +-} +-var FIDGETS = ["hop", "sway", "stretch", "look"]; +-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { +- const look = given ?? lookOf(bot); +- const ref = (0, import_react2.useRef)(null); +- const maskId = (0, import_react2.useRef)(""); +- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; +- const [poked, setPoked] = (0, import_react2.useState)(false); +- useGaze(ref, gaze); +- const common = { +- ref, +- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", +- "data-shape": look.image ? "image" : shapeId(look.shape), +- "data-state": STATES.includes(state) ? state : "idle", +- "data-live": live ? "" : void 0, +- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, +- "data-gaze": gaze ? "" : void 0, +- "data-poke": pokeable ? "" : void 0, +- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, +- role: label ? "img" : void 0, +- "aria-label": label, +- "aria-hidden": label ? void 0 : true, +- onClick: pokeable ? () => setPoked(true) : void 0, +- onAnimationEnd: poked ? (event) => { +- if (/^bt-poke/.test(event.animationName)) setPoked(false); +- } : void 0 +- }; +- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); +- const box = shapeOf(look.shape).size; +- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); +-} +-function StarIcon({ size = 8 }) { +- return (0, import_react2.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + }, [done, sessionId, callId]); + let replies = live.replies; + if (done) { @@ -8290,13 +8316,8 @@ diff --git a/client.js b/client.js + replies = JSON.parse(contentText(content)).replies ?? []; + } catch { + replies = []; - } -- }; -- try { -- visit(sheet.cssRules); -- } catch { - } -- return names; ++ } ++ } + if (replies.length === 0) return null; + const room2 = roster.roomsById[sessionId]; + return (0, import_react10.createElement)( @@ -8309,16 +8330,16 @@ diff --git a/client.js b/client.js + if (reply.kind === "user") { + const who = reply.name && reply.name !== "User" ? reply.name : reply.authorId; + if (reply.other) { -+ return (0, import_react10.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, openFile, fileImages }); ++ return (0, import_react10.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + } + return (0, import_react10.createElement)( + "div", -+ { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who }, ++ { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who, onClickCapture: bubbleLinkClick({ actions, subject: sessionId }) }, + who ? (0, import_react10.createElement)("span", { className: "bt-uauthor" }, who) : null, + (0, import_react10.createElement)( + "div", + { className: "bt-ububble" }, -+ bubbleMarkdown({ text: remoteMessageText(reply.text), actions, subject: reply.botId ?? sessionId, openFile, fileImages, labels: mdLabels(), variant: "compact" }) ++ (0, import_react10.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) + ), + room2?.origin === "face-to-face" ? (0, import_react10.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react10.createElement)(PersonMark, { name: who || void 0 })) : null + ); @@ -8369,8 +8390,6 @@ diff --git a/client.js b/client.js + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react10.createElement)(RemoteMessage, { -+ openFile, -+ fileImages, + key: `${reply.botId || reply.handle}-${index}`, + source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, + text: remoteMessageText(reply.text), @@ -8381,20 +8400,12 @@ diff --git a/client.js b/client.js + useUi + }); + } -+ return (0, import_react10.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq, openFile, fileImages }); ++ return (0, import_react10.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); + }) -+ ); + ); } --function compileFragmentSelectors(text, names) { -- const compiled = /* @__PURE__ */ new Map(); -- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { -- if (!compiled.has(fragment)) { -- const exact = []; -- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); -- } -- return compiled.get(fragment); -- }); +-function AdminStar() { +- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); +var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); +var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +function callTree(root) { @@ -8410,17 +8421,12 @@ diff --git a/client.js b/client.js + visit(root); + return calls; } --function installCompiledStyles(text, tag) { -- const style = installStyles("", tag); -- const cache = /* @__PURE__ */ new WeakMap(); -- const render = () => { -- const names = /* @__PURE__ */ new Set(); -- for (const sheet of document.styleSheets) { -- if (sheet.ownerNode?.dataset?.dshBot) continue; -- let list = cache.get(sheet); -- if (!list) { -- list = sheetClassNames(sheet); -- cache.set(sheet, list); +-function MainStar() { +- return (0, import_react2.createElement)( +- "svg", +- { viewBox: "0 0 20 20", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) +- ); +function useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); + (0, import_react10.useLayoutEffect)(() => { @@ -8429,8 +8435,17 @@ diff --git a/client.js b/client.js + actions.noteCards(sessionId, ids, 1); + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); -+} -+function renderCall(call4, { sessionId, roster, actions, useUi, findBot, openFile, fileImages }) { + } +-function useSettled(state) { +- const prev = (0, import_react2.useRef)(state); +- const [settled, setSettled] = (0, import_react2.useState)(false); +- (0, import_react2.useEffect)(() => { +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; ++function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; + const { text: arg, value: argValue } = toolArgs(call4); @@ -8440,83 +8455,132 @@ diff --git a/client.js b/client.js + const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; + if (done && call4.isError) return (0, import_react10.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); + return (0, import_react10.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -+ } -+ case "stop_bot": { -+ const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; -+ if (done && call4.isError) return (0, import_react10.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); -+ return (0, import_react10.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -+ } -+ case "create_bot": { -+ const created = findBot(arg("name")); -+ if (!done || created === void 0) return null; -+ return (0, import_react10.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); -+ } -+ case "update_bot": { -+ const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -+ return done && target ? (0, import_react10.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; -+ } -+ case "group_relay": -+ return (0, import_react10.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi, openFile, fileImages }); -+ case "create_group": -+ case "update_group": -+ case "delete_group": -+ case "post_to_group": { -+ const verbs = { -+ create_group: ["Created group", "Couldn't create the group"], -+ update_group: ["Updated group", "Couldn't update the group"], -+ delete_group: ["Deleted group", "Couldn't delete the group"], -+ post_to_group: ["Posted in", "Couldn't post in the group"] -+ }[tool]; -+ if (!done) return null; -+ const result = contentText(call4.content); -+ const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); -+ if (call4.isError || !named) return (0, import_react10.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); -+ const name = named[2]; -+ const room2 = roster.rooms.find((entry) => entry.name === name); -+ return (0, import_react10.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); -+ } -+ case "ask_user": { -+ const question = roster.questions?.[sessionId]; -+ if (!question || question.callId !== call4.callId) return (0, import_react10.createElement)("span", { className: "bt-q-gone", hidden: true }); -+ return (0, import_react10.createElement)(PendingQuestion, { sessionId, question, actions, roster, inline: true }); -+ } -+ case "request_secret": -+ return (0, import_react10.createElement)(SecretToolCell, { sessionId, callId: call4.callId, name: arg("name"), done, content: call4.content, roster, actions }); -+ case "ask_user_question": { -+ if (!done) return null; -+ let parsed = null; -+ try { -+ parsed = JSON.parse(contentText(call4.content)); -+ } catch { + } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; +-} +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- ); +-} +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +- } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +-} +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); +- } +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); ++ case "stop_bot": { ++ const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; ++ if (done && call4.isError) return (0, import_react10.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); ++ return (0, import_react10.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); ++ } ++ case "create_bot": { ++ const created = findBot(arg("name")); ++ if (!done || created === void 0) return null; ++ return (0, import_react10.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); ++ } ++ case "update_bot": { ++ const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); ++ return done && target ? (0, import_react10.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; ++ } ++ case "group_relay": ++ return (0, import_react10.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi }); ++ case "create_group": ++ case "update_group": ++ case "delete_group": ++ case "post_to_group": { ++ const verbs = { ++ create_group: ["Created group", "Couldn't create the group"], ++ update_group: ["Updated group", "Couldn't update the group"], ++ delete_group: ["Deleted group", "Couldn't delete the group"], ++ post_to_group: ["Posted in", "Couldn't post in the group"] ++ }[tool]; ++ if (!done) return null; ++ const result = contentText(call4.content); ++ const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); ++ if (call4.isError || !named) return (0, import_react10.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); ++ const name = named[2]; ++ const room2 = roster.rooms.find((entry) => entry.name === name); ++ return (0, import_react10.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); ++ } ++ case "ask_user": { ++ const question = roster.questions?.[sessionId]; ++ if (!question || question.callId !== call4.callId) return (0, import_react10.createElement)("span", { className: "bt-q-gone", hidden: true }); ++ return (0, import_react10.createElement)(PendingQuestion, { sessionId, question, actions, roster, inline: true }); ++ } ++ case "request_secret": ++ return (0, import_react10.createElement)(SecretToolCell, { sessionId, callId: call4.callId, name: arg("name"), done, content: call4.content, roster, actions }); ++ case "ask_user_question": { ++ if (!done) return null; ++ let parsed = null; ++ try { ++ parsed = JSON.parse(contentText(call4.content)); ++ } catch { + parsed = null; } -- for (const name of list) names.add(name); +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) +- ); +-} +- +-// src/client/icons.js +-var import_react3 = require("react"); +-function PlusIcon() { +- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-function SearchIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); + const answer = parsed?.answers?.[0]; + const question = argValue("questions")?.[0]; + if (!answer) return null; + return (0, import_react10.createElement)("div", { className: "bt-q-inline" }, (0, import_react10.createElement)(SettledQuestion, { question, answer })); - } -- const next = compileFragmentSelectors(text, names); -- if (style.textContent !== next) style.textContent = next; -- }; -- render(); -- let timer; -- const schedule = () => { -- clearTimeout(timer); -- timer = setTimeout(render, 120); -- }; -- const onLoad = (event) => { -- if (event.target instanceof HTMLLinkElement) schedule(); -- }; -- const observer = new MutationObserver(schedule); -- observer.observe(document.head, { childList: true }); -- document.addEventListener("load", onLoad, true); -- return () => { -- observer.disconnect(); -- document.removeEventListener("load", onLoad, true); -- clearTimeout(timer); -- style.remove(); -- }; ++ } + case "read_group_chat": { + if (!done) return null; + const room2 = roster.rooms.find((entry) => entry.name === arg("group")); @@ -8528,8 +8592,16 @@ diff --git a/client.js b/client.js + default: + return null; + } -+} -+function ToolCell({ node, sessionId, useRoster, useUi, actions, openFile, fileImages }) { + } +-function PeopleIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), +- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), +- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) +- ); ++function ToolCell({ node, sessionId, useRoster, useUi, actions }) { + const roster = useRoster((value) => value); + const root = node.data.root; + const turn = node.location?.turn?.turn ?? root.turn; @@ -8542,25 +8614,26 @@ diff --git a/client.js b/client.js + useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); + useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); + if (drawn.length === 0) return null; -+ const env = { sessionId, roster, actions, useUi, findBot, openFile, fileImages }; ++ const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react10.createElement)(import_react10.Fragment, null, drawn.map((call4, index) => (0, import_react10.createElement)(import_react10.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); } - --// src/client/mark.js --var import_react2 = require("react"); --var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; --var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; --var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; --function useGaze(ref, enabled) { -- (0, import_react2.useEffect)(() => { -- if (!enabled) return void 0; +-function ArrowUpIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); ++ +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; +function awaitingReply(roster, sessionId, now = Date.now()) { + const at = roster?.remoteAwaiting?.[sessionId]; + if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; + return now - at < AWAIT_REPLY_MS; -+} + } +-function MicIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), +- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) +- ); + +// src/client/remote-approval-wait.js +var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; @@ -8568,7 +8641,13 @@ diff --git a/client.js b/client.js + if (!remote) return null; + if (tool && inputAt <= (tool.at ?? 0)) return null; + return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; -+} + } +-function WaveIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) +- ); + +// src/client/activity-row-host.js +var RUNNING_HOST = "[data-chat-flow] [data-chat-running]"; @@ -8578,7 +8657,13 @@ diff --git a/client.js b/client.js + if (typeof root.matches === "function" && root.matches("[data-chat-running]")) return root; + if (typeof Element !== "undefined" && root instanceof Element) return root.querySelector("[data-chat-running]"); + return root.querySelector(RUNNING_HOST); -+} + } +-function ShareIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) +- ); + +// src/client/activity-row.js +var import_react11 = require("react"); @@ -8590,7 +8675,9 @@ diff --git a/client.js b/client.js + if (minutes < 60) return `${Math.max(1, minutes)}m`; + const hours = Math.floor(minutes / 60); + return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; -+} + } +-function CloseIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +var REMOTE_GRACE_MS = 1500; +function useAwaitingReply(roster, sessionId) { + const at = roster?.remoteAwaiting?.[sessionId]; @@ -8602,7 +8689,15 @@ diff --git a/client.js b/client.js + return () => clearTimeout(timer); + }, [shown, at]); + return shown; -+} + } +-var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); +-var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); +-var lineIcon = (paths) => function LineIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) +- ); +function findHost(where) { + if (where !== TAIL_HOST) return findRunningHostIn(document); + const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]"); @@ -8616,7 +8711,79 @@ diff --git a/client.js b/client.js + let top = -1; + for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn)); + return top; -+}; + }; +-var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; +-function CloudDownloadIcon({ done = false }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: CLOUD }), +- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) +- ); +-} +-var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); +-var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); +-var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); +-var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); +-var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); +-var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); +-var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); +-var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); +-var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); +-var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); +-var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); +-var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); +-var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); +-var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); +-var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); +-function PinMarkIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- (0, import_react3.createElement)( +- "g", +- { transform: "rotate(45 10 10)" }, +- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), +- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) +- ) +- ); +-} +-var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); +-var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); +-var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); +-var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); +-var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); +-var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); +-var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); +-var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); +-var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); +-var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); +-var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); +-var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); +-var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; +- +-// src/client/questions.js +-var import_react4 = require("react"); +-var keyLetter = (index) => String.fromCharCode(65 + index); +-var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); +-var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; +-function useOptionKeys(count, onPick, ownRef) { +- const pick2 = (0, import_react4.useRef)(onPick); +- pick2.current = onPick; +- (0, import_react4.useEffect)(() => { +- if (count === 0) return void 0; +- const onKey = (event) => { +- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; +- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; +- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; +- const index = event.key.toUpperCase().charCodeAt(0) - 65; +- if (index >= 0 && index < count) { +- event.preventDefault(); +- pick2.current(index); +- } else if (index === count && ownRef.current) { +- event.preventDefault(); +- ownRef.current.focus(); +- } +function useReplyAfterSend(remote, sending) { + const sendTurn = (0, import_react11.useRef)(-1); + const wasSending = (0, import_react11.useRef)(false); @@ -8634,22 +8801,14 @@ diff --git a/client.js b/client.js + sendTurn.current = lastTurnOnScreen(); + } + if (sendTurn.current < 0) return void 0; - let frame = 0; -- let point = null; -- const place = () => { ++ let frame = 0; + const check = () => { - frame = 0; -- const node = ref.current; -- if (!node || !point) return; -- const box = node.getBoundingClientRect(); -- const clamp = (value) => Math.max(-1, Math.min(1, value)); -- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); -- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); ++ frame = 0; + if (lastTurnOnScreen("assistant-step") > sendTurn.current) setReplied(true); }; -- const onMove = (event) => { -- point = { x: event.clientX, y: event.clientY }; -- if (!frame) frame = requestAnimationFrame(place); +- document.addEventListener("keydown", onKey, true); +- return () => document.removeEventListener("keydown", onKey, true); +- }, [count, ownRef]); + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(check); + }); @@ -8661,14 +8820,101 @@ diff --git a/client.js b/client.js + }; + }, [remote, sending]); + return replied; -+} + } +-function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { +- const [own, setOwn] = (0, import_react4.useState)(""); +- const [picked, setPicked] = (0, import_react4.useState)([]); +- const ownRef = (0, import_react4.useRef)(null); +- const titleId = (0, import_react4.useId)(); +- const options = optionsOf(question); +- const multi = question.multiSelect === true; +- const custom = own.trim(); +- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; +- const choose = (label) => { +- if (multi) { +- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); +- return; +- } +- onAnswer({ selected: [label] }); +- }; +- const submit = () => { +- if (!canSubmit) return; +- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); +- }; +- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); +- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; +- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { +- ref: ownRef, +- rows: 1, +- value: own, +- autoComplete: "off", +- spellCheck: false, +- className: "bt-q-input", +- "aria-label": t("Your own answer"), +- placeholder: t("Type your own answer"), +- "aria-keyshortcuts": ownLetter?.toLowerCase(), +- onChange: (event) => setOwn(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- submit(); +function useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react11.useState)(null); + (0, import_react11.useEffect)(() => { + if (!active) { + setHost(null); + return void 0; -+ } + } +- }) : null; +- const rows = options.map((option, optionIndex) => { +- const selected = multi && picked.includes(option.label); +- return (0, import_react4.createElement)( +- "button", +- { +- key: option.label, +- type: "button", +- className: "bt-q-opt", +- "aria-pressed": multi ? selected : void 0, +- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), +- onClick: () => choose(option.label) +- }, +- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), +- (0, import_react4.createElement)( +- "span", +- { className: "bt-q-body" }, +- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), +- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null +- ), +- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null +- ); +- }); +- if (raised && field) { +- rows.push((0, import_react4.createElement)( +- "label", +- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, +- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) +- )); +- } +- let foot = null; +- if (raised && multi) { +- foot = (0, import_react4.createElement)( +- "div", +- { className: "bt-q-foot" }, +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), +- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) +- ); +- } else if ((raised || multi) && canSubmit) { +- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); +- } +- return (0, import_react4.createElement)( +- "form", +- { +- className: raised ? "bt-q bt-q-raised" : "bt-q", +- "aria-labelledby": titleId, +- onSubmit: (event) => { +- event.preventDefault(); +- submit(); + let frame = 0; + const find = () => { + frame = 0; @@ -8683,11 +8929,7 @@ diff --git a/client.js b/client.js + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); - }; -- const onLeave = () => { -- point = null; -- ref.current?.style.setProperty("--bt-gx", "0"); -- ref.current?.style.setProperty("--bt-gy", "0"); ++ }; + }, [active, where]); + return host; +} @@ -8703,9 +8945,7 @@ diff --git a/client.js b/client.js + if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; + delete event.currentTarget.dataset.btEnter; + event.currentTarget.removeEventListener("animationend", settle); - }; -- window.addEventListener("pointermove", onMove, { passive: true }); -- document.documentElement.addEventListener("pointerleave", onLeave); ++ }; + const scan = () => { + frame = 0; + const next = document.querySelector("[data-chat-flow]"); @@ -8728,76 +8968,57 @@ diff --git a/client.js b/client.js + const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); + item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; + item.addEventListener("animationend", settle); -+ } + } +- }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), +- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, +- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null +- ), +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) +- ), +- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, +- field && !raised ? (0, import_react4.createElement)( +- "div", +- { className: "bt-q-custom" }, +- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), +- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null +- ) : null, +- foot +- ); + }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(scan); + }); + scan(); - return () => { - cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); ++ return () => { ++ cancelAnimationFrame(frame); + observer.disconnect(); - }; -- }, [enabled]); --} --var FIDGETS = ["hop", "sway", "stretch", "look"]; --function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { -- const look = given ?? lookOf(bot); -- const ref = (0, import_react2.useRef)(null); -- const maskId = (0, import_react2.useRef)(""); -- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; -- const [poked, setPoked] = (0, import_react2.useState)(false); -- useGaze(ref, gaze); -- const common = { -- ref, -- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", -- "data-shape": look.image ? "image" : shapeId(look.shape), -- "data-state": STATES.includes(state) ? state : "idle", -- "data-live": live ? "" : void 0, -- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, -- "data-gaze": gaze ? "" : void 0, -- "data-poke": pokeable ? "" : void 0, -- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, -- role: label ? "img" : void 0, -- "aria-label": label, -- "aria-hidden": label ? void 0 : true, -- onClick: pokeable ? () => setPoked(true) : void 0, -- onAnimationEnd: poked ? (event) => { -- if (/^bt-poke/.test(event.animationName)) setPoked(false); -- } : void 0 -- }; -- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); -- const box = shapeOf(look.shape).size; -- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); --} --function StarIcon({ size = 8 }) { -- return (0, import_react2.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) -- ); --} --function AdminStar() { -- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); --} --function MainStar() { -- return (0, import_react2.createElement)( -- "svg", -- { viewBox: "0 0 20 20", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) -- ); ++ }; + }, []); } --function useSettled(state) { -- const prev = (0, import_react2.useRef)(state); -- const [settled, setSettled] = (0, import_react2.useState)(false); -- (0, import_react2.useEffect)(() => { -- const was = prev.current; -- prev.current = state; -- if (state !== "idle") { -- setSettled(false); +-function SettledQuestion({ question, answer }) { +- const options = optionsOf(question); +- const prompt = question?.question ?? ""; +- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; +- if (chosen.length === 0) { +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), +- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) +- ) +- ); +- } +- return (0, import_react4.createElement)( +function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { + const roster = useRoster((value) => value); + const running = useSessionStatus((map) => map.get(sessionId)?.running === true); @@ -8835,48 +9056,10 @@ diff --git a/client.js b/client.js + const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; + if (wait <= 0) { + make(); - return void 0; - } -- if (was === "idle" || was === "alert") return void 0; -- setSettled(true); -- const timer = setTimeout(() => setSettled(false), 1800); ++ return void 0; ++ } + const timer = setTimeout(make, wait); - return () => clearTimeout(timer); -- }, [state]); -- return settled; --} --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null -- ); --} --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; -- } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; --} --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( ++ return () => clearTimeout(timer); + }, [wantKey, shown]); + const [phase, setPhase] = (0, import_react11.useState)(null); + const endedAt2 = (0, import_react11.useRef)(0); @@ -8931,34 +9114,24 @@ diff --git a/client.js b/client.js + if (!host || phase === null || !shown) return null; + const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; + return (0, import_react_dom2.createPortal)((0, import_react11.createElement)( -+ "div", + "div", +- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, +- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, +- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { +- const optionIndex = options.findIndex((option) => option.label === label); +- return (0, import_react4.createElement)( +- "div", +- { key: `${label}-${index}`, className: "bt-q-opt" }, +- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), +- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) +- ); +- })) +- ); + { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, + bot ? (0, import_react11.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react11.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react11.createElement)( - "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); -- } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); ++ "span", + { className: "bt-typing-text" }, + (0, import_react11.createElement)( + "span", @@ -8967,13 +9140,26 @@ diff --git a/client.js b/client.js + elapsed ? (0, import_react11.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null + ) + ) -+ ), host); - } - --// src/client/icons.js --var import_react3 = require("react"); --function PlusIcon() { -- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); ++ ), host); + } +-function QuestionCard({ matched }) { +- const pending = matched; +- const questions = pending.questions ?? []; +- const [index, setIndex] = (0, import_react4.useState)(0); +- const [answers, setAnswers] = (0, import_react4.useState)([]); +- const question = questions[index]; +- if (question === void 0) return null; +- const onAnswer = (answer) => { +- const next = [...answers, { id: question.id, ...answer }]; +- if (index + 1 < questions.length) { +- setAnswers(next); +- setIndex(index + 1); +- return; +- } +- pending.answer({ answers: next }); +- }; +- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); ++ +// src/client/dsh-offer.js +var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); +var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; @@ -8983,8 +9169,22 @@ diff --git a/client.js b/client.js + if (dsh.kind === "npm") return true; + return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } --function SearchIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-function PendingQuestion({ sessionId, question, actions, inline }) { +- const [answered, setAnswered] = (0, import_react4.useState)(null); +- if (answered === question.id) return null; +- const onAnswer = (answer) => { +- setAnswered(question.id); +- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); +- }; +- const onDismiss = () => { +- setAnswered(question.id); +- void actions.dismissQuestion(sessionId); +- }; +- return (0, import_react4.createElement)( +- "div", +- { className: inline ? "bt-q-inline" : "bt-q-dock" }, +- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) +- ); + +// src/client/overlay-hooks.js +var import_react12 = require("react"); @@ -9004,14 +9204,12 @@ diff --git a/client.js b/client.js + }; + }, []); } --function PeopleIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), -- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), -- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) -- ); +-function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const question = useRoster((value) => value.questions?.[sessionId]); +- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; +- const loading = useSession?.(historyLoading) === true; +- if (question === void 0 || question.callId && (drawn || loading)) return null; +- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); +function useDialogFocus(ref) { + (0, import_react12.useEffect)(() => { + const before = document.activeElement; @@ -9024,8 +9222,18 @@ diff --git a/client.js b/client.js + }; + }, []); } --function ArrowUpIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); +-function AnsweredCell({ node }) { +- const data = node.data ?? {}; +- const questions = Array.isArray(data.questions) ? data.questions : []; +- if (questions.length === 0) { +- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; +- } +- const answers = Array.isArray(data.answers) ? data.answers : []; +- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { +- key: question.id, +- question, +- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } +- }))); +function usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react12.useState)(280); + (0, import_react12.useLayoutEffect)(() => { @@ -9034,14 +9242,35 @@ diff --git a/client.js b/client.js + }, []); + return paneLeft; } --function MicIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), -- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) -- ); -+ + +-// src/client/secret-card.js +-var import_react5 = require("react"); +-var MIN_VALUE = 4; +-var NONE = {}; +-var KeyIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), +- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) +-); +-var LockIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), +- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) +-); +-var WarnIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), +- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) +-); +-var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); +-var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); +-var scopeLabel = (scope, roster) => { +- if (scope === "all") return t("All Bots"); +- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); +- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); +// src/client/member-status.js +var import_react13 = require("react"); +var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; @@ -9049,7 +9278,31 @@ diff --git a/client.js b/client.js +var stepIn = (work, botId, roomId, tool) => { + const own = work.bots?.[botId]; + return own?.roomId === roomId && own.tool === tool ? own.step : void 0; -+}; + }; +-var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; +-function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +- return (0, import_react5.createElement)("input", { +- className: "bt-secret-input", +- type: MASK_BY_CSS ? "text" : "password", +- "data-masked": MASK_BY_CSS ? "" : void 0, +- value, +- autoFocus, +- autoComplete: MASK_BY_CSS ? "off" : "new-password", +- autoCorrect: "off", +- autoCapitalize: "off", +- spellCheck: false, +- "data-1p-ignore": "", +- "data-lpignore": "true", +- "data-bwignore": "", +- "data-form-type": "other", +- "aria-label": label, +- placeholder: t("Paste or type the key"), +- onChange: (event) => onChange(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- onEnter?.(); +- } +var workIn = (room2, work, id) => { + const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id); + if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) }; @@ -9063,54 +9316,37 @@ diff --git a/client.js b/client.js + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; + }); - } --function WaveIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) -- ); ++} +function busyMembers(room2, work, roster) { + const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); + return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); - } --function ShareIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) -- ); ++} +function splitBusy(busy) { + const here = busy.filter((entry) => entry.state === "work" && entry.here); + const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); + return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; - } --function CloseIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); ++} +function botGroupWork(botId, work, roster) { + return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { + const entry = entries2.find((item) => item.botId === botId); + const room2 = roster.roomsById[roomId]; + return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; -+ }); + }); } --var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); --var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); --var lineIcon = (paths) => function LineIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) -- ); --}; --var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; --function CloudDownloadIcon({ done = false }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: CLOUD }), -- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) -- ); +-function ScopeSwitch({ value, onChange, bot }) { +- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, +- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), +- options.map(([id, text]) => (0, import_react5.createElement)("button", { +- key: id, +- type: "button", +- role: "radio", +- "aria-checked": value === id, +- className: "bt-secret-seg", +- onClick: () => onChange(id) +- }, text)) +var moreRooms = (count) => count === 1 ? t("1 more room") : t("{count} more rooms", { count }); +function summaryText(busy) { + const asks = busy.filter((entry) => entry.state === "ask"); @@ -9119,80 +9355,17 @@ diff --git a/client.js b/client.js + const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length }); + const first = workers[0]; + const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; -+ return { waiting, doing }; - } --var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); --var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); --var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); --var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); --var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); --var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); --var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); --var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); --var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); --var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); --var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); --var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); --var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); --var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); --var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); --function PinMarkIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- (0, import_react3.createElement)( -- "g", -- { transform: "rotate(45 10 10)" }, -- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), -- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) -- ) -+function Face({ entry, size }) { -+ return (0, import_react13.createElement)( -+ "span", -+ { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, -+ (0, import_react13.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) - ); - } --var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); --var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); --var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); --var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); --var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); --var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); --var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); --var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); --var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); --var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); --var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); --var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); --var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; -- --// src/client/questions.js --var import_react4 = require("react"); --var keyLetter = (index) => String.fromCharCode(65 + index); --var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); --var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; --function useOptionKeys(count, onPick, ownRef) { -- const pick2 = (0, import_react4.useRef)(onPick); -- pick2.current = onPick; -- (0, import_react4.useEffect)(() => { -- if (count === 0) return void 0; -- const onKey = (event) => { -- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -- const index = event.key.toUpperCase().charCodeAt(0) - 65; -- if (index >= 0 && index < count) { -- event.preventDefault(); -- pick2.current(index); -- } else if (index === count && ownRef.current) { -- event.preventDefault(); -- ownRef.current.focus(); -- } -- }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); ++ return { waiting, doing }; ++} ++function Face({ entry, size }) { ++ return (0, import_react13.createElement)( ++ "span", ++ { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, ++ (0, import_react13.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) + ); + } +-function SecretHead({ bot, title, detail, name, onClose }) { +- return (0, import_react5.createElement)( +var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; +function WorkRow({ bot, tool, step, label, at, now, onOpen }) { + const text2 = `${label} · ${workText(tool, step)}`; @@ -9204,108 +9377,7 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react13.createElement)(Shimmer, null, text2)), + elapsed ? (0, import_react13.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); - } --function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -- const [own, setOwn] = (0, import_react4.useState)(""); -- const [picked, setPicked] = (0, import_react4.useState)([]); -- const ownRef = (0, import_react4.useRef)(null); -- const titleId = (0, import_react4.useId)(); -- const options = optionsOf(question); -- const multi = question.multiSelect === true; -- const custom = own.trim(); -- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -- const choose = (label) => { -- if (multi) { -- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -- return; -- } -- onAnswer({ selected: [label] }); -- }; -- const submit = () => { -- if (!canSubmit) return; -- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -- }; -- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { -- ref: ownRef, -- rows: 1, -- value: own, -- autoComplete: "off", -- spellCheck: false, -- className: "bt-q-input", -- "aria-label": t("Your own answer"), -- placeholder: t("Type your own answer"), -- "aria-keyshortcuts": ownLetter?.toLowerCase(), -- onChange: (event) => setOwn(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- submit(); -- } -- }) : null; -- const rows = options.map((option, optionIndex) => { -- const selected = multi && picked.includes(option.label); -- return (0, import_react4.createElement)( -- "button", -- { -- key: option.label, -- type: "button", -- className: "bt-q-opt", -- "aria-pressed": multi ? selected : void 0, -- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), -- onClick: () => choose(option.label) -- }, -- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), -- (0, import_react4.createElement)( -- "span", -- { className: "bt-q-body" }, -- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), -- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null -- ), -- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null -- ); -- }); -- if (raised && field) { -- rows.push((0, import_react4.createElement)( -- "label", -- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, -- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) -- )); -- } -- let foot = null; -- if (raised && multi) { -- foot = (0, import_react4.createElement)( -- "div", -- { className: "bt-q-foot" }, -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) -- ); -- } else if ((raised || multi) && canSubmit) { -- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); -- } -- return (0, import_react4.createElement)( -- "form", -- { -- className: raised ? "bt-q bt-q-raised" : "bt-q", -- "aria-labelledby": titleId, -- onSubmit: (event) => { -- event.preventDefault(); -- submit(); -- } -- }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), -- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, -- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -- ), -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) ++} +function SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; @@ -9319,24 +9391,10 @@ diff --git a/client.js b/client.js + waiting ? (0, import_react13.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, + waiting && doing ? " · " : null, + doing ? (0, import_react13.createElement)(Shimmer, null, doing) : null - ), -- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -- field && !raised ? (0, import_react4.createElement)( -- "div", -- { className: "bt-q-custom" }, -- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), -- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null -- ) : null, -- foot ++ ), + elapsed ? (0, import_react13.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null - ); - } --function SettledQuestion({ question, answer }) { -- const options = optionsOf(question); -- const prompt = question?.question ?? ""; -- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; -- if (chosen.length === 0) { -- return (0, import_react4.createElement)( ++ ); ++} +function MemberDock({ sessionId, useRoster, useWork, actions }) { + const roster = useRoster((value) => value); + const work = useWork((value) => value); @@ -9354,36 +9412,19 @@ diff --git a/client.js b/client.js + const { rows: rows2, rest } = splitBusy(busy); + const open = () => actions.showDetails(sessionId); + return (0, import_react13.createElement)( - "div", -- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), -- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) -- ) ++ "div", + { ref, className: "bt-mw-dock-stack" }, + rows2.map((entry) => (0, import_react13.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), + rest.length > 0 ? (0, import_react13.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null - ); - } -- return (0, import_react4.createElement)( ++ ); ++ } + const rows = groups.slice(0, ROWS_MAX); + const more = groups.length - rows.length; + return (0, import_react13.createElement)( "div", -- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -- const optionIndex = options.findIndex((option) => option.label === label); -- return (0, import_react4.createElement)( -- "div", -- { key: `${label}-${index}`, className: "bt-q-opt" }, -- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), -- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) -- ); -- })) +- { className: "bt-q-head bt-secret-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), +- (0, import_react5.createElement)( + { ref, className: "bt-mw-dock-stack" }, + rows.map((item) => (0, import_react13.createElement)(WorkRow, { + key: item.room.id, @@ -9396,29 +9437,26 @@ diff --git a/client.js b/client.js + onOpen: () => actions.openSession(item.room.id) + })), + more > 0 ? (0, import_react13.createElement)( -+ "div", + "div", +- { className: "bt-q-text" }, +- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), +- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") +- ), +- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null + { className: "bt-mw-row bt-mw-line bt-mw-static" }, + (0, import_react13.createElement)("span", { className: "bt-mw-row-text" }, moreRooms(more)) + ) : null ); } --function QuestionCard({ matched }) { -- const pending = matched; -- const questions = pending.questions ?? []; -- const [index, setIndex] = (0, import_react4.useState)(0); -- const [answers, setAnswers] = (0, import_react4.useState)([]); -- const question = questions[index]; -- if (question === void 0) return null; -- const onAnswer = (answer) => { -- const next = [...answers, { id: question.id, ...answer }]; -- if (index + 1 < questions.length) { -- setAnswers(next); -- setIndex(index + 1); -- return; -- } -- pending.answer({ answers: next }); -- }; -- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); +-function SecretCard({ bot, request, roster, actions }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [scope, setScope] = (0, import_react5.useState)("all"); +- const [replacing, setReplacing] = (0, import_react5.useState)(false); +- const [busy, setBusy] = (0, import_react5.useState)(false); +- const [error, setError] = (0, import_react5.useState)(""); +- const noteId = (0, import_react5.useId)(); +- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); +- const run = (promise) => { +function useFlowColumn(ref, active, key) { + (0, import_react13.useLayoutEffect)(() => { + const node = ref.current; @@ -9444,13 +9482,7 @@ diff --git a/client.js b/client.js + return () => clearInterval(timer); + }, [active]); + return now; - } --function PendingQuestion({ sessionId, question, actions, inline }) { -- const [answered, setAnswered] = (0, import_react4.useState)(null); -- if (answered === question.id) return null; -- const onAnswer = (answer) => { -- setAnswered(question.id); -- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); ++} +var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; +var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ + ["always-bot", "Always allow for this Bot"], @@ -9459,32 +9491,75 @@ diff --git a/client.js b/client.js +function ApprovalCard({ approval, bot, actions }) { + const [busy, setBusy] = (0, import_react13.useState)(false); + const answer = (choice) => { -+ setBusy(true); + setBusy(true); +- setError(""); +- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); +- }; +- const cancel = () => run(actions.secretCancel(request.id)); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); +- }; +- const allow = () => { +- if (!busy) void run(actions.secretAllow(request.id)); + void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); }; -- const onDismiss = () => { -- setAnswered(question.id); -- void actions.dismissQuestion(sessionId); +- const filling = request.kind === "fill" || replacing; +- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; +- return (0, import_react5.createElement)( + return (0, import_react13.createElement)( -+ "div", + "div", +- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, +- (0, import_react5.createElement)(SecretHead, { +- bot, +- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), +- detail: request.purpose, +- name: request.name, +- onClose: busy ? void 0 : cancel +- }), +- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, +- filling ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-body" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), +- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null +- ) : null, +- (0, import_react5.createElement)( + { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, + (0, import_react13.createElement)("div", { className: "bt-mw-tool" }, approval.tool), + approval.detail?.text ? (0, import_react13.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, + approval.reason ? (0, import_react13.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, + approval.detail?.code ? (0, import_react13.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, + approval.answerable ? (0, import_react13.createElement)( -+ "div", + "div", +- { className: "bt-secret-note", id: noteId }, +- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), +- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), +- ".", +- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" +- ), +- warn, +- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, +- (0, import_react5.createElement)( + { className: "bt-mw-actions" }, + (0, import_react13.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), + (0, import_react13.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), + ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react13.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label))) + ) : (0, import_react13.createElement)( -+ "div", + "div", +- { className: "bt-q-foot" }, +- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), +- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) + { className: "bt-mw-actions" }, + (0, import_react13.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) -+ ) -+ ); -+} + ) + ); + } +-function SettledSecret({ bot, request, roster }) { +- const canceled = request.status === "canceled"; +- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); +- return (0, import_react5.createElement)( +function MemberWork({ entry, bot, room: room2, actions, now }) { + const [stopping, setStopping] = (0, import_react13.useState)(false); + if (entry.state === "ask") { @@ -9499,45 +9574,47 @@ diff --git a/client.js b/client.js + const stop = () => { + setStopping(true); + void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); - }; -- return (0, import_react4.createElement)( ++ }; + return (0, import_react13.createElement)( "div", -- { className: inline ? "bt-q-inline" : "bt-q-dock" }, -- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) +- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, +- (0, import_react5.createElement)( + { className: "bt-mw-member" }, + (0, import_react13.createElement)( -+ "div", + "div", +- { className: "bt-q-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), +- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( +- "p", +- { className: "bt-q-title" }, +- text, +- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) +- )), +- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) + { className: "bt-mw-state bt-mw-work" }, + (0, import_react13.createElement)("span", null, (0, import_react13.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), + entry.here && entry.doing.handed !== true ? (0, import_react13.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null -+ ) + ) ); } --function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const question = useRoster((value) => value.questions?.[sessionId]); -- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; -- const loading = useSession?.(historyLoading) === true; -- if (question === void 0 || question.callId && (drawn || loading)) return null; -- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); +-function RequestView({ bot, request, roster, actions }) { +- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); +-} +-function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { +- const bot = roster.byId[sessionId]; +- if (!bot) return null; +- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); +- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); +- if (done && /already saved/.test(contentText(content))) { +- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); +- } +- return null; +function useMemberWork(room2, useWork) { + const work = useWork?.((value) => value) ?? NO_WORK; + const entries2 = room2 ? memberWork(room2, work) : []; + const now = useNow(entries2.some((entry) => entry.state !== "idle")); + return { entries: entries2, now }; - } --function AnsweredCell({ node }) { -- const data = node.data ?? {}; -- const questions = Array.isArray(data.questions) ? data.questions : []; -- if (questions.length === 0) { -- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -- } -- const answers = Array.isArray(data.answers) ? data.answers : []; -- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { -- key: question.id, -- question, -- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -- }))); ++} +var MEMBER_CSS = ` +.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} +.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out} @@ -9624,36 +9701,8 @@ diff --git a/client.js b/client.js + const value = task?.progress; + if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; + return Math.max(0, Math.min(100, Math.round(value))); - } - --// src/client/secret-card.js --var import_react5 = require("react"); --var MIN_VALUE = 4; --var NONE = {}; --var KeyIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), -- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) --); --var LockIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), -- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) --); --var WarnIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), -- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) --); --var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); --var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); --var scopeLabel = (scope, roster) => { -- if (scope === "all") return t("All Bots"); -- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); -- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); ++} ++ +// src/client/inbox.js +var import_react14 = require("react"); +var KEEP_MS = 7 * 864e5; @@ -9663,30 +9712,7 @@ diff --git a/client.js b/client.js +var line = (d) => (0, import_react14.createElement)("path", { d }); +var icon = (...paths) => function StatusIcon() { + return (0, import_react14.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, paths.map(line)); - }; --var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; --function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { -- return (0, import_react5.createElement)("input", { -- className: "bt-secret-input", -- type: MASK_BY_CSS ? "text" : "password", -- "data-masked": MASK_BY_CSS ? "" : void 0, -- value, -- autoFocus, -- autoComplete: MASK_BY_CSS ? "off" : "new-password", -- autoCorrect: "off", -- autoCapitalize: "off", -- spellCheck: false, -- "data-1p-ignore": "", -- "data-lpignore": "true", -- "data-bwignore": "", -- "data-form-type": "other", -- "aria-label": label, -- placeholder: t("Paste or type the key"), -- onChange: (event) => onChange(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- onEnter?.(); ++}; +var RING = "M10 3.25a6.75 6.75 0 1 1 0 13.5 6.75 6.75 0 0 1 0-13.5z"; +var InboxIcon = icon("M3.5 11.5l1.9-6.1A1.5 1.5 0 0 1 6.83 4.35h6.34a1.5 1.5 0 0 1 1.43 1.05l1.9 6.1", "M3.5 11.5v3.25a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V11.5", "M3.5 11.5h3.75l1 1.75h3.5l1-1.75h3.75"); +var AskIcon = icon(RING, "M10 6.75v3.75", "M10 13.25h.01"); @@ -9757,8 +9783,7 @@ diff --git a/client.js b/client.js + }); + load(); + workflowPoll = setInterval(load, POLL_MS3); - } -- }); ++ } + return () => { + workflowListeners.delete(setWorkflows); + if (workflowListeners.size === 0 && workflowPoll !== null) { @@ -9768,22 +9793,7 @@ diff --git a/client.js b/client.js + }; + }, []); + return workflows; - } --function ScopeSwitch({ value, onChange, bot }) { -- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, -- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), -- options.map(([id, text]) => (0, import_react5.createElement)("button", { -- key: id, -- type: "button", -- role: "radio", -- "aria-checked": value === id, -- className: "bt-secret-seg", -- onClick: () => onChange(id) -- }, text)) -- ); ++} +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -10048,19 +10058,7 @@ diff --git a/client.js b/client.js + return Promise.resolve(actions.answerApproval(card.approval.id, choice)); + } + return void 0; - } --function SecretHead({ bot, title, detail, name, onClose }) { -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head bt-secret-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), -- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") -- ), -- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null ++} +var answerSlots = /* @__PURE__ */ new Map(); +var answerSlot = (id) => { + let slot = answerSlots.get(id); @@ -10113,25 +10111,8 @@ diff --git a/client.js b/client.js + redraw((count) => count + 1); + } }, t("Send back")), + (0, import_react14.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) - ); - } --function SecretCard({ bot, request, roster, actions }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [scope, setScope] = (0, import_react5.useState)("all"); -- const [replacing, setReplacing] = (0, import_react5.useState)(false); -- const [busy, setBusy] = (0, import_react5.useState)(false); -- const [error, setError] = (0, import_react5.useState)(""); -- const noteId = (0, import_react5.useId)(); -- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); -- const run = (promise) => { -- setBusy(true); -- setError(""); -- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); -- }; -- const cancel = () => run(actions.secretCancel(request.id)); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); ++ ); ++} +function AnswerFields({ card, busy, onAnswer }) { + const [, redraw] = (0, import_react14.useState)(0); + const question = card.kind === "question"; @@ -10151,9 +10132,7 @@ diff --git a/client.js b/client.js + } + if (custom === "") return; + onAnswer(card, custom); - }; -- const allow = () => { -- if (!busy) void run(actions.secretAllow(request.id)); ++ }; + const choose = (label) => { + if (busy) return; + if (!multi) { @@ -10162,18 +10141,7 @@ diff --git a/client.js b/client.js + } + slot.picked = slot.picked.includes(label) ? slot.picked.filter((item) => item !== label) : [...slot.picked, label]; + redraw((count) => count + 1); - }; -- const filling = request.kind === "fill" || replacing; -- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, -- (0, import_react5.createElement)(SecretHead, { -- bot, -- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), -- detail: request.purpose, -- name: request.name, -- onClose: busy ? void 0 : cancel ++ }; + return (0, import_react14.createElement)( + "span", + { className: "bt-inbox-answer" }, @@ -10201,54 +10169,43 @@ diff --git a/client.js b/client.js + event.preventDefault(); + send(); + } - }), -- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, -- filling ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-body" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), -- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null -- ) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-note", id: noteId }, -- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), -- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), -- ".", -- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" -- ), -- warn, -- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-foot" }, -- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), -- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) -- ) ++ }), + (0, import_react14.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) - ); ++ ); } --function SettledSecret({ bot, request, roster }) { -- const canceled = request.status === "canceled"; -- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); +-function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { ++function InboxRow({ wide, useRoster, useWork, useUi, actions }) { + const roster = useRoster((value) => value); +- const shown = useCards?.((state) => state[sessionId]) ?? NONE; +- const loading = useSession?.(historyLoading) === true; +- const bot = roster.byId[sessionId]; +- if (!bot || currentPart(bot) !== sessionId) return null; +- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); +- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); +- if (waiting.length === 0) return null; +- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); +-} +-function ScopeEditor({ secret, roster, onSave }) { +- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); +- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); +- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); +- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); - return (0, import_react5.createElement)( - "div", -- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), - (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), -- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( -- "p", -- { className: "bt-q-title" }, -- text, -- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -- )), -- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) -- ) -+function InboxRow({ wide, useRoster, useWork, useUi, actions }) { -+ const roster = useRoster((value) => value); +- "div", +- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, +- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( +- "label", +- { key: id, className: "bt-secret-radio" }, +- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), +- text +- )) +- ), +- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { +- key: bot.id, + const work = useWork ? useWork((value) => value) : {}; + const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; + const tasks = useTasks(actions); @@ -10258,7 +10215,16 @@ diff --git a/client.js b/client.js + return (0, import_react14.createElement)( + "button", + { -+ type: "button", + type: "button", +- className: "bt-secret-bot", +- "aria-pressed": picked.includes(bot.id), +- onClick: () => toggle(bot.id) +- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) +- ) + className: "bt-row bt-inbox-row", + "aria-label": label, + title: wide ? void 0 : label, @@ -10271,42 +10237,52 @@ diff --git a/client.js b/client.js + count > 0 ? (0, import_react14.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null ); } --function RequestView({ bot, request, roster, actions }) { -- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); +-function ValueEditor({ secret, onSave }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [replaced, setReplaced] = (0, import_react5.useState)(false); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!long) return; +- void Promise.resolve(onSave(value)).then((ok) => { +- if (ok) { +- setValue(""); +- setReplaced(true); +- } +- }); +- }; +- const edit = (next) => { +- setValue(next); +- setReplaced(false); +- }; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-inline" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) +- ), +- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null +- ); +function Status({ status }) { + const { label, tone, Icon } = STATUSES[status]; + return (0, import_react14.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react14.createElement)(Icon), (0, import_react14.createElement)("span", null, t(label))); } --function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -- const bot = roster.byId[sessionId]; -- if (!bot) return null; -- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); -- if (done && /already saved/.test(contentText(content))) { -- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); +-function SecretRow({ secret, roster, actions, run, open, onToggle }) { +- const [confirm, setConfirm] = (0, import_react5.useState)(false); +- (0, import_react5.useEffect)(() => { +- if (!open) setConfirm(false); +- }, [open]); +- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); +- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); +- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); +- return (0, import_react5.createElement)( +function whenText(card, now) { + if (card.status === "running" || card.status === "task") { + return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); - } -- return null; --} --function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const roster = useRoster((value) => value); -- const shown = useCards?.((state) => state[sessionId]) ?? NONE; -- const loading = useSession?.(historyLoading) === true; -- const bot = roster.byId[sessionId]; -- if (!bot || currentPart(bot) !== sessionId) return null; -- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -- if (waiting.length === 0) return null; -- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); --} --function ScopeEditor({ secret, roster, onSave }) { -- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); -- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); -- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); -- return (0, import_react5.createElement)( ++ } + return typeof card.at === "number" ? relativeShort(card.at) : ""; +} +function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) { @@ -10322,28 +10298,30 @@ diff --git a/client.js b/client.js + const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; + return (0, import_react14.createElement)( "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), +- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, - (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, -- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( -- "label", -- { key: id, className: "bt-secret-radio" }, -- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), -- text -- )) +- "button", +- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, +- (0, import_react5.createElement)( + { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status, "data-run": card.kind === "workflow" ? card.run?.id : void 0 }, + (0, import_react14.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react14.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react14.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), + (0, import_react14.createElement)( + "span", + { className: "bt-inbox-copy" }, + (0, import_react14.createElement)( -+ "span", + "span", +- { className: "bt-set-copy" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) + { className: "bt-inbox-top" }, + (0, import_react14.createElement)(Status, { status: card.status }), + (0, import_react14.createElement)("span", { className: "bt-inbox-meta", title: meta }, meta) -+ ), + ), +- (0, import_react5.createElement)( +- "span", +- { className: "bt-set-control" }, +- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) +- ) + (0, import_react14.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title), + card.calls > 0 ? (0, import_react14.createElement)("span", { className: "bt-inbox-calls" }, t("{count} tool calls", { count: card.calls })) : null, + card.reason ? (0, import_react14.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, failureSentence(card.reason, t)) : null, @@ -10370,17 +10348,21 @@ diff --git a/client.js b/client.js + ...locked ? [] : [(0, import_react14.createElement)(AnswerFields, { card, busy, onAnswer }), (0, import_react14.createElement)(ResultActions, { card, busy, onAnswer })], + error ? (0, import_react14.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null ), -- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { -- key: bot.id, -- type: "button", -- className: "bt-secret-bot", -- "aria-pressed": picked.includes(bot.id), -- onClick: () => toggle(bot.id) -- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, -- (0, import_react5.createElement)( +- open ? (0, import_react5.createElement)( - "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) +- { className: "bt-secret-edit" }, +- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), +- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- confirm ? [ +- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), +- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), +- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) +- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) +- ) +- ) : null + (0, import_react14.createElement)( + "span", + { className: "bt-inbox-actions" }, @@ -10388,25 +10370,22 @@ diff --git a/client.js b/client.js + answerable ? (0, import_react14.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "allow") }, t("Allow once")) : null, + ...(answerable ? alwaysChoices(card.approval, card.bot) : []).map(([choice, label]) => (0, import_react14.createElement)("button", { key: choice, type: "button", className: "bt-soft bt-soft-sm", "data-choice": choice, disabled: busy, onClick: () => onAnswer(card, choice) }, t(label))), + (0, import_react14.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": who ? t("Open {name}", { name: card.room?.name ?? who }) : t("Open"), onClick: () => onOpen(card) }, t("Open")) - ) ++ ) ); } --function ValueEditor({ secret, onSave }) { +-function AddSecret({ actions, run, onDone }) { +- const [name, setName] = (0, import_react5.useState)(""); +- const [purpose, setPurpose] = (0, import_react5.useState)(""); - const [value, setValue] = (0, import_react5.useState)(""); -- const [replaced, setReplaced] = (0, import_react5.useState)(false); -- const long = value.trim().length >= MIN_VALUE; +- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; - const save = () => { -- if (!long) return; -- void Promise.resolve(onSave(value)).then((ok) => { +- if (!ready) return; +- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { - if (ok) { - setValue(""); -- setReplaced(true); +- onDone(); - } - }); -- }; -- const edit = (next) => { -- setValue(next); -- setReplaced(false); +function useNow2(ms) { + const [now, setNow] = (0, import_react14.useState)(Date.now); + (0, import_react14.useEffect)(() => { @@ -10462,39 +10441,19 @@ diff --git a/client.js b/client.js + const open = (card) => openCard(actions, card); + return (0, import_react14.createElement)( "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), +- { className: "bt-set-row bt-set-paste bt-secret-add" }, +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), - (0, import_react5.createElement)( + { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, + (0, import_react14.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react14.createElement)( "div", -- { className: "bt-secret-inline" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) -- ), -- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null -- ); --} --function SecretRow({ secret, roster, actions, run, open, onToggle }) { -- const [confirm, setConfirm] = (0, import_react5.useState)(false); -- (0, import_react5.useEffect)(() => { -- if (!open) setConfirm(false); -- }, [open]); -- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); -- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); -- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, -- (0, import_react5.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) + { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" }, + (0, import_react14.createElement)( + "div", @@ -10503,27 +10462,9 @@ diff --git a/client.js b/client.js + (0, import_react14.createElement)("span", { className: "bt-mem-status" }, t("Your Bots")), + (0, import_react14.createElement)("span", { className: "bt-mem-head-gap" }), + (0, import_react14.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close inbox"), autoFocus: true, onClick: close }, (0, import_react14.createElement)(CloseIcon)) - ), -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-control" }, -- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) -- ) -- ), -- open ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit" }, -- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), -- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), -- (0, import_react5.createElement)( ++ ), + (0, import_react14.createElement)( - "div", -- { className: "bt-secret-row-actions" }, -- confirm ? [ -- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), -- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), -- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) -- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) ++ "div", + { className: "bt-mem-body bt-inbox-body" }, + ...locked ? [(0, import_react14.createElement)("p", { key: "readonly", className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly))] : [], + (0, import_react14.createElement)("div", { className: "bt-inbox-feedback", role: "status", "aria-live": "polite" }, feedback), @@ -10561,36 +10502,7 @@ diff --git a/client.js b/client.js + }, t(allDone ? "Show fewer" : "Show all")) : null + ); + }) - ) -- ) : null -- ); --} --function AddSecret({ actions, run, onDone }) { -- const [name, setName] = (0, import_react5.useState)(""); -- const [purpose, setPurpose] = (0, import_react5.useState)(""); -- const [value, setValue] = (0, import_react5.useState)(""); -- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!ready) return; -- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { -- if (ok) { -- setValue(""); -- onDone(); -- } -- }); -- }; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-set-row bt-set-paste bt-secret-add" }, -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) ++ ) ) ); } @@ -11607,14 +11519,14 @@ diff --git a/client.js b/client.js - }); - return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); + return items; - } --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( ++} +function roleNote(answer) { + if (answer?.changed === true) return t("Role changed."); + const reason = typeof answer?.reason === "string" && answer.reason !== "" ? answer.reason : t("refused"); + return t("Role unchanged: {reason}", { reason }); -+} + } +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +function ProjectList({ projects, error, note, onOpen, onRole }) { + const rows = projects === null ? (0, import_react17.createElement)("div", { className: "bt-projects-note" }, t("Reading Projects…")) : error !== "" ? (0, import_react17.createElement)("div", { className: "bt-projects-note" }, error) : projects.length === 0 ? (0, import_react17.createElement)("div", { className: "bt-projects-empty" }, t("No Projects yet")) : projects.map((project) => (0, import_react17.createElement)( "button", diff --git a/patches/ds-bot/files/test/chat-link-open.test.js b/patches/ds-bot/files/test/chat-link-open.test.js index cf4f2de91..92c20ab53 100644 --- a/patches/ds-bot/files/test/chat-link-open.test.js +++ b/patches/ds-bot/files/test/chat-link-open.test.js @@ -1,8 +1,17 @@ -// #2201: a link in a chat message was left to the host, which opens it in its own right sidebar — a -// browser tab FlyBot's fixed panel covers, so the content area looked blank and no error appeared. -// The link now opens a tab in FlyBot's own panel, the browser view the globe button already uses, and -// carries the URL there. Each case clicks the anchor MarkdownText draws and reads the action the page -// really calls; without the Desktop bridge there is no panel, so the page opens in the window itself. +// #2201 / #2319: what a pick inside a chat bubble does, and what the bubble must leave alone. +// +// #2201: a link left to the host opens in the host's right sidebar — a browser tab FlyBot's fixed panel +// covers, so the content area looked blank. The panel's own browser view carries the URL now, and a +// window without the Desktop bridge opens the page itself. +// +// #2319: the first fix wrapped every bubble in a Markdown delegate of its own, and a nested +// MarkdownDelegateProvider REPLACES the enclosing capabilities: the host's file opener and image +// resolver stopped reaching the bubble, so `![shot](screenshots/a.png)` drew the alt text `shot`, and a +// local `[x](path#L12)` link lost its chip. The harness below models that replacement semantics and +// renders an image through the resolver, which the earlier stub could not see. +// +// The click cases drive the capture handler the containers install, the way a browser dispatches one: +// capture on the way down, then the anchor's own handler when nothing stopped the event. import { test } from 'node:test' import assert from 'node:assert/strict' import { fileURLToPath } from 'node:url' @@ -10,28 +19,68 @@ import { runInNewContext } from 'node:vm' import { build } from 'esbuild' const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) -const all = (node, out = []) => { - if (Array.isArray(node)) { for (const child of node) all(child, out); return out } - if (node && typeof node === 'object') { out.push(node); for (const child of [node.props?.children].flat(Infinity)) all(child, out) } - return out -} -// The platform's Markdown scope: the nearest provider hands the link handler down, and the anchor -// MarkdownText draws calls it exactly as the real one does on a plain HTTP(S) click. -const delegate = { openExternalLink: undefined, openFile: undefined, fileImages: undefined } -function MarkdownDelegateProvider({ openExternalLink, openFile, fileImages, children }) { - Object.assign(delegate, { openExternalLink, openFile, fileImages }) - return children +// --------------------------------------------------------------------------- +// The platform's Markdown scope +// +// `MarkdownDelegateProvider` starts a context from exactly its own props: "Nested providers replace +// the enclosing capabilities" (dsh-client-ui-primitives). `scope` is that context; a provider element +// swaps it wholesale — never merges — so an inner provider that lacks `fileImages` hides the outer one, +// which is what #2313 did to every bubble. +const scope = { current: null } +function MarkdownDelegateProvider({ children }) { return children } +MarkdownDelegateProvider.__replacesScope = true + +// A DOM node is only needed for `event.target.closest(...)`; `mentionTarget` checks `instanceof Element`. +class Element {} +const anchorTarget = (href) => Object.assign(new Element(), { + href, + getAttribute: (name) => (name === 'href' ? href : null), + closest(selector) { + if (selector === 'a[href]') return this + const prefix = /^a\[href\^="(.*)"\]$/.exec(selector) + if (prefix && href.startsWith(prefix[1])) return this + return null + }, +}) +const plainTarget = () => Object.assign(new Element(), { closest: () => null }) + +// MarkdownText as the host renders it: an image resolves through the nearest `fileImages` (otherwise it +// falls back to the alt span), an http(s) destination becomes an anchor whose own handler is the host's +// `openExternalLink`, and anything else is a file link that needs `openFile` to become a chip. +function MarkdownImage({ destination, alt }) { + const fileImages = scope.current?.fileImages + const resolved = fileImages?.resolve?.(destination) + if (typeof resolved !== 'string') return h('span', { className: 'imageAlt' }, alt) + return h('img', { className: 'bt-md-image', src: resolved, alt, preview: fileImages }) +} +function MarkdownFileLink({ destination, children }) { + // The opener is read while the bubble renders, the way React hands a context value to a handler. + const openFile = scope.current?.openFile + if (typeof openFile !== 'function') return h('span', { className: 'bt-md-file-plain' }, children) + return h('span', { className: 'bt-md-file', onClick: (event) => { event?.preventDefault?.(); openFile(destination) } }, children) } -const LINK = /\]\((https?:\/\/[^)\s]+)\)/ function MarkdownText({ text }) { - const href = String(text ?? '').match(LINK)?.[1] - if (!href) return h('span', { className: 'bt-md', text }) - return h('a', { - className: 'bt-md-link', - href, - onClick: (event) => { event?.preventDefault?.(); delegate.openExternalLink?.(href) }, - }) + const source = String(text ?? '') + const token = /!\[([^\]]*)\]\(([^)\s]+)\)|\[([^\]]+)\]\(([^)\s]+)\)/g + const parts = [] + let last = 0 + for (let match = token.exec(source); match; match = token.exec(source)) { + if (match.index > last) parts.push(source.slice(last, match.index)) + if (match[1] !== undefined) parts.push(h(MarkdownImage, { destination: match[2], alt: match[1] })) + else { + const destination = match[4] + const openExternalLink = scope.current?.openExternalLink + // MarkdownAnchor: a modified or middle click is the browser's (new tab, new window), so the + // host delegate is not called and nothing is prevented. + parts.push(/^https?:/i.test(destination) + ? h('a', { className: 'bt-md-link', href: destination, onClick: (event) => { if (event?.button !== 0 || event?.metaKey || event?.ctrlKey || event?.shiftKey || event?.altKey) return; event?.preventDefault?.(); openExternalLink?.(destination) } }) + : h(MarkdownFileLink, { destination }, match[3])) + } + last = token.lastIndex + } + if (last < source.length) parts.push(source.slice(last)) + return h('span', { className: 'bt-md' }, ...parts) } // One state slot set per component path, effects only when their deps change, setters mark the tree @@ -79,6 +128,7 @@ const bundle = await build({ const opened = [] const env = { module: { exports: {} }, + Element, document: { body: {}, hidden: false, addEventListener() {}, removeEventListener() {} }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, @@ -92,26 +142,102 @@ env.require = (name) => { throw new Error(`Unexpected platform module ${name}`) } runInNewContext(bundle.outputFiles[0].text, env) -const { Bubbles } = env.module.exports +const { Bubbles, GroupReplies, TriggerCell } = env.module.exports -// Function components run the way React runs them, so the provider and the bubble see their props. +// Function components run the way React runs them, so the provider and the bubble see their props. A +// provider element replaces the scope for the children it renders and restores it afterwards. const expand = (node, path = 'r') => { if (node === null || node === undefined || typeof node === 'boolean') return null if (Array.isArray(node)) return node.map((child, at) => expand(child, `${path}.${at}`)) if (typeof node !== 'object') return node - if (typeof node.type === 'function') return withInstance(path, () => expand(node.type(node.props), path)) + if (typeof node.type === 'function') { + if (node.type.__replacesScope) { + const outer = scope.current + scope.current = { openExternalLink: node.props.openExternalLink, openFile: node.props.openFile, fileImages: node.props.fileImages } + try { return expand(node.props.children, `${path}.scope`) } finally { scope.current = outer } + } + return withInstance(`${path}:${node.type.name || 'anon'}`, () => expand(node.type(node.props), path)) + } return { ...node, props: { ...node.props, children: expand(node.props.children, path) } } } -const clickLink = (tree) => { - const anchor = all(tree).find(node => node.type === 'a') - assert.ok(anchor, 'the bubble draws the link as an anchor') - anchor.props.onClick({ preventDefault() {} }) - return anchor.props.href + +const runQueuedEffects = () => { + for (const inst of runtime.instances.values()) { + for (const entry of inst.pending.splice(0)) { + const previous = inst.deps.get(entry.index) + const deps = entry.deps + const changed = !previous || !deps || previous.length !== deps.length || deps.some((value, at) => value !== previous[at]) + if (!changed) continue + inst.deps.set(entry.index, deps) + entry.run() + } + } +} + +// Render, let the effects of that render run, wait for the page they ask for, render again: what a +// mounted component does over two paints, without a DOM. +const settle = async (node, delegate, rounds = 6) => { + let tree = withHost(node, delegate) + for (let round = 0; round < rounds; round++) { + runQueuedEffects() + await new Promise((resolve) => setImmediate(resolve)) + tree = withHost(node, delegate) + } + return tree +} + +// The host wraps the node list in its own provider: that is where `openFile` and `fileImages` live, +// and the host never hands `fileImages` to the node itself (#2319). +const withHost = (node, delegate) => { + const outer = scope.current + scope.current = delegate + try { return expand(node) } finally { scope.current = outer } +} + +const find = (node, match, parents = []) => { + if (Array.isArray(node)) { + for (const child of node) { const found = find(child, match, parents); if (found) return found } + return null + } + if (!node || typeof node !== 'object') return null + if (match(node)) return { node, parents } + for (const child of [node.props?.children].flat(Infinity)) { + const found = find(child, match, [...parents, node]) + if (found) return found + } + return null +} +// A browser dispatches a click down the tree (capture) and then to the target, unless a capture +// handler stopped it. Only the left button without modifiers reaches the target's own handler. +const clickOn = (tree, targetNode, { button = 0, metaKey = false, ctrlKey = false, shiftKey = false, altKey = false } = {}) => { + const found = find(tree, (node) => node === targetNode) + assert.ok(found, 'the clicked node is in the rendered tree') + const event = { + target: targetNode.type === 'a' && typeof targetNode.props?.href === 'string' ? anchorTarget(targetNode.props.href) : plainTarget(), + button, metaKey, ctrlKey, shiftKey, altKey, + defaultPrevented: false, stopped: false, + preventDefault() { this.defaultPrevented = true }, + stopPropagation() { this.stopped = true }, + } + for (const parent of found.parents) { + const handler = parent.props?.onClickCapture + if (typeof handler !== 'function') continue + handler(event) + if (event.stopped) return event + } + const onClick = targetNode.props?.onClick + if (button === 0 && typeof onClick === 'function') onClick(event) + return event +} +const anchorIn = (tree) => { + const found = find(tree, (node) => node.type === 'a') + assert.ok(found, 'the bubble draws the link as an anchor') + return found.node } // The panel the way index.js drives it: browserNewTab puts a tab in the Bot's strip and moves the // panel to the browser view (null when the Bot is not in the roster), browserNavigate loads the tab. -const panelHarness = () => { +const panelHarness = (roster = ROSTER) => { const panel = { details: null, view: 'details', renaming: false, tab: null } const tabs = new Map() const calls = [] @@ -121,7 +247,7 @@ const panelHarness = () => { useUi: (read) => read(panel), browserNewTab: (id) => { calls.push(['tab', id]) - if (!ROSTER.byId[id]) return null + if (!roster.byId[id]) return null const tabId = `t${++next}` tabs.set(tabId, null) Object.assign(panel, { details: id, view: 'browser', renaming: false, tab: null }) @@ -135,18 +261,29 @@ const panelHarness = () => { }, } } - const ROSTER = { bots: [], byId: { b1: { id: 'b1', name: 'FlyBot', color: 'blue' } }, roomsById: {} } +// The host's own delegate: what the outer provider carried before #2313 and carries again after #2319. +const hostScope = ({ openFile, fileImages } = {}) => { + const external = [] + return { external, openFile, fileImages, delegate: { openExternalLink: (href) => external.push(href), openFile, fileImages } } +} + +// --------------------------------------------------------------------------- +// #2201: the page opens in FlyBot's own panel test('#2201: a link in an assistant bubble opens FlyBot\'s own panel with the URL', () => { const p = panelHarness() const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate, toggleReaction() {} } - const tree = expand(h(Bubbles, { texts: ['see [the issue](https://github.com/o/r/issues/2) for detail'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi })) - const href = clickLink(tree) + const host = hostScope({}) + const tree = withHost(h(Bubbles, { texts: ['see [the issue](https://github.com/o/r/issues/2) for detail'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi }), host.delegate) + const anchor = anchorIn(tree) + const href = anchor.props.href + clickOn(tree, anchor) assert.deepEqual(p.calls, [['tab', 'b1'], ['navigate', 't1', href]], 'the link opens a tab in FlyBot\'s panel and carries the URL there') assert.equal(p.tabs.get('t1'), href, 'the tab shows the URL the link pointed at') assert.equal(p.panel.view, 'browser', 'the panel is on the browser view, not the details panel that covered the page') assert.equal(p.panel.details, 'b1', 'the panel is showing the Bot whose chat the link was in') + assert.deepEqual(host.external, [], 'the host never sees a page link that FlyBot opened') }) test('#2201: the click does not depend on the details panel being open or closed', () => { @@ -155,8 +292,8 @@ test('#2201: the click does not depend on the details panel being open or closed const p = panelHarness() Object.assign(p.panel, { details, view: 'details' }) const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } - const tree = expand(h(Bubbles, { texts: [`[x](${url})`], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi })) - clickLink(tree) + const tree = withHost(h(Bubbles, { texts: [`[x](${url})`], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi }), hostScope({}).delegate) + clickOn(tree, anchorIn(tree)) assert.deepEqual(p.calls, [['tab', 'b1'], ['navigate', 't1', url]], `the panel opens the same way with details ${details ? 'open' : 'closed'}`) assert.equal(p.panel.view, 'browser', 'the view the person lands on is the browser view either way') assert.equal(p.tabs.get('t1'), url) @@ -167,13 +304,15 @@ test('#2201: a Bot outside the roster gets no tab and the page opens in the wind opened.length = 0 const p = panelHarness() const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } - const tree = expand(h(Bubbles, { texts: ['[x](https://example.com/b)'], roster: ROSTER, selfId: 'ghost', actions, sessionId: 'ghost', useUi: p.useUi })) + const host = hostScope({}) + const tree = withHost(h(Bubbles, { texts: ['[x](https://example.com/b)'], roster: ROSTER, selfId: 'ghost', actions, sessionId: 'ghost', useUi: p.useUi }), host.delegate) let href - assert.doesNotThrow(() => { href = clickLink(tree) }, 'a missing tab falls back instead of throwing') + assert.doesNotThrow(() => { href = anchorIn(tree).props.href; clickOn(tree, anchorIn(tree)) }, 'a missing tab falls back instead of throwing') assert.deepEqual(p.calls, [['tab', 'ghost']], 'no navigation is attempted when browserNewTab answers null') assert.equal(p.panel.view, 'details', 'the panel is left where it was') // Exactly one window: a fallback here and another in the host would open the page twice. - assert.deepEqual(opened, [[href, '_blank', 'noopener']], 'the page opens in the window exactly once') + assert.deepEqual(opened, [[href, '_blank', 'noopener,noreferrer']], 'the page opens in the window exactly once') + assert.deepEqual(host.external, [], 'the host does not open a second copy') }) test('#2201: a room bubble targets the Bot that spoke, not the room id', () => { @@ -182,8 +321,10 @@ test('#2201: a room bubble targets the Bot that spoke, not the room id', () => { opened.length = 0 const p = panelHarness() const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } - const tree = expand(h(Bubbles, { texts: ['[x](https://example.com/room)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'room-1', useUi: p.useUi })) - const href = clickLink(tree) + const tree = withHost(h(Bubbles, { texts: ['[x](https://example.com/room)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'room-1', useUi: p.useUi }), hostScope({}).delegate) + const anchor = anchorIn(tree) + const href = anchor.props.href + clickOn(tree, anchor) assert.deepEqual(p.calls, [['tab', 'b1'], ['navigate', 't1', href]], 'the tab opens for the Bot whose message carried the link') assert.equal(p.tabs.get('t1'), href, 'the tab carries the URL') assert.deepEqual(opened, [], 'nothing falls back to the window when the panel opened the link') @@ -191,32 +332,179 @@ test('#2201: a room bubble targets the Bot that spoke, not the room id', () => { test('#2201: without the Desktop bridge the page opens in the window itself', () => { opened.length = 0 - const actions = {} - const tree = expand(h(Bubbles, { texts: ['[x](https://example.com/a)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: (read) => read({ details: null, view: 'details' }) })) - const href = clickLink(tree) - assert.deepEqual(opened, [[href, '_blank', 'noopener']], 'the link opens in the window, exactly once') + const tree = withHost(h(Bubbles, { texts: ['[x](https://example.com/a)'], roster: ROSTER, selfId: 'b1', actions: {}, sessionId: 'b1', useUi: (read) => read({ details: null, view: 'details' }) }), hostScope({}).delegate) + const anchor = anchorIn(tree) + const href = anchor.props.href + clickOn(tree, anchor) + assert.deepEqual(opened, [[href, '_blank', 'noopener,noreferrer']], 'the link opens in the window, exactly once') +}) + +// --------------------------------------------------------------------------- +// #2319: the bubble keeps the host's file resolver and opener + +test('#2319: a bubble image resolves through the host and is not drawn as its alt text', () => { + // The host gives `fileImages` to its own provider, never to the node (#2319 root cause). A bubble + // that starts a delegate without it replaces the resolver with `undefined` and MarkdownImage falls + // back to . This case is red on e40cecf6 and green once the bubble is bare. + const fileImages = { resolve: (path) => `/media/${path}`, labels: { open: 'Open', close: 'Close' } } + const host = hostScope({ fileImages }) + const tree = withHost(h(Bubbles, { texts: ['![shot](screenshots/a.png)'], roster: ROSTER, selfId: 'b1', actions: {}, sessionId: 'b1', useUi: (read) => read({}) }), host.delegate) + const image = find(tree, (node) => node.type === 'img')?.node + assert.ok(image, 'the image renders as an image, not as the alt span') + assert.equal(image.props.src, '/media/screenshots/a.png', 'the source comes from the host resolver') + assert.equal(image.props.preview, fileImages, 'the lightbox opens through the same host resolver') + assert.equal(find(tree, (node) => node.type === 'span' && node.props?.className === 'imageAlt'), null, 'nothing was drawn as alt text') }) -test('#2201: the bubble keeps the host\'s file opener and image resolver', () => { - // The nearest MarkdownDelegateProvider replaces the enclosing capabilities, so a provider that - // carried only openExternalLink dropped openFile (a local `[x](path#L12)` link rendered as plain - // text instead of a chip) and fileImages (local images). Bubbles is where the host hands them - // over; the delegate it builds must carry both on to MarkdownText. - const openFile = (path) => { throw new Error(`not called here: ${path}`) } - const fileImages = { resolve: (path) => `/media/${path}`, labels: { open: 'Open', loading: 'Loading', failed: 'Failed', dialog: 'Image', close: 'Close' } } +test('#2319: a local file link keeps the host\'s chip and its opener', () => { + opened.length = 0 + const openedFiles = [] + const host = hostScope({ openFile: (path) => openedFiles.push(path) }) const p = panelHarness() const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } - expand(h(Bubbles, { texts: ['[x](https://example.com/a)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi, openFile, fileImages })) - assert.equal(delegate.openFile, openFile, 'the delegate carries the host\'s openFile, so local file links stay chips') - assert.equal(delegate.fileImages, fileImages, 'the delegate carries the host\'s fileImages, so local images keep previewing') + const tree = withHost(h(Bubbles, { texts: ['[cells](core/src/client/cells.js#L12)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi }), host.delegate) + const chip = find(tree, (node) => node.type === 'span' && node.props?.className === 'bt-md-file')?.node + assert.ok(chip, 'the host openFile draws the file chip; without it the link is plain text') + clickOn(tree, chip) + assert.deepEqual(openedFiles, ['core/src/client/cells.js#L12'], 'the click reaches the host file opener') + assert.deepEqual(p.calls, [], 'a file is not a page: FlyBot\'s panel is not touched') + assert.deepEqual(opened, [], 'and nothing opens in the window') +}) + +// --------------------------------------------------------------------------- +// #2319: clicks the bubble must leave alone + +test('#2319: a modifier or middle click is left to the host', () => { + const cases = [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }] + for (const options of cases) { + opened.length = 0 + const p = panelHarness() + const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } + const host = hostScope({}) + const tree = withHost(h(Bubbles, { texts: ['[x](https://example.com/a)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi }), host.delegate) + const event = clickOn(tree, anchorIn(tree), options) + const label = Object.keys(options).join('+') + assert.deepEqual(p.calls, [], `${label}: FlyBot's panel is not involved`) + assert.deepEqual(opened, [], `${label}: FlyBot opens no window`) + // The click is the browser's: the bubble neither prevents it nor hands it to the host delegate + // (MarkdownAnchor ignores a modified or middle click itself, like the platform round it models). + assert.equal(event.defaultPrevented, false, `${label}: the bubble does not claim the click`) + assert.deepEqual(host.external, [], `${label}: the host delegate is not asked to open it`) + } }) -test('#2201: a tab with no navigation falls back to the window instead of doing nothing', () => { +test('#2319: a mention still opens the Bot part and is not taken for a page', () => { + // A mention is an https link to the origin the plugin rewrites names into, so the page rule must not + // swallow it: the capture handler opens the part, as it did before the link fix. + const roster = { bots: [{ id: 'b2', name: 'Chief', color: 'blue' }], byId: { b1: { id: 'b1', name: 'FlyBot', color: 'blue' }, b2: { id: 'b2', name: 'Chief', color: 'blue' } }, roomsById: {} } opened.length = 0 - const p = panelHarness() - const actions = { browserNewTab: p.browserNewTab } - const tree = expand(h(Bubbles, { texts: ['[x](https://example.com/c)'], roster: ROSTER, selfId: 'b1', actions, sessionId: 'b1', useUi: p.useUi })) - let href - assert.doesNotThrow(() => { href = clickLink(tree) }, 'a tab without browserNavigate does not throw') - assert.deepEqual(opened, [[href, '_blank', 'noopener']], 'the page still opens, exactly once') + const sessions = [] + const actions = { openSession: (id) => sessions.push(id) } + const host = hostScope({}) + const tree = withHost(h(Bubbles, { texts: ['ask @Chief'], roster, selfId: 'b1', actions, sessionId: 'b1', useUi: (read) => read({}) }), host.delegate) + const mention = find(tree, (node) => node.type === 'a' && String(node.props?.href ?? '').startsWith('https://bots.local/'))?.node + assert.ok(mention, 'a mention is drawn as an anchor') + clickOn(tree, mention) + assert.deepEqual(sessions, ['b2'], 'the mention opens the Bot part') + assert.deepEqual(host.external, [], 'the host delegate never sees a mention') + assert.deepEqual(opened, [], 'a mention is not a page') +}) + +test('#2319: the group replies card keeps its own button clickable', () => { + // GroupReplies gains a capture handler for links; its own controls must still receive a click that + // has no anchor in it (the Lead's guard against swallowing button presses). + opened.length = 0 + const details = [] + const actions = { showDetails: (id) => details.push(id), roomProgress: async () => [] } + const content = [{ type: 'text', text: JSON.stringify({ replies: [{ kind: 'working', botId: 'b1', name: 'FlyBot', text: 'working' }] }) }] + const tree = withHost(h(GroupReplies, { sessionId: 'b1', callId: 'c1', done: true, content, roster: ROSTER, actions, useUi: (read) => read({}) }), hostScope({}).delegate) + const button = find(tree, (node) => node.type === 'button')?.node + assert.ok(button, 'the reply card draws its button') + clickOn(tree, button) + assert.deepEqual(details, ['b1'], 'the capture handler leaves a button press alone') + assert.deepEqual(opened, [], 'and opens nothing') +}) + +// --------------------------------------------------------------------------- +// #2319 review: the click belongs to the line the person clicked + +test('#2319: a link in another Bot\'s reply opens that Bot\'s tab, not the room', () => { + // GroupReplies puts one capture handler on the card in the first cut of the fix; it claimed every + // anchor with the room as the subject, so a reply's own link fell to the window instead of opening + // the tab of the Bot that spoke. The Bot's own bubble carries the handler now. + opened.length = 0 + const roster = { bots: [{ id: 'b2', name: 'Chief', color: 'blue' }], byId: { b1: { id: 'b1', name: 'FlyBot', color: 'blue' }, b2: { id: 'b2', name: 'Chief', color: 'blue' } }, roomsById: {} } + const p = panelHarness(roster) + const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } + const url = 'https://example.com/reply' + const content = [{ type: 'text', text: JSON.stringify({ replies: [{ kind: 'bot', botId: 'b2', name: 'Chief', text: `see [the page](${url})` }] }) }] + const host = hostScope({}) + const tree = withHost(h(GroupReplies, { sessionId: 'room-1', callId: 'c1', done: true, content, roster, actions, useUi: p.useUi }), host.delegate) + clickOn(tree, anchorIn(tree)) + assert.deepEqual(p.calls, [['tab', 'b2'], ['navigate', 't1', url]], 'the tab belongs to the Bot whose reply carried the link') + assert.equal(p.tabs.get('t1'), url, 'the tab carries the URL') + assert.deepEqual(opened, [], 'the page does not fall back to a bare window') + assert.deepEqual(host.external, [], 'the host delegate never sees it') +}) + +test('#2319: a link on the person\'s own line in a group card is still claimed', () => { + // The capture handler moved from the card onto this row; a row with no handler would leave the link + // to the host, which opens it in the sidebar the panel covers (#2201). + opened.length = 0 + const roster = { bots: [], byId: { b1: { id: 'b1', name: 'FlyBot', color: 'blue' } }, roomsById: {} } + const p = panelHarness(roster) + const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate } + const url = 'https://example.com/mine' + const content = [{ type: 'text', text: JSON.stringify({ replies: [{ kind: 'user', authorId: 'u1', name: 'Ann', text: `[mine](${url})` }] }) }] + const host = hostScope({}) + const tree = withHost(h(GroupReplies, { sessionId: 'room-1', callId: 'c1', done: true, content, roster, actions, useUi: p.useUi }), host.delegate) + const row = find(tree, (node) => node.type === 'div' && node.props?.className === 'bt-urow')?.node + assert.ok(row, 'the card draws the person\'s own line') + const event = clickOn(tree, find(row, (node) => node.type === 'a')?.node) + assert.equal(event.defaultPrevented, true, 'the row claims the click, as the card did before') + assert.equal(event.stopped, true, 'and nothing else sees it') + assert.deepEqual(opened, [[url, '_blank', 'noopener,noreferrer']], 'the page opens once, in the window') + assert.deepEqual(host.external, [], 'the host never sees it') +}) + +test('#2319: an earlier part claims its own lines and leaves a Bot line to that Bot', async () => { + // EarlierParts is not exported; a handoff line mounts it through TriggerCell. Both kinds of row use + // the same subject here, so the point is that a link on either one is still claimed by this page and + // never falls to the host (the handler moved from the page container onto the person's row). + opened.length = 0 + const roster = { bots: [{ id: 'b1', name: 'FlyBot', color: 'blue' }], byId: { b1: { id: 'b1', name: 'FlyBot', color: 'blue' } }, roomsById: {}, exchangeTurns: {} } + const p = panelHarness(roster) + const mine = 'https://example.com/earlier-u' + const bot = 'https://example.com/earlier-b' + const page = { + items: [ + { kind: 'user', text: `[mine](${mine})`, sessionId: 'b1', turn: 1, seq: 1, time: 1 }, + { kind: 'bot', text: `[bot](${bot})`, sessionId: 'b1', turn: 1, seq: 2, time: 2 }, + ], + cursor: undefined, + startedAt: 1, + } + const actions = { browserNewTab: p.browserNewTab, browserNavigate: p.browserNavigate, earlier: () => Promise.resolve(page), noteTurnPeer() {} } + const node = h(TriggerCell, { + node: { data: { source: { kind: 'bot', handoff: true }, turn: 4, content: [] } }, + sessionId: 'b1', + useRoster: (read) => read(roster), + useSessions: (read) => read({ projectionsBySession: {}, byId: {} }), + useTurnClock: (read) => read({}), + useUi: p.useUi, + actions, + }) + const host = hostScope({}) + const tree = await settle(node, host.delegate) + const userRow = find(tree, (candidate) => candidate.type === 'div' && candidate.props?.className === 'bt-urow')?.node + assert.ok(userRow, 'the earlier part draws the person\'s own line') + const mineEvent = clickOn(tree, find(userRow, (candidate) => candidate.type === 'a')?.node) + assert.equal(mineEvent.defaultPrevented, true, 'a link on the person\'s line is claimed') + const botAnchor = find(tree, (candidate) => candidate.type === 'a' && candidate.props?.href === bot)?.node + assert.ok(botAnchor, 'the earlier part draws the Bot\'s line as a link') + const botEvent = clickOn(tree, botAnchor) + assert.equal(botEvent.defaultPrevented, true, 'a link in the Bot\'s line is claimed by its bubble') + assert.deepEqual(p.calls, [['tab', 'b1'], ['navigate', 't1', mine], ['tab', 'b1'], ['navigate', 't2', bot]], 'each link opens the part\'s own tab') + assert.deepEqual(host.external, [], 'the host delegate never sees either link') + assert.deepEqual(opened, [], 'nothing falls back to a bare window') })