From 6c0dfff3a93bc0f790d60613ff5f2479d7635c41 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 04:39:05 +0000 Subject: [PATCH 1/2] Show both names on face-room titles an ellipsis was cutting (#1102) Lobby labels under the avatars wrap. The sidebar, header pill, and browser tab lead with the room number, and the session breadcrumb wraps onto two lines inside its existing box. Co-authored-by: Danyang Chen --- changelog.d/1102-face-title-ellipsis.fixed.md | 1 + core/client.js | 22 ++++++++++---- core/src/client/brand.js | 5 ++++ core/src/client/header.js | 12 +++++--- core/src/client/sidebar.js | 5 ++-- core/src/client/styles.js | 8 +++++ core/test/branding.test.js | 21 +++++++++++++ core/test/face-row.test.js | 20 ++++++++++++- core/test/flybot-client.test.js | 7 +++-- docs/subsystems/core-client.md | 4 +-- docs/subsystems/social.md | 2 +- patches/ds-bot/0003-flybot-api-client.patch | 25 ++++++++++++---- patches/ds-bot/0008-client-bundle.patch | 30 +++++++++++++------ patches/ds-bot/0012-room-membership.patch | 22 ++++++++++---- patches/ds-bot/files/src/client/brand.js | 5 ++++ patches/ds-bot/files/test/branding.test.js | 21 +++++++++++++ patches/ds-bot/files/test/face-row.test.js | 20 ++++++++++++- .../ds-bot/files/test/flybot-client.test.js | 7 +++-- plugins/social/client.js | 6 ++-- plugins/social/test/face-tile-names.test.js | 25 ++++++++++++++++ 20 files changed, 223 insertions(+), 45 deletions(-) create mode 100644 changelog.d/1102-face-title-ellipsis.fixed.md create mode 100644 plugins/social/test/face-tile-names.test.js diff --git a/changelog.d/1102-face-title-ellipsis.fixed.md b/changelog.d/1102-face-title-ellipsis.fixed.md new file mode 100644 index 00000000..11d1c6a2 --- /dev/null +++ b/changelog.d/1102-face-title-ellipsis.fixed.md @@ -0,0 +1 @@ +Face lobby names and face-room titles stay readable. Labels under the avatars wrap, and the sidebar, header, and session title show both names instead of cutting the second one to an ellipsis ([#1102](https://github.com/ThinkFlowLab/FlyBot/issues/1102)). diff --git a/core/client.js b/core/client.js index 09506442..4785f170 100644 --- a/core/client.js +++ b/core/client.js @@ -3571,6 +3571,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-row-title.bt-row-title-face{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3;overflow:hidden} .bt-row-remote{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-2)} .bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -3594,6 +3595,8 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)} .bt-pill:active{transform:scale(.965)} .bt-pill-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500} +.bt-pill:has(.bt-pill-name-face){height:auto;min-height:40px;align-items:flex-start} +.bt-pill-name.bt-pill-name-face{min-width:0;white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden} .bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}} .bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}} .bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}} @@ -4281,6 +4284,11 @@ var CONVERSATION_CSS = ` whole header, so the header keeps its own layout, border and drag row. */ [class*="_header"] .bt-head{display:flex;align-items:center;height:auto;flex:none;width:auto;pointer-events:auto} [class*="_header"] .bt-head .bt-pill{height:30px;padding:3px 10px 3px 4px;gap:6px;font-size:13px;line-height:18px;max-width:min(320px,32vw)} +[class*="_header"] .bt-head .bt-pill:has(.bt-pill-name-face){height:auto;min-height:30px;align-items:flex-start} +/* The session breadcrumb is a 244px nowrap line (#1102). Two lines keep + "Face-to-face · SelfA1025, SelfB1025 · 739184" inside that box. */ +[class*="_header"] [class*="switcherTitle"]{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;text-overflow:ellipsis} +[class*="_header"] [class*="switcherTrigger"]{height:auto;align-items:flex-start} [class*="_header"] .bt-head .bt-mark{width:22px!important;height:22px!important;--bt-size:22px} [class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} /* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), @@ -4606,6 +4614,7 @@ function sessionTabLabel(roster, sessionId) { return friendBotTitle(bot); } if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2?.origin === "face-to-face") return faceRowTitle(roomTitle(room2)); if (room2) return roomTitle(room2); return bot?.name || ""; } @@ -8334,7 +8343,8 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, className: "bt-row", "aria-label": label, "aria-current": active ? "page" : void 0, - // The full title on hover: a long face-room title is cut to one line in the row (#304, #173). + // The full title on hover. The row wraps the number-first form so the second + // name is not cut to "SelfB…" (#1102, #304, #458). title: hover, "data-room-id": id, onClick: () => actions.openSession(id), @@ -8348,7 +8358,7 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, wide ? (0, import_react14.createElement)( "span", { className: "bt-row-body" }, - (0, import_react14.createElement)("span", { className: "bt-row-name" }, (0, import_react14.createElement)("span", { className: "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react14.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react14.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react14.createElement)(PinMark) : null), + (0, import_react14.createElement)("span", { className: "bt-row-name" }, (0, import_react14.createElement)("span", { className: face ? "bt-row-title bt-row-title-face" : "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react14.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react14.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react14.createElement)(PinMark) : null), workingLine ? (0, import_react14.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react14.createElement)("span", { className: "bt-row-preview" }, preview) : null ) : null, wide && alert && !active ? (0, import_react14.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react14.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null @@ -9085,7 +9095,9 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); - const name = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; + const full2 = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; + const face = room2?.origin === "face-to-face"; + const name = face ? faceRowTitle(full2) : full2; const useNews = useMemoryNews ?? noNews; const memoryIds = bot ? [bot.id] : room2?.members ?? []; const notice = useMemoryNotice(memoryIds, useNews); @@ -9101,11 +9113,11 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe className: "bt-pill", "aria-expanded": detailsOpen, "data-memory": memoryNotice ? "updated" : void 0, - "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? name }) : t("View conversation details"), + "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? full2 }) : t("View conversation details"), onClick: () => memoryNotice ? actions.openMemory(changed2) : actions.toggleDetails(sessionId) }, bot ? (0, import_react15.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react15.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, - (0, import_react15.createElement)("span", { className: "bt-pill-name" }, name || title || t("New chat")), + (0, import_react15.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), (0, import_react15.createElement)(MemoryNotice, { shown: memoryNotice }), (0, import_react15.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react15.createElement)(GoArrow)) ), diff --git a/core/src/client/brand.js b/core/src/client/brand.js index aa784c74..faecf7fa 100644 --- a/core/src/client/brand.js +++ b/core/src/client/brand.js @@ -2,6 +2,7 @@ // Paths stay here so tests can import them without React. import { isAutoFaceTitle } from '../shared/face-title.js' +import { faceRowTitle } from './text.js' import { friendBotTitle, roomTitle } from '../shared/remote-label.js' export const FLYBOT_WING = 'M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z' @@ -37,6 +38,10 @@ export function sessionTabLabel(roster, sessionId) { // An ended face-to-face room (closed, or someone left) no longer lists people who are not in it (#250). // A name the user chose is kept. if (room && room.origin === 'face-to-face' && (room.face?.status === 'closed' || room.face?.status === 'left') && isAutoFaceTitle(room.name)) return 'Face-to-face · closed' + // A live face room leads with its number (#1102). The long form + // "Face-to-face · SelfA1025, SelfB1025 · 739184" is what a tab clips to + // "Face-to-face · SelfA1025, SelfB…", which hides the second name and the number. + if (room?.origin === 'face-to-face') return faceRowTitle(roomTitle(room)) if (room) return roomTitle(room) return bot?.name || '' } diff --git a/core/src/client/header.js b/core/src/client/header.js index e4dfa8d9..28d202d1 100644 --- a/core/src/client/header.js +++ b/core/src/client/header.js @@ -1,6 +1,6 @@ import { createElement as h, Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { currentPart, latestMemoryChange } from './text.js' +import { currentPart, faceRowTitle, latestMemoryChange } from './text.js' import { ArrowUpIcon, CloseIcon, ArrowDownIcon, MemoryIcon } from './icons.js' import { botState, BotAvatar, RoomAvatar } from './mark.js' import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js' @@ -70,9 +70,13 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus const bot = roster.byId[sessionId] const room = roster.roomsById[sessionId] const detailsOpen = useUi(state => state.details === sessionId) - const name = bot?.remote + const full = bot?.remote ? friendBotTitle(bot) : (roomTitle(room) || bot?.name || '') + // The pill is one short row. The number-first title fits where + // "Face-to-face · SelfA1025, SelfB…" does not, and the full title stays on hover (#1102). + const face = room?.origin === 'face-to-face' + const name = face ? faceRowTitle(full) : full const useNews = useMemoryNews ?? noNews const memoryIds = bot ? [bot.id] : room?.members ?? [] const notice = useMemoryNotice(memoryIds, useNews) @@ -82,12 +86,12 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus return h('div', { className: 'bt-head' }, h('button', { type: 'button', className: 'bt-pill', 'aria-expanded': detailsOpen, 'data-memory': memoryNotice ? 'updated' : undefined, - 'aria-label': memoryNotice ? t('Memory updated. Open {name}\'s memory', { name: roster.byId[changed]?.name ?? name }) : t('View conversation details'), + 'aria-label': memoryNotice ? t('Memory updated. Open {name}\'s memory', { name: roster.byId[changed]?.name ?? full }) : t('View conversation details'), onClick: () => (memoryNotice ? actions.openMemory(changed) : actions.toggleDetails(sessionId)), }, bot ? h(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== undefined), badge: false }) : room ? h(RoomAvatar, { room, roster, size: 24 }) : null, - h('span', { className: 'bt-pill-name' }, name || title || t('New chat')), + h('span', { className: face ? 'bt-pill-name bt-pill-name-face' : 'bt-pill-name', title: face ? full : undefined }, name || title || t('New chat')), h(MemoryNotice, { shown: memoryNotice }), h('span', { className: 'bt-pill-go', 'aria-hidden': true }, h(GoArrow))), h('span', { className: 'bt-sr', role: 'status' }, memoryNotice ? t('Memory updated') : ''), diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index b7805c84..9f4f099d 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -172,7 +172,8 @@ function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSess const label = [hover, face ? t('Face-to-face') : '', pinned ? t('Pinned') : '', members.length > 0 ? t('Working') : '', waiting ? t('Waiting for your approval') : alert ? t('Waiting for your answer') : ''].filter(Boolean).join(', ') return h('button', { type: 'button', className: 'bt-row', 'aria-label': label, 'aria-current': active ? 'page' : undefined, - // The full title on hover: a long face-room title is cut to one line in the row (#304, #173). + // The full title on hover. The row wraps the number-first form so the second + // name is not cut to "SelfB…" (#1102, #304, #458). title: hover, 'data-room-id': id, onClick: () => actions.openSession(id), onContextMenu: (event) => { event.preventDefault(); actions.openMenu({ kind: 'room', id, x: event.clientX, y: event.clientY }) }, @@ -180,7 +181,7 @@ function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSess h(RoomAvatar, { room, roster, size: 36 }), pinned && !wide ? h(PinMark) : null, wide ? h('span', { className: 'bt-row-body' }, - h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, face ? faceRowTitle(title) : title), face ? h(FaceMark) : room.sharedFrom ? h('span', { className: 'bt-tag', title: from }, t('Shared')) : null, pinned ? h(PinMark) : null), + h('span', { className: 'bt-row-name' }, h('span', { className: face ? 'bt-row-title bt-row-title-face' : 'bt-row-title' }, face ? faceRowTitle(title) : title), face ? h(FaceMark) : room.sharedFrom ? h('span', { className: 'bt-tag', title: from }, t('Shared')) : null, pinned ? h(PinMark) : null), workingLine ? h('span', { className: 'bt-row-preview' }, workingLine) : preview ? h('span', { className: 'bt-row-preview' }, preview) : null) : null, wide && alert && !active ? h('span', { className: 'bt-unread bt-unread-wait', 'aria-hidden': true }) : wide && unread && !active ? h('span', { className: 'bt-unread', 'aria-hidden': true }) : null) diff --git a/core/src/client/styles.js b/core/src/client/styles.js index aafdb56a..e0f2a88e 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -149,6 +149,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-row-title.bt-row-title-face{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3;overflow:hidden} .bt-row-remote{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-2)} .bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -172,6 +173,8 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)} .bt-pill:active{transform:scale(.965)} .bt-pill-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500} +.bt-pill:has(.bt-pill-name-face){height:auto;min-height:40px;align-items:flex-start} +.bt-pill-name.bt-pill-name-face{min-width:0;white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden} .bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, 'width', 'margin-left')}} .bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, 'width', 'margin-left')}} .bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, 'transform')}} @@ -860,6 +863,11 @@ export const CONVERSATION_CSS = ` whole header, so the header keeps its own layout, border and drag row. */ [class*="_header"] .bt-head{display:flex;align-items:center;height:auto;flex:none;width:auto;pointer-events:auto} [class*="_header"] .bt-head .bt-pill{height:30px;padding:3px 10px 3px 4px;gap:6px;font-size:13px;line-height:18px;max-width:min(320px,32vw)} +[class*="_header"] .bt-head .bt-pill:has(.bt-pill-name-face){height:auto;min-height:30px;align-items:flex-start} +/* The session breadcrumb is a 244px nowrap line (#1102). Two lines keep + "Face-to-face · SelfA1025, SelfB1025 · 739184" inside that box. */ +[class*="_header"] [class*="switcherTitle"]{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;text-overflow:ellipsis} +[class*="_header"] [class*="switcherTrigger"]{height:auto;align-items:flex-start} [class*="_header"] .bt-head .bt-mark{width:22px!important;height:22px!important;--bt-size:22px} [class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} /* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), diff --git a/core/test/branding.test.js b/core/test/branding.test.js index 81716a51..91fd4475 100644 --- a/core/test/branding.test.js +++ b/core/test/branding.test.js @@ -94,6 +94,27 @@ test('#250: an ended face-to-face room says closed in the tab instead of listing off() }) +test('#1102: a live face-room tab leads with the number so both names stay in the title', () => { + const sessions = createSource({ byId: { room1: { id: 'room1', retainedBy: { mainView: 1 } } } }) + const roster = createSource({ + byId: {}, + roomsById: { + room1: { + name: 'Face-to-face · SelfA1025, SelfB1025 · 739184', + displayName: 'Face-to-face · SelfA1025, SelfB1025 · 739184', + origin: 'face-to-face', + face: { status: 'open' }, + }, + }, + }) + const doc = { title: 'FlyBot' } + const off = installTabTitle(sessions, roster, doc) + // The long form "Face-to-face · SelfA1025, SelfB…" loses the second name and the number + // when the tab clips the end. The number-first form fits, and both names are whole. + assert.equal(doc.title, '739184 · SelfA1025, SelfB1025 — FlyBot') + off() +}) + test('#173: the tab title follows a room the roster learns after it opened', () => { const sessions = createSource({ byId: { chief: { id: 'chief', retainedBy: { mainView: 1 } } } }) const roster = createSource({ byId: { chief: { name: 'Chief' } }, roomsById: {} }) diff --git a/core/test/face-row.test.js b/core/test/face-row.test.js index 98f68864..12186c57 100644 --- a/core/test/face-row.test.js +++ b/core/test/face-row.test.js @@ -11,7 +11,25 @@ test('#458: a face-room sidebar row leads with its number, so a cut title never assert.equal(faceRowTitle('Kitchen'), 'Kitchen') const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') const row = sidebar.slice(sidebar.indexOf('function RoomRow('), sidebar.indexOf('function AgentSessionRow(')) - assert.match(row, /className: 'bt-row-title' \}, face \? faceRowTitle\(title\) : title\)/) + assert.match(row, /className: face \? 'bt-row-title bt-row-title-face' : 'bt-row-title' \}, face \? faceRowTitle\(title\) : title\)/) +}) + +test('#1102: a face-room row wraps its number-first title, and the session breadcrumb wraps too', () => { + const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') + const styles = readFileSync(new URL('../src/client/styles.js', import.meta.url), 'utf8') + const header = readFileSync(new URL('../src/client/header.js', import.meta.url), 'utf8') + const row = sidebar.slice(sidebar.indexOf('function RoomRow('), sidebar.indexOf('function AgentSessionRow(')) + assert.match(row, /bt-row-title-face/) + // One line cut "739184 · SelfA1025, SelfB…". Up to three lines keep every name; other rows stay nowrap. + assert.match(styles, /\.bt-row-title\.bt-row-title-face\{[^}]*white-space:normal[^}]*-webkit-line-clamp:3/) + assert.match(styles, /\.bt-row-title\{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\}/) + // The in-app session title is the header breadcrumb, a 244px nowrap line + // ("Face-to-face · SelfA1025, SelfB…"). Two lines keep both names and the number. + assert.match(styles, /\[class\*="switcherTitle"\]\{[^}]*white-space:normal[^}]*-webkit-line-clamp:2/) + const pill = header.slice(header.indexOf('export function HeaderPill'), header.indexOf('function useTranscriptView')) + assert.match(pill, /bt-pill-name-face/) + assert.match(pill, /faceRowTitle\(/) + assert.match(styles, /\.bt-pill-name\.bt-pill-name-face\{[^}]*white-space:normal/) }) test("#458: a face room's turns never take over its Bots' sidebar preview; other groups still show", () => { diff --git a/core/test/flybot-client.test.js b/core/test/flybot-client.test.js index 4958cfeb..c6d85750 100644 --- a/core/test/flybot-client.test.js +++ b/core/test/flybot-client.test.js @@ -230,7 +230,7 @@ test('the account menu names each destination as it is named elsewhere (#392, #3 for (const gone of ['Automation tasks', 'Classic Agent mode', 'Switch', 'Mode']) assert.equal(Object.hasOwn(DICTIONARY.zh, gone), false, gone) }) -test('a face-room sidebar row stays on one line and shows its full title on hover (#304)', async () => { +test('a face-room sidebar row shows its full title on hover (#304, #1102)', async () => { const { readFileSync } = await import('node:fs') const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8').replace(/\r\n/g, '\n') const styles = readFileSync(new URL('../src/client/styles.js', import.meta.url), 'utf8').replace(/\r\n/g, '\n') @@ -238,8 +238,9 @@ test('a face-room sidebar row stays on one line and shows its full title on hove assert.match(mark, /className: 'bt-row-remote bt-row-face'/) const row = sidebar.slice(sidebar.indexOf('function RoomRow('), sidebar.indexOf('function AgentSessionRow(')) // #892: the hover is the title, plus who shared it for a shared room. - assert.match(row, /'aria-current': active \? 'page' : undefined,\n(\s*\/\/[^\n]*\n)?\s*title: hover,\n/) - // No sidebar row wraps its title (#384 dropped the friend's Bot two-line clamp too). + // More than one comment may sit above title: hover (#1102). + assert.match(row, /'aria-current': active \? 'page' : undefined,\n(\s*\/\/[^\n]*\n)*\s*title: hover,\n/) + // A friend's Bot row stays one line (#384). A face-room title wraps on `.bt-row-title-face` (#1102). assert.equal(styles.split('\n').some(line => line.includes('-webkit-line-clamp:2') && line.includes('.bt-row-')), false) assert.match(styles, /\.bt-row-title\{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\}/) }) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 272b53a3..889933f8 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -112,12 +112,12 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### FlyBot product chrome - User-visible chrome says **FlyBot** (header mark + name, About, settings title, tab title, favicon, zh copy). The mark is the existing FlyBot logo (`docs/images/logo.svg`), inlined as `FlyBotMark` from `src/client/brand.js`. Update popups say **FlyBot core {version}**. The npm package remains `ds-bot`; plugin id, localStorage keys, and `console.warn('[ds-bot]')` stay upstream. -- The tab title follows the open chat (`sessionTabLabel` in `brand.js`: a friend's Bot reads `Name (owner)`, #384, a room uses its display name; an ended face-to-face room, closed or left, reads `Face-to-face · closed` unless the user named it, #250). `installTabTitle` re-reads it on session **and** roster changes, so a face room the joiner opens before the roster names it, or a room renamed later, still gets its own title (#173). Sidebar row gear controls are hover/focus-only, and Bot settings call the duplicate/delete card **More**. An empty chat with a friend's Bot shows a one-line hint (`--bt-remote-empty`, set by `installHeaderEffects`) that the friend approves unless Auto-send is on (#173). The agent entry on **+** reads **Add Bot**. +- The tab title follows the open chat (`sessionTabLabel` in `brand.js`: a friend's Bot reads `Name (owner)`, #384, a room uses its display name; a live face-to-face room leads with its number (`739184 · SelfA1025, SelfB1025`, the same `faceRowTitle` as the sidebar, #1102) so a clipped tab does not hide the second name; an ended face-to-face room, closed or left, reads `Face-to-face · closed` unless the user named it, #250). `installTabTitle` re-reads it on session **and** roster changes, so a face room the joiner opens before the roster names it, or a room renamed later, still gets its own title (#173). Sidebar row gear controls are hover/focus-only, and Bot settings call the duplicate/delete card **More**. An empty chat with a friend's Bot shows a one-line hint (`--bt-remote-empty`, set by `installHeaderEffects`) that the friend approves unless Auto-send is on (#173). The agent entry on **+** reads **Add Bot**. - In `apply`, any `ctx.effect` that reads `roster` must come after `const roster = createSource(...)`: effects run immediately, and a read before the declaration throws and fails the whole client plugin load (`core/test/client-apply-order.test.js`). ### One sidebar list -- `TeamSidebar` lists local Bots, shown remote chats (`isShownRemoteChat`, #956), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows its one name, `friendBotTitle` from `shared/remote-label.js` ("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), in `bt-row-title`, then the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message — *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. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer, transcript, @ menu and mention chip agree. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`), so the cut never hides it; the hover text and label keep the full title (#458). A group row whose group holds an open question card (#550) says it waits for me: `Waiting for your answer` in its label and the waiting dot (`bt-unread-wait`), even when no approval waits there; the asking Bot's own row stays out of it because the card lives in the group (#552). A Bot row's group-work line (` · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. +- `TeamSidebar` lists local Bots, shown remote chats (`isShownRemoteChat`, #956), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). A friend's Bot row does not wrap its title (#384): it shows its one name, `friendBotTitle` from `shared/remote-label.js` ("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), in `bt-row-title`, then the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message — *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. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies 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 (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`) and wraps onto at most three lines so a second name is not cut to `SelfB…` (#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 answer` in its label and the waiting dot (`bt-unread-wait`), even when no approval waits there; the asking Bot's own row stays out of it because the card lives in the group (#552). A Bot row's group-work line (` · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. ### Agent inbox (#349, step 1) diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 1bb816e4..b8096fb5 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -47,7 +47,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 3. A server policy pause (`conversation_paused`: loop suspected between the Bots, emergency stop, or an older server's turn budget) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to *"Bot Social paused that chat (reason). Send your message again: it starts a new chat."* (structured: `code: 'conversation_paused'`, `paused`, `resend`), because `send_as_member` opens a fresh conversation for the pair's next message. It never points to the Bot Social website (`/ui`), which a FlyBot owner cannot sign in to, and never names Extend or turns (there is no turn limit, owner decision 2026-10-19, Bot Social #28; see below) and no other member Bot is tried for it. Other failures end in *Try again.*, never in a console pointer. Other `sendChat` failures are one short English line with a next step (reconnect, invite, try again) — never `Bot Social send_as_member: …`. A human's own message in a face-to-face or shared room to a friend's Bot that is already a member of that room is delivered as that human in the room (`onFaceLine` / room log); it is not signed or grant-checked as Chief (#143). Chief-originated `bot.send` / `message_friend_bot` to an ungranted friend Bot stays denied. The refusal says it the right way round and names the one setting that exists (#273): the sending Bot is not shared with that friend (*"Chief is not shared with that friend, so it cannot message their Bots. To allow it, share Chief with them in its settings → Sharing."*), or the target is not a Bot a friend shared with you; `message_friend_bot` tells the model to relay that reason as written. 4. Access is a per-Bot **grant** (#90, #138): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted **to me**), minus reverse-friends of invites I created and minus face-room-only remotes. Accepting **Sharing → Copy invite link** is the grant the other way; **Share with a friend** (`social/share-friends` + `social/share-with-friend`, via `grantBotToFriend` / `grant_bot_to_friend`) is the same grant for someone who is already a friend, with no invite code; both endpoints register the Bot's Bot Social identity on demand (`register(bot, { forInvite: true })`) when it has none yet, so the share list works on a Bot that was never invited or shared. Joining does not grant my talk-through Bot back. Join offers **Talk through** only when I have more than one Bot (default Chief; tooltip: not shared unless I invite them to it, #228). `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, preferring `list_bot_grants` / `revoke_bot_grant`) list people as `Name (@home)` and revoke one person. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`) and drops room-scoped remotes from Friends' Bots. A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`, keeping the stored `ownerName`/`home` and filling either from `whoami` when the room-only record never had it (#863). Registration also records the owner-level `ownerName` and `home` on that member identity (#848) from one `whoami`: top-level `display_name` and `home`, else `connector_identity.bot_handles[0]`, else the `flybot-home` entry in `bots[]` (given the `@`), and `registerAll` fills them into an identity saved before this without registering it again; the group room-share envelope reads them to name the inviter. 5. First start retries a taken home handle (`FlyBot`, `FlyBot2`, …) instead of failing for a YAML edit (#136). If every retry is still taken, the social UI never shows the host's `Unknown FlyBot endpoint social/…` message: `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal and `flybot.warn` records the failure. The client also maps `bot/not-found` on `social/*` to that idle state. Add friend shows `You are @your.home` (#181). -6. Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone **Enter chat**s. Copy never says there is no preview. Each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social, including `joinCode`) and draws one tile per person (#457: `FacePersonTile`, the person's name, `Me (you)` for the owner, with the Bots they brought underneath; the owner's tile has a tiny accent dot); **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). Owner-typed `FLYBOT-FACE-HUMAN` lines stay on that card until Approve / Always approve / Reject unless face **Auto-send** is on; the card and chat never show the raw envelope (#159). Face-room **Auto-send** is one switch per Bot of mine in the room (#457, `Auto-send Chief → room`, `social/face-auto { botId, on }`, kept in this home's `settings.faceAutoSend`, off by default): when that Bot's line comes back `pending_approval` and Bot Social says the hold is its own blanket rule (`hold_reason: 'blanket_outbound'`) or nothing but the room's Auto-send switch (`hold_reason: 'face_room_auto_send_off'` — what a clean face-room line carries once that switch is the only thing left), the host approves it at once (`decide_approval`; the switch is the owner's standing consent, so a switch that reads **on** has to send), so the room sees it; any other `hold_reason` (`risk`, `attachment`, `policy`, a value this build does not know) and other Bots' lines still wait for the owner. Only this face-room path accepts `face_room_auto_send_off` (`holdAllowsFaceAutoSend`, on top of `holdIsBlanket`); the two outbound paths keep the blanket rule alone (#865). The older account-wide `auto_approve_face_rooms` (`whoami` / MCP `set_auto_approve`) reads as every switch on; the first per-Bot change keeps the others on here and turns the account-wide one off (a `confirmation_required` answer is retried with `confirm_code` in the same click; the UI never asks for a code). REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. A number nobody else is using opens no room (#458): `social/face-join` without `start` answers `{ nobody: true, faceRoomId }` when the server's room holds only this home's Bots, and the host keeps that fresh candidate off the sidebar and Past rooms (an in-memory hold, dropped on the next poll after 2 minutes). The dialog's primary line is **Start a new room with …** and its primary button, listed first, is **Start a new room** (`start: true` adopts the held room through `GET`, no second create, #1053); **Cancel** (`social/face-leave { faceRoomId }` leaves it on Bot Social and releases a new room-only identity) is the other way out. The line does not read as a failed join. That answer is the only thing the host knows: a join that returns no other home is the same answer for an unused number and for a number whose room already opened, so the copy never claims nobody uses the number and the hint still points at an invite link (#999) — **Enter chat** from a lone waiting room is what burns a number. A room nobody from another home ever joined leaves no Past rooms entry and no kept transcript, whether the owner left it or the joining window ran out (the host then leaves it on Bot Social). The room stays open while two or more homes are in it and closes here only when this home is the last; the closed room's dock reads "… left the room. Room closed." with one **Close**. Leave and Close go back to the last non-face chat this browser showed (else the Main Bot) and close the details panel. Countdowns use the server's `expires_at` corrected by the Bot Social clock offset (from the `Date` header, applied only at 3 s or more) and catch up on `visibilitychange`. Join stays disabled until 6 digits; a number that is not empty and not six digits says **Enter 6 digits** under the field (`data-flybot=face-join-digits`, zh 请输入 6 位数字, #1052) while an empty field stays quiet, an outside click closes the dialog like Escape, and Enter chat steps aside while Leave asks. Core's sidebar leads a face-room row with its number and keeps face-room turns out of a Bot row's group-work line.7. Every import in shipped sources stays inside the plugin package: an installed plugin cannot reach into other packages' files (a repo-relative `../..` into `core/` breaks the installed bundle). +6. Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone **Enter chat**s. Copy never says there is no preview. Each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social, including `joinCode`) and draws one tile per person (#457: `FacePersonTile`, the person's name, `Me (you)` for the owner, with the Bots they brought underneath; the owner's tile has a tiny accent dot; the name and the Bot line wrap instead of a single-line ellipsis, and the full label stays on the tile's title, #1102); **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). Owner-typed `FLYBOT-FACE-HUMAN` lines stay on that card until Approve / Always approve / Reject unless face **Auto-send** is on; the card and chat never show the raw envelope (#159). Face-room **Auto-send** is one switch per Bot of mine in the room (#457, `Auto-send Chief → room`, `social/face-auto { botId, on }`, kept in this home's `settings.faceAutoSend`, off by default): when that Bot's line comes back `pending_approval` and Bot Social says the hold is its own blanket rule (`hold_reason: 'blanket_outbound'`) or nothing but the room's Auto-send switch (`hold_reason: 'face_room_auto_send_off'` — what a clean face-room line carries once that switch is the only thing left), the host approves it at once (`decide_approval`; the switch is the owner's standing consent, so a switch that reads **on** has to send), so the room sees it; any other `hold_reason` (`risk`, `attachment`, `policy`, a value this build does not know) and other Bots' lines still wait for the owner. Only this face-room path accepts `face_room_auto_send_off` (`holdAllowsFaceAutoSend`, on top of `holdIsBlanket`); the two outbound paths keep the blanket rule alone (#865). The older account-wide `auto_approve_face_rooms` (`whoami` / MCP `set_auto_approve`) reads as every switch on; the first per-Bot change keeps the others on here and turns the account-wide one off (a `confirmation_required` answer is retried with `confirm_code` in the same click; the UI never asks for a code). REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. A number nobody else is using opens no room (#458): `social/face-join` without `start` answers `{ nobody: true, faceRoomId }` when the server's room holds only this home's Bots, and the host keeps that fresh candidate off the sidebar and Past rooms (an in-memory hold, dropped on the next poll after 2 minutes). The dialog's primary line is **Start a new room with …** and its primary button, listed first, is **Start a new room** (`start: true` adopts the held room through `GET`, no second create, #1053); **Cancel** (`social/face-leave { faceRoomId }` leaves it on Bot Social and releases a new room-only identity) is the other way out. The line does not read as a failed join. That answer is the only thing the host knows: a join that returns no other home is the same answer for an unused number and for a number whose room already opened, so the copy never claims nobody uses the number and the hint still points at an invite link (#999) — **Enter chat** from a lone waiting room is what burns a number. A room nobody from another home ever joined leaves no Past rooms entry and no kept transcript, whether the owner left it or the joining window ran out (the host then leaves it on Bot Social). The room stays open while two or more homes are in it and closes here only when this home is the last; the closed room's dock reads "… left the room. Room closed." with one **Close**. Leave and Close go back to the last non-face chat this browser showed (else the Main Bot) and close the details panel. Countdowns use the server's `expires_at` corrected by the Bot Social clock offset (from the `Date` header, applied only at 3 s or more) and catch up on `visibilitychange`. Join stays disabled until 6 digits; a number that is not empty and not six digits says **Enter 6 digits** under the field (`data-flybot=face-join-digits`, zh 请输入 6 位数字, #1052) while an empty field stays quiet, an outside click closes the dialog like Escape, and Enter chat steps aside while Leave asks. Core's sidebar leads a face-room row with its number and keeps face-room turns out of a Bot row's group-work line.7. Every import in shipped sources stays inside the plugin package: an installed plugin cannot reach into other packages' files (a repo-relative `../..` into `core/` breaks the installed bundle). 8. A friend's Bot's reply to a line you typed (or to any request) goes back through `bot.send` as an `auto-reply` and, unless Auto-send is on for that pair, is held for the replying Bot's owner (#254). The hold is decided on the reply itself (#418): the host tags the auto-reply with the chat and turn it answers (`meta.replyIn`, `turns.js` → `hooks.js` `replyRemote`), the Bot's row in `state` lists its `heldReplies` (id, status, turn, recipient; owner only, no text) and counts a waiting one in `pendingCount`, and that turn's bubble is drawn held (dashed, `core/src/client/held-reply.js`) with the decision on it (`flybot.approvals` inline entry `social-held-reply`: **Waiting for approval**, `Chief → SelfB1238`, **Approve** / **Reject** and the **Auto-send from now on** link, which approves it and turns on Auto-send for that pair only, the replying Bot → that person, through `social/auto-send { approvalId }` (#417), with the same toast as the dock card; it never touches the home-wide switch). No dock draws it, in either chat; the overlay's pointer leads to that chat. A reply that left without a card because Auto-send is on for that pair (#417) reads **Auto-sent to SelfB1238** there, quietly, so the owner sees it went without approval: the middleware's `allow` says `autoSent` (with the same `recipient`) and the host keeps that mark by turn (`state.flybot.autoSent`, last 200, no text). After the decision the same place reads **Sent to SelfB1238** or **Not sent** (muted; **Retry** on a failed send, and only when the shared `canRetry()` allows it: this plugin's own dock and the core cards read that one predicate, so a terminal failure — the Bot is gone or no longer shared, the send was refused as not permitted — draws no button anywhere, #968), and the list preview shows the friend's line instead of a reply that did not go. The card names the person who gets it (#498): the holding middleware's `recipient` (the friend's owner name, stored as `recipientName`, so the card reads the same live and after a reload), else the owner of the friend's chat; never the friend's Bot (`Chief → Chief (SelfB1238)` named two Bots); a line held for a face-to-face room names that room instead of the bare `the room` (#991), the same way the room bullet below says. A hold made before the tag (no `replyIn`) keeps its dock card as below. A friend's handle is never a room id. The sender sees nothing until it is approved. The reply also goes back to a requesting Bot that was not shared with us (the default one-way per-Bot grant): the replying Bot's grant to it is enough for a `reply`, never for a new request (#272). 9. Two holds, one click (#258): a send also held by Bot Social (`pending_approval`) is finished by the transport when the host says the owner approved *this very send* on a FlyBot card (`msg.approved`, set by `hooks.js` `release` only; a Bot-supplied field is overwritten). The approval id comes from the `send_as_member` answer, is recorded as an own line (so a failed call is retried by the poll and never shows as a card) and is decided with the same `confirm_code` step as a typed line. Room lines (`via: room` / `room-share`) keep Bot Social's hold: the friend's owner decides those. A send that left on Auto-send (#417) clears Bot Social's hold itself, the same way `autoSendFaceLine` does for a room line (#737): the owner's switch already answered that question, and leaving the hold parked the line with no card anywhere, so it never arrived. That clear only happens for the blanket hold and nothing else (#865): `holdIsBlanket()` reads Bot Social's `hold_reason` and clears the line only when it is `blanket_outbound`; every other value — `risk`, `attachment`, `policy`, a value this build does not know — keeps the line on its card, exactly as with Auto-send off. The face-room path is the one exception, and only for its own switch (#457): `autoSendFaceLine` clears `face_room_auto_send_off` too, while both outbound paths here never do. The field wins when it and `reasons` disagree. A server that sends no field keeps the old allow-list: `reasons` must be non-empty and every entry exactly `mvp.all_outbound_requires_approval`, with no `risk_flags`, so a DLP category, `dlp.blocked`, a reason this build does not know (`policy.*`), an empty `reasons` list and any risk flag all keep the line (#766) — an absent field is not read as "not blanket", or every hold on an older server would strand. A hold Bot Social has not listed yet is not "no finding" either: the line waits and the report names the approval id (`warn`), so a lagging list is diagnosable instead of silent. It reads the pending list directly rather than through `pendingApprovals()`, which would also post the waiting notice for a hold it is about to clear, and a failed `decide_approval` only warns: the hold then shows as the owner's card. Holds that were already stuck before this change need one Approve / Reject in Friends' Bots. 10. Joining is not sharing (#336, Bot Social `faa7cb5`): Bot Social refuses a first message from a friend's Bot to one of my Bots that only joined their invite (`403 bot_not_shared`; nothing stored or delivered) and adds a `share.requested` event to my Bot's queue. `pollOnce` keeps it as one card per sender and Bot (`settings.shareRequests`, names cleaned, no peer content) and listed on `social/status` as `shareRequests`; Friends' Bots shows *"Helper (Eve) tried to start a chat with Writer. Writer is not shared with them."* with **Dismiss** (`social/share-request-dismiss`) and **Share Writer…**, which only opens that Bot's settings (Sharing). Nothing is granted from the card, no typed reply acts on it (#349) and the Bot never answers on its own; a card drops by itself once that Bot is shared with the sender (`flybot.grants.has`) or the Bot is gone. On the sender's side both send paths map `bot_not_shared` — and `capability_not_granted` only when the sentence is "peer has not granted chat.public", which is the same fact reported from the other side once a share is taken back (#971); the same code naming another capability, such as `profile.read`, stays a generic error and keeps Retry (#988) — to *"Coder (Bob) is not shared with your Bots, so the message was not delivered. Bot Social told its owner, who can share it with you. Sending again will not help until then."* (the transport prefixes `Not delivered:`), carrying `noRetry` so no card offers a Retry that cannot work, and with no retry through another member. Core `reach()` still refuses such an inbound message locally as the safety net. diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index badcbd3d..f6ea3561 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1985,7 +1985,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js // ------------------------------------------------------------------------- // Sidebar -@@ -37,77 +37,148 @@ const workKeys = text => (text === '' ? [] : text.split(',').map((key) => { +@@ -37,77 +37,149 @@ const workKeys = text => (text === '' ? [] : text.split(',').map((key) => { })) const workLine = (lead, tool, more) => h(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ''}`) @@ -2136,7 +2136,8 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js + const label = [hover, face ? t('Face-to-face') : '', pinned ? t('Pinned') : '', members.length > 0 ? t('Working') : '', waiting ? t('Waiting for your approval') : alert ? t('Waiting for your answer') : ''].filter(Boolean).join(', ') return h('button', { type: 'button', className: 'bt-row', 'aria-label': label, 'aria-current': active ? 'page' : undefined, -+ // The full title on hover: a long face-room title is cut to one line in the row (#304, #173). ++ // The full title on hover. The row wraps the number-first form so the second ++ // name is not cut to "SelfB…" (#1102, #304, #458). + title: hover, 'data-room-id': id, onClick: () => actions.openSession(id), onContextMenu: (event) => { event.preventDefault(); actions.openMenu({ kind: 'room', id, x: event.clientX, y: event.clientY }) }, @@ -2145,7 +2146,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js pinned && !wide ? h(PinMark) : null, wide ? h('span', { className: 'bt-row-body' }, - h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, room.name), pinned ? h(PinMark) : null), -+ h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, face ? faceRowTitle(title) : title), face ? h(FaceMark) : room.sharedFrom ? h('span', { className: 'bt-tag', title: from }, t('Shared')) : null, pinned ? h(PinMark) : null), ++ h('span', { className: 'bt-row-name' }, h('span', { className: face ? 'bt-row-title bt-row-title-face' : 'bt-row-title' }, face ? faceRowTitle(title) : title), face ? h(FaceMark) : room.sharedFrom ? h('span', { className: 'bt-tag', title: from }, t('Shared')) : null, pinned ? h(PinMark) : null), workingLine ? h('span', { className: 'bt-row-preview' }, workingLine) : preview ? h('span', { className: 'bt-row-preview' }, preview) : null) : null, - wide && approval && !active ? h('span', { className: 'bt-unread bt-unread-wait', 'aria-hidden': true }) + wide && alert && !active ? h('span', { className: 'bt-unread bt-unread-wait', 'aria-hidden': true }) @@ -2508,10 +2509,11 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} .bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} -@@ -132,21 +132,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -132,27 +132,32 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-row-title.bt-row-title-face{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3;overflow:hidden} +.bt-row-remote{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-2)} .bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -2536,6 +2538,14 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} .bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} + .bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)} + .bt-pill:active{transform:scale(.965)} + .bt-pill-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500} ++.bt-pill:has(.bt-pill-name-face){height:auto;min-height:40px;align-items:flex-start} ++.bt-pill-name.bt-pill-name-face{min-width:0;white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden} + .bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, 'width', 'margin-left')}} + .bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, 'width', 'margin-left')}} + .bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, 'transform')}} @@ -174,6 +174,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} @@ -2651,7 +2661,7 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-group-replies{display:contents} /* A Bot chat is flat in every work-details mode, without changing the user's mode (it is a DSH-wide setting): step groups lose their header and stay open, and what a -@@ -805,21 +805,23 @@ export const CONVERSATION_CSS = ` +@@ -805,21 +805,28 @@ export const CONVERSATION_CSS = ` [data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} :is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} [class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} @@ -2667,6 +2677,11 @@ diff --git a/src/client/styles.js b/src/client/styles.js + whole header, so the header keeps its own layout, border and drag row. */ +[class*="_header"] .bt-head{display:flex;align-items:center;height:auto;flex:none;width:auto;pointer-events:auto} +[class*="_header"] .bt-head .bt-pill{height:30px;padding:3px 10px 3px 4px;gap:6px;font-size:13px;line-height:18px;max-width:min(320px,32vw)} ++[class*="_header"] .bt-head .bt-pill:has(.bt-pill-name-face){height:auto;min-height:30px;align-items:flex-start} ++/* The session breadcrumb is a 244px nowrap line (#1102). Two lines keep ++ "Face-to-face · SelfA1025, SelfB1025 · 739184" inside that box. */ ++[class*="_header"] [class*="switcherTitle"]{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;text-overflow:ellipsis} ++[class*="_header"] [class*="switcherTrigger"]{height:auto;align-items:flex-start} +[class*="_header"] .bt-head .bt-mark{width:22px!important;height:22px!important;--bt-size:22px} [class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} /* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 29ca6ffd..5e44c34b 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1560,7 +1560,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,770 +1624,1678 @@ function usageOf(list, ids) { +@@ -1624,770 +1624,1681 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -3520,6 +3520,7 @@ diff --git a/client.js b/client.js +.bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} +.bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} +.bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-row-title.bt-row-title-face{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3;overflow:hidden} +.bt-row-remote{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-2)} +.bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -3543,6 +3544,8 @@ diff --git a/client.js b/client.js +.bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)} +.bt-pill:active{transform:scale(.965)} +.bt-pill-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500} ++.bt-pill:has(.bt-pill-name-face){height:auto;min-height:40px;align-items:flex-start} ++.bt-pill-name.bt-pill-name-face{min-width:0;white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden} +.bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}} +.bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}} +.bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}} @@ -3995,7 +3998,7 @@ diff --git a/client.js b/client.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -2488,2292 +2488,4073 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2488,2292 +2488,4079 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} @keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} @media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} @@ -4231,6 +4234,11 @@ diff --git a/client.js b/client.js + whole header, so the header keeps its own layout, border and drag row. */ +[class*="_header"] .bt-head{display:flex;align-items:center;height:auto;flex:none;width:auto;pointer-events:auto} +[class*="_header"] .bt-head .bt-pill{height:30px;padding:3px 10px 3px 4px;gap:6px;font-size:13px;line-height:18px;max-width:min(320px,32vw)} ++[class*="_header"] .bt-head .bt-pill:has(.bt-pill-name-face){height:auto;min-height:30px;align-items:flex-start} ++/* The session breadcrumb is a 244px nowrap line (#1102). Two lines keep ++ "Face-to-face · SelfA1025, SelfB1025 · 739184" inside that box. */ ++[class*="_header"] [class*="switcherTitle"]{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;text-overflow:ellipsis} ++[class*="_header"] [class*="switcherTrigger"]{height:auto;align-items:flex-start} +[class*="_header"] .bt-head .bt-mark{width:22px!important;height:22px!important;--bt-size:22px} +[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} +/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), @@ -4556,6 +4564,7 @@ diff --git a/client.js b/client.js + return friendBotTitle(bot); + } + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; ++ if (room2?.origin === "face-to-face") return faceRowTitle(roomTitle(room2)); + if (room2) return roomTitle(room2); + return bot?.name || ""; +} @@ -10180,7 +10189,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4785,55 +4785,130 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4785,55 +4785,131 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio onClick: () => actions.openSession(id), onContextMenu }, @@ -10321,7 +10330,8 @@ diff --git a/client.js b/client.js className: "bt-row", "aria-label": label, "aria-current": active ? "page" : void 0, -+ // The full title on hover: a long face-room title is cut to one line in the row (#304, #173). ++ // The full title on hover. The row wraps the number-first form so the second ++ // name is not cut to "SelfB…" (#1102, #304, #458). + title: hover, "data-room-id": id, onClick: () => actions.openSession(id), @@ -10340,7 +10350,7 @@ diff --git a/client.js b/client.js { className: "bt-row-body" }, - (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, room2.name), pinned ? (0, import_react8.createElement)(PinMark) : null), - workingLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -+ (0, import_react14.createElement)("span", { className: "bt-row-name" }, (0, import_react14.createElement)("span", { className: "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react14.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react14.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react14.createElement)(PinMark) : null), ++ (0, import_react14.createElement)("span", { className: "bt-row-name" }, (0, import_react14.createElement)("span", { className: face ? "bt-row-title bt-row-title-face" : "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react14.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react14.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react14.createElement)(PinMark) : null), + workingLine ? (0, import_react14.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react14.createElement)("span", { className: "bt-row-preview" }, preview) : null ) : null, - wide && approval && !active ? (0, import_react8.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null @@ -10984,12 +10994,14 @@ diff --git a/client.js b/client.js if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); -@@ -5548,52 +5548,38 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5548,52 +5548,40 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); - const name = bot?.name ?? room2?.name ?? ""; -+ const name = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; ++ const full2 = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; ++ const face = room2?.origin === "face-to-face"; ++ const name = face ? faceRowTitle(full2) : full2; const useNews = useMemoryNews ?? noNews; const memoryIds = bot ? [bot.id] : room2?.members ?? []; const notice = useMemoryNotice(memoryIds, useNews); @@ -11025,7 +11037,7 @@ diff --git a/client.js b/client.js "data-memory": memoryNotice ? "updated" : void 0, - "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed]?.name ?? name }) : t("View conversation details"), - onClick: () => memoryNotice ? actions.openMemory(changed) : actions.toggleDetails(sessionId) -+ "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? name }) : t("View conversation details"), ++ "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? full2 }) : t("View conversation details"), + onClick: () => memoryNotice ? actions.openMemory(changed2) : actions.toggleDetails(sessionId) }, - bot ? (0, import_react9.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, @@ -11033,7 +11045,7 @@ diff --git a/client.js b/client.js - (0, import_react9.createElement)(MemoryNotice, { shown: memoryNotice }), - (0, import_react9.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react9.createElement)(GoArrow)) + bot ? (0, import_react15.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react15.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, -+ (0, import_react15.createElement)("span", { className: "bt-pill-name" }, name || title || t("New chat")), ++ (0, import_react15.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), + (0, import_react15.createElement)(MemoryNotice, { shown: memoryNotice }), + (0, import_react15.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react15.createElement)(GoArrow)) ), diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 4d6188b9..760edee1 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -541,8 +541,11 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js diff --git a/src/client/header.js b/src/client/header.js --- a/src/client/header.js +++ b/src/client/header.js -@@ -3,9 +3,10 @@ import { createPortal } from 'react-dom' - import { currentPart, latestMemoryChange } from './text.js' +@@ -1,11 +1,12 @@ + import { createElement as h, Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' + import { createPortal } from 'react-dom' +-import { currentPart, latestMemoryChange } from './text.js' ++import { currentPart, faceRowTitle, latestMemoryChange } from './text.js' import { ArrowUpIcon, CloseIcon, ArrowDownIcon, MemoryIcon } from './icons.js' import { botState, BotAvatar, RoomAvatar } from './mark.js' +import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js' @@ -565,14 +568,18 @@ diff --git a/src/client/header.js b/src/client/header.js const opening = useTurnClock((state) => { const first = openingTurn(state[sessionId], firstTurns, hiddenTurns) return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : '' -@@ -68,26 +68,14 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus +@@ -68,40 +68,32 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus const bot = roster.byId[sessionId] const room = roster.roomsById[sessionId] const detailsOpen = useUi(state => state.details === sessionId) - const name = bot?.name ?? room?.name ?? '' -+ const name = bot?.remote ++ const full = bot?.remote + ? friendBotTitle(bot) + : (roomTitle(room) || bot?.name || '') ++ // The pill is one short row. The number-first title fits where ++ // "Face-to-face · SelfA1025, SelfB…" does not, and the full title stays on hover (#1102). ++ const face = room?.origin === 'face-to-face' ++ const name = face ? faceRowTitle(full) : full const useNews = useMemoryNews ?? noNews const memoryIds = bot ? [bot.id] : room?.members ?? [] const notice = useMemoryNotice(memoryIds, useNews) @@ -595,12 +602,15 @@ diff --git a/src/client/header.js b/src/client/header.js const memoryNotice = notice && changed !== null && roster.memory !== false return h('div', { className: 'bt-head' }, h('button', { -@@ -97,11 +97,11 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus + type: 'button', className: 'bt-pill', 'aria-expanded': detailsOpen, 'data-memory': memoryNotice ? 'updated' : undefined, +- 'aria-label': memoryNotice ? t('Memory updated. Open {name}\'s memory', { name: roster.byId[changed]?.name ?? name }) : t('View conversation details'), ++ 'aria-label': memoryNotice ? t('Memory updated. Open {name}\'s memory', { name: roster.byId[changed]?.name ?? full }) : t('View conversation details'), + onClick: () => (memoryNotice ? actions.openMemory(changed) : actions.toggleDetails(sessionId)), }, bot ? h(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== undefined), badge: false }) : room ? h(RoomAvatar, { room, roster, size: 24 }) : null, - h('span', { className: 'bt-pill-name' }, bot?.name ?? room?.name ?? (title || t('New chat'))), -+ h('span', { className: 'bt-pill-name' }, name || title || t('New chat')), ++ h('span', { className: face ? 'bt-pill-name bt-pill-name-face' : 'bt-pill-name', title: face ? full : undefined }, name || title || t('New chat')), h(MemoryNotice, { shown: memoryNotice }), h('span', { className: 'bt-pill-go', 'aria-hidden': true }, h(GoArrow))), h('span', { className: 'bt-sr', role: 'status' }, memoryNotice ? t('Memory updated') : ''), diff --git a/patches/ds-bot/files/src/client/brand.js b/patches/ds-bot/files/src/client/brand.js index aa784c74..faecf7fa 100644 --- a/patches/ds-bot/files/src/client/brand.js +++ b/patches/ds-bot/files/src/client/brand.js @@ -2,6 +2,7 @@ // Paths stay here so tests can import them without React. import { isAutoFaceTitle } from '../shared/face-title.js' +import { faceRowTitle } from './text.js' import { friendBotTitle, roomTitle } from '../shared/remote-label.js' export const FLYBOT_WING = 'M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z' @@ -37,6 +38,10 @@ export function sessionTabLabel(roster, sessionId) { // An ended face-to-face room (closed, or someone left) no longer lists people who are not in it (#250). // A name the user chose is kept. if (room && room.origin === 'face-to-face' && (room.face?.status === 'closed' || room.face?.status === 'left') && isAutoFaceTitle(room.name)) return 'Face-to-face · closed' + // A live face room leads with its number (#1102). The long form + // "Face-to-face · SelfA1025, SelfB1025 · 739184" is what a tab clips to + // "Face-to-face · SelfA1025, SelfB…", which hides the second name and the number. + if (room?.origin === 'face-to-face') return faceRowTitle(roomTitle(room)) if (room) return roomTitle(room) return bot?.name || '' } diff --git a/patches/ds-bot/files/test/branding.test.js b/patches/ds-bot/files/test/branding.test.js index 81716a51..91fd4475 100644 --- a/patches/ds-bot/files/test/branding.test.js +++ b/patches/ds-bot/files/test/branding.test.js @@ -94,6 +94,27 @@ test('#250: an ended face-to-face room says closed in the tab instead of listing off() }) +test('#1102: a live face-room tab leads with the number so both names stay in the title', () => { + const sessions = createSource({ byId: { room1: { id: 'room1', retainedBy: { mainView: 1 } } } }) + const roster = createSource({ + byId: {}, + roomsById: { + room1: { + name: 'Face-to-face · SelfA1025, SelfB1025 · 739184', + displayName: 'Face-to-face · SelfA1025, SelfB1025 · 739184', + origin: 'face-to-face', + face: { status: 'open' }, + }, + }, + }) + const doc = { title: 'FlyBot' } + const off = installTabTitle(sessions, roster, doc) + // The long form "Face-to-face · SelfA1025, SelfB…" loses the second name and the number + // when the tab clips the end. The number-first form fits, and both names are whole. + assert.equal(doc.title, '739184 · SelfA1025, SelfB1025 — FlyBot') + off() +}) + test('#173: the tab title follows a room the roster learns after it opened', () => { const sessions = createSource({ byId: { chief: { id: 'chief', retainedBy: { mainView: 1 } } } }) const roster = createSource({ byId: { chief: { name: 'Chief' } }, roomsById: {} }) diff --git a/patches/ds-bot/files/test/face-row.test.js b/patches/ds-bot/files/test/face-row.test.js index 98f68864..12186c57 100644 --- a/patches/ds-bot/files/test/face-row.test.js +++ b/patches/ds-bot/files/test/face-row.test.js @@ -11,7 +11,25 @@ test('#458: a face-room sidebar row leads with its number, so a cut title never assert.equal(faceRowTitle('Kitchen'), 'Kitchen') const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') const row = sidebar.slice(sidebar.indexOf('function RoomRow('), sidebar.indexOf('function AgentSessionRow(')) - assert.match(row, /className: 'bt-row-title' \}, face \? faceRowTitle\(title\) : title\)/) + assert.match(row, /className: face \? 'bt-row-title bt-row-title-face' : 'bt-row-title' \}, face \? faceRowTitle\(title\) : title\)/) +}) + +test('#1102: a face-room row wraps its number-first title, and the session breadcrumb wraps too', () => { + const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') + const styles = readFileSync(new URL('../src/client/styles.js', import.meta.url), 'utf8') + const header = readFileSync(new URL('../src/client/header.js', import.meta.url), 'utf8') + const row = sidebar.slice(sidebar.indexOf('function RoomRow('), sidebar.indexOf('function AgentSessionRow(')) + assert.match(row, /bt-row-title-face/) + // One line cut "739184 · SelfA1025, SelfB…". Up to three lines keep every name; other rows stay nowrap. + assert.match(styles, /\.bt-row-title\.bt-row-title-face\{[^}]*white-space:normal[^}]*-webkit-line-clamp:3/) + assert.match(styles, /\.bt-row-title\{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\}/) + // The in-app session title is the header breadcrumb, a 244px nowrap line + // ("Face-to-face · SelfA1025, SelfB…"). Two lines keep both names and the number. + assert.match(styles, /\[class\*="switcherTitle"\]\{[^}]*white-space:normal[^}]*-webkit-line-clamp:2/) + const pill = header.slice(header.indexOf('export function HeaderPill'), header.indexOf('function useTranscriptView')) + assert.match(pill, /bt-pill-name-face/) + assert.match(pill, /faceRowTitle\(/) + assert.match(styles, /\.bt-pill-name\.bt-pill-name-face\{[^}]*white-space:normal/) }) test("#458: a face room's turns never take over its Bots' sidebar preview; other groups still show", () => { diff --git a/patches/ds-bot/files/test/flybot-client.test.js b/patches/ds-bot/files/test/flybot-client.test.js index 4958cfeb..c6d85750 100644 --- a/patches/ds-bot/files/test/flybot-client.test.js +++ b/patches/ds-bot/files/test/flybot-client.test.js @@ -230,7 +230,7 @@ test('the account menu names each destination as it is named elsewhere (#392, #3 for (const gone of ['Automation tasks', 'Classic Agent mode', 'Switch', 'Mode']) assert.equal(Object.hasOwn(DICTIONARY.zh, gone), false, gone) }) -test('a face-room sidebar row stays on one line and shows its full title on hover (#304)', async () => { +test('a face-room sidebar row shows its full title on hover (#304, #1102)', async () => { const { readFileSync } = await import('node:fs') const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8').replace(/\r\n/g, '\n') const styles = readFileSync(new URL('../src/client/styles.js', import.meta.url), 'utf8').replace(/\r\n/g, '\n') @@ -238,8 +238,9 @@ test('a face-room sidebar row stays on one line and shows its full title on hove assert.match(mark, /className: 'bt-row-remote bt-row-face'/) const row = sidebar.slice(sidebar.indexOf('function RoomRow('), sidebar.indexOf('function AgentSessionRow(')) // #892: the hover is the title, plus who shared it for a shared room. - assert.match(row, /'aria-current': active \? 'page' : undefined,\n(\s*\/\/[^\n]*\n)?\s*title: hover,\n/) - // No sidebar row wraps its title (#384 dropped the friend's Bot two-line clamp too). + // More than one comment may sit above title: hover (#1102). + assert.match(row, /'aria-current': active \? 'page' : undefined,\n(\s*\/\/[^\n]*\n)*\s*title: hover,\n/) + // A friend's Bot row stays one line (#384). A face-room title wraps on `.bt-row-title-face` (#1102). assert.equal(styles.split('\n').some(line => line.includes('-webkit-line-clamp:2') && line.includes('.bt-row-')), false) assert.match(styles, /\.bt-row-title\{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\}/) }) diff --git a/plugins/social/client.js b/plugins/social/client.js index 242487c7..fb98fd4e 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -272,14 +272,14 @@ window.__ModuleLoader__.load({ .fs-dialog-head{display:flex;align-items:center;gap:8px;flex:none} .fs-dialog-head .fs-name{flex:1 1 auto} .fs-dialog > form{flex:1 1 auto;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:8px} -.fs-tiles{margin:0;padding:4px 0 0;list-style:none;display:grid;grid-template-columns:repeat(auto-fill,minmax(72px,1fr));gap:10px 6px;align-content:start} +.fs-tiles{margin:0;padding:4px 0 0;list-style:none;display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:10px 6px;align-content:start} .fs-pick{max-height:220px;overflow:auto} .fs-tile{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0;padding:6px 2px;border-radius:10px} .fs-tile-hit{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0;width:100%;margin:0;cursor:pointer;font:inherit;position:relative} .fs-tile-hit:hover,.fs-tile-on{background:var(--bt-hover,rgba(119,119,119,.09))} .fs-tile-mark{position:relative;flex:none;display:inline-flex} -.fs-tile-name{display:block;width:100%;font-size:12px;line-height:16px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--bt-ink,#141414)} -.fs-tile-sub{display:block;width:100%;font-size:11px;line-height:14px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--bt-ink-2,rgba(20,20,20,.6))} +.fs-tile-name{display:block;width:100%;font-size:12px;line-height:16px;text-align:center;white-space:normal;overflow-wrap:anywhere;color:var(--bt-ink,#141414)} +.fs-tile-sub{display:block;width:100%;font-size:11px;line-height:14px;text-align:center;white-space:normal;overflow-wrap:anywhere;color:var(--bt-ink-2,rgba(20,20,20,.6))} .fs-wait-overview{margin:0 0 8px;padding:8px 10px;border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:12px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:8px;min-width:0;max-width:100%} .fs-wait-overview .fs-wait-foot{margin-top:2px} .fs-tile-mine{position:absolute;right:-1px;bottom:-1px;width:8px;height:8px;border-radius:50%;background:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 2px var(--bt-card,#fff);pointer-events:none} diff --git a/plugins/social/test/face-tile-names.test.js b/plugins/social/test/face-tile-names.test.js new file mode 100644 index 00000000..9b225d76 --- /dev/null +++ b/plugins/social/test/face-tile-names.test.js @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +// #1102: lobby labels under the avatars were one nowrap line, so "SelfA1025 (you)" +// and "SelfB1025" drew as "SelfA1025 …" / "SelfB1025 …". They wrap, and the full +// label stays on the tile's title. +test('#1102: face-lobby names under avatars wrap instead of ellipsizing', () => { + const css = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + const rule = (selector) => { + const found = css.match(new RegExp(`${selector.replace('.', '\\.')}\\{[^}]*\\}`)) + assert.ok(found, selector) + return found[0] + } + const name = rule('.fs-tile-name') + const sub = rule('.fs-tile-sub') + assert.match(name, /white-space:normal/) + assert.match(name, /overflow-wrap:anywhere/) + assert.doesNotMatch(name, /white-space:nowrap/) + assert.doesNotMatch(name, /text-overflow:ellipsis/) + assert.match(sub, /white-space:normal/) + assert.doesNotMatch(sub, /text-overflow:ellipsis/) + assert.match(css, /\.fs-tiles\{[^}]*minmax\(96px,1fr\)/) + assert.match(css, /className: 'fs-tile-name', 'data-flybot': 'face-owner', title: label \}, label\)/) +}) From 7799db990423ab2784253e3ae643c5c3d92cba9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 04:39:31 +0000 Subject: [PATCH 2/2] Note that a face-room row wraps while its hover stays the full title (#1102) plugin-runtime owns flybot-client.test.js, which still pins the hover title. The row itself is no longer one line. Co-authored-by: Danyang Chen --- docs/subsystems/plugin-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 75754a92..5a42d0bf 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -23,7 +23,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots - `core/src/client/flybot-slots.js` — client slot registry. - `core/src/client/flybot-registry.js` — plugin discovery/registration. - `core/types/flybot.d.ts` — public types. -- Flybot unit tests (`flybot.test.js`, `flybot-client.test.js`, `flybot-feed.test.js`); `flybot-client.test.js` also pins the one name a mention wears on every surface — menu insert, bubble chip, sidebar preview, at-source row — and that two friends' same-named Bots stay apart while every alias still routes to its own handle (#967), the sidebar footer button (Connect apps opens Connectors), the account menu's Plugins row, the account menu's labels (Connect apps, Scheduled tasks opening Settings → Scheduled tasks, Settings, and the only classic Agent mode switch; #392-#394), and the one-line face-room row with its hover title (#304). +- Flybot unit tests (`flybot.test.js`, `flybot-client.test.js`, `flybot-feed.test.js`); `flybot-client.test.js` also pins the one name a mention wears on every surface — menu insert, bubble chip, sidebar preview, at-source row — and that two friends' same-named Bots stay apart while every alias still routes to its own handle (#967), the sidebar footer button (Connect apps opens Connectors), the account menu's Plugins row, the account menu's labels (Connect apps, Scheduled tasks opening Settings → Scheduled tasks, Settings, and the only classic Agent mode switch; #392-#394), and a face-room row that keeps its full title on hover (#304) while the number-first title wraps (#1102). ## Does not own