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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/419-run-stats-plurals.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The run-stats footer under the composer says `1 turn 1 step` instead of `1 turns 1 steps` ([#419](https://github.com/ThinkFlowLab/FlyBot/issues/419)).
41 changes: 34 additions & 7 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1768,8 +1768,8 @@ function bodyOf(def, paint, flat = false) {
return "";
}
}
var eyeMarkup = ([cx, cy, w, h38, tilt]) => `<g transform="translate(${cx} ${cy}) rotate(${tilt})"><rect class="bt-eye" x="${-w / 2}" y="${-h38 / 2}" width="${w}" height="${h38}" rx="${w / 2}"/></g>`;
var happyEyeMarkup = ([cx, cy, w, h38, tilt]) => `<g transform="translate(${cx} ${cy}) rotate(${tilt})"><path class="bt-eye-happy" opacity="0" d="M${-w * 0.62} ${h38 * 0.14}Q0 ${-h38 * 0.42} ${w * 0.62} ${h38 * 0.14}" fill="none" stroke="#000" stroke-width="${w * 0.5}" stroke-linecap="round"/></g>`;
var eyeMarkup = ([cx, cy, w, h39, tilt]) => `<g transform="translate(${cx} ${cy}) rotate(${tilt})"><rect class="bt-eye" x="${-w / 2}" y="${-h39 / 2}" width="${w}" height="${h39}" rx="${w / 2}"/></g>`;
var happyEyeMarkup = ([cx, cy, w, h39, tilt]) => `<g transform="translate(${cx} ${cy}) rotate(${tilt})"><path class="bt-eye-happy" opacity="0" d="M${-w * 0.62} ${h39 * 0.14}Q0 ${-h39 * 0.42} ${w * 0.62} ${h39 * 0.14}" fill="none" stroke="#000" stroke-width="${w * 0.5}" stroke-linecap="round"/></g>`;
var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`;
var holes = (def, eyes) => `<rect x="${-def.size}" y="${-def.size}" width="${def.size * 3}" height="${def.size * 3}" fill="#fff"/>${eyes}`;
var MASK_ID = "%MASK%";
Expand Down Expand Up @@ -2791,12 +2791,12 @@ function liveSprings(apply2) {
const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3));
last = now;
const steps = Math.ceil(dt * 240);
const h38 = dt / steps;
const h39 = dt / steps;
let moving = false;
for (const value of values.values()) {
for (let index = 0; index < steps; index += 1) {
value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h38;
value.x += value.v * h38;
value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h39;
value.x += value.v * h39;
}
if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) {
value.x = value.to;
Expand Down Expand Up @@ -15757,6 +15757,30 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since
return work.subscribe(notify);
}

// src/client/stats-dock.js
var import_react39 = require("react");
var STATS_DOCK = "conversation.composer.dock";
var STATS_ID = "stats";
var noun = (count, one, many) => `${count} ${Number(count) === 1 ? one : many}`;
function pluralStats(t2) {
return (key, params) => {
const text = t2(key, params);
if (key !== "stats.counts" || !params) return text;
const { turns, steps } = params;
return text === `${turns} turns ${steps} steps` ? `${noun(turns, "turn", "turns")} ${noun(steps, "step", "steps")}` : text;
};
}
function statsDock(entriesOf) {
const shell = () => (entriesOf(STATS_DOCK) ?? []).find((entry) => entry?.component !== StatsDock && entry?.options?.id === STATS_ID);
function StatsDock(props) {
const t2 = (0, import_react39.useMemo)(() => typeof props.t === "function" ? pluralStats(props.t) : props.t, [props.t]);
const entry = shell();
if (!entry || typeof props.usePerformanceUsage !== "function") return null;
return (0, import_react39.createElement)(entry.component, { ...props, t: t2 });
}
return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} };
}

// src/client/index.js
var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"];
function apply(ctx) {
Expand Down Expand Up @@ -16543,6 +16567,7 @@ function apply(ctx) {
} };
};
const shadow = (name, options, component) => ctx.slots.inject(name, () => ctx.slots.register({ name, ...options }, component));
const runStats = statsDock((name) => ctx.slots.entries?.(name) ?? []);
let atMenu = null;
const faceMenus = /* @__PURE__ */ new Map();
const watchFace = (sessionId) => {
Expand Down Expand Up @@ -16645,11 +16670,13 @@ function apply(ctx) {
...canDrawUserMessages() ? ["user", "steering"].map((key) => shadow("conversation.chat.node", { key, priority: -10, locale: "chat", inject: face }, UserMessageCell)) : [],
shadow("conversation.input.permission", { priority: -10 }, Nothing),
shadow("conversation.input.plan", { priority: -10 }, Nothing),
// The composer carries no model picker or run statistics; a Bot's model lives
// in its details panel.
// The composer carries no model picker; a Bot's model lives in its details panel.
shadow("conversation.input.model", { priority: -10 }, Nothing),
shadow("conversation.composer.dock", { id: "activity", order: 0, priority: -10 }, Nothing),
shadow("conversation.composer.dock", { id: "usage", order: 1, priority: -10 }, Nothing),
// The shell's run-stats footer stays (hidden in a friend's Bot chat by CSS), with
// "1 turn" / "1 step" instead of "1 turns 1 steps" (#419).
shadow(STATS_DOCK, { id: STATS_ID, order: 0, priority: -10, locale: "chat", inject: runStats.inject }, runStats.component),
shadow("conversation.input.left", { id: "bot-plus", order: -100, inject: face }, ComposerPlus),
shadow("conversation.input.left", { id: "bot-as", order: -90, inject: face }, ComposerIdentity),
shadow("conversation.input.right", { id: "bot-voice", order: 100, inject: face }, ComposerVoice),
Expand Down
9 changes: 7 additions & 2 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { ABOUT_CSS } from './about.js'
import { MEMBER_CSS, MemberDock } from './member-status.js'
import { USER_MESSAGE_CSS, UserMessageCell, canDrawUserMessages } from './user-message.js'
import { noticeApprovals } from './approval-notices.js'
import { STATS_DOCK, STATS_ID, statsDock } from './stats-dock.js'

export const inject = ['sessions', 'uiWorkspace', 'uiConversation', 'slots', 'theme', 'layout']

Expand Down Expand Up @@ -760,6 +761,8 @@ export function apply(ctx) {
}

const shadow = (name, options, component) => ctx.slots.inject(name, () => ctx.slots.register({ name, ...options }, component))
// The shell's run-stats footer, drawn by the shell's own entry with FlyBot's plurals (#419).
const runStats = statsDock(name => ctx.slots.entries?.(name) ?? [])

// The "@" menu's Bot group and the chats "+" references. The composer's controller
// for a Session is the shell's own, so the chats open at its caret.
Expand Down Expand Up @@ -871,11 +874,13 @@ export function apply(ctx) {
...(canDrawUserMessages() ? ['user', 'steering'].map(key => shadow('conversation.chat.node', { key, priority: -10, locale: 'chat', inject: face }, UserMessageCell)) : []),
shadow('conversation.input.permission', { priority: -10 }, Nothing),
shadow('conversation.input.plan', { priority: -10 }, Nothing),
// The composer carries no model picker or run statistics; a Bot's model lives
// in its details panel.
// The composer carries no model picker; a Bot's model lives in its details panel.
shadow('conversation.input.model', { priority: -10 }, Nothing),
shadow('conversation.composer.dock', { id: 'activity', order: 0, priority: -10 }, Nothing),
shadow('conversation.composer.dock', { id: 'usage', order: 1, priority: -10 }, Nothing),
// The shell's run-stats footer stays (hidden in a friend's Bot chat by CSS), with
// "1 turn" / "1 step" instead of "1 turns 1 steps" (#419).
shadow(STATS_DOCK, { id: STATS_ID, order: 0, priority: -10, locale: 'chat', inject: runStats.inject }, runStats.component),
shadow('conversation.input.left', { id: 'bot-plus', order: -100, inject: face }, ComposerPlus),
shadow('conversation.input.left', { id: 'bot-as', order: -90, inject: face }, ComposerIdentity),
shadow('conversation.input.right', { id: 'bot-voice', order: 100, inject: face }, ComposerVoice),
Expand Down
35 changes: 35 additions & 0 deletions core/src/client/stats-dock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { createElement as h, useMemo } from 'react'

// The shell's run-stats footer under the composer reads "1 turns 1 steps": its English
// string is a single plural template. FlyBot registers the shell's own footer one priority
// lower, with the same inject and locale, and hands it a `t` that says "1 turn" / "1 step"
// (#419). Nothing else about the footer changes; Chinese and other copy pass through.
export const STATS_DOCK = 'conversation.composer.dock'
export const STATS_ID = 'stats'

const noun = (count, one, many) => `${count} ${Number(count) === 1 ? one : many}`

// `t` for the footer: only the exact English plural template is rewritten, so a translated
// or reworded shell string is left as the shell wrote it.
export function pluralStats(t) {
return (key, params) => {
const text = t(key, params)
if (key !== 'stats.counts' || !params) return text
const { turns, steps } = params
return text === `${turns} turns ${steps} steps` ? `${noun(turns, 'turn', 'turns')} ${noun(steps, 'step', 'steps')}` : text
}
}

// `entriesOf(slot)` reads the slot ledger (`ctx.slots.entries`). The shell's entry is looked
// up when the footer renders, so plugin load order does not matter; without it, or without
// its hooks, no footer is drawn.
export function statsDock(entriesOf) {
const shell = () => (entriesOf(STATS_DOCK) ?? []).find(entry => entry?.component !== StatsDock && entry?.options?.id === STATS_ID)
function StatsDock(props) {
const t = useMemo(() => (typeof props.t === 'function' ? pluralStats(props.t) : props.t), [props.t])
const entry = shell()
if (!entry || typeof props.usePerformanceUsage !== 'function') return null
return h(entry.component, { ...props, t })
}
return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} }
}
84 changes: 84 additions & 0 deletions core/test/stats-dock.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { build } from 'esbuild'

// The shell's run-stats footer keeps its own component; FlyBot only hands it a `t` with
// "1 turn" / "1 step" (#419).
const { outputFiles } = await build({
stdin: {
contents: "export * from './src/client/stats-dock.js'",
resolveDir: fileURLToPath(new URL('..', import.meta.url)),
},
bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent',
external: ['react'],
})
const h = (type, props, ...children) => ({ type, props: { ...props, children } })
const react = { createElement: h, useMemo: make => make() }
const module = { exports: {} }
runInNewContext(outputFiles[0].text, { module, exports: module.exports, require: () => react })
const { STATS_DOCK, STATS_ID, pluralStats, statsDock } = module.exports

const ENGLISH = { 'stats.counts': '{turns} turns {steps} steps', 'stats.cacheHit': 'Cache hit {percent}%' }
const CHINESE = { 'stats.counts': '{turns} 轮 {steps} 步' }
const seat = dict => (key, params) => (dict[key] ?? key).replace(/\{(\w+)\}/g, (match, name) => (params && name in params ? String(params[name]) : match))

test('the English run counts say "1 turn" and "1 step"', () => {
const t = pluralStats(seat(ENGLISH))
assert.equal(t('stats.counts', { turns: 1, steps: 1 }), '1 turn 1 step')
assert.equal(t('stats.counts', { turns: 1, steps: 3 }), '1 turn 3 steps')
assert.equal(t('stats.counts', { turns: 9, steps: 9 }), '9 turns 9 steps')
assert.equal(t('stats.counts', { turns: 0, steps: 0 }), '0 turns 0 steps')
})

test('other keys and translated copy pass through unchanged', () => {
assert.equal(pluralStats(seat(ENGLISH))('stats.cacheHit', { percent: 1 }), 'Cache hit 1%')
assert.equal(pluralStats(seat(CHINESE))('stats.counts', { turns: 1, steps: 1 }), '1 轮 1 步')
assert.equal(pluralStats(seat({ 'stats.counts': 'Turns {turns} · Steps {steps}' }))('stats.counts', { turns: 1, steps: 1 }), 'Turns 1 · Steps 1')
})

test('the footer is the shell\'s own entry, drawn with the plural t and the shell\'s inject', () => {
const Shell = () => null
const performanceUsage = { subscribe: () => () => {}, getSnapshot: () => 'full' }
const shellEntry = { component: Shell, options: { id: STATS_ID, order: 0 }, inject: () => ({ hooks: { performanceUsage } }), locale: 'chat' }
const ledger = []
const dock = statsDock(name => (name === STATS_DOCK ? ledger : []))
ledger.push({ component: dock.component, options: { id: STATS_ID, order: 0, priority: -10 }, inject: dock.inject })
// Before the shell registers its footer, nothing is drawn.
assert.equal(dock.component({ t: seat(ENGLISH), usePerformanceUsage: () => 'full' }), null)
assert.equal(Object.keys(dock.inject()).length, 0)
ledger.push(shellEntry)
assert.equal(dock.inject().hooks.performanceUsage, performanceUsage)
const useChat = () => {}
const drawn = dock.component({ t: seat(ENGLISH), usePerformanceUsage: () => 'full', useChat })
assert.equal(drawn.type, Shell)
assert.equal(drawn.props.useChat, useChat)
assert.equal(drawn.props.t('stats.counts', { turns: 1, steps: 2 }), '1 turn 2 steps')
// Without the shell's hooks the footer is left out rather than crashing.
assert.equal(dock.component({ t: seat(ENGLISH) }), null)
})

test('the client registers the footer over the shell\'s "stats" dock entry', () => {
const index = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8')
assert.match(index, /shadow\(STATS_DOCK, \{ id: STATS_ID, order: 0, priority: -10, locale: 'chat', inject: runStats\.inject \}, runStats\.component\)/)
})

// The rewrite matches the shell's exact English template and dock id; flag it if DSH changes them.
test('the harness still writes "{turns} turns {steps} steps" in the "stats" dock entry', (t) => {
const harness = process.env.DSH_HARNESS
let file
try {
file = createRequire(join(harness ?? '.', 'package.json')).resolve('@deepseek-ai/dsh-client-ui-chat/package.json')
} catch {
return t.skip('no @deepseek-ai/dsh-client-ui-chat in the harness')
}
const client = join(file, '..', 'lib', 'client.js')
if (!existsSync(client)) return t.skip('no built dsh-client-ui-chat client')
const text = readFileSync(client, 'utf8')
assert.match(text, /"stats\.counts": "\{turns\} turns \{steps\} steps"/)
assert.match(text, /name: "conversation\.composer\.dock",\s*id: "stats"/)
})
1 change: 1 addition & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl
| --- | --- |
| `core/src/client/` | Client sources → build to `core/client.js`. |
| `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face room and Add friend (copy actions live inside Add friend, #181). The DSH Agent row reads **Add coding agent** (hint "A plain DSH session, not a Bot") so it is not mistaken for **Create new Bot** (#181). The short + menu does not list individual Bots. A group the host refuses because one of my Bots is not shared with the friend whose Bot is in it (`error.details.reason === 'ungranted-local'`, put on the error by `client/api.js`) is drawn as a card, not a red line: it names the Bot(s) and friend(s), and offers **Copy invite link for {Bot}** (`__flybot__.social.createInvite`; shown only when social is loaded and can invite that Bot, nothing is granted by the click; it says *copied* only when `copiedToClipboard` in `shared/remote-label.js` reports the write took, else it shows the link in a read-only, select-on-focus field to copy by hand) and **Leave {Bot} out** (`ungrantedLocalFix`, `shared/remote-label.js`). |
| `core/src/client/stats-dock.js` | The shell's run-stats footer under the composer (`conversation.composer.dock` id `stats`) re-registered one priority lower with the shell's own component, inject and `chat` locale, so the English counts read **1 turn** / **1 step** instead of `1 turns 1 steps` (`pluralStats` rewrites only the exact English template, #419). Still hidden in a friend's Bot chat by CSS (#419). Tests: `core/test/stats-dock.test.js`. |
| `core/src/client/face-at-filter.js` | A face room's `@` menu without DSH's Sessions rows (#305): `planFaceMenu` (rows to hide, where the highlight goes, when to dismiss), `faceMenuCss`, `watchFaceMenu`. Tests: `core/test/face-at-filter.test.js`. |
| `core/test/new-chat-menu.test.js` | **+** menu rows (#283): an action row's description is its own line under the title, an inline Bot hint is capped, so hovering never hides a title. |
| `core/test/new-chat.test.js` | Picker filters and the **+** short action menu (`data-menu`; New chat, New Bot, New group chat, plugin actions, then Add coding agent; #385 marks, verbs and hints). `ungrantedLocalFix`, `copiedToClipboard` (no clipboard or a refused write is a failure) and the refusal card (invite and leave-out actions, the copy-by-hand link field, no grant written by the client). |
Expand Down
Loading
Loading