From 0695233947165da107e14f294cd46b7c5e758b1a Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 25 Oct 2026 01:03:50 +0800 Subject: [PATCH] Stand in for a new group's header only while the host draws none Creating a group opens its Session with zero turns and the host draws no session header then, so a group made from "Create group chat" showed only its sidebar row (#998). The dock now hands `blank` to every `flybot.session.dock` slot and the social plugin's `social-group-strip` draws the group's name, its members and Invite by link exactly then. "Has said nothing" is not the host's condition, though: the host draws no header only while `session.blank && conversationPhase(...) === 'blank'`, and a session that has been sent to already reads `engaging` (a failed first send never turns it blank again). The dock therefore passes `session.blank === true && session.promptAttempted !== true`, so the strip and the host's header can never be on screen together, and its test pins both halves. Fixes #998 --- ...998-group-strip-before-first-turn.added.md | 1 + core/client.js | 5 +- core/src/client/flybot-slots.js | 16 ++++-- core/test/overlay-approvals.test.js | 38 ++++++++++++++ core/types/flybot.d.ts | 2 +- docs/subsystems/core-client.md | 1 + docs/subsystems/plugin-runtime.md | 2 +- docs/subsystems/social.md | 4 ++ patches/ds-bot/0008-client-bundle.patch | 7 +-- .../ds-bot/files/src/client/flybot-slots.js | 16 ++++-- .../files/test/overlay-approvals.test.js | 38 ++++++++++++++ patches/ds-bot/files/types/flybot.d.ts | 2 +- plugins/social/client.js | 51 +++++++++++++++++++ plugins/social/test/social.test.js | 28 +++++++++- 14 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 changelog.d/998-group-strip-before-first-turn.added.md diff --git a/changelog.d/998-group-strip-before-first-turn.added.md b/changelog.d/998-group-strip-before-first-turn.added.md new file mode 100644 index 00000000..c8904559 --- /dev/null +++ b/changelog.d/998-group-strip-before-first-turn.added.md @@ -0,0 +1 @@ +A group made with **Create group chat** now shows its own strip above the composer while it has not been used yet — the group's name, who is in it, and **Invite by link** — instead of only a sidebar row; the first message hands the top of the chat back to the session header, so the two never appear together ([#998](https://github.com/ThinkFlowLab/FlyBot/issues/998)). diff --git a/core/client.js b/core/client.js index 26f59829..fa346d38 100644 --- a/core/client.js +++ b/core/client.js @@ -2811,7 +2811,7 @@ function useClaimed() { }, []); return claimed; } -function FlyBotSessionDock({ sessionId, useRoster, actions }) { +function FlyBotSessionDock({ sessionId, useRoster, actions, session }) { const entries2 = useSlot("flybot.session.dock"); (0, import_react2.useEffect)(() => { if (!sessionId) return void 0; @@ -2822,10 +2822,11 @@ function FlyBotSessionDock({ sessionId, useRoster, actions }) { if (entries2.length === 0) return null; const room2 = roster?.roomsById?.[sessionId]; const bot = room2 ? void 0 : roster?.byId?.[sessionId]; + const blank3 = session?.blank === true && session?.promptAttempted !== true; return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( "div", { key: entry.id, className: "bt-flybot-session-dock", "data-flybot": entry.id }, - (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2, t, claim: (ids) => claimApprovals(`${entry.id}:${sessionId}`, ids) } }) + (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2, t, blank: blank3 === true, claim: (ids) => claimApprovals(`${entry.id}:${sessionId}`, ids) } }) ))); } function FlyBotRoomOverview({ room: room2, roster, actions }) { diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 93283c6c..15d4041e 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -17,7 +17,9 @@ // flybot.room.overview { id, order, component({ room, roster, actions, call }) } top of a group's overview // flybot.room.members { id, order, component({ room, roster, actions, call, host }) } rows at the foot of a local group's // Members card (#876: Invite by link); `host(endpoint, payload)` calls a core endpoint, which `call` cannot -// flybot.session.dock { id, order, policy?, component({ sessionId, roster, room, bot, actions, call, claim, t }) } above the composer; +// flybot.session.dock { id, order, policy?, component({ sessionId, roster, room, bot, actions, call, claim, t, blank }) } above the composer; +// `blank` is true only while the host draws no session header: nothing said yet and nothing +// sent yet (`session.blank && !session.promptAttempted`), #998; // a dock that draws the cards of one approval policy sets `policy` and calls claim(ids) with the // approval ids it shows: the overlay then draws no second card for them (#260) // flybot.new-chat.action { id, order, label, hint?, icon?, visible?, run({ close, actions, roster }) } a row in the sidebar '+' menu (after New group chat; Face-to-face room, Add friend) @@ -151,7 +153,7 @@ function useClaimed() { return claimed } -export function FlyBotSessionDock({ sessionId, useRoster, actions }) { +export function FlyBotSessionDock({ sessionId, useRoster, actions, session }) { const entries = useSlot('flybot.session.dock') useEffect(() => { if (!sessionId) return undefined @@ -162,8 +164,16 @@ export function FlyBotSessionDock({ sessionId, useRoster, actions }) { if (entries.length === 0) return null const room = roster?.roomsById?.[sessionId] const bot = room ? undefined : roster?.byId?.[sessionId] + // #998: the host hides a session's whole header until its first turn, so a group made a moment ago + // shows only its sidebar row. A dock that stands in for that header has to know exactly when the host + // draws no header at all, which is narrower than "has said nothing": the host checks its own phase, + // `session.blank && conversationPhase(...) === 'blank'` (dsh-client-ui-conversation), and a session that + // has been sent to already reads `engaging` there, so its header is on screen. `promptAttempted` is + // that fact on the snapshot, and a failed first send never turns it back off, so standing in for a + // header the host is already drawing cannot happen. + const blank = session?.blank === true && session?.promptAttempted !== true return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-session-dock', 'data-flybot': entry.id }, - h(Safe, { entry, props: { sessionId, roster, room, bot, actions, call, t, claim: ids => claimApprovals(`${entry.id}:${sessionId}`, ids) } })))) + h(Safe, { entry, props: { sessionId, roster, room, bot, actions, call, t, blank: blank === true, claim: ids => claimApprovals(`${entry.id}:${sessionId}`, ids) } })))) } export function FlyBotRoomOverview({ room, roster, actions }) { diff --git a/core/test/overlay-approvals.test.js b/core/test/overlay-approvals.test.js index a43235e1..d612c824 100644 --- a/core/test/overlay-approvals.test.js +++ b/core/test/overlay-approvals.test.js @@ -152,6 +152,44 @@ test('#271: no pointer for an approval whose chat is the open one, whatever the } finally { off() } }) +// #998: a group with nothing said yet gets no session header from the host, so a dock that stands in for +// that header has to know the same fact, narrowed the way the host narrows it: nothing said and nothing sent. +// The session's own snapshot carries it as `blank` plus `promptAttempted`; the dock passes the combined +// answer to every slot so a plugin can draw the header the host is missing — and never a second one. +test('#998: the session dock tells a slot whether the chat is still blank', async () => { + const { FlyBotSessionDock, registry } = await import('../src/client/flybot-slots.js') + const probe = () => null + const off = registry().register('flybot.session.dock', { id: 'probe-blank', component: probe }) + // The renderer below the dock is a boundary component, so the slot's props sit on its element. + const find = node => { + if (!node || typeof node !== 'object') return undefined + if (node.props?.entry?.id === 'probe-blank') return node.props.props + for (const child of node.children ?? []) { + const hit = find(child) + if (hit) return hit + } + return undefined + } + try { + const roster = { roomsById: { 'room-1': { id: 'room-1', name: 'Launch plan' } } } + for (const [session, want] of [ + [{ blank: true }, true], + // The host draws its own header from the first send on (its phase reads `engaging`), so a dock that + // stood in for the header then would show two of them — until the first turn, or forever after a + // failed first send, which never makes the session blank again. + [{ blank: true, promptAttempted: true }, false], + [{ blank: false }, false], + [undefined, false], + ]) { + const inst = { hooks: {}, effects: [], rendered: false, props: { sessionId: 'room-1', useRoster: pick => pick(roster), session, actions: {} } } + const props = find(render(FlyBotSessionDock, inst)) + assert.ok(props, 'the slot is rendered') + assert.equal(props.blank, want, JSON.stringify(session)) + assert.equal(props.room?.id, 'room-1', 'the room still reaches the slot') + } + } finally { off() } +}) + // #968: the approval card in the dock is the second surface that used to ignore the flag. A terminal failure // draws no Retry; the retryable one keeps it. test('#968: a terminal failed approval draws no Retry in the dock card', async () => { diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 24e5de19..bff6e10b 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -573,7 +573,7 @@ export interface FlyBotSlots { 'flybot.approvals': { id: string; order?: number; policy?: string; component(props: ApprovalCardProps): any } 'flybot.room.overview': { id: string; order?: number; component(props: { room: any; roster: any; actions: any; call: FlyBotCall }): any } 'flybot.room.members': { id: string; order?: number; component(props: { room: any; roster: any; actions: any; call: FlyBotCall; host: FlyBotCall }): any } - 'flybot.session.dock': { id: string; order?: number; /** The `by` of the approvals this dock draws; the overlay then draws no second card for the ids it claims. */ policy?: string; component(props: { sessionId?: string; roster?: any; room?: any; bot?: any; actions?: any; call: FlyBotCall; /** Report the approval ids this dock shows (a plugin's own policy). */ claim?: (ids: string[]) => void }): any } + 'flybot.session.dock': { id: string; order?: number; /** The `by` of the approvals this dock draws; the overlay then draws no second card for the ids it claims. */ policy?: string; component(props: { sessionId?: string; roster?: any; room?: any; bot?: any; actions?: any; call: FlyBotCall; /** True only while the host draws no session header: nothing said yet and nothing sent yet (`session.blank && !session.promptAttempted`, #998). */ blank?: boolean; /** Report the approval ids this dock shows (a plugin's own policy). */ claim?: (ids: string[]) => void }): any } 'flybot.account.header': { id: string; order?: number; component(props: { actions?: any; call: FlyBotCall }): any } 'flybot.settings.page': { id: string diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 3753de6a..64b2e192 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -47,6 +47,7 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t - `client.js` matches `core/src/client/`; `npm run build` + `--check` (also invariant 3 of [ds-bot-core](ds-bot-core.md)). - The bundle carries the slot wrapper's empty rule: `.bt-flybot-section:empty { margin-top: 0; border-top: 0; padding-top: 0; }` sits beside the base `.bt-flybot-section` rule in `FLYBOT_CSS` (`flybot-slots.js`), so a `flybot.sidebar.section` entry whose component renders `null` leaves no separator behind while a section with rows keeps the base rule (#818). Behavior is specified on [plugin-runtime](plugin-runtime.md). +- The built bundle carries the dock wrapper's new `blank` prop: `FlyBotSessionDock` reads `session.blank && !session.promptAttempted` and hands the answer to every `flybot.session.dock` slot (#998). Behavior is specified on [plugin-runtime](plugin-runtime.md). ### Host Bot look in the bundle diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 1f994e4c..47d327b6 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -71,7 +71,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Sidebar + friend actions - `hooks.js` hands a transport `approved: true` (never read from the message) when the owner approved that send on a FlyBot card; social uses it to finish Bot Social's own hold with the same click (#258). -- A `flybot.session.dock` entry may set `policy` (the `by` of the approvals it draws; social registers `policy: 'social'`) and receives the host translator `t` and `claim(ids)`: call it with the approval ids the dock shows so the overlay draws no second card for them (#260). The host also marks the chat each mounted session dock belongs to as open (`chatOpened`), and the overlay draws no pointer for an approval of the open chat (#271). The pointer's chat is the approval's room, else the friend's chat it is addressed to, else its sender Bot (#278). +- A `flybot.session.dock` entry may set `policy` (the `by` of the approvals it draws; social registers `policy: 'social'`) and receives the host translator `t` and `claim(ids)`: call it with the approval ids the dock shows so the overlay draws no second card for them (#260). The host also marks the chat each mounted session dock belongs to as open (`chatOpened`), and the overlay draws no pointer for an approval of the open chat (#271). A dock entry also gets `blank`: true only while the host is drawing no session header at all — the session has said nothing (`session.blank`) **and** nothing has been sent to it (`!session.promptAttempted`; the host's own condition is `session.blank && conversationPhase(...) === "blank"`, and a sent-to session reads `engaging`), and a failed first send never turns it blank again. Social's group strip (`social-group-strip`) stands in for that missing header exactly then and is gone at the first message (#998). The pointer's chat is the approval's room, else the friend's chat it is addressed to, else its sender Bot (#278). - Social registers a second `flybot.session.dock` entry, `social-room-end` (`FaceRoomStatus`, order 40): the plain ended-room line for a left/closed face room (#191). - Social registers `flybot.settings.page` (`SocialServerSettings`, label **Bot Social**): the server address, its status and the switch form (#337). The host side is `social/server` (a read) and `social/server-check` / `social/server-set` (writes: blocked on a read-only host and, like every plugin write, while the team has more than one member). - Social also registers `flybot.account.header` (`MyHandle`): my own Bot Social handle plus Copy at the top of the account menu (#181), hidden while not connected. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 7e622939..2242f093 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -92,6 +92,10 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - My own handle (`@name.home`) shows with a **Copy** link at the top of the account menu (slot `flybot.account.header`, `MyHandle`) as well as in Add friend (#181). - Sidebar **+** is a short action menu, not the To:/composer. Add friend has two labelled groups (#343): **Accept an invite** (the full-width paste box `Paste invite link or code`, which wraps so a whole pasted link shows and accepts on Enter (#499), **Talk through [Bot]** when there is more than one Bot, then **Accept invite**) and **Invite a friend** (a **Share [Bot]** picker with the whole Bot name when there is more than one, then the shared invite control below), with a `You are @your.home` line (#181) under the title and **Done** at the bottom (Escape also closes). Typing in the paste box clears a failed accept's error (#499). After **Accept invite** the result shows: `{person} shared {Bot} with you.`, and about 1.8 s later the panel closes on its own and opens the new friend's Bot when its chat is already listed (#499); when the join brought no Bot (`Joined {person}'s invite, but their Bot hasn't loaded yet…`) or sent a request, the panel stays open until **Done** (#343). **Talk through** stays on one line (nowrap label). Add friend and Bot Sharing draw one invite control (`inviteControls` in `client.js`, #499): before a link there is one button, **Copy invite link for {Bot}** (mint + clipboard); then the link sits on its own full-width read-only row above one action row: **Copy link** (the one primary button), **Copy with instructions**, and a quiet **New link** text button that makes a fresh link without copying it (#230). Every copy note names the Bot: `Invite link for {Bot} copied.` / `Invite for {Bot} copied with instructions.`. The Sharing hint is `Invite grants {Bot} only.` and adds `Revoke anytime in People with access.` only once People with access lists someone (`ManageAccess` reports its count through `onGrants`); the empty list says `Nobody has this Bot yet. Copy an invite link above and send it to a friend.` (#499). Below that, **Share with a friend** (#500) lists friends who do not have this Bot yet (one **Share** per friend's Bot, each row named `Bot (Owner)` with that Bot's handle and its own busy state, #973); invite links for new people stay. A read of that list that fails, or that was already in flight when a **Revoke** settled, never replaces the rows a successful read drew: the card updates only from a read that succeeded after the revoke, a failed one keeps the rows and says so with a **Try again**, and `No friends left to share with…` prints only when the last successful read returned nobody (#984). The **Sharing** heading uses the same 4px inset as the core settings headings. The social strip keeps the **N to approve** badge and opens that chat; the stacked jump list stays out of the column. Held `face_room_message` drafts show in that face chat with readable names (`Chief → the room`), never raw `bot_01…` or a `FLYBOT-FACE-HUMAN` envelope. +### A group's own header before its first turn (#998) + +Creating a group opens its Session with zero turns, and the host draws no session header for a Session that has said nothing — so a group made from **Create group chat** showed only its sidebar row until the first message. The dock entry `social-group-strip` (`flybot.session.dock`, order 30) stands in for that header exactly while the dock's `blank` prop is true — nothing said and nothing sent, the state in which the host draws no header: the group's name, its members' names and **Invite by link** (the same `room-invite-mint` route the Members card uses, shown inline with Copy). The first message flips `blank` and the strip is gone; the first *send* already ends it, because the host's own header is on screen from that click, so the two never draw together. A face room keeps its own line (`social-room-end`) and gets no strip. + ### Face-room human turns and UI polish (#166, #167) - A plain human line in an open face-to-face room is logged and fanned out once, and starts a single pass of every local Bot (admin first, capped at 6, no @ needed; #287, #295; core `openingSpeakers`, see [core-host](core-host.md)). Since #299 the Bots pick the responder from a plain line (they may `[PASS]`; the first Bot that answers ends the round unless the line is to everyone; the first Bot answers if all pass on the owner's own line), and a line to everyone or `@all` is unchanged. A friend's attested plain line (or `@all`) starts the same pass on this home, routed and single like the owner's own line (#462) (the plugin passes `attested: true` to `postHuman` only for a stamped line); an attested line that names Bots starts only the Bot(s) it names by the label the picker shows (`Friend's Chief`) or by handle; `plugins/social/test` has the two-home case. **Explicit mention list (#293):** the sender's host resolves the picker's labels against the room (`faceMentions().handles`; core `onFaceLine(fn)` passes `{ mentions }` for the owner's typed line) and `faceApi.send` puts the handles in `mentions` next to `typed_by_owner` only when `GET /v1/platform` `features` lists `mentions` (the one feature probe, `serverMentions()`); never on the legacy envelope, Bot lines or an older server. The receiver reads `mentions` (`cleanMentions`, at most 8 handles) only from a line the server attests `typed_by_owner` (`renderFaceEvent`), and `postHuman` resolves the handles against the room first (`mentionedByHandle`): only my own Bots in the list answer; a list naming nobody the room knows, or no list, falls back to the label/name reading above. The composer's `@` picker inserts the label text, so the handles are derived from it at send, not tracked in the composer. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 6cdc9a1a..9a6ab9c9 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1476,7 +1476,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,9 +1624,875 @@ +@@ -1624,9 +1624,876 @@ const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -2005,7 +2005,7 @@ diff --git a/client.js b/client.js + }, []); + return claimed; +} -+function FlyBotSessionDock({ sessionId, useRoster, actions }) { ++function FlyBotSessionDock({ sessionId, useRoster, actions, session }) { + const entries2 = useSlot("flybot.session.dock"); + (0, import_react2.useEffect)(() => { + if (!sessionId) return void 0; @@ -2016,10 +2016,11 @@ diff --git a/client.js b/client.js + if (entries2.length === 0) return null; + const room2 = roster?.roomsById?.[sessionId]; + const bot = room2 ? void 0 : roster?.byId?.[sessionId]; ++ const blank3 = session?.blank === true && session?.promptAttempted !== true; + return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( + "div", + { key: entry.id, className: "bt-flybot-session-dock", "data-flybot": entry.id }, -+ (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2, t, claim: (ids) => claimApprovals(`${entry.id}:${sessionId}`, ids) } }) ++ (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2, t, blank: blank3 === true, claim: (ids) => claimApprovals(`${entry.id}:${sessionId}`, ids) } }) + ))); +} +function FlyBotRoomOverview({ room: room2, roster, actions }) { diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index 93283c6c..15d4041e 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -17,7 +17,9 @@ // flybot.room.overview { id, order, component({ room, roster, actions, call }) } top of a group's overview // flybot.room.members { id, order, component({ room, roster, actions, call, host }) } rows at the foot of a local group's // Members card (#876: Invite by link); `host(endpoint, payload)` calls a core endpoint, which `call` cannot -// flybot.session.dock { id, order, policy?, component({ sessionId, roster, room, bot, actions, call, claim, t }) } above the composer; +// flybot.session.dock { id, order, policy?, component({ sessionId, roster, room, bot, actions, call, claim, t, blank }) } above the composer; +// `blank` is true only while the host draws no session header: nothing said yet and nothing +// sent yet (`session.blank && !session.promptAttempted`), #998; // a dock that draws the cards of one approval policy sets `policy` and calls claim(ids) with the // approval ids it shows: the overlay then draws no second card for them (#260) // flybot.new-chat.action { id, order, label, hint?, icon?, visible?, run({ close, actions, roster }) } a row in the sidebar '+' menu (after New group chat; Face-to-face room, Add friend) @@ -151,7 +153,7 @@ function useClaimed() { return claimed } -export function FlyBotSessionDock({ sessionId, useRoster, actions }) { +export function FlyBotSessionDock({ sessionId, useRoster, actions, session }) { const entries = useSlot('flybot.session.dock') useEffect(() => { if (!sessionId) return undefined @@ -162,8 +164,16 @@ export function FlyBotSessionDock({ sessionId, useRoster, actions }) { if (entries.length === 0) return null const room = roster?.roomsById?.[sessionId] const bot = room ? undefined : roster?.byId?.[sessionId] + // #998: the host hides a session's whole header until its first turn, so a group made a moment ago + // shows only its sidebar row. A dock that stands in for that header has to know exactly when the host + // draws no header at all, which is narrower than "has said nothing": the host checks its own phase, + // `session.blank && conversationPhase(...) === 'blank'` (dsh-client-ui-conversation), and a session that + // has been sent to already reads `engaging` there, so its header is on screen. `promptAttempted` is + // that fact on the snapshot, and a failed first send never turns it back off, so standing in for a + // header the host is already drawing cannot happen. + const blank = session?.blank === true && session?.promptAttempted !== true return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-session-dock', 'data-flybot': entry.id }, - h(Safe, { entry, props: { sessionId, roster, room, bot, actions, call, t, claim: ids => claimApprovals(`${entry.id}:${sessionId}`, ids) } })))) + h(Safe, { entry, props: { sessionId, roster, room, bot, actions, call, t, blank: blank === true, claim: ids => claimApprovals(`${entry.id}:${sessionId}`, ids) } })))) } export function FlyBotRoomOverview({ room, roster, actions }) { diff --git a/patches/ds-bot/files/test/overlay-approvals.test.js b/patches/ds-bot/files/test/overlay-approvals.test.js index a43235e1..d612c824 100644 --- a/patches/ds-bot/files/test/overlay-approvals.test.js +++ b/patches/ds-bot/files/test/overlay-approvals.test.js @@ -152,6 +152,44 @@ test('#271: no pointer for an approval whose chat is the open one, whatever the } finally { off() } }) +// #998: a group with nothing said yet gets no session header from the host, so a dock that stands in for +// that header has to know the same fact, narrowed the way the host narrows it: nothing said and nothing sent. +// The session's own snapshot carries it as `blank` plus `promptAttempted`; the dock passes the combined +// answer to every slot so a plugin can draw the header the host is missing — and never a second one. +test('#998: the session dock tells a slot whether the chat is still blank', async () => { + const { FlyBotSessionDock, registry } = await import('../src/client/flybot-slots.js') + const probe = () => null + const off = registry().register('flybot.session.dock', { id: 'probe-blank', component: probe }) + // The renderer below the dock is a boundary component, so the slot's props sit on its element. + const find = node => { + if (!node || typeof node !== 'object') return undefined + if (node.props?.entry?.id === 'probe-blank') return node.props.props + for (const child of node.children ?? []) { + const hit = find(child) + if (hit) return hit + } + return undefined + } + try { + const roster = { roomsById: { 'room-1': { id: 'room-1', name: 'Launch plan' } } } + for (const [session, want] of [ + [{ blank: true }, true], + // The host draws its own header from the first send on (its phase reads `engaging`), so a dock that + // stood in for the header then would show two of them — until the first turn, or forever after a + // failed first send, which never makes the session blank again. + [{ blank: true, promptAttempted: true }, false], + [{ blank: false }, false], + [undefined, false], + ]) { + const inst = { hooks: {}, effects: [], rendered: false, props: { sessionId: 'room-1', useRoster: pick => pick(roster), session, actions: {} } } + const props = find(render(FlyBotSessionDock, inst)) + assert.ok(props, 'the slot is rendered') + assert.equal(props.blank, want, JSON.stringify(session)) + assert.equal(props.room?.id, 'room-1', 'the room still reaches the slot') + } + } finally { off() } +}) + // #968: the approval card in the dock is the second surface that used to ignore the flag. A terminal failure // draws no Retry; the retryable one keeps it. test('#968: a terminal failed approval draws no Retry in the dock card', async () => { diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index 24e5de19..bff6e10b 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -573,7 +573,7 @@ export interface FlyBotSlots { 'flybot.approvals': { id: string; order?: number; policy?: string; component(props: ApprovalCardProps): any } 'flybot.room.overview': { id: string; order?: number; component(props: { room: any; roster: any; actions: any; call: FlyBotCall }): any } 'flybot.room.members': { id: string; order?: number; component(props: { room: any; roster: any; actions: any; call: FlyBotCall; host: FlyBotCall }): any } - 'flybot.session.dock': { id: string; order?: number; /** The `by` of the approvals this dock draws; the overlay then draws no second card for the ids it claims. */ policy?: string; component(props: { sessionId?: string; roster?: any; room?: any; bot?: any; actions?: any; call: FlyBotCall; /** Report the approval ids this dock shows (a plugin's own policy). */ claim?: (ids: string[]) => void }): any } + 'flybot.session.dock': { id: string; order?: number; /** The `by` of the approvals this dock draws; the overlay then draws no second card for the ids it claims. */ policy?: string; component(props: { sessionId?: string; roster?: any; room?: any; bot?: any; actions?: any; call: FlyBotCall; /** True only while the host draws no session header: nothing said yet and nothing sent yet (`session.blank && !session.promptAttempted`, #998). */ blank?: boolean; /** Report the approval ids this dock shows (a plugin's own policy). */ claim?: (ids: string[]) => void }): any } 'flybot.account.header': { id: string; order?: number; component(props: { actions?: any; call: FlyBotCall }): any } 'flybot.settings.page': { id: string diff --git a/plugins/social/client.js b/plugins/social/client.js index 4d09673c..4cef1f9a 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -151,6 +151,12 @@ window.__ModuleLoader__.load({ .fs-refresh{flex:none;font-size:11px;line-height:14px;color:var(--bt-ink-3,rgba(20,20,20,.36));font-weight:400} .fs-thinking{margin:0 12px 6px;padding-inline-start:8px;font-size:13px;line-height:20px;min-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fs-room-end{display:flex;align-items:center;gap:8px;margin:6px 12px;padding:8px 10px;border-radius:8px;background:var(--bt-hover,rgba(119,119,119,.09));color:var(--bt-ink-2,rgba(20,20,20,.6));font-size:12px;line-height:16px} +/* #998: the stand-in for a group header the host has not drawn yet (zero turns). */ +.fs-group-strip{display:flex;flex-direction:column;gap:4px;margin:6px 12px;padding:8px 10px;border-radius:8px;background:var(--bt-hover,rgba(119,119,119,.09));font-size:12px;line-height:17px} +.fs-group-strip-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px} +.fs-group-strip-name{font-size:13px;font-weight:600;color:var(--bt-ink)} +.fs-group-strip-members{color:var(--bt-ink-3,rgba(20,20,20,.45))} +.fs-group-strip-invite{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .fs-room-end-close{margin-left:auto;flex:none} /* A left or closed face room is read-only (#197): its end line replaces the composer. */ [class*="_composerStack"]:has([data-flybot="face-room-end"]) [class*="_root"]:has(> [data-composer-card]){display:none!important} @@ -2137,6 +2143,49 @@ window.__ModuleLoader__.load({ return 'Room closed.' } // A room that closed around you is no dead end (#458): one Close goes back to where you were. + // #998: a group made just now is a zero-turn Session, and the host hides the whole session header + // until the first turn, so a new group showed only its sidebar row. This strip stands in for that + // header while — and only while — the chat is still blank: the group's name, who is in it, and the + // way to invite someone. The first message ends it and the host's own header takes over. + function GroupStrip({ room, roster, blank } = {}) { + useStyles() + const [invite, setInvite] = useState(null) + const [busy, setBusy] = useState(false) + const [note, setNote] = useState(null) + const [copied, setCopied] = useState(false) + if (blank !== true || !room?.id || room?.face) return null + const memberNames = (Array.isArray(room.members) ? room.members : []) + .map(id => oneLine(roster?.byId?.[id]?.name ?? roster?.bots?.find?.(bot => bot.id === id)?.name ?? '')) + .filter(Boolean) + const mint = async () => { + setBusy(true) + setNote(null) + try { + const value = await host('room-invite-mint', { roomId: room.id }) + if (value?.invite?.url) setInvite(value.invite) + else setNote(tr('No link came back. Try again.')) + } catch (error) { setNote(errorText(error)) } finally { setBusy(false) } + } + const copy = async () => { + try { + await (typeof navigator !== 'undefined' ? navigator.clipboard : undefined)?.writeText?.(invite?.url ?? '') + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { /* clipboard denied: the link is on screen to select */ } + } + return h('div', { className: 'fs-group-strip', 'data-flybot': 'group-strip' }, + h('div', { className: 'fs-group-strip-head' }, + h('span', { className: 'fs-group-strip-name', 'data-flybot': 'group-strip-name' }, oneLine(room.name) || tr('New group')), + memberNames.length > 0 ? h('span', { className: 'fs-group-strip-members', 'data-flybot': 'group-strip-members' }, memberNames.join(', ')) : null), + h('div', { className: 'fs-group-strip-invite' }, + invite?.url + ? h(Fragment, null, + h('code', { className: 'fs-invite-url', 'data-flybot': 'group-strip-url' }, invite.url), + h('button', { type: 'button', className: 'fs-link', 'data-flybot': 'group-strip-copy', onClick: copy }, copied ? tr('Copied') : tr('Copy link'))) + : h('button', { type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'group-strip-invite', disabled: busy, onClick: mint }, tr('Invite by link'))), + note ? h('div', { className: 'fs-hint', 'data-flybot': 'group-strip-note', role: 'status' }, note) : null) + } + function FaceRoomStatus({ room, sessionId, roster, actions } = {}) { noteChat(sessionId, room) const text = faceEndLine(room) @@ -3435,6 +3484,7 @@ window.__ModuleLoader__.load({ localHoldView, friendPerson, MyHandle, + GroupStrip, FaceRoomStatus, PeerThinking, thinkingLine, @@ -3491,6 +3541,7 @@ window.__ModuleLoader__.load({ slots().register('flybot.room.overview', { id: 'social-face', order: 50, component: FaceRoomOverview }), // An older host has no Members-card slot: the row is simply not drawn there. (() => { try { return slots().register('flybot.room.members', { id: 'social-room-invite', order: 50, component: LocalInviteRow }) } catch { return () => {} } })(), + slots().register('flybot.session.dock', { id: 'social-group-strip', order: 30, component: GroupStrip }), slots().register('flybot.session.dock', { id: 'social-room-end', order: 40, component: FaceRoomStatus }), slots().register('flybot.session.dock', { id: 'social-approval', order: 50, policy: 'social', component: ChatApprovalDock }), slots().register('flybot.session.dock', { id: 'social-thinking', order: 60, component: PeerThinking }), diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index d046bf7a..a879c502 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -741,7 +741,7 @@ test('client.js registers a sidebar section and @ rows into the FlyBot registry' runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) const plugin = registered[0].factory(() => ({ createElement: () => null, useEffect() {}, useState: v => [v, () => {}] })) plugin.apply({}) - assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.new-chat.action#social-add-friend', 'flybot.composer.mention#social', 'flybot.account.header#social-handle', 'flybot.bot.details#social-invite', 'flybot.approvals#social-held-reply', 'flybot.settings.page#social', 'flybot.room.overview#social-face', 'flybot.room.members#social-room-invite', 'flybot.session.dock#social-room-end', 'flybot.session.dock#social-approval', 'flybot.session.dock#social-thinking', 'flybot.session.dock#social-paused']) + assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.new-chat.action#social-add-friend', 'flybot.composer.mention#social', 'flybot.account.header#social-handle', 'flybot.bot.details#social-invite', 'flybot.approvals#social-held-reply', 'flybot.settings.page#social', 'flybot.room.overview#social-face', 'flybot.room.members#social-room-invite', 'flybot.session.dock#social-group-strip', 'flybot.session.dock#social-room-end', 'flybot.session.dock#social-approval', 'flybot.session.dock#social-thinking', 'flybot.session.dock#social-paused']) // Transport errors are worded in English from their code, never the localized host text. assert.match(plugin.errorText(Object.assign(new Error('请求失败(HTTP 404)'), { code: 'HTTP_404' })), /^The FlyBot host did not answer \(HTTP 404\)/) assert.equal(plugin.errorText(Object.assign(new Error('x'), { code: 'bot/refused' })), 'x') @@ -6641,6 +6641,32 @@ test('#297/#443: a face room\'s Bots thinking show as one quiet line above the c assert.equal(plugin.thinkingLine(['A', 'B', 'C', 'D']), 'A, B and 2 more are thinking…') }) +// #998: a group with nothing said yet is a zero-turn Session, and the host draws no session header until +// the first turn — so the chat itself names the group and offers the invite, and hands over on message one. +test('#998: a zero-turn group draws its own strip above the composer, and drops it at the first turn', () => { + const registered = [] + const entries = [] + const flybot = { slots: { register: (slot, entry) => { entries.push([slot, entry]); return () => {} } }, call: async () => ({}) } + const sandbox = { window: { __ModuleLoader__: { load: r => registered.push(r) } }, setTimeout, clearTimeout, console, document: { getElementById: () => null, createElement: () => ({}), head: { appendChild() {} } } } + sandbox.globalThis = sandbox + sandbox.__flybot__ = flybot + runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) + const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) + const plugin = registered[0].factory(() => ({ createElement: h, Fragment: 'F', useEffect() {}, useState: v => [v, () => {}] })) + const roster = { byId: { b1: { id: 'b1', name: 'Chief' }, b2: { id: 'b2', name: 'Writer' } }, bots: [] } + const room = { id: 'room-1', name: 'Launch plan', members: ['b1', 'b2'] } + const strip = plugin.GroupStrip({ room, roster, blank: true }) + const json = JSON.stringify(strip) + assert.match(json, /Launch plan/, 'the group names itself') + assert.match(json, /Chief, Writer/, 'and says who is in it') + assert.match(json, /Invite by link/, 'with the one way to add someone') + assert.match(json, /group-strip/) + assert.equal(plugin.GroupStrip({ room, roster, blank: false }), null, 'the first message: the host header takes over, never two') + assert.equal(plugin.GroupStrip({ room, roster }), null, 'a dock that is not told the chat is blank draws nothing') + assert.equal(plugin.GroupStrip({ room: { ...room, face: { status: 'open' } }, roster, blank: true }), null, 'a face room keeps its own line') + assert.equal(plugin.GroupStrip({ room: undefined, roster, blank: true }), null, 'a Bot chat has no group strip') +}) + test('#297: the thinking line is a plain session dock below the approval card, styled like the other quiet notes', () => { const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8') assert.match(source, /register\('flybot\.session\.dock', \{ id: 'social-thinking', order: 60, component: PeerThinking \}\)/)