Skip to content

Latest commit

 

History

History
215 lines (148 loc) · 66 KB

core-client.md

File metadata and controls

215 lines (148 loc) · 66 KB
subsystem core-client
owned_paths
core/src/client/
core/client.js

core-client

The browser side of the patched ds-bot core (core/): sources in core/src/client/, built into the committed bundle core/client.js. Split out of ds-bot-core so a host-only change does not have to edit this page. client.js is generated: never hand-edit it, run npm run build in core/ and commit both.

Owns

  • Browser client: sidebar, chat, panels, themes, characters, Bot browser panel (core/client.js generated from core/src/client/). The sidebar + new-chat picker (core/src/client/new-chat.js) takes extra rows from slot flybot.new-chat.action (after New group chat; label / hint go through the same t() map) and lists only listedChatBots (gone, hidden, and home-level remotes stay out; owner labels match the sidebar). Covered by core/test/new-chat.test.js in patch 0003-flybot-api-client.

Does not own

  • The server side, core/index.js, core/src/host/, core/src/shared/ → core-host.
  • Plugin slots and the browser flybot service (flybot-slots.js, flybot-registry.js) → plugin-runtime.
  • Pin, patches, core/dev/ → ds-bot-core.

Key files

File Role
core/src/client/ Client sources → build to core/client.js.
core/src/client/new-chat.js Sidebar + picker. Plugin rows via flybot.new-chat.action (newChatActionRows in flybot-registry.js); social adds Face-to-face room and Add friend (copy actions live inside Add friend, #181). The DSH Agent row reads Add coding agent (hint "A plain DSH session, not a Bot") so it is not mistaken for Create new Bot (#181). The short + menu does not list individual Bots. A group the host refuses because one of my Bots is not shared with the friend whose Bot is in it (error.details.reason === 'ungranted-local', put on the error by client/api.js) is drawn as a card, not a red line: it names the Bot(s) and friend(s), and offers Copy invite link for {Bot} (__flybot__.social.createInvite; shown only when social is loaded and can invite that Bot, nothing is granted by the click; it says copied only when copiedToClipboard in shared/remote-label.js reports the write took, else it shows the link in a read-only, select-on-focus field to copy by hand) and Leave {Bot} out (ungrantedLocalFix, shared/remote-label.js).
core/src/client/stats-dock.js The shell's run-stats footer under the composer (conversation.composer.dock) re-registered one priority lower with the shell's own component, inject and chat locale, so the English counts read 1 turn / 1 step instead of 1 turns 1 steps (pluralStats rewrites only the exact English template, #419). The shell ids differ by version — one stats footer in 0.2.0-rc.2, the counts split into an activity pill next to usage in 0.2.1-alpha.1 — so statsDocks registers one wrapper per id and the wrapper whose id the shell does not publish draws nothing (#659); usage stays hidden. Still hidden in a friend's Bot chat by CSS (#419). Tests: core/test/stats-dock.test.js.
core/src/client/room-invite-preview.js The one room-invite preview card (#876). invitePreviewOf says only what a server reply can honestly say (a name cleaned at fixed point, the lead, n / m in the room, the deadline), inviteCountdown words the deadline, RoomInvitePreview draws the card from a caller-supplied h; kind renames the data-flybot hooks, so the face-room paste flow (face-link-preview) and the member-invite dialog (room-invite-preview) render the same tree instead of two copies of the markup. Registered on __flybot__ in flybot-slots.js, because a classic-script plugin cannot import it. Tests: core/test/room-invite-preview.test.js.
core/src/client/face-at-filter.js A face room's @ menu without DSH's Sessions rows (#305): planFaceMenu (rows to hide, where the highlight goes, when to dismiss), faceMenuCss, watchFaceMenu. Tests: core/test/face-at-filter.test.js.
core/test/new-chat-menu.test.js + menu rows (#283): an action row's description is its own line under the title, an inline Bot hint is capped, so hovering never hides a title.
core/test/new-chat.test.js Picker filters and the + short action menu (data-menu; New chat, New Bot, New group chat, plugin actions, then Add coding agent; #385 marks, verbs and hints). ungrantedLocalFix, copiedToClipboard (no clipboard or a refused write is a failure) and the refusal card (invite and leave-out actions, the copy-by-hand link field, no grant written by the client).
core/src/client/pane-width.js The side panel's width policy (#705, #462): DEFAULT_WIDTH / DETAILS_WIDTH / DETAILS_MAX / MIN_WIDTH for the Bot's browser, MIN_CHAT for what the conversation keeps beside the panel, and panelWidthRange(view, viewport) — a narrow window shrinks the Details panel down to MIN_DETAILS (240) before it starves the conversation, so the row always fits and no pane is pushed past the window edge. Pure numbers, so core/test/narrow-pane.test.js needs no React.
core/src/client/panes.js PANE_MIN_WIDTH_CSS (#705): every pane the multi-pane row flexes gets min-width:0, the conversation column is matched by its DSH class fragment, and the panel's long values (About text, 4.1M tok · Cache hit …) wrap with overflow-wrap:anywhere, so a value that cannot wrap never widens the panel past the space reserved for it.
core/src/shared/approval-waiting.js The one rule for "this hold still waits for the owner's decision" (#734, #754): isApprovalWaiting(status) is true for pending and pending_approval and false for every settled spelling (approved, auto, failed, rejected, stale, missing). The host writes pending; Bot Social's own outbound hold arrives as pending_approval. Host and client both import this one file — the held-reply card, the approvals overlay and the chat bubble on this side, hooks.js (decide(), heldOf(), the cap, the in-flight filter, the cleanup) and channel.js (a row's wait badge) on the other — and the overlay asks the host for [...WAITING_STATUSES, 'failed'], so the two spellings cannot drift apart again on one side only. Tests: core/test/approval-waiting.test.js, core/test/host-approval-status.test.js.
core/src/client/schedules.js Settings → Scheduled tasks, a Bot's card and the details line. Each row has Edit (pencil; the row turns into the same form that creates a task, filled in, with Save changes; an edited task keeps its time zone, a cron rule it cannot show is kept) and Pause / Resume; a paused row reads Paused (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets Retry, which runs the task once now (actions.scheduleRetry, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button Open Plugins closes the Settings dialog and opens DSH's Plugins panel (actions.selectPanel('plugins')); the Harness settings dialog has no such switch (#378). Tests: core/test/schedules-client.test.js.
core/src/client/inbox.js Agent inbox, step 1 (#349): the Inbox row at the top of the sidebar (InboxRow, count badge = cards in Needs you) and the inbox dialog (InboxDialog, ui.inbox, actions.openInbox / closeInbox) with the groups Needs you, In progress and Done (inboxModel); failed turns, a Latest line and a per-card N tool calls count (#471); a group's open question card (#552). Tests: core/test/inbox.test.js.
core/src/client/standing-approvals.js Standing tool approvals (#540): StandingApprovalsCard, the Always allowed card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with Remove (actions.removeStandingApproval). The approval cards (member-status.js ApprovalCard, the inbox card) add Always allow for this Bot and, for an ask from a group, Always allow for this group (alwaysChoices; never on a friend's Bot). Host side in core-host. Tests: core/test/member-status.test.js, core/test/standing-approvals.test.js.
core/src/client/mobile.js Phone-width layout, step 1 (#362): MOBILE_CSS under @media (max-width:640px) for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: core/test/mobile-layout.test.js.

Invariants

Add a new invariant under the heading it belongs to (or a new heading), not at the end of the page: parallel PRs then do not edit the same lines.

Generated bundle

  • client.js matches core/src/client/; npm run build + --check (also invariant 3 of ds-bot-core).
  • 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.

Host Bot look in the bundle

  • The committed bundle includes flybot-slots.js registry(), which exposes BotAvatar / BotMark / lookOf on globalThis.__flybot__ for classic-script plugins. Behavior is specified on plugin-runtime.

New group chat with a friend's Bot

  • 新建群聊 (new-chat.js) picks a local Bot as admin; a friend's remote chat is split into participants: [{ kind: 'bot', remote: true, handle }] (splitRoomPicks). The host also converts remote chat ids passed as members, so the friend's owner gets the inbound-share projection instead of a silent local-only member. If the host refuses the group because one of my Bots is not shared with that friend, the screen keeps the picks and shows the refusal card with the invite / leave-out actions (see the new-chat.js row).

Main view landing and navigation trace (#261)

useLandOnMainBot (sidebar.js) puts an emptied main view back on a Bot. landingPart (text.js) picks where: a part archived for a fresh one goes on in the fresh part; a view that emptied while its chat is still the Bot's current part (a session list reload, not the user leaving) returns to that same chat; a chat whose Bot the roster no longer lists (a revoked share, a removal) stays put while its session is still in the session list, including a blank chat of that same session, because the session list — not the roster — says whether the conversation exists (#261); a New Session the user opened, or a session archived together with its Bot, goes to the Main Bot. Every openSession and landOn with no user activation behind it (navigator.userActivation.isActive === false) is written to the client diagnostics and the console (navigation: landOn to … from … with no click (caller stack)), so a jump a user cannot explain names its caller.

Secrets page scope control (#545)

  • Settings → Secrets: each key's row shows All my Bots, the chosen Bots' faces, or No Bot; opening it shows ScopeEditor (secret-card.js) with three radios, All my Bots / Chosen Bots / No Bot, and Save access sends secret-update with 'all', the picked ids or []. A new key and a connector key start as All my Bots (host rule in core-host).

Held-send approval card

A flybot.session.dock component also gets the host translator t, so a plugin's card can read in the interface language (the social hold card's Held for your approval: contains … line, #371; its words are in i18n.js).

The overlay (flybot-slots.js FlyBotApprovals) is the fallback, not a second copy (#260): an approval whose policy a flybot.session.dock entry declares (policy, e.g. social) is drawn by that dock in its chat, which reports the ids it shows through claim(ids); the overlay skips claimed ids. A docked approval no open chat shows becomes a small pointer (Sender → Recipient, Waiting for approval, Review opens the chat via actions.openSession) after 1.2 s, so moving between chats never flashes it; the chat that is open never gets a pointer for its own approvals (each FlyBotSessionDock reports its chat through chatOpened, #271). An approval's chat is its room, else the chat whose turn a held reply answers (meta.replyIn, #418), else the friend's chat it is addressed to, else the Bot that wrote it; the pointer's Review opens that one (#278). The claims live in approval-claims.js so a chat cell can claim without the slot components. Any other policy's approval is a card in the overlay, with the same two lines as the dock's card (Waiting for approval / Not sent, then Sender → Recipient; the policy's long reason is the tooltip) and Approve / Reject (Retry for a failed send). Approve / Reject is drawn only for a status isApprovalWaiting() calls waiting (pending, pending_approval), the same rule the held-reply card asks (#734); a settled status gets no button the host would refuse, and a failed send keeps Retry. The recipient is the person who gets it (#418, #498): the host's recipientName, else the owner of the friend's chat (friendBotOwner), else as the host named it, so a card reads Chief → SelfB1233, never two Bot names. senderName / targetName are resolved server-side and a delegated marker follows a relayed send (hop > 0). The decision itself is owner-bound in the host (#52); see core-host.

Face-to-face room lead

  • A local group's Members · N card (group-settings.js) ends with the plugin rows of slot flybot.room.members (FlyBotRoomMembers in flybot-slots.js), after the note on who leads the group (#892) and on the card's own row padding (.bt-flybot-room-members), never in a face or shared (inbound-share) room; the rows get host, the core endpoint call (also __flybot__.host), for room-invite / room-invite-mint / room-invite-revoke (#876, the social plugin's Invite by link). Covered by core/test/room-members-slot.test.js. Plugin copy that core's dictionary carries is translated through __flybot__.t (#892: Invite by link, the Join-or-create dialog), so a zh UI reads it in Chinese. A room a link opened is titled by its own name on the joining home (roomTitle in remote-label.js: sidebar, header, drawer, brand); "Shared room from …" stays as the sidebar row's hover and the Shared tag's title (#892). In a room projected from another home the Members rows carry what that side could confirm (#904): a Bot learned from the host's roster reads Unconfirmed until this home holds its own fact about it, one with no Bot Social identity reads Not connected, and the remove × is not offered there — the host's membership is changed on the host's side.
  • The face room's details (FaceMembers in details-drawer.js) open with FaceLead (group-settings.js, #476): the current lead as Bot (person), Default until the creator picked one. Only when room.face.lead.canPick (this home made the room and Bot Social stores a lead) does it show Change, a Make lead button per Bot of every home and Use the default lead; each calls social/face-lead. Everyone else sees it read-only. FacePersonRow marks the lead's Bot with a Lead tag (isLeadSeat, by handle). Never a chat command.

Room avatars in face-to-face chats

  • RoomAvatar (src/client/mark.js roomAvatarPeople) seats current local room.members and live remote participants (handle-seeded via lookBotForRemote). A face-to-face room with only remotes no longer falls back to the grey placeholder in the sidebar row or chat header.

Room remote bubbles unwrap the fence

  • Group RemoteMessage (cells.js) shows remoteMessageText(reply.text), the same unwrap the 1:1 friend chat already uses. Stored room lines stay fenced for the model; the bubble shows the inner words, not the <untrusted-message> wrapper (#98).
  • A Bot line that Bot Social holds for approval in a Face-to-face room is drawn once, in its approval card (#306): GroupMessage (cells.js) asks isHeldLine(room, text, bot), which matches the line to a face_room_message entry of room.pendingApprovals sent by that same Bot (the card's from equals the bubble's Bot name, ignoring case and spacing; a card with no from matches by text alone, and another Bot's identical words stay a full bubble) (whitespace-collapsed, without a To the room: lead, allowing for the 800-character cap), and then draws nothing in the room. #456 makes the room's own record the first source: heldLineState(room, { seq, botId, text }) (cells.js) reads room.heldLines (by the line's seq, which GroupReplies passes from the round reply; else by Bot and exact text) — sending / pending draws nothing (the card is the line's only place, greyed), rejected draws one grey event line <Bot> · Not sent (.bt-event.bt-not-sent, data-flybot=held-not-sent, {name} · 未发出) with the Bot's mark, also after a reload and in Past rooms; a line still waiting when the room ended reads Not sent too; a sending record older than a minute (a restart mid-send) falls back to the bubble. Approve removes the record, so the line becomes a normal bubble. Other room kinds, lines the owner typed (approved at once, never listed) and every other Bot line are unchanged.

Remote Bot display

  • A friend's remote Bot is stored and delivered by its handle (@<bot>.<home> — what the @ menu inserts and sendUserMentions matches). Where the user reads it — mention chips (src/client/user-message.js) and sidebar previews (nameMentions in src/client/text.js) — the client rewrites the handle to the display name taken from the local roster (roster.bots[].remote, roster.remoteBots), never from message text, so a remote-controlled name reaches the UI only through the sanitized roster records (#71; display names, #9).
  • In a face-to-face room the bubble matches a mention by every alias the room host routes on (faceAliases in shared/remote-label.js, the same list face-mentions.js uses), so @SelfA's Chief typed on B is A's own Chief on A. The chip shows @ and the seat label (faceSeatLabel, SelfA · Chief) on both homes, a bare name means the writer's own Bot (my line tries my seats first, a friend's line the friend's, whatever order the room lists its seats in, as faceCandidates does), a friend's Bot outside the room is not matched, and one space typed after a picked @Name (the menu inserts its own) is dropped from the drawing (#305).
  • A remote Bot's generated look is seeded by its handle (remoteSeed in src/client/characters.js), never by its session id. When Bot Social sends avatar / color / avatar_updated_at, flybot.remote.publish stores them on the remote-chat record (cached by avatar_updated_at). lookBotForRemote overlays a person payload's look (face-room members[] is the source for room avatars). A stored color/avatar still wins over the seed; missing look stays handle-seeded (#137).
  • A friend's 1:1 chat (#443): AssistantCell gives the reply bubbles a bt-lead BotMark from the chat record (friendLead; none on a Not sent: note). The activity row (activity-row.js) stays up while awaitingReply (awaiting.js, roster.remoteAwaiting, 10 min cap) holds. Between the send turn and the echo turn it sits in the last turn's tail, with a 1.5 s grace and data-carry so there is no second entrance, which keeps one row from send to reply. The social PeerThinking line is for face rooms only. A friend chat shows the row for a run that started from my submission and, between runs, while awaitingReply holds. The friend's reply run (the echo, which has no submission) hides it at once, and only the end of my send run gets the grace. The turn-tail hiding rules in styles.js spare .bt-typing and its tail item, or the row between runs would be drawn with display:none.
  • A friend's Bot the owner hid, a remote chat marked gone/hidden, and a home-level handle (@friend.home, not @bot.friend.home) must not reappear in the sidebar, the 发给 / 和谁聊… picker, @ mentions, or list_bots friends lines (isListedRemoteChat). flybot.remote.publish skips those handles so remote-chat sync marks the persisted session gone/hidden instead of opening it again; history is kept. Handle comparison is case-insensitive (normRemoteHandle).
  • The "@" reader is shared with the host (core/src/shared/mentions.js, bundled into core/client.js). #287 added unmatchedMentions there (the @ words that name nobody, used by the host for the face-room hint); mentionSpans and what the bubbles draw are unchanged.
  • A friend's Bot settings surface is not the local editor (#160): no rename, avatar editor, model, folder, instructions, Main Bot, Hide, Duplicate, or Delete. The details gear and context menu say Bot settings. The menu is Mark as Unread, Bot settings, and Copy conversation ID. Per-friend Auto-send and Remove this Bot stay on that page (flybot.bot.details); Remove closes the overlay so a stale local editor cannot appear. The Bots settings list is local Bots only.
  • A friend's Bot details overview is that compact panel: handle plus FlyBotDetails (Auto-send and a muted Remove link), no Browser tab, no Usage / model / folder. Opening the gear uses remote-bot-settings (RemoteBotSettingsSheet): a bottom sheet on narrow viewports and a centered card from 520px up, with the scrim behind the dialog (z-index stacking like bt-settings / bt-mem) so the sheet stays clickable through enter/leave animations. The surrounding DSH settings-shell chrome (back/title) is not redesigned (#166). The sheet takes focus when it opens (tabIndex -1, preventScroll), closes on Escape from its own onKeyDown as well as useEscape, and gives focus back without scrolling; in the tree a friend's .bt-row-wrap (row plus gear) shrinks like a plain row (.bt-tree-item>.bt-row-wrap), so the gear never overflows the sidebar and focusing it cannot scroll it sideways (#274). On every wide row (own Bots too) the gear's focus and click run keepSidebarStill, which puts back any sideways scroll a focus left on the row's ancestors or the page, now and after the settings dialog opened (#421). The Settings nav's last entry reads Harness settings ↗ with a tooltip saying it opens DeepSeek Harness's own settings in a separate window; FlyBot does not embed or retitle that dialog (#421).
  • An own Bot's settings (BotSettingsForm) draw the flybot.bot.details entries once, as a titled Sharing card right after Instructions (FlyBotDetails with wrap → Card), so sharing is not at the end of the page; with no entry there is no card. Bot settings and group settings both end with a More card (Duplicate / Delete). The composer card's :focus-within border is 55% ink, hover stays 30% (#406). Keyboard Tab order, checked in headless Chrome on a mock bench (#406): sidebar (Search, New…, the Bots, Connect apps, Account) → chat header pill → message box → Add, Dictate, voice → the turn stats; the shell.overlay layer (approval cards) comes after the main column in the DOM, and no stop focuses an invisible element. The message box comes before + because DSH's DOM puts the input first and FlyBot's CSS only reorders it visually.
  • Every listed Bot row in wide sidebar mode is a bt-row-wrap: the session bt-row and a sibling gear (bt-row-settings, hover/focus) that opens Edit Bot for an own Bot or Bot settings for a friend's (#166, #276).
  • The Session header is the shell's own: FlyBot registers the Bot pill in conversation.session.header.utilities rather than claiming the single conversation.session.header slot, so the seats that header renders — the lineage title and conversation.session.header.actions, where @deepseek-ai/ui-subagent puts the subagent catalog — keep rendering (#803). The floating header style FlyBot used to draw over the Session header is gone.
  • What the pill does to the page is not in the pill and not in a seat of its own (#803). The shell draws .utilities — and the pill with it — only while its chrome shows, which a Session without a first message hides, so a side effect parked in the pill stops there: the friend's empty-chat hint, body[data-bt-friend-chat], the Message {name} composer label, the visit ledger, body[data-bt-room] and the face-room @ filter. installHeaderEffects (src/client/header-effects.js) is installed once from apply and keeps them on the Session in the main view. It carries no React, and each effect has its own dependency key: the sessions list answers before the roster, so a single "did the Session change" guard would drop a friend's flags on the first pass, while beginVisit/endVisit keyed on more than the Session would open a second visit for a chat that was only just opened. test/header-effects.test.js drives those transitions on jsdom; test/header-slot.test.js pins that no such effect runs in HeaderPill and that FlyBot claims no other header seat.
  • conversation.session.header.corner is not available as that seat: it is single and ui-sidebar-right already registers its right-sidebar button there with no priority (0), and SlotCore.register throws on a second registration in the same cell (single slot "…" already has a registration at priority 0), which would fail FlyBot's whole client plugin from apply. Only --bt-first-sep still rides the pill, because it needs a turn and so always has a header to be drawn in.
  • The localized Message {name} label on the composer is one of those effects, not something the pill holds: installHeaderEffects starts holdComposerLabel (src/client/composer-label.js) for the Session in the main view, keyed sessionId|name, so the label survives a Session whose header is not drawn. It sets the placeholder CSS var and writes the label on every [data-composer-input]'s aria-label and data-placeholder; cleanup hands each input the shell's latest own value back when the Session changes or FlyBot stops (#166, #803). The shell picks its copy by phase before it knows whose chat it is (the new-session hero copy on a first open, its coding copy after a send), so a MutationObserver (those two attributes and child changes) rewrites them in its own callback, before the next paint, while the chat is open (#419, #499); test/composer-label.test.js drives it on a jsdom composer. The same installer sets body[data-bt-friend-chat] in a friend's Bot chat, and CSS hides the shell's run-stats footer ([data-composer-stats]) there: the friend's Bot runs on its owner's home, so local turns/steps/tok/s are relay turns and always 0 (#419). ContextCell hides Tool added / Tool removed rows for the roster's pluginTools (set in indexRoster via setPluginTools, text.js) as it does for connector tools (#419). The poll re-reads pluginTools from every state answer (a plugin reload makes no revision), and ContextCell (injected with face) selects them from the roster so a row already drawn re-renders when they arrive.

Face-room held bubble and overview

  • A face-to-face own message uses data-held on .bt-umsg while that room's pendingCount > 0: dashed, mixed user-surface, --bt-ink (not the finished sent user bubble). Incoming lines are unchanged. Face-to-face GroupOverview hides the Usage row.

FlyBot product chrome

  • User-visible chrome says FlyBot (header mark + name, About, settings title, tab title, favicon, zh copy). The mark is the existing FlyBot logo (docs/images/logo.svg), inlined as FlyBotMark from src/client/brand.js. Update popups say FlyBot core {version}. The npm package remains ds-bot; plugin id, localStorage keys, and console.warn('[ds-bot]') stay upstream.
  • The tab title follows the open chat (sessionTabLabel in brand.js: a friend's Bot reads Name (owner), #384, a room uses its display name; an ended face-to-face room, closed or left, reads Face-to-face · closed unless the user named it, #250). installTabTitle re-reads it on session and roster changes, so a face room the joiner opens before the roster names it, or a room renamed later, still gets its own title (#173). Sidebar row gear controls are hover/focus-only, and Bot settings call the duplicate/delete card More. An empty chat with a friend's Bot shows a one-line hint (--bt-remote-empty, set by installHeaderEffects) that the friend approves unless Auto-send is on (#173). The agent entry on + reads Add Bot.
  • In apply, any ctx.effect that reads roster must come after const roster = createSource(...): effects run immediately, and a read before the declaration throws and fails the whole client plugin load (core/test/client-apply-order.test.js).

One sidebar list

  • TeamSidebar lists local Bots, listed remote chats (isListedRemoteChat), live face-to-face rooms, and shared rooms in one bt-list. Own chats sort before foreign ones (a remote Bot, a face room, or sharedFrom); pinned and recency still win. A friend's Bot keeps the globe RemoteMark; a live face room gets a small people FaceMark (bt-row-remote bt-row-face). No row wraps its title (#384): a friend's Bot row shows its one name, friendBotTitle from shared/remote-label.js ("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), in bt-row-title, then the globe (at --bt-ink-2, readable) on one line; its second line is the preview like an own row, or Friend's Bot before the first message, and the full name is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (lookAvoidingLocals) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. indexRoster registers the own Bots (setLocalBots) and BotMark applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree; a published image/emoji/shape is left alone. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (faceRowTitle in text.js: Face-to-face · A, B · 482915 reads 482915 · A, B), so the cut never hides it; the hover text and label keep the full title (#458). A group row whose group holds an open question card (#550) says it waits for me: Waiting for your answer in its label and the waiting dot (bt-unread-wait), even when no approval waits there; the asking Bot's own row stays out of it because the card lives in the group (#552). A Bot row's group-work line (<group> · Thinking…) skips face rooms (awayGroups in text.js): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (isPastFaceRoom) so the social section can fold them under Past rooms. With @flybot/tree on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set include(botId) === false to omit a Bot from the default list (tree-archived and leftover 归档-* / Archive-* names, #452); those Bots stay findable in Search when not also bot.hidden.

Agent inbox (#349, step 1)

  • One Inbox row sits at the top of the sidebar, above the Bot list (wide and rail), with a count badge of the Needs you cards; it opens the inbox dialog over the conversation (owner decision 2026-10-20, option a). No new + menu entry, no new sidebar section.
  • Groups, in this order and with these words (the decision's "Need you" is written Needs you, as in the triage): Needs you, In progress, Done; each has a one-line empty message. Only data the client already has, no new host state: work.approvals (tool approvals), pendingCount on chats (held Bot messages), work.bots / work.rooms (running turns), the task records of src/host/tasks.js (actions.tasks → the existing tasks endpoint, polled every 30 s) and the Session list's updatedAt and last line for finished Bot chats.
  • Only my own Bots (not a friend's Bot, not another member's): a friend's chat appears only as a held message of mine waiting in it. Every card carries assigner, owner and executingBot (the first two are me in step 1) so team views can filter later.
  • Needs you: tool approvals, held messages, tasks that are blocked, have a result ready or failed (with the reason), and failed turns (#471, kind: 'turn'): from work.ended, the same ended-turn entries the system notifications read (#353), a place's (a Bot's own chat or one group) latest ended turn when it failed, in the last 7 days, while that Bot is not running or waiting for an approval there; its title is the chat's last line (The last turn did not finish without the Session list), its reason the failure. A later finished turn there clears it, and that Bot chat is then not also in Done. Open only. In progress: running turns (with elapsed time; a turn waiting for an approval shows only in Needs you) and open/claimed/working tasks. Done: accepted or cancelled tasks and Bot chats whose last turn finished. Done and failed tasks age out after 7 days; Done shows the newest 20.
  • A group's open question card is a Needs you card too (#552): roster.questions[<room id>] — a card a Bot asked about work handed out from that group (#550) — has the status Needs your answer (input, as a blocked task's question does), the question as its title, its detail as the reason, and in as its place; Open opens that group, where the card sits in the composer dock and takes the answer, so the inbox adds no second answer path. A card in a Bot's own chat, one for a removed group, and one from another member's Bot add none.
  • Each card has exactly one status, as a colored pill with an icon and a word (never color alone), the Bot, where and when, the title on one line with the full text on hover, and Open (closes the inbox, opens the chat). An approval the group chat's card can answer (answerable) also has Reject / Allow once, which call actions.answerApproval, the same answer as that card (no second approval path); the card leaves and a status line says what was done; an error stays on the card. No native confirm dialog. A held Bot message, an approval DSH's card answers in the Bot's own chat, and every task have Open only: Bot-sent messages are approved in their chat. Every action is a button, so the dialog is keyboard reachable; the close button takes focus and Escape closes.
  • Latest activity (#471): every card may have one more line, Latest: … (card.activity, bt-inbox-activity, full text on hover): a task's newest note, else what its Bot runs there now (only for running turns and tasks), else the chat's last line (previewOf); left out when it only repeats the title or the reason.
  • Tool-call count (#471): a card whose place used at least one tool shows one muted line, N tool calls (card.calls, bt-inbox-calls): the count of the turn running in that place now, else of the newest ended turn there, from the work snapshot (work.bots[id].calls, work.rooms[roomId][].calls, work.ended[].calls, core-host). One place only, never added across places; a count of 0 or unknown draws no line. Nothing new is polled and the card stays read-only. i18n key {count} tool calls.
  • Out of scope so far (#471): the three-column board with a List / Board toggle (v2), a first-run guide, team views (#51/#149/#55/#57).

Phone-width layout (#362, step 1)

  • Below 640px (MOBILE_MAX_WIDTH, core/src/client/mobile.js, MOBILE_CSS, installed last in the shell group so it wins at equal specificity) a ~400px phone browser can skim a chat, press Approve / Reject on a held reply and work the Agent inbox. Wider screens are unchanged: every rule sits in that one media query.
  • Held-reply and floating approval cards: Reject / Approve share the card width as 40px buttons, Approve filled with the accent; the held card uses the message's full width. The floating approval stack and the chat pointers span the screen beside the shell's 56px icon rail (DSH collapses the sidebar below 1024px), so the top + and the account stay reachable.
  • Inbox cards put their buttons on a row under the text (36px), and the title and reason wrap to two lines instead of one truncated line. The inbox dialog already fills the screen at this width (.bt-mem).
  • Out of step 1 (owner decision 2026-10-20): PWA install and push (after #352), native apps. A notification will only ever open the card; approval stays on the in-app buttons.

Approval wait badge on chat rows

  • A Bot row uses the same gold wait dot as a room row when bot.pendingCount > 0 (Bot Social holds on that friend's chat) or a local hold is open. Shared-room overview pending lines stay one line with ellipsis (bt-shared-wait).

System notifications (#353)

  • approval-notices.js noticeApprovals raises one system notification per event while the window is in the background or shows another chat: an approval that starts waiting ({name} is waiting for your approval), and a turn of one of the user's own Bots that finished ({name} finished) or failed ({name} failed: <reason>), from work.ended (turns.js endedView: the last 20, process-local; no friend's Bot, no turn a friend started, no hidden turn another Bot asked for, no face-to-face or inbound-share room, no turn the user stopped). Only when the platform already allows notifications (never asks), once per event, nothing from before the window opened; a click focuses the window and opens the chat or group, and a notification never approves, rejects or sends. Settings → General → Notifications (prefs.notifications, on unless false) switches every kind off; events seen while off stay told.
  • Waiting too long (#469): an approval still waiting WAITING_TOO_LONG (15 min) after it started, or after the window opened if it started earlier, gets one more notification ({name} has waited {n} min for your approval), from a timer set for the next due approval and cleared with the effect.
  • Per-kind switches (#469): under the main switch, Waiting for approval (covers the reminder), Finished and Failed (prefs.notify.<approval|finished|failed>, on unless false).
  • One per chat (#469): every notification of a chat or group shares the tag ds-bot-<chat or room id>, so a newer one replaces the older; its body counts the updates ({count} updates, the reminder not counted) since the chat was last on screen or its notification clicked. Quiet hours, mute and an in-app list are later slices. Covered by core/test/approval-notices.test.js.

Bot tree chevron

  • The sidebar Bot-list tree (sidebar.js TreeNode, from slot flybot.sidebar.structure; optional include(botId) filters the default list, #452) draws collapse/expand as .bt-tree-toggle: a 28×28 hit target with a 16px .bt-tree-chev glyph, hover background, and a :focus-visible accent ring. The glyph rotates when aria-expanded is false; the button stays upright so hover and focus stay square. Expand/Collapse {name} labels and aria-expanded are unchanged. Leaf rows use the same 28px width as a spacer (.bt-tree-leaf) so names stay aligned; indent is still depth * 16px and the Bot row keeps its height. An own Bot's row, in the tree and in the flat list, names the model the Bot runs on in its hover text (botRowTitle in text.js: Coder · DeepSeek V4 Pro, the model's served name from modelOf, also in text.js so the sidebar does not import the model menu, #366); a friend's Bot row keeps its remoteBotTitle, since it runs on its owner's model.

New-chat menu

  • The + menu is a popover anchored next to the sidebar + (#195, #173): its .bt-pane carries data-menu and stays see-through, the menu is a 280px card at the top left with no title row, and a press on the pane outside the card (or Escape) closes it. New chat, New group chat, New Bot and plugin rows open their full pane as before.
  • The sidebar footer button (ConnectApps in sidebar.js, label Connect apps / 连接应用) opens Settings > Connectors through actions.openConnectors(), the same call and the same words as the account menu's Connect apps row. The shell's plugin panel stays reachable in the Bot surface through the account menu's Plugins row (actions.selectPanel('plugins'), 插件), without switching to classic Agent mode.
  • One name per destination in the account menu and command palette (#392, #393, #394): Settings opens the Settings dialog (General), since Bot settings / Edit Bot open one Bot; Scheduled tasks opens Settings → Scheduled tasks, the name Settings and a Bot's details use (DSH's own bundle stays "Automation tasks" in the Plugins hints); and Switch to classic Agent mode is the only way to classic Agent mode, so Settings → General is Appearance only.
  • Create Bot starts on the Main Bot's model (#230): startModel() (bot-draft.js) picks the Main Bot's chosen model, else the one it last ran on, when it is served; then roster.defaultModel, then the first served model. Only served models are picked, so a provider without a credential is never preselected.
  • A missing model credential has its fix under the error (#405): TurnErrorCell (model-menu.js) shadows DSH's turn-error chat node (locale: 'chat', so title and the AUTH / QUOTA / account copy are DSH's own strings). When the failure says the route has no API key (missingModelKey, e.g. no API key for provider route "deepseek-official"), the row says the Bot's model has no API key yet (the raw provider text is not drawn, not even on hover, since a provider may echo part of a key) and, for a deepseek* route, offers the same chips as a Bot with no model (setupChoices: Sign in to DeepSeek where the shell can, Enter a DeepSeek API key, Open settings to set up a custom model); any other or unnamed route gets Manage models. Under the DeepSeek chips (here and in the no-model dock) a How to set a DeepSeek API key link (KeyHelpLink, new tab) opens docs/deepseek-api-key.md on GitHub: DSH's own key dialog keeps only a typed or pasted key (#451), so the guide also gives the credentials file and DEEPSEEK_API_KEY ways. Every other failure reads as DSH writes it, with no buttons.
  • Models and providers…, Manage models and Open settings to set up a custom model open DSH's Settings on its Models page (#407), not General: the settings.launcher seat's openSettings() takes no section, so openHarnessModels() (sidebar.js) calls the seat's openOnboarding('flybot-models'). That settings.onboarding step (ModelsSettingsStep, order 1000, after DSH's own steps) is handed openSection: opened on purpose (explicit) it calls openSection('models'), then complete(); in first-run onboarding it only completes. With no seat it falls back to openHarnessSettings(). FlyBot's own Harness settings entry still opens DSH settings as a whole.
  • Settings say each thing once (#230): the Model and Main Bot cards show no row label that repeats the card title (SwitchRow showLabel: false keeps the switch's accessible name), the Create Bot dialog has its Back / Close in the header only (the footer Back / Cancel is for the inline form), and the account menu is as wide as its longest item (width:max-content, capped to the viewport) instead of cutting Switch to classic Agent mode.
  • Slot flybot.settings.page (0003-flybot-api-client, types in flybot.d.ts, #337) adds a page to the Settings list right after Connectors: { id, order, label, icon?, component }. SettingsDialog (settings.js) lists useSettingsPages() entries under the key plugin:<id> and draws the component through SettingsPageSlot (a failing component draws nothing, it does not close the dialog); a page whose plugin is gone opens General. label is an English key for the host t(); the component gets { roster, actions, call, run, t, Section, Row } (the host translator, the Settings section card and row, so the page matches the others and reads zh from i18n.js). Social registers Bot Social (the server, see social). Test: core/test/settings-page-slot.test.js.
  • Slot flybot.account.header (0003-flybot-api-client) is a compact block at the top of the account menu (FlyBotAccountHeader); social uses it for the owner's own handle (#181).
  • Slot flybot.new-chat.action (0003-flybot-api-client, types in flybot.d.ts) is a row on the sidebar + new-chat menu, after New group chat. Plugins register { id, order, label, hint?, visible?, run }; the picker looks label / hint up with t() like the neighbouring rows. A string returned from run is shown as a toast (actions.notify). Social registers Face-to-face room and Add friend; see plugin-runtime and social.
  • Sidebar + (mode === 'new') opens a short action menu (data-menu, no To:/composer) until New chat or New group chat. That menu does not list individual Bots (Chief and the rest stay behind New chat). The Agent row in that FlyBot menu is Add coding agent; every row has its own mark (ChatIcon, BotIcon, PeopleIcon, a plugin row's icon, drawn through SlotIcon behind its own error boundary with the people mark as fallback; a function or memo/forwardRef component, TerminalIcon), one verb style (New … / Add …) and one short hint with no closing period (#385: Message one of your Bots, Set up a Bot with its own role, Chat with several of your Bots, Meet people nearby with a 6-digit number, Paste an invite link or code, A coding session without a Bot). A Bot's right-click menu has Edit Bot and no separate Rename Bot (the name is edited there); Add DSH Agent stays the DSH product string on Agent rows elsewhere (#126, #181).
  • Group bubbles, notices, and failed-reply lines never show a raw <untrusted-message> wrap or a developer error (llm-deepseek: no API key). remoteMessageText unwraps stored fences; failureLabel maps missing-key / unknown model failures to a short friendly line. Details stay in Settings → Feedback.
  • GroupReplies draws a user reply with other: true (another member's face-room line, #180) as PersonMessage (cells.js): the person's name, the person mark (PersonMark in mark.js) and a received --bt-bubble bubble, not a centred bt-event notice and not the sent --bt-user style; own lines keep the right-hand bubble (#229). other comes only from the host's person-line post (hooks.js postHuman), never from text. Another home member's line in a shared group (UserMessageCell, author stamped by the host) gets the same person mark and the received surface (.bt-umsg-other .bt-ubody). PersonMark({ name }) draws the person's initial on a color picked from the name (personColor, the core default-color hash of person:<name>), so one person looks the same on every home and two people are told apart; without a name it is the plain silhouette (#182).
  • A person's line keeps its line breaks (#455): .bt-person-msg .bt-bubble :is(p,li) is white-space: pre-line, as the sender's own pre-wrap bubble, since Markdown would join single newlines. A trigger whose source has shown: true (a line a running round already drew, host #455) draws nothing. Covered by core/test/face-people.test.js.
  • A face-to-face room is person-first (#229, #457, owner decision 2026-10-20). face-seats.js (re-exported by group-settings.js) builds it from participant fields and the roster, never message text: faceSeats lists the live Bot seats ({ left: true } adds the ones that left, marked left), faceSeatLabel names each Bot <Bot> (<person>), mine included (Chief (SelfB), Chief (SelfA); a Bot is never (you)), faceBotLabel gives a local Bot that name in a face room, and facePeople groups the seats by person (me once, first; a friend by home) with facePersonLabel putting (you) on me only. The overview and group settings Members · N count people and draw one row per person (FacePersonRow: person mark, name, then that person's Bots; my Bots open their chat).
  • In a face room a bubble names its speaker the same way (#457): my Bots' lines Chief (SelfB) (cells.js GroupMessage via faceBotLabel), a friend's Bot Researcher (SelfA) (remoteSpeaker), name only, with the handle as the tooltip instead of a globe and handle line. My own typed line is mine, not my Bot's: it carries my person mark and name (data-face-mine) and is never drawn held, so it does not change colour while a Bot's reply waits (user-message.js). A @Chief (SelfA) chip stays bound to that Bot after it leaves: faceMentionables also matches the left seats (after the live ones), so the name never falls through to my own Chief.
  • One name for a friend's Bot (#384, #305, owner decision 2026-10-19). remoteBotTitle (shared/remote-label.js) is the only format: the Bot's name, then its owner in parentheses (ownerNameOf: the human owner, ownerName / owner, before the home's label homeName), Helper (Bob), and a friend's Chief the same, Chief (Bob) (no <owner>'s Chief special case). For a friend's chat in the roster, friendBotTitle(bot) is that one call (#498), on both homes: the sidebar row (one string, no muted owner part), chat header, tab title, composer placeholder, row tooltip and the @ rows (a friend's Bot by that name with its handle as the description; the inserted text is unchanged; a friend's Bot is offered once: in a group by its seat, in a Bot's chat by its remote member row, which inserts the handle, not also by its chat row); in a face room faceSeatLabel / at-menu.js faceLabel give the same format (Chief (Bob), my own Bot Chief (SelfB) with my name, #457). The social plugin's classic script reads the same functions from globalThis.__flybot__.names (friendBotTitle, friendBotOwner, set in flybot-slots.js registry()). Approval cards name the receiving person instead (Chief → SelfB1233, #418). faceAliases keeps the older labels (Bob · Chief, Chief · Bob's Bot, Bob's Chief) as aliases, so a line typed before the change still calls the same Bot. DSH's own Sessions rows in a face room's @ menu are filtered in FlyBot (face-at-filter.js, below) until DSH has an API for it (#422).
  • Group and face-room Bot labels (#526, owner decision 2026-10-20, option a). The same person-first format: Reviewer (flybot682b8a43), never Bot Social's member label plus the handle (Reviewer · FlyBot 682b8a43 (flybot682b8a43)). remoteBotName drops a trailing · <owner> from a Bot's name when it is that Bot's owner (the server's label reaching a name slot, e.g. a bubble header with no seat yet); ownerNameOf passes over an owner name made up from an install id (isAutoOwnerName: FlyBot + hex id) to the owner's username from the home handle, and keeps it only when no handle is known; seatOwnerName reads my own seat's owner the same way (face-seats.js, at-menu.js). The Bot names in FacePersonRow and the remote rows of the group Members lists (group-settings.js, details-drawer.js) carry the handle as a tooltip, as bubble headers already do. Covered by core/test/group-bot-label.test.js.
  • A face-to-face room's subtitle (roomSubtitle → faceRoomCount in group-settings.js) names what it counts, 1 person · 1 Bot / 2 people · 4 Bots: Bots are the live Bot seats, people the distinct owners among them (me once, a friend by owner name); human seats and Bots that left do not count (#306).
  • The composer @ in a face-to-face room (mentionRows / atSources in at-menu.js) offers the room's own Bots, each once (a repeated remote seat is folded by handle; Bots that left are out), labelled as the Members panel names them (Chief (SelfA), Chief (SelfB)), plus Everyone with the room's Bot count (2 Bots, #457). Published friends' Bots (remoteMentionRows) and plugin rows (the social Friends' Bots section) are not offered there, since only the Bots in the room can be reached in it. Only the label changes: the inserted text is still the @Name the room routes on (@SelfA's Chief), never a handle (#305).
  • A face room's @ menu shows no DSH Sessions rows (#305, owner decision 2026-10-19: filter in FlyBot for now; #422 asks DSH for an API). DSH's built-in reference source cannot be dropped for one Session, so installHeaderEffects (header-effects.js) starts actions.filterFaceAtMenu for a face room, which watches that Session's trigger controller (inputTriggers.sessionOf, the same one the composer uses) with watchFaceMenu (face-at-filter.js): a <style data-bt-face-at> hides the reference rows whose icon is session (Sessions and Subagents) and their section title by MenuView's option ids, controller.hover moves the highlight past them in the direction of the key (so arrow keys skip them and Enter never picks one), and a menu with nothing else left is dismiss()ed, so Enter sends. File rows stay. Elsewhere, Everyone's count is the Bots it reaches (local members and seated friends' Bots), 1 member / 2 members.

Silent creation (#645)

  • Creating a Bot or a group chat opens nothing in the chat by itself: the picker's group path sends no greeting flag and the card sends no brief, so a new chat has no turn, no greeting bubble and no first-request cue. A line already typed in the composer is the user's own request: Create new Bot closes the pane and afterCreate (bot-draft.js) opens the new Bot, then sends that line to it as its first turn, so the Bot answers it and createPayload carries no brief for the host to keep as instructions. The Bot exists by then, so a failed send still opens its chat and reports the failure as a notice instead of leaving the card, whose Create can only answer "that name already exists" (#698). A Bot's first turn is otherwise a line the user writes in its chat or a message another Bot sends it. The cells no longer draw a creation turn: TriggerCell skips role === 'kickoff' (a setup prompt, not a line the parent sent) exactly as historyEntry does (#698); host side in core-host.

Group member dock (#546)

Every Bot at work in a group shows in one place, the member dock above the composer (member-status.js MemberDock), never as a second row in the transcript: GroupReplies (cells.js) draws only the round's lines, and splitBusy puts the round's current speaker first among the rows. Each row (WorkRow) is the Bot's mark, Name · <phrase>: <step> (workText; without a step <phrase>…) and the elapsed time from one minute on; the summary line for approvals here and members past the rows (SummaryLine) is a row of the same shape and column, with the first worker's time, not a centered .bt-event. The step is work.bots[id].step from the host, used for a group row only when that view is about the same group and tool (stepIn). A tool with no phrase reads Using <tool> (workPhrase), never only "On it". Rows come from the polled work view, so they leave with the turn. The approval card's action row wraps (.bt-mw-actions, flex-wrap) so all four choices, 拒绝 included, stay reachable in the panel's narrow width instead of overflowing off its left edge (#593). Tests: core/test/member-status.test.js.

A group or face room shows only its own work (#553, owner decision 2026-10-20 23:03): memberWork takes a member's entry in work.rooms[room], else its own-chat turn whose work.bots[id].fromRoomId is this room (work handed out from here with message_bot), and nothing else. A Bot busy in another room or its own chat is idle in this room's dock and member list, with no "busy elsewhere" hint and none of that room's steps, since its members can be other people. Approvals count when their roomId is this room, or when they wait in the handed own-chat turn. Handed work has no Stop in the member list (it runs in the Bot's own chat; stop-member-work stops the group turn). The Bot's own chat dock and the sidebar's Bot row still name the groups it works in.

Test connection (#409)

  • Bot settings → Model has a Test connection row under the model picker (ModelTestRow in model-test.js, shown once the Bot has a chosen model). Its hint says the request may be billed. A click posts test-model ({ id }) once; the button is disabled while it runs (Testing…), then the row shows Works. {model} answered in {seconds} s. or a short reason per host code (testOutcome: NO_MODEL, NO_KEY, AUTH, QUOTA, RATE_LIMIT, TIMEOUT, NOT_SERVED, MODEL_NOT_FOUND, TRANSPORT, SERVER, INVALID_REQUEST, else UNKNOWN) with the code beside it. The host never sends provider text, so nothing a provider echoes (a key, a token) can be drawn. Picking another model clears the result. Tests: core/test/model-test.test.js.

Hidden DSH tool-removal notices

  • FlyBot restrict()s DSH's ask_user_question off Bot sessions (hideBlockingQuestion). DSH then appends a developer tool-removal context row, and @deepseek-ai/dsh-client-ui-chat renders it as "Tool removed: {name}". ContextCell shadows conversation.chat.node context and returns null for those notices (HIDDEN_REMOVED_TOOLS). Other tool-change context (tool-added, unrelated removals) still shows; tool-call cells are unchanged. Empty shadowed nodes collapse via the existing :empty flow-item rule. Covered by core/test/tool-cells.test.js.

  • A Bot's held reply to a friend is drawn on its own turn (#418): AssistantCell (cells.js) finds it in the Bot's heldReplies (text.js heldReplyOf, by session and turn) and wraps the bubbles in HeldReply (bt-held-reply, data-state pending / approved / rejected / failed; pending and not-sent bubbles are dashed and muted). The turn's closing text carries HeldReplyActions (held-reply.js): while it waits, the holding policy's inline flybot.approvals entry (inline: true, props { held, decide, call, actions }) or plain Approve / Reject, and it claims the id so no overlay card or pointer repeats it; after the decision a quiet Sent to {to} / Not sent line (Retry for a failed send); a reply that went through Auto-send (auto, #417) is a plain bubble with Auto-sent to {to}. The recipient is the person who gets the reply (#418, #498): the host's toOwner (from the hold's recipientName, stored, so the same live and after a reload), else the owner of the friend's chat (friendBotOwner), so the card reads Chief → SelfB1233. The sidebar preview (previewOf unsentTurns) shows the friend's line for a turn whose reply did not go. The overlay ignores inline entries.

  • The held-send overlay (FlyBotApprovals, flybot-slots.js) runs every hook before its if (pending.length === 0) return null: a hook after that return made React throw on the first held message and no card ever drew (#254). test/overlay-approvals.test.js renders it with a stand-in that enforces React's hook-count rule.

Connectors page and the Composio row (#311)

Settings → Connectors (src/client/connectors.js) lists one row per connector from roster.connectors. The GitHub row takes a token. The Composio row takes an API key and an app choice: the picked apps as pills (click to remove) and a Search Composio apps box that asks connector-app-search (debounced; with the key being pasted before connecting, never saved by the search, and with the saved key after) and offers the results to add; without a key to search with, or when the search fails, it filters the built-in common apps instead (#359). It then shows Connected: <apps>, one line per picked app with Ready or Needs sign-in from connector-app-status (asked when the row is ready and again when the list or the picker changes; nothing is drawn until Composio answers), with Choose apps (the same picker, each pill also showing its status) (changes the list through connector-apps, no key) and Disconnect. The key goes from the masked input to connector-connect and is never read back. Copy is English with a zh entry for every string in i18n.js. Under a connected connector the row reads On for all N Bots, On for K of N Bots or Off for every Bot. Choose which Bots may use it., and Choose Bots opens one switch per local Bot (src/client/connector-bots.js, setBotConnector(botId, connector, enabled) → bot-connectors, #335, #369, #601): the Bot's name, on by default; only an explicit bot.connectors[id] === false reads as off. Sharing a Bot changes nothing there; the footer line says it is on by default for each of the owner's Bots on every turn, and that a friend's own Bot never gets it (zh: 默认对你的每个 Bot 开启…). The "also when others use it" switch is gone (#601). The same switch per connector is the Connectors card in each Bot's settings (BotConnectorsCard, shown only once a connector is connected). Connector tool notices from DSH (Tool added/removed: mcp__github__* / mcp__composio__*) are not drawn in the chat.

About FlyBot page (#374)

src/client/about.js shows the FlyBot mark, Version <core version> · <branch> <commit> (the part after the version only for a git install), the Star button (the repository from update.repoUrl, the live count only when it came back) and the star request line, then Updates (the FlyBot core row and the DeepSeek Harness (underlying) row, zh DeepSeek Harness(底层)) and Links (Source code → the repository, Release notes → its releases, Report a problem → Settings → Feedback). For a git install on a branch the FlyBot core row reads Installed from GitHub (main 082563a). Updates come from the maintainer: reinstall FlyBot to get them., and after Check for updates Up to date with main., 1 commit behind main. …, N commits behind main. … or Could not compare with main. … plus · Checked <time>; a row with no known commit keeps the first sentence. An install pinned to a commit (build.pinned) reads Pinned to commit 6d04db6. Updates come from the maintainer: reinstall FlyBot to get them. and the version line shows only the short commit (#379). Other installs keep their old lines (Updates are not offered for this install., Installed from a local folder; update it there., Update checks are off.). Every string has a zh entry.

Discarded promise .catch (#213)

A discarded .catch(() => {}) in the client sources either starts with void (fire and forget) or carries a one-line reason comment (an awaited wait); a .catch(() => undefined) whose value is used stays. check-empty-catch enforces it (see docs/code-standards.md).

Tests

npm test in core/ (it also fails when client.js is stale).

  • A friend's line that Bot Social attests the friend typed themselves (#227) is drawn as the person: AssistantCell looks the turn up in roster.remoteTypedTurns (typedBy in src/client/text.js) and renders PersonMessage — the person's name and a person mark (.bt-person-mark) — instead of the Bot's Bubbles. The name comes from the roster (the authenticated Bot Social from), never from message text.
  • The same attested line sent to one of my own Bots outside that friend's chat (a friend's Chief typing to my Helper) arrives as a remote message whose source has remote.typedBy; RemoteMessage (cells.js) then draws it as the person (name, person mark, via <Bot>) instead of the friend's Bot avatar and handle. Without typedBy it keeps the Bot rendering (#233 follow-up). In a face-to-face room remoteSpeaker(source, roster, sessionId) draws the Bot with its room seat's look (the participant's color / avatar from Bot Social members[]), so the bubble matches the Members panel, the room avatar and the waiting tiles; a chat record for that handle keeps its id and wears the seat's look (#182). The via name is the carrying Bot's plain name when this home knows that Bot (its chat or roster.remoteBots), and is left out when it does not; the model-facing sender label (X (typed via @…, Bot Social)) and the handle-derived · owner's Bot never reach that header (#275). Settings → Bots offers only the user's own Bots as a Main Bot, in the first-Main-Bot and Add a Main Bot selects (#276).
  • A friend's line in my Bot's own chat (#420): friend-lines.js friendLineNews compares each Bot's host stamp friendLineAt with the last stamp this browser has shown (localStorage ds-bot:friend-lines; stamps against stamps, never this browser's clock) and, when a newer one arrives while that chat is not visited, index.js marks the Bot unread the way Mark as Unread does, so the row shows the blue dot until the chat is opened. A browser with no record yet takes what is there as shown; another member's shared Bot and a friend's Bot chat are skipped. The Main Bot tile shows the same dot (.bt-tile-unread; a wait still shows on its avatar). A line whose source has remote.firstContact opens with one muted FirstContact line (cells.js): <person or friend's Bot> is talking to <Bot> through Bot Social. Covered by core/test/friend-lines.test.js and core/test/inbound-ui.test.js.
  • The roster poll (index.js pull) sends watching: !document.hidden with each state call (#497), so the host knows a window is on screen and the social plugin reads friends' inboxes at its live cadence: a friend's first line, its held reply's card, the Inbox count and the unread badge arrive in that window within seconds, with no reload.
  • The NEW divider (cells.js TurnTailCell / TurnProcessCell / the unread opening) compares turn times with the visit's seen (when the chat was last left) and entered (when this visit opened). Once the divider has been on screen (NewMessagePills in header.js, view.divider === 'in-view') and a shown turn begins after the visit opened (movedOnSince: a friend's next line, the user's own message; hidden exchange turns do not count), actions.settleVisit sets seen to entered (settledVisit) and every divider of the visit goes, as on leaving and coming back. A divider still scrolled out of view, or a chat where nothing new happened, keeps it until the visit ends. Same rule in every chat, including a friend's typed line in the Bot's own chat (#275). Covered by core/test/turn-tail.test.js.