| subsystem | core-client | ||
|---|---|---|---|
| owned_paths |
|
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.
-
Browser client: sidebar, chat, panels, themes, characters, Bot browser panel (
core/client.jsgenerated fromcore/src/client/). The sidebar + new-chat picker (core/src/client/new-chat.js) takes extra rows from slotflybot.new-chat.action(after New group chat;label/hintgo through the samet()map) and lists onlylistedChatBots(gone, hidden, and home-level remotes stay out; owner labels match the sidebar). Covered bycore/test/new-chat.test.jsin patch0003-flybot-api-client. -
The chat's
ask_usercard (questions.js) answers where it is drawn: one option answers a single-select card at once, a multi-select card toggles its rows and Submit sends the set, and the × dismisses it; the same card waits in the dock above the composer when its call is not on screen. In a window that holds the team read-only the card keeps the question, its rows and the host's sentence (roster.readOnly, read throughlockedWindowfromschedules.js) and draws none of those controls — no option button, Submit, Skip, × or own-answer field, and no form to submit. Each of them reached a write the host refuses (answer-question,dismiss-question), and the refusal was invisible: a pressed answer marked the card answered and the next poll said nothing, while the × hid the card until that poll brought it back. Tests:core/test/questions-readonly.test.js. -
Read-only banner: when another dsh holds the team (
roster.readOnlya non-empty string), the sidebar draws the host's own sentence and, beside it, one Reload control in both shapes (wide and rail; the control stays outside therole="status"line so a live region remains text). It callsactions.reloadTeam, which re-reads the whole team (statewithoutsince, seestateRequest/stateAnswerIsStaleinsources.js) so the banner and every refused control clear once the other holder let go, without a page reload that would drop what is in the composer. Tests:core/test/sidebar-readonly-reload.test.js,core/test/client-reload-state.test.js. -
Chat context rows: a tool the plugin registers at install draws one
tool-additionrow, andcells.jskeeps every name inTEAM_TOOLSout of the chat (SILENT_REGISTRATION_TOOLS, #1101) — the Project Assistant'sproject_digestjoins them (#1712), so its 09:00 Session shows the digest, not its registration. -
A Settings dialog takes focus when it opens (
useDialogFocusincore/src/client/overlay-hooks.js; the.bt-settingsdialog carriestabIndex -1and a ref) and gives it back to the still-connected opener when it closes; it is not a focus trap, so Tab can still leave the dialog for the page behind the scrim (#2251). It is drawn forDIALOG_EXIT_MS(160 ms) withpointer-events: noneon the leaving layer, so the unmount restores focus only while the dialog still holds it (or nothing does) — a click on the page during that window keeps its focus (#2270). -
Settings → Feedback's error list (
feedback.js) still titles the block with the whole log (Recent errors · 60) but lists only the newest eight, so when the report holds more rows oneShow morecontrol (aria-expanded) sits under them and draws every loaded error, folding back toShow less; a report of eight or fewer draws no control (#2266). -
An error toast keeps the host's code:
reportErrorinindex.jsputserror.codeinto the toast payload andfeedback.jsdraws it as abt-tag bt-toast-code, so a reader can tellbot/read-onlyfrom any other refusal. That code is also the one action a toast can take: abot/read-onlytoast offers Reload (bt-toast-reload) in place, which dismisses the toast and runs the client's own full-state read (actions.reloadTeam), so the window becomes writable once the other holder let go without a page reload that would drop the composer; a failed read goes toactions.reportError(error, 'reload')the way the sidebar's Reload does. Every other code, and a toast with no code, draws the sentence and Details exactly as before (P-502). -
A link picked in a chat message opens in FlyBot's own panel: a capture-phase click handler on each bubble and on the person's own line (
bubbleLinkClickincells.js; a group card's container carries none, so a reply's link opens the tab of the Bot that spoke) takes anhttp(s)anchor before the host's delegate does, puts a tab in the panel's browser view (the globe button's view) withactions.browserNewTaband carries the URL withactions.browserNavigate;browserNewTabanswersnullfor a Bot outside the roster, and with no tab — or no Desktop bridge — the page opens in the window itself. A mention, a file link and a modified or middle click are left alone, and the bubble renders bareMarkdownTextso the host'sopenFile/fileImagesreach it: a nestedMarkdownDelegateProviderreplaces the host's capabilities, and one that lackedfileImagesdrew every bubble image as its alt text (#2319). Left to the host a click opened a tab in the host's right sidebar, which this panel's fixed layer covers, so the content area looked blank (#2201). Tests:core/test/chat-link-open.test.js.
- The server side,
core/index.js,core/src/host/,core/src/shared/→ core-host. - Plugin slots and the browser
flybotservice (flybot-slots.js,flybot-registry.js) → plugin-runtime. - Pin, patches,
core/dev/→ ds-bot-core.
| File | Role |
|---|---|
core/src/client/ |
Client sources → build to core/client.js. |
core/src/client/busy-ack.js / busy-ack-logic.js |
Busy-send spoken acknowledgment (#1584): BusyAckBubble draws one short bot bubble while a human send is accepted during a running turn (host busyAcks or local queue/steer pending). Pure helpers in busy-ack-logic.js. Tests: core/test/busy-ack-client.test.js. |
core/src/client/memory-empty.js |
The memory dialog's refused-folder line (#2101), decided by pure helpers because the suite has no renderer: memoryEmptyNotice(view) answers one line naming the folder when entries is empty and the host reported refused scopes in view.blocked, and null for the ordinary empty view, so memory-panel.js draws it above {name} has not saved anything yet; memoryBlockedNotice(view) answers that same line from view.blocked alone, whatever entries holds, and the panel draws it above the summary and above the entries list, because entries is own and team merged and one scope's entries would otherwise silence the other scope's refusal. A refused folder and a fresh Bot both answer entries: []; the host side of that field is in core-host. Tests: core/test/memory-empty.test.js. |
core/src/client/memory-panel.js |
The memory dialog. MemoryRow is the Bot details pane's row — how much it keeps and the way into MemoryDialog — and useMemoryCount keeps that count from the same read the dialog does. The dialog has two pages: the summary the model writes from the entries (stale and failed states, Try again), and the entries themselves behind the ⋯ menu, grouped by scope and topic with a two-click Remove. The foot's Ask or update is one model call that answers from the entries or changes them, and lists each change as applied or refused. useMemoryView re-reads every POLL_MS while a summary is being written and only the latest answer counts. Since #2276 a read-only window (roster.readOnly a non-empty string) draws neither Remove nor the ask form — the host refuses memory-forget and memory-ask — and says why in the host's own sentence, while the entries and Regenerate summary (a read) still work. Tests: core/test/memory-panel-readonly.test.js, core/test/memory.test.js, core/test/memory-empty.test.js, core/test/memory-search.test.js. |
core/src/client/exchange.js |
The Bot-exchange sheet: ExchangeDialog polls the stored turns between two Bots and ExchangeLine draws one. A stored line is read once, by exchangeText: a leading Chief → 助手 delegation trace and an <untrusted-message …> fence are routing, not words, so the bubble and the message tools' Copy receive the same cleaned string. Unlike a sidebar row it is not folded to one line — a reply's paragraph breaks are the writer's own and stay exactly as stored. Tests: core/test/exchange-read.test.js. |
core/src/client/i18n.js |
The zh map the host t() reads. A plugin Settings page that calls t('…') adds the English key here (Settings → Bot Social friends and sharing, #1074); npm run build in core/ copies the same map into 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. DETAILS_WIDTH is 380 since #1347: at 320 a settings row's label and its control could not share a line, so "Name" broke into "Nam" + "e" and "Test connection" into "Test connecti" + "on". browser-pane.js reads a saved width from MIN_DETAILS up, so a width stored under the older 320 policy is kept rather than silently widened. 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. The same sheet clips horizontal overflow on the document, the conversation column and the app frame (overflow-x:clip, #1086), and watchShellLevel / levelShell put a leftover scrollLeft back, so an error toast or dialog cannot leave the window shifted. Tests: core/test/shell-level.test.js. |
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/shared/held-retry.js |
The one rule for "this failure can still be retried" (#968): canRetry(record) is false for retry: false, for noRetry: true, and for a record whose stored error/code names a terminal reason (no such remote bot / is not shared with your Bots / is no longer shared with you / has not granted chat.public / bot_not_shared / bot_not_found; a bare capability_not_granted is not one, #988), true otherwise. The host states the same answer in two spellings (noRetry on an approval, retry: false on a held reply), so both cards and Bot Social's own dock ask this one function instead of keeping a copy each; a hold written before the flag existed is read from its stored sentence at render time, with no state migration. Tests: core/test/held-retry.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; a new task opens with no target chosen — Send to reads "Pick a Bot or group chat" and Create task stays disabled until one is picked, so one click cannot create the task for the first Bot (#1334); an edited task keeps its time zone, a cron rule it cannot show is kept), Copy (CopyRow, #1287: the row turns into the create form's target picker, minus the chat the task already runs in, and Copy posts schedule-create with copyFrom: { sessionId, id } — the host writes the row, so the copy keeps the source's next run) 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). The page opens with the team's Quiet hours card (#1166): Off with a Turn on button, or On, 22:00–07:00 with the window's two time fields and Save window (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line, as a role="alert" so it is announced and not only printed (#2232)). While quiet hours are on and an every/cron task exists, the view's quietUnchecked flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. A repeat whose interval is not a whole number of minutes is stated as seconds (Every 90 seconds, #1651): the edit form opens holding that exact value in the seconds unit, and saving a task whose repeat was not touched sends the stored interval back unchanged instead of a rounded number of minutes. The details panel's line (ScheduleSummary) lists every task of that Bot, paused and finished ones included, because a Bot whose tasks are all paused has tasks while a Bot with none is the only empty state; each row answers when — Next: <time>, Paused or Finished — and an unreadable stored instant falls back to the repeat rule instead of throwing, so one corrupt scheduledAt cannot take the panel down. That empty state's button is the only one that opens the new-task form (openSettings('schedules', bot.id)); a row opens the settings list (#2024). A read-only window draws neither that button nor a Bot card's New task row: both ask the settings list for the Bot's id, which opens the create form, and the locked page draws no form (P-457). The line draws three rows, so above three the title carries the real count (Scheduled tasks · 5) and one final row (2 more not shown) opens that same settings list — never the new-task form, which only the empty state means (#2030). Until a read answers, the shared view is null and the line draws nothing rather than claiming the Bot has no tasks: the empty state belongs to a read that answers zero (#2035). A read that fails is not a skeleton that never ends: it answers a view carrying readFailure, the page prints The scheduled tasks could not be read: <reason> instead of Loading… (schedulesReadFailure; a Bot's card prints the same sentence and offers no New task), and the details lines stay absent rather than inviting a task that may not be creatable. A group chat's own details panel lists the tasks that room owns (RoomSchedules, #2039): the same three-row shape and count, filtered to tasks carrying that room's roomId and no botId, so a Bot's task in the same view and another room's task stay out; a room with no tasks draws nothing at all, and a face-to-face room has no such section. Its rows and its N more not shown line open the settings list with the single argument, never a target. The two empty surfaces read the view's someHidden (#2112): a narrowed list that dropped another member's rows says so — the settings page "No scheduled tasks you can see. Another member's tasks stay hidden.", the details line "No scheduled tasks you can see. Add one" — instead of "No scheduled tasks yet.", while a view with nothing hidden keeps the plain sentences. A read-only window (roster.readOnly a non-empty string, read the way the header reads it) draws no scheduled-task write at all (#2232): no Quiet hours toggle, window fields or Save window — the card still states Off or On with the window — no New task and no create form, and no Edit, Copy, Pause/Resume, Delete or Retry on a row; the page draws no read-only sentence of its own, because the Settings header carries that one (#2199), and BotSchedules draws no New task row either and the details line above draws no empty state, so no click lands on a form the window cannot draw (P-457). A locked card with no tasks draws nothing at all, heading included (P-482): it has no row to show, and the bare heading read as a list that failed to draw. In a writable window a refused Pause, Resume, Delete or Retry puts the host's own sentence on that row's alert line — one alert per row, cleared on the next attempt and on that row's next success — while a successful Pause still republishes the host's answer and says nothing; the create form's own refusal line is a role="alert" too. A row also says what an unattended run would carry (#2323): reachText names only a connector that is both ready and on for this Bot (isOn from connector-bots.js for the per-Bot switch; connectedConnectors before it drops a sender and a webhook-only channel; no per-Bot record means on; connecting/error are dropped even though the Connectors page counts them as connected, #2354), reads "Runs with no connectors" when no connector is both ready and on, and says nothing on a room task, which has no single Bot's switch. 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); ask_user cards (a group's and the Bot's own chat) answered in the inbox (#552, #1140); a Needs you title and reason wrap in full so the ask can be read on the card (#1177); a result card accepted (Accept) or sent back with a note (Send back → the box → Send it back), with its result.links evidence and the producing Bot's model, and a sent-back card reading Sent back: … (#401). Tests: core/test/inbox.test.js. Since #1706 a failed workflow run is one Needs you row, on the same list and InboxCard path: the newest failure per graph inside that same 7-day window (workflow/state runs + history, deduped by run id), titled <graph> · <node> when the failure names a node and carrying `failure.text |
core/src/client/turn-failures.js |
Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (flybot.session.dock, data-flybot=turn-failures), newest last, at most 3, naming the provider error from the host's ended entry and the streak when it is above 1. Registered by index.js (flybot-turn-failures, order 20); failureLines(snapshot, place, t) lives in core/src/shared/turn-failures.js so the test reads it without React. Since #1347 the error goes through failureSentence (core/src/shared/failure-copy.js), which turns a provider refusal stored as a cut-off JSON body into a sentence that names the feature and where to change the model; the Inbox reads the same helper. It never posts, retries or counts as a reply. Tests: core/test/dead-turn.test.js, core/test/ui-clickthrough.test.js. |
core/src/shared/preview.js |
What a preview may show of a stored line (#130, #1347): visibleText(text) unwraps complete <untrusted-message> wraps and then removes leftover marker fragments until neither changes the text — a from="User"> tail, a untrusted-message> whose brackets an older pass already stripped, a nested fragment that would otherwise rebuild a marker — and folds the result to the one line a sidebar row is. stripFence is the marker half on its own. It is used where a row is built (previewOf in src/client/text.js, the group row in src/client/sidebar.js, the host's preview in src/host/channel.js), so no surface has to remember it, and by the details drawer's copy export: transcriptOf (src/client/text.js) reads each stored turn through the same helper, so a copied conversation carries the words, not an untrusted fence, and copyBodyOf drops a leading delegation trace only when its first hop is a Bot's name taken from the roster (copyNames; longest first, and no names strips nothing), which keeps a line like Settings → Models 页面 as written; a copied reply keeps its own line breaks, and only a trace-only line or a fence-marker line is dropped. Tests: core/test/ui-clickthrough.test.js, core/test/details-copy.test.js. |
core/src/client/board-card.js |
The task board's card frame (#1363): BoardCard draws the identity line, the title, the note and the actions the board passes in, with TASK_SKIN. A card carries data-source (task) and data-key (task:<id>). The caller is kanban.js. Tests: core/test/board-card.test.js, core/test/kanban.test.js. |
core/src/shared/board-card.js |
Task-card identity (#1363): taskCardKey(id) is task:<id> and taskCard(task) builds the model BoardCard draws. Tests: core/test/board-card.test.js. |
core/src/client/kanban.js |
Per-Bot task board (#1058, #1099): the Tasks row under Inbox (KanbanRow, ui.board, actions.openBoard / closeBoard) and KanbanDialog. boardRail lists the viewer's Bots nested by createdBy and the groups they administer; boardColumns maps task status onto Not started / In progress / Done. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared board-card.js component: a task card says source: 'task', keys task:<id> and keeps data-task / data-column for this board's own selectors (#1265 step 1). The dialog body is BoardView (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card is the shared BoardCard with TASK_SKIN, and dragging it or dropping it on a column only reports the request to the dialog's onMove (actions.taskUpdate), so the view still never writes. In a group scope, columnTree nests subtasks under the overall task (kind: 'group') when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or Start / Complete calls actions.taskUpdate; only the Bot's owner gets those actions. A group scope loads tasks { roomId }. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by updatedAt (tiebreak createdAt), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. Since #2045 that page is also counted for what it is: when the archive holds a full page (ARCHIVE_SHOWN), the Done heading reads 20 newest and a Bot's rail row reads 20+ done rather than a fraction that would present the page as the history, and in a group the page is cut across the room, so every Bot's share of it is a lower bound and reads the same way rather than presenting its slice as its own total; a page under the limit keeps {done}/{total} exactly as before. P-364: the rail adds up the list the scope actually draws from, and a room board keeps its active cards in a second list of its own (groupTasks), so a room rail counts the room's active cards as well as the archive page, reading 1/4 beside four drawn cards and 0/1 for a Bot whose cards are all still active, while a Bot board's numbers are unchanged. #2069: it counts one list and never both — own still holds the Bot board's read after the rail switches to a room, and adding it counted the room's active cards twice, so a room board that draws two cards read 1/3 where it now reads 1/2. P-360: a Bot board's load effect is keyed by the Bot it shows, so switching the rail re-reads the new Bot's active list and archive page — the page it kept was that of the Bot it left, and filtering it by the new Bot emptied its Done column while the poll kept refilling the old one. That read without limit stays uncapped on purpose: it is the id-lookup and tooling path (statusOf, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. A card in the Done column also says when it finished — a relative time beside the assignee (bt-board-when, relativeShort), on a Bot's board and a group's alike (#2066). The stamp is the first terminal entry in the card's actors (accepted, cancelled or failed), never updatedAt: record() bumps updatedAt on a plain edit too, so taking it would name the edit instead of the finish and taking the last terminal entry would do the same. A row written before that history existed falls back to updatedAt, and a card with neither a finite at nor a finite updatedAt draws no node at all — relativeShort(undefined) would print Invalid Date and 0 would print 1970. The other columns keep the footer they had. #2248: a read-only window (roster.readOnly a non-empty string, read once in KanbanDialog) draws no board write the host would refuse — no Add task form, no Start / Complete, no draggable card, and a drop on a column runs no taskUpdate — while the readable board (rail, three columns, cards, archive, Done stamp) and a writable window are unchanged. Since #2273 that board also says inside itself why the writes are gone: because the board is a full-screen overlay that covers the sidebar banner and the Settings hint, BoardView draws the host's own read-only sentence as one role="status" line above the columns (bt-board-hint), the same wording those two surfaces use and never a second one, and a writable window draws no such line. Tests: core/test/kanban.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. |
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.
client.jsmatchescore/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-sectionrule inFLYBOT_CSS(flybot-slots.js), so aflybot.sidebar.sectionentry whose component rendersnullleaves no separator behind while a section with rows keeps the base rule (#818). Behavior is specified on plugin-runtime. - The built bundle carries the dock wrapper's new
blankprop:FlyBotSessionDockreadssession.blank && !session.promptAttemptedand hands the answer to everyflybot.session.dockslot (#998). Behavior is specified on plugin-runtime. - A blank Bot chat draws its own strip (#1347): on that same
blankfact, and only for a local Bot (!bot.remote), the dock rendersBotChatStrip(bt-chat-strip,data-flybot=bot-chat-strip) above the composer with the Bot's name, its role, "No messages yet. Say something to {name}." and a Details button. It disappears with the strip's own condition the moment the shell draws the real header, and a friend's Bot chat keeps its own empty hint (#173). Tests:core/test/overlay-approvals.test.js. - A settings row never breaks its own words (#1347):
.bt-bs-row>.bt-set-copy>.bt-bs-labeliswhite-space:nowrapwith an ellipsis, and.bt-bs-row .bt-soft/.bt-miniareflex:none;white-space:nowrap. The hint under a label still wraps — it is prose, and clipping it was tried and reverted..bt-paneldeclarescontainer:bt-panel/inline-size, and@container bt-panel (max-width:300px)stacks the row at the panel's narrow floor, so nothing is clipped to fit a width that is not there. - The mode word beside the title reads as a status, not a control (#1347):
[data-slot="conversation.session.header.actions"] [class*="_label"]loses its fill and radius and takescursor:help. DSH draws that label as aspanwith no click handler (AgentPresetLabel), so a chip that looks pressable is a promise nothing keeps. - The thread cannot paint above a dialog, a sheet or a scrim (#1347).
[class*="_centerCol"]carriesposition:relative;z-index:0;isolation:isolate, so the chat column is one stacking context that sits below.bt-panel(40),.bt-pane(45) and the dialog layers (120/121) whatever any single bubble does. This is the load-bearing half: withposition:staticthe column was no context at all, so a descendant the browser promoted to its own composited layer — an entrance animation's transform (bt-row-in), afilter, awill-change, a sticky box — was ordered against the ROOT stacking context and could sort above a dialog's z-index box. Raising the tiers alone does not carry the fix, for the same reason: a promoted descendant is sorted in the root context and never reads the column's z-index.contain:paintis deliberately absent: it would clip the reaction strip and message menu, which portal tobodyfor that reason.core/test/ui-clickthrough.test.jspins the rule and its z-index.contain:paintis deliberately absent: it would clip the reaction strip and message menu, which portal tobodyfor that reason.core/test/ui-clickthrough.test.jspins the rule and its z-index. - Each overlay layer is its own stacking context (#1347).
.bt-settings-layer,.bt-mem-layer,.bt-palette-layerand.bt-remote-sheet-layercarryisolation:isolatebeside theirz-index, so the layer as a whole — scrim and dialog together — is what sits in the tier, whatever a dialog's own contents do. - A modal's scrim separates the two surfaces (#1347).
.bt-scrimwasrgba(0,0,0,.32), which left the thread legible behind a dialog and read as the chat painting through it (the owner saw a message at "full opacity" behind the Inbox, and assistant text "across columns" in Tasks). It is nowrgba(12,12,14,.62)in the light theme andrgba(0,0,0,.7)in the dark one: still one layer, still dismissed by a click. - One tier per layer, no ties (#1347). Dialogs are 120 (
.bt-settings-layer, and.bt-remote-sheet-layer) and 121 (.bt-mem-layer— Inbox, Tasks, Memory); popovers are 130 (.bt-menu,.bt-react-strip,.bt-update-pop, and the in-panel.bt-avedit); the palette is 140; the toast stays 1000. Before this the context menu and the reaction strip sharedz-index:60with the Settings dialog, so a popover opened after the dialog tied with it and painted over it on DOM order. Nothing may sit in the old 60/61 band again.
- The committed bundle includes
flybot-slots.jsregistry(), which exposesBotAvatar/BotMark/lookOfonglobalThis.__flybot__for classic-script plugins. Behavior is specified on plugin-runtime.
- 新建群聊 (
new-chat.js) picks a local Bot as admin; a friend's remote chat is split intoparticipants: [{ 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 thenew-chat.jsrow).
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. The session list also decides whether an id may be handed to the shell at all: the shell's replaceMain calls sessions.retain, which throws synchronously on an id the list does not know (sessions.retain: unknown session), and the landing runs from a 400 ms timer with no click behind it — so since #2206 a caller that passes the list gets only a destination that list knows, a fresh part it has not listed yet falls back to the chat it replaced, and with nothing known landingPart answers undefined and landOn records refused and leaves the view where it is instead of an uncaught error. 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.
- 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 sendssecret-updatewith'all', the picked ids or[]. A new key and a connector key start as All my Bots (host rule in core-host).
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 face-room create prompt (Start a new room with {code}, and the hint under it, #1053) is in that same map, so a zh UI reads it in Chinese. So are the social picker's two face-room rows and the screen they open (#1021): Join by number or link (用号码或链接加入) and Start a face-to-face room (面对面建群), the start row's hint, and its own about line, all four used through t() — so both rows and both dialog titles read Chinese, and the single key #892 wrote for one combined row (Join or create by number or link) is gone.
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 canRetry() allows, #968). 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 only while canRetry() allows it (#968). Since #2310 a read-only window (lockedWindow, schedules.js) draws none of the three: the card keeps its two lines, the recipient and the text, and carries the host's own sentence in one role="status" line (bt-hint bt-read-only) in their place, because decide is not in the host's read path and the buttons could only earn bot/read-only after the click. 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.
- A local group's Members · N card (
group-settings.js) offers Make task lead / Use the admin on each of this home's Bots (#1182). The picked Bot is the room's task lead (room.taskLead); the tag reads Task lead, and the note says the admin divides the board when none is picked. Face rooms and shared rooms do not show it. The card ends with the plugin rows of slotflybot.room.members(FlyBotRoomMembersinflybot-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 gethost, the core endpoint call (also__flybot__.host), forroom-invite/room-invite-mint/room-invite-revoke/room-invite-seat(#876, #1064, the social plugin's Invite by link; a claim the host could not finish is named there, and Seat them is in the same dictionary). Covered bycore/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, including Enter 6 digits under a partial room number, #1052), so a zh UI reads it in Chinese. A room a link opened is titled by its own name on the joining home (roomTitleinremote-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 (
FaceMembersindetails-drawer.js) open withFaceLead(group-settings.js, #476): the current lead asBot (person), Default until the creator picked one. Only whenroom.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 callssocial/face-lead. Everyone else sees it read-only.FacePersonRowmarks the lead's Bot with a Lead tag (isLeadSeat, by handle). Never a chat command.
RoomAvatar(src/client/mark.jsroomAvatarPeople) seats current localroom.membersand live remoteparticipants(handle-seeded vialookBotForRemote). A face-to-face room with only remotes no longer falls back to the grey placeholder in the sidebar row or chat header. A face room whose only seated Bot is the local one — alone, or while a peer has no handle yet — is that Bot alone, so the header, sidebar and details icon show the local Chief's look instead of the greyyoucircle (#1089).roomAvatarPeoplestill returns locals first, then remotes, and a one-member group chat is still[member, you].RoomAvatarpaintsclusterOrder(src/client/cluster-order.js): with no remote the order is unchanged; with four or fewer seats the remotes are drawn first and the locals last, so a friend's look does not cover the local Bot; past four, locals stay in the visible seats (#963).
- Group
RemoteMessage(cells.js) showsremoteMessageText(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) asksisHeldLine(room, text, bot), which matches the line to aface_room_messageentry ofroom.pendingApprovalssent by that same Bot (the card'sfromequals the bubble's Bot name, ignoring case and spacing; a card with nofrommatches by text alone, and another Bot's identical words stay a full bubble) (whitespace-collapsed, without aTo 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) readsroom.heldLines(by the line'sseq, whichGroupRepliespasses from the round reply; else by Bot and exact text) —sending/pendingdraws nothing (the card is the line's only place, greyed),rejecteddraws 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; asendingrecord 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.
Bubbles(src/client/cells.js) drops an empty or whitespace-only text before it maps rows, so a turn that ended in tool calls only, or whose reply a card elsewhere carries, draws nothing under its trace row — no bubble and no avatar.GroupMessageandPersonMessage(a group chat's line) andExchangeLine(the exchange sheet, afterexchangeTextstrips the routing) draw no row for the same shape, andRemoteMessagedrops the bubble but keeps its header and its files. Covered bycore/test/empty-message-cell.test.js.
- The messages the chat loads above the current part (
EarlierPartsinsrc/client/cells.js) are drawn with the identity they were said under, so the toolbar on them works: the host stamps every bot line of a page with its part'ssessionId, itsturnand itsseq(core/src/host/parts.jsdisplayItems), and the panel passes one key per text toBubbles(msgKeys), because the lines of a page are merged into one bubble stack and a place in that stack moves as older pages arrive. A reaction is stored under the message (<part>:<turn>:<seq>) rather than under a shifting index, and the toolbar's Reply quotes into the open chat (sessionId), so the composer shows the quote. A live bubble keeps its singlemsgKeyplus its index, unchanged. Tests:core/test/earlier-message-actions.test.js.
- A friend's remote Bot is stored and delivered by its handle (
@<bot>.<home>, whatsendUserMentionsmatches), and the user reads one name for it on every surface (#967):remoteMentionLabelinshared/remote-label.js— the sidebar row's title (friendBotTitle, #498), and on a title two different handles share the home too, so two friends'Chiefstay apart. The@menu inserts that name (remoteMentionRowsinflybot-registry.js, so a row shows what it inserts), the bubble chips it (mentionableBots/userTextPartsinsrc/client/user-message.js), and the sidebar preview renders it (nameMentionsinsrc/client/text.js).mentionableBotsreadsroster.remoteBotsas well asroster.botsand the room's seats, so a friend's Bot chips before it has a chat of its own and inside a group it holds no seat in; it also keeps the handle as a second name of the same Bot, so a line written before this change still chips, and a chip whose Bot has no chat yet is a mark, not a button that would open nothing. Names come from the sanitized roster records, never from message text, so a remote-controlled name reaches the UI only through them (#71; display names, #9). The handle stays the routing key: the menu's text is a label, not a transport token. - In a face-to-face room the bubble matches a mention by every alias the room host routes on (
faceAliasesinshared/remote-label.js, the same listface-mentions.jsuses), so@SelfA's Chieftyped 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, asfaceCandidatesdoes), 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 (
remoteSeedinsrc/client/characters.js), never by its session id. When Bot Social sendsavatar/color/avatar_updated_at,flybot.remote.publishstores them on the remote-chat record (cached byavatar_updated_at).lookBotForRemoteoverlays a person payload's look (face-roommembers[]is the source for room avatars). A storedcolor/avatarstill wins over the seed; missing look stays handle-seeded (#137). The @ menu icon (menuIcons) and the user-bubble mention chip (withMentionLookinuser-message.js) draw that same record throughmarkBotForMention. A stand-in that keeps the look only onremoteis not drawn:lookOfdoes not read it, and the handle seed (a green moon for@chief.selfa1025.home) was showing beside the stored blue whale (#1088). The seed remains the fallback when neither the chat nor the face-room seat has a look. A friend's file-chip anchor useslookAvoidingLocals, the same decision asBotMark; a local Bot still owns a shared@Namechip (#963). - A shared display name does not restyle a local Bot (#963). The
@Namefile chip (mentionCssinsrc/client/mention-style.js) uses the local Bot's look when a friend has the same name; that friend's own mention anchor keeps the friend's color, and a name only a friend has keeps that friend's look.indexRosterdoes not let a remote chat replace a local Bot's id or session keys, androsterBotresolves a member id to the local Bot.@Namein a bubble (linkMentions) names the local Bot when both are listed. A group reply that is not remote resolves throughrosterBotbefore the index. The friend's own row keeps the friend's look, unless that drawn look is the same picture as the local Bot of the same display name, in which case the row steps its shape and the local Bot stays as stored (#1069). - A friend's 1:1 chat (#443):
AssistantCellgives the reply bubbles abt-leadBotMarkfrom the chat record (friendLead; none on aNot sent:note). The activity row (activity-row.js; host[data-chat-flow] [data-chat-running], nested underconversation.chat.flowon DSH 0.2.1+ — #1626) stays up whileawaitingReply(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 anddata-carryso there is no second entrance, which keeps one row from send to reply. The socialPeerThinkingline is for face rooms only. While the row is up in a friend chat and no local tool is the newest activity, its phrase is Waiting for their approval (等对方批准) instead of the thinking pool (Sorting out the detailsand the rest): that wait is the other home holding the reply, and this home cannot see the card (#1087). A local tool line still wins. A friend chat shows the row for a run that started from my submission and, between runs, whileawaitingReplyholds. 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 instyles.jsspare.bt-typingand its tail item, or the row between runs would be drawn withdisplay: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, orlist_botsfriends lines (isListedRemoteChat).flybot.remote.publishskips 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 intocore/client.js). #287 addedunmatchedMentionsthere (the@words that name nobody, used by the host for the face-room hint);mentionSpansand 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 usesremote-bot-settings(RemoteBotSettingsSheet): a bottom sheet on narrow viewports and a centered card from 520px up, with the scrim behind the dialog (z-indexstacking likebt-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 ownonKeyDownas well asuseEscape, 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 runkeepSidebarStill, 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 theflybot.bot.detailsentries once, as a titled Sharing card right after Instructions (FlyBotDetailswithwrap→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). A write the host refuses is reported on the dialog's own line under its heading, outside the page's scrolling box:SettingsDialogowns the sentencerunstores, and.bt-settings-scrollno longer holds it, so scrolling down to the control that was pressed cannot leave the refusal above the viewport with the switch apparently unmoved (#2194). A window that holds the team read-only says so in that same header:SettingsDialogprints the host's own sentence fromroster.readOnlyas arole="status"line under the heading and above the refused-write line, so the standing state comes first and the answer to one pressed control sits closest to it, and a blank or missingreadOnlydraws nothing at all (#2199). The details drawer's own Settings view is not that dialog and had no header sentence: it now reads the same window lock (lockedWindow,schedules.js) and, in a read-only window, draws the host's sentence in the form's place instead of mountingBotSettingsForm, so no write control the host would refuse is drawn and a writable window keeps the form unchanged. Tests:core/test/details-bot-settings-readonly.test.js. The composer card's:focus-withinborder 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; theshell.overlaylayer (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 sessionbt-rowand 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.utilitiesrather than claiming the singleconversation.session.headerslot, so the seats that header renders — the lineage title andconversation.session.header.actions, where@deepseek-ai/ui-subagentputs 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], theMessage {name}composer label, the visit ledger,body[data-bt-room]and the face-room@filter.installHeaderEffects(src/client/header-effects.js) is installed once fromapplyand 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, whilebeginVisit/endVisitkeyed on more than the Session would open a second visit for a chat that was only just opened.test/header-effects.test.jsdrives those transitions on jsdom;test/header-slot.test.jspins that no such effect runs inHeaderPilland that FlyBot claims no other header seat. conversation.session.header.corneris not available as that seat: it issingleandui-sidebar-rightalready registers its right-sidebar button there with nopriority(0), andSlotCore.registerthrows 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 fromapply. Only--bt-first-sepstill 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:
installHeaderEffectsstartsholdComposerLabel(src/client/composer-label.js) for the Session in the main view, keyedsessionId|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]'saria-labelanddata-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 aMutationObserver(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.jsdrives it on a jsdom composer. The same installer setsbody[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).ContextCellhidesTool added/Tool removedrows for the roster'spluginTools(set inindexRosterviasetPluginTools,text.js) as it does for connector tools (#419). The poll re-readspluginToolsfrom everystateanswer (a plugin reload makes no revision), andContextCell(injected withface) selects them from the roster so a row already drawn re-renders when they arrive.
When a human message is accepted while the Bot is already running (DSH queue or steer, including long background job waits), BusyAckBubble (src/client/busy-ack.js, mounted from HeaderPill next to the activity row) draws one short bot bubble promptly: host live busyAcks from busy-ack.js on the host, or a local pending submission with placement queued / steering (busy-ack-logic.js). The text is the host's composed line (topic + running tool, message language, never the previous one — #1629); with only a local pending submission (the host ack not here yet) the bubble holds the Bot's mark and a short blinking indicator, no fixed sentence, and the host's line replaces it when it lands (#1656). It hides once spoken assistant text streams. Related but separate: #1509 (Steer does not abort a tool wait). Covered by core/test/busy-ack-client.test.js.
A group room is acknowledged where its round is drawn (#1646). The open chat is the room, not the member's Session, so BusyAckBubble reads roomBusyAck(room) — the newest entry of room.busyAcks — and draws it as one bot bubble in the room flow's running row ([data-chat-running]) under that member's BotMark. Three rules decide what it shows:
- Only a running member's turn. The host publishes
room.busyAcksper member id throughroomBusyAcksView(sessions), which names only members whose turn is still running; a room with no such entry draws nothing. A member whose turn is running before its line is composed is published with an empty text, and the room draws the same blinking indicator an own-chat pending ack draws (#1656) — the mark and the dots, no sentence — until the host's line replaces it. On the host the ack is cleared byturn/endand byagent/statusidle, so a finished or idle member loses the row instead of leaving a stale line. - Only a person's line. A room line counts when
isHumanRoomLine(source)holds:role: 'room'with nosenderSessionId(the Bot's own call), noaskedBy(another Bot's @-mention) and noremote(a friend's Bot). A Bot talking in the room is acknowledged by nothing. - The person's words, not the relay header. The host strips the
[Group chat "…" · new message from the user]headerdelivery.jsputs on a room line before composing, so the ack quotes what the person typed.
The member's own chat keeps its busyAcks[sessionId] bubble and its visibility rules unchanged; that path and its line are untouched. The fixed fallback sentence #1584 used is gone (#1656), in the room as in the own chat: a pending ack is the blinking indicator, and the host's composed line is the text. The host side (isHumanRoomLine, the relay-header strip, roomBusyAcksView, channel.js busyAcks) is in core-host.md.
- A face-to-face own message uses
data-heldon.bt-umsgwhile that room'spendingCount > 0: dashed, mixed user-surface,--bt-ink(not the finished sent user bubble). Incoming lines are unchanged. Face-to-faceGroupOverviewhides the Usage row.
- 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 asFlyBotMarkfromsrc/client/brand.js. Update popups say FlyBot core {version}. The npm package remainsds-bot; plugin id, localStorage keys, andconsole.warn('[ds-bot]')stay upstream. - The tab title follows the open chat (
sessionTabLabelinbrand.js: a friend's Bot readsName (owner), #384, a room uses its display name; a live face-to-face room leads with its number (739184 · SelfA1025, SelfB1025, the samefaceRowTitleas the sidebar, #1102) so a clipped tab does not hide the second name; an ended face-to-face room, closed or left, readsFace-to-face · closedunless the user named it, #250).installTabTitlere-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 byinstallHeaderEffects) that the friend approves unless Auto-send is on (#173). The agent entry on + reads Add Bot. - In
apply, anyctx.effectthat readsrostermust come afterconst 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).
-
TeamSidebarlists local Bots, shown remote chats (isShownRemoteChat, #956), live face-to-face rooms, and shared rooms in onebt-list. Own chats sort before foreign ones (a remote Bot, a face room, orsharedFrom); pinned and recency still win. A friend's Bot keeps the globeRemoteMark; a live face room gets a small peopleFaceMark(bt-row-remote bt-row-face). A friend's Bot row does not wrap its title (#384): it shows its one name,friendBotTitlefromshared/remote-label.js("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), inbt-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 — no longer shared once that Bot has left the published list, when the row stays with its history and its way back (#956) — 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. A published preset shape stays, unless it is the same drawn look as a local Bot with the same display name — two default Chiefs both store the blue whale — in which case the friend's row steps and the local Bot keeps its stored look (#1069). A published image or emoji stays.indexRosterregisters the own Bots (setLocalBots) andBotMarkapplies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer, transcript, @ menu and mention chip agree. A face-room row leads with its number (faceRowTitleintext.js:Face-to-face · A, B · 482915reads482915 · A, B) and wraps onto at most three lines so a second name is not cut toSelfB…(#1102); the hover text and label keep the full title (#304, #458). Other rows stay one line. The session header breadcrumb (the in-app session title) wraps that same long title onto two lines inside its box, and the header pill shows the number-first form with the full title on hover. A group row whose group holds an open question card (#550) says it waits for me:Waiting for your answerin 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 (awayGroupsintext.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/treeon, 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 setinclude(botId) === falseto omit a Bot from the default list (tree-archived and leftover归档-*/Archive-*names, #452); those Bots stay findable in Search when not alsobot.hidden. -
The host's read-only sentence (
roster.readOnly) is drawn under the top buttons in both sidebar shapes (#2188): the wide sidebar keeps itsbt-hint bt-read-onlyblock unchanged, and the rail carries the same node clipped to one centred line (.bt-rail .bt-read-only, after the wide rule because the rail element also carriesbt-side) with the whole sentence intitleandaria-label. A writable window, and a host sentence that is empty or only whitespace, draw no hint in either shape — the decision trimsroster.readOnlythe way the workflow page does (#2176), while the text drawn is still the host's own sentence. Tests:core/test/sidebar-readonly.test.js.
- 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),pendingCounton chats (held Bot messages),work.bots/work.rooms(running turns), the task records ofsrc/host/tasks.js(actions.tasks→ the existingtasksendpoint, polled every 30 s) and the Session list'supdatedAtand 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,ownerandexecutingBot(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'): fromwork.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. A hidden Bot's failure in its own chat is left out (#1566): it has no sidebar row, so Open could not show the chat the card names; one in a group still lists, because that group's row opens there. 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. An ask_user card in the Bot's own chat (roster.questions[<session id>], #1140) is the same kind of row and names that Bot; Open goes to the session that holds the card (the Bot's chat, not the group the task was handed out from). Both rows show the card's options and a short answer. One option sends at once; a multi-select toggles and Submit sends the set, with the short answer. Either call isactions.answerQuestion, the same answer the chat card sends, and the row leaves. What was typed or toggled stays when the Inbox re-renders. A task that is waiting on input shows the same short answer and callsactions.answerTaskInput(task-answer-input), so the owner is not sent to a group that has no card as the only way to answer. A card 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, and Open (closes the inbox, opens the chat). In progress and Done keep the title on one line, with the full text on hover. A Needs you card (
data-statusapproval,held,input,resultorfailed) shows its title and reason in full — they wrap, including line breaks, and the phone layout's two-line clamp does not apply — so the ask can be read in the list (#1177). The dialog body scrolls. Hover still carries the full title. An approval the inbox lists and the group chat's card can answer (answerable) also has Reject / Allow once, which callactions.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 and an approval DSH's card answers in the Bot's own chat have Open only: Bot-sent messages are approved in their chat. Ask_user rows and a task waiting on input are answered in the inbox, as the previous bullet says. Every action is a button, so the dialog is keyboard reachable; the close button takes focus and Escape closes. - Done is the acceptance surface (#401). A
result-readytask is a Needs you card with the result summary as its reason, its evidence as up to five links (task.result.links, rendered under the title) and the producing Bot plus its model in the meta line. The card answers the result in place: Accept callsactions.acceptTaskResult(task-accept-result) and the card leaves; Send back opens a one-line box (What should change) and Send it back callsactions.sendBackTask(task-send-back) with that note, so the same task returns to the Bot in In progress and shows Sent back: … (task.followUp.note). A note is required — an empty box sends nothing — and the box survives a roster or clock poll, like a short answer (#1140). Typing an approval in the chat is not an acceptance: this button is. - Where the decision lands (#1154). A task that names the issue or PR it came from shows one link under the title, Comment goes to <owner/repo/issues/N> (
card.task.target.href,bt-inbox-target, new tab). A comment that could not be posted adds one alert line on the card —Could not comment on <target>: <reason>(bt-inbox-writeback, rolealert), drawn only forwriteBack.state === 'failed'so a pending or posted comment adds nothing; the decision itself is already recorded and stays on the card. A comment nobody posted within a day iswriteBack.state === 'unconfirmed'(#1172): the same class carries one quiet line,Could not confirm the comment on <target> went out, with norole, because it is not an error — the drive has stopped asking for it. A task without a target draws none of these lines. - 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). The task board (#1058) is a separate dialog and does not replace this list.
- A Tasks row sits under Inbox. It opens a dialog: a left rail of the viewer's own Bots (a child indented under the Bot that created it) and, below that, groups the viewer administers (human role admin, or they own the Bot in
room.admin; face-to-face rooms stay out). - The board has three columns, Not started, In progress and Done, filled from
actions.tasks(src/host/tasks.js, core-host). A card shows a short id, a priority chip, the title, an optional note, the Bot's name and a progress percent. Start and Complete, or a drop on a column, callactions.taskUpdate. A card the viewer does not own (another member's Bot in a group view) has neither the buttons nor a drag. The columns arecolumnOf's reading insrc/shared/task-board.js(result-readystays In progress), and the committed bundle carries that module withoccupiesSlot, the host's one-slot rule — the client draws the column, the host decides whether the member may claim (#1744). - Choosing a group loads
tasks { roomId }and lanes the cards by Bot. An overall group task (kind: 'group') is marked Group task, and a subtask in the same column is indented under it (columnTree). A subtask whose overall task is in another column carries Part of {title}. The group view says assigning, claiming, and progress live on this board, and that @ only wakes someone. Choosing a Bot filters that Bot's own list, including a child. New task adds one on the selected Bot. The host decides who may read or move a task; the dialog only hides the actions. A card waiting on input shows that question.
- 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). In progress and Done wrap the title and reason to two lines instead of one truncated line. A Needs you card shows that text in full at this width too (#1177). 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.
- The document (
html,body,#root), the conversation column and the app frame (the grid whose direct child is the center column) useoverflow-x:clip.overflow:hiddenstill honorsscrollLeftandscrollIntoView; clip does not, and setting only the inline axis to clip does not turn the block axis into a scrollport. Other_frameclasses (an image frame, a zoom frame) are not the app frame and are left alone. watchShellLevel(panes.js, installed with the pane rules) zerosscrollLefton the page and that frame on focus, and on the composer's ancestors when[data-composer-input]or[data-composer-card]is focused or typed in, twice across animation frames — the same leftover-offset class askeepSidebarStill(#274). A transcript scroller that is not an ancestor of the composer keeps its own horizontal offset. A documentscrollwhose target is the page is put back; an inner scroller's event is not.- The error toast (
.bt-toastinfeedback.js) is centered withleft/rightand auto margins (width:fit-content), notleft:50%plustranslateX(-50%), so its box stays inside the viewport before the clip. Its enter and leave motion moves on Y only. DSH's own toast is not restyled; the clip covers it. The Enter-chat "Internal error" itself is #1085; this keeps the shell from staying clipped after that dialog is dismissed.
- 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).
approval-notices.jsnoticeApprovalsraises 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>), fromwork.ended(turns.jsendedView: 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 unlessfalse) 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 unlessfalse). - One per chat (#469): every notification of a chat or group shares the
tagds-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 bycore/test/approval-notices.test.js.
- The sidebar Bot-list tree (
sidebar.jsTreeNode, from slotflybot.sidebar.structure; optionalinclude(botId)filters the default list, #452) draws collapse/expand as.bt-tree-toggle: a 28×28 hit target with a 16px.bt-tree-chevglyph, hover background, and a:focus-visibleaccent ring. The glyph rotates whenaria-expandedis false; the button stays upright so hover and focus stay square. Expand/Collapse{name}labels andaria-expandedare unchanged. Leaf rows use the same 28px width as a spacer (.bt-tree-leaf) so names stay aligned; indent is stilldepth * 16pxand 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 (botRowTitleintext.js:Coder · DeepSeek V4 Pro, the model's served name frommodelOf, also intext.jsso the sidebar does not import the model menu, #366); a friend's Bot row keeps itsremoteBotTitle, since it runs on its owner's model.
- The + menu is a popover anchored next to the sidebar + (#195, #173): its
.bt-panecarriesdata-menuand 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. - Settings → Workflows flowchart wire plane (#1459):
GraphCanvasinworkflows.jsplaces nodes on a zero-width center line (.bt-wf-plane). Connector paths are drawn in an SVG shifted byWIRE_SPAN(480): the plane sets--bt-wf-wire-spanon its inline style and.bt-wf-wiresinstyles.jsusesleft:calc(-1 * var(--bt-wf-wire-span))andwidth:calc(2 * var(--bt-wf-wire-span)), so coordinatesnode.x + WIRE_SPANline up with each card's center instead of fanning to a corner. The@flybot/workflowstage drawer uses the same anchoring (--wf-wire-spanon.wf-plane, matching rules on.wf-wires). Since #2122 a wire is routed rather than drawn as one symmetric cubic:wireRoutes(nodes, edges, heights)gives every forward edge a port-to-port dogleg whose vertical run sits on a lane no other wire holds (a lane at leastWIRE_LANEfrom every overlapping one, never inside a card andWIRE_GAPclear of one, so two cards on one column send the wire round the card between them), a forward edge whose two cards share a row — a loop across one layer, or a skip across a row — has no corridor to run in, so its two horizontal runs sitWIRE_GAPoutside that row instead (below its lowest measured bottom, above the target's top) and the vertical run between them has to clear both end cards' bodies as well as the cards beside them (#2151), an edge that leaves a row for one below measures that corridor from the row's lowest measured bottom too, so a taller card beside the source does not swallow the run (#2160), and the turn is clamped toto.top − 1, so a card beside the source that reaches past the next row's top — at the factory 108px pitch a 40-character CJK name measures 111px against the 108px pitch — leaves the run above that top instead of inside the row below, and a 91px latin card still turns inside the corridor that is left (#2164), a back edge (its target above its source) leaves and lands on the right edges and loops outside every card atrightMost + WIRE_GAP, and each of its two turns runs in the gap just outside its own card's row — stepped further out when a card's measured height leaves less thanWIRE_GAP— so a back edge that shares a row with a parallel card clears that card instead of passing behind it, and two edges that must share a port cross at an angle instead of lying on one stroke. The SVG group carries theWIRE_SPANshift, so a route'sdstays in plane coordinates. Card heights are measured the way the width is (ResizeObserveron the cards, seeded through thecardHeightsprop), andcardBoxes/parallelFramesread them: the parallel frame is the union of its members' measured boxes plusFRAME_PADon all four sides, so it encloses a member the editor stored lower and a taller run card, with the authored 72px only the floor before a card is measured. A Settings graph whose authored 960px plane is wider than the canvas is fitted instead of clipped (#1752):fitScale(nodes, width)returns onescale()from the widest content reach (|x| + 108, so the dashed frame counts), capped at 1 and floored at 0.5 when the canvas is too narrow for half size;GraphCanvasmeasures the canvas (getBoundingClientRectplus aResizeObserver) and wraps the plane in.bt-wf-fit, so cards, frames and the wire SVG share the transform. pointer deltas and drop offsets are divided by the scale, so dragging still commits authored plane coordinates. Below the 0.5 floor the plane is wider than the canvas by design, and a scroller cannot go left ofscrollLeft0, so #1769 reserves the leftover on the left instead of letting it spill out of both edges:fitLayout(nodes, width)returns{ reach, scale, pad, left, right, scrollWidth }from the same reach,GraphCanvasputspadon the stage'spadding-left, everything then sits atx >= 0and only the right side scrolls. A graph that fits returnspad0 and keeps the #1752 scale unchanged; the width can be seeded through thecanvasWidthprop before the first measure, and the measurement still wins. Tests:plugins/workflow/test/client.test.js(the plugin's own wire plane); Since P-395 each route is also a hit target of its own: a 10px transparent path (inlinepointer-events: stroke, so it reaches the pointer through thepointer-events: nonelayer) carriesdata-key,role="img"and anaria-labelplus<title>naming both cards (A → B), so two wires that end at the same port can still be told apart and read. The plane is no longeraria-hidden; it is a named group (Connections). While the pointer is on one wire that route carriesdata-active(styles.jsdraws its stroke in--bt-accentat 2.4px) and every other route carriesdata-dim(opacity .16), cleared when the pointer leaves. That is pointer state only: in a read-only graph, and in Edit, hovering or clicking a wire commits nothing. Settings:core/test/workflows-page.test.js(including the #2122 routed fork/join/back-edge case). - Settings → Workflows (
src/client/workflows.js, #1364) sits right after Scheduled tasks, beside the other automation page, so the page list's existing order and the plugin-slot rule (aflybot.settings.pageentry is listed after Connectors) are unchanged. It is not a second row in the account menu: the menu keeps its one name per destination. The page reads@flybot/workflowthroughcall('workflow/state'): Factory presets are the template catalog (a drawable graph, so a pipeline can be opened before it is copied) and Yours are the workflows saved on this machine whose ids are not those templates. Each user-owned workflow row shows rename (✎) and delete (×) action buttons; factory presets show no actions and refuse both operations (#1434). Rename opens a modal with a name field and callsworkflow/rename; delete confirms ("Are you sure you want to delete '{name}'? This cannot be undone.") and callsworkflow/delete, which removes the workflow from state, releases ephemeral Bots if a run exists, and cleans logs/runs/routes. Opening one draws the flowchart read-only: a saved workflow gets Edit (WorkflowsViewinsrc/client/workflows.js), and only then can a node be moved, an edge drawn, a local Bot dropped, a node removed, and each node's system prompt typed in a field beside the canvas — that prompt is what the node's run Bot receives — and the picture is saved withworkflow/graph; Finish editing leaves that mode (#1393, #1378). A factory preset stays read-only until Use this copies it withworkflow/create, and that copy opens in Edit. While any workflow of the page hasactiveCount > 0the page readsworkflow/stateagain every 2 s — the room drawer's own predicate and beat — so a chip that goes running → done moves without reopening the page and a finished run stops the beat; an Edit in progress suspends it and unmount clears it (#1810). A read that rejects or never returns settles as an error with Retry; an empty store says there are no workflows. When that read isbot/not-found, or the host saysUnknown FlyBot endpoint workflow/…, the page says the Workflow plugin is not installed or not enabled (a default install includes@flybot/workflow) in the interface language, and Retry stays (#1381). Any other failure still shows its own reason. The page does not teachworkflows/<id>.json. The definition engine that still stores those files stays on the host and is not this page. In a window that holds the team read-only,roster.readOnlycarries the host's own sentence: the page draws that sentence where Edit was, shows no rename/delete on Yours, and never opens the editor, soworkflow/graphis not called at all — the channel would refuse it after the layout was built (#2176). Tests:core/test/workflows-page.test.js. - The sidebar footer button (
ConnectAppsinsidebar.js, label Connect apps / 连接应用) opens Settings > Connectors throughactions.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; thenroster.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'sturn-errorchat 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 adeepseek*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 andDEEPSEEK_API_KEYways. 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.launcherseat'sopenSettings()takes no section, soopenHarnessModels()(sidebar.js) calls the seat'sopenOnboarding('flybot-models'). Thatsettings.onboardingstep (ModelsSettingsStep, order 1000, after DSH's own steps) is handedopenSection: opened on purpose (explicit) it callsopenSection('models'), thencomplete(); in first-run onboarding it only completes. With no seat it falls back toopenHarnessSettings(). 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 (
SwitchRowshowLabel: falsekeeps 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 cuttingSwitch to classic Agent mode. - Slot
flybot.settings.page(0003-flybot-api-client, types inflybot.d.ts, #337) adds a page to the Settings list right after Connectors:{ id, order, label, icon?, component }.SettingsDialog(settings.js) listsuseSettingsPages()entries under the keyplugin:<id>and draws the component throughSettingsPageSlot(a failing component draws nothing, it does not close the dialog); a page whose plugin is gone opens General.labelis an English key for the hostt(); 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 fromi18n.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 inflybot.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 lookslabel/hintup witht()like the neighbouring rows. A string returned fromrunis 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'sicon, drawn throughSlotIconbehind 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); aflybot.sidebar.structureentry'smenuItemsrows follow in their own group, and the menu hands each one the window's read-only flag (readOnly: trueonly whenroster.readOnlytrims to a non-empty string), so an entry hides a write the host would refuse instead of painting its failure (#2238); a read-only window (roster.readOnlya non-empty string, read the same way) also draws none of the menu's own core writes — no Pin/Unpin, Duplicate, Make Main Bot, Remove Main Bot role, Hide from sidebar, Rename group, Delete or Delete group, because the host refuses every one of them withbot/read-only— while Edit Bot, Group settings, Mark as Unread and Copy conversation ID stay; the DSH Agent row's menu and a Session's menu read the same flag, so a read-only window draws no Pin/Unpin, Remove DSH Agent, Rename or Archive there either, because the host refusesset-flags,remove-agent,agent-renameandagent-archivefor such a window, and with every row hidden the menu draws nothing at all rather than an empty box. Tests:core/test/context-menu-readonly.test.js,core/test/context-menu-agent-readonly.test.js; 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).remoteMessageTextunwraps stored fences;failureLabelmaps missing-key / unknown model failures to a short friendly line. Details stay in Settings → Feedback. - A closed face room's status line already says who left (#1103).
GroupReplies(leaveNoticeCoveredByCloseincells.js) does not also draw a short leave notice for that person (SelfB1025 left the room, orCoder (SelfB1025) left). A leave while the room is still open, a leave the status line does not name, and every other notice stay. The social plugin uses the same rule when it decides whether to post the line. Test:core/test/tool-cells.test.js. GroupRepliesdraws auserreply withother: true(another member's face-room line, #180) asPersonMessage(cells.js): the person's name, the person mark (PersonMarkinmark.js) and a received--bt-bubblebubble, not a centredbt-eventnotice and not the sent--bt-userstyle; own lines keep the right-hand bubble (#229).othercomes only from the host's person-line post (hooks.jspostHuman), 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 ofperson:<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)iswhite-space: pre-line, as the sender's ownpre-wrapbubble, since Markdown would join single newlines. A trigger whose source hasshown: true(a line a running round already drew, host #455) draws nothing. Covered bycore/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 bygroup-settings.js) builds it from participant fields and the roster, never message text:faceSeatslists the live Bot seats ({ left: true }adds the ones that left, markedleft),faceSeatLabelnames each Bot<Bot> (<person>), mine included (Chief (SelfB),Chief (SelfA); a Bot is never(you)),faceBotLabelgives a local Bot that name in a face room, andfacePeoplegroups the seats by person (me once, first; a friend by home) withfacePersonLabelputting(you)on me only. The overview and group settings Members · N count people and draw one row per person (FacePersonRow: each Bot that person brought, drawn with the look the chat and the sidebar use — the local roster record, or the friend's seat/chat look, stepped off a same-named local Bot — then the person's name and the Bot names; my Bots open their chat). A row with no Bot still draws the person silhouette, not a blank disc (#1089). Owner names that share an initial (SelfA1025,SelfB1025) are not the mark (#1104). - In a face room a bubble names its speaker the same way (#457): my Bots' lines
Chief (SelfB)(cells.jsGroupMessageviafaceBotLabel), a friend's BotResearcher (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:faceMentionablesalso 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 labelhomeName),Helper (Bob), and a friend's Chief the same,Chief (Bob)(no<owner>'s Chiefspecial 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 roomfaceSeatLabel/at-menu.jsfaceLabelgive the same format (Chief (Bob), my own BotChief (SelfB)with my name, #457). The social plugin's classic script reads the same functions fromglobalThis.__flybot__.names(friendBotTitle,friendBotOwner, set inflybot-slots.jsregistry()). Approval cards name the receiving person instead (Chief → SelfB1233, #418).faceAliaseskeeps 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)).remoteBotNamedrops a trailing· <owner>from a Bot's name when it is that Bot's owner (the server'slabelreaching a name slot, e.g. a bubble header with no seat yet);ownerNameOfpasses 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;seatOwnerNamereads my own seat's owner the same way (face-seats.js,at-menu.js). The Bot names inFacePersonRowand 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 bycore/test/group-bot-label.test.js. - A face-to-face room's subtitle (
roomSubtitle→faceRoomCountingroup-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). Any other group's subtitle, command-palette row and Groups-list tag useseatedCount: humans, this home's Bots, and remote Bots still in the room — the same people the Members card lists — so a shared room does not read as only the Bots this home brought. A remote that left is not counted. The "Add a Bot first" note uses that same total. - The composer
@in a face-to-face room (mentionRows/atSourcesinat-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 socialFriends' Botssection) 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@Namethe room routes on (@SelfA's Chief), never a handle (#305). - An ordinary group's
@still shows the seat asremoteBotTitle(Backend (Friend), handle on the second line) but insertsremoteCallName(#1141): the Bot's own name (@Backend,@architect), whichcallsInalso aliases. It does not insert a second@, a.homehandle, or the placeholder(Friend). A handle used as that name goes throughcleanPeerName, so a newline or a fence fragment in the handle is not the insert token. - 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-inreferencesource cannot be dropped for one Session, soinstallHeaderEffects(header-effects.js) startsactions.filterFaceAtMenufor a face room, which watches that Session's trigger controller (inputTriggers.sessionOf, the same one the composer uses) withwatchFaceMenu(face-at-filter.js): a<style data-bt-face-at>hides thereferencerows whose icon issession(Sessions and Subagents) and their section title by MenuView's option ids,controller.hovermoves 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 isdismiss()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.
- 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 andafterCreate(bot-draft.js) opens the new Bot, then sends that line to it as its first turn, so the Bot answers it andcreatePayloadcarries 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:TriggerCellskipsrole === 'kickoff'(a setup prompt, not a line the parent sent) exactly ashistoryEntrydoes (#698); host side in core-host.
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). The command the owner approves is read in full: .bt-mw-code wraps (white-space:pre-wrap, overflow-wrap:anywhere) and scrolls inside a 120px max-height instead of one nowrap line clipped with text-overflow:ellipsis, so a long command is not drawn shorter than the text the card actually stores (#1992). 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 names the groups it works in, four rows at most and then a counted line (1 more room / {count} more rooms, .bt-mw-static) when it is at work in more, so the fifth room is never dropped silently; that line is not a button, because the Bot's details panel lists no rooms to open (#2043). The sidebar's Bot row still names the groups it works in.
- Bot settings → Model has a Test connection row under the model picker (
ModelTestRowinmodel-test.js, shown once the Bot has a chosen model). Its hint says the request may be billed. A click poststest-model({ id }) once; the button is disabled while it runs (Testing…), then the row showsWorks. {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'sask_user_questionoff Bot sessions (hideBlockingQuestion). DSH then appends a developertool-removalcontext row, and@deepseek-ai/dsh-client-ui-chatrenders it as "Tool removed: {name}".ContextCellshadowsconversation.chat.nodecontextand returns null for those notices (HIDDEN_REMOVED_TOOLS). A tool a person turns on, and an unrelated removal, still show;tool-callcells are unchanged. Empty shadowed nodes collapse via the existing:emptyflow-item rule. Covered bycore/test/tool-cells.test.js. -
The product's own tools are the same kind of row (#1101). DSH appends a developer message when a session's request header gains or loses a tool.
ContextCelldoes not printTool added/Tool removedfor the team-tool names (TEAM_TOOLSinsrc/host/constants.js; the client keeps the same list, so a new team tool is added there in the same pull request —archive_bot(#638) is the newest) or fortrack_links, the notice the other home of that report showed on a plain math turn. A call the Bot actually makes stays a tool cell.web_searchandbashstill show. Covered bycore/test/hidden-tool-removal.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'sheldReplies(text.jsheldReplyOf, by session and turn) and wraps the bubbles inHeldReply(bt-held-reply,data-statepending / approved / rejected / failed; pending and not-sent bubbles are dashed and muted). The turn's closing text carriesHeldReplyActions(held-reply.js): while it waits, the holding policy's inlineflybot.approvalsentry (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 quietSent to {to}/Not sentline (Retry for a failed send thatcanRetry()allows; a terminal failure draws no button, #968); a reply that went through Auto-send (auto, #417) is a plain bubble withAuto-sent to {to}. The recipient is the person who gets the reply (#418, #498): the host'stoOwner(from the hold'srecipientName, stored, so the same live and after a reload), else the owner of the friend's chat (friendBotOwner), so the card readsChief → SelfB1233. The sidebar preview (previewOfunsentTurns) shows the friend's line for a turn whose reply did not go. The overlay ignores inline entries. In a read-only window the same lock (lockedWindow) replaces that bubble's Approve / Reject, and a failed send's Retry, with the host's own sentence on one line, while the bubble itself is drawn as before (#2310,core/test/approvals-readonly.test.js). -
The held-send overlay (
FlyBotApprovals,flybot-slots.js) runs every hook before itsif (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.jsrenders it with a stand-in that enforces React's hook-count rule.
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.
The Feishu row (#1263) is a sender (kind: 'sender' from the host, not an id the client knows): the input is labelled Feishu webhook URL and its link goes to Feishu's custom-bot page, never to GitHub's token page. Because a sender has no tools there is no Choose Bots button and no On for N Bots line, and the Bot-settings Connectors card skips it (connectedConnectors drops kind === 'sender'), so no switch is offered that would change nothing. Once it is ready the row carries a text field with Send, which is the only caller of connector-send; a refused webhook shows Could not connect: … with Try again beside it.
The same row is the whole act of connecting Feishu (#1266): it draws one row, not two, and the form has a mode — Custom bot webhook (one masked URL, outbound only, exactly phase 1) or App (long connection) (App ID + App Secret, one field per credential the definition declares, the App ID not masked because it is not a secret). Connect submits { mode, credentials } through connectorConnect, which passes an object body straight to connector-connect and keeps the older single token body for every other row; the status line names the mode that is live, and Disconnect clears both. With kind: 'channel' the row offers the same per-Bot Choose Bots switch GitHub and Composio have (connectedConnectors includes a channel, so connector-bots.js lists it; off by default, never for a Bot shared with someone else) — a webhook-only row still shows no switch. App mode also renders 允许的飞书用户: the refused senders the host reports, each with Allow / Block (connector-allow / connector-block), plus a paste-an-open_id field for the case where nobody has messaged yet; nobody is allowed until the owner adds them, so connecting the app never lets a stranger spend the owner's tokens.
A row in the error state offers Copy error beside Try again (#2027): it hands the connector's stored error — up to the 2,000-character host window from core-host — to the shared copiedToClipboard(), reads Copied only when the write landed and Could not copy the error when the page has no clipboard or the write was refused, and is drawn only while the connector is in that state, so a ready/off row's actions are unchanged. Tests: core/test/connectors-page.test.js.
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.
src/client/feedback.js reads the window lock once per render (lockedWindow, schedules.js, the same function the other locked screens call) and draws no Clear while the window only reads: Clear calls diagnosticsClear, which truncates the shared <home>/logs/diagnostics.jsonl on the host — the evidence a bug report is supposed to carry — and a reading host refuses it with bot/read-only. Download, Show, Copy and Report on GitHub stay; a writable window keeps Clear exactly as before, including its disabled state. Settings mounts the page with the roster (settings.js, shown === 'feedback'), the way the neighbouring pages already do. Tests: core/test/feedback-readonly-clear.test.js.
src/client/feedback.js used to say Cleared the error log. whatever the host answered, because
diagnostics-clear answered a constant. The host's clear() now resolves
{ leaf: 'truncated' | 'kept' | 'absent', rotated: 'removed' | 'kept' | 'none' }, the endpoint passes
that result through, and clearNote picks the sentence from it: the unchanged one when the leaf was
truncated and no rotated copy is left, one that names logs/diagnostics.jsonl.1 when the copy could
not be removed, and one that does not claim a cleared log when the leaf was left alone — the guard
refused a link or a shared name, or the host could not truncate a plain file (a write-protected file,
a read-only mount). The sentence names no reason, because the answer carries none. A leaf this host
has no file for (absent) keeps the unchanged sentence, because nothing was on disk to clear. An answer that carries no result (an older host) keeps the old sentence,
so the page still works against one. Both new sentences are keys in i18n.js; core/test/i18n.test.js fails
when one is missing. Tests: core/test/feedback-clear-sentence.test.js.
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).
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:
AssistantCelllooks the turn up inroster.remoteTypedTurns(typedByinsrc/client/text.js) and rendersPersonMessage— the person's name and a person mark (.bt-person-mark) — instead of the Bot'sBubbles. The name comes from the roster (the authenticated Bot Socialfrom), 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. WithouttypedByit keeps the Bot rendering (#233 follow-up). In a face-to-face roomremoteSpeaker(source, roster, sessionId)draws the Bot with its room seat's look (the participant'scolor/avatarfrom Bot Socialmembers[]), 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). Thevianame is the carrying Bot's plain name when this home knows that Bot (its chat orroster.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 Botnever 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.jsfriendLineNewscompares each Bot's host stampfriendLineAtwith the last stamp this browser has shown (localStorageds-bot:friend-lines; stamps against stamps, never this browser's clock) and, when a newer one arrives while that chat is not visited,index.jsmarks 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 hasremote.firstContactopens with one mutedFirstContactline (cells.js):<person or friend's Bot> is talking to <Bot> through Bot Social. Covered bycore/test/friend-lines.test.jsandcore/test/inbound-ui.test.js. - The roster poll (
index.jspull) sendswatching: !document.hiddenwith eachstatecall (#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.jsTurnTailCell/TurnProcessCell/ the unread opening) compares turn times with the visit'sseen(when the chat was last left) andentered(when this visit opened). Once the divider has been on screen (NewMessagePillsinheader.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.settleVisitsetsseentoentered(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 bycore/test/turn-tail.test.js.
src/client/project-board.js draws one Project over the read model the host already aggregates
(project-board). projectBoardView({ project, rows, canManage, tasks }) is pure: it groups the rows
by source (bot | group | workflow, empty sources left out), carries column, assignee and the
runId/nodeId a workflow row came from, and keeps row.id equal to the underlying task id, so a row
never copies a row. The tasks list is the ordinary task read joined by id (a Map, one pass): a row
takes its title, falls back to note, and only then to @assignee / —, plus its priority, so three
cards held by the same person no longer read the same. A row that carries the host's audit text (#1730) shows it inside the card detail as bt-project-audit, read from that same row with no second fetch; a row without it renders exactly as before. An empty Project renders the empty state, not an
error. canManage comes from project-read and only decides whether the management affordances are
drawn; the refusal itself is the host's. The record section says what it hid (#2021): projectBoardRecord
carries total on the decisions and decidedTotal/pendingTotal on the meetings, and
ProjectBoardRecord draws one bt-project-more line reading N more not shown (还有 N 条未显示)
under each capped list when that total is above the rows drawn — nothing when it is absent or equal,
so a host that sends no total draws the section exactly as before; data-decisions keeps meaning
"rows drawn" beside data-decisions-total. Since #2041 a decision row is a button (data-path with
the data-sha256 the host computed) and activating it — by click or keyboard, no tabIndex needed —
opens that one file under the row: ProjectBoardRecord keeps one open path, asks the host's
project-decision read for that name, and draws the answer as text under a data-open-decision block
(a pre for the body, the host's own sentence for absent / too large / not listed, never markup
the answer carries). Activating the same row again closes it, another row replaces it, and closing
reads nothing (#2041).
The Project settings panel (#1749) saves through the three host write paths it draws — project-bind,
project-assistant-owner and project-memory-write — and through no fourth. Its Pause the daily digest
box reads the caller's own assistantMine.off from project-read (#1845), never project.assistant.off
(a field the host does not write): the opt-out lives per owner, so another owner's switch is never drawn,
and a Save that changes nothing leaves the stored value and the 09:00 task alone. Its memory box sends one of
the two kinds the host knows: the free text is recorded as a decision, never as a third kind
project-memory-write would refuse, so the success line (Memory saved.) is reachable. The status line
quotes a refusal through hostText, so a zh reader reads the host's own sentence in Chinese: since
#1847 the host refuses an empty entry and one over 600 characters at that write door, and i18n.js
carries both sentences — the exact key for A Project memory entry needs text and the number-carrying
[/^A Project memory entry is at most (\d+) characters$/, …] — so a zh box the host refuses reads
项目记忆条目需要文字 / 项目记忆条目最多 600 个字符 instead of Memory saved., with no row added. Since
#1887 the box warns before that click: a counter under the field reads N/600 in code points, the unit
the host bounds (#1886) and not what maxLength would count in UTF-16 units, it turns over-limit at 601,
and an empty or whitespace-only box disables Save — neither state calls the endpoint or writes anything,
so the two host sentences stay for the race where the host refuses an entry the panel allowed. Tests:
core/test/project-board-panels.test.js.
The Members panel lists every owner and every member the board names, and since #1859 also the
Project's own Chief as its own row (data-member-role="chief"), read from chief on the project-read
record rather than a second call; a Project without a Chief draws no such row. It offers no write — the
membership write is a later slice. Tests: core/test/project-board-panels.test.js.
The Project team block (#1860) sits above the cards and draws the three identities a Project has:
its own Chief (project.chief.botId, #1859), the assistant that speaks for the caller
(project.assistant.botId, #1712) and the role pool — roles.maintainers / roles.members with the
Owner derived from ownerUserIds exactly as the host derives it (rolesOf), plus the caller's own
role and choices. Those last two are the projects listing's answer, not the record's, so
ProjectBoardOverlay keeps them on the record it hands the pane instead of reading the listing a
second time. A Bot row is a button (data-open-bot) that closes the board and opens that chat through
actions.openSession; a Chief row with no botId — or with the { error } the host records when the
spawn failed — stays drawn and reads Not set, so this slice never waits on #1859. The rank words
live in src/client/role-labels.js, which the sidebar and this block both print. Tests:
core/test/project-board-panels.test.js.
The dossier (#1864) is the read-only half beside those rows. Each Bot row carries a Dossier
control (data-dossier-bot), and ProjectDossierPanel draws three sections
from the one project-team answer the overlay reads with the board: Persona (the Bot's
instructions, role and model), Memory (the MEMORY.md head and the topic names) and Skills
(the attached skill capability names). The panel never re-fetches per section, and a null row —
the host's answer for a deleted Bot — shows Not set with nothing invented. readProjectBoard
keeps the dossier on the same read as the rows, so the board and the panel cannot disagree, and the
"my agent" row uses the same panel. Since #1900 that one answer is the open's, not the poll's: the
five-second refresh hands it back instead of asking project-team again, because the host builds it by
reading persona and memory files, and a refused dossier leaves the rows, the done group and the tasks
on screen with an empty team block instead of the empty-Project answer. Since #1910 that block is drawn
but not remembered: a refused read returns teamFresh false, the overlay keeps no answer for the poll
to hand back, and the next tick asks project-team again until an answer lands — a real empty team is
fresh and frozen like any other answer, so "no answer yet" never becomes "the team is empty". Since
#1913 the panel is also the one place that refreshes it: opening the Dossier control pays for one
project-team read (the host caps the answer at ~16 KB) and replaces the answer the poll hands back,
so a memory head the review pass rewrote after the board opened is one click away. A refused refresh
draws nothing new and leaves the rows the panel already had, and the five-second poll still never reads
the dossier, so #1900's saving stands. A role-pool entry draws none: since #1892 it is plain @id
beside its rank, because project-team answers rows keyed by the Project's own Bots and the old
control asked for a row the host never sent. The entry in the owner's own Bot chat is
ProjectAgentEntry in the header: the roster already carries bot.projectId, the entry reads
project-read once for assistant.botId and opens that Session through actions.openSession; the
assistant is not a special case of the board, and the Project page keeps its own row. Tests:
core/test/project-board-panels.test.js and core/test/project-team.test.js.
The pane is registered on the existing shell.overlay surface and opens at #project=<id>; the overlay
re-reads the hash on hashchange, so an entry that writes it while the overlay is mounted still opens
the board. The sidebar is the only entry (#1854): the Project board button #1701 put in
KanbanDialog's header is gone, so one board is reached one way.
Since #1828 the sidebar draws the entry: one Projects row below Tasks, with an expand arrow that
lists every Project the projects listing answered together with the caller's role there. A row
opens that board by writing #project=<id> (the same entry point the overlay reads), the rail's
version opens the first board, and an install with no Project reads No Projects yet. A
right-click on a row opens the My role menu: it lists the choices the listing answered and
marks the current rank (a Member has only the current one, and an Owner never demotes themselves),
calls project-role, and shows a refusal's reason unchanged (Role unchanged: last-owner). Both are
core endpoints and go to the host by their bare names — projects, project-role — because
src/host/channel.js dispatches them that way; the flybot/ prefix this module once sent named an
endpoint the host does not have, and the list stayed empty everywhere (#1854). The
policy stays in the host; this module (src/client/projects-nav.js) only draws the answer. The rank
words themselves moved to src/client/role-labels.js (#1860), so the sidebar, its menu and the
board's Project team block read one vocabulary. Tests: core/test/projects-nav.test.js.
A row knows the board its task lives on without re-deciding membership: the mapping follows the host's own
source (group → the task's origin.roomId, bot → its ownerBotId, a workflow row is one a run
carried in and has no board of its own). A row with a board is a button: it writes #task=<kind>:<id> and
actions.openBoard(), and KanbanDialog consumes that hash once on mount, moves its scope to that Bot or
group and clears the hash, so a reload does not reopen the same board. A row without a board (or without a
task record) is not a button — role alone opens nothing. Opening the pane is four host reads, never a
per-Bot read: projects, project-read, project-board, and the tasks list the rows are joined
against. Since #1814 those four repeat every five seconds while the pane is open, because what a card has
waited and whether its lease is live are computed by the host at read time: a lease that expires during
the visit turns the card stalled on its own instead of freezing the board. The first read failing
answers the empty state as it did; a poll that fails keeps the board the last good read drew. The
dossier is not among the four (#1900): it is read when the pane opens and again on the next open,
not on a healthy tick — since #1910 the ticks after a refused open read do ask again, and the answer
that lands is the one frozen for the rest of the open. Polling
pauses while a row's own board is open (the hash no longer names this Project) and while the tab is
hidden, and a read that lands after the pane closed is dropped. Tests:
core/test/project-board-view.test.js, core/test/project-board.test.js,
core/test/project-board-entry.test.js.
The pane draws the three host groups beside the columns (#1768) — Waiting with the role and how long
the card waited, In flight with the holder and running/stalled, Blocked with the card's own
reason and, when the host measured one, how long it has been stuck (reason · 2d, from the row's
waitedMs; a card the host could not date keeps the reason alone) — and the header counts waiting /
in flight / blocked / done. The groups and the counts are the
project-board answer, not a second read: waiting, inflight, blocked and counts ride
along with tasks in the same response. A count the host answered as null renders as unknown and
never as 0; a group with no items is not drawn, and a board with neither rows nor groups is still the
empty state. Rows keep their task id, so #task=<id> still opens the card.
The wait a card shows is one label for the whole board (#1896): waitedLabel reads seconds under a
minute, minutes under an hour, hours under a day, and a span over zero never floors to 0s — a card that
waited 40 seconds reads 40s, not 0m, the way elapsedLabel never says 0m. The three duration
metrics go through that same function, so a 35-second median merge reads 35s; a missing or negative
span still returns nothing and an unread duration stays the dash. Tests:
core/test/project-board-view.test.js.
The board's three column names go through the same dictionary as the task board: not-started,
in-progress and done are keys in i18n.js, so a zh UI reads 未开始 / 进行中 / 已完成 instead of the raw
status.
Finished cards are one group of their own (#1855): projectBoardView({ project, rows, done, … }) takes the
archive the host sends beside the rows, keeps every terminal card out of the three source groups (a terminal
row arriving in rows from an older host is folded into the archive instead of dropped), and answers it as
done: { group: 'done', label: 'Done cards', collapsed: true, rows } with the same row shape, so a finished
card still opens its board and still reads its column. The pane draws it after the queue groups as a
<details data-group="done"> with no open attribute: the browser keeps it closed until the reader opens
it, so a Project with 191 finished cards paints the work in flight first, and the header keeps Done: <count>
from the host's own count. The group's label is the Done cards key in i18n.js (已完成 in zh). Tests:
core/test/project-board-view.test.js.
A pending decision card draws a level badge (ordinary / major / severe, in the interface
language), the recipients it is addressed to (@name) and, for a severe card, the host's own pause
reason. A row names the Bot holding the claim (holder) and, when it is written for a person, says
for <user>; a decision file keeps its Written-by:, so project automation, a Bot claim and a person
an Assistant speaks for never read as one identity. All of it is read off the host answer, never
inferred from an assignee label.
core/src/client/project-board.js draws the seven cards the host's project-board read carries in metrics: four day counts and three seven-day medians, each with a one-line caption naming the field and the window. A value the host answered null renders — (u+2014) and never 0; an ok: false read draws the same cards plus one line naming the host's reason. metricsView is pure and takes the host's object as it came. A meeting row also draws the proxy line (#1861): projectBoardRecord keeps each meeting's participants with their speaksFor, and the decided and pending rows render every proxy as <name> for {user} in the same for {user} words a task row uses for its holder, with the spoken-for ids also on data-speaks-for. The running table below renders metrics.running — a claimed card's title, status, holder, claim age (from the claim's own start), source and last activity — marks a row whose claim passed RUNNING_OVERDUE_MS and sends a click through the same onOpen the board rows use; with no rows it draws one empty line. The Waiting / In flight / Blocked numbers stay on the one-line count header beside those groups. A card's title, priority and board come off the row the host sent and fall back to the tasks read joined by id — that read keeps open cards only, so a finished card in the done half has no record to join and would otherwise draw as #<id> with no priority and no board to open; boardOfRow prefers the row's { kind, id } and derives it only when the row carries none, and a workflow row keeps no board. A row stored or served before the row carried text still reads exactly as it did.