Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/998-group-strip-before-first-turn.added.md
Original file line number Diff line number Diff line change
@@ -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)).
5 changes: 3 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 }) {
Expand Down
16 changes: 13 additions & 3 deletions core/src/client/flybot-slots.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 }) {
Expand Down
38 changes: 38 additions & 0 deletions core/test/overlay-approvals.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion core/types/flybot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/plugin-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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;
Expand All @@ -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 }) {
Expand Down
Loading
Loading