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/1347-ui-clickthrough.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Bot settings drawer opens wide enough for its own controls: at 320px the "Name" field's label broke into "Nam" and "e", the "Test connection" button into "Test connecti" and "on", and Main Bot's "Change" into "Ch", "an", "ge". The Details panel now opens at 380px, a row's label stays on one line, a button never breaks its own words, and below 300px — the panel's floor in a narrow window — the row stacks so nothing is clipped to fit a width that is not there. A preview in the sidebar shows the words of a line and never its untrusted fence or the fragment an older write left behind (`from="User"> …`). A Bot chat with nothing said yet, which the shell draws with no header at all, now names itself above the composer and says it is empty instead of being a blank column with a composer. The details drawer's copy button is named for what it does ("Copy conversation", not "Share conversation") and says "Conversation copied as Markdown" when the copy lands, or reports why it did not. The mode word beside the chat title no longer draws as a filled chip that looks clickable and is not. A provider failure stored as a cut-off JSON body — a real Inbox showed `404: {"code":"UnsupportedModel","message":"The requested model does not support …` — now reads as a sentence naming the feature and where to change the model, in the chat's failure lines and in the Inbox ([#1347](https://github.com/ThinkFlowLab/FlyBot/issues/1347)). A chat message can no longer paint over a dialog, a sheet or a scrim. The chat column had no stacking context of its own, so anything the browser gave its own composited layer — a new row's entrance animation, a filter, a sticky box — was ordered against the page root instead of against the column, and could sort above a dialog's layer. With the column made one stacking context that sits below the panel and the dialogs, a right-aligned message stays behind **Tasks**, **Settings** and the **Inbox** scrim. Dialogs and popovers also moved to separate tiers (dialogs 120/121, popovers 130, palette 140), because the context menu and the reaction strip shared z-index 60 with the Settings dialog and a later-opened popover could paint over it ([#1347](https://github.com/ThinkFlowLab/FlyBot/issues/1347)). A modal also covers what is behind it now: the scrim was translucent enough that the thread stayed legible through it, and each overlay layer is its own stacking context so the scrim and its dialog move as one ([#1347](https://github.com/ThinkFlowLab/FlyBot/issues/1347)).
233 changes: 207 additions & 26 deletions core/client.js

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion core/src/client/bot-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,9 +276,16 @@ export const BOT_SETTINGS_CSS = `
.bt-bs .bt-avedit{left:0;transform:none}
.bt-bs-row{min-height:48px}
label.bt-bs-row{cursor:text}
.bt-bs-row>.bt-set-copy{flex:1 1 auto}
.bt-bs-label{font-size:13px;line-height:18px;font-weight:500}
/* #1347: a row keeps its label and its control side by side. The *label* is one line — "Name" used to
break into "Nam" + "e" inside a 36px column — and a control never breaks its own words: "Test
connection" came out as "Test connecti" / "on" and Main Bot's "Change" as "Ch/an/ge". The hint under
a label is prose, so it wraps and is never cut: the row grows instead. */
.bt-bs-row>.bt-set-copy{flex:1 1 8ch;min-width:8ch}
.bt-bs-row>.bt-set-copy>.bt-bs-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-bs-row .bt-soft,.bt-bs-row .bt-mini{flex:none;white-space:nowrap}
.bt-bs-row .bt-model-trigger{flex:none;max-width:58%}
.bt-bs-row .bt-model-trigger :is(.bt-model-name,.bt-model-provider){white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
${MODEL_TEST_CSS}
.bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease}
.bt-bs-input:hover{background:var(--bt-hover)}
Expand All @@ -300,4 +307,12 @@ ${MODEL_TEST_CSS}
.bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)}
.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out}
@media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}}
/* #1347: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below
300px the row stacks instead: the copy keeps its whole width on top, the control sits under it and
keeps its own words. Nothing is clipped to fit a width that is not there. */
@container bt-panel (max-width:300px){
.bt-bs-row{flex-wrap:wrap;row-gap:6px}
.bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0}
.bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left}
}
`
8 changes: 5 additions & 3 deletions core/src/client/browser-pane.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// body-level layer by bot-browsers.js; the panel draws the strip and the toolbar
// and reports the rectangle the shown webview covers.
import { createElement as h, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { DEFAULT_WIDTH, DETAILS_WIDTH, panelWidthRange } from './pane-width.js'
import { DEFAULT_WIDTH, DETAILS_WIDTH, MIN_DETAILS, panelWidthRange } from './pane-width.js'
import { createPortal } from 'react-dom'
import { BotMark } from './mark.js'
import { STORAGE } from './storage.js'
Expand Down Expand Up @@ -44,11 +44,13 @@ export function Favicon({ src, className }) {
}

// null until the user sets a width: the Bot page then opens at DETAILS_WIDTH and the
// browser at DEFAULT_WIDTH.
// browser at DEFAULT_WIDTH. A saved width narrower than DETAILS_WIDTH is a *stored* choice from a
// narrower policy (#1347 raised DETAILS_WIDTH from 320 to 380), so it is kept: only the floor of
// what the panel can be dragged to is enforced here.
const savedWidth = () => {
try {
const value = Number(localStorage.getItem(WIDTH_KEY))
return Number.isFinite(value) && value >= DETAILS_WIDTH ? value : null
return Number.isFinite(value) && value >= MIN_DETAILS ? value : null
} catch { return null }
}
const fitWidth = (width, [low, high]) => Math.round(Math.max(low, Math.min(width, high)))
Expand Down
22 changes: 19 additions & 3 deletions core/src/client/details-drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { ScheduleSummary } from './schedules.js'
import { Segmented, useGlide, useMorph } from './motion.js'
import { MemberWork, useMemberWork } from './member-status.js'
import { t } from './i18n.js'
import { remoteBotTitle, roomTitle } from '../shared/remote-label.js'
import { remoteBotTitle, roomTitle, copiedToClipboard } from '../shared/remote-label.js'
import { FlyBotRoomOverview, FlyBotDetails } from './flybot-slots.js'
import { call } from './api.js'

Expand Down Expand Up @@ -257,9 +257,17 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro
.then(() => { setError(''); return true })
.catch(failure => { setError(failure?.message ?? String(failure)); return false })
const share = () => {
void navigator.clipboard?.writeText(transcript).then(() => {
// #1347: a copy that says nothing is a click that did nothing to anyone watching. `copiedToClipboard`
// answers whether the text really landed — a page with no clipboard at all is a `false` there, not the
// quiet success `navigator.clipboard?.writeText(...)` gives when the API is missing — so the toast
// reports what happened instead of the tick alone claiming it.
const text = transcript
if (!text) { actions.notify?.(t('Nothing to copy yet')); return }
void copiedToClipboard(text).then(written => {
if (!written) { actions.reportError?.(new Error(t('Could not copy the conversation')), 'share'); return }
setCopied(true)
setTimeout(() => setCopied(false), 1600)
actions.notify?.(t('Conversation copied as Markdown'))
})
}
const errorLine = error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, error) : null
Expand All @@ -282,7 +290,15 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro
bot && !bot.remote && roster.browser === true && actions.openBrowser
? h('button', { type: 'button', className: 'bt-round', 'aria-label': t('Open browser'), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, h(GlobeIcon))
: null,
bot || room ? h('button', { type: 'button', className: 'bt-round', 'aria-label': copied ? t('Copied conversation') : t('Share conversation'), title: copied ? t('Copied') : t('Copy conversation as Markdown'), onClick: share }, copied ? h('span', { key: 'copied', className: 'bt-pop-in' }, '✓') : h(ShareIcon)) : null,
bot || room ? h('button', {
type: 'button', className: 'bt-round',
// #1347: the accessible name says what the click does, which is copy — "Share conversation"
// promised a sharing step that never came. It still says "Copied" after a copy, so a screen
// reader hears the same confirmation the toast gives.
'aria-label': copied ? t('Copied conversation') : t('Copy conversation'),
title: copied ? t('Copied') : t('Copy conversation as Markdown'),
onClick: share,
}, copied ? h('span', { key: 'copied', className: 'bt-pop-in' }, '✓') : h(ShareIcon)) : null,
hasStrip ? null : h('button', { type: 'button', className: 'bt-round', 'aria-label': t('Close details'), onClick: close }, h(CloseIcon)))
if (!bot && !room) {
return [h(Fragment, { key: 'top' }, top),
Expand Down
40 changes: 38 additions & 2 deletions core/src/client/flybot-slots.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { t } from './i18n.js'
import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-claims.js'
import { isApprovalWaiting, WAITING_STATUSES } from '../shared/approval-waiting.js'
import { canRetry } from '../shared/held-retry.js'
import { isMainOf } from './sources.js'

import { createClientApi as createClientApiWith } from './flybot-registry.js'

Expand Down Expand Up @@ -172,8 +173,34 @@ export function FlyBotSessionDock({ sessionId, useRoster, actions, session }) {
// that fact on the snapshot, and a failed first send never turns it back off, so standing in for a
// header the host is already drawing cannot happen.
const blank = session?.blank === true && session?.promptAttempted !== true
return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-session-dock', 'data-flybot': entry.id },
h(Safe, { entry, props: { sessionId, roster, room, bot, actions, call, t, blank: blank === true, claim: ids => claimApprovals(`${entry.id}:${sessionId}`, ids) } }))))
// #1347: a Bot's chat has the same first-paint problem as a group's, and the sidebar row is the only
// thing naming it. The shell draws `headerBlank` with no title for a Session that has said nothing,
// so the panel below the composer reads as a blank column: no name, no avatar, no way back to the
// Bot's details. The strip is the same one a group gets (#998) and reads the same `blank` fact.
// A friend's Bot is not this home's chat: it keeps its own empty hint (#173), so it is left out.
const botRoom = blank && bot !== undefined && !bot.remote ? bot : null
return h(Fragment, null,
botRoom ? h(BotChatStrip, { bot: botRoom, roster, actions, sessionId }) : null,
entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-session-dock', 'data-flybot': entry.id },
h(Safe, { entry, props: { sessionId, roster, room, bot, actions, call, t, blank: blank === true, claim: ids => claimApprovals(`${entry.id}:${sessionId}`, ids) } }))))
}

// A Bot chat's own header before its first turn (#1347): who this chat is with, that nothing has been
// said yet, and the one thing a blank column cannot offer — its details. It is the dock's own child, so
// it disappears the moment the shell draws the real header (the same `blank` condition #998 uses).
export function BotChatStrip({ bot, roster, actions, sessionId }) {
const main = isMainOf(roster, bot.id)
const subtitle = [bot.role, main ? t('Main Bot') : ''].filter(Boolean).join(' · ') || t('Bot')
return h('div', { className: 'bt-chat-strip', 'data-flybot': 'bot-chat-strip' },
h(BotAvatar, { bot, size: 44, main, live: false }),
h('div', { className: 'bt-chat-strip-who' },
h('span', { className: 'bt-chat-strip-name', title: bot.name }, bot.name),
h('span', { className: 'bt-chat-strip-sub' }, subtitle),
h('span', { className: 'bt-chat-strip-empty' }, t('No messages yet. Say something to {name}.', { name: bot.name }))),
h('button', {
type: 'button', className: 'bt-soft bt-soft-sm bt-chat-strip-go',
onClick: () => (actions?.toggleDetails ? actions.toggleDetails(sessionId) : undefined),
}, t('Details')))
}

export function FlyBotRoomOverview({ room, roster, actions }) {
Expand Down Expand Up @@ -323,6 +350,15 @@ export const fileSize = n => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).

export const FLYBOT_CSS = `
.bt-flybot-session-dock{width:100%;max-width:min(992px,100%);margin:0 auto;padding:0 0 6px;box-sizing:border-box}
/* #1347: a Bot chat's own strip before its first turn, the same shape a group's uses (#998): one row
above the composer naming the chat, so a blank column is never nameless. */
.bt-chat-strip{display:flex;align-items:center;gap:12px;width:100%;max-width:min(992px,100%);margin:0 auto 10px;padding:10px 12px;box-sizing:border-box;border:.5px solid var(--bt-line-2);border-radius:14px;background:var(--bt-card,var(--bt-main));animation:bt-rise .18s ease-out}
.bt-chat-strip-who{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px}
.bt-chat-strip-name{font-size:15px;line-height:20px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bt-chat-strip-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bt-chat-strip-empty{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere}
.bt-chat-strip-go{flex:none}
@media (prefers-reduced-motion:reduce){.bt-chat-strip{animation:none}}
.bt-flybot-session-dock [data-flybot=chat-approvals]{align-items:flex-end;padding-bottom:4px}
.bt-flybot-session-dock .fs-hold{margin-left:auto}
.bt-flybot-approval-files { margin: 0 0 8px; padding-left: 18px; font-size: 12px; }
Expand Down
10 changes: 10 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,16 @@ const ZH = {
'Close details': '关闭详情',
'Copied conversation': '已复制对话',
'Share conversation': '分享对话',
'Copy conversation': '复制对话',
'Nothing to copy yet': '还没有可复制的内容',
'Conversation copied as Markdown': '对话已复制为 Markdown',
'Could not copy the conversation': '无法复制对话',
'No messages yet. Say something to {name}.': '还没有消息。给 {name} 发点什么吧。',
'This model does not support that request. Pick another model in the Bot\'s settings.': '该模型不支持这项请求。请在 Bot 设置里换一个模型。',
'This model does not support {feature}. Pick another model in the Bot\'s settings.': '该模型不支持 {feature}。请在 Bot 设置里换一个模型。',
'The provider does not know this model. Check the model name in the Bot\'s settings.': '服务方不认识这个模型。请在 Bot 设置里检查模型名称。',
'The conversation is longer than this model can read. Condense it, or pick a model with a larger window.': '这段对话超出了该模型能读的长度。请压缩对话,或换一个上下文更大的模型。',
'The model service refused the request. Check the model and the endpoint in the Bot\'s settings.': '模型服务拒绝了这次请求。请在 Bot 设置里检查模型和接口地址。',
'Copy conversation as Markdown': '把对话复制为 Markdown',
'This conversation is not part of your Bot team.': '这个对话不属于你的 Bot 团队。',
'Admin {name}': '管理员 {name}',
Expand Down
3 changes: 2 additions & 1 deletion core/src/client/inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useEscape } from './overlay-hooks.js'
import { alwaysChoices } from './member-status.js'
import { currentPart, previewOf, relativeShort } from './text.js'
import { hostText, t } from './i18n.js'
import { failureSentence } from '../shared/failure-copy.js'

// Done (and a failed task) ages out after 7 days.
export const KEEP_MS = 7 * 86_400_000
Expand Down Expand Up @@ -461,7 +462,7 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen })
h('span', { className: 'bt-inbox-meta', title: meta }, meta)),
h('span', { className: 'bt-inbox-title', title: card.title }, card.title),
card.calls > 0 ? h('span', { className: 'bt-inbox-calls' }, t('{count} tool calls', { count: card.calls })) : null,
card.reason ? h('span', { className: 'bt-inbox-reason', title: card.reason }, card.reason) : null,
card.reason ? h('span', { className: 'bt-inbox-reason', title: card.reason }, failureSentence(card.reason, t)) : null,
card.activity ? h('span', { className: 'bt-inbox-activity', title: card.activity }, t('Latest: {text}', { text: card.activity })) : null,
card.hint ? h('span', { className: 'bt-inbox-hint' }, card.hint) : null,
evidence.length > 0 ? h('span', { className: 'bt-inbox-evidence' }, ...evidence.map((href, index) => h('a', {
Expand Down
8 changes: 8 additions & 0 deletions core/src/client/model-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,12 @@ export const MODEL_TEST_CSS = `
.bt-model-test-result[data-ok=true]{color:#1a7f37}
.bt-model-test-result[data-ok=false]{color:#c21d2e}
.bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)}
/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own
words ("Test connection" on one line) and takes the room it needs. */
.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch}
.bt-model-test>.bt-soft{flex:none;white-space:nowrap}
@container bt-panel (max-width:300px){
.bt-model-test{flex-wrap:wrap;row-gap:6px}
.bt-model-test>.bt-set-copy{flex:1 1 100%}
}
`
8 changes: 7 additions & 1 deletion core/src/client/pane-width.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
// DETAILS_WIDTH..DETAILS_MAX, so a wider browser comes back to DETAILS_MAX and a narrower one keeps
// its width; the browser takes any width from MIN_WIDTH.
export const DEFAULT_WIDTH = 520
export const DETAILS_WIDTH = 320
// #1347: 320 was too narrow for the settings form the details view hosts. A label and its control
// share one row ("Name" beside the name field, "Test connection" beside its hint), and at 320 the
// label took 36px — "Nam" on one line and "e" on the next — while the button broke into
// "Test connecti" / "on". 380 keeps every such row on one line at the default text size.
export const DETAILS_WIDTH = 380
export const DETAILS_MAX = 520
export const MIN_WIDTH = 360
// The conversation keeps this much room beside the panel whenever the window can hold it; below
Expand All @@ -13,6 +17,8 @@ export const MIN_CHAT = 420
// A narrow window shrinks the Details panel before it starves the conversation (#705): below
// MIN_CHAT + DETAILS_WIDTH the panel gives way down to MIN_DETAILS, so the row fits the window and
// no pane is pushed past its own edge (the conversation may then be narrower than MIN_CHAT).
// Below MIN_DETAILS the settings row itself stops wrapping: it stacks (see `.bt-bs-row` in
// styles.js), because a 240px column cannot hold a label and a 96px field on one line.
export const MIN_DETAILS = 240

export function panelWidthRange(view, viewport) {
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/remote-bot-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { FlyBotDetails } from './flybot-slots.js'
import { t } from './i18n.js'

export const REMOTE_BOT_SETTINGS_CSS = `
.bt-remote-sheet-layer{position:fixed;inset:0;z-index:60;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none}
.bt-remote-sheet-layer{position:fixed;inset:0;z-index:120;isolation:isolate;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none}
.bt-remote-sheet-layer[data-leaving]{pointer-events:none}
.bt-remote-sheet-layer .bt-scrim{position:absolute;inset:0;z-index:0;pointer-events:auto}
.bt-remote-sheet-layer[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in}
Expand Down
Loading
Loading